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

302 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-06-28 01:01 +0000

1"""ModelGrid: single-render-surface grid of catalog cards. 

2 

3Single render surface via ``render_line(y)``; one strip painted per 

4visible row keeps fast scrolls cheap. Decoration uses theme-token 

5strings (``"on $panel"`` / ``"$primary"``) so themes own their contrast. 

6""" 

7 

8from __future__ import annotations 

9 

10import time 

11from dataclasses import dataclass 

12from pathlib import Path 

13from typing import ClassVar 

14 

15from textual import events 

16from textual.binding import Binding, BindingType 

17from textual.content import Content 

18from textual.geometry import Region, Size 

19from textual.message import Message 

20from textual.reactive import reactive 

21from textual.strip import Strip 

22from textual.style import Style 

23from textual.widget import Widget 

24 

25from lilbee.cli.tui.pill import pill 

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

27 CatalogRow, 

28 CatalogRowKind, 

29 FrontierCatalogRow, 

30 KeyStatus, 

31 LocalCatalogRow, 

32) 

33from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS 

34from lilbee.runtime.hardware import FitChip, FitLevel 

35 

36_CSS_FILE = Path(__file__).parent / "model_grid.tcss" 

37 

38_CARD_BODY_HEIGHT = 6 

39"""Body lines per card: name / primary pills / secondary pills / specs / status / hint.""" 

40 

41_BORDER_RESERVED_LINES = 2 

42"""Top + bottom border slots; reserved on every card so layout stays stable.""" 

43 

44_CARD_HEIGHT = _CARD_BODY_HEIGHT + _BORDER_RESERVED_LINES 

45 

46_ROW_GUTTER = 0 

47_ROW_HEIGHT = _CARD_HEIGHT + _ROW_GUTTER 

48_DEFAULT_COLUMNS = 4 

49_CARD_MIN_WIDTH = 32 

50_CARD_GUTTER = 1 

51 

52_BORDER_TOP_LEFT = "╭" 

53_BORDER_TOP_RIGHT = "╮" 

54_BORDER_BOTTOM_LEFT = "╰" 

55_BORDER_BOTTOM_RIGHT = "╯" 

56_BORDER_HORIZONTAL = "─" 

57_BORDER_VERTICAL = "│" 

58 

59# Theme-token style strings; resolved at render time on the active theme. 

60_CARD_BODY_STYLE = "on $panel" 

61# Every card draws a border at all times so the grid reads as discrete tiles. 

62# The default tone is dim; the selected card gets a brighter color depending 

63# on whether the grid has focus. 

64_DEFAULT_BORDER_STYLE = "$border-blurred on $panel" 

65_FOCUSED_BORDER_STYLE = "$primary on $panel" 

66_BLURRED_BORDER_STYLE = "$border-blurred on $panel" 

67# Inter-card gutter and empty slot fill: match the screen's surface so gaps 

68# read as theme background, not raw terminal black. 

69_GAP_STYLE = "on $background" 

70 

71 

72@dataclass 

73class _CardLines: 

74 """Pre-rendered content lines for one card. Each entry is one terminal row.""" 

75 

76 lines: list[Content] 

77 

78 

79class ModelGrid(Widget, can_focus=True): 

80 """Single-render-surface grid of ``CatalogRow`` cards.""" 

81 

82 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") 

83 

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

85 Binding("up", "cursor_up", "Up", show=False), 

86 Binding("down", "cursor_down", "Down", show=False), 

87 Binding("left", "cursor_left", "Left", show=False), 

88 Binding("right", "cursor_right", "Right", show=False), 

89 Binding("enter", "select", "Select", show=False), 

90 ] 

91 

92 highlighted: reactive[int | None] = reactive(None) 

93 

94 @dataclass 

95 class Selected(Message): 

96 """Posted when a card is activated. ``row`` is the underlying CatalogRow.""" 

97 

98 grid: ModelGrid 

99 row: CatalogRow 

100 

101 @property 

102 def control(self) -> ModelGrid: 

103 return self.grid 

104 

105 @dataclass 

106 class LeaveUp(Message): 

107 grid: ModelGrid 

108 

109 @dataclass 

110 class LeaveDown(Message): 

111 grid: ModelGrid 

112 

113 @dataclass 

114 class Highlighted(Message): 

115 """Posted on every cursor move so the catalog can run keyboard-driven 

116 prefetch (mouse wheel triggers via the scroll watcher; cell-by-cell 

117 keyboard scrolling never crosses the 85 % threshold by itself). 

118 """ 

119 

120 grid: ModelGrid 

121 index: int 

122 

123 # Window inside which a second click on the same card counts as a 

124 # double-click and posts Selected. Single click outside this window only 

125 # highlights so users can't accidentally trigger an install with one mis-tap. 

126 _DOUBLE_CLICK_WINDOW_S: ClassVar[float] = 0.5 

127 

128 def __init__( 

129 self, 

130 rows: list[CatalogRow] | None = None, 

131 *, 

132 name: str | None = None, 

133 id: str | None = None, 

134 classes: str | None = None, 

135 ) -> None: 

136 super().__init__(name=name, id=id, classes=classes) 

137 self._rows: list[CatalogRow] = list(rows or []) 

138 self._cards_per_row: int = _DEFAULT_COLUMNS 

139 self._last_click_index: int | None = None 

140 self._last_click_at: float = 0.0 

141 

142 @property 

143 def rows(self) -> list[CatalogRow]: 

144 """The dataset backing this grid (defensive copy).""" 

145 return list(self._rows) 

146 

147 @property 

148 def columns_per_row(self) -> int: 

149 """Current column count, derived from the container width on resize.""" 

150 return self._cards_per_row 

151 

152 def set_rows(self, rows: list[CatalogRow]) -> None: 

153 """Replace the dataset and reset the highlight.""" 

154 self._rows = list(rows) 

155 self.highlighted = None 

156 self.refresh(layout=True) 

157 

158 def on_resize(self) -> None: 

159 new_cols = self._columns_for_width(self.size.width) 

160 if new_cols != self._cards_per_row: 

161 self._cards_per_row = new_cols 

162 self.refresh(layout=True) 

163 

164 @staticmethod 

165 def _columns_for_width(width: int) -> int: 

166 if width <= 0: 

167 return _DEFAULT_COLUMNS 

168 return max(1, width // (_CARD_MIN_WIDTH + _CARD_GUTTER)) 

169 

170 def _total_rows(self) -> int: 

171 if not self._rows or self._cards_per_row <= 0: 

172 return 0 

173 return (len(self._rows) + self._cards_per_row - 1) // self._cards_per_row 

174 

175 def get_content_width(self, container: Size, viewport: Size) -> int: 

176 return container.width 

177 

178 def get_content_height(self, container: Size, viewport: Size, width: int) -> int: 

179 if not self._rows: 

180 return 0 

181 cols = self._columns_for_width(width) 

182 rows = (len(self._rows) + cols - 1) // cols 

183 return rows * _ROW_HEIGHT 

184 

185 def watch_highlighted(self, _old: int | None, new: int | None) -> None: 

186 """Repaint, scroll the cell into view, post Highlighted. 

187 

188 ModelGrid itself has ``height: auto`` so it isn't scrollable; the 

189 outer ``#catalog-grid`` VerticalScroll is. We translate the cell's 

190 local offset to the parent's doc coords (using ``virtual_region``) 

191 and ask the parent to scroll. The Highlighted message lets the 

192 catalog screen run keyboard-driven prefetch on every cursor move. 

193 """ 

194 self.refresh() 

195 if new is None or self._cards_per_row <= 0 or self.size.width <= 0: 

196 return 

197 self.post_message(self.Highlighted(self, new)) 

198 parent = self.parent 

199 if not isinstance(parent, Widget): 

200 return 

201 col_width = max(1, self.size.width // self._cards_per_row) 

202 row = new // self._cards_per_row 

203 col = new % self._cards_per_row 

204 grid_doc = self.virtual_region 

205 parent.scroll_to_region( 

206 Region( 

207 grid_doc.x + col * col_width, 

208 grid_doc.y + row * _ROW_HEIGHT, 

209 col_width, 

210 _CARD_HEIGHT, 

211 ), 

212 animate=False, 

213 ) 

214 

215 def on_focus(self) -> None: 

216 """Auto-highlight first card on focus so Tab navigation has visible feedback.""" 

217 if self._rows and self.highlighted is None: 

218 self.highlighted = 0 

219 

220 def on_blur(self) -> None: 

221 # Mirrors toad's GridSelect: when the user crosses into a sibling grid, 

222 # this grid's cursor goes away entirely instead of lingering as a 

223 # blurred ghost. Otherwise stacked catalog sections show two cursors 

224 # simultaneously and the user can't tell which grid owns focus. 

225 self.highlighted = None 

226 

227 def action_cursor_up(self) -> None: 

228 if self.highlighted is None: 

229 self.highlighted = 0 

230 return 

231 if self.highlighted < self._cards_per_row: 

232 self.post_message(self.LeaveUp(self)) 

233 return 

234 self.highlighted = max(0, self.highlighted - self._cards_per_row) 

235 

236 def action_cursor_down(self) -> None: 

237 if self.highlighted is None: 

238 self.highlighted = 0 

239 return 

240 next_index = self.highlighted + self._cards_per_row 

241 if next_index >= len(self._rows): 

242 self.post_message(self.LeaveDown(self)) 

243 return 

244 self.highlighted = next_index 

245 

246 def action_cursor_left(self) -> None: 

247 if self.highlighted is None: 

248 self.highlighted = 0 

249 return 

250 self.highlighted = max(0, self.highlighted - 1) 

251 

252 def action_cursor_right(self) -> None: 

253 if self.highlighted is None: 

254 self.highlighted = 0 

255 return 

256 self.highlighted = min(len(self._rows) - 1, self.highlighted + 1) 

257 

258 def action_select(self) -> None: 

259 """Activate the highlighted card (post Selected with its row).""" 

260 if self.highlighted is None or not self._rows: 

261 return 

262 if 0 <= self.highlighted < len(self._rows): 

263 self.post_message(self.Selected(self, self._rows[self.highlighted])) 

264 

265 def highlight_first(self) -> None: 

266 """Move highlight to the first card; mirrors the GridSelect surface.""" 

267 if self._rows: 

268 self.highlighted = 0 

269 

270 def highlight_last(self) -> None: 

271 """Move highlight to the last card; mirrors the GridSelect surface.""" 

272 if self._rows: 

273 self.highlighted = len(self._rows) - 1 

274 

275 def _cell_at(self, x: int, y: int) -> int | None: 

276 """Return the dataset index at terminal-local ``(x, y)`` or None.""" 

277 if not self._rows or self._cards_per_row <= 0: 

278 return None 

279 if y < 0: 

280 return None 

281 row = y // _ROW_HEIGHT 

282 within_row = y - row * _ROW_HEIGHT 

283 if within_row >= _CARD_HEIGHT: 

284 return None 

285 col_width = max(1, self.size.width // self._cards_per_row) 

286 col = min(self._cards_per_row - 1, x // col_width) 

287 index = row * self._cards_per_row + col 

288 if index >= len(self._rows): 

289 return None 

290 return index 

291 

292 def on_click(self, event: events.Click) -> None: 

293 """Single click only highlights; double-click on the same card posts Selected. 

294 

295 The 'second click on a highlighted card installs' rule the previous 

296 catalog used was unsafe under the new auto-highlight-on-focus path: 

297 a fresh focus pre-highlights index 0, so a single mouse click on 

298 card 0 immediately fired Selected and started an install. Now we 

299 require two clicks on the same card within ``_DOUBLE_CLICK_WINDOW_S`` 

300 to install; a stray click only highlights, matching the toad gesture 

301 users expect. 

302 """ 

303 index = self._cell_at(event.x, event.y) 

304 if index is None: 

305 return 

306 now = time.monotonic() 

307 is_double_click = ( 

308 self._last_click_index == index 

309 and now - self._last_click_at <= self._DOUBLE_CLICK_WINDOW_S 

310 ) 

311 self._last_click_index = index 

312 self._last_click_at = now 

313 if is_double_click: 

314 self.post_message(self.Selected(self, self._rows[index])) 

315 return 

316 self.highlighted = index 

317 self.focus() 

318 

319 def render_line(self, y: int) -> Strip: 

320 """Compose one terminal line by stitching the per-column card slices.""" 

321 if y < 0: 

322 return Strip.blank(self.size.width) 

323 grid_row, line_within = divmod(y, _ROW_HEIGHT) 

324 if grid_row >= self._total_rows() or line_within >= _CARD_HEIGHT: 

325 return Strip.blank(self.size.width) 

326 col_width = max(1, self.size.width // max(1, self._cards_per_row)) 

327 border_style = _FOCUSED_BORDER_STYLE if self.has_focus else _BLURRED_BORDER_STYLE 

328 segments: list[Content] = [] 

329 for col in range(self._cards_per_row): 

330 index = grid_row * self._cards_per_row + col 

331 if index >= len(self._rows): 

332 # Empty slot in a partial last row -> match screen surface. 

333 segments.append(Content.styled(" " * col_width, _GAP_STYLE)) 

334 continue 

335 row = self._rows[index] 

336 selected = index == self.highlighted 

337 card = _render_card_strip( 

338 row, selected=selected, width=col_width, border_style=border_style 

339 ) 

340 segments.append(card.lines[line_within]) 

341 joined = Content("").join(segments) 

342 return Strip(joined.render_segments(Style.null())).simplify() 

343 

344 

345_NAME_MAX_CHARS = 28 

346"""Cap displayed model names so long refs don't blow up the grid layout.""" 

347 

348_ELLIPSIS = "…" 

349 

350 

351def _truncate_name(name: str) -> str: 

352 if len(name) <= _NAME_MAX_CHARS: 

353 return name 

354 return name[: _NAME_MAX_CHARS - 1].rstrip() + _ELLIPSIS 

355 

356 

357def _render_card_strip( 

358 row: CatalogRow, *, selected: bool, width: int, border_style: str 

359) -> _CardLines: 

360 """Return the ``_CARD_HEIGHT`` content lines that make up one card slot. 

361 

362 Every card paints a ``$panel`` body fill plus a round box border in 

363 ``_DEFAULT_BORDER_STYLE``; the selected card swaps the border color for 

364 ``border_style`` (the focused / blurred token picked by ``render_line``). 

365 The body is always panel-tinted so cards read as discrete tiles even on 

366 dark themes. 

367 """ 

368 body = ( 

369 _frontier_lines(row) 

370 if row.kind == CatalogRowKind.FRONTIER 

371 else _local_lines(row, selected=selected) 

372 ) 

373 

374 inner_width = max(3, width - _CARD_GUTTER) 

375 body_width = inner_width - 2 # subtract the two side-border columns 

376 # Gap between cards on the same row; theme-tinted so it reads as a card 

377 # separator, not as raw black. 

378 gap = Content.styled(" " * _CARD_GUTTER, _GAP_STYLE) if _CARD_GUTTER else Content("") 

379 

380 body_padded = [_pad_line(line, body_width) for line in body[:_CARD_BODY_HEIGHT]] 

381 while len(body_padded) < _CARD_BODY_HEIGHT: 

382 body_padded.append(Content(" " * body_width)) 

383 

384 border_color = border_style if selected else _DEFAULT_BORDER_STYLE 

385 top = Content.styled( 

386 _BORDER_TOP_LEFT + _BORDER_HORIZONTAL * body_width + _BORDER_TOP_RIGHT, 

387 border_color, 

388 ) 

389 bottom = Content.styled( 

390 _BORDER_BOTTOM_LEFT + _BORDER_HORIZONTAL * body_width + _BORDER_BOTTOM_RIGHT, 

391 border_color, 

392 ) 

393 side = Content.styled(_BORDER_VERTICAL, border_color) 

394 

395 framed = [top] 

396 for line in body_padded: 

397 # Wrap each padded body line in side bars, then layer the panel 

398 # background across the whole inner_width so the body reads as a 

399 # single tile (the bg covers any unstyled padding inside `_pad_line`). 

400 wrapped = Content.assemble(side, line, side) 

401 framed.append(wrapped.stylize_before(_CARD_BODY_STYLE)) 

402 framed.append(bottom) 

403 

404 return _CardLines(lines=[Content.assemble(line, gap) for line in framed]) 

405 

406 

407def _pad_line(content: Content, width: int) -> Content: 

408 """Right-pad *content* to *width* columns with plain spaces.""" 

409 rendered_width = content.cell_length 

410 if rendered_width >= width: 

411 return content 

412 return Content.assemble(content, Content(" " * (width - rendered_width))) 

413 

414 

415def _local_lines(row: LocalCatalogRow, *, selected: bool) -> list[Content]: 

416 from lilbee.cli.tui import messages as msg 

417 from lilbee.cli.tui.widgets.model_card import _compat_pill 

418 

419 bg = TASK_COLORS.get(row.task, "$primary") 

420 name = Content.styled(_truncate_name(row.name), "bold") 

421 # Two pill rows so wide secondary chips (fit + 'unsupported') don't push 

422 # the card border out of alignment on narrow grid columns. 

423 primary_pills: list[Content] = [] 

424 if row.featured: 

425 primary_pills.append(pill("pick", "$warning", "$text")) 

426 primary_pills.append(pill(row.task, bg, "$text")) 

427 # Drop the 'native' backend pill on cards to free horizontal space; the 

428 # backend is implied for local models. Remote backends (ollama, etc.) 

429 # still surface their pill since that's a meaningful distinction. 

430 if row.backend and row.backend != "native": 

431 primary_pills.append(pill(row.backend, "$accent", "$text")) 

432 primary_line = Content(" ").join(primary_pills) 

433 

434 secondary_pills: list[Content] = [] 

435 if row.fit is not None: 

436 # Card uses the compact 'fits' / 'tight' / "won't run" label only; 

437 # the headroom GB lives in the detail drawer where the wider pane 

438 # can render it without competing for card width. 

439 secondary_pills.append(_fit_pill_compact(row.fit)) 

440 compat_chip = _compat_pill(row.compat) 

441 if compat_chip is not None: 

442 secondary_pills.append(compat_chip) 

443 secondary_line = Content(" ").join(secondary_pills) if secondary_pills else Content("") 

444 

445 # Family card with multiple quants: replace the simple specs line 

446 # with an inline chip strip so the user sees every available size 

447 # at a glance without expanding into the drawer. 

448 if len(row.size_variants) > 1: 

449 specs = _build_size_variant_strip(row.size_variants) 

450 else: 

451 specs = _build_specs(row.params, row.quant, row.size) 

452 status = _build_local_status(row) 

453 lines: list[Content] = [name, primary_line, secondary_line, specs] 

454 lines.append(status if status is not None else Content("")) 

455 if selected: 

456 hint = msg.INSTALLED_CARD_HINT if row.installed else msg.SETUP_CARD_HINT 

457 lines.append(Content.styled(hint, "$text-muted 40% italic")) 

458 else: 

459 lines.append(Content("")) 

460 return lines 

461 

462 

463def _build_size_variant_strip(variants: list) -> Content: 

464 """Inline chip strip showing every quant for a family-aggregated card. 

465 

466 Renders compact 'Q4 · Q5 · F16' style chips so the eye reads the 

467 available sizes at a glance. Per-variant fit colors aren't applied 

468 here; the drawer (right pane) carries the full fit-per-size detail 

469 when a card is highlighted. 

470 """ 

471 labels = [v.quant if v.quant != "--" else v.label for v in variants] 

472 return Content.styled(f" {MIDDLE_DOT} ".join(labels), "$text-muted") 

473 

474 

475def _frontier_lines(row: FrontierCatalogRow) -> list[Content]: 

476 name = Content.styled(_truncate_name(row.name), "bold") 

477 pill_line = Content(" ").join( 

478 [pill(row.provider, "$accent", "$text"), _key_status_pill(row.key_status)] 

479 ) 

480 info = Content.styled(f"Cloud via {row.provider} API", "$text-muted") 

481 # Frontier cards have no secondary pill line, but pad to _CARD_BODY_HEIGHT 

482 # so they align with local cards in the same grid row. 

483 return [name, pill_line, Content(""), info, Content(""), Content("")] 

484 

485 

486_FIT_LEVEL_BACKGROUND: dict[FitLevel, str] = { 

487 FitLevel.FITS: "$success", 

488 FitLevel.TIGHT: "$warning", 

489 FitLevel.WONT_RUN: "$error", 

490} 

491 

492 

493_FIT_LEVEL_LABEL_COMPACT: dict[FitLevel, str] = { 

494 FitLevel.FITS: "fits", 

495 FitLevel.TIGHT: "tight", 

496 FitLevel.WONT_RUN: "won't run", 

497} 

498 

499 

500def _fit_pill(fit: FitChip) -> Content: 

501 """Verbose fit chip with headroom GB, used by the detail drawer. 

502 

503 Headroom is signed; negative values mean the model overflows the host's 

504 available memory by that much. The chip's background tracks the level so 

505 colour-blind users still get the qualitative signal from the label itself. 

506 Cards render the compact form (``fits`` / ``tight`` / ``won't run``) 

507 via :func:`_fit_pill_compact` so the pill row fits the card width; the 

508 headroom GB belongs in the wider drawer where it has room to breathe. 

509 """ 

510 headroom_gb = fit.headroom_gb 

511 if fit.level is FitLevel.FITS: 

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

513 elif fit.level is FitLevel.TIGHT: 

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

515 else: 

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

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

518 

519 

520def _fit_pill_compact(fit: FitChip) -> Content: 

521 """Card-side compact fit chip: just ``fits`` / ``tight`` / ``won't run``.""" 

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

523 

524 

525def _key_status_pill(status: KeyStatus) -> Content: 

526 if status == KeyStatus.READY: 

527 return pill("ready", "$success", "$text") 

528 return pill("needs key", "$warning", "$text") 

529 

530 

531def _build_specs(params: str, quant: str, size: str) -> Content: 

532 parts = [p for p in (params, quant, size) if p and p != "--"] 

533 if not parts: 

534 return Content("--") 

535 return Content(f" {MIDDLE_DOT} ".join(parts)) 

536 

537 

538def _build_local_status(row: LocalCatalogRow) -> Content | None: 

539 if row.installed: 

540 return pill("installed", "$success", "$text") 

541 if row.sort_downloads > 0: 

542 return Content.styled(f"{row.downloads}", "$text-muted") 

543 return None