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*.""" ...