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

282 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +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("h", "cursor_left", "Left", show=False), 

104 Binding("l", "cursor_right", "Right", show=False), 

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

106 ] 

107 

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

109 

110 @dataclass 

111 class Selected(Message): 

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

113 

114 grid: ModelGrid 

115 row: CatalogRow 

116 

117 @property 

118 def control(self) -> ModelGrid: 

119 return self.grid 

120 

121 @dataclass 

122 class LeaveUp(Message): 

123 grid: ModelGrid 

124 

125 @dataclass 

126 class LeaveDown(Message): 

127 grid: ModelGrid 

128 

129 @dataclass 

130 class Highlighted(Message): 

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

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

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

134 """ 

135 

136 grid: ModelGrid 

137 index: int 

138 

139 def __init__( 

140 self, 

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

142 *, 

143 name: str | None = None, 

144 id: str | None = None, 

145 classes: str | None = None, 

146 ) -> None: 

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

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

149 self._cards_per_row: int = _DEFAULT_COLUMNS 

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

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

152 self._reveal_pending: bool = False 

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

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

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

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

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

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

159 

160 @property 

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

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

163 return list(self._rows) 

164 

165 @property 

166 def columns_per_row(self) -> int: 

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

168 return self._cards_per_row 

169 

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

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

172 

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

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

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

176 """ 

177 previous_index = self.highlighted 

178 previous_key = ( 

179 _row_key(self._rows[previous_index]) 

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

181 else None 

182 ) 

183 self._rows = list(rows) 

184 self._card_cache.clear() 

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

186 self.refresh(layout=True) 

187 

188 def _relocated_highlight( 

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

190 ) -> int | None: 

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

192 if not self._rows: 

193 return None 

194 if previous_key is not None: 

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

196 if _row_key(row) == previous_key: 

197 return index 

198 if previous_index is not None: 

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

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

201 return 0 if self.has_focus else None 

202 

203 def on_resize(self) -> None: 

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

205 if new_cols != self._cards_per_row: 

206 self._cards_per_row = new_cols 

207 self._card_cache.clear() 

208 self.refresh(layout=True) 

209 if self._reveal_pending: 

210 self._reveal_highlight() 

211 

212 def on_show(self) -> None: 

213 if self._reveal_pending: 

214 self._reveal_highlight() 

215 

216 @staticmethod 

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

218 if width <= 0: 

219 return _DEFAULT_COLUMNS 

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

221 

222 def _total_rows(self) -> int: 

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

224 return 0 

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

226 

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

228 return container.width 

229 

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

231 if not self._rows: 

232 return 0 

233 cols = self._columns_for_width(width) 

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

235 return rows * _ROW_HEIGHT 

236 

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

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

239 

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

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

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

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

244 """ 

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

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

247 self._card_cache.clear() 

248 self.refresh() 

249 if new is None: 

250 self._reveal_pending = False 

251 return 

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

253 self._reveal_highlight(new) 

254 

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

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

257 if index is None: 

258 index = self.highlighted 

259 if index is None: 

260 self._reveal_pending = False 

261 return 

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

263 self._reveal_pending = True 

264 return 

265 self._reveal_pending = False 

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

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

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

269 self._scroll_region_into_view(cell) 

270 

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

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

273 

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

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

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

277 """ 

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

279 widget: Widget = self 

280 while isinstance(widget.parent, Widget): 

281 container = widget.parent 

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

283 widget = container 

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

285 break 

286 region = ( 

287 region.translate(-scroll_offset) 

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

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

290 .translate(container.virtual_region_with_margin.offset) 

291 ) 

292 

293 def on_focus(self) -> None: 

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

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

296 self.highlighted = 0 

297 

298 def on_blur(self) -> None: 

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

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

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

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

303 self.highlighted = None 

304 

305 def action_cursor_up(self) -> None: 

306 if self.highlighted is None: 

307 self.highlighted = 0 

308 return 

309 if self.highlighted < self._cards_per_row: 

310 self.post_message(self.LeaveUp(self)) 

311 return 

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

313 

314 def action_cursor_down(self) -> None: 

315 if self.highlighted is None: 

316 self.highlighted = 0 

317 return 

318 next_index = self.highlighted + self._cards_per_row 

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

320 self.post_message(self.LeaveDown(self)) 

321 return 

322 self.highlighted = next_index 

323 

324 def action_cursor_left(self) -> None: 

325 if self.highlighted is None: 

326 self.highlighted = 0 

327 return 

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

329 

330 def action_cursor_right(self) -> None: 

331 if self.highlighted is None: 

332 self.highlighted = 0 

333 return 

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

335 

336 def action_select(self) -> None: 

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

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

339 return 

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

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

342 

343 def highlight_first(self) -> None: 

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

345 if self._rows: 

346 self.highlighted = 0 

347 

348 def highlight_last(self) -> None: 

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

350 if self._rows: 

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

352 

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

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

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

356 return None 

357 if y < 0: 

358 return None 

359 row = y // _ROW_HEIGHT 

360 within_row = y - row * _ROW_HEIGHT 

361 if within_row >= _CARD_HEIGHT: 

362 return None 

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

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

365 index = row * self._cards_per_row + col 

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

367 return None 

368 return index 

369 

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

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

372 

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

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

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

376 """ 

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

378 if index is None: 

379 return 

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

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

382 return 

383 self.highlighted = index 

384 self.focus() 

385 

386 def _card_lines( 

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

388 ) -> _CardLines: 

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

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

391 cached = self._card_cache.get(key) 

392 if cached is None: 

393 cached = _render_card_strip( 

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

395 ) 

396 self._card_cache[key] = cached 

397 return cached 

398 

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

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

401 if y < 0: 

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

403 grid_row, line_within = divmod(y, _ROW_HEIGHT) 

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

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

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

407 border_style = _FOCUSED_BORDER_STYLE if self.has_focus else _BLURRED_BORDER_STYLE 

408 segments: list[Content] = [] 

409 for col in range(self._cards_per_row): 

410 index = grid_row * self._cards_per_row + col 

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

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

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

414 continue 

415 selected = index == self.highlighted 

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

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

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

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

420 

421 

422def _render_card_strip( 

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

424) -> _CardLines: 

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

426 

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

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

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

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

431 dark themes. 

432 """ 

433 inner_width = max(3, width - _CARD_GUTTER) 

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

435 body = ( 

436 _frontier_lines(row) 

437 if row.kind == CatalogRowKind.FRONTIER 

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

439 ) 

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

441 # separator, not as raw black. 

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

443 

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

445 while len(body_padded) < _CARD_BODY_HEIGHT: 

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

447 

448 border_color = border_style if selected else _DEFAULT_BORDER_STYLE 

449 top = Content.styled( 

450 _BORDER_TOP_LEFT + _BORDER_HORIZONTAL * body_width + _BORDER_TOP_RIGHT, 

451 border_color, 

452 ) 

453 bottom = Content.styled( 

454 _BORDER_BOTTOM_LEFT + _BORDER_HORIZONTAL * body_width + _BORDER_BOTTOM_RIGHT, 

455 border_color, 

456 ) 

457 side = Content.styled(_BORDER_VERTICAL, border_color) 

458 

459 framed = [top] 

460 for line in body_padded: 

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

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

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

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

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

466 framed.append(bottom) 

467 

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

469 

470 

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

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

473 

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

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

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

477 """ 

478 rendered_width = content.cell_length 

479 if rendered_width > width: 

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

481 if rendered_width == width: 

482 return content 

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

484 

485 

486def _local_lines( 

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

488) -> list[Content]: 

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

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

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

492 

493 

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

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

496 pill_line = Content(" ").join( 

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

498 ) 

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

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

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

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

503 

504 

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

506# chips live in catalog_card_shared.