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

282 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +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 

10from dataclasses import dataclass 

11from pathlib import Path 

12from typing import ClassVar 

13 

14from textual import events 

15from textual.binding import Binding, BindingType 

16from textual.content import Content 

17from textual.geometry import Region, Size 

18from textual.message import Message 

19from textual.reactive import reactive 

20from textual.strip import Strip 

21from textual.style import Style 

22from textual.widget import Widget 

23 

24from lilbee.cli.tui.pill import pill 

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

26 CatalogRow, 

27 CatalogRowKind, 

28 FrontierCatalogRow, 

29 LocalCatalogRow, 

30) 

31from lilbee.cli.tui.widgets.catalog_card_shared import ( 

32 _key_status_pill, 

33 _local_card_lines, 

34 _truncate_name, 

35) 

36 

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

38 

39_CARD_BODY_HEIGHT = 6 

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

41 

42_BORDER_RESERVED_LINES = 2 

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

44 

45_CARD_HEIGHT = _CARD_BODY_HEIGHT + _BORDER_RESERVED_LINES 

46 

47_ROW_GUTTER = 0 

48_ROW_HEIGHT = _CARD_HEIGHT + _ROW_GUTTER 

49_DEFAULT_COLUMNS = 4 

50_CARD_MIN_WIDTH = 32 

51_CARD_GUTTER = 1 

52 

53# Body width of the narrowest grid column (GridSelect min_column_width 30, 

54# less the gutter and the two side borders). Used when a caller renders card 

55# lines without a concrete column width. 

56_DEFAULT_BODY_WIDTH = 27 

57 

58_BORDER_TOP_LEFT = "╭" 

59_BORDER_TOP_RIGHT = "╮" 

60_BORDER_BOTTOM_LEFT = "╰" 

61_BORDER_BOTTOM_RIGHT = "╯" 

62_BORDER_HORIZONTAL = "─" 

63_BORDER_VERTICAL = "│" 

64 

65_DOUBLE_CLICK_CHAIN = 2 

66"""events.Click.chain value for the second click of a double-click.""" 

67 

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

69_CARD_BODY_STYLE = "on $panel" 

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

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

72# on whether the grid has focus. 

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

74_FOCUSED_BORDER_STYLE = "$primary on $panel" 

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

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

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

78_GAP_STYLE = "on $background" 

79 

80 

81@dataclass 

82class _CardLines: 

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

84 

85 lines: list[Content] 

86 

87 

88def _row_key(row: CatalogRow) -> tuple[CatalogRowKind, str]: 

89 """Identity used to re-locate the highlighted row across a dataset swap.""" 

90 return (row.kind, row.name) 

91 

92 

93class ModelGrid(Widget, can_focus=True): 

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

95 

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

97 

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

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

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

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

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

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

104 ] 

105 

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

107 

108 @dataclass 

109 class Selected(Message): 

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

111 

112 grid: ModelGrid 

113 row: CatalogRow 

114 

115 @property 

116 def control(self) -> ModelGrid: 

117 return self.grid 

118 

119 @dataclass 

120 class LeaveUp(Message): 

121 grid: ModelGrid 

122 

123 @dataclass 

124 class LeaveDown(Message): 

125 grid: ModelGrid 

126 

127 @dataclass 

128 class Highlighted(Message): 

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

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

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

132 """ 

133 

134 grid: ModelGrid 

135 index: int 

136 

137 def __init__( 

138 self, 

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

140 *, 

141 name: str | None = None, 

142 id: str | None = None, 

143 classes: str | None = None, 

144 ) -> None: 

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

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

147 self._cards_per_row: int = _DEFAULT_COLUMNS 

148 # A highlight assigned before layout (restore-after-remount, initial 

149 # focus) can't scroll into view yet; on_resize completes it. 

150 self._reveal_pending: bool = False 

151 # render_line is called once per terminal row, so each card is asked for 

152 # _CARD_HEIGHT times per repaint; cache the built lines so a card renders 

153 # once. Flushed on set_rows and highlight changes, and on a resize that 

154 # shifts the column count; col_width is part of the key, so a width change 

155 # at the same column count is served fresh without an explicit flush. 

156 self._card_cache: dict[tuple[int, int, bool, str], _CardLines] = {} 

157 

158 @property 

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

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

161 return list(self._rows) 

162 

163 @property 

164 def columns_per_row(self) -> int: 

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

166 return self._cards_per_row 

167 

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

169 """Replace the dataset, keeping the cursor on the same row when it survives. 

170 

171 Background refreshes land through here constantly (HF pages arrive a 

172 few rows at a time), so the highlight follows the row's identity into 

173 the new dataset; resetting it would strand the cursor mid-navigation. 

174 """ 

175 previous_index = self.highlighted 

176 previous_key = ( 

177 _row_key(self._rows[previous_index]) 

178 if previous_index is not None and 0 <= previous_index < len(self._rows) 

179 else None 

180 ) 

181 self._rows = list(rows) 

182 self._card_cache.clear() 

183 self.highlighted = self._relocated_highlight(previous_index, previous_key) 

184 self.refresh(layout=True) 

185 

186 def _relocated_highlight( 

187 self, previous_index: int | None, previous_key: tuple[CatalogRowKind, str] | None 

188 ) -> int | None: 

189 """Where the cursor lands after a dataset replacement.""" 

190 if not self._rows: 

191 return None 

192 if previous_key is not None: 

193 for index, row in enumerate(self._rows): 

194 if _row_key(row) == previous_key: 

195 return index 

196 if previous_index is not None: 

197 return min(previous_index, len(self._rows) - 1) 

198 # A focused grid always shows a cursor; an unfocused one stays bare. 

199 return 0 if self.has_focus else None 

200 

201 def on_resize(self) -> None: 

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

203 if new_cols != self._cards_per_row: 

204 self._cards_per_row = new_cols 

205 self._card_cache.clear() 

206 self.refresh(layout=True) 

207 if self._reveal_pending: 

208 self._reveal_highlight() 

209 

210 def on_show(self) -> None: 

211 if self._reveal_pending: 

212 self._reveal_highlight() 

213 

214 @staticmethod 

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

216 if width <= 0: 

217 return _DEFAULT_COLUMNS 

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

219 

220 def _total_rows(self) -> int: 

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

222 return 0 

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

224 

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

226 return container.width 

227 

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

229 if not self._rows: 

230 return 0 

231 cols = self._columns_for_width(width) 

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

233 return rows * _ROW_HEIGHT 

234 

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

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

237 

238 The Highlighted message lets the catalog screen run keyboard-driven 

239 prefetch and drawer updates on every cursor move; it is posted even 

240 before layout so listeners never miss a move, while the scroll part 

241 waits for a real size (``_reveal_pending`` + ``on_resize``). 

242 """ 

243 # The two cards whose selected state flipped must re-render; clearing 

244 # also bounds the cache to one repaint's worth of cards. 

245 self._card_cache.clear() 

246 self.refresh() 

247 if new is None: 

248 self._reveal_pending = False 

249 return 

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

251 self._reveal_highlight(new) 

252 

253 def _reveal_highlight(self, index: int | None = None) -> None: 

254 """Scroll the highlighted cell into view, deferring until layout exists.""" 

255 if index is None: 

256 index = self.highlighted 

257 if index is None: 

258 self._reveal_pending = False 

259 return 

260 if self._cards_per_row <= 0 or self.size.width <= 0: 

261 self._reveal_pending = True 

262 return 

263 self._reveal_pending = False 

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

265 row, col = divmod(index, self._cards_per_row) 

266 cell = Region(col * col_width, row * _ROW_HEIGHT, col_width, _CARD_HEIGHT) 

267 self._scroll_region_into_view(cell) 

268 

269 def _scroll_region_into_view(self, cell: Region) -> None: 

270 """Reveal *cell* (grid-local coords) by scrolling every ancestor that can. 

271 

272 ModelGrid paints cards as strips, so there is no child widget to hand 

273 to ``Screen.scroll_to_widget``; this mirrors its ancestor walk for a 

274 region instead of assuming any particular ancestor is the scrollable. 

275 """ 

276 region = cell.translate(self.virtual_region.offset) 

277 widget: Widget = self 

278 while isinstance(widget.parent, Widget): 

279 container = widget.parent 

280 scroll_offset = container.scroll_to_region(region, animate=False) 

281 widget = container 

282 if not region or not isinstance(widget.parent, Widget): 

283 break 

284 region = ( 

285 region.translate(-scroll_offset) 

286 .translate(container.styles.margin.top_left) 

287 .translate(container.styles.border.spacing.top_left) 

288 .translate(container.virtual_region_with_margin.offset) 

289 ) 

290 

291 def on_focus(self) -> None: 

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

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

294 self.highlighted = 0 

295 

296 def on_blur(self) -> None: 

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

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

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

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

301 self.highlighted = None 

302 

303 def action_cursor_up(self) -> None: 

304 if self.highlighted is None: 

305 self.highlighted = 0 

306 return 

307 if self.highlighted < self._cards_per_row: 

308 self.post_message(self.LeaveUp(self)) 

309 return 

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

311 

312 def action_cursor_down(self) -> None: 

313 if self.highlighted is None: 

314 self.highlighted = 0 

315 return 

316 next_index = self.highlighted + self._cards_per_row 

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

318 self.post_message(self.LeaveDown(self)) 

319 return 

320 self.highlighted = next_index 

321 

322 def action_cursor_left(self) -> None: 

323 if self.highlighted is None: 

324 self.highlighted = 0 

325 return 

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

327 

328 def action_cursor_right(self) -> None: 

329 if self.highlighted is None: 

330 self.highlighted = 0 

331 return 

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

333 

334 def action_select(self) -> None: 

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

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

337 return 

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

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

340 

341 def highlight_first(self) -> None: 

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

343 if self._rows: 

344 self.highlighted = 0 

345 

346 def highlight_last(self) -> None: 

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

348 if self._rows: 

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

350 

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

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

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

354 return None 

355 if y < 0: 

356 return None 

357 row = y // _ROW_HEIGHT 

358 within_row = y - row * _ROW_HEIGHT 

359 if within_row >= _CARD_HEIGHT: 

360 return None 

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

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

363 index = row * self._cards_per_row + col 

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

365 return None 

366 return index 

367 

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

369 """Single click only highlights; a double-click on the same card installs. 

370 

371 A single click must never install (auto-highlight-on-focus made that 

372 a one-mis-tap hazard). ``event.chain`` carries the click multiplicity, 

373 so the double-click window follows the user's terminal settings. 

374 """ 

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

376 if index is None: 

377 return 

378 if event.chain >= _DOUBLE_CLICK_CHAIN and index == self.highlighted: 

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

380 return 

381 self.highlighted = index 

382 self.focus() 

383 

384 def _card_lines( 

385 self, index: int, col_width: int, selected: bool, border_style: str 

386 ) -> _CardLines: 

387 """Build (and cache for this repaint) the card lines for one cell.""" 

388 key = (index, col_width, selected, border_style) 

389 cached = self._card_cache.get(key) 

390 if cached is None: 

391 cached = _render_card_strip( 

392 self._rows[index], selected=selected, width=col_width, border_style=border_style 

393 ) 

394 self._card_cache[key] = cached 

395 return cached 

396 

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

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

399 if y < 0: 

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

401 grid_row, line_within = divmod(y, _ROW_HEIGHT) 

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

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

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

405 border_style = _FOCUSED_BORDER_STYLE if self.has_focus else _BLURRED_BORDER_STYLE 

406 segments: list[Content] = [] 

407 for col in range(self._cards_per_row): 

408 index = grid_row * self._cards_per_row + col 

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

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

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

412 continue 

413 selected = index == self.highlighted 

414 card = self._card_lines(index, col_width, selected, border_style) 

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

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

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

418 

419 

420def _render_card_strip( 

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

422) -> _CardLines: 

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

424 

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

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

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

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

429 dark themes. 

430 """ 

431 inner_width = max(3, width - _CARD_GUTTER) 

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

433 body = ( 

434 _frontier_lines(row) 

435 if row.kind == CatalogRowKind.FRONTIER 

436 else _local_lines(row, selected=selected, body_width=body_width) 

437 ) 

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

439 # separator, not as raw black. 

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

441 

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

443 while len(body_padded) < _CARD_BODY_HEIGHT: 

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

445 

446 border_color = border_style if selected else _DEFAULT_BORDER_STYLE 

447 top = Content.styled( 

448 _BORDER_TOP_LEFT + _BORDER_HORIZONTAL * body_width + _BORDER_TOP_RIGHT, 

449 border_color, 

450 ) 

451 bottom = Content.styled( 

452 _BORDER_BOTTOM_LEFT + _BORDER_HORIZONTAL * body_width + _BORDER_BOTTOM_RIGHT, 

453 border_color, 

454 ) 

455 side = Content.styled(_BORDER_VERTICAL, border_color) 

456 

457 framed = [top] 

458 for line in body_padded: 

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

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

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

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

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

464 framed.append(bottom) 

465 

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

467 

468 

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

470 """Fit *content* to exactly *width* columns, padding or truncating. 

471 

472 Truncating here rather than trusting each line builder keeps the card 

473 frame intact by construction: one over-wide line otherwise pushes its 

474 right border out and misaligns every card beside it in the row. 

475 """ 

476 rendered_width = content.cell_length 

477 if rendered_width > width: 

478 return content.truncate(width, ellipsis=True) 

479 if rendered_width == width: 

480 return content 

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

482 

483 

484def _local_lines( 

485 row: LocalCatalogRow, *, selected: bool, body_width: int = _DEFAULT_BODY_WIDTH 

486) -> list[Content]: 

487 """Grid presentation of the shared card slots: every slot paints a line.""" 

488 slots = _local_card_lines(row, selected=selected, body_width=body_width) 

489 return [line if line is not None else Content("") for line in slots] 

490 

491 

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

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

494 pill_line = Content(" ").join( 

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

496 ) 

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

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

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

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

501 

502 

503# _local_card_lines / _key_status_pill / _truncate_name and the fit / compat 

504# chips live in catalog_card_shared.