Coverage for src / lilbee / cli / tui / widgets / model_card.py: 100%
84 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"""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 KeyStatus,
31 LocalCatalogRow,
32)
33from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS
35if TYPE_CHECKING:
36 pass
38_CSS_FILE = Path(__file__).parent / "model_card.tcss"
40_NAME_MAX_CHARS = 28
41"""Maximum displayed model-name length; longer names are ellipsis-truncated."""
43_ELLIPSIS = "…"
46def _truncate_name(name: str) -> str:
47 """Return *name* shortened to ``_NAME_MAX_CHARS`` with an ellipsis tail."""
48 if len(name) <= _NAME_MAX_CHARS:
49 return name
50 return name[: _NAME_MAX_CHARS - 1].rstrip() + _ELLIPSIS
53class ModelCard(containers.VerticalGroup):
54 """A single model card displaying name, task pill, specs, and status."""
56 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
58 selected: reactive[bool] = reactive(False)
60 def __init__(self, row: CatalogRow) -> None:
61 self._row = row
62 super().__init__()
63 if row.kind == CatalogRowKind.FRONTIER:
64 self.add_class("-frontier")
66 @property
67 def row(self) -> CatalogRow:
68 return self._row
70 def watch_selected(self, selected: bool) -> None:
71 self.set_class(selected, "-selected")
72 # Re-render so the highlight-only hint appears / disappears.
73 # Cheap: one Static.update() per highlight move beats the
74 # CSS-driven display: none toggle on a separate Label widget.
75 try:
76 body = self.query_one(".card-body", widgets.Static)
77 except Exception:
78 return
79 body.update(_render(self._row, selected=selected))
81 def compose(self) -> ComposeResult:
82 yield widgets.Static(
83 _render(self._row, selected=self.selected),
84 classes="card-body",
85 markup=False,
86 )
89def _render(row: CatalogRow, *, selected: bool) -> Content:
90 """Compose the full card content (name + pills + specs + status + hint)."""
91 if row.kind == CatalogRowKind.FRONTIER:
92 return _render_frontier(row)
93 return _render_local(row, selected=selected)
96def _render_local(row: LocalCatalogRow, *, selected: bool) -> Content:
97 from lilbee.cli.tui import messages as msg
99 bg = TASK_COLORS.get(row.task, "$primary")
100 name = Content.styled(_truncate_name(row.name), "bold")
101 pills: list[Content] = []
102 if row.featured:
103 pills.append(pill("pick", "$warning", "$text"))
104 pills.append(pill(row.task, bg, "$text"))
105 if row.backend:
106 pills.append(pill(row.backend, "$accent", "$text"))
107 pill_line = Content(" ").join(pills)
108 specs = _build_specs(row.params, row.quant, row.size)
109 status = _build_local_status(row)
111 parts: list[Content] = [name, pill_line, specs]
112 if status is not None:
113 parts.append(status)
114 if selected:
115 hint = msg.INSTALLED_CARD_HINT if row.installed else msg.SETUP_CARD_HINT
116 parts.append(Content.styled(hint, "$text-muted 40% italic"))
117 return Content("\n").join(parts)
120def _render_frontier(row: FrontierCatalogRow) -> Content:
121 name = Content.styled(_truncate_name(row.name), "bold")
122 backend_pill = pill(row.provider, "$accent", "$text")
123 status_pill = _key_status_pill(row.key_status)
124 pill_line = Content(" ").join([backend_pill, status_pill])
125 info = Content.styled(f"Cloud via {row.provider} API", "$text-muted")
126 return Content("\n").join([name, pill_line, info])
129def _key_status_pill(status: KeyStatus) -> Content:
130 if status == KeyStatus.READY:
131 return pill("ready", "$success", "$text")
132 return pill("needs key", "$warning", "$text")
135def _build_specs(params: str, quant: str, size: str) -> Content:
136 """Build the specs line: params · quant · size."""
137 parts = [p for p in (params, quant, size) if p and p != "--"]
138 if not parts:
139 return Content("--")
140 return Content(f" {MIDDLE_DOT} ".join(parts))
143def _build_local_status(row: LocalCatalogRow) -> Content | None:
144 """Build the status pill for installed or download count."""
145 if row.installed:
146 return pill("installed", "$success", "$text")
147 if row.sort_downloads > 0:
148 return Content.styled(f"↓ {row.downloads}", "$text-muted")
149 return None