Coverage for vibefpmgui/rename_dialog.py: 91%
145 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"""Preview of a Rename Symbol: which files and lines will change.
3Each file has a checkbox. Files outside the project (dependencies) start
4unticked: fpm re-downloads git dependencies, so edits there would be lost,
5and a dependency is usually not yours to change. Each changed line is shown
6as it will read, in the editor's syntax colors (as in the Search panel), the
7new name highlighted.
8"""
10from __future__ import annotations
12from collections.abc import Callable
13from pathlib import Path
15from pygments.styles import get_style_by_name
16from qtpy.QtCore import QRect, Qt
17from qtpy.QtGui import QColor, QFont, QFontMetrics, QPalette
18from qtpy.QtWidgets import (
19 QApplication,
20 QDialog,
21 QDialogButtonBox,
22 QLabel,
23 QStyle,
24 QStyledItemDelegate,
25 QStyleOptionViewItem,
26 QTreeWidget,
27 QTreeWidgetItem,
28 QVBoxLayout,
29)
31from . import highlighter as hl
32from . import lsp as lsp_mod
33from .references_panel import short_path
34from .result_tree import MATCH_COLOR, paint_code, syntax_formats
36PATH_ROLE = Qt.UserRole + 1
37LINE_ROLE = Qt.UserRole + 2 # a changed line's row: (file, line (0-based), the new name's (start, end) in it)
40def renamed_spans(edits: list[lsp_mod.TextEdit], line: int) -> list[tuple[int, int]]:
41 """Where a line's edits put their new text, in the line as it will read
42 (each edit shifts those after it on the line)."""
43 spans, shift = [], 0
44 for edit in sorted((e for e in edits if e.line == line == e.end_line), key=lambda e: e.column):
45 start = edit.column + shift
46 spans.append((start, start + len(edit.new_text)))
47 shift += len(edit.new_text) - (edit.end_column - edit.column)
48 return spans
51class _LineDelegate(QStyledItemDelegate):
52 """A changed line: its number, then the line in the editor's font and
53 syntax colors, the new name highlighted (plain on the selected row)."""
55 def __init__(self, dialog: RenamePreviewDialog):
56 super().__init__(dialog.tree)
57 self.dialog = dialog
59 def paint(self, painter, option, index):
60 where = index.data(LINE_ROLE)
61 if where is None:
62 super().paint(painter, option, index)
63 return
64 path, line, spans = where
65 opt = QStyleOptionViewItem(option)
66 self.initStyleOption(opt, index)
67 opt.text = ""
68 style = opt.widget.style() if opt.widget else QApplication.style()
69 style.drawControl(QStyle.CE_ItemViewItem, opt, painter, opt.widget)
70 selected = bool(opt.state & QStyle.State_Selected)
71 group = QPalette.Active if opt.state & QStyle.State_Active else QPalette.Inactive
72 plain = opt.palette.color(group, QPalette.HighlightedText if selected else QPalette.Text)
73 dim = QColor(plain)
74 dim.setAlpha(140)
75 mono = self.dialog.mono
76 full = self.dialog.lines(path)[line] if line < len(self.dialog.lines(path)) else ""
77 code = full.lstrip()
78 column = len(full) - len(code)
79 painter.save()
80 painter.setClipRect(opt.rect)
81 number = f"{line + 1:>5} "
82 painter.setFont(mono)
83 painter.setPen(dim)
84 width = QFontMetrics(mono).horizontalAdvance(number)
85 painter.drawText(QRect(opt.rect.left() + 4, opt.rect.top(), width, opt.rect.height()),
86 Qt.AlignVCenter | Qt.AlignLeft, number)
87 left = opt.rect.left() + 4 + width
88 metrics = QFontMetrics(mono)
89 for start, end in spans: # (the new name: highlighted, as the Search panel's matches)
90 a, b = max(start - column, 0), max(end - column, 0)
91 x = left + metrics.horizontalAdvance(code[:a])
92 painter.fillRect(QRect(x, opt.rect.top() + 2, metrics.horizontalAdvance(code[a:b]),
93 opt.rect.height() - 4), MATCH_COLOR)
94 formats = None if selected else self.dialog.line_formats(path, line)
95 paint_code(painter, QRect(left, opt.rect.top(), opt.rect.right() - left, opt.rect.height()), code,
96 formats, column, mono, plain)
97 painter.restore()
100def is_dependency(path: Path, project_dir: Path | None) -> bool:
101 if project_dir is None: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 return False
103 root = project_dir.resolve()
104 if root not in path.parents: 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true
105 return True
106 return "dependencies" in path.relative_to(root).parts[:2] # build/dependencies/...
109class RenamePreviewDialog(QDialog):
110 def __init__(self, old: str, new: str, edits: dict[Path, list[lsp_mod.TextEdit]],
111 file_text: Callable[[Path], str], open_files: set[Path], project_dir: Path | None,
112 mono: QFont | None = None, parent=None):
113 super().__init__(parent)
114 self.setWindowTitle("Rename Symbol")
115 self.resize(760, 480)
116 count = sum(len(e) for e in edits.values())
117 intro = QLabel(f"Rename <b>{old}</b> to <b>{new}</b>: {count} change{'s' * (count != 1)} in "
118 f"{len(edits)} file{'s' * (len(edits) != 1)}. Files open in the editor are changed "
119 "there (unsaved, Undo reverts); other files are saved straight away.")
120 intro.setWordWrap(True)
121 self.mono = mono or QFont()
122 self._lines: dict[str, list[str]] = {} # file -> its lines as they will read
123 self._style = get_style_by_name(hl.style_name(QApplication.palette()))
124 self._formats: dict = {} # token type -> format
125 self._line_formats: dict[tuple[str, int], list | None] = {}
126 self.tree = QTreeWidget()
127 self.tree.setHeaderHidden(True)
128 self.tree.setUniformRowHeights(True)
129 self.tree.setItemDelegate(_LineDelegate(self))
130 deps = False
131 for path in sorted(edits, key=lambda p: (is_dependency(p, project_dir), short_path(p, project_dir))):
132 file_edits = edits[path]
133 dependency = is_dependency(path, project_dir)
134 deps |= dependency
135 notes = []
136 if path in open_files:
137 notes.append("open in the editor")
138 if dependency: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 notes.append("dependency — not changed unless ticked")
140 note = f" — {', '.join(notes)}" if notes else ""
141 node = QTreeWidgetItem([f"{short_path(path, project_dir)} ({len(file_edits)}){note}"])
142 node.setFlags(node.flags() | Qt.ItemIsUserCheckable)
143 node.setCheckState(0, Qt.Unchecked if dependency else Qt.Checked)
144 node.setData(0, PATH_ROLE, str(path))
145 node.setToolTip(0, str(path))
146 self.tree.addTopLevelItem(node)
147 # The changed lines, as they will read afterwards.
148 try:
149 old_lines = file_text(path).split("\n")
150 except OSError:
151 old_lines = []
152 new_lines = lsp_mod.apply_edits("\n".join(old_lines), file_edits).split("\n")
153 self._lines[str(path)] = new_lines
154 for line in sorted({e.line for e in file_edits}):
155 after = new_lines[line].strip() if line < len(new_lines) else ""
156 before = old_lines[line].strip() if line < len(old_lines) else ""
157 child = QTreeWidgetItem([f"{line + 1}: {after}"])
158 child.setToolTip(0, f"Before: {before}\nAfter: {after}")
159 child.setData(0, LINE_ROLE, (str(path), line, renamed_spans(file_edits, line)))
160 if mono is not None: 160 ↛ 162line 160 didn't jump to line 162 because the condition on line 160 was always true
161 child.setFont(0, mono)
162 child.setFlags(child.flags() & ~Qt.ItemIsUserCheckable)
163 node.addChild(child)
164 node.setExpanded(len(edits) <= 6)
165 buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
166 buttons.button(QDialogButtonBox.Ok).setText("Rename")
167 buttons.accepted.connect(self.accept)
168 buttons.rejected.connect(self.reject)
169 layout = QVBoxLayout(self)
170 layout.addWidget(intro)
171 layout.addWidget(self.tree, 1)
172 if deps: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 warn = QLabel("Files in dependencies are left unticked: fpm may download them again "
174 "(losing the change), and the rest of that package wouldn't be renamed.")
175 warn.setWordWrap(True)
176 warn.setStyleSheet("color: gray;")
177 layout.addWidget(warn)
178 layout.addWidget(buttons)
180 def lines(self, path: str) -> list[str]:
181 return self._lines.get(path, [])
183 def line_formats(self, path: str, line: int) -> list | None:
184 """The syntax format of each column of a changed line, as it will read
185 (lexed with the lines before it, as the Search panel's)."""
186 key = (path, line)
187 if key not in self._line_formats:
188 self._line_formats[key] = syntax_formats(self.lines(path), line + 1, Path(path), self._style,
189 self._formats)
190 return self._line_formats[key]
192 def selected(self) -> list[Path]:
193 out = []
194 for i in range(self.tree.topLevelItemCount()):
195 node = self.tree.topLevelItem(i)
196 if node.checkState(0) == Qt.Checked: 196 ↛ 194line 196 didn't jump to line 194 because the condition on line 196 was always true
197 out.append(Path(node.data(0, PATH_ROLE)))
198 return out