Coverage for src / lilbee / cli / tui / widgets / model_list_item.py: 100%
66 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""ModelListItem: single-row widget for the catalog list view.
3The list view mirrors the grid view's visual language (pills, dim specs)
4but at one row per model instead of cards in a grid. Each item renders
5two lines: bold name plus task/backend/featured pills on line 1, dim
6`params | quant | size` plus `downloads` or `installed` pill on line 2.
7Focusable so the screen can drive keyboard navigation with j/k/g/G.
8Clicking or pressing enter posts a Selected message the screen catches.
9"""
11from __future__ import annotations
13from dataclasses import dataclass
14from pathlib import Path
15from typing import TYPE_CHECKING, ClassVar
17from textual import containers, widgets
18from textual.app import ComposeResult
19from textual.binding import Binding
20from textual.content import Content
21from textual.events import Click
22from textual.message import Message
24from lilbee.cli.tui.pill import pill
25from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS
26from lilbee.models import FEATURED_STAR
28if TYPE_CHECKING:
29 from lilbee.cli.tui.screens.catalog import TableRow
31_CSS_FILE = Path(__file__).parent / "model_list_item.tcss"
34class ModelListItem(containers.VerticalGroup, can_focus=True):
35 """A single model row, rendered as two stacked lines."""
37 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
39 BINDINGS: ClassVar = [Binding("enter", "select", "Select", show=False)]
41 @dataclass
42 class Selected(Message):
43 """Posted when the user activates a list item via click or Enter."""
45 item: ModelListItem
47 @property
48 def control(self) -> ModelListItem:
49 return self.item
51 def __init__(self, row: TableRow) -> None:
52 self._row = row
53 super().__init__()
55 @property
56 def row(self) -> TableRow:
57 return self._row
59 def action_select(self) -> None:
60 self.post_message(self.Selected(self))
62 def on_click(self, event: Click) -> None:
63 event.stop()
64 self.focus()
65 self.post_message(self.Selected(self))
67 def compose(self) -> ComposeResult:
68 row = self._row
69 yield widgets.Static(_build_head(row), id="list-head")
70 with containers.HorizontalGroup(id="list-meta"):
71 yield widgets.Label(_build_specs(row.params, row.quant, row.size), id="list-specs")
72 status = _build_status(row)
73 if status is not None:
74 yield widgets.Label(status, id="list-status")
77def _build_head(row: TableRow) -> Content:
78 """Bold name plus task/backend pills, featured-star prefix if applicable."""
79 bg = TASK_COLORS.get(row.task, "$primary")
80 parts: list[Content] = []
81 if row.featured:
82 parts.append(Content.styled(f"{FEATURED_STAR} ", "$warning"))
83 parts.append(Content.styled(row.name, "bold"))
84 parts.append(Content(" "))
85 parts.append(pill(row.task, bg, "$text"))
86 if row.backend:
87 parts.append(Content(" "))
88 parts.append(pill(row.backend, "$accent", "$text"))
89 return Content.assemble(*parts)
92def _build_specs(params: str, quant: str, size: str) -> Content:
93 """Build the specs line: params middle-dot quant middle-dot size."""
94 parts = [p for p in (params, quant, size) if p and p != "--"]
95 if not parts:
96 return Content.styled("--", "$text-muted")
97 return Content.styled(f" {MIDDLE_DOT} ".join(parts), "$text-muted")
100def _build_status(row: TableRow) -> Content | None:
101 """Build the status pill for installed or download count."""
102 if row.installed:
103 return pill("installed", "$success", "$text")
104 if row.sort_downloads > 0:
105 return Content.styled(f"↓ {row.downloads}", "$text-muted")
106 return None