Coverage for src / lilbee / cli / tui / widgets / catalog_detail.py: 100%
80 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +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.cli.tui.pill import pill
21from lilbee.cli.tui.screens.catalog_utils import (
22 CatalogRow,
23 CatalogRowKind,
24 FrontierCatalogRow,
25 LocalCatalogRow,
26 SizeVariant,
27)
28from lilbee.runtime.hardware import FitChip, FitLevel
30_CSS_FILE = Path(__file__).parent / "catalog_detail.tcss"
32_FIT_LEVEL_BACKGROUND: dict[FitLevel, str] = {
33 FitLevel.FITS: "$success",
34 FitLevel.TIGHT: "$warning",
35 FitLevel.WONT_RUN: "$error",
36}
38_EMPTY_HINT = "Highlight a model to see details."
41class CatalogDetailDrawer(Vertical):
42 """Right-side panel that mirrors the highlighted catalog row.
44 Designed as a passive renderer: the screen calls update_for_row on
45 every ``ModelGrid.Highlighted`` event. There is no event subscription
46 inside the drawer so it stays test-friendly and decoupled from the
47 grid widget's message routing.
48 """
50 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") if _CSS_FILE.exists() else ""
52 def compose(self) -> ComposeResult:
53 yield Static(_EMPTY_HINT, id="catalog-detail-name", classes="catalog-detail-name")
54 yield Static("", id="catalog-detail-fit", classes="catalog-detail-fit")
55 yield Static("", id="catalog-detail-sizes", classes="catalog-detail-sizes")
56 yield Static("", id="catalog-detail-license", classes="catalog-detail-license")
57 yield Static("", id="catalog-detail-description", classes="catalog-detail-description")
59 def update_for_row(self, row: CatalogRow | None) -> None:
60 """Render the drawer for *row*; clearing back to the empty hint when None."""
61 if row is None:
62 self._clear()
63 return
64 if row.kind == CatalogRowKind.FRONTIER:
65 self._render_frontier(row)
66 return
67 self._render_local(row)
69 def _clear(self) -> None:
70 self.query_one("#catalog-detail-name", Static).update(_EMPTY_HINT)
71 for selector in (
72 "#catalog-detail-fit",
73 "#catalog-detail-sizes",
74 "#catalog-detail-license",
75 "#catalog-detail-description",
76 ):
77 self.query_one(selector, Static).update("")
79 def _render_local(self, row: LocalCatalogRow) -> None:
80 self.query_one("#catalog-detail-name", Static).update(row.name)
81 fit_widget = self.query_one("#catalog-detail-fit", Static)
82 if row.fit is not None:
83 fit_widget.update(_render_fit_pill(row.fit))
84 else:
85 fit_widget.update("")
86 sizes = self.query_one("#catalog-detail-sizes", Static)
87 sizes.update(_render_sizes_block(row.size_variants))
88 license_widget = self.query_one("#catalog-detail-license", Static)
89 license_widget.update(_license_text(row))
90 description = self.query_one("#catalog-detail-description", Static)
91 description.update(_description_text(row))
93 def _render_frontier(self, row: FrontierCatalogRow) -> None:
94 self.query_one("#catalog-detail-name", Static).update(row.name)
95 self.query_one("#catalog-detail-fit", Static).update("")
96 self.query_one("#catalog-detail-sizes", Static).update("")
97 self.query_one("#catalog-detail-license", Static).update(f"Provider {row.provider}")
98 self.query_one("#catalog-detail-description", Static).update(
99 f"Cloud model accessed via the {row.provider} API."
100 )
103def _render_fit_pill(fit: FitChip) -> Content:
104 if fit.level is FitLevel.FITS:
105 text = f"fits +{fit.headroom_gb:.1f} GB"
106 elif fit.level is FitLevel.TIGHT:
107 text = f"tight +{max(0.0, fit.headroom_gb):.1f} GB"
108 else:
109 text = f"won't {fit.headroom_gb:.1f} GB"
110 return pill(text, _FIT_LEVEL_BACKGROUND[fit.level], "$text")
113def _render_sizes_block(variants: list[SizeVariant]) -> str:
114 """Multi-line plain-text listing of every variant the row carries."""
115 if not variants:
116 return ""
117 lines = ["Sizes"]
118 for v in variants:
119 suffix = ""
120 if v.fit is not None:
121 if v.fit.level is FitLevel.FITS:
122 suffix = " ✓"
123 elif v.fit.level is FitLevel.TIGHT:
124 suffix = " ⚠"
125 else:
126 suffix = " ✗"
127 lines.append(f" {v.label} {v.size_gb:.1f} GB{suffix}")
128 return "\n".join(lines)
131def _license_text(_row: LocalCatalogRow) -> str:
132 """License placeholder; CatalogModel/ModelFamily don't carry one yet.
134 Kept as a stub so callers have a stable seam: future plumbing for
135 per-row license strings (HF metadata fetch, family-level config) can
136 fill this in without touching the drawer's render path.
137 """
138 return ""
141def _description_text(row: LocalCatalogRow) -> str:
142 if row.catalog_model is not None and row.catalog_model.description:
143 return row.catalog_model.description
144 if row.family is not None and row.family.description:
145 return row.family.description
146 return ""