Coverage for src/lilbee/cli/tui/widgets/discover_rails.py: 100%
43 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +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 VerticalScroll
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(VerticalScroll):
60 """Stack of three named rails. Each rail is a small ModelGrid.
62 A scroll container, not a plain Vertical: the rails can exceed the pane
63 height, and the card cursor's reveal path needs a scrollable ancestor to
64 follow the highlight.
65 """
67 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") if _CSS_FILE.exists() else ""
69 _RAIL_FOR_YOU = "For You"
70 _RAIL_COLLECTION = "Your Collection"
71 _RAIL_FRESH = "Fresh on the Hub"
73 def compose(self) -> ComposeResult:
74 for rail_id, label in (
75 ("for-you", self._RAIL_FOR_YOU),
76 ("collection", self._RAIL_COLLECTION),
77 ("fresh", self._RAIL_FRESH),
78 ):
79 yield _RailHeading(label, rail_id=rail_id)
80 yield ModelGrid(id=f"discover-grid-{rail_id}", classes="discover-rail-grid")
82 def set_rails(
83 self,
84 *,
85 for_you: list[LocalCatalogRow],
86 collection: list[LocalCatalogRow],
87 fresh: list[LocalCatalogRow],
88 ) -> None:
89 """Push three row slices into their respective rail grids.
91 Empty lists render an empty grid (zero height); we don't omit
92 the heading because a steady three-rail layout reads better than
93 a layout that shifts when one rail has data and another doesn't.
94 """
95 self._set_rail("for-you", for_you)
96 self._set_rail("collection", collection)
97 self._set_rail("fresh", fresh)
99 def _set_rail(self, rail_id: str, rows: list[LocalCatalogRow]) -> None:
100 try:
101 grid = self.query_one(f"#discover-grid-{rail_id}", ModelGrid)
102 except Exception:
103 return
104 # ModelGrid.set_rows takes the CatalogRow union; LocalCatalogRow is
105 # a member but list invariance needs an explicit cast (mypy hint
106 # references stable/common_issues#variance).
107 grid.set_rows(cast(list[CatalogRow], rows))