Coverage for src/lilbee/cli/tui/widgets/catalog_detail.py: 100%
82 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"""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.widgets import Static
19from lilbee.catalog.types import ModelCompat
20from lilbee.cli.tui.screens.catalog_utils import (
21 CatalogRow,
22 CatalogRowKind,
23 FrontierCatalogRow,
24 LocalCatalogRow,
25 SizeVariant,
26)
27from lilbee.cli.tui.widgets.catalog_card_shared import _render_fit_pill
28from lilbee.runtime.hardware import FitLevel
30_CSS_FILE = Path(__file__).parent / "catalog_detail.tcss"
32_EMPTY_HINT = "Highlight a model to see details."
35class CatalogDetailDrawer(Vertical):
36 """Right-side panel that mirrors the highlighted catalog row.
38 Designed as a passive renderer: the screen calls update_for_row on
39 every ``ModelGrid.Highlighted`` event. There is no event subscription
40 inside the drawer so it stays test-friendly and decoupled from the
41 grid widget's message routing.
42 """
44 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") if _CSS_FILE.exists() else ""
46 def compose(self) -> ComposeResult:
47 yield Static(
48 _EMPTY_HINT, id="catalog-detail-name", classes="catalog-detail-name", markup=False
49 )
50 yield Static("", id="catalog-detail-fit", classes="catalog-detail-fit")
51 yield Static("", id="catalog-detail-sizes", classes="catalog-detail-sizes")
52 yield Static(
53 "", id="catalog-detail-license", classes="catalog-detail-license", markup=False
54 )
55 yield Static("", id="catalog-detail-compat", classes="catalog-detail-compat", markup=False)
56 yield Static(
57 "",
58 id="catalog-detail-description",
59 classes="catalog-detail-description",
60 markup=False,
61 )
63 def update_for_row(self, row: CatalogRow | None) -> None:
64 """Render the drawer for *row*; clearing back to the empty hint when None."""
65 if row is None:
66 self._clear()
67 return
68 if row.kind == CatalogRowKind.FRONTIER:
69 self._render_frontier(row)
70 return
71 self._render_local(row)
73 def _clear(self) -> None:
74 self.query_one("#catalog-detail-name", Static).update(_EMPTY_HINT)
75 for selector in (
76 "#catalog-detail-fit",
77 "#catalog-detail-sizes",
78 "#catalog-detail-license",
79 "#catalog-detail-compat",
80 "#catalog-detail-description",
81 ):
82 self.query_one(selector, Static).update("")
84 def _render_local(self, row: LocalCatalogRow) -> None:
85 self.query_one("#catalog-detail-name", Static).update(row.name)
86 fit_widget = self.query_one("#catalog-detail-fit", Static)
87 if row.fit is not None:
88 fit_widget.update(_render_fit_pill(row.fit))
89 else:
90 fit_widget.update("")
91 sizes = self.query_one("#catalog-detail-sizes", Static)
92 sizes.update(_render_sizes_block(row.size_variants))
93 license_widget = self.query_one("#catalog-detail-license", Static)
94 license_widget.update(_license_text(row))
95 compat_widget = self.query_one("#catalog-detail-compat", Static)
96 compat_widget.update(_compat_sentence(row))
97 description = self.query_one("#catalog-detail-description", Static)
98 description.update(_description_text(row))
100 def _render_frontier(self, row: FrontierCatalogRow) -> None:
101 self.query_one("#catalog-detail-name", Static).update(row.name)
102 self.query_one("#catalog-detail-fit", Static).update("")
103 self.query_one("#catalog-detail-sizes", Static).update("")
104 self.query_one("#catalog-detail-license", Static).update(f"Provider {row.provider}")
105 self.query_one("#catalog-detail-compat", Static).update("")
106 self.query_one("#catalog-detail-description", Static).update(
107 f"Cloud model accessed via the {row.provider} API."
108 )
111def _render_sizes_block(variants: list[SizeVariant]) -> str:
112 """Multi-line plain-text listing of every variant the row carries."""
113 if not variants:
114 return ""
115 lines = ["Sizes"]
116 for v in variants:
117 suffix = ""
118 if v.fit is not None:
119 if v.fit.level is FitLevel.FITS:
120 suffix = " ✓"
121 elif v.fit.level is FitLevel.TIGHT:
122 suffix = " ⚠"
123 else:
124 suffix = " ✗"
125 lines.append(f" {v.label} {v.size_gb:.1f} GB{suffix}")
126 return "\n".join(lines)
129def _license_text(_row: LocalCatalogRow) -> str:
130 """License placeholder; CatalogModel/ModelFamily don't carry one yet.
132 Kept as a stub so callers have a stable seam: future plumbing for
133 per-row license strings (HF metadata fetch, family-level config) can
134 fill this in without touching the drawer's render path.
135 """
136 return ""
139def _compat_sentence(row: LocalCatalogRow) -> str:
140 """Build the architecture-compatibility sentence for the detail drawer."""
141 from lilbee.cli.tui import messages as msg
143 arch = row.catalog_model.architecture if row.catalog_model is not None else ""
144 arch_label = arch or "unknown"
145 template = {
146 ModelCompat.SUPPORTED: msg.COMPAT_DETAIL_SENTENCE_SUPPORTED,
147 ModelCompat.UNSUPPORTED: msg.COMPAT_DETAIL_SENTENCE_UNSUPPORTED,
148 ModelCompat.UNKNOWN: msg.COMPAT_DETAIL_SENTENCE_UNKNOWN,
149 }[row.compat]
150 return template.format(arch=arch_label) if "{arch}" in template else template
153def _description_text(row: LocalCatalogRow) -> str:
154 if row.catalog_model is not None and row.catalog_model.description:
155 return row.catalog_model.description
156 if row.family is not None and row.family.description:
157 return row.family.description
158 return ""