vibefpmgui.rename_dialog

Preview of a Rename Symbol: which files and lines will change.

Each file has a checkbox. Files outside the project (dependencies) start unticked: fpm re-downloads git dependencies, so edits there would be lost, and a dependency is usually not yours to change. Each changed line is shown as it will read, in the editor's syntax colors (as in the Search panel), the new name highlighted.

  1"""Preview of a Rename Symbol: which files and lines will change.
  2
  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"""
  9
 10from __future__ import annotations
 11
 12from collections.abc import Callable
 13from pathlib import Path
 14
 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)
 30
 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
 35
 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)
 38
 39
 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
 49
 50
 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)."""
 54
 55    def __init__(self, dialog: RenamePreviewDialog):
 56        super().__init__(dialog.tree)
 57        self.dialog = dialog
 58
 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()
 98
 99
100def is_dependency(path: Path, project_dir: Path | None) -> bool:
101    if project_dir is None:
102        return False
103    root = project_dir.resolve()
104    if root not in path.parents:
105        return True
106    return "dependencies" in path.relative_to(root).parts[:2]  # build/dependencies/...
107
108
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:
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:
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:
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)
179
180    def lines(self, path: str) -> list[str]:
181        return self._lines.get(path, [])
182
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]
191
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:
197                out.append(Path(node.data(0, PATH_ROLE)))
198        return out
PATH_ROLE = 257
LINE_ROLE = 258
def renamed_spans(edits: list[vibefpmgui.lsp.TextEdit], line: int) -> list[tuple[int, int]]:
41def renamed_spans(edits: list[lsp_mod.TextEdit], line: int) -> list[tuple[int, int]]:
42    """Where a line's edits put their new text, in the line as it will read
43    (each edit shifts those after it on the line)."""
44    spans, shift = [], 0
45    for edit in sorted((e for e in edits if e.line == line == e.end_line), key=lambda e: e.column):
46        start = edit.column + shift
47        spans.append((start, start + len(edit.new_text)))
48        shift += len(edit.new_text) - (edit.end_column - edit.column)
49    return spans

Where a line's edits put their new text, in the line as it will read (each edit shifts those after it on the line).

def is_dependency(path: pathlib.Path, project_dir: pathlib.Path | None) -> bool:
101def is_dependency(path: Path, project_dir: Path | None) -> bool:
102    if project_dir is None:
103        return False
104    root = project_dir.resolve()
105    if root not in path.parents:
106        return True
107    return "dependencies" in path.relative_to(root).parts[:2]  # build/dependencies/...
class RenamePreviewDialog(PySide6.QtWidgets.QDialog):
110class RenamePreviewDialog(QDialog):
111    def __init__(self, old: str, new: str, edits: dict[Path, list[lsp_mod.TextEdit]],
112                 file_text: Callable[[Path], str], open_files: set[Path], project_dir: Path | None,
113                 mono: QFont | None = None, parent=None):
114        super().__init__(parent)
115        self.setWindowTitle("Rename Symbol")
116        self.resize(760, 480)
117        count = sum(len(e) for e in edits.values())
118        intro = QLabel(f"Rename <b>{old}</b> to <b>{new}</b>: {count} change{'s' * (count != 1)} in "
119                       f"{len(edits)} file{'s' * (len(edits) != 1)}. Files open in the editor are changed "
120                       "there (unsaved, Undo reverts); other files are saved straight away.")
121        intro.setWordWrap(True)
122        self.mono = mono or QFont()
123        self._lines: dict[str, list[str]] = {}  # file -> its lines as they will read
124        self._style = get_style_by_name(hl.style_name(QApplication.palette()))
125        self._formats: dict = {}  # token type -> format
126        self._line_formats: dict[tuple[str, int], list | None] = {}
127        self.tree = QTreeWidget()
128        self.tree.setHeaderHidden(True)
129        self.tree.setUniformRowHeights(True)
130        self.tree.setItemDelegate(_LineDelegate(self))
131        deps = False
132        for path in sorted(edits, key=lambda p: (is_dependency(p, project_dir), short_path(p, project_dir))):
133            file_edits = edits[path]
134            dependency = is_dependency(path, project_dir)
135            deps |= dependency
136            notes = []
137            if path in open_files:
138                notes.append("open in the editor")
139            if dependency:
140                notes.append("dependency — not changed unless ticked")
141            note = f"  — {', '.join(notes)}" if notes else ""
142            node = QTreeWidgetItem([f"{short_path(path, project_dir)}  ({len(file_edits)}){note}"])
143            node.setFlags(node.flags() | Qt.ItemIsUserCheckable)
144            node.setCheckState(0, Qt.Unchecked if dependency else Qt.Checked)
145            node.setData(0, PATH_ROLE, str(path))
146            node.setToolTip(0, str(path))
147            self.tree.addTopLevelItem(node)
148            # The changed lines, as they will read afterwards.
149            try:
150                old_lines = file_text(path).split("\n")
151            except OSError:
152                old_lines = []
153            new_lines = lsp_mod.apply_edits("\n".join(old_lines), file_edits).split("\n")
154            self._lines[str(path)] = new_lines
155            for line in sorted({e.line for e in file_edits}):
156                after = new_lines[line].strip() if line < len(new_lines) else ""
157                before = old_lines[line].strip() if line < len(old_lines) else ""
158                child = QTreeWidgetItem([f"{line + 1}:  {after}"])
159                child.setToolTip(0, f"Before: {before}\nAfter:  {after}")
160                child.setData(0, LINE_ROLE, (str(path), line, renamed_spans(file_edits, line)))
161                if mono is not None:
162                    child.setFont(0, mono)
163                child.setFlags(child.flags() & ~Qt.ItemIsUserCheckable)
164                node.addChild(child)
165            node.setExpanded(len(edits) <= 6)
166        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
167        buttons.button(QDialogButtonBox.Ok).setText("Rename")
168        buttons.accepted.connect(self.accept)
169        buttons.rejected.connect(self.reject)
170        layout = QVBoxLayout(self)
171        layout.addWidget(intro)
172        layout.addWidget(self.tree, 1)
173        if deps:
174            warn = QLabel("Files in dependencies are left unticked: fpm may download them again "
175                          "(losing the change), and the rest of that package wouldn't be renamed.")
176            warn.setWordWrap(True)
177            warn.setStyleSheet("color: gray;")
178            layout.addWidget(warn)
179        layout.addWidget(buttons)
180
181    def lines(self, path: str) -> list[str]:
182        return self._lines.get(path, [])
183
184    def line_formats(self, path: str, line: int) -> list | None:
185        """The syntax format of each column of a changed line, as it will read
186        (lexed with the lines before it, as the Search panel's)."""
187        key = (path, line)
188        if key not in self._line_formats:
189            self._line_formats[key] = syntax_formats(self.lines(path), line + 1, Path(path), self._style,
190                                                     self._formats)
191        return self._line_formats[key]
192
193    def selected(self) -> list[Path]:
194        out = []
195        for i in range(self.tree.topLevelItemCount()):
196            node = self.tree.topLevelItem(i)
197            if node.checkState(0) == Qt.Checked:
198                out.append(Path(node.data(0, PATH_ROLE)))
199        return out

QDialog(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, sizeGripEnabled: bool | None = None, modal: bool | None = None) -> None

RenamePreviewDialog( old: str, new: str, edits: dict[pathlib.Path, list[vibefpmgui.lsp.TextEdit]], file_text: Callable[[pathlib.Path], str], open_files: set[pathlib.Path], project_dir: pathlib.Path | None, mono: PySide6.QtGui.QFont | None = None, parent=None)
111    def __init__(self, old: str, new: str, edits: dict[Path, list[lsp_mod.TextEdit]],
112                 file_text: Callable[[Path], str], open_files: set[Path], project_dir: Path | None,
113                 mono: QFont | None = None, parent=None):
114        super().__init__(parent)
115        self.setWindowTitle("Rename Symbol")
116        self.resize(760, 480)
117        count = sum(len(e) for e in edits.values())
118        intro = QLabel(f"Rename <b>{old}</b> to <b>{new}</b>: {count} change{'s' * (count != 1)} in "
119                       f"{len(edits)} file{'s' * (len(edits) != 1)}. Files open in the editor are changed "
120                       "there (unsaved, Undo reverts); other files are saved straight away.")
121        intro.setWordWrap(True)
122        self.mono = mono or QFont()
123        self._lines: dict[str, list[str]] = {}  # file -> its lines as they will read
124        self._style = get_style_by_name(hl.style_name(QApplication.palette()))
125        self._formats: dict = {}  # token type -> format
126        self._line_formats: dict[tuple[str, int], list | None] = {}
127        self.tree = QTreeWidget()
128        self.tree.setHeaderHidden(True)
129        self.tree.setUniformRowHeights(True)
130        self.tree.setItemDelegate(_LineDelegate(self))
131        deps = False
132        for path in sorted(edits, key=lambda p: (is_dependency(p, project_dir), short_path(p, project_dir))):
133            file_edits = edits[path]
134            dependency = is_dependency(path, project_dir)
135            deps |= dependency
136            notes = []
137            if path in open_files:
138                notes.append("open in the editor")
139            if dependency:
140                notes.append("dependency — not changed unless ticked")
141            note = f"  — {', '.join(notes)}" if notes else ""
142            node = QTreeWidgetItem([f"{short_path(path, project_dir)}  ({len(file_edits)}){note}"])
143            node.setFlags(node.flags() | Qt.ItemIsUserCheckable)
144            node.setCheckState(0, Qt.Unchecked if dependency else Qt.Checked)
145            node.setData(0, PATH_ROLE, str(path))
146            node.setToolTip(0, str(path))
147            self.tree.addTopLevelItem(node)
148            # The changed lines, as they will read afterwards.
149            try:
150                old_lines = file_text(path).split("\n")
151            except OSError:
152                old_lines = []
153            new_lines = lsp_mod.apply_edits("\n".join(old_lines), file_edits).split("\n")
154            self._lines[str(path)] = new_lines
155            for line in sorted({e.line for e in file_edits}):
156                after = new_lines[line].strip() if line < len(new_lines) else ""
157                before = old_lines[line].strip() if line < len(old_lines) else ""
158                child = QTreeWidgetItem([f"{line + 1}:  {after}"])
159                child.setToolTip(0, f"Before: {before}\nAfter:  {after}")
160                child.setData(0, LINE_ROLE, (str(path), line, renamed_spans(file_edits, line)))
161                if mono is not None:
162                    child.setFont(0, mono)
163                child.setFlags(child.flags() & ~Qt.ItemIsUserCheckable)
164                node.addChild(child)
165            node.setExpanded(len(edits) <= 6)
166        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
167        buttons.button(QDialogButtonBox.Ok).setText("Rename")
168        buttons.accepted.connect(self.accept)
169        buttons.rejected.connect(self.reject)
170        layout = QVBoxLayout(self)
171        layout.addWidget(intro)
172        layout.addWidget(self.tree, 1)
173        if deps:
174            warn = QLabel("Files in dependencies are left unticked: fpm may download them again "
175                          "(losing the change), and the rest of that package wouldn't be renamed.")
176            warn.setWordWrap(True)
177            warn.setStyleSheet("color: gray;")
178            layout.addWidget(warn)
179        layout.addWidget(buttons)

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, sizeGripEnabled: bool | None = None, modal: bool | None = None) -> None

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

mono
tree
def lines(self, path: str) -> list[str]:
181    def lines(self, path: str) -> list[str]:
182        return self._lines.get(path, [])
def line_formats(self, path: str, line: int) -> list | None:
184    def line_formats(self, path: str, line: int) -> list | None:
185        """The syntax format of each column of a changed line, as it will read
186        (lexed with the lines before it, as the Search panel's)."""
187        key = (path, line)
188        if key not in self._line_formats:
189            self._line_formats[key] = syntax_formats(self.lines(path), line + 1, Path(path), self._style,
190                                                     self._formats)
191        return self._line_formats[key]

The syntax format of each column of a changed line, as it will read (lexed with the lines before it, as the Search panel's).

def selected(self) -> list[pathlib.Path]:
193    def selected(self) -> list[Path]:
194        out = []
195        for i in range(self.tree.topLevelItemCount()):
196            node = self.tree.topLevelItem(i)
197            if node.checkState(0) == Qt.Checked:
198                out.append(Path(node.data(0, PATH_ROLE)))
199        return out
staticMetaObject = PySide6.QtCore.QMetaObject("RenamePreviewDialog" inherits "QDialog": )