add edit step + do recipe in main view, added carousel for recipe selection
This commit is contained in:
4
frontend/views/list_select/__init__.py
Normal file
4
frontend/views/list_select/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .item import ListItem
|
||||
from .base import SelectableList
|
||||
from .list_view import ListView
|
||||
from .carousel import CarouselView
|
||||
88
frontend/views/list_select/base.py
Normal file
88
frontend/views/list_select/base.py
Normal 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*."""
|
||||
...
|
||||
67
frontend/views/list_select/carousel.py
Normal file
67
frontend/views/list_select/carousel.py
Normal 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')
|
||||
47
frontend/views/list_select/item.py
Normal file
47
frontend/views/list_select/item.py
Normal 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)
|
||||
54
frontend/views/list_select/list_view.py
Normal file
54
frontend/views/list_select/list_view.py
Normal 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)
|
||||
Reference in New Issue
Block a user