Coverage for src/lilbee/cli/tui/widgets/catalog_card_shared.py: 100%
95 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"""Rendering helpers shared by the catalog card, grid, and list widgets.
3The local-card body (name, pill lines, specs, status, hint) is built here
4once; ``model_grid`` renders the slots as a fixed-height card and
5``model_card`` as an auto-height card. ``model_list`` keeps its own two-line
6text presentation but draws its labels, colors, and spec strip from the same
7maps so the three surfaces cannot drift apart.
8"""
10from __future__ import annotations
12from textual.content import Content
14from lilbee.catalog.types import ModelCompat
15from lilbee.cli.tui import messages as msg
16from lilbee.cli.tui.pill import pill
17from lilbee.cli.tui.screens.catalog_utils import (
18 NATIVE_BACKEND,
19 KeyStatus,
20 LocalCatalogRow,
21 SizeVariant,
22)
23from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS
24from lilbee.runtime.hardware import FitChip, FitLevel
26_NAME_MAX_CHARS = 28
27_ELLIPSIS = "…"
29# Pill background per fit level, shared by the grid card and the detail drawer.
30_FIT_LEVEL_BACKGROUND: dict[FitLevel, str] = {
31 FitLevel.FITS: "$success",
32 FitLevel.TIGHT: "$warning",
33 FitLevel.WONT_RUN: "$error",
34}
36# Label and color per hosted-provider key status, shared by the cards and the list.
37KEY_STATUS_LABEL: dict[KeyStatus, str] = {
38 KeyStatus.READY: msg.KEY_STATUS_READY,
39 KeyStatus.MISSING_KEY: msg.KEY_STATUS_NEEDS_KEY,
40 KeyStatus.INVALID_KEY: msg.KEY_STATUS_KEY_REJECTED,
41}
43KEY_STATUS_COLOR: dict[KeyStatus, str] = {
44 KeyStatus.READY: "$success",
45 KeyStatus.MISSING_KEY: "$warning",
46 KeyStatus.INVALID_KEY: "$error",
47}
49_FIT_LEVEL_LABEL_COMPACT: dict[FitLevel, str] = {
50 FitLevel.FITS: "fits",
51 FitLevel.TIGHT: "tight",
52 FitLevel.WONT_RUN: "won't run",
53}
56def _render_fit_pill(fit: FitChip) -> Content:
57 """Verbose fit chip with signed headroom GB, used by the detail drawer.
59 Negative headroom means the model overflows available memory; the won't-run
60 label reports the shortfall as a positive amount.
61 """
62 if fit.level is FitLevel.FITS:
63 text = f"fits +{fit.headroom_gb:.1f} GB"
64 elif fit.level is FitLevel.TIGHT:
65 text = f"tight +{max(0.0, fit.headroom_gb):.1f} GB"
66 else:
67 text = f"won't run, short by {abs(fit.headroom_gb):.1f} GB"
68 return pill(text, _FIT_LEVEL_BACKGROUND[fit.level], "$text")
71def _fit_pill_compact(fit: FitChip) -> Content:
72 """Card-side compact fit chip: just ``fits`` / ``tight`` / ``won't run``."""
73 return pill(_FIT_LEVEL_LABEL_COMPACT[fit.level], _FIT_LEVEL_BACKGROUND[fit.level], "$text")
76def _compat_label(compat: ModelCompat) -> str | None:
77 """Compat text for non-SUPPORTED rows, or None for SUPPORTED."""
78 if compat is ModelCompat.SUPPORTED:
79 return None
80 if compat is ModelCompat.UNSUPPORTED:
81 return msg.COMPAT_PILL_UNSUPPORTED
82 return msg.COMPAT_PILL_UNKNOWN
85def _compat_pill(compat: ModelCompat) -> Content | None:
86 """Return the compat chip Content for non-SUPPORTED rows, or None for SUPPORTED."""
87 label = _compat_label(compat)
88 if label is None:
89 return None
90 if compat is ModelCompat.UNSUPPORTED:
91 return pill(label, "$warning", "$text")
92 return pill(label, "$panel", "$text-muted")
95def _truncate_name(name: str) -> str:
96 """Return *name* shortened to ``_NAME_MAX_CHARS`` with an ellipsis tail."""
97 if len(name) <= _NAME_MAX_CHARS:
98 return name
99 return name[: _NAME_MAX_CHARS - 1].rstrip() + _ELLIPSIS
102def _key_status_pill(status: KeyStatus) -> Content:
103 return pill(KEY_STATUS_LABEL[status], KEY_STATUS_COLOR[status], "$text")
106def _spec_strip(params: str, quant: str, size: str) -> str:
107 """Dot-joined spec fragments, skipping empty / placeholder values."""
108 parts = [p for p in (params, quant, size) if p and p != "--"]
109 return f" {MIDDLE_DOT} ".join(parts)
112def _build_specs(params: str, quant: str, size: str) -> Content:
113 """Build the specs line: params · quant · size."""
114 text = _spec_strip(params, quant, size)
115 return Content(text) if text else Content("--")
118def _build_local_status(row: LocalCatalogRow) -> Content | None:
119 """Build the status pill for installed or download count."""
120 if row.installed:
121 return pill("installed", "$success", "$text")
122 if row.sort_downloads > 0:
123 return Content.styled(f"↓ {row.downloads}", "$text-muted")
124 return None
127def _local_card_lines(
128 row: LocalCatalogRow, *, selected: bool, body_width: int | None = None
129) -> list[Content | None]:
130 """Semantic card slots: name, primary pills, secondary pills, specs, status, hint.
132 A slot is None when the row has nothing for it; the grid paints None as a
133 blank line (fixed-height card) while the wizard card omits the slot
134 (auto-height). *body_width* selects the grid presentation: the compact fit
135 pill and the inline size-variant strip, both sized to the card column.
136 """
137 bg = TASK_COLORS.get(row.task, "$primary")
138 name = Content.styled(_truncate_name(row.name), "bold")
139 # Two pill rows so wide secondary chips (fit + 'unsupported') don't push
140 # the card border out of alignment on narrow grid columns.
141 primary_pills: list[Content] = []
142 if row.featured:
143 primary_pills.append(pill("pick", "$warning", "$text"))
144 primary_pills.append(pill(row.task, bg, "$text"))
145 # Drop the 'native' backend pill on cards to free horizontal space; the
146 # backend is implied for local models. Remote backends (ollama, etc.)
147 # still surface their pill since that's a meaningful distinction.
148 if row.backend and row.backend != NATIVE_BACKEND:
149 primary_pills.append(pill(row.backend, "$accent", "$text"))
150 primary_line = Content(" ").join(primary_pills)
152 secondary_pills: list[Content] = []
153 if body_width is not None and row.fit is not None:
154 # Grid card uses the compact 'fits' / 'tight' / "won't run" label only;
155 # the headroom GB lives in the detail drawer where the wider pane
156 # can render it without competing for card width.
157 secondary_pills.append(_fit_pill_compact(row.fit))
158 compat_chip = _compat_pill(row.compat)
159 if compat_chip is not None:
160 secondary_pills.append(compat_chip)
161 secondary_line = Content(" ").join(secondary_pills) if secondary_pills else None
163 # Family card with multiple quants: replace the simple specs line
164 # with an inline chip strip so the user sees every available size
165 # at a glance without expanding into the drawer.
166 if body_width is not None and len(row.size_variants) > 1:
167 specs = _build_size_variant_strip(row.size_variants, body_width)
168 else:
169 specs = _build_specs(row.params, row.quant, row.size)
170 status = _build_local_status(row)
172 hint: Content | None = None
173 if selected:
174 hint_text = msg.INSTALLED_CARD_HINT if row.installed else msg.SETUP_CARD_HINT
175 hint = Content.styled(hint_text, "$text-muted 40% italic")
176 return [name, primary_line, secondary_line, specs, status, hint]
179def _build_size_variant_strip(variants: list[SizeVariant], width: int) -> Content:
180 """Inline chip strip showing the quants of a family-aggregated card.
182 Renders compact 'Q4 · Q5 · F16' style chips so the eye reads the
183 available sizes at a glance. A family that varies by parameter count
184 rather than quant would render identical chips, so colliding quants
185 fall back to the full per-variant label. Per-variant fit colors aren't
186 applied here; the drawer (right pane) carries the full fit-per-size
187 detail when a card is highlighted.
189 Duplicate labels collapse, then chips that do not fit *width* are dropped
190 and counted as ``+N``: a family can hold more variants than a card column
191 has room for, and the long fallback labels reach that limit quickly.
192 """
193 quants = [v.quant if v.quant != "--" else v.label for v in variants]
194 labels = quants if len(set(quants)) == len(quants) else [v.label for v in variants]
195 # Two repos in one family can share a parameter count and quant, which
196 # renders the same chip twice and reads as a bug.
197 labels = list(dict.fromkeys(labels))
198 sep = f" {MIDDLE_DOT} "
199 shown = len(labels)
200 while shown > 1:
201 text = sep.join(labels[:shown])
202 hidden = len(labels) - shown
203 if hidden:
204 text = f"{text} +{hidden}"
205 if len(text) <= width:
206 return Content.styled(text, "$text-muted")
207 shown -= 1
208 return Content.styled(labels[0][:width] if labels else "", "$text-muted")