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

1"""Discover-tab rails: For You / Your Collection / Fresh. 

2 

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. 

8 

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

13 

14from __future__ import annotations 

15 

16from pathlib import Path 

17from typing import ClassVar, cast 

18 

19from textual.app import ComposeResult 

20from textual.binding import Binding, BindingType 

21from textual.containers import VerticalScroll 

22from textual.widgets import Static 

23 

24from lilbee.cli.tui.screens.catalog_utils import CatalogRow, LocalCatalogRow 

25from lilbee.cli.tui.widgets.model_grid import ModelGrid 

26 

27_CSS_FILE = Path(__file__).parent / "discover_rails.tcss" 

28 

29 

30class _RailHeading(Static, can_focus=True): 

31 """Focusable Static used as a rail heading. 

32 

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

38 

39 BINDINGS: ClassVar[list[BindingType]] = [ 

40 Binding("enter", "focus_grid", "Open", show=False), 

41 Binding("space", "focus_grid", "Open", show=False), 

42 ] 

43 

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 

47 

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() 

57 

58 

59class DiscoverRails(VerticalScroll): 

60 """Stack of three named rails. Each rail is a small ModelGrid. 

61 

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

66 

67 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") if _CSS_FILE.exists() else "" 

68 

69 _RAIL_FOR_YOU = "For You" 

70 _RAIL_COLLECTION = "Your Collection" 

71 _RAIL_FRESH = "Fresh on the Hub" 

72 

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") 

81 

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. 

90 

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) 

98 

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))