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

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 Vertical 

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

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

61 

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

63 

64 _RAIL_FOR_YOU = "For You" 

65 _RAIL_COLLECTION = "Your Collection" 

66 _RAIL_FRESH = "Fresh on the Hub" 

67 

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

76 

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. 

85 

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) 

93 

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