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