Coverage for src / lilbee / cli / tui / widgets / catalog_detail.py: 100%
91 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
1"""Right-pane detail drawer for the catalog screen.
3Focus-following: the catalog screen wires ``ModelGrid.Highlighted`` to
4``CatalogDetailDrawer.update_for_row``. The drawer renders the focused
5row's name, fit chip, every size variant with its per-variant fit, the
6license, and a description preview. Visibility toggles via the
7``-collapsed`` CSS class so width changes are a single layout pass.
8"""
10from __future__ import annotations
12from pathlib import Path
13from typing import ClassVar
15from textual.app import ComposeResult
16from textual.containers import Vertical
17from textual.content import Content
18from textual.widgets import Static
20from lilbee.catalog.types import ModelCompat
21from lilbee.cli.tui.pill import pill
22from lilbee.cli.tui.screens.catalog_utils import (
23 CatalogRow,
24 CatalogRowKind,
25 FrontierCatalogRow,
26 LocalCatalogRow,
27 SizeVariant,
28)
29from lilbee.runtime.hardware import FitChip, FitLevel
31_CSS_FILE = Path(__file__).parent / "catalog_detail.tcss"
33_FIT_LEVEL_BACKGROUND: dict[FitLevel, str] = {
34 FitLevel.FITS: "$success",
35 FitLevel.TIGHT: "$warning",
36 FitLevel.WONT_RUN: "$error",
37}
39_EMPTY_HINT = "Highlight a model to see details."
42class CatalogDetailDrawer(Vertical):
43 """Right-side panel that mirrors the highlighted catalog row.
45 Designed as a passive renderer: the screen calls update_for_row on
46 every ``ModelGrid.Highlighted`` event. There is no event subscription
47 inside the drawer so it stays test-friendly and decoupled from the
48 grid widget's message routing.
49 """
51 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") if _CSS_FILE.exists() else ""
53 def compose(self) -> ComposeResult:
54 yield Static(_EMPTY_HINT, id="catalog-detail-name", classes="catalog-detail-name")
55 yield Static("", id="catalog-detail-fit", classes="catalog-detail-fit")
56 yield Static("", id="catalog-detail-sizes", classes="catalog-detail-sizes")
57 yield Static("", id="catalog-detail-license", classes="catalog-detail-license")
58 yield Static("", id="catalog-detail-compat", classes="catalog-detail-compat")
59 yield Static("", id="catalog-detail-description", classes="catalog-detail-description")
61 def update_for_row(self, row: CatalogRow | None) -> None:
62 """Render the drawer for *row*; clearing back to the empty hint when None."""
63 if row is None:
64 self._clear()
65 return
66 if row.kind == CatalogRowKind.FRONTIER:
67 self._render_frontier(row)
68 return
69 self._render_local(row)
71 def _clear(self) -> None:
72 self.query_one("#catalog-detail-name", Static).update(_EMPTY_HINT)
73 for selector in (
74 "#catalog-detail-fit",
75 "#catalog-detail-sizes",
76 "#catalog-detail-license",
77 "#catalog-detail-compat",
78 "#catalog-detail-description",
79 ):
80 self.query_one(selector, Static).update("")
82 def _render_local(self, row: LocalCatalogRow) -> None:
83 self.query_one("#catalog-detail-name", Static).update(row.name)
84 fit_widget = self.query_one("#catalog-detail-fit", Static)
85 if row.fit is not None:
86 fit_widget.update(_render_fit_pill(row.fit))
87 else:
88 fit_widget.update("")
89 sizes = self.query_one("#catalog-detail-sizes", Static)
90 sizes.update(_render_sizes_block(row.size_variants))
91 license_widget = self.query_one("#catalog-detail-license", Static)
92 license_widget.update(_license_text(row))
93 compat_widget = self.query_one("#catalog-detail-compat", Static)
94 compat_widget.update(_compat_sentence(row))
95 description = self.query_one("#catalog-detail-description", Static)
96 description.update(_description_text(row))
98 def _render_frontier(self, row: FrontierCatalogRow) -> None:
99 self.query_one("#catalog-detail-name", Static).update(row.name)
100 self.query_one("#catalog-detail-fit", Static).update("")
101 self.query_one("#catalog-detail-sizes", Static).update("")
102 self.query_one("#catalog-detail-license", Static).update(f"Provider {row.provider}")
103 self.query_one("#catalog-detail-compat", Static).update("")
104 self.query_one("#catalog-detail-description", Static).update(
105 f"Cloud model accessed via the {row.provider} API."
106 )
109def _render_fit_pill(fit: FitChip) -> Content:
110 if fit.level is FitLevel.FITS:
111 text = f"fits +{fit.headroom_gb:.1f} GB"
112 elif fit.level is FitLevel.TIGHT:
113 text = f"tight +{max(0.0, fit.headroom_gb):.1f} GB"
114 else:
115 text = f"won't {fit.headroom_gb:.1f} GB"
116 return pill(text, _FIT_LEVEL_BACKGROUND[fit.level], "$text")
119def _render_sizes_block(variants: list[SizeVariant]) -> str:
120 """Multi-line plain-text listing of every variant the row carries."""
121 if not variants:
122 return ""
123 lines = ["Sizes"]
124 for v in variants:
125 suffix = ""
126 if v.fit is not None:
127 if v.fit.level is FitLevel.FITS:
128 suffix = " ✓"
129 elif v.fit.level is FitLevel.TIGHT:
130 suffix = " ⚠"
131 else:
132 suffix = " ✗"
133 lines.append(f" {v.label} {v.size_gb:.1f} GB{suffix}")
134 return "\n".join(lines)
137def _license_text(_row: LocalCatalogRow) -> str:
138 """License placeholder; CatalogModel/ModelFamily don't carry one yet.
140 Kept as a stub so callers have a stable seam: future plumbing for
141 per-row license strings (HF metadata fetch, family-level config) can
142 fill this in without touching the drawer's render path.
143 """
144 return ""
147def _compat_sentence(row: LocalCatalogRow) -> str:
148 """Build the architecture-compatibility sentence for the detail drawer."""
149 from lilbee.cli.tui import messages as msg
151 arch = row.catalog_model.architecture if row.catalog_model is not None else ""
152 arch_label = arch or "unknown"
153 template = {
154 ModelCompat.SUPPORTED: msg.COMPAT_DETAIL_SENTENCE_SUPPORTED,
155 ModelCompat.UNSUPPORTED: msg.COMPAT_DETAIL_SENTENCE_UNSUPPORTED,
156 ModelCompat.UNKNOWN: msg.COMPAT_DETAIL_SENTENCE_UNKNOWN,
157 }[row.compat]
158 return template.format(arch=arch_label) if "{arch}" in template else template
161def _description_text(row: LocalCatalogRow) -> str:
162 if row.catalog_model is not None and row.catalog_model.description:
163 return row.catalog_model.description
164 if row.family is not None and row.family.description:
165 return row.family.description
166 return ""