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

82 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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(_EMPTY_HINT, id="catalog-detail-name", classes="catalog-detail-name") 

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

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

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

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

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

53 

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

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

56 if row is None: 

57 self._clear() 

58 return 

59 if row.kind == CatalogRowKind.FRONTIER: 

60 self._render_frontier(row) 

61 return 

62 self._render_local(row) 

63 

64 def _clear(self) -> None: 

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

66 for selector in ( 

67 "#catalog-detail-fit", 

68 "#catalog-detail-sizes", 

69 "#catalog-detail-license", 

70 "#catalog-detail-compat", 

71 "#catalog-detail-description", 

72 ): 

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

74 

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

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

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

78 if row.fit is not None: 

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

80 else: 

81 fit_widget.update("") 

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

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

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

85 license_widget.update(_license_text(row)) 

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

87 compat_widget.update(_compat_sentence(row)) 

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

89 description.update(_description_text(row)) 

90 

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

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

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

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

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

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

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

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

99 ) 

100 

101 

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

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

104 if not variants: 

105 return "" 

106 lines = ["Sizes"] 

107 for v in variants: 

108 suffix = "" 

109 if v.fit is not None: 

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

111 suffix = " ✓" 

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

113 suffix = " ⚠" 

114 else: 

115 suffix = " ✗" 

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

117 return "\n".join(lines) 

118 

119 

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

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

122 

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

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

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

126 """ 

127 return "" 

128 

129 

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

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

132 from lilbee.cli.tui import messages as msg 

133 

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

135 arch_label = arch or "unknown" 

136 template = { 

137 ModelCompat.SUPPORTED: msg.COMPAT_DETAIL_SENTENCE_SUPPORTED, 

138 ModelCompat.UNSUPPORTED: msg.COMPAT_DETAIL_SENTENCE_UNSUPPORTED, 

139 ModelCompat.UNKNOWN: msg.COMPAT_DETAIL_SENTENCE_UNKNOWN, 

140 }[row.compat] 

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

142 

143 

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

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

146 return row.catalog_model.description 

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

148 return row.family.description 

149 return ""