vibefpmgui.git_stats_view

The Git panel's Stats page (see vibefpmgui.git_stats): the repository's numbers on tiles at the top, its people (their commits, share, first and latest) on the left, and on the right a plot of each one's commits so far over time, from the first commit to now (Total), or their commits in each month as stacked bars (Per month: the month's commits, by whom, when the mouse is over it). pyqtgraph: wheel zooms, drag pans, double-click fits it again. The people with the most commits get a line (a color) each, the rest one together; choosing people in the list highlights theirs.

  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:
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:
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()
MOST = 8
OTHERS = 'Others'
MODE_TITLES = {'total': 'Total', 'monthly': 'Per month'}
MODE_TIPS = {'total': "Each person's commits so far, over time", 'monthly': "Everyone's commits in each month, stacked"}
PERSON_ROLE = 257
LINE_COLORS = {'light': ['#4c6ef5', '#e8590c', '#2f9e44', '#ae3ec9', '#e03131', '#1098ad', '#f08c00', '#5c940d', '#868e96'], 'dark': ['#748ffc', '#ff922b', '#51cf66', '#da77f2', '#ff6b6b', '#3bc9db', '#fcc419', '#94d82d', '#adb5bd']}
def date_words(seconds: int) -> str:
60def date_words(seconds: int) -> str:
61    return datetime.fromtimestamp(seconds).strftime("%Y-%m-%d")
def steps( times: list[int], start: int, end: int) -> tuple[list[float], list[float]]:
64def steps(times: list[int], start: int, end: int) -> tuple[list[float], list[float]]:
65    """A staircase of commits so far: 0 at ``start``, one more at each commit,
66    level to ``end``."""
67    xs, ys = [float(start)], [0.0]
68    for n, t in enumerate(times, 1):
69        xs += [t, t]
70        ys += [n - 1, n]
71    xs.append(float(max(end, times[-1] if times else start)))
72    ys.append(float(len(times)))
73    return xs, ys

A staircase of commits so far: 0 at start, one more at each commit, level to end.

def lines(stats: vibefpmgui.git_stats.RepoStats) -> list[tuple[str, list[int]]]:
76def lines(stats: gst.RepoStats) -> list[tuple[str, list[int]]]:
77    """(who, their commits' dates) for each line: the people with the most
78    commits, then the rest together."""
79    shown = [(a.name, a.times) for a in stats.authors[:MOST]]
80    rest = sorted(t for a in stats.authors[MOST:] for t in a.times)
81    if rest:
82        shown.append((f"{OTHERS} ({len(stats.authors) - MOST} people)", rest))
83    return shown

(who, their commits' dates) for each line: the people with the most commits, then the rest together.

def month_number(seconds: float) -> int:
86def month_number(seconds: float) -> int:
87    """Months since year 0 of a date (local time): consecutive months, consecutive numbers."""
88    when = datetime.fromtimestamp(seconds)
89    return when.year * 12 + when.month - 1

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

def month_start(number: int) -> int:
92def month_start(number: int) -> int:
93    """When a month (`month_number`) starts (seconds since 1970)."""
94    return int(datetime(number // 12, number % 12 + 1, 1).timestamp())

When a month (month_number) starts (seconds since 1970).

def per_month( stats: vibefpmgui.git_stats.RepoStats, now: float) -> tuple[list[int], list[tuple[str, list[int]]]]:
 97def per_month(stats: gst.RepoStats, now: float) -> tuple[list[int], list[tuple[str, list[int]]]]:
 98    """(the months' edges: when each starts, and when the last ends; for each
 99    line of `lines`, its commits in each month), from the first commit's
100    month to this one."""
101    first, last = month_number(stats.first), month_number(max(now, stats.last))
102    edges = [month_start(n) for n in range(first, last + 2)]
103    counts = []
104    for who, times in lines(stats):
105        months = [0] * (last - first + 1)
106        for t in times:
107            months[month_number(t) - first] += 1
108        counts.append((who, months))
109    return edges, counts

(the months' edges: when each starts, and when the last ends; for each line of lines, its commits in each month), from the first commit's month to this one.

class GitStatsView(PySide6.QtWidgets.QWidget):
128class GitStatsView(QWidget):
129    mode_changed = Signal(str)  # TOTAL or MONTHLY chosen above the plot
130    reload_requested = Signal()  # (the Git panel's pages' signal: see GitLogView; the stats have no options of their own)
131
132    TILES: ClassVar[list[tuple[str, str]]] = [("commits", "Commits"), ("people", "People"), ("age", "Age"), ("latest", "Latest commit"),
133             ("recent", "Last 30 days"), ("branches", "Branches"), ("tags", "Tags"), ("files", "Files"),
134             ("size", "Size (.git)")]
135
136    def __init__(self, parent=None):
137        super().__init__(parent)
138        self.stats: gst.RepoStats | None = None
139        self.pal: dict = {}
140        self.dark = False
141        self.curves: dict[str, pg.PlotDataItem | pg.BarGraphItem] = {}  # each line's (or bars'), by whom
142        self.colors: dict[str, QColor] = {}
143        self.mode = TOTAL
144        self._stale = False  # (the plot's to be drawn again when it's next shown: see _redraw)
145        self.months: tuple[list[int], list[tuple[str, list[int]]]] | None = None  # (Per month's: see per_month)
146        self.tiles = {key: _Tile(caption) for key, caption in self.TILES}
147        tiles = QWidget()
148        grid = QGridLayout(tiles)
149        grid.setContentsMargins(0, 0, 0, 0)
150        grid.setSpacing(6)
151        for n, (key, _caption) in enumerate(self.TILES):
152            grid.addWidget(self.tiles[key], 0, n)
153
154        self.people = QTreeWidget()
155        self.people.setHeaderLabels(["Person", "Commits", "Share", "First", "Latest"])
156        self.people.setRootIsDecorated(False)
157        self.people.setUniformRowHeights(True)
158        self.people.setSortingEnabled(False)
159        self.people.setSelectionMode(QTreeWidget.ExtendedSelection)
160        self.people.itemSelectionChanged.connect(self._highlight)
161        self.people.viewport().installEventFilter(self)  # (clicking the one chosen again: none)
162
163        self.plot = pg.PlotWidget(axisItems={"bottom": pg.DateAxisItem()})
164        self.plot.setMenuEnabled(False)
165        self.plot.hideButtons()
166        self.plot.showGrid(x=True, y=True, alpha=0.15)
167        self.plot.setLabel("left", "Commits")
168        self.plot.getViewBox().setMouseEnabled(x=True, y=True)
169        self.plot.scene().sigMouseClicked.connect(self._clicked)
170        self.plot.scene().sigMouseMoved.connect(self._hovered)
171        self.legend = self.plot.addLegend(offset=(10, 10))
172        self.mode_buttons: dict[str, QToolButton] = {}
173        group = QButtonGroup(self)
174        modes = QHBoxLayout()
175        modes.setContentsMargins(0, 0, 0, 0)
176        modes.setSpacing(2)
177        modes.addStretch(1)
178        for mode in (TOTAL, MONTHLY):
179            button = self.mode_buttons[mode] = QToolButton()
180            button.setObjectName("GitView")  # (as the panel's Diff and Stats)
181            button.setText(MODE_TITLES[mode])
182            button.setToolTip(MODE_TIPS[mode])
183            button.setCheckable(True)
184            button.setAutoRaise(True)
185            button.clicked.connect(lambda _checked=False, m=mode: self.set_mode(m, user=True))
186            group.addButton(button)
187            modes.addWidget(button)
188        self.mode_buttons[TOTAL].setChecked(True)
189        plot_side = QWidget()
190        plot_layout = QVBoxLayout(plot_side)
191        plot_layout.setContentsMargins(0, 0, 0, 0)
192        plot_layout.setSpacing(4)
193        plot_layout.addLayout(modes)
194        plot_layout.addWidget(self.plot, 1)
195
196        self.splitter = QSplitter(Qt.Horizontal)
197        self.splitter.addWidget(self.people)
198        self.splitter.addWidget(plot_side)
199        self.splitter.setStretchFactor(0, 0)
200        self.splitter.setStretchFactor(1, 1)
201
202        content = QWidget()
203        layout = QVBoxLayout(content)
204        layout.setContentsMargins(0, 0, 0, 0)
205        layout.setSpacing(8)
206        layout.addWidget(tiles)
207        layout.addWidget(self.splitter, 1)
208        self.message = QLabel()
209        self.message.setAlignment(Qt.AlignCenter)
210        self.message.setWordWrap(True)
211        self.stack = QStackedLayout(self)
212        self.stack.addWidget(self.message)
213        self.stack.addWidget(content)
214        self.content = content
215        self.show_message("Reading the repository…")
216
217    # ------------------------------------------------------------ styling
218    def refresh_palette(self, pal: dict, dark: bool):
219        """The panel's colors (`build_monitor.PALETTES`)."""
220        self.pal, self.dark = pal, dark
221        big = QFont(QApplication.font())
222        big.setPointSizeF(big.pointSizeF() + 4)
223        big.setWeight(QFont.Bold)
224        for tile in self.tiles.values():
225            tile.value.setFont(big)
226            tile.caption.setStyleSheet(f"color: {pal['muted']};")
227        self.setStyleSheet(f"#StatTile {{ background: {pal['hover']}; border-radius: 8px; }}"
228                           f"QTreeWidget {{ background: {pal['bg']}; border: 1px solid {pal['line']};"
229                           " border-radius: 6px; }")
230        self.message.setStyleSheet(f"color: {pal['muted']};")
231        self.plot.setBackground(pal["bg"])
232        for name in ("left", "bottom"):
233            axis = self.plot.getAxis(name)
234            axis.setPen(pal["muted"])
235            axis.setTextPen(pal["muted"])
236        self._label()
237        self.legend.setLabelTextColor(pal["text"])
238        backdrop = QColor(pal["bg"])
239        backdrop.setAlphaF(0.75)  # (a little of the plot shows through)
240        self.legend.setBrush(backdrop)
241        self._redraw()
242
243    def _label(self):
244        self.plot.setLabel("left", "Commits a month" if self.mode == MONTHLY else "Commits",
245                           color=self.pal.get("muted"))
246
247    # -------------------------------------------------------------- state
248    def set_mode(self, mode: str, user: bool = False):
249        """The plot: TOTAL or MONTHLY (``user``: chosen with its buttons: said
250        with mode_changed, to be remembered)."""
251        mode = mode if mode in MODE_TITLES else TOTAL
252        self.mode = mode
253        self.mode_buttons[mode].setChecked(True)
254        self._label()
255        self._redraw()
256        if user:
257            self.mode_changed.emit(mode)
258
259    def _redraw(self):
260        """The plot drawn again (new colors, another plot): now if it's on
261        show, else when it's next shown."""
262        if self.stats is None:
263            return
264        if self.isVisible():
265            self._draw()
266        else:
267            self._stale = True
268
269    def showEvent(self, event):
270        super().showEvent(event)
271        if self._stale and self.stats is not None:
272            self._draw()
273
274    # ------------------------------------------------------- read by the panel
275    # (The Git panel's pages that read from the repository: see vibefpmgui.git_panel.)
276    def reload_key(self, _root) -> tuple:
277        return ()
278
279    def reader(self, root):
280        return lambda: gst.read_stats(root)
281
282    def show_result(self, stats: gst.RepoStats):
283        self.show_stats(stats)
284
285    def show_message(self, text: str):
286        self.stats = None
287        self.message.setText(text)
288        self.stack.setCurrentWidget(self.message)
289
290    def show_stats(self, stats: gst.RepoStats, now: float | None = None):
291        """A repository's statistics."""
292        self.stats = stats
293        now = time.time() if now is None else now
294        total = stats.commits
295        values = {
296            "commits": (f"{total:,}", f"{stats.merges:,} of them merges"),
297            "people": (f"{len(stats.authors):,}", "who made commits (by name)"),
298            "age": (gst.span_words(now - stats.first), f"the first commit: {date_words(stats.first)}"),
299            "latest": (date_words(stats.last), f"{gst.span_words(now - stats.last)} ago"),
300            "recent": (f"{stats.since(30 * gst.DAY, now):,}",
301                       f"commits in the last 30 days; {stats.since(365 * gst.DAY, now):,} in the last year"),
302            "branches": (f"{stats.branches:,}", f"local; {stats.remote_branches:,} remote"),
303            "tags": (f"{stats.tags:,}", "tags"),
304            "files": (f"{stats.files:,}", "files tracked by git"),
305            "size": (human_size(stats.size), "the repository's history on disk"),
306        }
307        for key, (value, tip) in values.items():
308            self.tiles[key].value.setText(value)
309            self.tiles[key].setToolTip(tip)
310
311        self.people.clear()
312        for author in stats.authors:
313            item = QTreeWidgetItem([author.name, f"{author.commits:,}", f"{100 * author.commits / total:.1f}%",
314                                    date_words(author.first), date_words(author.last)])
315            item.setData(0, PERSON_ROLE, author.name)
316            item.setToolTip(0, f"{author.name} <{author.email}>")
317            for column in (1, 2):
318                item.setTextAlignment(column, Qt.AlignRight | Qt.AlignVCenter)
319            self.people.addTopLevelItem(item)
320        for column in range(5):
321            self.people.resizeColumnToContents(column)
322        self.people.setColumnWidth(0, min(self.people.columnWidth(0), 260))  # (a very long name: cut)
323        wanted = sum(self.people.columnWidth(c) for c in range(5)) + 24  # (all the columns, and a scroll bar)
324        sizes = self.splitter.sizes()
325        if sizes[0] < wanted:
326            total = sum(sizes) if sum(sizes) > wanted else max(self.width(), wanted + 400)
327            self.splitter.setSizes([wanted, max(total - wanted, 200)])
328        self._draw(now)
329        self.stack.setCurrentWidget(self.content)
330
331    def _draw(self, now: float | None = None):
332        self._stale = False
333        stats = self.stats
334        now = time.time() if now is None else now
335        self.plot.clear()
336        self.legend.clear()
337        self.curves, self.colors, self.months = {}, {}, None
338        palette = LINE_COLORS["dark" if self.dark else "light"]
339        shown = lines(stats)
340        for n, (who, _times) in enumerate(shown):
341            self.colors[who] = QColor(palette[min(n, len(palette) - 1)])
342        if self.mode == MONTHLY:
343            self._draw_months(now)
344        else:
345            start, end = stats.first, max(int(now), stats.last)
346            for who, times in shown:
347                xs, ys = steps(times, start, end)
348                self.curves[who] = self.plot.plot(xs, ys, pen=pg.mkPen(self.colors[who], width=2),
349                                                  name=f"{who} · {len(times):,}")
350        self.plot.enableAutoRange()
351        self._highlight()
352
353    def _draw_months(self, now: float):
354        """Each month's commits: a bar, its people's parts stacked (the most
355        active at the bottom)."""
356        edges, counts = self.months = per_month(self.stats, now)
357        gap = 0.08  # (of a month: between the bars)
358        x0 = [a + (b - a) * gap / 2 for a, b in pairwise(edges)]
359        x1 = [b - (b - a) * gap / 2 for a, b in pairwise(edges)]
360        bottom = [0] * len(x0)
361        for who, months in counts:
362            top = [b + c for b, c in zip(bottom, months)]
363            bars = pg.BarGraphItem(x0=x0, x1=x1, y0=bottom, y1=top, pen=pg.mkPen(None),
364                                   brush=QBrush(self.colors[who]))
365            self.plot.addItem(bars)
366            self.legend.addItem(bars, f"{who} · {sum(months):,}")
367            self.curves[who] = bars
368            bottom = top
369
370    def _highlight(self):
371        """The chosen people's lines thicker, the others' faint (none chosen: all as they are)."""
372        chosen = {item.data(0, PERSON_ROLE) for item in self.people.selectedItems()}
373        for who, curve in self.curves.items():
374            mine = who in chosen or (who.startswith(OTHERS) and chosen - set(self.curves))
375            color = QColor(self.colors[who])
376            color.setAlphaF(1.0 if not chosen or mine else 0.25)
377            if isinstance(curve, pg.BarGraphItem):
378                curve.setOpts(brush=QBrush(color))
379            else:
380                pen = QPen(color)
381                pen.setWidthF(3.5 if chosen and mine else 2)
382                pen.setCosmetic(True)
383                curve.setPen(pen)
384
385    def eventFilter(self, obj, event):
386        """A plain click on the only person chosen: unchoose them (all the
387        lines as they were)."""
388        if (obj is self.people.viewport() and event.type() == QEvent.MouseButtonPress
389                and event.button() == Qt.LeftButton and event.modifiers() == Qt.NoModifier):
390            item = self.people.itemAt(event.position().toPoint())
391            if item is not None and self.people.selectedItems() == [item]:
392                self.people.clearSelection()
393                return True
394        return super().eventFilter(obj, event)
395
396    def month_text(self, x: float) -> str:
397        """What a Per month bar holds (the one at ``x``, seconds since 1970): ""
398        for none."""
399        if self.months is None:
400            return ""
401        edges, counts = self.months
402        index = bisect_right(edges, x) - 1
403        if not 0 <= index < len(edges) - 1:
404            return ""
405        month = datetime.fromtimestamp(edges[index]).strftime("%B %Y")
406        parts = [(who, months[index]) for who, months in counts if months[index]]
407        total = sum(n for _who, n in parts)
408        rows = "".join(f"<br>{who}: {n:,}" for who, n in sorted(parts, key=lambda p: -p[1]))
409        return f"<b>{month}</b>: {total:,} commit{'s' * (total != 1)}{rows}"
410
411    def _hovered(self, pos):
412        if self.mode != MONTHLY or not self.plot.sceneBoundingRect().contains(pos):
413            return
414        text = self.month_text(self.plot.getViewBox().mapSceneToView(pos).x())
415        if text:
416            QToolTip.showText(QCursor.pos(), text, self.plot)
417        else:
418            QToolTip.hideText()
419
420    def _clicked(self, event):
421        if event.double():
422            self.plot.enableAutoRange()

QWidget(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

GitStatsView(parent=None)
136    def __init__(self, parent=None):
137        super().__init__(parent)
138        self.stats: gst.RepoStats | None = None
139        self.pal: dict = {}
140        self.dark = False
141        self.curves: dict[str, pg.PlotDataItem | pg.BarGraphItem] = {}  # each line's (or bars'), by whom
142        self.colors: dict[str, QColor] = {}
143        self.mode = TOTAL
144        self._stale = False  # (the plot's to be drawn again when it's next shown: see _redraw)
145        self.months: tuple[list[int], list[tuple[str, list[int]]]] | None = None  # (Per month's: see per_month)
146        self.tiles = {key: _Tile(caption) for key, caption in self.TILES}
147        tiles = QWidget()
148        grid = QGridLayout(tiles)
149        grid.setContentsMargins(0, 0, 0, 0)
150        grid.setSpacing(6)
151        for n, (key, _caption) in enumerate(self.TILES):
152            grid.addWidget(self.tiles[key], 0, n)
153
154        self.people = QTreeWidget()
155        self.people.setHeaderLabels(["Person", "Commits", "Share", "First", "Latest"])
156        self.people.setRootIsDecorated(False)
157        self.people.setUniformRowHeights(True)
158        self.people.setSortingEnabled(False)
159        self.people.setSelectionMode(QTreeWidget.ExtendedSelection)
160        self.people.itemSelectionChanged.connect(self._highlight)
161        self.people.viewport().installEventFilter(self)  # (clicking the one chosen again: none)
162
163        self.plot = pg.PlotWidget(axisItems={"bottom": pg.DateAxisItem()})
164        self.plot.setMenuEnabled(False)
165        self.plot.hideButtons()
166        self.plot.showGrid(x=True, y=True, alpha=0.15)
167        self.plot.setLabel("left", "Commits")
168        self.plot.getViewBox().setMouseEnabled(x=True, y=True)
169        self.plot.scene().sigMouseClicked.connect(self._clicked)
170        self.plot.scene().sigMouseMoved.connect(self._hovered)
171        self.legend = self.plot.addLegend(offset=(10, 10))
172        self.mode_buttons: dict[str, QToolButton] = {}
173        group = QButtonGroup(self)
174        modes = QHBoxLayout()
175        modes.setContentsMargins(0, 0, 0, 0)
176        modes.setSpacing(2)
177        modes.addStretch(1)
178        for mode in (TOTAL, MONTHLY):
179            button = self.mode_buttons[mode] = QToolButton()
180            button.setObjectName("GitView")  # (as the panel's Diff and Stats)
181            button.setText(MODE_TITLES[mode])
182            button.setToolTip(MODE_TIPS[mode])
183            button.setCheckable(True)
184            button.setAutoRaise(True)
185            button.clicked.connect(lambda _checked=False, m=mode: self.set_mode(m, user=True))
186            group.addButton(button)
187            modes.addWidget(button)
188        self.mode_buttons[TOTAL].setChecked(True)
189        plot_side = QWidget()
190        plot_layout = QVBoxLayout(plot_side)
191        plot_layout.setContentsMargins(0, 0, 0, 0)
192        plot_layout.setSpacing(4)
193        plot_layout.addLayout(modes)
194        plot_layout.addWidget(self.plot, 1)
195
196        self.splitter = QSplitter(Qt.Horizontal)
197        self.splitter.addWidget(self.people)
198        self.splitter.addWidget(plot_side)
199        self.splitter.setStretchFactor(0, 0)
200        self.splitter.setStretchFactor(1, 1)
201
202        content = QWidget()
203        layout = QVBoxLayout(content)
204        layout.setContentsMargins(0, 0, 0, 0)
205        layout.setSpacing(8)
206        layout.addWidget(tiles)
207        layout.addWidget(self.splitter, 1)
208        self.message = QLabel()
209        self.message.setAlignment(Qt.AlignCenter)
210        self.message.setWordWrap(True)
211        self.stack = QStackedLayout(self)
212        self.stack.addWidget(self.message)
213        self.stack.addWidget(content)
214        self.content = content
215        self.show_message("Reading the repository…")

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def mode_changed(unknown):

The type of the None singleton.

def reload_requested(unknown):

The type of the None singleton.

TILES: ClassVar[list[tuple[str, str]]] = [('commits', 'Commits'), ('people', 'People'), ('age', 'Age'), ('latest', 'Latest commit'), ('recent', 'Last 30 days'), ('branches', 'Branches'), ('tags', 'Tags'), ('files', 'Files'), ('size', 'Size (.git)')]
pal: dict
dark
curves: dict[str, pyqtgraph.graphicsItems.PlotDataItem.PlotDataItem | pyqtgraph.graphicsItems.BarGraphItem.BarGraphItem]
colors: dict[str, PySide6.QtGui.QColor]
mode
months: tuple[list[int], list[tuple[str, list[int]]]] | None
tiles
people
plot
legend
mode_buttons: dict[str, PySide6.QtWidgets.QToolButton]
splitter
message
stack
content
def refresh_palette(self, pal: dict, dark: bool):
218    def refresh_palette(self, pal: dict, dark: bool):
219        """The panel's colors (`build_monitor.PALETTES`)."""
220        self.pal, self.dark = pal, dark
221        big = QFont(QApplication.font())
222        big.setPointSizeF(big.pointSizeF() + 4)
223        big.setWeight(QFont.Bold)
224        for tile in self.tiles.values():
225            tile.value.setFont(big)
226            tile.caption.setStyleSheet(f"color: {pal['muted']};")
227        self.setStyleSheet(f"#StatTile {{ background: {pal['hover']}; border-radius: 8px; }}"
228                           f"QTreeWidget {{ background: {pal['bg']}; border: 1px solid {pal['line']};"
229                           " border-radius: 6px; }")
230        self.message.setStyleSheet(f"color: {pal['muted']};")
231        self.plot.setBackground(pal["bg"])
232        for name in ("left", "bottom"):
233            axis = self.plot.getAxis(name)
234            axis.setPen(pal["muted"])
235            axis.setTextPen(pal["muted"])
236        self._label()
237        self.legend.setLabelTextColor(pal["text"])
238        backdrop = QColor(pal["bg"])
239        backdrop.setAlphaF(0.75)  # (a little of the plot shows through)
240        self.legend.setBrush(backdrop)
241        self._redraw()

The panel's colors (build_monitor.PALETTES).

def set_mode(self, mode: str, user: bool = False):
248    def set_mode(self, mode: str, user: bool = False):
249        """The plot: TOTAL or MONTHLY (``user``: chosen with its buttons: said
250        with mode_changed, to be remembered)."""
251        mode = mode if mode in MODE_TITLES else TOTAL
252        self.mode = mode
253        self.mode_buttons[mode].setChecked(True)
254        self._label()
255        self._redraw()
256        if user:
257            self.mode_changed.emit(mode)

The plot: TOTAL or MONTHLY (user: chosen with its buttons: said with mode_changed, to be remembered).

def showEvent(self, event):
269    def showEvent(self, event):
270        super().showEvent(event)
271        if self._stale and self.stats is not None:
272            self._draw()

showEvent(self, event: PySide6.QtGui.QShowEvent, /) -> None

def reload_key(self, _root) -> tuple:
276    def reload_key(self, _root) -> tuple:
277        return ()
def reader(self, root):
279    def reader(self, root):
280        return lambda: gst.read_stats(root)
def show_result(self, stats: vibefpmgui.git_stats.RepoStats):
282    def show_result(self, stats: gst.RepoStats):
283        self.show_stats(stats)
def show_message(self, text: str):
285    def show_message(self, text: str):
286        self.stats = None
287        self.message.setText(text)
288        self.stack.setCurrentWidget(self.message)
def show_stats( self, stats: vibefpmgui.git_stats.RepoStats, now: float | None = None):
290    def show_stats(self, stats: gst.RepoStats, now: float | None = None):
291        """A repository's statistics."""
292        self.stats = stats
293        now = time.time() if now is None else now
294        total = stats.commits
295        values = {
296            "commits": (f"{total:,}", f"{stats.merges:,} of them merges"),
297            "people": (f"{len(stats.authors):,}", "who made commits (by name)"),
298            "age": (gst.span_words(now - stats.first), f"the first commit: {date_words(stats.first)}"),
299            "latest": (date_words(stats.last), f"{gst.span_words(now - stats.last)} ago"),
300            "recent": (f"{stats.since(30 * gst.DAY, now):,}",
301                       f"commits in the last 30 days; {stats.since(365 * gst.DAY, now):,} in the last year"),
302            "branches": (f"{stats.branches:,}", f"local; {stats.remote_branches:,} remote"),
303            "tags": (f"{stats.tags:,}", "tags"),
304            "files": (f"{stats.files:,}", "files tracked by git"),
305            "size": (human_size(stats.size), "the repository's history on disk"),
306        }
307        for key, (value, tip) in values.items():
308            self.tiles[key].value.setText(value)
309            self.tiles[key].setToolTip(tip)
310
311        self.people.clear()
312        for author in stats.authors:
313            item = QTreeWidgetItem([author.name, f"{author.commits:,}", f"{100 * author.commits / total:.1f}%",
314                                    date_words(author.first), date_words(author.last)])
315            item.setData(0, PERSON_ROLE, author.name)
316            item.setToolTip(0, f"{author.name} <{author.email}>")
317            for column in (1, 2):
318                item.setTextAlignment(column, Qt.AlignRight | Qt.AlignVCenter)
319            self.people.addTopLevelItem(item)
320        for column in range(5):
321            self.people.resizeColumnToContents(column)
322        self.people.setColumnWidth(0, min(self.people.columnWidth(0), 260))  # (a very long name: cut)
323        wanted = sum(self.people.columnWidth(c) for c in range(5)) + 24  # (all the columns, and a scroll bar)
324        sizes = self.splitter.sizes()
325        if sizes[0] < wanted:
326            total = sum(sizes) if sum(sizes) > wanted else max(self.width(), wanted + 400)
327            self.splitter.setSizes([wanted, max(total - wanted, 200)])
328        self._draw(now)
329        self.stack.setCurrentWidget(self.content)

A repository's statistics.

def eventFilter(self, obj, event):
385    def eventFilter(self, obj, event):
386        """A plain click on the only person chosen: unchoose them (all the
387        lines as they were)."""
388        if (obj is self.people.viewport() and event.type() == QEvent.MouseButtonPress
389                and event.button() == Qt.LeftButton and event.modifiers() == Qt.NoModifier):
390            item = self.people.itemAt(event.position().toPoint())
391            if item is not None and self.people.selectedItems() == [item]:
392                self.people.clearSelection()
393                return True
394        return super().eventFilter(obj, event)

A plain click on the only person chosen: unchoose them (all the lines as they were).

def month_text(self, x: float) -> str:
396    def month_text(self, x: float) -> str:
397        """What a Per month bar holds (the one at ``x``, seconds since 1970): ""
398        for none."""
399        if self.months is None:
400            return ""
401        edges, counts = self.months
402        index = bisect_right(edges, x) - 1
403        if not 0 <= index < len(edges) - 1:
404            return ""
405        month = datetime.fromtimestamp(edges[index]).strftime("%B %Y")
406        parts = [(who, months[index]) for who, months in counts if months[index]]
407        total = sum(n for _who, n in parts)
408        rows = "".join(f"<br>{who}: {n:,}" for who, n in sorted(parts, key=lambda p: -p[1]))
409        return f"<b>{month}</b>: {total:,} commit{'s' * (total != 1)}{rows}"

What a Per month bar holds (the one at x, seconds since 1970): "" for none.

staticMetaObject = PySide6.QtCore.QMetaObject("GitStatsView" inherits "QWidget": Methods: #33 type=Signal, signature=mode_changed(QString), parameters=QString #34 type=Signal, signature=reload_requested() )