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
« 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.
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.widget import Widget
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)
36_CSS_FILE = Path(__file__).parent / "model_grid.tcss"
38_CARD_BODY_HEIGHT = 6
39"""Body lines per card: name / primary pills / secondary pills / specs / status / hint."""
41_BORDER_RESERVED_LINES = 2
42"""Top + bottom border slots; reserved on every card so layout stays stable."""
44_CARD_HEIGHT = _CARD_BODY_HEIGHT + _BORDER_RESERVED_LINES
46_ROW_GUTTER = 0
47_ROW_HEIGHT = _CARD_HEIGHT + _ROW_GUTTER
48_DEFAULT_COLUMNS = 4
49_CARD_MIN_WIDTH = 32
50_CARD_GUTTER = 1
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
57_BORDER_TOP_LEFT = "╭"
58_BORDER_TOP_RIGHT = "╮"
59_BORDER_BOTTOM_LEFT = "╰"
60_BORDER_BOTTOM_RIGHT = "╯"
61_BORDER_HORIZONTAL = "─"
62_BORDER_VERTICAL = "│"
64_DOUBLE_CLICK_CHAIN = 2
65"""events.Click.chain value for the second click of a double-click."""
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"
80@dataclass
81class _CardLines:
82 """Pre-rendered content lines for one card. Each entry is one terminal row."""
84 lines: list[Content]
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)
92class ModelGrid(Widget, can_focus=True):
93 """Single-render-surface grid of ``CatalogRow`` cards."""
95 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
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 ]
107 highlighted: reactive[int | None] = reactive(None)
109 @dataclass
110 class Selected(Message):
111 """Posted when a card is activated. ``row`` is the underlying CatalogRow."""
113 grid: ModelGrid
114 row: CatalogRow
116 @property
117 def control(self) -> ModelGrid:
118 return self.grid
120 @dataclass
121 class LeaveUp(Message):
122 grid: ModelGrid
124 @dataclass
125 class LeaveDown(Message):
126 grid: ModelGrid
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 """
135 grid: ModelGrid
136 index: int
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] = {}
159 @property
160 def rows(self) -> list[CatalogRow]:
161 """The dataset backing this grid (defensive copy)."""
162 return list(self._rows)
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
169 def set_rows(self, rows: list[CatalogRow]) -> None:
170 """Replace the dataset, keeping the cursor on the same row when it survives.
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)
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
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()
211 def on_show(self) -> None:
212 if self._reveal_pending:
213 self._reveal_highlight()
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))
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
226 def get_content_width(self, container: Size, viewport: Size) -> int:
227 return container.width
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
236 def watch_highlighted(self, _old: int | None, new: int | None) -> None:
237 """Repaint, post Highlighted, scroll the cell into view.
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)
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)
270 def _scroll_region_into_view(self, cell: Region) -> None:
271 """Reveal *cell* (grid-local coords) by scrolling every ancestor that can.
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 )
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
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
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)
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
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)
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)
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]))
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
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
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
369 def on_click(self, event: events.Click) -> None:
370 """Single click only highlights; a double-click on the same card installs.
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()
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
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()
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.
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("")
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))
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)
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)
467 return _CardLines(lines=[Content.assemble(line, gap) for line in framed])
470def _pad_line(content: Content, width: int) -> Content:
471 """Fit *content* to exactly *width* columns, padding or truncating.
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)))
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]
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("")]
504# _local_card_lines / _key_status_pill / _truncate_name and the fit / compat
505# chips live in catalog_card_shared.