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