add edit step + do recipe in main view, added carousel for recipe selection

This commit is contained in:
2026-03-12 22:57:15 +01:00
parent 90257a62a0
commit d5dacb8fc4
21 changed files with 1052 additions and 279 deletions

View File

@@ -0,0 +1,4 @@
from .item import ListItem
from .base import SelectableList
from .list_view import ListView
from .carousel import CarouselView

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List
from PIL import ImageDraw
from .item import ListItem
class SelectableList(ABC):
"""Abstract base for list and carousel selection widgets.
Manages a collection of :class:`ListItem` objects with a single
*selected_index*, wrapping navigation, and a shared highlight-bar
drawing primitive. Concrete subclasses (:class:`ListView`,
:class:`CarouselView`) only differ in how they render the items.
The two subclasses expose an identical public interface so they can
be swapped interchangeably.
"""
def __init__(self,
items: List[ListItem] | None = None,
x_offset: int = 40,
max_visible: int = 5,
item_height: int = 20):
self._items: List[ListItem] = list(items or [])
self.selected_index: int = 0
self.x_offset = x_offset
self.max_visible = max_visible
self.item_height = item_height
# ---- item access ------------------------------------------------
@property
def items(self) -> List[ListItem]:
return self._items
@items.setter
def items(self, value: List[ListItem]):
self._items = list(value)
if self._items and self.selected_index >= len(self._items):
self.selected_index = len(self._items) - 1
elif not self._items:
self.selected_index = 0
@property
def selected_item(self) -> ListItem | None:
if self._items and 0 <= self.selected_index < len(self._items):
return self._items[self.selected_index]
return None
# ---- navigation -------------------------------------------------
def select_previous(self):
"""Move selection to the previous item (wraps around)."""
if self._items:
self.selected_index = (self.selected_index - 1) % len(self._items)
def select_next(self):
"""Move selection to the next item (wraps around)."""
if self._items:
self.selected_index = (self.selected_index + 1) % len(self._items)
# ---- drawing helpers --------------------------------------------
def _draw_highlight(self,
draw: ImageDraw.ImageDraw,
x: int,
y_center: int,
font_size: int = 10,
width: int = 90):
"""Draw a rounded highlight bar via overlapping circles.
The bar radius is derived from *font_size* so the bar always
fits snugly around the rendered text.
"""
r = font_size // 2 + 4
for i in range(0, width, max(1, r // 2)):
draw.circle((x + i, y_center), r, fill='black')
# ---- rendering (subclass) ---------------------------------------
@abstractmethod
def render(self, draw: ImageDraw.ImageDraw, y_start: int) -> None:
"""Render the widget onto *draw* starting at *y_start*."""
...

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from PIL import ImageDraw
from .base import SelectableList
class CarouselView(SelectableList):
"""Single-item display shows the selected item centred and larger.
Position indicator dots at the bottom communicate how many items
exist and which one is currently selected. Uses
:pymethod:`ListItem.render_large` so callers can optionally provide
a more detailed rendering for carousel mode.
``large_font_size`` controls the font size passed to
:pymethod:`ListItem.render_large` (default 30, roughly 3× PIL default).
``center_x`` sets the horizontal centre used when drawing the item text
(default 84, the midpoint of a 168 px wide display). PIL's ``anchor='mm'``
is forwarded so the text is centred on that x coordinate.
``render_height`` is the total vertical space (in pixels) allocated to the
carousel; dots are placed at the very bottom of this region. Defaults to
``max_visible * item_height``.
"""
def __init__(self, *args,
large_font_size: int = 30,
center_x: int = 84,
render_height: int | None = None,
**kwargs):
super().__init__(*args, **kwargs)
self.large_font_size = large_font_size
self.center_x = center_x
self.render_height = render_height
def render(self, draw: ImageDraw.ImageDraw, y_start: int) -> None:
if not self._items:
return
item = self._items[self.selected_index]
height = self.render_height or (self.max_visible * self.item_height)
y_center = y_start + height // 2
# Render selected item centred (large variant, no highlight)
item.render_large(
draw,
(self.center_x, y_center),
fill='black',
font_size=self.large_font_size,
anchor='mm',
)
total = len(self._items)
# Position indicator dots at the top of the allocated area
if total > 1:
indicator_y = y_start
dot_spacing = min(8, max(4, 80 // max(total - 1, 1)))
total_width = (total - 1) * dot_spacing
start_x = self.center_x - total_width // 2
for i in range(total):
x = start_x + i * dot_spacing
r = 2 if i == self.selected_index else 1
draw.circle((x, indicator_y), r, fill='black')

View File

@@ -0,0 +1,47 @@
from typing import Callable, Tuple
from PIL import ImageDraw
RenderFn = Callable[[ImageDraw.ImageDraw, Tuple[int, int], str], None]
class ListItem:
"""A renderable item for use in ListView or CarouselView.
Each item carries a render function called as render_fn(draw, (x, y), fill).
An optional render_large_fn is used by CarouselView for a more prominent
display; it falls back to render_fn when not provided.
"""
def __init__(self,
render_fn: RenderFn,
render_large_fn: RenderFn | None = None):
self._render_fn = render_fn
self._render_large_fn = render_large_fn
def render(self,
draw: ImageDraw.ImageDraw,
position: Tuple[int, int],
fill: str = 'black',
**kwargs):
"""Render the item at normal (list) size.
Extra keyword arguments (e.g. ``font_size``) are forwarded to the
underlying render function.
"""
self._render_fn(draw, position, fill, **kwargs)
def render_large(self,
draw: ImageDraw.ImageDraw,
position: Tuple[int, int],
fill: str = 'black',
**kwargs):
"""Render the item at large (carousel) size.
Uses render_large_fn if provided, otherwise falls back to render_fn.
Extra keyword arguments (e.g. ``font_size``) are forwarded to the
underlying render function.
"""
fn = self._render_large_fn or self._render_fn
fn(draw, position, fill, **kwargs)

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import List, Tuple
from PIL import ImageDraw
from .base import SelectableList
from .item import ListItem
class ListView(SelectableList):
"""Compact scrolling list shows up to *max_visible* items at once.
The visible window is kept centred on the selected item and scrolls
as the selection moves.
``font_size`` is forwarded to :pymeth:`ListItem.render` so the
underlying render function can use it (e.g. for PIL ``draw.text``).
Pass ``None`` to let render functions use their own default.
"""
def __init__(self, *args, font_size: int | None = None, **kwargs):
super().__init__(*args, **kwargs)
self.font_size = font_size
def _get_visible_window(self) -> Tuple[List[ListItem], int]:
"""Return (visible_items, start_offset)."""
total = len(self._items)
if total <= self.max_visible:
return self._items, 0
start = max(0, self.selected_index - self.max_visible // 2)
start = min(start, total - self.max_visible)
end = start + self.max_visible
return self._items[start:end], start
def render(self, draw: ImageDraw.ImageDraw, y_start: int) -> None:
if not self._items:
return
visible_items, start = self._get_visible_window()
fs = self.font_size or 10
kwargs = {} if self.font_size is None else {'font_size': self.font_size}
for idx, item in enumerate(visible_items):
actual_idx = start + idx
y_pos = y_start + idx * self.item_height
if actual_idx == self.selected_index:
self._draw_highlight(draw, self.x_offset, y_pos + fs // 2, font_size=fs)
item.render(draw, (self.x_offset, y_pos), fill='white', **kwargs)
else:
item.render(draw, (self.x_offset, y_pos), fill='black', **kwargs)