vibefpmgui.diff_view

Showing two versions of a file: side by side, or as one unified list.

align lines the versions up row by row (difflib), and with_context keeps a few unchanged lines around each change, folding the rest into a "⋯ N" row. DiffView draws the rows: each side in a syntax-colored pane with its own line numbers, removed lines tinted red and added ones green, and the two sides scrolling together.

  1"""Showing two versions of a file: side by side, or as one unified list.
  2
  3`align` lines the versions up row by row (difflib), and `with_context`
  4keeps a few unchanged lines around each change, folding the rest into a
  5"⋯ N" row. `DiffView` draws the rows: each side in a syntax-colored pane
  6with its own line numbers, removed lines tinted red and added ones green,
  7and the two sides scrolling together.
  8"""
  9
 10from __future__ import annotations
 11
 12import difflib
 13from typing import NamedTuple
 14
 15from qtpy.QtCore import QRect, QSize, Qt, Signal
 16from qtpy.QtGui import QColor, QFont, QPainter, QPalette, QTextCharFormat, QTextFormat
 17from qtpy.QtWidgets import (
 18    QHBoxLayout,
 19    QPlainTextEdit,
 20    QStackedWidget,
 21    QTextEdit,
 22    QWidget,
 23)
 24
 25from .highlighter import PygmentsHighlighter, apply_style, is_dark, lexer_for_path
 26
 27SAME, CHANGED, REMOVED, ADDED, GAP = "same", "changed", "removed", "added", "gap"
 28FILLER = "filler"  # (a pane's row with no line: the other side has one)
 29CONTEXT = 3  # unchanged lines kept around each change
 30REMOVED_COLOR, ADDED_COLOR = QColor("#e03131"), QColor("#2f9e44")
 31
 32
 33class Row(NamedTuple):
 34    kind: str                # SAME, CHANGED (a line edited: old and new), REMOVED, ADDED, GAP
 35    old_no: int | None       # 1-based line numbers (None: no line on that side)
 36    old: str | None
 37    new_no: int | None
 38    new: str | None
 39    hidden: int = 0          # GAP: how many unchanged lines it stands for
 40
 41
 42def lines_of(text: str | None) -> list[str]:
 43    """A text's lines (none for None or "")."""
 44    if not text:
 45        return []
 46    lines = text.split("\n")
 47    return lines[:-1] if text.endswith("\n") else lines
 48
 49
 50def align(old: list[str], new: list[str]) -> list[Row]:
 51    """Every line of both versions, lined up: unchanged lines side by side,
 52    edited ones paired (CHANGED), the rest REMOVED or ADDED. The lines the
 53    two begin and end with alike are matched as they are; only those
 54    between (usually a small part of a file) are compared line by line,
 55    which takes time growing with their number squared (a 12,000-line file
 56    whole: a third of a second)."""
 57    shorter = min(len(old), len(new))
 58    start = 0
 59    while start < shorter and old[start] == new[start]:
 60        start += 1
 61    end = 0  # (lines alike at the end, after those at the start)
 62    while end < shorter - start and old[-1 - end] == new[-1 - end]:
 63        end += 1
 64    rows: list[Row] = [Row(SAME, i + 1, old[i], i + 1, new[i]) for i in range(start)]
 65    matcher = difflib.SequenceMatcher(None, old[start:len(old) - end], new[start:len(new) - end], autojunk=False)
 66    for op, a0, a1, b0, b1 in matcher.get_opcodes():
 67        a0, a1, b0, b1 = a0 + start, a1 + start, b0 + start, b1 + start
 68        if op == "equal":
 69            rows += [Row(SAME, a0 + i + 1, old[a0 + i], b0 + i + 1, new[b0 + i]) for i in range(a1 - a0)]
 70            continue
 71        paired = min(a1 - a0, b1 - b0) if op == "replace" else 0
 72        rows += [Row(CHANGED, a0 + i + 1, old[a0 + i], b0 + i + 1, new[b0 + i]) for i in range(paired)]
 73        rows += [Row(REMOVED, a, old[a - 1], None, None) for a in range(a0 + paired + 1, a1 + 1)]
 74        rows += [Row(ADDED, None, None, b, new[b - 1]) for b in range(b0 + paired + 1, b1 + 1)]
 75    rows += [Row(SAME, len(old) - end + i + 1, old[len(old) - end + i], len(new) - end + i + 1,
 76                 new[len(new) - end + i]) for i in range(end)]
 77    return rows
 78
 79
 80def compare(old: str | None, new: str | None) -> list[Row]:
 81    """Two versions' rows to show (`align`, folded with `with_context`): no
 82    Qt, so it can be worked out on a pool thread (see DiffView.show_rows)."""
 83    return with_context(align(lines_of(old), lines_of(new)))
 84
 85
 86def with_context(rows: list[Row], context: int = CONTEXT) -> list[Row]:
 87    """The rows near a change (``context`` unchanged lines around each), the
 88    other unchanged ones folded into GAP rows."""
 89    near = [False] * len(rows)
 90    for i, row in enumerate(rows):
 91        if row.kind != SAME:
 92            for j in range(max(0, i - context), min(len(rows), i + context + 1)):
 93                near[j] = True
 94    out: list[Row] = []
 95    i = 0
 96    while i < len(rows):
 97        if near[i]:
 98            out.append(rows[i])
 99            i += 1
100            continue
101        j = i
102        while j < len(rows) and not near[j]:
103            j += 1
104        out.append(Row(GAP, None, None, None, None, j - i))
105        i = j
106    return out
107
108
109class PaneRows(NamedTuple):
110    """What a pane shows, row by row."""
111    text: list[str]
112    gutter: list[tuple[str, str]]  # (numbers, mark: "-", "+" or "")
113    kinds: list[str]               # SAME, REMOVED, ADDED, FILLER, GAP
114    new_lines: list[int | None]    # the newer version's line on each row (None: none there)
115
116
117def side_by_side(rows: list[Row]) -> tuple[PaneRows, PaneRows]:
118    """The older and the newer version's panes: a row each per Row."""
119    old, new = PaneRows([], [], [], []), PaneRows([], [], [], [])
120    for row in rows:
121        for pane, number, line, kind, mark in ((old, row.old_no, row.old, REMOVED, "-"),
122                                              (new, row.new_no, row.new, ADDED, "+")):
123            pane.new_lines.append(row.new_no)
124            if row.kind == GAP:
125                pane.text.append("")
126                pane.gutter.append((f"⋯ {row.hidden:,}", ""))
127                pane.kinds.append(GAP)
128                continue
129            changed = row.kind in (CHANGED, kind)
130            pane.text.append(line if line is not None else "")
131            pane.gutter.append((str(number) if number else "", mark if changed else ""))
132            pane.kinds.append(kind if changed else FILLER if number is None else SAME)
133    return old, new
134
135
136def unified(rows: list[Row]) -> PaneRows:
137    """One pane: unchanged lines once; within each run of changes, the
138    removed lines and then the added ones."""
139    pane = PaneRows([], [], [], [])
140    removed: list[Row] = []
141    added: list[Row] = []
142
143    def flush():
144        for row in removed:
145            pane.text.append(row.old)
146            pane.gutter.append((f"{row.old_no:>5} {'':>5}", "-"))
147            pane.kinds.append(REMOVED)
148            pane.new_lines.append(None)
149        for row in added:
150            pane.text.append(row.new)
151            pane.gutter.append((f"{'':>5} {row.new_no:>5}", "+"))
152            pane.kinds.append(ADDED)
153            pane.new_lines.append(row.new_no)
154        removed.clear()
155        added.clear()
156
157    for row in rows:
158        if row.kind in (REMOVED, CHANGED):
159            removed.append(row)
160        if row.kind in (ADDED, CHANGED):
161            added.append(row)
162        if row.kind in (SAME, GAP):
163            flush()
164            pane.text.append(row.new if row.kind == SAME else "")
165            pane.gutter.append((f"{row.old_no:>5} {row.new_no:>5}", "") if row.kind == SAME
166                               else (f"⋯ {row.hidden:,}", ""))
167            pane.kinds.append(row.kind)
168            pane.new_lines.append(row.new_no)
169    flush()
170    return pane
171
172
173# ------------------------------------------------------------------ widgets
174class _Gutter(QWidget):
175    def __init__(self, pane: DiffPane):
176        super().__init__(pane)
177        self.pane = pane
178
179    def sizeHint(self):
180        return QSize(self.pane.gutter_width(), 0)
181
182    def paintEvent(self, event):
183        self.pane.paint_gutter(event)
184
185
186class DiffPane(QPlainTextEdit):
187    """One side of a diff (or the unified list): read-only text, a gutter of
188    line numbers (and -/+ marks), and a tint behind each changed line."""
189
190    def __init__(self, parent=None):
191        super().__init__(parent)
192        self.setReadOnly(True)
193        self.setLineWrapMode(QPlainTextEdit.NoWrap)
194        self.rows = PaneRows([], [], [], [])
195        self.highlighter: PygmentsHighlighter | None = None
196        self.gutter = _Gutter(self)
197        self.updateRequest.connect(self._scroll_gutter)
198
199    def gutter_width(self) -> int:
200        widest = max((len(numbers) for numbers, _mark in self.rows.gutter), default=1)
201        return self.fontMetrics().horizontalAdvance("9" * widest + " + ") + 8
202
203    def _place_gutter(self):
204        self.setViewportMargins(self.gutter_width(), 0, 0, 0)
205        r = self.contentsRect()
206        self.gutter.setGeometry(QRect(r.left(), r.top(), self.gutter_width(), r.height()))
207
208    def _scroll_gutter(self, rect, dy):
209        if dy:
210            self.gutter.scroll(0, dy)
211        else:
212            self.gutter.update(0, rect.y(), self.gutter.width(), rect.height())
213
214    def resizeEvent(self, event):
215        super().resizeEvent(event)
216        self._place_gutter()
217
218    def show_rows(self, rows: PaneRows, path: str):
219        """``path`` picks the syntax colors."""
220        self.rows = rows
221        lexer = lexer_for_path(path)
222        if self.highlighter is None:
223            self.highlighter = PygmentsHighlighter(self.document(), lexer)
224        with self.highlighter.replacing_text(lexer):  # (colored at once: not typed)
225            self.setPlainText("\n".join(rows.text))
226        apply_style(self, self.highlighter)
227        self._place_gutter()
228        self._tint()
229
230    def refresh_style(self):
231        if self.highlighter is not None:
232            apply_style(self, self.highlighter)
233        self._tint()
234
235    def set_font(self, font: QFont):
236        self.setFont(font)
237        self._place_gutter()
238
239    def line_at(self, row: int) -> int | None:
240        """The newer version's line at a row, or the nearest one before it (a
241        removed line has none of its own)."""
242        for line in reversed(self.rows.new_lines[:row + 1]):
243            if line is not None:
244                return line
245        return None
246
247    def _tint(self):
248        """A tint behind removed and added lines; rows with no line, and
249        folded unchanged ones, a little darker than the background."""
250        dark = is_dark(self.palette())
251        base = self.palette().color(QPalette.Base)
252        colors = {REMOVED: QColor(REMOVED_COLOR), ADDED: QColor(ADDED_COLOR)}
253        for color in colors.values():
254            color.setAlpha(70 if dark else 45)
255        colors[FILLER] = colors[GAP] = base.lighter(125) if dark else base.darker(106)
256        selections = []
257        block = self.document().firstBlock()
258        kinds = self.rows.kinds
259        while block.isValid():
260            n = block.blockNumber()
261            color = colors.get(kinds[n]) if n < len(kinds) else None
262            if color is not None:
263                selection = QTextEdit.ExtraSelection()
264                selection.format = QTextCharFormat()
265                selection.format.setBackground(color)
266                selection.format.setProperty(QTextFormat.FullWidthSelection, True)
267                selection.cursor = self.textCursor()
268                selection.cursor.setPosition(block.position())
269                selections.append(selection)
270            block = block.next()
271        self.setExtraSelections(selections)
272
273    def paint_gutter(self, event):
274        painter = QPainter(self.gutter)
275        dark = is_dark(self.palette())
276        base = self.palette().color(QPalette.Base)
277        painter.fillRect(event.rect(), base.lighter(112) if dark else base.darker(104))
278        muted = QColor(self.palette().color(QPalette.Text))
279        muted.setAlpha(130)
280        block = self.firstVisibleBlock()
281        top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
282        height = self.fontMetrics().height()
283        while block.isValid() and top <= event.rect().bottom():
284            n = block.blockNumber()
285            if n < len(self.rows.gutter):
286                numbers, mark = self.rows.gutter[n]
287                if self.rows.kinds[n] == GAP:
288                    painter.setPen(muted)
289                    painter.drawText(QRect(0, top, self.gutter.width(), height), Qt.AlignCenter, numbers)
290                else:
291                    painter.setPen(REMOVED_COLOR if mark == "-" else ADDED_COLOR if mark == "+" else muted)
292                    painter.drawText(QRect(0, top, self.gutter.width() - 6, height),
293                                     Qt.AlignRight | Qt.AlignVCenter, f"{numbers} {mark or ' '}")
294            top += round(self.blockBoundingRect(block).height())
295            block = block.next()
296
297
298class DiffView(QWidget):
299    """Two versions of a file, side by side or as one unified list.
300    Double-clicking a line asks to open the file there."""
301
302    open_requested = Signal(int)  # a line of the newer version (1-based)
303
304    def __init__(self, parent=None):
305        super().__init__(parent)
306        self.unified = False
307        self.rows: list[Row] = []
308        self.path = ""
309        self.old_pane, self.new_pane, self.unified_pane = DiffPane(), DiffPane(), DiffPane()
310        side = QWidget()
311        side_layout = QHBoxLayout(side)
312        side_layout.setContentsMargins(0, 0, 0, 0)
313        side_layout.setSpacing(2)
314        side_layout.addWidget(self.old_pane)
315        side_layout.addWidget(self.new_pane)
316        self.stack = QStackedWidget()
317        self.stack.addWidget(side)
318        self.stack.addWidget(self.unified_pane)
319        layout = QHBoxLayout(self)
320        layout.setContentsMargins(0, 0, 0, 0)
321        layout.addWidget(self.stack)
322        # The two sides scroll together (they have the same rows).
323        for a, b in ((self.old_pane, self.new_pane), (self.new_pane, self.old_pane)):
324            a.verticalScrollBar().valueChanged.connect(b.verticalScrollBar().setValue)
325            a.horizontalScrollBar().valueChanged.connect(b.horizontalScrollBar().setValue)
326        for pane in self.panes():
327            pane.viewport().installEventFilter(self)
328
329    def panes(self) -> tuple[DiffPane, DiffPane, DiffPane]:
330        return self.old_pane, self.new_pane, self.unified_pane
331
332    def set_unified(self, on: bool):
333        self.unified = on
334        self.stack.setCurrentIndex(1 if on else 0)
335        self._show()
336
337    def show_diff(self, old: str | None, new: str | None, path: str):
338        """Compare two versions (None: there's no such version, so every line
339        of the other is added or removed)."""
340        self.show_rows(compare(old, new), path)
341
342    def show_rows(self, rows: list[Row], path: str):
343        """Rows already compared (`compare`, on a pool thread for big files)."""
344        self.path = path
345        self.rows = rows
346        self._show()
347
348    def clear(self):
349        self.rows, self.path = [], ""
350        for pane in self.panes():
351            pane.show_rows(PaneRows([], [], [], []), "")
352
353    def _show(self):
354        if self.unified:
355            self.unified_pane.show_rows(unified(self.rows), self.path)
356        else:
357            old, new = side_by_side(self.rows)
358            self.old_pane.show_rows(old, self.path)
359            self.new_pane.show_rows(new, self.path)
360
361    def refresh_style(self):
362        for pane in self.panes():
363            pane.refresh_style()
364
365    def set_font(self, font: QFont):
366        for pane in self.panes():
367            pane.set_font(font)
368
369    def eventFilter(self, obj, event):
370        if event.type() == event.Type.MouseButtonDblClick:
371            pane = next((p for p in self.panes() if p.viewport() is obj), None)
372            if pane is not None:
373                line = pane.line_at(pane.cursorForPosition(event.position().toPoint()).blockNumber())
374                if line is not None:
375                    self.open_requested.emit(line)
376                    return True
377        return False
FILLER = 'filler'
CONTEXT = 3
class Row(typing.NamedTuple):
34class Row(NamedTuple):
35    kind: str                # SAME, CHANGED (a line edited: old and new), REMOVED, ADDED, GAP
36    old_no: int | None       # 1-based line numbers (None: no line on that side)
37    old: str | None
38    new_no: int | None
39    new: str | None
40    hidden: int = 0          # GAP: how many unchanged lines it stands for

Row(kind, old_no, old, new_no, new, hidden)

Row( kind: str, old_no: int | None, old: str | None, new_no: int | None, new: str | None, hidden: int = 0)

Create new instance of Row(kind, old_no, old, new_no, new, hidden)

kind: str

Alias for field number 0

old_no: int | None

Alias for field number 1

old: str | None

Alias for field number 2

new_no: int | None

Alias for field number 3

new: str | None

Alias for field number 4

hidden: int

Alias for field number 5

def lines_of(text: str | None) -> list[str]:
43def lines_of(text: str | None) -> list[str]:
44    """A text's lines (none for None or "")."""
45    if not text:
46        return []
47    lines = text.split("\n")
48    return lines[:-1] if text.endswith("\n") else lines

A text's lines (none for None or "").

def align(old: list[str], new: list[str]) -> list[Row]:
51def align(old: list[str], new: list[str]) -> list[Row]:
52    """Every line of both versions, lined up: unchanged lines side by side,
53    edited ones paired (CHANGED), the rest REMOVED or ADDED. The lines the
54    two begin and end with alike are matched as they are; only those
55    between (usually a small part of a file) are compared line by line,
56    which takes time growing with their number squared (a 12,000-line file
57    whole: a third of a second)."""
58    shorter = min(len(old), len(new))
59    start = 0
60    while start < shorter and old[start] == new[start]:
61        start += 1
62    end = 0  # (lines alike at the end, after those at the start)
63    while end < shorter - start and old[-1 - end] == new[-1 - end]:
64        end += 1
65    rows: list[Row] = [Row(SAME, i + 1, old[i], i + 1, new[i]) for i in range(start)]
66    matcher = difflib.SequenceMatcher(None, old[start:len(old) - end], new[start:len(new) - end], autojunk=False)
67    for op, a0, a1, b0, b1 in matcher.get_opcodes():
68        a0, a1, b0, b1 = a0 + start, a1 + start, b0 + start, b1 + start
69        if op == "equal":
70            rows += [Row(SAME, a0 + i + 1, old[a0 + i], b0 + i + 1, new[b0 + i]) for i in range(a1 - a0)]
71            continue
72        paired = min(a1 - a0, b1 - b0) if op == "replace" else 0
73        rows += [Row(CHANGED, a0 + i + 1, old[a0 + i], b0 + i + 1, new[b0 + i]) for i in range(paired)]
74        rows += [Row(REMOVED, a, old[a - 1], None, None) for a in range(a0 + paired + 1, a1 + 1)]
75        rows += [Row(ADDED, None, None, b, new[b - 1]) for b in range(b0 + paired + 1, b1 + 1)]
76    rows += [Row(SAME, len(old) - end + i + 1, old[len(old) - end + i], len(new) - end + i + 1,
77                 new[len(new) - end + i]) for i in range(end)]
78    return rows

Every line of both versions, lined up: unchanged lines side by side, edited ones paired (CHANGED), the rest REMOVED or ADDED. The lines the two begin and end with alike are matched as they are; only those between (usually a small part of a file) are compared line by line, which takes time growing with their number squared (a 12,000-line file whole: a third of a second).

def compare(old: str | None, new: str | None) -> list[Row]:
81def compare(old: str | None, new: str | None) -> list[Row]:
82    """Two versions' rows to show (`align`, folded with `with_context`): no
83    Qt, so it can be worked out on a pool thread (see DiffView.show_rows)."""
84    return with_context(align(lines_of(old), lines_of(new)))

Two versions' rows to show (align, folded with with_context): no Qt, so it can be worked out on a pool thread (see DiffView.show_rows).

def with_context( rows: list[Row], context: int = 3) -> list[Row]:
 87def with_context(rows: list[Row], context: int = CONTEXT) -> list[Row]:
 88    """The rows near a change (``context`` unchanged lines around each), the
 89    other unchanged ones folded into GAP rows."""
 90    near = [False] * len(rows)
 91    for i, row in enumerate(rows):
 92        if row.kind != SAME:
 93            for j in range(max(0, i - context), min(len(rows), i + context + 1)):
 94                near[j] = True
 95    out: list[Row] = []
 96    i = 0
 97    while i < len(rows):
 98        if near[i]:
 99            out.append(rows[i])
100            i += 1
101            continue
102        j = i
103        while j < len(rows) and not near[j]:
104            j += 1
105        out.append(Row(GAP, None, None, None, None, j - i))
106        i = j
107    return out

The rows near a change (context unchanged lines around each), the other unchanged ones folded into GAP rows.

class PaneRows(typing.NamedTuple):
110class PaneRows(NamedTuple):
111    """What a pane shows, row by row."""
112    text: list[str]
113    gutter: list[tuple[str, str]]  # (numbers, mark: "-", "+" or "")
114    kinds: list[str]               # SAME, REMOVED, ADDED, FILLER, GAP
115    new_lines: list[int | None]    # the newer version's line on each row (None: none there)

What a pane shows, row by row.

PaneRows( text: list[str], gutter: list[tuple[str, str]], kinds: list[str], new_lines: list[int | None])

Create new instance of PaneRows(text, gutter, kinds, new_lines)

text: list[str]

Alias for field number 0

gutter: list[tuple[str, str]]

Alias for field number 1

kinds: list[str]

Alias for field number 2

new_lines: list[int | None]

Alias for field number 3

def side_by_side( rows: list[Row]) -> tuple[PaneRows, PaneRows]:
118def side_by_side(rows: list[Row]) -> tuple[PaneRows, PaneRows]:
119    """The older and the newer version's panes: a row each per Row."""
120    old, new = PaneRows([], [], [], []), PaneRows([], [], [], [])
121    for row in rows:
122        for pane, number, line, kind, mark in ((old, row.old_no, row.old, REMOVED, "-"),
123                                              (new, row.new_no, row.new, ADDED, "+")):
124            pane.new_lines.append(row.new_no)
125            if row.kind == GAP:
126                pane.text.append("")
127                pane.gutter.append((f"⋯ {row.hidden:,}", ""))
128                pane.kinds.append(GAP)
129                continue
130            changed = row.kind in (CHANGED, kind)
131            pane.text.append(line if line is not None else "")
132            pane.gutter.append((str(number) if number else "", mark if changed else ""))
133            pane.kinds.append(kind if changed else FILLER if number is None else SAME)
134    return old, new

The older and the newer version's panes: a row each per Row.

def unified(rows: list[Row]) -> PaneRows:
137def unified(rows: list[Row]) -> PaneRows:
138    """One pane: unchanged lines once; within each run of changes, the
139    removed lines and then the added ones."""
140    pane = PaneRows([], [], [], [])
141    removed: list[Row] = []
142    added: list[Row] = []
143
144    def flush():
145        for row in removed:
146            pane.text.append(row.old)
147            pane.gutter.append((f"{row.old_no:>5} {'':>5}", "-"))
148            pane.kinds.append(REMOVED)
149            pane.new_lines.append(None)
150        for row in added:
151            pane.text.append(row.new)
152            pane.gutter.append((f"{'':>5} {row.new_no:>5}", "+"))
153            pane.kinds.append(ADDED)
154            pane.new_lines.append(row.new_no)
155        removed.clear()
156        added.clear()
157
158    for row in rows:
159        if row.kind in (REMOVED, CHANGED):
160            removed.append(row)
161        if row.kind in (ADDED, CHANGED):
162            added.append(row)
163        if row.kind in (SAME, GAP):
164            flush()
165            pane.text.append(row.new if row.kind == SAME else "")
166            pane.gutter.append((f"{row.old_no:>5} {row.new_no:>5}", "") if row.kind == SAME
167                               else (f"⋯ {row.hidden:,}", ""))
168            pane.kinds.append(row.kind)
169            pane.new_lines.append(row.new_no)
170    flush()
171    return pane

One pane: unchanged lines once; within each run of changes, the removed lines and then the added ones.

class DiffPane(PySide6.QtWidgets.QPlainTextEdit):
187class DiffPane(QPlainTextEdit):
188    """One side of a diff (or the unified list): read-only text, a gutter of
189    line numbers (and -/+ marks), and a tint behind each changed line."""
190
191    def __init__(self, parent=None):
192        super().__init__(parent)
193        self.setReadOnly(True)
194        self.setLineWrapMode(QPlainTextEdit.NoWrap)
195        self.rows = PaneRows([], [], [], [])
196        self.highlighter: PygmentsHighlighter | None = None
197        self.gutter = _Gutter(self)
198        self.updateRequest.connect(self._scroll_gutter)
199
200    def gutter_width(self) -> int:
201        widest = max((len(numbers) for numbers, _mark in self.rows.gutter), default=1)
202        return self.fontMetrics().horizontalAdvance("9" * widest + " + ") + 8
203
204    def _place_gutter(self):
205        self.setViewportMargins(self.gutter_width(), 0, 0, 0)
206        r = self.contentsRect()
207        self.gutter.setGeometry(QRect(r.left(), r.top(), self.gutter_width(), r.height()))
208
209    def _scroll_gutter(self, rect, dy):
210        if dy:
211            self.gutter.scroll(0, dy)
212        else:
213            self.gutter.update(0, rect.y(), self.gutter.width(), rect.height())
214
215    def resizeEvent(self, event):
216        super().resizeEvent(event)
217        self._place_gutter()
218
219    def show_rows(self, rows: PaneRows, path: str):
220        """``path`` picks the syntax colors."""
221        self.rows = rows
222        lexer = lexer_for_path(path)
223        if self.highlighter is None:
224            self.highlighter = PygmentsHighlighter(self.document(), lexer)
225        with self.highlighter.replacing_text(lexer):  # (colored at once: not typed)
226            self.setPlainText("\n".join(rows.text))
227        apply_style(self, self.highlighter)
228        self._place_gutter()
229        self._tint()
230
231    def refresh_style(self):
232        if self.highlighter is not None:
233            apply_style(self, self.highlighter)
234        self._tint()
235
236    def set_font(self, font: QFont):
237        self.setFont(font)
238        self._place_gutter()
239
240    def line_at(self, row: int) -> int | None:
241        """The newer version's line at a row, or the nearest one before it (a
242        removed line has none of its own)."""
243        for line in reversed(self.rows.new_lines[:row + 1]):
244            if line is not None:
245                return line
246        return None
247
248    def _tint(self):
249        """A tint behind removed and added lines; rows with no line, and
250        folded unchanged ones, a little darker than the background."""
251        dark = is_dark(self.palette())
252        base = self.palette().color(QPalette.Base)
253        colors = {REMOVED: QColor(REMOVED_COLOR), ADDED: QColor(ADDED_COLOR)}
254        for color in colors.values():
255            color.setAlpha(70 if dark else 45)
256        colors[FILLER] = colors[GAP] = base.lighter(125) if dark else base.darker(106)
257        selections = []
258        block = self.document().firstBlock()
259        kinds = self.rows.kinds
260        while block.isValid():
261            n = block.blockNumber()
262            color = colors.get(kinds[n]) if n < len(kinds) else None
263            if color is not None:
264                selection = QTextEdit.ExtraSelection()
265                selection.format = QTextCharFormat()
266                selection.format.setBackground(color)
267                selection.format.setProperty(QTextFormat.FullWidthSelection, True)
268                selection.cursor = self.textCursor()
269                selection.cursor.setPosition(block.position())
270                selections.append(selection)
271            block = block.next()
272        self.setExtraSelections(selections)
273
274    def paint_gutter(self, event):
275        painter = QPainter(self.gutter)
276        dark = is_dark(self.palette())
277        base = self.palette().color(QPalette.Base)
278        painter.fillRect(event.rect(), base.lighter(112) if dark else base.darker(104))
279        muted = QColor(self.palette().color(QPalette.Text))
280        muted.setAlpha(130)
281        block = self.firstVisibleBlock()
282        top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
283        height = self.fontMetrics().height()
284        while block.isValid() and top <= event.rect().bottom():
285            n = block.blockNumber()
286            if n < len(self.rows.gutter):
287                numbers, mark = self.rows.gutter[n]
288                if self.rows.kinds[n] == GAP:
289                    painter.setPen(muted)
290                    painter.drawText(QRect(0, top, self.gutter.width(), height), Qt.AlignCenter, numbers)
291                else:
292                    painter.setPen(REMOVED_COLOR if mark == "-" else ADDED_COLOR if mark == "+" else muted)
293                    painter.drawText(QRect(0, top, self.gutter.width() - 6, height),
294                                     Qt.AlignRight | Qt.AlignVCenter, f"{numbers} {mark or ' '}")
295            top += round(self.blockBoundingRect(block).height())
296            block = block.next()

One side of a diff (or the unified list): read-only text, a gutter of line numbers (and -/+ marks), and a tint behind each changed line.

DiffPane(parent=None)
191    def __init__(self, parent=None):
192        super().__init__(parent)
193        self.setReadOnly(True)
194        self.setLineWrapMode(QPlainTextEdit.NoWrap)
195        self.rows = PaneRows([], [], [], [])
196        self.highlighter: PygmentsHighlighter | None = None
197        self.gutter = _Gutter(self)
198        self.updateRequest.connect(self._scroll_gutter)

__init__(self, text: str, /, parent: PySide6.QtWidgets.QWidget | None = None, *, tabChangesFocus: bool | None = None, documentTitle: str | None = None, undoRedoEnabled: bool | None = None, lineWrapMode: PySide6.QtWidgets.QPlainTextEdit.LineWrapMode | None = None, readOnly: bool | None = None, plainText: str | None = None, overwriteMode: bool | None = None, tabStopDistance: float | None = None, cursorWidth: int | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, blockCount: int | None = None, maximumBlockCount: int | None = None, backgroundVisible: bool | None = None, centerOnScroll: bool | None = None, placeholderText: str | None = None) -> None __init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, *, tabChangesFocus: bool | None = None, documentTitle: str | None = None, undoRedoEnabled: bool | None = None, lineWrapMode: PySide6.QtWidgets.QPlainTextEdit.LineWrapMode | None = None, readOnly: bool | None = None, plainText: str | None = None, overwriteMode: bool | None = None, tabStopDistance: float | None = None, cursorWidth: int | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, blockCount: int | None = None, maximumBlockCount: int | None = None, backgroundVisible: bool | None = None, centerOnScroll: bool | None = None, placeholderText: str | None = None) -> None

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

rows
gutter
def gutter_width(self) -> int:
200    def gutter_width(self) -> int:
201        widest = max((len(numbers) for numbers, _mark in self.rows.gutter), default=1)
202        return self.fontMetrics().horizontalAdvance("9" * widest + " + ") + 8
def resizeEvent(self, event):
215    def resizeEvent(self, event):
216        super().resizeEvent(event)
217        self._place_gutter()

resizeEvent(self, e: PySide6.QtGui.QResizeEvent, /) -> None

def show_rows(self, rows: PaneRows, path: str):
219    def show_rows(self, rows: PaneRows, path: str):
220        """``path`` picks the syntax colors."""
221        self.rows = rows
222        lexer = lexer_for_path(path)
223        if self.highlighter is None:
224            self.highlighter = PygmentsHighlighter(self.document(), lexer)
225        with self.highlighter.replacing_text(lexer):  # (colored at once: not typed)
226            self.setPlainText("\n".join(rows.text))
227        apply_style(self, self.highlighter)
228        self._place_gutter()
229        self._tint()

path picks the syntax colors.

def refresh_style(self):
231    def refresh_style(self):
232        if self.highlighter is not None:
233            apply_style(self, self.highlighter)
234        self._tint()
def set_font(self, font: PySide6.QtGui.QFont):
236    def set_font(self, font: QFont):
237        self.setFont(font)
238        self._place_gutter()
def line_at(self, row: int) -> int | None:
240    def line_at(self, row: int) -> int | None:
241        """The newer version's line at a row, or the nearest one before it (a
242        removed line has none of its own)."""
243        for line in reversed(self.rows.new_lines[:row + 1]):
244            if line is not None:
245                return line
246        return None

The newer version's line at a row, or the nearest one before it (a removed line has none of its own).

def paint_gutter(self, event):
274    def paint_gutter(self, event):
275        painter = QPainter(self.gutter)
276        dark = is_dark(self.palette())
277        base = self.palette().color(QPalette.Base)
278        painter.fillRect(event.rect(), base.lighter(112) if dark else base.darker(104))
279        muted = QColor(self.palette().color(QPalette.Text))
280        muted.setAlpha(130)
281        block = self.firstVisibleBlock()
282        top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
283        height = self.fontMetrics().height()
284        while block.isValid() and top <= event.rect().bottom():
285            n = block.blockNumber()
286            if n < len(self.rows.gutter):
287                numbers, mark = self.rows.gutter[n]
288                if self.rows.kinds[n] == GAP:
289                    painter.setPen(muted)
290                    painter.drawText(QRect(0, top, self.gutter.width(), height), Qt.AlignCenter, numbers)
291                else:
292                    painter.setPen(REMOVED_COLOR if mark == "-" else ADDED_COLOR if mark == "+" else muted)
293                    painter.drawText(QRect(0, top, self.gutter.width() - 6, height),
294                                     Qt.AlignRight | Qt.AlignVCenter, f"{numbers} {mark or ' '}")
295            top += round(self.blockBoundingRect(block).height())
296            block = block.next()
staticMetaObject = PySide6.QtCore.QMetaObject("DiffPane" inherits "QPlainTextEdit": )
class DiffView(PySide6.QtWidgets.QWidget):
299class DiffView(QWidget):
300    """Two versions of a file, side by side or as one unified list.
301    Double-clicking a line asks to open the file there."""
302
303    open_requested = Signal(int)  # a line of the newer version (1-based)
304
305    def __init__(self, parent=None):
306        super().__init__(parent)
307        self.unified = False
308        self.rows: list[Row] = []
309        self.path = ""
310        self.old_pane, self.new_pane, self.unified_pane = DiffPane(), DiffPane(), DiffPane()
311        side = QWidget()
312        side_layout = QHBoxLayout(side)
313        side_layout.setContentsMargins(0, 0, 0, 0)
314        side_layout.setSpacing(2)
315        side_layout.addWidget(self.old_pane)
316        side_layout.addWidget(self.new_pane)
317        self.stack = QStackedWidget()
318        self.stack.addWidget(side)
319        self.stack.addWidget(self.unified_pane)
320        layout = QHBoxLayout(self)
321        layout.setContentsMargins(0, 0, 0, 0)
322        layout.addWidget(self.stack)
323        # The two sides scroll together (they have the same rows).
324        for a, b in ((self.old_pane, self.new_pane), (self.new_pane, self.old_pane)):
325            a.verticalScrollBar().valueChanged.connect(b.verticalScrollBar().setValue)
326            a.horizontalScrollBar().valueChanged.connect(b.horizontalScrollBar().setValue)
327        for pane in self.panes():
328            pane.viewport().installEventFilter(self)
329
330    def panes(self) -> tuple[DiffPane, DiffPane, DiffPane]:
331        return self.old_pane, self.new_pane, self.unified_pane
332
333    def set_unified(self, on: bool):
334        self.unified = on
335        self.stack.setCurrentIndex(1 if on else 0)
336        self._show()
337
338    def show_diff(self, old: str | None, new: str | None, path: str):
339        """Compare two versions (None: there's no such version, so every line
340        of the other is added or removed)."""
341        self.show_rows(compare(old, new), path)
342
343    def show_rows(self, rows: list[Row], path: str):
344        """Rows already compared (`compare`, on a pool thread for big files)."""
345        self.path = path
346        self.rows = rows
347        self._show()
348
349    def clear(self):
350        self.rows, self.path = [], ""
351        for pane in self.panes():
352            pane.show_rows(PaneRows([], [], [], []), "")
353
354    def _show(self):
355        if self.unified:
356            self.unified_pane.show_rows(unified(self.rows), self.path)
357        else:
358            old, new = side_by_side(self.rows)
359            self.old_pane.show_rows(old, self.path)
360            self.new_pane.show_rows(new, self.path)
361
362    def refresh_style(self):
363        for pane in self.panes():
364            pane.refresh_style()
365
366    def set_font(self, font: QFont):
367        for pane in self.panes():
368            pane.set_font(font)
369
370    def eventFilter(self, obj, event):
371        if event.type() == event.Type.MouseButtonDblClick:
372            pane = next((p for p in self.panes() if p.viewport() is obj), None)
373            if pane is not None:
374                line = pane.line_at(pane.cursorForPosition(event.position().toPoint()).blockNumber())
375                if line is not None:
376                    self.open_requested.emit(line)
377                    return True
378        return False

Two versions of a file, side by side or as one unified list. Double-clicking a line asks to open the file there.

DiffView(parent=None)
305    def __init__(self, parent=None):
306        super().__init__(parent)
307        self.unified = False
308        self.rows: list[Row] = []
309        self.path = ""
310        self.old_pane, self.new_pane, self.unified_pane = DiffPane(), DiffPane(), DiffPane()
311        side = QWidget()
312        side_layout = QHBoxLayout(side)
313        side_layout.setContentsMargins(0, 0, 0, 0)
314        side_layout.setSpacing(2)
315        side_layout.addWidget(self.old_pane)
316        side_layout.addWidget(self.new_pane)
317        self.stack = QStackedWidget()
318        self.stack.addWidget(side)
319        self.stack.addWidget(self.unified_pane)
320        layout = QHBoxLayout(self)
321        layout.setContentsMargins(0, 0, 0, 0)
322        layout.addWidget(self.stack)
323        # The two sides scroll together (they have the same rows).
324        for a, b in ((self.old_pane, self.new_pane), (self.new_pane, self.old_pane)):
325            a.verticalScrollBar().valueChanged.connect(b.verticalScrollBar().setValue)
326            a.horizontalScrollBar().valueChanged.connect(b.horizontalScrollBar().setValue)
327        for pane in self.panes():
328            pane.viewport().installEventFilter(self)

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

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

def open_requested(unknown):

The type of the None singleton.

unified
rows: list[Row]
path
stack
def panes( self) -> tuple[DiffPane, DiffPane, DiffPane]:
330    def panes(self) -> tuple[DiffPane, DiffPane, DiffPane]:
331        return self.old_pane, self.new_pane, self.unified_pane
def set_unified(self, on: bool):
333    def set_unified(self, on: bool):
334        self.unified = on
335        self.stack.setCurrentIndex(1 if on else 0)
336        self._show()
def show_diff(self, old: str | None, new: str | None, path: str):
338    def show_diff(self, old: str | None, new: str | None, path: str):
339        """Compare two versions (None: there's no such version, so every line
340        of the other is added or removed)."""
341        self.show_rows(compare(old, new), path)

Compare two versions (None: there's no such version, so every line of the other is added or removed).

def show_rows(self, rows: list[Row], path: str):
343    def show_rows(self, rows: list[Row], path: str):
344        """Rows already compared (`compare`, on a pool thread for big files)."""
345        self.path = path
346        self.rows = rows
347        self._show()

Rows already compared (compare, on a pool thread for big files).

def clear(self):
349    def clear(self):
350        self.rows, self.path = [], ""
351        for pane in self.panes():
352            pane.show_rows(PaneRows([], [], [], []), "")
def refresh_style(self):
362    def refresh_style(self):
363        for pane in self.panes():
364            pane.refresh_style()
def set_font(self, font: PySide6.QtGui.QFont):
366    def set_font(self, font: QFont):
367        for pane in self.panes():
368            pane.set_font(font)
def eventFilter(self, obj, event):
370    def eventFilter(self, obj, event):
371        if event.type() == event.Type.MouseButtonDblClick:
372            pane = next((p for p in self.panes() if p.viewport() is obj), None)
373            if pane is not None:
374                line = pane.line_at(pane.cursorForPosition(event.position().toPoint()).blockNumber())
375                if line is not None:
376                    self.open_requested.emit(line)
377                    return True
378        return False

eventFilter(self, watched: PySide6.QtCore.QObject, event: PySide6.QtCore.QEvent, /) -> bool

staticMetaObject = PySide6.QtCore.QMetaObject("DiffView" inherits "QWidget": Methods: #33 type=Signal, signature=open_requested(int), parameters=int )