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
« 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.
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"""
8from __future__ import annotations
10from dataclasses import dataclass
11from pathlib import Path
12from typing import ClassVar
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
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)
37_CSS_FILE = Path(__file__).parent / "model_grid.tcss"
39_CARD_BODY_HEIGHT = 6
40"""Body lines per card: name / primary pills / secondary pills / specs / status / hint."""
42_BORDER_RESERVED_LINES = 2
43"""Top + bottom border slots; reserved on every card so layout stays stable."""
45_CARD_HEIGHT = _CARD_BODY_HEIGHT + _BORDER_RESERVED_LINES
47_ROW_GUTTER = 0
48_ROW_HEIGHT = _CARD_HEIGHT + _ROW_GUTTER
49_DEFAULT_COLUMNS = 4
50_CARD_MIN_WIDTH = 32
51_CARD_GUTTER = 1
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
58_BORDER_TOP_LEFT = "╭"
59_BORDER_TOP_RIGHT = "╮"
60_BORDER_BOTTOM_LEFT = "╰"
61_BORDER_BOTTOM_RIGHT = "╯"
62_BORDER_HORIZONTAL = "─"
63_BORDER_VERTICAL = "│"
65_DOUBLE_CLICK_CHAIN = 2
66"""events.Click.chain value for the second click of a double-click."""
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"
81@dataclass
82class _CardLines:
83 """Pre-rendered content lines for one card. Each entry is one terminal row."""
85 lines: list[Content]
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)
93class ModelGrid(Widget, can_focus=True):
94 """Single-render-surface grid of ``CatalogRow`` cards."""
96 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
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 ]
106 highlighted: reactive[int | None] = reactive(None)
108 @dataclass
109 class Selected(Message):
110 """Posted when a card is activated. ``row`` is the underlying CatalogRow."""
112 grid: ModelGrid
113 row: CatalogRow
115 @property
116 def control(self) -> ModelGrid:
117 return self.grid
119 @dataclass
120 class LeaveUp(Message):
121 grid: ModelGrid
123 @dataclass
124 class LeaveDown(Message):
125 grid: ModelGrid
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 """
134 grid: ModelGrid
135 index: int
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] = {}
158 @property
159 def rows(self) -> list[CatalogRow]:
160 """The dataset backing this grid (defensive copy)."""
161 return list(self._rows)
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
168 def set_rows(self, rows: list[CatalogRow]) -> None:
169 """Replace the dataset, keeping the cursor on the same row when it survives.
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)
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
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()
210 def on_show(self) -> None:
211 if self._reveal_pending:
212 self._reveal_highlight()
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))
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
225 def get_content_width(self, container: Size, viewport: Size) -> int:
226 return container.width
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
235 def watch_highlighted(self, _old: int | None, new: int | None) -> None:
236 """Repaint, post Highlighted, scroll the cell into view.
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)
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)
269 def _scroll_region_into_view(self, cell: Region) -> None:
270 """Reveal *cell* (grid-local coords) by scrolling every ancestor that can.
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 )
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
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
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)
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
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)
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)
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]))
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
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
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
368 def on_click(self, event: events.Click) -> None:
369 """Single click only highlights; a double-click on the same card installs.
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()
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
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()
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.
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("")
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))
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)
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)
466 return _CardLines(lines=[Content.assemble(line, gap) for line in framed])
469def _pad_line(content: Content, width: int) -> Content:
470 """Fit *content* to exactly *width* columns, padding or truncating.
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)))
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]
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("")]
503# _local_card_lines / _key_status_pill / _truncate_name and the fit / compat
504# chips live in catalog_card_shared.