Coverage for src / lilbee / cli / tui / widgets / model_card.py: 100%

50 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-04-29 19:16 +0000

1"""ModelCard — compact card widget for the catalog grid view.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from typing import TYPE_CHECKING, ClassVar 

7 

8from textual import containers, widgets 

9from textual.app import ComposeResult 

10from textual.content import Content 

11from textual.reactive import reactive 

12 

13from lilbee.cli.tui.pill import pill 

14from lilbee.cli.tui.widgets.catalog_theme import MIDDLE_DOT, TASK_COLORS 

15 

16if TYPE_CHECKING: 

17 from lilbee.cli.tui.screens.catalog import TableRow 

18 

19_CSS_FILE = Path(__file__).parent / "model_card.tcss" 

20 

21 

22class ModelCard(containers.VerticalGroup): 

23 """A single model card displaying name, task pill, specs, and status.""" 

24 

25 # Widget CSS lives in model_card.tcss so it gets syntax highlighting and 

26 # matches the convention used for screens. Textual's Widget class only 

27 # supports DEFAULT_CSS (there is no widget-level CSS_PATH), so we load the 

28 # file once at import time. 

29 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8") 

30 

31 selected: reactive[bool] = reactive(False) 

32 

33 def __init__(self, row: TableRow) -> None: 

34 self._row = row 

35 super().__init__() 

36 

37 @property 

38 def row(self) -> TableRow: 

39 return self._row 

40 

41 def watch_selected(self, selected: bool) -> None: 

42 self.set_class(selected, "-selected") 

43 

44 def compose(self) -> ComposeResult: 

45 from lilbee.cli.tui import messages as msg 

46 

47 row = self._row 

48 bg = TASK_COLORS.get(row.task, "$primary") 

49 yield widgets.Label(row.name, id="card-name") 

50 with containers.HorizontalGroup(id="card-pills"): 

51 if row.featured: 

52 yield widgets.Label(pill("pick", "$warning", "$text"), id="card-pick") 

53 yield widgets.Label(pill(row.task, bg, "$text"), id="card-task") 

54 if row.backend: 

55 yield widgets.Label(pill(row.backend, "$accent", "$text"), id="card-backend") 

56 specs = _build_specs(row.params, row.quant, row.size) 

57 yield widgets.Label(specs, id="card-info") 

58 status = _build_status(row) 

59 if status is not None: 

60 yield widgets.Label(status, id="card-status") 

61 # Subtle "Enter to install" hint; CSS shows it only when the card 

62 # is highlighted (GridSelect cursor), hides for installed cards. 

63 if not row.installed: 

64 yield widgets.Label(msg.SETUP_CARD_HINT, id="card-hint") 

65 

66 

67def _build_specs(params: str, quant: str, size: str) -> Content: 

68 """Build the specs line: params · quant · size.""" 

69 parts = [p for p in (params, quant, size) if p and p != "--"] 

70 if not parts: 

71 return Content("--") 

72 return Content(f" {MIDDLE_DOT} ".join(parts)) 

73 

74 

75def _build_status(row: TableRow) -> Content | None: 

76 """Build the status pill for installed or download count.""" 

77 if row.installed: 

78 return pill("installed", "$success", "$text") 

79 if row.sort_downloads > 0: 

80 return Content.styled(f"{row.downloads}", "$text-muted") 

81 return None