Coverage for src/lilbee/cli/tui/widgets/catalog_detail.py: 100%

82 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +0000

1"""Right-pane detail drawer for the catalog screen. 

2 

3Focus-following: the catalog screen wires ``ModelGrid.Highlighted`` to 

4``CatalogDetailDrawer.update_for_row``. The drawer renders the focused 

5row's name, fit chip, every size variant with its per-variant fit, the 

6license, and a description preview. Visibility toggles via the 

7``-collapsed`` CSS class so width changes are a single layout pass. 

8""" 

9 

10from __future__ import annotations 

11 

12from pathlib import Path 

13from typing import ClassVar 

14 

15from textual.app import ComposeResult 

16from textual.containers import Vertical 

17from textual.widgets import Static 

18 

19from lilbee.catalog.types import ModelCompat 

20from lilbee.cli.tui.screens.catalog_utils import ( 

21 CatalogRow, 

22 CatalogRowKind, 

23 FrontierCatalogRow, 

24 LocalCatalogRow, 

25 SizeVariant, 

26) 

27from lilbee.cli.tui.widgets.catalog_card_shared import _render_fit_pill 

28from lilbee.runtime.hardware import FitLevel 

29 

30_CSS_FILE = Path(__file__).parent / "catalog_detail.tcss" 

31 

32_EMPTY_HINT = "Highlight a model to see details." 

33 

34 

35class CatalogDetailDrawer(Vertical): 

36 """Right-side panel that mirrors the highlighted catalog row. 

37 

38 Designed as a passive renderer: the screen calls update_for_row on 

39 every ``ModelGrid.Highlighted`` event. There is no event subscription 

40 inside the drawer so it stays test-friendly and decoupled from the 

41 grid widget's message routing. 

42 """ 

43 

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

45 

46 def compose(self) -> ComposeResult: 

47 yield Static( 

48 _EMPTY_HINT, id="catalog-detail-name", classes="catalog-detail-name", markup=False 

49 ) 

50 yield Static("", id="catalog-detail-fit", classes="catalog-detail-fit") 

51 yield Static("", id="catalog-detail-sizes", classes="catalog-detail-sizes") 

52 yield Static( 

53 "", id="catalog-detail-license", classes="catalog-detail-license", markup=False 

54 ) 

55 yield Static("", id="catalog-detail-compat", classes="catalog-detail-compat", markup=False) 

56 yield Static( 

57 "", 

58 id="catalog-detail-description", 

59 classes="catalog-detail-description", 

60 markup=False, 

61 ) 

62 

63 def update_for_row(self, row: CatalogRow | None) -> None: 

64 """Render the drawer for *row*; clearing back to the empty hint when None.""" 

65 if row is None: 

66 self._clear() 

67 return 

68 if row.kind == CatalogRowKind.FRONTIER: 

69 self._render_frontier(row) 

70 return 

71 self._render_local(row) 

72 

73 def _clear(self) -> None: 

74 self.query_one("#catalog-detail-name", Static).update(_EMPTY_HINT) 

75 for selector in ( 

76 "#catalog-detail-fit", 

77 "#catalog-detail-sizes", 

78 "#catalog-detail-license", 

79 "#catalog-detail-compat", 

80 "#catalog-detail-description", 

81 ): 

82 self.query_one(selector, Static).update("") 

83 

84 def _render_local(self, row: LocalCatalogRow) -> None: 

85 self.query_one("#catalog-detail-name", Static).update(row.name) 

86 fit_widget = self.query_one("#catalog-detail-fit", Static) 

87 if row.fit is not None: 

88 fit_widget.update(_render_fit_pill(row.fit)) 

89 else: 

90 fit_widget.update("") 

91 sizes = self.query_one("#catalog-detail-sizes", Static) 

92 sizes.update(_render_sizes_block(row.size_variants)) 

93 license_widget = self.query_one("#catalog-detail-license", Static) 

94 license_widget.update(_license_text(row)) 

95 compat_widget = self.query_one("#catalog-detail-compat", Static) 

96 compat_widget.update(_compat_sentence(row)) 

97 description = self.query_one("#catalog-detail-description", Static) 

98 description.update(_description_text(row)) 

99 

100 def _render_frontier(self, row: FrontierCatalogRow) -> None: 

101 self.query_one("#catalog-detail-name", Static).update(row.name) 

102 self.query_one("#catalog-detail-fit", Static).update("") 

103 self.query_one("#catalog-detail-sizes", Static).update("") 

104 self.query_one("#catalog-detail-license", Static).update(f"Provider {row.provider}") 

105 self.query_one("#catalog-detail-compat", Static).update("") 

106 self.query_one("#catalog-detail-description", Static).update( 

107 f"Cloud model accessed via the {row.provider} API." 

108 ) 

109 

110 

111def _render_sizes_block(variants: list[SizeVariant]) -> str: 

112 """Multi-line plain-text listing of every variant the row carries.""" 

113 if not variants: 

114 return "" 

115 lines = ["Sizes"] 

116 for v in variants: 

117 suffix = "" 

118 if v.fit is not None: 

119 if v.fit.level is FitLevel.FITS: 

120 suffix = " ✓" 

121 elif v.fit.level is FitLevel.TIGHT: 

122 suffix = " ⚠" 

123 else: 

124 suffix = " ✗" 

125 lines.append(f" {v.label} {v.size_gb:.1f} GB{suffix}") 

126 return "\n".join(lines) 

127 

128 

129def _license_text(_row: LocalCatalogRow) -> str: 

130 """License placeholder; CatalogModel/ModelFamily don't carry one yet. 

131 

132 Kept as a stub so callers have a stable seam: future plumbing for 

133 per-row license strings (HF metadata fetch, family-level config) can 

134 fill this in without touching the drawer's render path. 

135 """ 

136 return "" 

137 

138 

139def _compat_sentence(row: LocalCatalogRow) -> str: 

140 """Build the architecture-compatibility sentence for the detail drawer.""" 

141 from lilbee.cli.tui import messages as msg 

142 

143 arch = row.catalog_model.architecture if row.catalog_model is not None else "" 

144 arch_label = arch or "unknown" 

145 template = { 

146 ModelCompat.SUPPORTED: msg.COMPAT_DETAIL_SENTENCE_SUPPORTED, 

147 ModelCompat.UNSUPPORTED: msg.COMPAT_DETAIL_SENTENCE_UNSUPPORTED, 

148 ModelCompat.UNKNOWN: msg.COMPAT_DETAIL_SENTENCE_UNKNOWN, 

149 }[row.compat] 

150 return template.format(arch=arch_label) if "{arch}" in template else template 

151 

152 

153def _description_text(row: LocalCatalogRow) -> str: 

154 if row.catalog_model is not None and row.catalog_model.description: 

155 return row.catalog_model.description 

156 if row.family is not None and row.family.description: 

157 return row.family.description 

158 return ""