Coverage for src/secchi/ui/widgets/detail.py: 17%
234 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"""Detail view — the single main screen: header, stat cards, and tabs."""
3from __future__ import annotations
5from rich.text import Text
6from textual.app import ComposeResult
7from textual.containers import Container, Grid, Horizontal, VerticalScroll
8from textual.widgets import DataTable, Markdown, Static, TabbedContent, TabPane
10from secchi.models import DerivedPackageData, FetchError, PackageInfo, PackageRef
11from secchi.services.intelligence import SignalWarning
12from secchi.ui import palette
13from secchi.ui.widgets.bar import render_bar
14from secchi.ui.widgets.overview import OverviewTab
15from secchi.ui.widgets.stat_card import StatCard
16from secchi.utils import (
17 derive_github_repo,
18 format_age,
19 format_pct_delta,
20 shorten_bytes,
21 shorten_number,
22)
24# Kept in one place so additional package sources can be surfaced without
25# changing the project-card layout. Only ecosystems present in source_registries
26# are rendered today.
27ECOSYSTEM_ICONS: dict[str, str] = {
28 "crates.io": "🦀",
29 "pypi": "🐍",
30 "npm": "⬢",
31 "homebrew": "🍺",
32 "go": "🐹",
33 "cran": "📈",
34 "docker": "🐳",
35 "nix": "❄️",
36 "winget": "🪟",
37 "scoop": "📦",
38}
41class DetailView(Container):
42 """Rich single-package view — the app's only main-content screen."""
44 def __init__(
45 self,
46 ref: PackageRef,
47 info: PackageInfo | None,
48 error: FetchError | None,
49 derived: DerivedPackageData | None,
50 warnings: list[SignalWarning],
51 parent_app: object,
52 ) -> None:
53 super().__init__(id="detail-view")
54 self._ref = ref
55 self._info = info
56 self._error = error
57 self._derived = derived
58 self._warnings = warnings
59 self._app = parent_app
61 # ── layout ──
63 def compose(self) -> ComposeResult:
64 with VerticalScroll(id="detail-content"):
65 yield from self._compose_summary_row()
67 if self._warnings:
68 warning_lines = "\n".join(
69 f"• {warning.source}: {warning.message}"
70 for warning in self._warnings
71 )
72 yield Static(
73 f"[b yellow]Signal warnings[/]\n{warning_lines}",
74 id="signal-warnings",
75 )
77 if self._info and self._info.latest_version:
78 yield from self._compose_tabs()
79 elif self._error:
80 yield Static(f"[b red]Error[/]\n{self._error.message}", id="error-view")
81 else:
82 yield Static("Loading…", id="loading-view")
84 def _compose_summary_row(self) -> ComposeResult:
85 with Horizontal(id="summary-row"):
86 yield Static(self._build_project_card(), id="project-card")
87 if self._info and self._info.latest_version:
88 yield from self._compose_stat_cards()
90 def _build_project_card(self) -> str:
91 title = self._build_title()
92 if not self._info:
93 return title
95 info = self._info
96 registries = info.source_registries or [info.registry]
97 icons = " ".join(_registry_icon_label(registry) for registry in registries)
99 lines = [title]
100 if info.description:
101 lines.append(f"[#E5E7EB]{info.description[:96]}[/]")
103 repo = derive_github_repo([info.repository_url, info.homepage])
104 repo_str = (
105 f"github.com/{repo[0]}/{repo[1]}" if repo else (info.repository_url or "—")
106 )
107 docs_str = (
108 _short_url(info.documentation_url or info.homepage)
109 if (info.documentation_url or info.homepage)
110 else "—"
111 )
112 stars = (
113 shorten_number(info.github_stats.stars)
114 if info.github_stats.resolved
115 else "—"
116 )
117 latest = f"v{info.latest_version}" if info.latest_version else "—"
119 lines.append(
120 f"[dim]Latest[/] [{palette.GREEN}]{latest}[/] "
121 f"[dim]Stars[/] [{palette.YELLOW}]{stars}[/]"
122 )
123 lines.append(
124 f"[dim]Repo[/] [{palette.CYAN}]{repo_str}[/] "
125 f"[dim]Docs[/] [{palette.CYAN}]{docs_str}[/]"
126 )
128 size = _resolve_size(info)
129 meta: list[str] = []
130 if icons:
131 meta.append(f"[dim]Ecosystem[/] {icons}")
132 if info.license:
133 meta.append(f"[dim]License[/] [{palette.PURPLE}]{info.license}[/]")
134 if size:
135 meta.append(f"[dim]Size[/] {shorten_bytes(size)}")
136 if meta:
137 lines.append(" ".join(meta))
138 return "\n".join(lines)
140 def _compose_stat_cards(self) -> ComposeResult:
141 info = self._info
142 derived = self._derived
143 assert info is not None
145 total = derived.downloads_30d_total if derived else info.download_counts.month
146 pct = derived.downloads_30d_pct_change if derived else None
147 pct_text, pct_color = format_pct_delta(pct)
148 adoption_card = StatCard(
149 "ADOPTION",
150 shorten_number(total),
151 delta=pct_text,
152 signal=_trend_label(pct),
153 delta_color=pct_color,
154 signal_color=_trend_color(pct),
155 )
157 health = derived.health_score if derived else None
158 grade = health.grade if health else "—"
159 grade_color = _grade_color(grade)
160 health_delta, health_delta_color = _health_mom(derived)
161 health_card = StatCard(
162 "HEALTH",
163 f"{health.total} / 100" if health else "—",
164 delta=health_delta,
165 signal=_health_label(health.total) if health else "",
166 delta_color=health_delta_color,
167 signal_color=grade_color,
168 )
170 deps = derived.reverse_dependency_summary if derived else None
171 dep_growth = deps.monthly_growth if deps else None
172 dep_card = StatCard(
173 "DEPENDENTS",
174 shorten_number(deps.count) if deps and deps.count is not None else "—",
175 delta=_dependent_delta(dep_growth),
176 signal=_dependent_signal(dep_growth, deps.count if deps else None),
177 delta_color=_growth_color(dep_growth),
178 signal_color=_growth_color(dep_growth),
179 )
181 latest_pct = _latest_version_adoption(info, derived)
182 latest_card = StatCard(
183 "LATEST VERSION",
184 f"v{info.latest_version}" if info.latest_version else "—",
185 delta=f"{latest_pct:.0f}% adoption"
186 if latest_pct is not None
187 else "— adoption",
188 signal=_rollout_signal(latest_pct),
189 delta_color=_rollout_color(latest_pct),
190 signal_color=_rollout_color(latest_pct),
191 )
193 with Grid(id="stat-cards"):
194 yield adoption_card
195 yield health_card
196 yield dep_card
197 yield latest_card
199 def _compose_tabs(self) -> ComposeResult:
200 info = self._info
201 derived = self._derived
202 assert info is not None and derived is not None
203 with TabbedContent(id="detail-tabs"):
204 with TabPane("Overview", id="tab-overview"):
205 yield OverviewTab(info, derived)
206 with TabPane("Releases", id="tab-releases"):
207 yield self._releases_pane()
208 with TabPane("Dependencies", id="tab-deps"):
209 yield self._deps_pane()
210 with TabPane("Versions", id="tab-versions"):
211 yield self._versions_pane()
212 with TabPane("Activity", id="tab-activity"):
213 yield self._activity_pane()
215 # ── content builders ──
217 def _build_title(self) -> str:
218 star = f" [{palette.YELLOW}]★[/]" if self._ref.favorite else ""
219 name = self._ref.name
220 return f"[b {palette.GREEN}]{name}[/]{star}"
222 def _releases_pane(self) -> VerticalScroll:
223 info = self._info
224 derived = self._derived
225 assert info is not None and derived is not None
226 adoption = derived.release_adoption
227 lines: list[str] = []
228 for ver in info.versions[:30]:
229 pct = adoption.get(ver.version, 0.0)
230 bar = render_bar(pct / 100, width=12) if pct else ""
231 age = format_age(ver.release_date)
232 yanked = f" [{palette.RED}](yanked)[/]" if ver.is_yanked else ""
233 lines.append(
234 f"[{palette.GREEN}]{ver.version:<12}[/]{yanked} [dim]{age:<14}[/] {bar} "
235 f"[b]{pct:4.1f}%[/]"
236 )
237 body = "\n".join(lines) if lines else "[dim]No releases.[/]"
238 children: list = [Static(body, classes="pane-block")]
239 if info.release_notes:
240 children.append(Static("\n[b]Release Notes[/]\n", classes="pane-heading"))
241 children.append(Markdown(info.release_notes))
242 return VerticalScroll(*children, classes="tab-scroll")
244 def _deps_pane(self) -> VerticalScroll:
245 table = DataTable(cursor_type=None, zebra_stripes=True)
246 table.add_columns("Package", "Requirement", "Type")
247 for dep in self._info.dependencies if self._info else []:
248 table.add_row(
249 Text(dep.name),
250 dep.requirement or "—",
251 "optional" if dep.optional else "required",
252 )
253 if not (self._info and self._info.dependencies):
254 table.add_row(Text("—"), "no dependencies", "")
255 return VerticalScroll(table, classes="tab-scroll")
257 def _versions_pane(self) -> VerticalScroll:
258 table = DataTable(cursor_type=None, zebra_stripes=True)
259 table.add_columns("Version", "Released", "Downloads", "Size")
260 for ver in self._info.versions[:100] if self._info else []:
261 date_str = (
262 ver.release_date.strftime("%Y-%m-%d") if ver.release_date else "—"
263 )
264 label = f"(yanked) {ver.version}" if ver.is_yanked else ver.version
265 table.add_row(
266 Text(label, style=palette.RED if ver.is_yanked else ""),
267 date_str,
268 shorten_number(ver.downloads) if ver.downloads else "—",
269 shorten_bytes(ver.size_bytes),
270 )
271 return VerticalScroll(table, classes="tab-scroll")
273 def _activity_pane(self) -> VerticalScroll:
274 derived = self._derived
275 events = derived.activity if derived else []
276 lines: list[str] = []
277 for ev in events:
278 ref = f"[{palette.BLUE}]{ev.ref}[/]: " if ev.ref else ""
279 title = ev.title if len(ev.title) <= 70 else ev.title[:69] + "…"
280 lines.append(
281 f"[{palette.GREEN}]{ev.kind.icon}[/] [b]{ev.kind.label:<12}[/] {ref}{title}"
282 f" [dim]{format_age(ev.timestamp)}[/]"
283 )
284 body = "\n".join(lines) if lines else "[dim]No recent activity.[/]"
285 return VerticalScroll(Static(body, classes="pane-block"), classes="tab-scroll")
288# ── helpers ──
291def _grade_color(grade: str) -> str:
292 if grade in {"A", "B"}:
293 return palette.GREEN
294 if grade == "C":
295 return palette.HEALTH_FAIR
296 return palette.RED
299def _health_label(score: int) -> str:
300 if score >= 90:
301 return "Excellent"
302 if score >= 75:
303 return "Good"
304 if score >= 50:
305 return "Fair"
306 return "Needs Attention"
309def _trend_label(pct: float | None) -> str:
310 if pct is None or abs(pct) < 5:
311 return "Stable"
312 return "Growing" if pct > 0 else "Declining"
315def _trend_color(pct: float | None) -> str:
316 if pct is None or abs(pct) < 5:
317 return "dim"
318 return palette.GREEN if pct > 0 else palette.RED
321def _health_mom(derived: DerivedPackageData | None) -> tuple[str, str]:
322 points = derived.health_timeline if derived else []
323 if len(points) < 2:
324 return "— MoM", "dim"
325 delta = points[-1].value - points[-2].value
326 sign = "+" if delta > 0 else ""
327 color = palette.GREEN if delta >= 0 else palette.RED
328 return f"{sign}{delta} MoM", color
331def _dependent_delta(growth: int | None) -> str:
332 if growth is None:
333 return "— this month"
334 sign = "+" if growth >= 0 else ""
335 return f"{sign}{shorten_number(growth)} this month"
338def _dependent_signal(growth: int | None, count: int | None) -> str:
339 if count is None:
340 return "Unavailable"
341 if growth is None:
342 return "Tracking"
343 if growth > 0:
344 return "Accelerating"
345 if growth == 0:
346 return "Stable"
347 return "Contracting"
350def _growth_color(growth: int | None) -> str:
351 if growth is None or growth == 0:
352 return "dim"
353 return palette.GREEN if growth > 0 else palette.RED
356def _latest_version_adoption(
357 info: PackageInfo,
358 derived: DerivedPackageData | None,
359) -> float | None:
360 if not info.latest_version or not derived:
361 return None
362 return derived.release_adoption.get(info.latest_version)
365def _rollout_signal(pct: float | None) -> str:
366 if pct is None:
367 return "Unknown rollout"
368 if pct >= 50:
369 return "Healthy rollout"
370 if pct >= 25:
371 return "Mixed rollout"
372 return "Lagging rollout"
375def _rollout_color(pct: float | None) -> str:
376 if pct is None:
377 return "dim"
378 if pct >= 50:
379 return palette.GREEN
380 if pct >= 25:
381 return palette.HEALTH_FAIR
382 return palette.RED
385def _short_url(url: str) -> str:
386 return url.replace("https://", "").replace("http://", "").rstrip("/")
389def _resolve_size(info) -> int | None:
390 for v in info.versions:
391 if v.version == info.latest_version and v.size_bytes:
392 return v.size_bytes
393 if info.latest_release_files:
394 total = sum(f.size for f in info.latest_release_files)
395 if total:
396 return total
397 return None
400def _registry_icon_label(registry) -> str:
401 return ECOSYSTEM_ICONS.get(registry.value, registry.icon)