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

281 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +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.widget import Widget 

22 

23from lilbee.cli.tui.pill import pill 

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

25 CatalogRow, 

26 CatalogRowKind, 

27 FrontierCatalogRow, 

28 LocalCatalogRow, 

29) 

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

31 _key_status_pill, 

32 _local_card_lines, 

33 _truncate_name, 

34) 

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# Body width of the narrowest grid column (GridSelect min_column_width 30, 

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

54# lines without a concrete column width. 

55_DEFAULT_BODY_WIDTH = 27 

56 

57_BORDER_TOP_LEFT = "╭" 

58_BORDER_TOP_RIGHT = "╮" 

59_BORDER_BOTTOM_LEFT = "╰" 

60_BORDER_BOTTOM_RIGHT = "╯" 

61_BORDER_HORIZONTAL = "─" 

62_BORDER_VERTICAL = "│" 

63 

64_DOUBLE_CLICK_CHAIN = 2 

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

66 

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

68_CARD_BODY_STYLE = "on $panel" 

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

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

71# on whether the grid has focus. 

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

73_FOCUSED_BORDER_STYLE = "$primary on $panel" 

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

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

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

77_GAP_STYLE = "on $background" 

78 

79 

80@dataclass 

81class _CardLines: 

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

83 

84 lines: list[Content] 

85 

86 

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

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

89 return (row.kind, row.name) 

90 

91 

92class ModelGrid(Widget, can_focus=True): 

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

94 

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

96 

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

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

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

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

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

102 Binding("h", "cursor_left", "Left", show=False), 

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

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

105 ] 

106 

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

108 

109 @dataclass 

110 class Selected(Message): 

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

112 

113 grid: ModelGrid 

114 row: CatalogRow 

115 

116 @property 

117 def control(self) -> ModelGrid: 

118 return self.grid 

119 

120 @dataclass 

121 class LeaveUp(Message): 

122 grid: ModelGrid 

123 

124 @dataclass 

125 class LeaveDown(Message): 

126 grid: ModelGrid 

127 

128 @dataclass 

129 class Highlighted(Message): 

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

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

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

133 """ 

134 

135 grid: ModelGrid 

136 index: int 

137 

138 def __init__( 

139 self, 

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

141 *, 

142 name: str | None = None, 

143 id: str | None = None, 

144 classes: str | None = None, 

145 ) -> None: 

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

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

148 self._cards_per_row: int = _DEFAULT_COLUMNS 

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

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

151 self._reveal_pending: bool = False 

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

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

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

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

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

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

158 

159 @property 

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

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

162 return list(self._rows) 

163 

164 @property 

165 def columns_per_row(self) -> int: 

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

167 return self._cards_per_row 

168 

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

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

171 

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

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

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

175 """ 

176 previous_index = self.highlighted 

177 previous_key = ( 

178 _row_key(self._rows[previous_index]) 

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

180 else None 

181 ) 

182 self._rows = list(rows) 

183 self._card_cache.clear() 

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

185 self.refresh(layout=True) 

186 

187 def _relocated_highlight( 

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

189 ) -> int | None: 

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

191 if not self._rows: 

192 return None 

193 if previous_key is not None: 

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

195 if _row_key(row) == previous_key: 

196 return index 

197 if previous_index is not None: 

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

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

200 return 0 if self.has_focus else None 

201 

202 def on_resize(self) -> None: 

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

204 if new_cols != self._cards_per_row: 

205 self._cards_per_row = new_cols 

206 self._card_cache.clear() 

207 self.refresh(layout=True) 

208 if self._reveal_pending: 

209 self._reveal_highlight() 

210 

211 def on_show(self) -> None: 

212 if self._reveal_pending: 

213 self._reveal_highlight() 

214 

215 @staticmethod 

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

217 if width <= 0: 

218 return _DEFAULT_COLUMNS 

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

220 

221 def _total_rows(self) -> int: 

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

223 return 0 

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

225 

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

227 return container.width 

228 

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

230 if not self._rows: 

231 return 0 

232 cols = self._columns_for_width(width) 

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

234 return rows * _ROW_HEIGHT 

235 

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

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

238 

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

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

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

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

243 """ 

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

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

246 self._card_cache.clear() 

247 self.refresh() 

248 if new is None: 

249 self._reveal_pending = False 

250 return 

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

252 self._reveal_highlight(new) 

253 

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

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

256 if index is None: 

257 index = self.highlighted 

258 if index is None: 

259 self._reveal_pending = False 

260 return 

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

262 self._reveal_pending = True 

263 return 

264 self._reveal_pending = False 

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

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

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

268 self._scroll_region_into_view(cell) 

269 

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

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

272 

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

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

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

276 """ 

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

278 widget: Widget = self 

279 while isinstance(widget.parent, Widget): 

280 container = widget.parent 

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

282 widget = container 

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

284 break 

285 region = ( 

286 region.translate(-scroll_offset) 

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

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

289 .translate(container.virtual_region_with_margin.offset) 

290 ) 

291 

292 def on_focus(self) -> None: 

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

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

295 self.highlighted = 0 

296 

297 def on_blur(self) -> None: 

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

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

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

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

302 self.highlighted = None 

303 

304 def action_cursor_up(self) -> None: 

305 if self.highlighted is None: 

306 self.highlighted = 0 

307 return 

308 if self.highlighted < self._cards_per_row: 

309 self.post_message(self.LeaveUp(self)) 

310 return 

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

312 

313 def action_cursor_down(self) -> None: 

314 if self.highlighted is None: 

315 self.highlighted = 0 

316 return 

317 next_index = self.highlighted + self._cards_per_row 

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

319 self.post_message(self.LeaveDown(self)) 

320 return 

321 self.highlighted = next_index 

322 

323 def action_cursor_left(self) -> None: 

324 if self.highlighted is None: 

325 self.highlighted = 0 

326 return 

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

328 

329 def action_cursor_right(self) -> None: 

330 if self.highlighted is None: 

331 self.highlighted = 0 

332 return 

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

334 

335 def action_select(self) -> None: 

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

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

338 return 

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

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

341 

342 def highlight_first(self) -> None: 

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

344 if self._rows: 

345 self.highlighted = 0 

346 

347 def highlight_last(self) -> None: 

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

349 if self._rows: 

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

351 

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

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

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

355 return None 

356 if y < 0: 

357 return None 

358 row = y // _ROW_HEIGHT 

359 within_row = y - row * _ROW_HEIGHT 

360 if within_row >= _CARD_HEIGHT: 

361 return None 

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

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

364 index = row * self._cards_per_row + col 

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

366 return None 

367 return index 

368 

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

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

371 

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

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

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

375 """ 

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

377 if index is None: 

378 return 

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

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

381 return 

382 self.highlighted = index 

383 self.focus() 

384 

385 def _card_lines( 

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

387 ) -> _CardLines: 

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

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

390 cached = self._card_cache.get(key) 

391 if cached is None: 

392 cached = _render_card_strip( 

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

394 ) 

395 self._card_cache[key] = cached 

396 return cached 

397 

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

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

400 if y < 0: 

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

402 grid_row, line_within = divmod(y, _ROW_HEIGHT) 

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

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

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

406 border_style = _FOCUSED_BORDER_STYLE if self.has_focus else _BLURRED_BORDER_STYLE 

407 segments: list[Content] = [] 

408 for col in range(self._cards_per_row): 

409 index = grid_row * self._cards_per_row + col 

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

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

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

413 continue 

414 selected = index == self.highlighted 

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

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

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

418 return Strip(joined.render_segments(self.visual_style)).simplify() 

419 

420 

421def _render_card_strip( 

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

423) -> _CardLines: 

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

425 

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

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

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

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

430 dark themes. 

431 """ 

432 inner_width = max(3, width - _CARD_GUTTER) 

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

434 body = ( 

435 _frontier_lines(row) 

436 if row.kind == CatalogRowKind.FRONTIER 

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

438 ) 

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

440 # separator, not as raw black. 

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

442 

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

444 while len(body_padded) < _CARD_BODY_HEIGHT: 

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

446 

447 border_color = border_style if selected else _DEFAULT_BORDER_STYLE 

448 top = Content.styled( 

449 _BORDER_TOP_LEFT + _BORDER_HORIZONTAL * body_width + _BORDER_TOP_RIGHT, 

450 border_color, 

451 ) 

452 bottom = Content.styled( 

453 _BORDER_BOTTOM_LEFT + _BORDER_HORIZONTAL * body_width + _BORDER_BOTTOM_RIGHT, 

454 border_color, 

455 ) 

456 side = Content.styled(_BORDER_VERTICAL, border_color) 

457 

458 framed = [top] 

459 for line in body_padded: 

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

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

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

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

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

465 framed.append(bottom) 

466 

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

468 

469 

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

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

472 

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

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

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

476 """ 

477 rendered_width = content.cell_length 

478 if rendered_width > width: 

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

480 if rendered_width == width: 

481 return content 

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

483 

484 

485def _local_lines( 

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

487) -> list[Content]: 

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

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

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

491 

492 

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

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

495 pill_line = Content(" ").join( 

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

497 ) 

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

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

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

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

502 

503 

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

505# chips live in catalog_card_shared.