Coverage for src / lilbee / cli / tui / widgets / model_grid.py: 100%
296 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +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
10import time
11from dataclasses import dataclass
12from pathlib import Path
13from typing import ClassVar
15from textual import events
16from textual.binding import Binding, BindingType
17from textual.content import Content
18from textual.geometry import Region, Size
19from textual.message import Message
20from textual.reactive import reactive
21from textual.strip import Strip
22from textual.style import Style
23from textual.widget import Widget
25from lilbee.cli.tui.pill import pill
26from lilbee.cli.tui.screens.catalog_utils import (
27 CatalogRow,
28 CatalogRowKind,
29 FrontierCatalogRow,
30 KeyStatus,
31 LocalCatalogRow,
32)
33from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS
34from lilbee.runtime.hardware import FitChip, FitLevel
36_CSS_FILE = Path(__file__).parent / "model_grid.tcss"
38_CARD_BODY_HEIGHT = 5
39"""Body lines emitted per card: name / 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_BORDER_TOP_LEFT = "╭"
53_BORDER_TOP_RIGHT = "╮"
54_BORDER_BOTTOM_LEFT = "╰"
55_BORDER_BOTTOM_RIGHT = "╯"
56_BORDER_HORIZONTAL = "─"
57_BORDER_VERTICAL = "│"
59# Theme-token style strings; resolved at render time on the active theme.
60_CARD_BODY_STYLE = "on $panel"
61# Every card draws a border at all times so the grid reads as discrete tiles.
62# The default tone is dim; the selected card gets a brighter color depending
63# on whether the grid has focus.
64_DEFAULT_BORDER_STYLE = "$border-blurred on $panel"
65_FOCUSED_BORDER_STYLE = "$primary on $panel"
66_BLURRED_BORDER_STYLE = "$border-blurred on $panel"
67# Inter-card gutter and empty slot fill: match the screen's surface so gaps
68# read as theme background, not raw terminal black.
69_GAP_STYLE = "on $background"
72@dataclass
73class _CardLines:
74 """Pre-rendered content lines for one card. Each entry is one terminal row."""
76 lines: list[Content]
79class ModelGrid(Widget, can_focus=True):
80 """Single-render-surface grid of ``CatalogRow`` cards."""
82 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
84 BINDINGS: ClassVar[list[BindingType]] = [
85 Binding("up", "cursor_up", "Up", show=False),
86 Binding("down", "cursor_down", "Down", show=False),
87 Binding("left", "cursor_left", "Left", show=False),
88 Binding("right", "cursor_right", "Right", show=False),
89 Binding("enter", "select", "Select", show=False),
90 ]
92 highlighted: reactive[int | None] = reactive(None)
94 @dataclass
95 class Selected(Message):
96 """Posted when a card is activated. ``row`` is the underlying CatalogRow."""
98 grid: ModelGrid
99 row: CatalogRow
101 @property
102 def control(self) -> ModelGrid:
103 return self.grid
105 @dataclass
106 class LeaveUp(Message):
107 grid: ModelGrid
109 @dataclass
110 class LeaveDown(Message):
111 grid: ModelGrid
113 @dataclass
114 class Highlighted(Message):
115 """Posted on every cursor move so the catalog can run keyboard-driven
116 prefetch (mouse wheel triggers via the scroll watcher; cell-by-cell
117 keyboard scrolling never crosses the 85 % threshold by itself).
118 """
120 grid: ModelGrid
121 index: int
123 # Window inside which a second click on the same card counts as a
124 # double-click and posts Selected. Single click outside this window only
125 # highlights so users can't accidentally trigger an install with one mis-tap.
126 _DOUBLE_CLICK_WINDOW_S: ClassVar[float] = 0.5
128 def __init__(
129 self,
130 rows: list[CatalogRow] | None = None,
131 *,
132 name: str | None = None,
133 id: str | None = None,
134 classes: str | None = None,
135 ) -> None:
136 super().__init__(name=name, id=id, classes=classes)
137 self._rows: list[CatalogRow] = list(rows or [])
138 self._cards_per_row: int = _DEFAULT_COLUMNS
139 self._last_click_index: int | None = None
140 self._last_click_at: float = 0.0
142 @property
143 def rows(self) -> list[CatalogRow]:
144 """The dataset backing this grid (defensive copy)."""
145 return list(self._rows)
147 @property
148 def columns_per_row(self) -> int:
149 """Current column count, derived from the container width on resize."""
150 return self._cards_per_row
152 def set_rows(self, rows: list[CatalogRow]) -> None:
153 """Replace the dataset and reset the highlight."""
154 self._rows = list(rows)
155 self.highlighted = None
156 self.refresh(layout=True)
158 def on_resize(self) -> None:
159 new_cols = self._columns_for_width(self.size.width)
160 if new_cols != self._cards_per_row:
161 self._cards_per_row = new_cols
162 self.refresh(layout=True)
164 @staticmethod
165 def _columns_for_width(width: int) -> int:
166 if width <= 0:
167 return _DEFAULT_COLUMNS
168 return max(1, width // (_CARD_MIN_WIDTH + _CARD_GUTTER))
170 def _total_rows(self) -> int:
171 if not self._rows or self._cards_per_row <= 0:
172 return 0
173 return (len(self._rows) + self._cards_per_row - 1) // self._cards_per_row
175 def get_content_width(self, container: Size, viewport: Size) -> int:
176 return container.width
178 def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
179 if not self._rows:
180 return 0
181 cols = self._columns_for_width(width)
182 rows = (len(self._rows) + cols - 1) // cols
183 return rows * _ROW_HEIGHT
185 def watch_highlighted(self, _old: int | None, new: int | None) -> None:
186 """Repaint, scroll the cell into view, post Highlighted.
188 ModelGrid itself has ``height: auto`` so it isn't scrollable; the
189 outer ``#catalog-grid`` VerticalScroll is. We translate the cell's
190 local offset to the parent's doc coords (using ``virtual_region``)
191 and ask the parent to scroll. The Highlighted message lets the
192 catalog screen run keyboard-driven prefetch on every cursor move.
193 """
194 self.refresh()
195 if new is None or self._cards_per_row <= 0 or self.size.width <= 0:
196 return
197 self.post_message(self.Highlighted(self, new))
198 parent = self.parent
199 if not isinstance(parent, Widget):
200 return
201 col_width = max(1, self.size.width // self._cards_per_row)
202 row = new // self._cards_per_row
203 col = new % self._cards_per_row
204 grid_doc = self.virtual_region
205 parent.scroll_to_region(
206 Region(
207 grid_doc.x + col * col_width,
208 grid_doc.y + row * _ROW_HEIGHT,
209 col_width,
210 _CARD_HEIGHT,
211 ),
212 animate=False,
213 )
215 def on_focus(self) -> None:
216 """Auto-highlight first card on focus so Tab navigation has visible feedback."""
217 if self._rows and self.highlighted is None:
218 self.highlighted = 0
220 def on_blur(self) -> None:
221 # Mirrors toad's GridSelect: when the user crosses into a sibling grid,
222 # this grid's cursor goes away entirely instead of lingering as a
223 # blurred ghost. Otherwise stacked catalog sections show two cursors
224 # simultaneously and the user can't tell which grid owns focus.
225 self.highlighted = None
227 def action_cursor_up(self) -> None:
228 if self.highlighted is None:
229 self.highlighted = 0
230 return
231 if self.highlighted < self._cards_per_row:
232 self.post_message(self.LeaveUp(self))
233 return
234 self.highlighted = max(0, self.highlighted - self._cards_per_row)
236 def action_cursor_down(self) -> None:
237 if self.highlighted is None:
238 self.highlighted = 0
239 return
240 next_index = self.highlighted + self._cards_per_row
241 if next_index >= len(self._rows):
242 self.post_message(self.LeaveDown(self))
243 return
244 self.highlighted = next_index
246 def action_cursor_left(self) -> None:
247 if self.highlighted is None:
248 self.highlighted = 0
249 return
250 self.highlighted = max(0, self.highlighted - 1)
252 def action_cursor_right(self) -> None:
253 if self.highlighted is None:
254 self.highlighted = 0
255 return
256 self.highlighted = min(len(self._rows) - 1, self.highlighted + 1)
258 def action_select(self) -> None:
259 """Activate the highlighted card (post Selected with its row)."""
260 if self.highlighted is None or not self._rows:
261 return
262 if 0 <= self.highlighted < len(self._rows):
263 self.post_message(self.Selected(self, self._rows[self.highlighted]))
265 def highlight_first(self) -> None:
266 """Move highlight to the first card; mirrors the GridSelect surface."""
267 if self._rows:
268 self.highlighted = 0
270 def highlight_last(self) -> None:
271 """Move highlight to the last card; mirrors the GridSelect surface."""
272 if self._rows:
273 self.highlighted = len(self._rows) - 1
275 def _cell_at(self, x: int, y: int) -> int | None:
276 """Return the dataset index at terminal-local ``(x, y)`` or None."""
277 if not self._rows or self._cards_per_row <= 0:
278 return None
279 if y < 0:
280 return None
281 row = y // _ROW_HEIGHT
282 within_row = y - row * _ROW_HEIGHT
283 if within_row >= _CARD_HEIGHT:
284 return None
285 col_width = max(1, self.size.width // self._cards_per_row)
286 col = min(self._cards_per_row - 1, x // col_width)
287 index = row * self._cards_per_row + col
288 if index >= len(self._rows):
289 return None
290 return index
292 def on_click(self, event: events.Click) -> None:
293 """Single click only highlights; double-click on the same card posts Selected.
295 The 'second click on a highlighted card installs' rule the previous
296 catalog used was unsafe under the new auto-highlight-on-focus path:
297 a fresh focus pre-highlights index 0, so a single mouse click on
298 card 0 immediately fired Selected and started an install. Now we
299 require two clicks on the same card within ``_DOUBLE_CLICK_WINDOW_S``
300 to install; a stray click only highlights, matching the toad gesture
301 users expect.
302 """
303 index = self._cell_at(event.x, event.y)
304 if index is None:
305 return
306 now = time.monotonic()
307 is_double_click = (
308 self._last_click_index == index
309 and now - self._last_click_at <= self._DOUBLE_CLICK_WINDOW_S
310 )
311 self._last_click_index = index
312 self._last_click_at = now
313 if is_double_click:
314 self.post_message(self.Selected(self, self._rows[index]))
315 return
316 self.highlighted = index
317 self.focus()
319 def render_line(self, y: int) -> Strip:
320 """Compose one terminal line by stitching the per-column card slices."""
321 if y < 0:
322 return Strip.blank(self.size.width)
323 grid_row, line_within = divmod(y, _ROW_HEIGHT)
324 if grid_row >= self._total_rows() or line_within >= _CARD_HEIGHT:
325 return Strip.blank(self.size.width)
326 col_width = max(1, self.size.width // max(1, self._cards_per_row))
327 border_style = _FOCUSED_BORDER_STYLE if self.has_focus else _BLURRED_BORDER_STYLE
328 segments: list[Content] = []
329 for col in range(self._cards_per_row):
330 index = grid_row * self._cards_per_row + col
331 if index >= len(self._rows):
332 # Empty slot in a partial last row -> match screen surface.
333 segments.append(Content.styled(" " * col_width, _GAP_STYLE))
334 continue
335 row = self._rows[index]
336 selected = index == self.highlighted
337 card = _render_card_strip(
338 row, selected=selected, width=col_width, border_style=border_style
339 )
340 segments.append(card.lines[line_within])
341 joined = Content("").join(segments)
342 return Strip(joined.render_segments(Style.null())).simplify()
345_NAME_MAX_CHARS = 28
346"""Cap displayed model names so long refs don't blow up the grid layout."""
348_ELLIPSIS = "…"
351def _truncate_name(name: str) -> str:
352 if len(name) <= _NAME_MAX_CHARS:
353 return name
354 return name[: _NAME_MAX_CHARS - 1].rstrip() + _ELLIPSIS
357def _render_card_strip(
358 row: CatalogRow, *, selected: bool, width: int, border_style: str
359) -> _CardLines:
360 """Return the ``_CARD_HEIGHT`` content lines that make up one card slot.
362 Every card paints a ``$panel`` body fill plus a round box border in
363 ``_DEFAULT_BORDER_STYLE``; the selected card swaps the border color for
364 ``border_style`` (the focused / blurred token picked by ``render_line``).
365 The body is always panel-tinted so cards read as discrete tiles even on
366 dark themes.
367 """
368 body = (
369 _frontier_lines(row)
370 if row.kind == CatalogRowKind.FRONTIER
371 else _local_lines(row, selected=selected)
372 )
374 inner_width = max(3, width - _CARD_GUTTER)
375 body_width = inner_width - 2 # subtract the two side-border columns
376 # Gap between cards on the same row; theme-tinted so it reads as a card
377 # separator, not as raw black.
378 gap = Content.styled(" " * _CARD_GUTTER, _GAP_STYLE) if _CARD_GUTTER else Content("")
380 body_padded = [_pad_line(line, body_width) for line in body[:_CARD_BODY_HEIGHT]]
381 while len(body_padded) < _CARD_BODY_HEIGHT:
382 body_padded.append(Content(" " * body_width))
384 border_color = border_style if selected else _DEFAULT_BORDER_STYLE
385 top = Content.styled(
386 _BORDER_TOP_LEFT + _BORDER_HORIZONTAL * body_width + _BORDER_TOP_RIGHT,
387 border_color,
388 )
389 bottom = Content.styled(
390 _BORDER_BOTTOM_LEFT + _BORDER_HORIZONTAL * body_width + _BORDER_BOTTOM_RIGHT,
391 border_color,
392 )
393 side = Content.styled(_BORDER_VERTICAL, border_color)
395 framed = [top]
396 for line in body_padded:
397 # Wrap each padded body line in side bars, then layer the panel
398 # background across the whole inner_width so the body reads as a
399 # single tile (the bg covers any unstyled padding inside `_pad_line`).
400 wrapped = Content.assemble(side, line, side)
401 framed.append(wrapped.stylize_before(_CARD_BODY_STYLE))
402 framed.append(bottom)
404 return _CardLines(lines=[Content.assemble(line, gap) for line in framed])
407def _pad_line(content: Content, width: int) -> Content:
408 """Right-pad *content* to *width* columns with plain spaces."""
409 rendered_width = content.cell_length
410 if rendered_width >= width:
411 return content
412 return Content.assemble(content, Content(" " * (width - rendered_width)))
415def _local_lines(row: LocalCatalogRow, *, selected: bool) -> list[Content]:
416 from lilbee.cli.tui import messages as msg
418 bg = TASK_COLORS.get(row.task, "$primary")
419 name = Content.styled(_truncate_name(row.name), "bold")
420 pills: list[Content] = []
421 if row.featured:
422 pills.append(pill("pick", "$warning", "$text"))
423 pills.append(pill(row.task, bg, "$text"))
424 if row.fit is not None:
425 # Card uses the compact 'fits' / 'tight' / "won't run" label only;
426 # the headroom GB lives in the detail drawer where the wider pane
427 # can render it without competing for card width.
428 pills.append(_fit_pill_compact(row.fit))
429 # Drop the 'native' backend pill on cards to free horizontal space; the
430 # backend is implied for local models. Remote backends (ollama, etc.)
431 # still surface their pill since that's a meaningful distinction.
432 if row.backend and row.backend != "native":
433 pills.append(pill(row.backend, "$accent", "$text"))
434 pill_line = Content(" ").join(pills)
435 # Family card with multiple quants: replace the simple specs line
436 # with an inline chip strip so the user sees every available size
437 # at a glance without expanding into the drawer.
438 if len(row.size_variants) > 1:
439 specs = _build_size_variant_strip(row.size_variants)
440 else:
441 specs = _build_specs(row.params, row.quant, row.size)
442 status = _build_local_status(row)
443 lines: list[Content] = [name, pill_line, specs]
444 lines.append(status if status is not None else Content(""))
445 if selected:
446 hint = msg.INSTALLED_CARD_HINT if row.installed else msg.SETUP_CARD_HINT
447 lines.append(Content.styled(hint, "$text-muted 40% italic"))
448 else:
449 lines.append(Content(""))
450 return lines
453def _build_size_variant_strip(variants: list) -> Content:
454 """Inline chip strip showing every quant for a family-aggregated card.
456 Renders compact 'Q4 · Q5 · F16' style chips so the eye reads the
457 available sizes at a glance. Per-variant fit colors aren't applied
458 here; the drawer (right pane) carries the full fit-per-size detail
459 when a card is highlighted.
460 """
461 labels = [v.quant if v.quant != "--" else v.label for v in variants]
462 return Content.styled(f" {MIDDLE_DOT} ".join(labels), "$text-muted")
465def _frontier_lines(row: FrontierCatalogRow) -> list[Content]:
466 name = Content.styled(_truncate_name(row.name), "bold")
467 pill_line = Content(" ").join(
468 [pill(row.provider, "$accent", "$text"), _key_status_pill(row.key_status)]
469 )
470 info = Content.styled(f"Cloud via {row.provider} API", "$text-muted")
471 return [name, pill_line, info, Content(""), Content("")]
474_FIT_LEVEL_BACKGROUND: dict[FitLevel, str] = {
475 FitLevel.FITS: "$success",
476 FitLevel.TIGHT: "$warning",
477 FitLevel.WONT_RUN: "$error",
478}
481_FIT_LEVEL_LABEL_COMPACT: dict[FitLevel, str] = {
482 FitLevel.FITS: "fits",
483 FitLevel.TIGHT: "tight",
484 FitLevel.WONT_RUN: "won't run",
485}
488def _fit_pill(fit: FitChip) -> Content:
489 """Verbose fit chip with headroom GB, used by the detail drawer.
491 Headroom is signed; negative values mean the model overflows the host's
492 available memory by that much. The chip's background tracks the level so
493 colour-blind users still get the qualitative signal from the label itself.
494 Cards render the compact form (``fits`` / ``tight`` / ``won't run``)
495 via :func:`_fit_pill_compact` so the pill row fits the card width; the
496 headroom GB belongs in the wider drawer where it has room to breathe.
497 """
498 headroom_gb = fit.headroom_gb
499 if fit.level is FitLevel.FITS:
500 text = f"fits +{headroom_gb:.1f} GB"
501 elif fit.level is FitLevel.TIGHT:
502 text = f"tight +{max(0.0, headroom_gb):.1f} GB"
503 else:
504 text = f"won't {headroom_gb:.1f} GB"
505 return pill(text, _FIT_LEVEL_BACKGROUND[fit.level], "$text")
508def _fit_pill_compact(fit: FitChip) -> Content:
509 """Card-side compact fit chip: just ``fits`` / ``tight`` / ``won't run``."""
510 return pill(_FIT_LEVEL_LABEL_COMPACT[fit.level], _FIT_LEVEL_BACKGROUND[fit.level], "$text")
513def _key_status_pill(status: KeyStatus) -> Content:
514 if status == KeyStatus.READY:
515 return pill("ready", "$success", "$text")
516 return pill("needs key", "$warning", "$text")
519def _build_specs(params: str, quant: str, size: str) -> Content:
520 parts = [p for p in (params, quant, size) if p and p != "--"]
521 if not parts:
522 return Content("--")
523 return Content(f" {MIDDLE_DOT} ".join(parts))
526def _build_local_status(row: LocalCatalogRow) -> Content | None:
527 if row.installed:
528 return pill("installed", "$success", "$text")
529 if row.sort_downloads > 0:
530 return Content.styled(f"↓ {row.downloads}", "$text-muted")
531 return None