vibefpmgui.git_blame
View → Show Git Blame: beside the editor (left of the minimap), for each
line of the file, the commit that last changed it — its short hash, author
and date — scrolling with the text. Each run of lines from one commit is a
card spanning them, labelled at its top, tinted and with a bar in the
color of the commit's age (see AGE_BANDS: this week, this month, this
year, older — the same in every file). A card lights up under the mouse and
sinks when pressed, as a button: click it to open the commit on GitHub or
GitLab (the repository's origin), or, with no web page for it, to copy
its hash; hover it for the commit's message.
The blame is of the text in the editor (git blame --contents), so its
lines stay matched while you edit: lines changed since the last commit say
"Not committed yet". It's read off the window's thread, again once typing
pauses and when the file is saved.
1"""View → Show Git Blame: beside the editor (left of the minimap), for each 2line of the file, the commit that last changed it — its short hash, author 3and date — scrolling with the text. Each run of lines from one commit is a 4card spanning them, labelled at its top, tinted and with a bar in the 5color of the commit's age (see AGE_BANDS: this week, this month, this 6year, older — the same in every file). A card lights up under the mouse and 7sinks when pressed, as a button: click it to open the commit on GitHub or 8GitLab (the repository's ``origin``), or, with no web page for it, to copy 9its hash; hover it for the commit's message. 10 11The blame is of the text in the editor (``git blame --contents``), so its 12lines stay matched while you edit: lines changed since the last commit say 13"Not committed yet". It's read off the window's thread, again once typing 14pauses and when the file is saved. 15""" 16 17from __future__ import annotations 18 19import os 20import re 21import tempfile 22import time 23from dataclasses import dataclass 24from pathlib import Path 25 26from qtpy.QtCore import QEvent, QPoint, QRect, QRectF, QSize, Qt, QUrl 27from qtpy.QtGui import ( 28 QColor, 29 QDesktopServices, 30 QFont, 31 QFontMetrics, 32 QPainter, 33 QPalette, 34 QPen, 35) 36from qtpy.QtWidgets import QApplication, QToolTip, QWidget 37 38from . import git_status as gs 39from .background import call_in_background 40 41NOT_COMMITTED = "0" * 40 # (git's hash for lines changed since the last commit) 42HEADER_RE = re.compile(r"^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$") 43CHARACTERS = 34 # the column's width, in characters ("a1b2c3d Jacob Williams 2026-10-12") 44DAY = 86_400 # seconds 45# A commit's age: (younger than this many days, its name, its color, how strongly shown). The same in every file. 46AGE_BANDS = ((7, "this week", "#f08c00", 1.0), (31, "this month", "#e3b341", 0.9), 47 (365, "this year", "#7b9cc4", 0.8), (None, "older", "#9a9a9a", 0.55)) 48 49 50def age_band(commit_time: int, now: float | None = None) -> tuple[str, str, float]: 51 """A commit's age band: (its name, its color, how strongly it's shown).""" 52 days = ((now if now is not None else time.time()) - commit_time) / DAY 53 for limit, name, color, strength in AGE_BANDS: 54 if limit is None or days < limit: 55 return name, color, strength 56 raise AssertionError("the last band has no limit") 57 58 59def age_words(commit_time: int, now: float | None = None) -> str: 60 """ "this month (12 days ago)", "older (3 years ago)"…""" 61 days = max(0, int(((now if now is not None else time.time()) - commit_time) // DAY)) 62 ago = ("today" if days == 0 else "yesterday" if days == 1 else f"{days} days ago" if days < 60 63 else f"{days // 30} months ago" if days < 730 else f"{days // 365} years ago") 64 return f"{age_band(commit_time, now)[0]} ({ago})" 65 66 67@dataclass 68class Commit: 69 sha: str 70 author: str = "" 71 time: int = 0 # seconds since 1970 (its author's) 72 summary: str = "" # the first line of its message 73 74 @property 75 def committed(self) -> bool: 76 return self.sha != NOT_COMMITTED 77 78 def date(self) -> str: 79 return time.strftime("%Y-%m-%d", time.localtime(self.time)) if self.time else "" 80 81 82@dataclass 83class Blame: 84 lines: list[str] # each line's commit (its hash), the file's lines in order 85 commits: dict[str, Commit] 86 remote: str = "" # the repository's origin (for the commits' web pages) 87 root: Path | None = None # the repository's top folder (for a commit's details) 88 89 90@dataclass 91class FileChange: 92 status: str # A, M, D, R, C, T (git's letters) 93 path: str 94 old_path: str = "" # a renamed or copied file's name before 95 added: int | None = None # lines (None: a binary file) 96 deleted: int | None = None 97 98 99@dataclass 100class CommitDetails: 101 """What the tooltip shows of a commit, besides what the blame says.""" 102 email: str = "" 103 message: str = "" # the whole of it 104 committer: str = "" # (when not the author: a rebase, a patch applied by someone else) 105 commit_time: int = 0 106 files: list[FileChange] | None = None 107 108 109MAX_FILES = 30 # files listed in the tooltip (more: "and N more") 110 111 112def commit_details(root: Path, sha: str) -> CommitDetails: 113 """A commit's message, its author's email, its committer, and the files it 114 changed (each with its lines added and deleted; a merge's: what it 115 brought into its first parent, not git's "combined" list, which leaves 116 out what came from one side only). Raises GitError.""" 117 header = gs._check(root, "show", "-s", "--no-color", "--format=%ae%x00%cn%x00%ct%x00%an%x00%B", sha) 118 email, committer, commit_time, author, message = (header.decode("utf-8", "replace").split("\0", 4) + [""] * 5)[:5] 119 statuses = gs._check(root, "-c", "core.quotepath=false", "show", "--no-color", "--format=", "--name-status", 120 "-M", "-m", "--first-parent", sha).decode("utf-8", "replace").splitlines() 121 counts = gs._check(root, "-c", "core.quotepath=false", "show", "--no-color", "--format=", "--numstat", "-M", 122 "-m", "--first-parent", sha).decode("utf-8", "replace").splitlines() 123 files = [] 124 for status_line, count_line in zip(statuses, counts, strict=False): 125 parts = status_line.split("\t") 126 letter = parts[0][:1] 127 path, old = (parts[2], parts[1]) if letter in "RC" and len(parts) > 2 else (parts[-1], "") 128 added, deleted, *_ = count_line.split("\t") + ["", ""] 129 files.append(FileChange(letter, path, old, int(added) if added.isdigit() else None, 130 int(deleted) if deleted.isdigit() else None)) 131 return CommitDetails(email.strip(), message.strip(), committer.strip() if committer.strip() != author.strip() 132 else "", int(commit_time) if commit_time.strip().isdigit() else 0, files) 133 134 135def parse_porcelain(output: str) -> tuple[list[str], dict[str, Commit]]: 136 """``git blame --porcelain`` read: each line's commit, and the commits.""" 137 lines: dict[int, str] = {} 138 commits: dict[str, Commit] = {} 139 current = None 140 for row in output.split("\n"): 141 if row.startswith("\t"): 142 continue # (the line's text) 143 match = HEADER_RE.match(row) 144 if match: 145 current = commits.setdefault(match.group(1), Commit(match.group(1))) 146 lines[int(match.group(3))] = current.sha 147 continue 148 if current is None: 149 continue 150 key, _, value = row.partition(" ") 151 if key == "author": 152 current.author = "You" if current.sha == NOT_COMMITTED else value 153 elif key == "author-time" and value.isdigit(): 154 current.time = int(value) 155 elif key == "summary": 156 current.summary = value 157 return [lines.get(n, NOT_COMMITTED) for n in range(1, max(lines, default=0) + 1)], commits 158 159 160def blame(path: Path, contents: str | bytes) -> Blame | None: 161 """The blame of ``contents`` as the file ``path`` (None: it isn't in a git 162 repository, or git doesn't know it). ``contents``: the bytes the file would 163 have, saved (its line endings and encoding: the editor's text has "\n", 164 and a file committed with "\r\n" would otherwise differ on every line). 165 Raises GitError if git fails.""" 166 root = gs.find_repository(path.parent) # (a folder: the file's) 167 if root is None: 168 return None 169 relative = path.resolve().relative_to(root.resolve()).as_posix() 170 data = contents.encode("utf-8") if isinstance(contents, str) else contents 171 with tempfile.NamedTemporaryFile("wb", suffix=path.suffix, delete=False) as file: 172 file.write(data) 173 try: 174 code, output, message = gs._git(root, "blame", "--porcelain", "--contents", file.name, "--", relative) 175 finally: 176 os.unlink(file.name) 177 if code != 0: 178 if "no such path" in message or "not tracked" in message or "fatal: no such ref" in message: 179 return None # (a file git doesn't know yet) 180 raise gs.GitError(message.strip().splitlines()[-1] if message.strip() else "git blame failed") 181 lines, commits = parse_porcelain((output or b"").decode("utf-8", "replace")) 182 _code, remote, _message = gs._git(root, "config", "--get", "remote.origin.url") 183 return Blame(lines, commits, (remote or b"").decode().strip(), root) 184 185 186def short_name(author: str, room: int = 14) -> str: 187 """An author's name, cut to fit: "Jacob Williams" or "Jacob W.".""" 188 if len(author) <= room: 189 return author 190 first, *rest = author.split() 191 return f"{first} {rest[-1][0]}." if rest and len(first) + 3 <= room else author[:room - 1] + "…" 192 193 194class BlameColumn(QWidget): 195 """The column (see the module's description).""" 196 197 def __init__(self, editor, parent=None): 198 super().__init__(parent) 199 self.editor = editor 200 self.blame: Blame | None = None 201 self.message = "" # (why there's no blame: shown at the top) 202 self._hover: int | None = None # the card under the mouse (its first line) 203 self._pressed: int | None = None # the card being clicked (its first line) 204 self._details: dict[str, CommitDetails | str] = {} # a commit's details (or why git couldn't), once read 205 self._loading: set[str] = set() 206 self._card_at = QPoint() # (where the mouse rested: the card is shown there) 207 self.setMouseTracking(True) 208 self.setAttribute(Qt.WA_OpaquePaintEvent) 209 editor.updateRequest.connect(lambda *_: self.update()) # (scrolled, edited: drawn again with it) 210 self.refresh_font() 211 212 # ------------------------------------------------------------ contents 213 def set_blame(self, blame: Blame | None, message: str = ""): 214 self.blame, self.message = blame, message 215 if self._hover is not None: # (its lines may have moved: tinted again as the mouse moves) 216 self._hover = None 217 self.editor.show_commit_lines(None) 218 self.update() 219 220 def refresh_font(self): 221 font = QFont(QApplication.font()) 222 line = self.editor.fontMetrics().height() 223 font.setPixelSize(max(8, int(line * 0.72))) 224 self.setFont(font) 225 self.setFixedWidth(QFontMetrics(font).horizontalAdvance("0" * CHARACTERS) + 12) 226 227 def sizeHint(self) -> QSize: 228 return QSize(self.width(), 100) 229 230 # ------------------------------------------------------------ where 231 def _visible_lines(self): 232 """(line index, top y, height) of each line on view.""" 233 editor = self.editor 234 block = editor.firstVisibleBlock() 235 offset = editor.contentOffset() 236 top = editor.blockBoundingGeometry(block).translated(offset).top() 237 while block.isValid() and top <= self.height(): 238 height = editor.blockBoundingRect(block).height() 239 if block.isVisible(): 240 yield block.blockNumber(), top, height 241 top += height 242 block = block.next() 243 244 def runs(self): 245 """The cards on view: (first line, commit, top y, bottom y) for each run 246 of lines from one commit (a run starting above the view: from its top).""" 247 if self.blame is None: 248 return 249 run = None 250 for line, top, height in self._visible_lines(): 251 sha = self.blame.lines[line] if line < len(self.blame.lines) else None 252 commit = self.blame.commits.get(sha) if sha else None 253 if run is not None and commit is run[1] and line == run[4] + 1: 254 run[3], run[4] = top + height, line 255 continue 256 if run is not None: 257 yield tuple(run[:4]) 258 run = [line, commit, top, top + height, line] if commit is not None else None 259 if run is not None: 260 yield tuple(run[:4]) 261 262 def run_at(self, y: float): 263 return next((r for r in self.runs() if r[2] <= y < r[3]), None) 264 265 def commit_at(self, y: float) -> Commit | None: 266 if self.blame is None: 267 return None 268 for line, top, height in self._visible_lines(): 269 if top <= y < top + height and line < len(self.blame.lines): 270 return self.blame.commits.get(self.blame.lines[line]) 271 return None 272 273 # ------------------------------------------------------------ drawing 274 @staticmethod 275 def _age_color(commit: Commit, base: QColor, now: float | None = None) -> QColor: 276 """The color of a commit's age band (see AGE_BANDS); not committed: red.""" 277 if not commit.committed: 278 return QColor("#e5484d") 279 _name, name, strength = age_band(commit.time, now) 280 color = QColor(name) 281 color.setAlphaF(strength) 282 return color if base.lightness() < 128 else color.darker(112) 283 284 def paintEvent(self, _event): 285 painter = QPainter(self) 286 painter.setRenderHint(QPainter.Antialiasing) 287 palette = self.editor.palette() 288 base = palette.color(QPalette.Base) 289 light = base.lightness() >= 128 290 background = base.darker(106) if light else base.lighter(110) 291 painter.fillRect(self.rect(), background) 292 text, dim = palette.color(QPalette.Text), QColor(palette.color(QPalette.Text)) 293 dim.setAlpha(150) 294 if self.blame is None: 295 if self.message: 296 painter.setPen(dim) 297 painter.drawText(QRect(8, 4, self.width() - 12, 40), Qt.AlignLeft | Qt.TextWordWrap, self.message) 298 painter.end() 299 return 300 metrics = QFontMetrics(self.font()) 301 line_height = self.editor.fontMetrics().height() 302 for first, commit, top, bottom in self.runs(): 303 accent = self._age_color(commit, base) 304 hovered = commit.committed and first == self._hover 305 pressed = hovered and first == self._pressed 306 card = QRectF(4, top + 1.5, self.width() - 8, max(bottom - top - 3, 4)) 307 if pressed: 308 card.translate(0, 1) # (sunk: pressed in) 309 elif commit.committed: # (a soft shadow under it: raised) 310 shadow = QColor(0, 0, 0, 40 if light else 90) 311 painter.setPen(Qt.NoPen) 312 painter.setBrush(shadow) 313 painter.drawRoundedRect(card.translated(0, 1.2), 5, 5) 314 fill = QColor(base.lighter(103) if light else base.lighter(135)) 315 tint = QColor(accent) 316 tint.setAlphaF(0.10 + (0.12 if hovered else 0) + (0.10 if pressed else 0)) 317 painter.setBrush(fill) 318 if commit.committed: 319 painter.setPen(QPen(accent, 1.2) if hovered else QPen(QColor(0, 0, 0, 30 if light else 70), 1)) 320 else: # (not committed: dashed, red, not clickable) 321 painter.setPen(QPen(QColor("#e5484d"), 1, Qt.DashLine)) 322 painter.drawRoundedRect(card, 5, 5) 323 painter.setPen(Qt.NoPen) 324 painter.setBrush(tint) 325 painter.drawRoundedRect(card, 5, 5) 326 bar = QRectF(card.left() + 3, card.top() + 3, 3, card.height() - 6) # (the age bar, in the card) 327 painter.setBrush(accent) 328 painter.drawRoundedRect(bar, 1.5, 1.5) 329 if card.height() < line_height * 0.6: 330 continue # (too little of it on view for its label) 331 label_top = card.top() + max(0.0, (min(card.height(), line_height) - metrics.height()) / 2) 332 x, room = card.left() + 11, card.width() - 15 333 font = QFont(self.font()) 334 if not commit.committed: 335 font.setItalic(True) 336 painter.setFont(font) 337 painter.setPen(dim) 338 painter.drawText(QRectF(x, label_top, room, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter, 339 "Not committed yet") 340 continue 341 parts = [(commit.sha[:7], True, text), (short_name(commit.author), False, text), (commit.date(), False, dim)] 342 for part, bold, color in parts: 343 font.setBold(bold) 344 painter.setFont(font) 345 width = QFontMetrics(font).horizontalAdvance(part) 346 if width > room: 347 part = QFontMetrics(font).elidedText(part, Qt.ElideRight, int(room)) 348 width = room 349 painter.setPen(color) # (the hash bold, in the text's color: the age is the bar's and tint's) 350 painter.drawText(QRectF(x, label_top, width + 2, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter, 351 part) 352 x += width + metrics.horizontalAdvance(" ") 353 room = card.right() - 4 - x 354 if room <= 8: 355 break 356 painter.end() 357 358 # ------------------------------------------------------------ the mouse 359 def event(self, event): 360 if event.type() == QEvent.ToolTip: # (the mouse rested on a card: its card, as fortitude's) 361 commit = self.commit_at(event.pos().y()) 362 popup = self.editor.hover_popup 363 if commit is None: 364 if (popup.anchor or ("",))[0] == "blame": 365 popup.schedule_hide() 366 else: 367 self._card_at = event.globalPos() 368 self.show_card(commit) 369 if commit.committed and commit.sha not in self._details: 370 self._load_details(commit) 371 return True 372 return super().event(event) 373 374 def show_card(self, commit: Commit): 375 """A commit's card in the editor's hover popup (it stays while the mouse 376 goes into it, so its links can be clicked).""" 377 palette = self.editor.palette() 378 html = commit_card_html(commit, self._details.get(commit.sha), self.blame.remote if self.blame else "", 379 palette.color(QPalette.Text)) 380 self.editor.hover_popup.show_at(self._card_at, html, ("blame", commit.sha)) 381 382 def _load_details(self, commit: Commit): 383 """Read a commit's message and files (off the window's thread, once): 384 its card then shows them, if it's still open.""" 385 if commit.sha in self._loading or self.blame is None or self.blame.root is None: 386 return 387 self._loading.add(commit.sha) 388 root, sha = self.blame.root, commit.sha 389 390 def work(): 391 try: 392 return commit_details(root, sha) 393 except gs.GitError as error: 394 return str(error) 395 396 def done(details): 397 self._loading.discard(sha) 398 self._details[sha] = details 399 popup = self.editor.hover_popup 400 if popup.isVisible() and popup.anchor == ("blame", sha): 401 self.show_card(commit) 402 403 call_in_background(work, done, self) 404 405 def _show_commit_lines(self, commit: Commit | None): 406 """Every line of the file from ``commit`` tinted in its card's color 407 (None: none), while the mouse is on its card.""" 408 if commit is None or self.blame is None: 409 self.editor.show_commit_lines(None) 410 return 411 tint = self._age_color(commit, self.editor.palette().color(QPalette.Base)) 412 tint.setAlphaF(0.22 if self.editor.palette().color(QPalette.Base).lightness() < 128 else 0.28) 413 lines = {n for n, sha in enumerate(self.blame.lines) if sha == commit.sha} 414 self.editor.show_commit_lines(lines, tint) 415 416 def wheelEvent(self, event): 417 # Scroll the editor, as if the wheel were over the text (as the minimap does): the 418 # column, and the minimap, follow it. 419 self.editor.wheelEvent(event) 420 421 def mouseMoveEvent(self, event): 422 run = self.run_at(event.position().y()) 423 hover = run[0] if run is not None and run[1].committed else None 424 if hover != self._hover: 425 self._hover = hover 426 self.update() 427 self._show_commit_lines(run[1] if hover is not None else None) 428 self.setCursor(Qt.PointingHandCursor if hover is not None else Qt.ArrowCursor) 429 430 def leaveEvent(self, event): 431 self._hover = self._pressed = None 432 self.update() 433 self._show_commit_lines(None) 434 if (self.editor.hover_popup.anchor or ("",))[0] == "blame": 435 self.editor.hover_popup.schedule_hide() # (unless the mouse went into it) 436 super().leaveEvent(event) 437 438 def mousePressEvent(self, event): 439 if event.button() == Qt.LeftButton: 440 run = self.run_at(event.position().y()) 441 self._pressed = run[0] if run is not None and run[1].committed else None 442 self.update() 443 444 def mouseReleaseEvent(self, event): 445 if event.button() != Qt.LeftButton: 446 return 447 pressed, self._pressed = self._pressed, None 448 self.update() 449 run = self.run_at(event.position().y()) 450 if run is None or run[0] != pressed: # (released off the card pressed: no click, as a button) 451 return 452 commit = run[1] 453 url = self._commit_url(commit) 454 if url: 455 open_web_page(url) 456 else: 457 QApplication.clipboard().setText(commit.sha) 458 QToolTip.showText(self.mapToGlobal(event.position().toPoint() + QPoint(8, 8)), 459 f"No web page for this repository's commits (its origin isn't on GitHub or " 460 f"GitLab): the hash {commit.sha[:10]} is copied.", self) 461 462 def _commit_url(self, commit: Commit) -> str | None: 463 if self.blame is None or not self.blame.remote: 464 return None 465 from .window.packages import ( 466 commit_web_url, # (here: the window's module imports the editor's) 467 ) 468 469 return commit_web_url(self.blame.remote, commit.sha) 470 471 472STATUS_CHIPS = {"A": ("added", "#2f9e44"), "M": ("modified", "#1c7ed6"), "D": ("deleted", "#e03131"), 473 "R": ("renamed", "#7048e8"), "C": ("copied", "#7048e8"), "T": ("type changed", "#868e96")} 474 475 476def web_base(remote: str) -> str | None: 477 """The repository's web page (GitHub, GitLab, Codeberg…), None: none known.""" 478 if not remote: 479 return None 480 from .window.packages import ( 481 repo_web_url, # (here: the window's module imports the editor's) 482 ) 483 484 return repo_web_url(remote) 485 486 487def issue_url(base: str, number: str, merge_request: bool = False) -> str | None: 488 """An issue's ("#33") or merge request's ("!34", GitLab's) page.""" 489 if "gitlab." in base: 490 return f"{base}/-/{'merge_requests' if merge_request else 'issues'}/{number}" 491 if merge_request: 492 return None # (only GitLab writes them so) 493 return f"{base}/issues/{number}" # (GitHub sends a pull request's number on to it; Codeberg too) 494 495 496def file_url(base: str, sha: str, path: str) -> str: 497 """A file as it was in a commit.""" 498 if "gitlab." in base: 499 return f"{base}/-/blob/{sha}/{path}" 500 if "codeberg." in base: 501 return f"{base}/src/commit/{sha}/{path}" 502 if "bitbucket." in base: 503 return f"{base}/src/{sha}/{path}" 504 return f"{base}/blob/{sha}/{path}" 505 506 507def linkify(text: str, base: str | None) -> str: 508 """Text for the card: escaped, its web addresses links, and its issue 509 ("#33") and merge request ("!34") numbers links to their pages.""" 510 out, last = [], 0 511 pattern = re.compile(r"(?P<url>https?://[^\s<>\"')\]]+)|(?<![\w&/])(?P<kind>[#!])(?P<number>\d+)\b") 512 for match in pattern.finditer(text): 513 out.append(_escape(text[last:match.start()])) 514 last = match.end() 515 if match.group("url"): 516 url = match.group("url").rstrip(".,;:") 517 out.append(f"<a href='{_escape(url)}'>{_escape(url)}</a>{_escape(match.group('url')[len(url):])}") 518 continue 519 url = issue_url(base, match.group("number"), match.group("kind") == "!") if base else None 520 out.append(f"<a href='{url}'>{_escape(match.group(0))}</a>" if url else _escape(match.group(0))) 521 return "".join(out) + _escape(text[last:]) 522 523 524def commit_card_html(commit: Commit, details: CommitDetails | str | None, remote: str, text: QColor) -> str: 525 """A commit, as the editor's other hover cards (fortitude's): its hash 526 (a link: its page, or "copy"), age band, author (and committer), date, 527 the whole message (issue and merge request numbers, web addresses: 528 links), and the files it changed (each a link to it as it was then), 529 with their lines added and deleted. ``details``: None while they're 530 read; a string: why git couldn't.""" 531 muted = QColor(text) 532 muted.setAlpha(150) 533 muted = muted.name(QColor.HexArgb) 534 dark = text.lightness() > 128 535 green, red = ("#57d163", "#ff6b6b") if dark else ("#1a7f37", "#cf222e") 536 small = "font-size:small" 537 base = web_base(remote) 538 host = "GitHub" if base and "github." in base else "GitLab" if base and "gitlab." in base else "the web" 539 page = f"{base}/{'-/commit' if base and 'gitlab.' in base else 'commit'}/{commit.sha}" if base else None 540 if not commit.committed: 541 return (f"<span style='color:{muted}; {small}'>GIT BLAME</span><br>" 542 "<i>Not committed yet</i>: changed since the last commit.") 543 band, color, _strength = age_band(commit.time) 544 when = time.strftime("%Y-%m-%d %H:%M", time.localtime(commit.time)) if commit.time else "" 545 chip = f"background-color:{color}; color:white; font-weight:700; {small}" 546 hash_link = page or f"copy:{commit.sha}" 547 out = [(f"<span style='color:{muted}; {small}'>COMMIT</span><br>" 548 f"<a href='{hash_link}' style='text-decoration:none'><b><code>{commit.sha[:12]}</code></b></a>" 549 f" <span style='{chip}'> {band} </span>")] 550 email = f" <{_escape(details.email)}>" if isinstance(details, CommitDetails) and details.email else "" 551 ago = age_words(commit.time).split(" (")[-1][:-1] 552 out.append(f"<br><b>{_escape(commit.author)}</b><span style='color:{muted}'>{email}</span><br>" 553 f"<span style='color:{muted}; {small}'>{when} · {ago}</span>") 554 if isinstance(details, CommitDetails) and details.committer: 555 out.append(f"<br><span style='color:{muted}; {small}'>committed by {_escape(details.committer)}</span>") 556 message = details.message if isinstance(details, CommitDetails) and details.message else commit.summary 557 subject, _, body = message.partition("\n") 558 out.append(f"<p style='margin-top:8px; margin-bottom:0'><b>{linkify(subject, base)}</b></p>") 559 if body.strip(): 560 lines = (linkify(line, base) if line.strip() else "" for line in body.strip("\n").split("\n")) 561 out.append(f"<p style='margin-top:4px; margin-bottom:0'>{'<br>'.join(lines)}</p>") 562 if details is None: 563 out.append(f"<p style='margin-top:8px; color:{muted}'><i>Reading the commit…</i></p>") 564 elif isinstance(details, str): 565 out.append(f"<p style='margin-top:8px; color:{muted}'><i>git couldn't read it: {_escape(details)}</i></p>") 566 elif details.files is not None: 567 files = details.files 568 added = sum(f.added or 0 for f in files) 569 deleted = sum(f.deleted or 0 for f in files) 570 rows = [(f"<p style='margin-top:10px; margin-bottom:2px'><span style='color:{muted}; {small}'>" 571 f"FILES CHANGED · {len(files)}</span> <span style='color:{green}'>+{added}</span> " 572 f"<span style='color:{red}'>−{deleted}</span></p><table cellspacing='0' cellpadding='1'>")] 573 for change in files[:MAX_FILES]: 574 word, status_color = STATUS_CHIPS.get(change.status, (change.status, "#868e96")) 575 name = (f"{_escape(change.old_path)} → {_escape(change.path)}" if change.old_path 576 else _escape(change.path)) 577 if base and change.status != "D": 578 name = f"<a href='{_escape(file_url(base, commit.sha, change.path))}'>{name}</a>" 579 if change.added is None: 580 counts, bar = f"<span style='color:{muted}'>binary</span>", "" 581 elif change.added + change.deleted == 0: # (renamed, or its mode changed: no lines) 582 counts, bar = f"<span style='color:{muted}'>—</span>", "" 583 else: 584 counts = (f"<span style='color:{green}'>+{change.added}</span> " 585 f"<span style='color:{red}'>−{change.deleted}</span>") 586 total = change.added + change.deleted 587 plus = round(5 * change.added / total) if total else 0 588 bar = (f"<span style='color:{green}'>{'■' * plus}</span>" 589 f"<span style='color:{red}'>{'■' * (5 - plus if total else 0)}</span>" 590 f"<span style='color:{muted}'>{'□' * (0 if total else 5)}</span>") 591 rows.append(f"<tr><td style='padding-right:6px'><span style='background-color:{status_color}; " 592 f"color:white; font-weight:700; {small}' title='{word}'> {change.status} </span>" 593 f"</td><td style='padding-right:10px'>{name}</td><td align='right'>{counts} </td>" 594 f"<td>{bar}</td></tr>") 595 rows.append("</table>") 596 if len(files) > MAX_FILES: 597 rows.append(f"<span style='color:{muted}; {small}'>and {len(files) - MAX_FILES} more</span>") 598 out += rows 599 actions = [f"<a href='{page}'>Open on {host}</a>"] if page else [] 600 actions.append(f"<a href='copy:{commit.sha}'>Copy hash</a>") 601 out.append(f"<p style='margin-top:10px; margin-bottom:0; {small}'>{' · '.join(actions)}</p>") 602 return "".join(out) 603 604 605def open_web_page(url: str): 606 """Open a page in the browser.""" 607 QDesktopServices.openUrl(QUrl(url)) 608 609 610def _escape(text: str) -> str: 611 return text.replace("&", "&").replace("<", "<").replace(">", ">")
51def age_band(commit_time: int, now: float | None = None) -> tuple[str, str, float]: 52 """A commit's age band: (its name, its color, how strongly it's shown).""" 53 days = ((now if now is not None else time.time()) - commit_time) / DAY 54 for limit, name, color, strength in AGE_BANDS: 55 if limit is None or days < limit: 56 return name, color, strength 57 raise AssertionError("the last band has no limit")
A commit's age band: (its name, its color, how strongly it's shown).
60def age_words(commit_time: int, now: float | None = None) -> str: 61 """ "this month (12 days ago)", "older (3 years ago)"…""" 62 days = max(0, int(((now if now is not None else time.time()) - commit_time) // DAY)) 63 ago = ("today" if days == 0 else "yesterday" if days == 1 else f"{days} days ago" if days < 60 64 else f"{days // 30} months ago" if days < 730 else f"{days // 365} years ago") 65 return f"{age_band(commit_time, now)[0]} ({ago})"
"this month (12 days ago)", "older (3 years ago)"…
68@dataclass 69class Commit: 70 sha: str 71 author: str = "" 72 time: int = 0 # seconds since 1970 (its author's) 73 summary: str = "" # the first line of its message 74 75 @property 76 def committed(self) -> bool: 77 return self.sha != NOT_COMMITTED 78 79 def date(self) -> str: 80 return time.strftime("%Y-%m-%d", time.localtime(self.time)) if self.time else ""
83@dataclass 84class Blame: 85 lines: list[str] # each line's commit (its hash), the file's lines in order 86 commits: dict[str, Commit] 87 remote: str = "" # the repository's origin (for the commits' web pages) 88 root: Path | None = None # the repository's top folder (for a commit's details)
91@dataclass 92class FileChange: 93 status: str # A, M, D, R, C, T (git's letters) 94 path: str 95 old_path: str = "" # a renamed or copied file's name before 96 added: int | None = None # lines (None: a binary file) 97 deleted: int | None = None
100@dataclass 101class CommitDetails: 102 """What the tooltip shows of a commit, besides what the blame says.""" 103 email: str = "" 104 message: str = "" # the whole of it 105 committer: str = "" # (when not the author: a rebase, a patch applied by someone else) 106 commit_time: int = 0 107 files: list[FileChange] | None = None
What the tooltip shows of a commit, besides what the blame says.
113def commit_details(root: Path, sha: str) -> CommitDetails: 114 """A commit's message, its author's email, its committer, and the files it 115 changed (each with its lines added and deleted; a merge's: what it 116 brought into its first parent, not git's "combined" list, which leaves 117 out what came from one side only). Raises GitError.""" 118 header = gs._check(root, "show", "-s", "--no-color", "--format=%ae%x00%cn%x00%ct%x00%an%x00%B", sha) 119 email, committer, commit_time, author, message = (header.decode("utf-8", "replace").split("\0", 4) + [""] * 5)[:5] 120 statuses = gs._check(root, "-c", "core.quotepath=false", "show", "--no-color", "--format=", "--name-status", 121 "-M", "-m", "--first-parent", sha).decode("utf-8", "replace").splitlines() 122 counts = gs._check(root, "-c", "core.quotepath=false", "show", "--no-color", "--format=", "--numstat", "-M", 123 "-m", "--first-parent", sha).decode("utf-8", "replace").splitlines() 124 files = [] 125 for status_line, count_line in zip(statuses, counts, strict=False): 126 parts = status_line.split("\t") 127 letter = parts[0][:1] 128 path, old = (parts[2], parts[1]) if letter in "RC" and len(parts) > 2 else (parts[-1], "") 129 added, deleted, *_ = count_line.split("\t") + ["", ""] 130 files.append(FileChange(letter, path, old, int(added) if added.isdigit() else None, 131 int(deleted) if deleted.isdigit() else None)) 132 return CommitDetails(email.strip(), message.strip(), committer.strip() if committer.strip() != author.strip() 133 else "", int(commit_time) if commit_time.strip().isdigit() else 0, files)
A commit's message, its author's email, its committer, and the files it changed (each with its lines added and deleted; a merge's: what it brought into its first parent, not git's "combined" list, which leaves out what came from one side only). Raises GitError.
136def parse_porcelain(output: str) -> tuple[list[str], dict[str, Commit]]: 137 """``git blame --porcelain`` read: each line's commit, and the commits.""" 138 lines: dict[int, str] = {} 139 commits: dict[str, Commit] = {} 140 current = None 141 for row in output.split("\n"): 142 if row.startswith("\t"): 143 continue # (the line's text) 144 match = HEADER_RE.match(row) 145 if match: 146 current = commits.setdefault(match.group(1), Commit(match.group(1))) 147 lines[int(match.group(3))] = current.sha 148 continue 149 if current is None: 150 continue 151 key, _, value = row.partition(" ") 152 if key == "author": 153 current.author = "You" if current.sha == NOT_COMMITTED else value 154 elif key == "author-time" and value.isdigit(): 155 current.time = int(value) 156 elif key == "summary": 157 current.summary = value 158 return [lines.get(n, NOT_COMMITTED) for n in range(1, max(lines, default=0) + 1)], commits
git blame --porcelain read: each line's commit, and the commits.
161def blame(path: Path, contents: str | bytes) -> Blame | None: 162 """The blame of ``contents`` as the file ``path`` (None: it isn't in a git 163 repository, or git doesn't know it). ``contents``: the bytes the file would 164 have, saved (its line endings and encoding: the editor's text has "\n", 165 and a file committed with "\r\n" would otherwise differ on every line). 166 Raises GitError if git fails.""" 167 root = gs.find_repository(path.parent) # (a folder: the file's) 168 if root is None: 169 return None 170 relative = path.resolve().relative_to(root.resolve()).as_posix() 171 data = contents.encode("utf-8") if isinstance(contents, str) else contents 172 with tempfile.NamedTemporaryFile("wb", suffix=path.suffix, delete=False) as file: 173 file.write(data) 174 try: 175 code, output, message = gs._git(root, "blame", "--porcelain", "--contents", file.name, "--", relative) 176 finally: 177 os.unlink(file.name) 178 if code != 0: 179 if "no such path" in message or "not tracked" in message or "fatal: no such ref" in message: 180 return None # (a file git doesn't know yet) 181 raise gs.GitError(message.strip().splitlines()[-1] if message.strip() else "git blame failed") 182 lines, commits = parse_porcelain((output or b"").decode("utf-8", "replace")) 183 _code, remote, _message = gs._git(root, "config", "--get", "remote.origin.url") 184 return Blame(lines, commits, (remote or b"").decode().strip(), root)
The blame of contents as the file path (None: it isn't in a git
repository, or git doesn't know it). contents: the bytes the file would
have, saved (its line endings and encoding: the editor's text has "
",
and a file committed with "
" would otherwise differ on every line).
Raises GitError if git fails.
187def short_name(author: str, room: int = 14) -> str: 188 """An author's name, cut to fit: "Jacob Williams" or "Jacob W.".""" 189 if len(author) <= room: 190 return author 191 first, *rest = author.split() 192 return f"{first} {rest[-1][0]}." if rest and len(first) + 3 <= room else author[:room - 1] + "…"
An author's name, cut to fit: "Jacob Williams" or "Jacob W.".
195class BlameColumn(QWidget): 196 """The column (see the module's description).""" 197 198 def __init__(self, editor, parent=None): 199 super().__init__(parent) 200 self.editor = editor 201 self.blame: Blame | None = None 202 self.message = "" # (why there's no blame: shown at the top) 203 self._hover: int | None = None # the card under the mouse (its first line) 204 self._pressed: int | None = None # the card being clicked (its first line) 205 self._details: dict[str, CommitDetails | str] = {} # a commit's details (or why git couldn't), once read 206 self._loading: set[str] = set() 207 self._card_at = QPoint() # (where the mouse rested: the card is shown there) 208 self.setMouseTracking(True) 209 self.setAttribute(Qt.WA_OpaquePaintEvent) 210 editor.updateRequest.connect(lambda *_: self.update()) # (scrolled, edited: drawn again with it) 211 self.refresh_font() 212 213 # ------------------------------------------------------------ contents 214 def set_blame(self, blame: Blame | None, message: str = ""): 215 self.blame, self.message = blame, message 216 if self._hover is not None: # (its lines may have moved: tinted again as the mouse moves) 217 self._hover = None 218 self.editor.show_commit_lines(None) 219 self.update() 220 221 def refresh_font(self): 222 font = QFont(QApplication.font()) 223 line = self.editor.fontMetrics().height() 224 font.setPixelSize(max(8, int(line * 0.72))) 225 self.setFont(font) 226 self.setFixedWidth(QFontMetrics(font).horizontalAdvance("0" * CHARACTERS) + 12) 227 228 def sizeHint(self) -> QSize: 229 return QSize(self.width(), 100) 230 231 # ------------------------------------------------------------ where 232 def _visible_lines(self): 233 """(line index, top y, height) of each line on view.""" 234 editor = self.editor 235 block = editor.firstVisibleBlock() 236 offset = editor.contentOffset() 237 top = editor.blockBoundingGeometry(block).translated(offset).top() 238 while block.isValid() and top <= self.height(): 239 height = editor.blockBoundingRect(block).height() 240 if block.isVisible(): 241 yield block.blockNumber(), top, height 242 top += height 243 block = block.next() 244 245 def runs(self): 246 """The cards on view: (first line, commit, top y, bottom y) for each run 247 of lines from one commit (a run starting above the view: from its top).""" 248 if self.blame is None: 249 return 250 run = None 251 for line, top, height in self._visible_lines(): 252 sha = self.blame.lines[line] if line < len(self.blame.lines) else None 253 commit = self.blame.commits.get(sha) if sha else None 254 if run is not None and commit is run[1] and line == run[4] + 1: 255 run[3], run[4] = top + height, line 256 continue 257 if run is not None: 258 yield tuple(run[:4]) 259 run = [line, commit, top, top + height, line] if commit is not None else None 260 if run is not None: 261 yield tuple(run[:4]) 262 263 def run_at(self, y: float): 264 return next((r for r in self.runs() if r[2] <= y < r[3]), None) 265 266 def commit_at(self, y: float) -> Commit | None: 267 if self.blame is None: 268 return None 269 for line, top, height in self._visible_lines(): 270 if top <= y < top + height and line < len(self.blame.lines): 271 return self.blame.commits.get(self.blame.lines[line]) 272 return None 273 274 # ------------------------------------------------------------ drawing 275 @staticmethod 276 def _age_color(commit: Commit, base: QColor, now: float | None = None) -> QColor: 277 """The color of a commit's age band (see AGE_BANDS); not committed: red.""" 278 if not commit.committed: 279 return QColor("#e5484d") 280 _name, name, strength = age_band(commit.time, now) 281 color = QColor(name) 282 color.setAlphaF(strength) 283 return color if base.lightness() < 128 else color.darker(112) 284 285 def paintEvent(self, _event): 286 painter = QPainter(self) 287 painter.setRenderHint(QPainter.Antialiasing) 288 palette = self.editor.palette() 289 base = palette.color(QPalette.Base) 290 light = base.lightness() >= 128 291 background = base.darker(106) if light else base.lighter(110) 292 painter.fillRect(self.rect(), background) 293 text, dim = palette.color(QPalette.Text), QColor(palette.color(QPalette.Text)) 294 dim.setAlpha(150) 295 if self.blame is None: 296 if self.message: 297 painter.setPen(dim) 298 painter.drawText(QRect(8, 4, self.width() - 12, 40), Qt.AlignLeft | Qt.TextWordWrap, self.message) 299 painter.end() 300 return 301 metrics = QFontMetrics(self.font()) 302 line_height = self.editor.fontMetrics().height() 303 for first, commit, top, bottom in self.runs(): 304 accent = self._age_color(commit, base) 305 hovered = commit.committed and first == self._hover 306 pressed = hovered and first == self._pressed 307 card = QRectF(4, top + 1.5, self.width() - 8, max(bottom - top - 3, 4)) 308 if pressed: 309 card.translate(0, 1) # (sunk: pressed in) 310 elif commit.committed: # (a soft shadow under it: raised) 311 shadow = QColor(0, 0, 0, 40 if light else 90) 312 painter.setPen(Qt.NoPen) 313 painter.setBrush(shadow) 314 painter.drawRoundedRect(card.translated(0, 1.2), 5, 5) 315 fill = QColor(base.lighter(103) if light else base.lighter(135)) 316 tint = QColor(accent) 317 tint.setAlphaF(0.10 + (0.12 if hovered else 0) + (0.10 if pressed else 0)) 318 painter.setBrush(fill) 319 if commit.committed: 320 painter.setPen(QPen(accent, 1.2) if hovered else QPen(QColor(0, 0, 0, 30 if light else 70), 1)) 321 else: # (not committed: dashed, red, not clickable) 322 painter.setPen(QPen(QColor("#e5484d"), 1, Qt.DashLine)) 323 painter.drawRoundedRect(card, 5, 5) 324 painter.setPen(Qt.NoPen) 325 painter.setBrush(tint) 326 painter.drawRoundedRect(card, 5, 5) 327 bar = QRectF(card.left() + 3, card.top() + 3, 3, card.height() - 6) # (the age bar, in the card) 328 painter.setBrush(accent) 329 painter.drawRoundedRect(bar, 1.5, 1.5) 330 if card.height() < line_height * 0.6: 331 continue # (too little of it on view for its label) 332 label_top = card.top() + max(0.0, (min(card.height(), line_height) - metrics.height()) / 2) 333 x, room = card.left() + 11, card.width() - 15 334 font = QFont(self.font()) 335 if not commit.committed: 336 font.setItalic(True) 337 painter.setFont(font) 338 painter.setPen(dim) 339 painter.drawText(QRectF(x, label_top, room, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter, 340 "Not committed yet") 341 continue 342 parts = [(commit.sha[:7], True, text), (short_name(commit.author), False, text), (commit.date(), False, dim)] 343 for part, bold, color in parts: 344 font.setBold(bold) 345 painter.setFont(font) 346 width = QFontMetrics(font).horizontalAdvance(part) 347 if width > room: 348 part = QFontMetrics(font).elidedText(part, Qt.ElideRight, int(room)) 349 width = room 350 painter.setPen(color) # (the hash bold, in the text's color: the age is the bar's and tint's) 351 painter.drawText(QRectF(x, label_top, width + 2, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter, 352 part) 353 x += width + metrics.horizontalAdvance(" ") 354 room = card.right() - 4 - x 355 if room <= 8: 356 break 357 painter.end() 358 359 # ------------------------------------------------------------ the mouse 360 def event(self, event): 361 if event.type() == QEvent.ToolTip: # (the mouse rested on a card: its card, as fortitude's) 362 commit = self.commit_at(event.pos().y()) 363 popup = self.editor.hover_popup 364 if commit is None: 365 if (popup.anchor or ("",))[0] == "blame": 366 popup.schedule_hide() 367 else: 368 self._card_at = event.globalPos() 369 self.show_card(commit) 370 if commit.committed and commit.sha not in self._details: 371 self._load_details(commit) 372 return True 373 return super().event(event) 374 375 def show_card(self, commit: Commit): 376 """A commit's card in the editor's hover popup (it stays while the mouse 377 goes into it, so its links can be clicked).""" 378 palette = self.editor.palette() 379 html = commit_card_html(commit, self._details.get(commit.sha), self.blame.remote if self.blame else "", 380 palette.color(QPalette.Text)) 381 self.editor.hover_popup.show_at(self._card_at, html, ("blame", commit.sha)) 382 383 def _load_details(self, commit: Commit): 384 """Read a commit's message and files (off the window's thread, once): 385 its card then shows them, if it's still open.""" 386 if commit.sha in self._loading or self.blame is None or self.blame.root is None: 387 return 388 self._loading.add(commit.sha) 389 root, sha = self.blame.root, commit.sha 390 391 def work(): 392 try: 393 return commit_details(root, sha) 394 except gs.GitError as error: 395 return str(error) 396 397 def done(details): 398 self._loading.discard(sha) 399 self._details[sha] = details 400 popup = self.editor.hover_popup 401 if popup.isVisible() and popup.anchor == ("blame", sha): 402 self.show_card(commit) 403 404 call_in_background(work, done, self) 405 406 def _show_commit_lines(self, commit: Commit | None): 407 """Every line of the file from ``commit`` tinted in its card's color 408 (None: none), while the mouse is on its card.""" 409 if commit is None or self.blame is None: 410 self.editor.show_commit_lines(None) 411 return 412 tint = self._age_color(commit, self.editor.palette().color(QPalette.Base)) 413 tint.setAlphaF(0.22 if self.editor.palette().color(QPalette.Base).lightness() < 128 else 0.28) 414 lines = {n for n, sha in enumerate(self.blame.lines) if sha == commit.sha} 415 self.editor.show_commit_lines(lines, tint) 416 417 def wheelEvent(self, event): 418 # Scroll the editor, as if the wheel were over the text (as the minimap does): the 419 # column, and the minimap, follow it. 420 self.editor.wheelEvent(event) 421 422 def mouseMoveEvent(self, event): 423 run = self.run_at(event.position().y()) 424 hover = run[0] if run is not None and run[1].committed else None 425 if hover != self._hover: 426 self._hover = hover 427 self.update() 428 self._show_commit_lines(run[1] if hover is not None else None) 429 self.setCursor(Qt.PointingHandCursor if hover is not None else Qt.ArrowCursor) 430 431 def leaveEvent(self, event): 432 self._hover = self._pressed = None 433 self.update() 434 self._show_commit_lines(None) 435 if (self.editor.hover_popup.anchor or ("",))[0] == "blame": 436 self.editor.hover_popup.schedule_hide() # (unless the mouse went into it) 437 super().leaveEvent(event) 438 439 def mousePressEvent(self, event): 440 if event.button() == Qt.LeftButton: 441 run = self.run_at(event.position().y()) 442 self._pressed = run[0] if run is not None and run[1].committed else None 443 self.update() 444 445 def mouseReleaseEvent(self, event): 446 if event.button() != Qt.LeftButton: 447 return 448 pressed, self._pressed = self._pressed, None 449 self.update() 450 run = self.run_at(event.position().y()) 451 if run is None or run[0] != pressed: # (released off the card pressed: no click, as a button) 452 return 453 commit = run[1] 454 url = self._commit_url(commit) 455 if url: 456 open_web_page(url) 457 else: 458 QApplication.clipboard().setText(commit.sha) 459 QToolTip.showText(self.mapToGlobal(event.position().toPoint() + QPoint(8, 8)), 460 f"No web page for this repository's commits (its origin isn't on GitHub or " 461 f"GitLab): the hash {commit.sha[:10]} is copied.", self) 462 463 def _commit_url(self, commit: Commit) -> str | None: 464 if self.blame is None or not self.blame.remote: 465 return None 466 from .window.packages import ( 467 commit_web_url, # (here: the window's module imports the editor's) 468 ) 469 470 return commit_web_url(self.blame.remote, commit.sha)
The column (see the module's description).
198 def __init__(self, editor, parent=None): 199 super().__init__(parent) 200 self.editor = editor 201 self.blame: Blame | None = None 202 self.message = "" # (why there's no blame: shown at the top) 203 self._hover: int | None = None # the card under the mouse (its first line) 204 self._pressed: int | None = None # the card being clicked (its first line) 205 self._details: dict[str, CommitDetails | str] = {} # a commit's details (or why git couldn't), once read 206 self._loading: set[str] = set() 207 self._card_at = QPoint() # (where the mouse rested: the card is shown there) 208 self.setMouseTracking(True) 209 self.setAttribute(Qt.WA_OpaquePaintEvent) 210 editor.updateRequest.connect(lambda *_: self.update()) # (scrolled, edited: drawn again with it) 211 self.refresh_font()
__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.
245 def runs(self): 246 """The cards on view: (first line, commit, top y, bottom y) for each run 247 of lines from one commit (a run starting above the view: from its top).""" 248 if self.blame is None: 249 return 250 run = None 251 for line, top, height in self._visible_lines(): 252 sha = self.blame.lines[line] if line < len(self.blame.lines) else None 253 commit = self.blame.commits.get(sha) if sha else None 254 if run is not None and commit is run[1] and line == run[4] + 1: 255 run[3], run[4] = top + height, line 256 continue 257 if run is not None: 258 yield tuple(run[:4]) 259 run = [line, commit, top, top + height, line] if commit is not None else None 260 if run is not None: 261 yield tuple(run[:4])
The cards on view: (first line, commit, top y, bottom y) for each run of lines from one commit (a run starting above the view: from its top).
285 def paintEvent(self, _event): 286 painter = QPainter(self) 287 painter.setRenderHint(QPainter.Antialiasing) 288 palette = self.editor.palette() 289 base = palette.color(QPalette.Base) 290 light = base.lightness() >= 128 291 background = base.darker(106) if light else base.lighter(110) 292 painter.fillRect(self.rect(), background) 293 text, dim = palette.color(QPalette.Text), QColor(palette.color(QPalette.Text)) 294 dim.setAlpha(150) 295 if self.blame is None: 296 if self.message: 297 painter.setPen(dim) 298 painter.drawText(QRect(8, 4, self.width() - 12, 40), Qt.AlignLeft | Qt.TextWordWrap, self.message) 299 painter.end() 300 return 301 metrics = QFontMetrics(self.font()) 302 line_height = self.editor.fontMetrics().height() 303 for first, commit, top, bottom in self.runs(): 304 accent = self._age_color(commit, base) 305 hovered = commit.committed and first == self._hover 306 pressed = hovered and first == self._pressed 307 card = QRectF(4, top + 1.5, self.width() - 8, max(bottom - top - 3, 4)) 308 if pressed: 309 card.translate(0, 1) # (sunk: pressed in) 310 elif commit.committed: # (a soft shadow under it: raised) 311 shadow = QColor(0, 0, 0, 40 if light else 90) 312 painter.setPen(Qt.NoPen) 313 painter.setBrush(shadow) 314 painter.drawRoundedRect(card.translated(0, 1.2), 5, 5) 315 fill = QColor(base.lighter(103) if light else base.lighter(135)) 316 tint = QColor(accent) 317 tint.setAlphaF(0.10 + (0.12 if hovered else 0) + (0.10 if pressed else 0)) 318 painter.setBrush(fill) 319 if commit.committed: 320 painter.setPen(QPen(accent, 1.2) if hovered else QPen(QColor(0, 0, 0, 30 if light else 70), 1)) 321 else: # (not committed: dashed, red, not clickable) 322 painter.setPen(QPen(QColor("#e5484d"), 1, Qt.DashLine)) 323 painter.drawRoundedRect(card, 5, 5) 324 painter.setPen(Qt.NoPen) 325 painter.setBrush(tint) 326 painter.drawRoundedRect(card, 5, 5) 327 bar = QRectF(card.left() + 3, card.top() + 3, 3, card.height() - 6) # (the age bar, in the card) 328 painter.setBrush(accent) 329 painter.drawRoundedRect(bar, 1.5, 1.5) 330 if card.height() < line_height * 0.6: 331 continue # (too little of it on view for its label) 332 label_top = card.top() + max(0.0, (min(card.height(), line_height) - metrics.height()) / 2) 333 x, room = card.left() + 11, card.width() - 15 334 font = QFont(self.font()) 335 if not commit.committed: 336 font.setItalic(True) 337 painter.setFont(font) 338 painter.setPen(dim) 339 painter.drawText(QRectF(x, label_top, room, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter, 340 "Not committed yet") 341 continue 342 parts = [(commit.sha[:7], True, text), (short_name(commit.author), False, text), (commit.date(), False, dim)] 343 for part, bold, color in parts: 344 font.setBold(bold) 345 painter.setFont(font) 346 width = QFontMetrics(font).horizontalAdvance(part) 347 if width > room: 348 part = QFontMetrics(font).elidedText(part, Qt.ElideRight, int(room)) 349 width = room 350 painter.setPen(color) # (the hash bold, in the text's color: the age is the bar's and tint's) 351 painter.drawText(QRectF(x, label_top, width + 2, metrics.height()), Qt.AlignLeft | Qt.AlignVCenter, 352 part) 353 x += width + metrics.horizontalAdvance(" ") 354 room = card.right() - 4 - x 355 if room <= 8: 356 break 357 painter.end()
paintEvent(self, event: PySide6.QtGui.QPaintEvent, /) -> None
360 def event(self, event): 361 if event.type() == QEvent.ToolTip: # (the mouse rested on a card: its card, as fortitude's) 362 commit = self.commit_at(event.pos().y()) 363 popup = self.editor.hover_popup 364 if commit is None: 365 if (popup.anchor or ("",))[0] == "blame": 366 popup.schedule_hide() 367 else: 368 self._card_at = event.globalPos() 369 self.show_card(commit) 370 if commit.committed and commit.sha not in self._details: 371 self._load_details(commit) 372 return True 373 return super().event(event)
event(self, event: PySide6.QtCore.QEvent, /) -> bool
375 def show_card(self, commit: Commit): 376 """A commit's card in the editor's hover popup (it stays while the mouse 377 goes into it, so its links can be clicked).""" 378 palette = self.editor.palette() 379 html = commit_card_html(commit, self._details.get(commit.sha), self.blame.remote if self.blame else "", 380 palette.color(QPalette.Text)) 381 self.editor.hover_popup.show_at(self._card_at, html, ("blame", commit.sha))
A commit's card in the editor's hover popup (it stays while the mouse goes into it, so its links can be clicked).
417 def wheelEvent(self, event): 418 # Scroll the editor, as if the wheel were over the text (as the minimap does): the 419 # column, and the minimap, follow it. 420 self.editor.wheelEvent(event)
wheelEvent(self, event: PySide6.QtGui.QWheelEvent, /) -> None
422 def mouseMoveEvent(self, event): 423 run = self.run_at(event.position().y()) 424 hover = run[0] if run is not None and run[1].committed else None 425 if hover != self._hover: 426 self._hover = hover 427 self.update() 428 self._show_commit_lines(run[1] if hover is not None else None) 429 self.setCursor(Qt.PointingHandCursor if hover is not None else Qt.ArrowCursor)
mouseMoveEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None
431 def leaveEvent(self, event): 432 self._hover = self._pressed = None 433 self.update() 434 self._show_commit_lines(None) 435 if (self.editor.hover_popup.anchor or ("",))[0] == "blame": 436 self.editor.hover_popup.schedule_hide() # (unless the mouse went into it) 437 super().leaveEvent(event)
leaveEvent(self, event: PySide6.QtCore.QEvent, /) -> None
439 def mousePressEvent(self, event): 440 if event.button() == Qt.LeftButton: 441 run = self.run_at(event.position().y()) 442 self._pressed = run[0] if run is not None and run[1].committed else None 443 self.update()
mousePressEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None
445 def mouseReleaseEvent(self, event): 446 if event.button() != Qt.LeftButton: 447 return 448 pressed, self._pressed = self._pressed, None 449 self.update() 450 run = self.run_at(event.position().y()) 451 if run is None or run[0] != pressed: # (released off the card pressed: no click, as a button) 452 return 453 commit = run[1] 454 url = self._commit_url(commit) 455 if url: 456 open_web_page(url) 457 else: 458 QApplication.clipboard().setText(commit.sha) 459 QToolTip.showText(self.mapToGlobal(event.position().toPoint() + QPoint(8, 8)), 460 f"No web page for this repository's commits (its origin isn't on GitHub or " 461 f"GitLab): the hash {commit.sha[:10]} is copied.", self)
mouseReleaseEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None
477def web_base(remote: str) -> str | None: 478 """The repository's web page (GitHub, GitLab, Codeberg…), None: none known.""" 479 if not remote: 480 return None 481 from .window.packages import ( 482 repo_web_url, # (here: the window's module imports the editor's) 483 ) 484 485 return repo_web_url(remote)
The repository's web page (GitHub, GitLab, Codeberg…), None: none known.
488def issue_url(base: str, number: str, merge_request: bool = False) -> str | None: 489 """An issue's ("#33") or merge request's ("!34", GitLab's) page.""" 490 if "gitlab." in base: 491 return f"{base}/-/{'merge_requests' if merge_request else 'issues'}/{number}" 492 if merge_request: 493 return None # (only GitLab writes them so) 494 return f"{base}/issues/{number}" # (GitHub sends a pull request's number on to it; Codeberg too)
An issue's ("#33") or merge request's ("!34", GitLab's) page.
497def file_url(base: str, sha: str, path: str) -> str: 498 """A file as it was in a commit.""" 499 if "gitlab." in base: 500 return f"{base}/-/blob/{sha}/{path}" 501 if "codeberg." in base: 502 return f"{base}/src/commit/{sha}/{path}" 503 if "bitbucket." in base: 504 return f"{base}/src/{sha}/{path}" 505 return f"{base}/blob/{sha}/{path}"
A file as it was in a commit.
508def linkify(text: str, base: str | None) -> str: 509 """Text for the card: escaped, its web addresses links, and its issue 510 ("#33") and merge request ("!34") numbers links to their pages.""" 511 out, last = [], 0 512 pattern = re.compile(r"(?P<url>https?://[^\s<>\"')\]]+)|(?<![\w&/])(?P<kind>[#!])(?P<number>\d+)\b") 513 for match in pattern.finditer(text): 514 out.append(_escape(text[last:match.start()])) 515 last = match.end() 516 if match.group("url"): 517 url = match.group("url").rstrip(".,;:") 518 out.append(f"<a href='{_escape(url)}'>{_escape(url)}</a>{_escape(match.group('url')[len(url):])}") 519 continue 520 url = issue_url(base, match.group("number"), match.group("kind") == "!") if base else None 521 out.append(f"<a href='{url}'>{_escape(match.group(0))}</a>" if url else _escape(match.group(0))) 522 return "".join(out) + _escape(text[last:])
Text for the card: escaped, its web addresses links, and its issue ("#33") and merge request ("!34") numbers links to their pages.
525def commit_card_html(commit: Commit, details: CommitDetails | str | None, remote: str, text: QColor) -> str: 526 """A commit, as the editor's other hover cards (fortitude's): its hash 527 (a link: its page, or "copy"), age band, author (and committer), date, 528 the whole message (issue and merge request numbers, web addresses: 529 links), and the files it changed (each a link to it as it was then), 530 with their lines added and deleted. ``details``: None while they're 531 read; a string: why git couldn't.""" 532 muted = QColor(text) 533 muted.setAlpha(150) 534 muted = muted.name(QColor.HexArgb) 535 dark = text.lightness() > 128 536 green, red = ("#57d163", "#ff6b6b") if dark else ("#1a7f37", "#cf222e") 537 small = "font-size:small" 538 base = web_base(remote) 539 host = "GitHub" if base and "github." in base else "GitLab" if base and "gitlab." in base else "the web" 540 page = f"{base}/{'-/commit' if base and 'gitlab.' in base else 'commit'}/{commit.sha}" if base else None 541 if not commit.committed: 542 return (f"<span style='color:{muted}; {small}'>GIT BLAME</span><br>" 543 "<i>Not committed yet</i>: changed since the last commit.") 544 band, color, _strength = age_band(commit.time) 545 when = time.strftime("%Y-%m-%d %H:%M", time.localtime(commit.time)) if commit.time else "" 546 chip = f"background-color:{color}; color:white; font-weight:700; {small}" 547 hash_link = page or f"copy:{commit.sha}" 548 out = [(f"<span style='color:{muted}; {small}'>COMMIT</span><br>" 549 f"<a href='{hash_link}' style='text-decoration:none'><b><code>{commit.sha[:12]}</code></b></a>" 550 f" <span style='{chip}'> {band} </span>")] 551 email = f" <{_escape(details.email)}>" if isinstance(details, CommitDetails) and details.email else "" 552 ago = age_words(commit.time).split(" (")[-1][:-1] 553 out.append(f"<br><b>{_escape(commit.author)}</b><span style='color:{muted}'>{email}</span><br>" 554 f"<span style='color:{muted}; {small}'>{when} · {ago}</span>") 555 if isinstance(details, CommitDetails) and details.committer: 556 out.append(f"<br><span style='color:{muted}; {small}'>committed by {_escape(details.committer)}</span>") 557 message = details.message if isinstance(details, CommitDetails) and details.message else commit.summary 558 subject, _, body = message.partition("\n") 559 out.append(f"<p style='margin-top:8px; margin-bottom:0'><b>{linkify(subject, base)}</b></p>") 560 if body.strip(): 561 lines = (linkify(line, base) if line.strip() else "" for line in body.strip("\n").split("\n")) 562 out.append(f"<p style='margin-top:4px; margin-bottom:0'>{'<br>'.join(lines)}</p>") 563 if details is None: 564 out.append(f"<p style='margin-top:8px; color:{muted}'><i>Reading the commit…</i></p>") 565 elif isinstance(details, str): 566 out.append(f"<p style='margin-top:8px; color:{muted}'><i>git couldn't read it: {_escape(details)}</i></p>") 567 elif details.files is not None: 568 files = details.files 569 added = sum(f.added or 0 for f in files) 570 deleted = sum(f.deleted or 0 for f in files) 571 rows = [(f"<p style='margin-top:10px; margin-bottom:2px'><span style='color:{muted}; {small}'>" 572 f"FILES CHANGED · {len(files)}</span> <span style='color:{green}'>+{added}</span> " 573 f"<span style='color:{red}'>−{deleted}</span></p><table cellspacing='0' cellpadding='1'>")] 574 for change in files[:MAX_FILES]: 575 word, status_color = STATUS_CHIPS.get(change.status, (change.status, "#868e96")) 576 name = (f"{_escape(change.old_path)} → {_escape(change.path)}" if change.old_path 577 else _escape(change.path)) 578 if base and change.status != "D": 579 name = f"<a href='{_escape(file_url(base, commit.sha, change.path))}'>{name}</a>" 580 if change.added is None: 581 counts, bar = f"<span style='color:{muted}'>binary</span>", "" 582 elif change.added + change.deleted == 0: # (renamed, or its mode changed: no lines) 583 counts, bar = f"<span style='color:{muted}'>—</span>", "" 584 else: 585 counts = (f"<span style='color:{green}'>+{change.added}</span> " 586 f"<span style='color:{red}'>−{change.deleted}</span>") 587 total = change.added + change.deleted 588 plus = round(5 * change.added / total) if total else 0 589 bar = (f"<span style='color:{green}'>{'■' * plus}</span>" 590 f"<span style='color:{red}'>{'■' * (5 - plus if total else 0)}</span>" 591 f"<span style='color:{muted}'>{'□' * (0 if total else 5)}</span>") 592 rows.append(f"<tr><td style='padding-right:6px'><span style='background-color:{status_color}; " 593 f"color:white; font-weight:700; {small}' title='{word}'> {change.status} </span>" 594 f"</td><td style='padding-right:10px'>{name}</td><td align='right'>{counts} </td>" 595 f"<td>{bar}</td></tr>") 596 rows.append("</table>") 597 if len(files) > MAX_FILES: 598 rows.append(f"<span style='color:{muted}; {small}'>and {len(files) - MAX_FILES} more</span>") 599 out += rows 600 actions = [f"<a href='{page}'>Open on {host}</a>"] if page else [] 601 actions.append(f"<a href='copy:{commit.sha}'>Copy hash</a>") 602 out.append(f"<p style='margin-top:10px; margin-bottom:0; {small}'>{' · '.join(actions)}</p>") 603 return "".join(out)
A commit, as the editor's other hover cards (fortitude's): its hash
(a link: its page, or "copy"), age band, author (and committer), date,
the whole message (issue and merge request numbers, web addresses:
links), and the files it changed (each a link to it as it was then),
with their lines added and deleted. details: None while they're
read; a string: why git couldn't.
606def open_web_page(url: str): 607 """Open a page in the browser.""" 608 QDesktopServices.openUrl(QUrl(url))
Open a page in the browser.