Coverage for src / lilbee / cli / tui / widgets / discover_rails.py: 100%
43 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""Discover-tab rails: For You / Your Collection / Fresh.
3Each rail is a small ModelGrid bound to a curated row slice the catalog
4screen passes in via ``set_rails``. The widget owns layout (heading +
5ModelGrid stack) and nothing else; row construction stays in the
6catalog screen so the rails inherit every cache, fit-stamp, and routing
7behavior the per-task tabs already have.
9Rail headings are focusable so Tab cycles through them as named
10landmarks; pressing Enter on a focused heading jumps focus down to
11that rail's grid.
12"""
14from __future__ import annotations
16from pathlib import Path
17from typing import ClassVar, cast
19from textual.app import ComposeResult
20from textual.binding import Binding, BindingType
21from textual.containers import Vertical
22from textual.widgets import Static
24from lilbee.cli.tui.screens.catalog_utils import CatalogRow, LocalCatalogRow
25from lilbee.cli.tui.widgets.model_grid import ModelGrid
27_CSS_FILE = Path(__file__).parent / "discover_rails.tcss"
30class _RailHeading(Static, can_focus=True):
31 """Focusable Static used as a rail heading.
33 Tab cycles through these so keyboard users have a quick way to land
34 on a category instead of arrow-walking through every card. Enter
35 drops focus into the rail's grid so the user can immediately move
36 the card cursor.
37 """
39 BINDINGS: ClassVar[list[BindingType]] = [
40 Binding("enter", "focus_grid", "Open", show=False),
41 Binding("space", "focus_grid", "Open", show=False),
42 ]
44 def __init__(self, label: str, *, rail_id: str, **kwargs: object) -> None:
45 super().__init__(label, classes="discover-rail-heading", id=f"heading-{rail_id}", **kwargs) # type: ignore[arg-type]
46 self._rail_id = rail_id
48 def action_focus_grid(self) -> None:
49 parent = self.parent
50 if parent is None:
51 return
52 try:
53 grid = parent.query_one(f"#discover-grid-{self._rail_id}", ModelGrid)
54 except Exception:
55 return
56 grid.focus()
59class DiscoverRails(Vertical):
60 """Stack of three named rails. Each rail is a small ModelGrid."""
62 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") if _CSS_FILE.exists() else ""
64 _RAIL_FOR_YOU = "For You"
65 _RAIL_COLLECTION = "Your Collection"
66 _RAIL_FRESH = "Fresh on the Hub"
68 def compose(self) -> ComposeResult:
69 for rail_id, label in (
70 ("for-you", self._RAIL_FOR_YOU),
71 ("collection", self._RAIL_COLLECTION),
72 ("fresh", self._RAIL_FRESH),
73 ):
74 yield _RailHeading(label, rail_id=rail_id)
75 yield ModelGrid(id=f"discover-grid-{rail_id}", classes="discover-rail-grid")
77 def set_rails(
78 self,
79 *,
80 for_you: list[LocalCatalogRow],
81 collection: list[LocalCatalogRow],
82 fresh: list[LocalCatalogRow],
83 ) -> None:
84 """Push three row slices into their respective rail grids.
86 Empty lists render an empty grid (zero height); we don't omit
87 the heading because a steady three-rail layout reads better than
88 a layout that shifts when one rail has data and another doesn't.
89 """
90 self._set_rail("for-you", for_you)
91 self._set_rail("collection", collection)
92 self._set_rail("fresh", fresh)
94 def _set_rail(self, rail_id: str, rows: list[LocalCatalogRow]) -> None:
95 try:
96 grid = self.query_one(f"#discover-grid-{rail_id}", ModelGrid)
97 except Exception:
98 return
99 # ModelGrid.set_rows takes the CatalogRow union; LocalCatalogRow is
100 # a member but list invariance needs an explicit cast (mypy hint
101 # references stable/common_issues#variance).
102 grid.set_rows(cast(list[CatalogRow], rows))