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

1"""ModelListItem: single-row widget for the catalog list view. 

2 

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""" 

10 

11from __future__ import annotations 

12 

13from dataclasses import dataclass 

14from pathlib import Path 

15from typing import TYPE_CHECKING, ClassVar 

16 

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 

23 

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 

27 

28if TYPE_CHECKING: 

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

30 

31_CSS_FILE = Path(__file__).parent / "model_list_item.tcss" 

32 

33 

34class ModelListItem(containers.VerticalGroup, can_focus=True): 

35 """A single model row, rendered as two stacked lines.""" 

36 

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

38 

39 BINDINGS: ClassVar = [Binding("enter", "select", "Select", show=False)] 

40 

41 @dataclass 

42 class Selected(Message): 

43 """Posted when the user activates a list item via click or Enter.""" 

44 

45 item: ModelListItem 

46 

47 @property 

48 def control(self) -> ModelListItem: 

49 return self.item 

50 

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

52 self._row = row 

53 super().__init__() 

54 

55 @property 

56 def row(self) -> TableRow: 

57 return self._row 

58 

59 def action_select(self) -> None: 

60 self.post_message(self.Selected(self)) 

61 

62 def on_click(self, event: Click) -> None: 

63 event.stop() 

64 self.focus() 

65 self.post_message(self.Selected(self)) 

66 

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") 

75 

76 

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) 

90 

91 

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") 

98 

99 

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