Coverage for vibefpmgui/diff_view.py: 95%
274 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Showing two versions of a file: side by side, or as one unified list.
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"""
10from __future__ import annotations
12import difflib
13from typing import NamedTuple
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)
25from .highlighter import PygmentsHighlighter, apply_style, is_dark, lexer_for_path
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")
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
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
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
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)))
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
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)
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
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] = []
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()
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
173# ------------------------------------------------------------------ widgets
174class _Gutter(QWidget):
175 def __init__(self, pane: DiffPane):
176 super().__init__(pane)
177 self.pane = pane
179 def sizeHint(self):
180 return QSize(self.pane.gutter_width(), 0)
182 def paintEvent(self, event):
183 self.pane.paint_gutter(event)
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."""
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)
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
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()))
208 def _scroll_gutter(self, rect, dy):
209 if dy: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 self.gutter.scroll(0, dy)
211 else:
212 self.gutter.update(0, rect.y(), self.gutter.width(), rect.height())
214 def resizeEvent(self, event):
215 super().resizeEvent(event)
216 self._place_gutter()
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()
230 def refresh_style(self):
231 if self.highlighter is not None:
232 apply_style(self, self.highlighter)
233 self._tint()
235 def set_font(self, font: QFont):
236 self.setFont(font)
237 self._place_gutter()
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]): 242 ↛ 245line 242 didn't jump to line 245 because the loop on line 242 didn't complete
243 if line is not None:
244 return line
245 return None
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)
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()
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."""
302 open_requested = Signal(int) # a line of the newer version (1-based)
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)
329 def panes(self) -> tuple[DiffPane, DiffPane, DiffPane]:
330 return self.old_pane, self.new_pane, self.unified_pane
332 def set_unified(self, on: bool):
333 self.unified = on
334 self.stack.setCurrentIndex(1 if on else 0)
335 self._show()
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)
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()
348 def clear(self):
349 self.rows, self.path = [], ""
350 for pane in self.panes():
351 pane.show_rows(PaneRows([], [], [], []), "")
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)
361 def refresh_style(self):
362 for pane in self.panes():
363 pane.refresh_style()
365 def set_font(self, font: QFont):
366 for pane in self.panes():
367 pane.set_font(font)
369 def eventFilter(self, obj, event):
370 if event.type() == event.Type.MouseButtonDblClick: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true
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