Coverage for vibefpmgui/git_stats_view.py: 95%

295 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""The Git panel's *Stats* page (see `vibefpmgui.git_stats`): the 

2repository's numbers on tiles at the top, its people (their commits, share, 

3first and latest) on the left, and on the right a plot of each one's commits 

4so far over time, from the first commit to now (*Total*), or their commits 

5in each month as stacked bars (*Per month*: the month's commits, by whom, 

6when the mouse is over it). pyqtgraph: wheel zooms, drag pans, double-click 

7fits it again. The people with the most commits get a line (a color) each, 

8the rest one together; choosing people in the list highlights theirs. 

9""" 

10 

11from __future__ import annotations 

12 

13import os 

14import time 

15from bisect import bisect_right 

16from datetime import datetime 

17from itertools import pairwise 

18from typing import ClassVar 

19 

20import qtpy 

21 

22os.environ.setdefault("PYQTGRAPH_QT_LIB", qtpy.API_NAME) # (pyqtgraph: qtpy's Qt binding) 

23 

24import pyqtgraph as pg 

25from qtpy.QtCore import QEvent, Qt, Signal 

26from qtpy.QtGui import QBrush, QColor, QCursor, QFont, QPen 

27from qtpy.QtWidgets import ( 

28 QApplication, 

29 QButtonGroup, 

30 QGridLayout, 

31 QHBoxLayout, 

32 QLabel, 

33 QSplitter, 

34 QStackedLayout, 

35 QToolButton, 

36 QToolTip, 

37 QTreeWidget, 

38 QTreeWidgetItem, 

39 QVBoxLayout, 

40 QWidget, 

41) 

42 

43from . import git_stats as gst 

44from .build_outputs import human_size 

45 

46MOST = 8 # people with a line of their own (the rest: "Others", together) 

47OTHERS = "Others" 

48TOTAL, MONTHLY = "total", "monthly" # the plot: commits so far, or each month's (stacked bars) 

49MODE_TITLES = {TOTAL: "Total", MONTHLY: "Per month"} 

50MODE_TIPS = {TOTAL: "Each person's commits so far, over time", 

51 MONTHLY: "Everyone's commits in each month, stacked"} 

52PERSON_ROLE = Qt.UserRole + 1 # a person's row: their name 

53LINE_COLORS = { 

54 "light": ["#4c6ef5", "#e8590c", "#2f9e44", "#ae3ec9", "#e03131", "#1098ad", "#f08c00", "#5c940d", "#868e96"], 

55 "dark": ["#748ffc", "#ff922b", "#51cf66", "#da77f2", "#ff6b6b", "#3bc9db", "#fcc419", "#94d82d", "#adb5bd"], 

56} 

57 

58 

59def date_words(seconds: int) -> str: 

60 return datetime.fromtimestamp(seconds).strftime("%Y-%m-%d") 

61 

62 

63def steps(times: list[int], start: int, end: int) -> tuple[list[float], list[float]]: 

64 """A staircase of commits so far: 0 at ``start``, one more at each commit, 

65 level to ``end``.""" 

66 xs, ys = [float(start)], [0.0] 

67 for n, t in enumerate(times, 1): 

68 xs += [t, t] 

69 ys += [n - 1, n] 

70 xs.append(float(max(end, times[-1] if times else start))) 

71 ys.append(float(len(times))) 

72 return xs, ys 

73 

74 

75def lines(stats: gst.RepoStats) -> list[tuple[str, list[int]]]: 

76 """(who, their commits' dates) for each line: the people with the most 

77 commits, then the rest together.""" 

78 shown = [(a.name, a.times) for a in stats.authors[:MOST]] 

79 rest = sorted(t for a in stats.authors[MOST:] for t in a.times) 

80 if rest: 

81 shown.append((f"{OTHERS} ({len(stats.authors) - MOST} people)", rest)) 

82 return shown 

83 

84 

85def month_number(seconds: float) -> int: 

86 """Months since year 0 of a date (local time): consecutive months, consecutive numbers.""" 

87 when = datetime.fromtimestamp(seconds) 

88 return when.year * 12 + when.month - 1 

89 

90 

91def month_start(number: int) -> int: 

92 """When a month (`month_number`) starts (seconds since 1970).""" 

93 return int(datetime(number // 12, number % 12 + 1, 1).timestamp()) 

94 

95 

96def per_month(stats: gst.RepoStats, now: float) -> tuple[list[int], list[tuple[str, list[int]]]]: 

97 """(the months' edges: when each starts, and when the last ends; for each 

98 line of `lines`, its commits in each month), from the first commit's 

99 month to this one.""" 

100 first, last = month_number(stats.first), month_number(max(now, stats.last)) 

101 edges = [month_start(n) for n in range(first, last + 2)] 

102 counts = [] 

103 for who, times in lines(stats): 

104 months = [0] * (last - first + 1) 

105 for t in times: 

106 months[month_number(t) - first] += 1 

107 counts.append((who, months)) 

108 return edges, counts 

109 

110 

111class _Tile(QWidget): 

112 """A number, and what it is under it.""" 

113 

114 def __init__(self, caption: str): 

115 super().__init__() 

116 self.value = QLabel() 

117 self.caption = QLabel(caption) 

118 self.setObjectName("StatTile") 

119 self.setAttribute(Qt.WA_StyledBackground, True) 

120 layout = QVBoxLayout(self) 

121 layout.setContentsMargins(10, 6, 10, 6) 

122 layout.setSpacing(0) 

123 layout.addWidget(self.value) 

124 layout.addWidget(self.caption) 

125 

126 

127class GitStatsView(QWidget): 

128 mode_changed = Signal(str) # TOTAL or MONTHLY chosen above the plot 

129 reload_requested = Signal() # (the Git panel's pages' signal: see GitLogView; the stats have no options of their own) 

130 

131 TILES: ClassVar[list[tuple[str, str]]] = [("commits", "Commits"), ("people", "People"), ("age", "Age"), ("latest", "Latest commit"), 

132 ("recent", "Last 30 days"), ("branches", "Branches"), ("tags", "Tags"), ("files", "Files"), 

133 ("size", "Size (.git)")] 

134 

135 def __init__(self, parent=None): 

136 super().__init__(parent) 

137 self.stats: gst.RepoStats | None = None 

138 self.pal: dict = {} 

139 self.dark = False 

140 self.curves: dict[str, pg.PlotDataItem | pg.BarGraphItem] = {} # each line's (or bars'), by whom 

141 self.colors: dict[str, QColor] = {} 

142 self.mode = TOTAL 

143 self._stale = False # (the plot's to be drawn again when it's next shown: see _redraw) 

144 self.months: tuple[list[int], list[tuple[str, list[int]]]] | None = None # (Per month's: see per_month) 

145 self.tiles = {key: _Tile(caption) for key, caption in self.TILES} 

146 tiles = QWidget() 

147 grid = QGridLayout(tiles) 

148 grid.setContentsMargins(0, 0, 0, 0) 

149 grid.setSpacing(6) 

150 for n, (key, _caption) in enumerate(self.TILES): 

151 grid.addWidget(self.tiles[key], 0, n) 

152 

153 self.people = QTreeWidget() 

154 self.people.setHeaderLabels(["Person", "Commits", "Share", "First", "Latest"]) 

155 self.people.setRootIsDecorated(False) 

156 self.people.setUniformRowHeights(True) 

157 self.people.setSortingEnabled(False) 

158 self.people.setSelectionMode(QTreeWidget.ExtendedSelection) 

159 self.people.itemSelectionChanged.connect(self._highlight) 

160 self.people.viewport().installEventFilter(self) # (clicking the one chosen again: none) 

161 

162 self.plot = pg.PlotWidget(axisItems={"bottom": pg.DateAxisItem()}) 

163 self.plot.setMenuEnabled(False) 

164 self.plot.hideButtons() 

165 self.plot.showGrid(x=True, y=True, alpha=0.15) 

166 self.plot.setLabel("left", "Commits") 

167 self.plot.getViewBox().setMouseEnabled(x=True, y=True) 

168 self.plot.scene().sigMouseClicked.connect(self._clicked) 

169 self.plot.scene().sigMouseMoved.connect(self._hovered) 

170 self.legend = self.plot.addLegend(offset=(10, 10)) 

171 self.mode_buttons: dict[str, QToolButton] = {} 

172 group = QButtonGroup(self) 

173 modes = QHBoxLayout() 

174 modes.setContentsMargins(0, 0, 0, 0) 

175 modes.setSpacing(2) 

176 modes.addStretch(1) 

177 for mode in (TOTAL, MONTHLY): 

178 button = self.mode_buttons[mode] = QToolButton() 

179 button.setObjectName("GitView") # (as the panel's Diff and Stats) 

180 button.setText(MODE_TITLES[mode]) 

181 button.setToolTip(MODE_TIPS[mode]) 

182 button.setCheckable(True) 

183 button.setAutoRaise(True) 

184 button.clicked.connect(lambda _checked=False, m=mode: self.set_mode(m, user=True)) 

185 group.addButton(button) 

186 modes.addWidget(button) 

187 self.mode_buttons[TOTAL].setChecked(True) 

188 plot_side = QWidget() 

189 plot_layout = QVBoxLayout(plot_side) 

190 plot_layout.setContentsMargins(0, 0, 0, 0) 

191 plot_layout.setSpacing(4) 

192 plot_layout.addLayout(modes) 

193 plot_layout.addWidget(self.plot, 1) 

194 

195 self.splitter = QSplitter(Qt.Horizontal) 

196 self.splitter.addWidget(self.people) 

197 self.splitter.addWidget(plot_side) 

198 self.splitter.setStretchFactor(0, 0) 

199 self.splitter.setStretchFactor(1, 1) 

200 

201 content = QWidget() 

202 layout = QVBoxLayout(content) 

203 layout.setContentsMargins(0, 0, 0, 0) 

204 layout.setSpacing(8) 

205 layout.addWidget(tiles) 

206 layout.addWidget(self.splitter, 1) 

207 self.message = QLabel() 

208 self.message.setAlignment(Qt.AlignCenter) 

209 self.message.setWordWrap(True) 

210 self.stack = QStackedLayout(self) 

211 self.stack.addWidget(self.message) 

212 self.stack.addWidget(content) 

213 self.content = content 

214 self.show_message("Reading the repository…") 

215 

216 # ------------------------------------------------------------ styling 

217 def refresh_palette(self, pal: dict, dark: bool): 

218 """The panel's colors (`build_monitor.PALETTES`).""" 

219 self.pal, self.dark = pal, dark 

220 big = QFont(QApplication.font()) 

221 big.setPointSizeF(big.pointSizeF() + 4) 

222 big.setWeight(QFont.Bold) 

223 for tile in self.tiles.values(): 

224 tile.value.setFont(big) 

225 tile.caption.setStyleSheet(f"color: {pal['muted']};") 

226 self.setStyleSheet(f"#StatTile {{ background: {pal['hover']}; border-radius: 8px; }}" 

227 f"QTreeWidget {{ background: {pal['bg']}; border: 1px solid {pal['line']};" 

228 " border-radius: 6px; }") 

229 self.message.setStyleSheet(f"color: {pal['muted']};") 

230 self.plot.setBackground(pal["bg"]) 

231 for name in ("left", "bottom"): 

232 axis = self.plot.getAxis(name) 

233 axis.setPen(pal["muted"]) 

234 axis.setTextPen(pal["muted"]) 

235 self._label() 

236 self.legend.setLabelTextColor(pal["text"]) 

237 backdrop = QColor(pal["bg"]) 

238 backdrop.setAlphaF(0.75) # (a little of the plot shows through) 

239 self.legend.setBrush(backdrop) 

240 self._redraw() 

241 

242 def _label(self): 

243 self.plot.setLabel("left", "Commits a month" if self.mode == MONTHLY else "Commits", 

244 color=self.pal.get("muted")) 

245 

246 # -------------------------------------------------------------- state 

247 def set_mode(self, mode: str, user: bool = False): 

248 """The plot: TOTAL or MONTHLY (``user``: chosen with its buttons: said 

249 with mode_changed, to be remembered).""" 

250 mode = mode if mode in MODE_TITLES else TOTAL 

251 self.mode = mode 

252 self.mode_buttons[mode].setChecked(True) 

253 self._label() 

254 self._redraw() 

255 if user: 

256 self.mode_changed.emit(mode) 

257 

258 def _redraw(self): 

259 """The plot drawn again (new colors, another plot): now if it's on 

260 show, else when it's next shown.""" 

261 if self.stats is None: 

262 return 

263 if self.isVisible(): 

264 self._draw() 

265 else: 

266 self._stale = True 

267 

268 def showEvent(self, event): 

269 super().showEvent(event) 

270 if self._stale and self.stats is not None: 

271 self._draw() 

272 

273 # ------------------------------------------------------- read by the panel 

274 # (The Git panel's pages that read from the repository: see vibefpmgui.git_panel.) 

275 def reload_key(self, _root) -> tuple: 

276 return () 

277 

278 def reader(self, root): 

279 return lambda: gst.read_stats(root) 

280 

281 def show_result(self, stats: gst.RepoStats): 

282 self.show_stats(stats) 

283 

284 def show_message(self, text: str): 

285 self.stats = None 

286 self.message.setText(text) 

287 self.stack.setCurrentWidget(self.message) 

288 

289 def show_stats(self, stats: gst.RepoStats, now: float | None = None): 

290 """A repository's statistics.""" 

291 self.stats = stats 

292 now = time.time() if now is None else now 

293 total = stats.commits 

294 values = { 

295 "commits": (f"{total:,}", f"{stats.merges:,} of them merges"), 

296 "people": (f"{len(stats.authors):,}", "who made commits (by name)"), 

297 "age": (gst.span_words(now - stats.first), f"the first commit: {date_words(stats.first)}"), 

298 "latest": (date_words(stats.last), f"{gst.span_words(now - stats.last)} ago"), 

299 "recent": (f"{stats.since(30 * gst.DAY, now):,}", 

300 f"commits in the last 30 days; {stats.since(365 * gst.DAY, now):,} in the last year"), 

301 "branches": (f"{stats.branches:,}", f"local; {stats.remote_branches:,} remote"), 

302 "tags": (f"{stats.tags:,}", "tags"), 

303 "files": (f"{stats.files:,}", "files tracked by git"), 

304 "size": (human_size(stats.size), "the repository's history on disk"), 

305 } 

306 for key, (value, tip) in values.items(): 

307 self.tiles[key].value.setText(value) 

308 self.tiles[key].setToolTip(tip) 

309 

310 self.people.clear() 

311 for author in stats.authors: 

312 item = QTreeWidgetItem([author.name, f"{author.commits:,}", f"{100 * author.commits / total:.1f}%", 

313 date_words(author.first), date_words(author.last)]) 

314 item.setData(0, PERSON_ROLE, author.name) 

315 item.setToolTip(0, f"{author.name} <{author.email}>") 

316 for column in (1, 2): 

317 item.setTextAlignment(column, Qt.AlignRight | Qt.AlignVCenter) 

318 self.people.addTopLevelItem(item) 

319 for column in range(5): 

320 self.people.resizeColumnToContents(column) 

321 self.people.setColumnWidth(0, min(self.people.columnWidth(0), 260)) # (a very long name: cut) 

322 wanted = sum(self.people.columnWidth(c) for c in range(5)) + 24 # (all the columns, and a scroll bar) 

323 sizes = self.splitter.sizes() 

324 if sizes[0] < wanted: 324 ↛ 327line 324 didn't jump to line 327 because the condition on line 324 was always true

325 total = sum(sizes) if sum(sizes) > wanted else max(self.width(), wanted + 400) 

326 self.splitter.setSizes([wanted, max(total - wanted, 200)]) 

327 self._draw(now) 

328 self.stack.setCurrentWidget(self.content) 

329 

330 def _draw(self, now: float | None = None): 

331 self._stale = False 

332 stats = self.stats 

333 now = time.time() if now is None else now 

334 self.plot.clear() 

335 self.legend.clear() 

336 self.curves, self.colors, self.months = {}, {}, None 

337 palette = LINE_COLORS["dark" if self.dark else "light"] 

338 shown = lines(stats) 

339 for n, (who, _times) in enumerate(shown): 

340 self.colors[who] = QColor(palette[min(n, len(palette) - 1)]) 

341 if self.mode == MONTHLY: 

342 self._draw_months(now) 

343 else: 

344 start, end = stats.first, max(int(now), stats.last) 

345 for who, times in shown: 

346 xs, ys = steps(times, start, end) 

347 self.curves[who] = self.plot.plot(xs, ys, pen=pg.mkPen(self.colors[who], width=2), 

348 name=f"{who} · {len(times):,}") 

349 self.plot.enableAutoRange() 

350 self._highlight() 

351 

352 def _draw_months(self, now: float): 

353 """Each month's commits: a bar, its people's parts stacked (the most 

354 active at the bottom).""" 

355 edges, counts = self.months = per_month(self.stats, now) 

356 gap = 0.08 # (of a month: between the bars) 

357 x0 = [a + (b - a) * gap / 2 for a, b in pairwise(edges)] 

358 x1 = [b - (b - a) * gap / 2 for a, b in pairwise(edges)] 

359 bottom = [0] * len(x0) 

360 for who, months in counts: 

361 top = [b + c for b, c in zip(bottom, months)] 

362 bars = pg.BarGraphItem(x0=x0, x1=x1, y0=bottom, y1=top, pen=pg.mkPen(None), 

363 brush=QBrush(self.colors[who])) 

364 self.plot.addItem(bars) 

365 self.legend.addItem(bars, f"{who} · {sum(months):,}") 

366 self.curves[who] = bars 

367 bottom = top 

368 

369 def _highlight(self): 

370 """The chosen people's lines thicker, the others' faint (none chosen: all as they are).""" 

371 chosen = {item.data(0, PERSON_ROLE) for item in self.people.selectedItems()} 

372 for who, curve in self.curves.items(): 

373 mine = who in chosen or (who.startswith(OTHERS) and chosen - set(self.curves)) 

374 color = QColor(self.colors[who]) 

375 color.setAlphaF(1.0 if not chosen or mine else 0.25) 

376 if isinstance(curve, pg.BarGraphItem): 

377 curve.setOpts(brush=QBrush(color)) 

378 else: 

379 pen = QPen(color) 

380 pen.setWidthF(3.5 if chosen and mine else 2) 

381 pen.setCosmetic(True) 

382 curve.setPen(pen) 

383 

384 def eventFilter(self, obj, event): 

385 """A plain click on the only person chosen: unchoose them (all the 

386 lines as they were).""" 

387 if (obj is self.people.viewport() and event.type() == QEvent.MouseButtonPress 

388 and event.button() == Qt.LeftButton and event.modifiers() == Qt.NoModifier): 

389 item = self.people.itemAt(event.position().toPoint()) 

390 if item is not None and self.people.selectedItems() == [item]: 

391 self.people.clearSelection() 

392 return True 

393 return super().eventFilter(obj, event) 

394 

395 def month_text(self, x: float) -> str: 

396 """What a Per month bar holds (the one at ``x``, seconds since 1970): "" 

397 for none.""" 

398 if self.months is None: 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 return "" 

400 edges, counts = self.months 

401 index = bisect_right(edges, x) - 1 

402 if not 0 <= index < len(edges) - 1: 

403 return "" 

404 month = datetime.fromtimestamp(edges[index]).strftime("%B %Y") 

405 parts = [(who, months[index]) for who, months in counts if months[index]] 

406 total = sum(n for _who, n in parts) 

407 rows = "".join(f"<br>{who}: {n:,}" for who, n in sorted(parts, key=lambda p: -p[1])) 

408 return f"<b>{month}</b>: {total:,} commit{'s' * (total != 1)}{rows}" 

409 

410 def _hovered(self, pos): 

411 if self.mode != MONTHLY or not self.plot.sceneBoundingRect().contains(pos): 

412 return 

413 text = self.month_text(self.plot.getViewBox().mapSceneToView(pos).x()) 

414 if text: 

415 QToolTip.showText(QCursor.pos(), text, self.plot) 

416 else: 

417 QToolTip.hideText() 

418 

419 def _clicked(self, event): 

420 if event.double(): 

421 self.plot.enableAutoRange()