Coverage for src/lilbee/cli/tui/widgets/catalog_detail.py: 100%
82 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"""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(_EMPTY_HINT, id="catalog-detail-name", classes="catalog-detail-name")
48 yield Static("", id="catalog-detail-fit", classes="catalog-detail-fit")
49 yield Static("", id="catalog-detail-sizes", classes="catalog-detail-sizes")
50 yield Static("", id="catalog-detail-license", classes="catalog-detail-license")
51 yield Static("", id="catalog-detail-compat", classes="catalog-detail-compat")
52 yield Static("", id="catalog-detail-description", classes="catalog-detail-description")
54 def update_for_row(self, row: CatalogRow | None) -> None:
55 """Render the drawer for *row*; clearing back to the empty hint when None."""
56 if row is None:
57 self._clear()
58 return
59 if row.kind == CatalogRowKind.FRONTIER:
60 self._render_frontier(row)
61 return
62 self._render_local(row)
64 def _clear(self) -> None:
65 self.query_one("#catalog-detail-name", Static).update(_EMPTY_HINT)
66 for selector in (
67 "#catalog-detail-fit",
68 "#catalog-detail-sizes",
69 "#catalog-detail-license",
70 "#catalog-detail-compat",
71 "#catalog-detail-description",
72 ):
73 self.query_one(selector, Static).update("")
75 def _render_local(self, row: LocalCatalogRow) -> None:
76 self.query_one("#catalog-detail-name", Static).update(row.name)
77 fit_widget = self.query_one("#catalog-detail-fit", Static)
78 if row.fit is not None:
79 fit_widget.update(_render_fit_pill(row.fit))
80 else:
81 fit_widget.update("")
82 sizes = self.query_one("#catalog-detail-sizes", Static)
83 sizes.update(_render_sizes_block(row.size_variants))
84 license_widget = self.query_one("#catalog-detail-license", Static)
85 license_widget.update(_license_text(row))
86 compat_widget = self.query_one("#catalog-detail-compat", Static)
87 compat_widget.update(_compat_sentence(row))
88 description = self.query_one("#catalog-detail-description", Static)
89 description.update(_description_text(row))
91 def _render_frontier(self, row: FrontierCatalogRow) -> None:
92 self.query_one("#catalog-detail-name", Static).update(row.name)
93 self.query_one("#catalog-detail-fit", Static).update("")
94 self.query_one("#catalog-detail-sizes", Static).update("")
95 self.query_one("#catalog-detail-license", Static).update(f"Provider {row.provider}")
96 self.query_one("#catalog-detail-compat", Static).update("")
97 self.query_one("#catalog-detail-description", Static).update(
98 f"Cloud model accessed via the {row.provider} API."
99 )
102def _render_sizes_block(variants: list[SizeVariant]) -> str:
103 """Multi-line plain-text listing of every variant the row carries."""
104 if not variants:
105 return ""
106 lines = ["Sizes"]
107 for v in variants:
108 suffix = ""
109 if v.fit is not None:
110 if v.fit.level is FitLevel.FITS:
111 suffix = " ✓"
112 elif v.fit.level is FitLevel.TIGHT:
113 suffix = " ⚠"
114 else:
115 suffix = " ✗"
116 lines.append(f" {v.label} {v.size_gb:.1f} GB{suffix}")
117 return "\n".join(lines)
120def _license_text(_row: LocalCatalogRow) -> str:
121 """License placeholder; CatalogModel/ModelFamily don't carry one yet.
123 Kept as a stub so callers have a stable seam: future plumbing for
124 per-row license strings (HF metadata fetch, family-level config) can
125 fill this in without touching the drawer's render path.
126 """
127 return ""
130def _compat_sentence(row: LocalCatalogRow) -> str:
131 """Build the architecture-compatibility sentence for the detail drawer."""
132 from lilbee.cli.tui import messages as msg
134 arch = row.catalog_model.architecture if row.catalog_model is not None else ""
135 arch_label = arch or "unknown"
136 template = {
137 ModelCompat.SUPPORTED: msg.COMPAT_DETAIL_SENTENCE_SUPPORTED,
138 ModelCompat.UNSUPPORTED: msg.COMPAT_DETAIL_SENTENCE_UNSUPPORTED,
139 ModelCompat.UNKNOWN: msg.COMPAT_DETAIL_SENTENCE_UNKNOWN,
140 }[row.compat]
141 return template.format(arch=arch_label) if "{arch}" in template else template
144def _description_text(row: LocalCatalogRow) -> str:
145 if row.catalog_model is not None and row.catalog_model.description:
146 return row.catalog_model.description
147 if row.family is not None and row.family.description:
148 return row.family.description
149 return ""