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

1"""Detail view — the single main screen: header, stat cards, and tabs.""" 

2 

3from __future__ import annotations 

4 

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 

9 

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) 

23 

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} 

39 

40 

41class DetailView(Container): 

42 """Rich single-package view — the app's only main-content screen.""" 

43 

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 

60 

61 # ── layout ── 

62 

63 def compose(self) -> ComposeResult: 

64 with VerticalScroll(id="detail-content"): 

65 yield from self._compose_summary_row() 

66 

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 ) 

76 

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

83 

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

89 

90 def _build_project_card(self) -> str: 

91 title = self._build_title() 

92 if not self._info: 

93 return title 

94 

95 info = self._info 

96 registries = info.source_registries or [info.registry] 

97 icons = " ".join(_registry_icon_label(registry) for registry in registries) 

98 

99 lines = [title] 

100 if info.description: 

101 lines.append(f"[#E5E7EB]{info.description[:96]}[/]") 

102 

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

118 

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 ) 

127 

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) 

139 

140 def _compose_stat_cards(self) -> ComposeResult: 

141 info = self._info 

142 derived = self._derived 

143 assert info is not None 

144 

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 ) 

156 

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 ) 

169 

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 ) 

180 

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 ) 

192 

193 with Grid(id="stat-cards"): 

194 yield adoption_card 

195 yield health_card 

196 yield dep_card 

197 yield latest_card 

198 

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

214 

215 # ── content builders ── 

216 

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

221 

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

243 

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

256 

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

272 

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

286 

287 

288# ── helpers ── 

289 

290 

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 

297 

298 

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" 

307 

308 

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" 

313 

314 

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 

319 

320 

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 

329 

330 

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" 

336 

337 

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" 

348 

349 

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 

354 

355 

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) 

363 

364 

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" 

373 

374 

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 

383 

384 

385def _short_url(url: str) -> str: 

386 return url.replace("https://", "").replace("http://", "").rstrip("/") 

387 

388 

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 

398 

399 

400def _registry_icon_label(registry) -> str: 

401 return ECOSYSTEM_ICONS.get(registry.value, registry.icon)