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
« 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"""
11from __future__ import annotations
13import os
14import time
15from bisect import bisect_right
16from datetime import datetime
17from itertools import pairwise
18from typing import ClassVar
20import qtpy
22os.environ.setdefault("PYQTGRAPH_QT_LIB", qtpy.API_NAME) # (pyqtgraph: qtpy's Qt binding)
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)
43from . import git_stats as gst
44from .build_outputs import human_size
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}
59def date_words(seconds: int) -> str:
60 return datetime.fromtimestamp(seconds).strftime("%Y-%m-%d")
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
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
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
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())
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
111class _Tile(QWidget):
112 """A number, and what it is under it."""
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)
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)
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)")]
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)
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)
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)
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)
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…")
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()
242 def _label(self):
243 self.plot.setLabel("left", "Commits a month" if self.mode == MONTHLY else "Commits",
244 color=self.pal.get("muted"))
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)
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
268 def showEvent(self, event):
269 super().showEvent(event)
270 if self._stale and self.stats is not None:
271 self._draw()
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 ()
278 def reader(self, root):
279 return lambda: gst.read_stats(root)
281 def show_result(self, stats: gst.RepoStats):
282 self.show_stats(stats)
284 def show_message(self, text: str):
285 self.stats = None
286 self.message.setText(text)
287 self.stack.setCurrentWidget(self.message)
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)
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)
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()
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
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)
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)
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}"
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()
419 def _clicked(self, event):
420 if event.double():
421 self.plot.enableAutoRange()