Coverage for src/lilbee/cli/tui/widgets/model_card.py: 100%
45 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"""ModelCard: compact card widget for the catalog grid view.
3Renders both ``LocalCatalogRow`` (installable / installed GGUFs) and
4``FrontierCatalogRow`` (cloud chat models) via type dispatch so the
5catalog can show a Frontier section above local sections without a
6second widget class.
8A card composes its name + pills + specs + status + (highlight-only)
9hint into a single ``Static`` rendering one ``Content`` object. This
10keeps the per-card widget count to two (the container + one Static)
11so the catalog's grid view can scale to thousands of rows without
12saturating the compositor with mount/reflow work.
13"""
15from __future__ import annotations
17from pathlib import Path
18from typing import TYPE_CHECKING, ClassVar
20from textual import containers, widgets
21from textual.app import ComposeResult
22from textual.content import Content
23from textual.reactive import reactive
25from lilbee.cli.tui.pill import pill
26from lilbee.cli.tui.screens.catalog_utils import (
27 CatalogRow,
28 CatalogRowKind,
29 FrontierCatalogRow,
30 LocalCatalogRow,
31)
32from lilbee.cli.tui.widgets.catalog_card_shared import (
33 _key_status_pill,
34 _local_card_lines,
35 _truncate_name,
36)
38if TYPE_CHECKING:
39 pass
41_CSS_FILE = Path(__file__).parent / "model_card.tcss"
44class ModelCard(containers.VerticalGroup):
45 """A single model card displaying name, task pill, specs, and status."""
47 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
49 selected: reactive[bool] = reactive(False)
51 def __init__(self, row: CatalogRow) -> None:
52 self._row = row
53 super().__init__()
54 if row.kind == CatalogRowKind.FRONTIER:
55 self.add_class("-frontier")
57 @property
58 def row(self) -> CatalogRow:
59 return self._row
61 def watch_selected(self, selected: bool) -> None:
62 self.set_class(selected, "-selected")
63 # Re-render so the highlight-only hint appears / disappears.
64 # Cheap: one Static.update() per highlight move beats the
65 # CSS-driven display: none toggle on a separate Label widget.
66 try:
67 body = self.query_one(".card-body", widgets.Static)
68 except Exception:
69 return
70 body.update(_render(self._row, selected=selected))
72 def compose(self) -> ComposeResult:
73 yield widgets.Static(
74 _render(self._row, selected=self.selected),
75 classes="card-body",
76 markup=False,
77 )
80def _render(row: CatalogRow, *, selected: bool) -> Content:
81 """Compose the full card content (name + pills + specs + status + hint)."""
82 if row.kind == CatalogRowKind.FRONTIER:
83 return _render_frontier(row)
84 return _render_local(row, selected=selected)
87def _render_local(row: LocalCatalogRow, *, selected: bool) -> Content:
88 """Auto-height presentation of the shared card slots: absent slots are omitted."""
89 slots = _local_card_lines(row, selected=selected)
90 return Content("\n").join([line for line in slots if line is not None])
93def _render_frontier(row: FrontierCatalogRow) -> Content:
94 name = Content.styled(_truncate_name(row.name), "bold")
95 backend_pill = pill(row.provider, "$accent", "$text")
96 status_pill = _key_status_pill(row.key_status)
97 pill_line = Content(" ").join([backend_pill, status_pill])
98 info = Content.styled(f"Cloud via {row.provider} API", "$text-muted")
99 return Content("\n").join([name, pill_line, info])
102# _local_card_lines / _key_status_pill / _truncate_name and the compat chip
103# live in catalog_card_shared.