Coverage for src / lilbee / cli / tui / widgets / model_card.py: 100%
50 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""ModelCard — compact card widget for the catalog grid view."""
3from __future__ import annotations
5from pathlib import Path
6from typing import TYPE_CHECKING, ClassVar
8from textual import containers, widgets
9from textual.app import ComposeResult
10from textual.content import Content
11from textual.reactive import reactive
13from lilbee.cli.tui.pill import pill
14from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS
16if TYPE_CHECKING:
17 from lilbee.cli.tui.screens.catalog import TableRow
19_CSS_FILE = Path(__file__).parent / "model_card.tcss"
22class ModelCard(containers.VerticalGroup):
23 """A single model card displaying name, task pill, specs, and status."""
25 # Widget CSS lives in model_card.tcss so it gets syntax highlighting and
26 # matches the convention used for screens. Textual's Widget class only
27 # supports DEFAULT_CSS (there is no widget-level CSS_PATH), so we load the
28 # file once at import time.
29 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
31 selected: reactive[bool] = reactive(False)
33 def __init__(self, row: TableRow) -> None:
34 self._row = row
35 super().__init__()
37 @property
38 def row(self) -> TableRow:
39 return self._row
41 def watch_selected(self, selected: bool) -> None:
42 self.set_class(selected, "-selected")
44 def compose(self) -> ComposeResult:
45 from lilbee.cli.tui import messages as msg
47 row = self._row
48 bg = TASK_COLORS.get(row.task, "$primary")
49 yield widgets.Label(row.name, id="card-name")
50 with containers.HorizontalGroup(id="card-pills"):
51 if row.featured:
52 yield widgets.Label(pill("pick", "$warning", "$text"), id="card-pick")
53 yield widgets.Label(pill(row.task, bg, "$text"), id="card-task")
54 if row.backend:
55 yield widgets.Label(pill(row.backend, "$accent", "$text"), id="card-backend")
56 specs = _build_specs(row.params, row.quant, row.size)
57 yield widgets.Label(specs, id="card-info")
58 status = _build_status(row)
59 if status is not None:
60 yield widgets.Label(status, id="card-status")
61 # Subtle "Enter to install" hint; CSS shows it only when the card
62 # is highlighted (GridSelect cursor), hides for installed cards.
63 if not row.installed:
64 yield widgets.Label(msg.SETUP_CARD_HINT, id="card-hint")
67def _build_specs(params: str, quant: str, size: str) -> Content:
68 """Build the specs line: params · quant · size."""
69 parts = [p for p in (params, quant, size) if p and p != "--"]
70 if not parts:
71 return Content("--")
72 return Content(f" {MIDDLE_DOT} ".join(parts))
75def _build_status(row: TableRow) -> Content | None:
76 """Build the status pill for installed or download count."""
77 if row.installed:
78 return pill("installed", "$success", "$text")
79 if row.sort_downloads > 0:
80 return Content.styled(f"↓ {row.downloads}", "$text-muted")
81 return None