Coverage for src/secchi/ui/widgets/sidebar.py: 22%
219 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
1"""Sidebar — favorites + all packages, keyboard-navigable."""
3from __future__ import annotations
5import logging
6from typing import ClassVar
8from textual.app import ComposeResult
9from textual.binding import Binding
10from textual.containers import Vertical, VerticalScroll
11from textual.message import Message
12from textual.widgets import Static
14from secchi.models import PackageRef, Project
15from secchi.spotlight import FALLBACK_SPOTLIGHT, Spotlight, spotlight_disabled
16from secchi.trending import FALLBACK_TRENDING, TrendingRepo
17from secchi.ui import palette
19logger = logging.getLogger(__name__)
22class SidebarItem(Static):
23 """A clickable/selectable package source showing name + registry + version."""
25 def __init__(self, ref: PackageRef) -> None:
26 self.ref = ref
27 self._version = ""
28 super().__init__(self._content())
29 self.can_focus = False
31 def set_version(self, version: str) -> None:
32 self._version = version
33 self.update(self._content())
35 def _content(self) -> str:
36 name = self.ref.name
37 display = name if len(name) <= 13 else name[:12] + "…"
38 version = self._version or "…"
39 registry = self.ref.registry.display_name
40 return f"{display:<13} [dim]{registry:<8} {version:>6}[/]"
42 def on_click(self) -> None:
43 self.post_message(Sidebar.PackageSelected(self.ref))
46class ProjectItem(Static):
47 """Clickable workspace project heading."""
49 def __init__(self, project: Project) -> None:
50 self.project = project
51 super().__init__()
52 self.can_focus = False
54 def on_click(self) -> None:
55 self.post_message(Sidebar.ProjectSelected(self.project))
58class Sidebar(Vertical):
59 """Left sidebar: PACKAGES (favorites + all)."""
61 can_focus = True
63 BINDINGS: ClassVar[list[Binding]] = [
64 Binding("up", "cursor_up", "Up", show=False),
65 Binding("down", "cursor_down", "Down", show=False),
66 Binding("enter", "select_cursor", "Select", show=False),
67 ]
69 class PackageSelected(Message):
70 """Emitted when a package is chosen (click or Enter). Bubbles to the app."""
72 def __init__(self, ref: PackageRef) -> None:
73 super().__init__()
74 self.ref = ref
76 class ProjectSelected(Message):
77 """Emitted when a workspace project heading is chosen."""
79 def __init__(self, project: Project) -> None:
80 super().__init__()
81 self.project = project
83 def __init__(self) -> None:
84 super().__init__()
85 self._items: dict[str, SidebarItem] = {}
86 self._order: list[str] = []
87 self._cursor: int = -1
88 self._favorites_only: bool = False
89 self._spotlight: Spotlight | None = (
90 None if spotlight_disabled() else FALLBACK_SPOTLIGHT
91 )
92 self._last_spotlight_markup: str = ""
93 self._trending: TrendingRepo | None = FALLBACK_TRENDING
94 self._last_trending_markup: str = ""
96 def compose(self) -> ComposeResult:
97 yield VerticalScroll(id="sidebar-list")
98 if self._spotlight is not None:
99 self._last_spotlight_markup = self._spotlight_markup()
100 promo = Static(self._last_spotlight_markup, classes="sidebar-promo")
101 promo.border_title = "SPOTLIGHT"
102 yield promo
103 if self._trending is not None:
104 self._last_trending_markup = self._trending_markup()
105 trending = Static(self._last_trending_markup, classes="sidebar-trending")
106 trending.border_title = "NEW & TRENDING THIS WEEK"
107 yield trending
109 def on_mount(self) -> None:
110 self._build()
112 # ── construction ──
114 def _build(self) -> None:
115 listing = self.query_one("#sidebar-list", VerticalScroll)
116 listing.remove_children()
117 self._items.clear()
118 self._order.clear()
120 app = self.app
121 if not hasattr(app, "project"):
122 return
123 project: Project = app.project
124 workspace = getattr(app, "workspace_projects", [])
126 if workspace:
127 listing.mount(Static("PROJECTS", classes="sidebar-title"))
128 projects = workspace
129 if self._favorites_only:
130 projects = [project for project in projects if project.favorite]
131 for project in projects:
132 star = f"[{palette.YELLOW}]★[/] " if project.favorite else ""
133 description = (
134 f" [dim]— {project.description[:24]}[/]"
135 if project.description
136 else ""
137 )
138 title = project.title or project.name
139 project_item = ProjectItem(project)
140 project_item.update(f"{star}[b]{title}[/]{description}")
141 project_item.add_class("sidebar-project")
142 listing.mount(project_item)
143 if project.repository_url:
144 listing.mount(
145 Static(
146 f" [dim]{project.repository_url[:38]}[/]",
147 classes="sidebar-project-repository",
148 )
149 )
150 for ref in project.packages:
151 self._add_item(ref, key_suffix=f"project:{project.name}")
152 self._refresh_versions()
153 self._highlight()
154 return
156 packages = getattr(app, "visible_packages", project.packages)
158 listing.mount(Static("PACKAGES", classes="sidebar-title"))
160 favorites = [r for r in packages if r.favorite]
161 all_pkgs = packages
163 if favorites:
164 listing.mount(
165 Static(
166 f"[{palette.YELLOW}]★ Favorites[/] [dim]({len(favorites)})[/]",
167 classes="sidebar-section",
168 )
169 )
170 for ref in favorites:
171 self._add_item(ref, key_suffix="fav")
173 listing.mount(
174 Static(
175 f"All Packages [dim]({len(all_pkgs)})[/]",
176 classes="sidebar-section sidebar-section--all",
177 )
178 )
179 shown = favorites if self._favorites_only else all_pkgs
180 for ref in shown:
181 self._add_item(ref, key_suffix="all")
183 self._refresh_versions()
184 self._highlight()
186 def _add_item(self, ref: PackageRef, key_suffix: str) -> None:
187 item = SidebarItem(ref)
188 order_key = f"{key_suffix}:{ref.registry.value}:{ref.name}"
189 self._items[order_key] = item
190 self._order.append(order_key)
191 self.query_one("#sidebar-list", VerticalScroll).mount(item)
193 # ── version population ──
195 def _refresh_versions(self) -> None:
196 app = self.app
197 data = getattr(app, "package_data", {})
198 for _order_key, item in self._items.items():
199 info = data.get(self._pkg_key(item.ref))
200 if info and info.latest_version:
201 item.set_version(info.latest_version)
203 def refresh_versions(self) -> None:
204 self._refresh_versions()
206 def set_spotlight(self, spotlight: Spotlight | None) -> None:
207 self._spotlight = None if spotlight_disabled() else spotlight
208 new_markup = self._spotlight_markup()
209 try:
210 promo = self.query_one(".sidebar-promo", Static)
211 except Exception:
212 # Dynamic sidebar cards may be updated before they are mounted.
213 logger.debug("Unable to find spotlight card", exc_info=True)
214 if self._spotlight is not None and self.is_mounted:
215 self._last_spotlight_markup = new_markup
216 promo = Static(new_markup, classes="sidebar-promo")
217 promo.border_title = "SPOTLIGHT"
218 self.mount(promo)
219 return
220 if self._spotlight is None:
221 promo.remove()
222 self._last_spotlight_markup = ""
223 elif new_markup != self._last_spotlight_markup:
224 self._last_spotlight_markup = new_markup
225 promo.update(new_markup)
227 def set_trending(self, trending: TrendingRepo | None) -> None:
228 self._trending = trending
229 new_markup = self._trending_markup()
230 try:
231 card = self.query_one(".sidebar-trending", Static)
232 except Exception:
233 # Dynamic sidebar cards may be updated before they are mounted.
234 logger.debug("Unable to find trending card", exc_info=True)
235 if self._trending is not None and self.is_mounted:
236 self._last_trending_markup = new_markup
237 card = Static(new_markup, classes="sidebar-trending")
238 card.border_title = "NEW & TRENDING THIS WEEK"
239 self.mount(card)
240 return
241 if self._trending is None:
242 card.remove()
243 self._last_trending_markup = ""
244 elif new_markup != self._last_trending_markup:
245 self._last_trending_markup = new_markup
246 card.update(new_markup)
248 # ── selection / highlight ──
250 def _pkg_key(self, ref: PackageRef) -> str:
251 project = f"{ref.project_name}:" if ref.project_name else ""
252 return f"{project}{ref.registry.value}:{ref.name}"
254 def select_package(self, ref: PackageRef) -> None:
255 target = self._pkg_key(ref)
256 for i, order_key in enumerate(self._order):
257 if self._pkg_key(self._items[order_key].ref) == target:
258 self._cursor = i
259 break
260 self._highlight()
262 def deselect_all(self) -> None:
263 self._cursor = -1
264 self._highlight()
266 def _highlight(self) -> None:
267 for i, order_key in enumerate(self._order):
268 item = self._items[order_key]
269 item.set_class(i == self._cursor, "sidebar-item--selected")
271 # ── actions ──
273 def action_cursor_down(self) -> None:
274 if not self._order:
275 return
276 self._cursor = min(self._cursor + 1, len(self._order) - 1)
277 self._highlight()
278 self._scroll_to_cursor()
280 def action_cursor_up(self) -> None:
281 if not self._order:
282 return
283 self._cursor = max(self._cursor - 1, 0)
284 self._highlight()
285 self._scroll_to_cursor()
287 def action_select_cursor(self) -> None:
288 if 0 <= self._cursor < len(self._order):
289 ref = self._items[self._order[self._cursor]].ref
290 self.post_message(self.PackageSelected(ref))
292 def _scroll_to_cursor(self) -> None:
293 if 0 <= self._cursor < len(self._order):
294 item = self._items[self._order[self._cursor]]
295 self.query_one("#sidebar-list", VerticalScroll).scroll_to_widget(
296 item, animate=False
297 )
299 def toggle_favorites_filter(self) -> None:
300 self._favorites_only = not self._favorites_only
301 self._build()
303 def _spotlight_markup(self) -> str:
304 if self._spotlight is None:
305 return ""
306 stars = (
307 f"[{palette.YELLOW}]★ {self._spotlight.stars}[/] "
308 if self._spotlight.stars is not None
309 else ""
310 )
311 return (
312 f"[b white]{self._spotlight.title}[/]\n"
313 f"[#94A3B8]{self._spotlight.description}[/]\n"
314 f"{stars}[dim]{self._spotlight.project_stage}[/]\n"
315 f"[#22D3EE]{self._spotlight.url}[/]"
316 )
318 def _trending_markup(self) -> str:
319 if self._trending is None:
320 return ""
321 lang = (
322 f" [{palette.PURPLE}]{self._trending.language}[/]"
323 if self._trending.language
324 else ""
325 )
326 return (
327 f"[b white]{self._trending.title}[/]{lang}\n"
328 f"[#94A3B8]{self._trending.description}[/]\n"
329 f"[#22D3EE]{self._trending.url}[/] "
330 f"[{palette.YELLOW}]★ {self._trending.stars}[/]"
331 )