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

91 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-28 01:01 +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.content import Content 

18from textual.widgets import Static 

19 

20from lilbee.catalog.types import ModelCompat 

21from lilbee.cli.tui.pill import pill 

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

23 CatalogRow, 

24 CatalogRowKind, 

25 FrontierCatalogRow, 

26 LocalCatalogRow, 

27 SizeVariant, 

28) 

29from lilbee.runtime.hardware import FitChip, FitLevel 

30 

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

32 

33_FIT_LEVEL_BACKGROUND: dict[FitLevel, str] = { 

34 FitLevel.FITS: "$success", 

35 FitLevel.TIGHT: "$warning", 

36 FitLevel.WONT_RUN: "$error", 

37} 

38 

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

40 

41 

42class CatalogDetailDrawer(Vertical): 

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

44 

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

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

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

48 grid widget's message routing. 

49 """ 

50 

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

52 

53 def compose(self) -> ComposeResult: 

54 yield Static(_EMPTY_HINT, id="catalog-detail-name", classes="catalog-detail-name") 

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

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

57 yield Static("", id="catalog-detail-license", classes="catalog-detail-license") 

58 yield Static("", id="catalog-detail-compat", classes="catalog-detail-compat") 

59 yield Static("", id="catalog-detail-description", classes="catalog-detail-description") 

60 

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

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

63 if row is None: 

64 self._clear() 

65 return 

66 if row.kind == CatalogRowKind.FRONTIER: 

67 self._render_frontier(row) 

68 return 

69 self._render_local(row) 

70 

71 def _clear(self) -> None: 

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

73 for selector in ( 

74 "#catalog-detail-fit", 

75 "#catalog-detail-sizes", 

76 "#catalog-detail-license", 

77 "#catalog-detail-compat", 

78 "#catalog-detail-description", 

79 ): 

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

81 

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

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

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

85 if row.fit is not None: 

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

87 else: 

88 fit_widget.update("") 

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

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

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

92 license_widget.update(_license_text(row)) 

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

94 compat_widget.update(_compat_sentence(row)) 

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

96 description.update(_description_text(row)) 

97 

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

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

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

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

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

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

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

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

106 ) 

107 

108 

109def _render_fit_pill(fit: FitChip) -> Content: 

110 if fit.level is FitLevel.FITS: 

111 text = f"fits +{fit.headroom_gb:.1f} GB" 

112 elif fit.level is FitLevel.TIGHT: 

113 text = f"tight +{max(0.0, fit.headroom_gb):.1f} GB" 

114 else: 

115 text = f"won't {fit.headroom_gb:.1f} GB" 

116 return pill(text, _FIT_LEVEL_BACKGROUND[fit.level], "$text") 

117 

118 

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

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

121 if not variants: 

122 return "" 

123 lines = ["Sizes"] 

124 for v in variants: 

125 suffix = "" 

126 if v.fit is not None: 

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

128 suffix = " ✓" 

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

130 suffix = " ⚠" 

131 else: 

132 suffix = " ✗" 

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

134 return "\n".join(lines) 

135 

136 

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

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

139 

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

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

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

143 """ 

144 return "" 

145 

146 

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

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

149 from lilbee.cli.tui import messages as msg 

150 

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

152 arch_label = arch or "unknown" 

153 template = { 

154 ModelCompat.SUPPORTED: msg.COMPAT_DETAIL_SENTENCE_SUPPORTED, 

155 ModelCompat.UNSUPPORTED: msg.COMPAT_DETAIL_SENTENCE_UNSUPPORTED, 

156 ModelCompat.UNKNOWN: msg.COMPAT_DETAIL_SENTENCE_UNKNOWN, 

157 }[row.compat] 

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

159 

160 

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

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

163 return row.catalog_model.description 

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

165 return row.family.description 

166 return ""