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