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
« 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.
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("h", "cursor_left", "Left", show=False),
104 Binding("l", "cursor_right", "Right", show=False),
105 Binding("enter", "select", "Select", show=False),
106 ]
108 highlighted: reactive[int | None] = reactive(None)
110 @dataclass
111 class Selected(Message):
112 """Posted when a card is activated. ``row`` is the underlying CatalogRow."""
114 grid: ModelGrid
115 row: CatalogRow
117 @property
118 def control(self) -> ModelGrid:
119 return self.grid
121 @dataclass
122 class LeaveUp(Message):
123 grid: ModelGrid
125 @dataclass
126 class LeaveDown(Message):
127 grid: ModelGrid
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 """
136 grid: ModelGrid
137 index: int
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] = {}
160 @property
161 def rows(self) -> list[CatalogRow]:
162 """The dataset backing this grid (defensive copy)."""
163 return list(self._rows)
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
170 def set_rows(self, rows: list[CatalogRow]) -> None:
171 """Replace the dataset, keeping the cursor on the same row when it survives.
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)
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
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()
212 def on_show(self) -> None:
213 if self._reveal_pending:
214 self._reveal_highlight()
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))
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
227 def get_content_width(self, container: Size, viewport: Size) -> int:
228 return container.width
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
237 def watch_highlighted(self, _old: int | None, new: int | None) -> None:
238 """Repaint, post Highlighted, scroll the cell into view.
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)
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)
271 def _scroll_region_into_view(self, cell: Region) -> None:
272 """Reveal *cell* (grid-local coords) by scrolling every ancestor that can.
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 )
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
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
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)
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
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)
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)
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]))
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
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
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
370 def on_click(self, event: events.Click) -> None:
371 """Single click only highlights; a double-click on the same card installs.
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()
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
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()
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.
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("")
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))
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)
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)
468 return _CardLines(lines=[Content.assemble(line, gap) for line in framed])
471def _pad_line(content: Content, width: int) -> Content:
472 """Fit *content* to exactly *width* columns, padding or truncating.
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)))
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]
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("")]
505# _local_card_lines / _key_status_pill / _truncate_name and the fit / compat
506# chips live in catalog_card_shared.