vibefpmgui.find_bar

The editor's find and replace bar.

  1"""The editor's find and replace bar."""
  2
  3from __future__ import annotations
  4
  5import re
  6from typing import TYPE_CHECKING
  7
  8from qtpy.QtCore import QRegularExpression, Qt
  9from qtpy.QtGui import QTextCursor, QTextDocument
 10from qtpy.QtWidgets import (
 11    QCheckBox,
 12    QHBoxLayout,
 13    QLabel,
 14    QLineEdit,
 15    QToolButton,
 16    QVBoxLayout,
 17    QWidget,
 18)
 19
 20from .editor_shared import MAX_FIND_HIGHLIGHTS
 21
 22BEYOND_BMP_RE = re.compile("[\U00010000-\U0010FFFF]")  # (characters Qt counts as two)
 23
 24if TYPE_CHECKING:
 25    from .code_editor import CodeEditor
 26
 27
 28class FindBar(QWidget):
 29    """Find (and optionally replace) bar shown below the editor."""
 30
 31    def __init__(self, editor: CodeEditor):
 32        super().__init__()
 33        self.editor = editor
 34        self.find_edit = QLineEdit()
 35        self.find_edit.setPlaceholderText("Find")
 36        self.find_edit.setClearButtonEnabled(True)
 37        self.replace_edit = QLineEdit()
 38        self.replace_edit.setPlaceholderText("Replace with")
 39        self.case_check = QCheckBox("Match case")
 40        self.word_check = QCheckBox("Whole words")
 41        self.count_label = QLabel()
 42
 43        def button(text, tip, slot):
 44            b = QToolButton()
 45            b.setText(text)
 46            b.setToolTip(tip)
 47            b.setAutoRaise(True)
 48            b.clicked.connect(slot)
 49            return b
 50
 51        self.prev_button = button("‹", "Previous match (Shift+Enter)", self.find_previous)
 52        self.next_button = button("›", "Next match (Enter)", self.find_next)
 53        self.replace_button = button("Replace", "Replace this match", self.replace_one)
 54        self.replace_all_button = button("All", "Replace every match", self.replace_all)
 55        close = button("✕", "Close (Esc)", self.close_bar)
 56
 57        top = QHBoxLayout()
 58        top.setContentsMargins(6, 4, 6, 0)
 59        top.addWidget(self.find_edit, 1)
 60        top.addWidget(self.count_label)
 61        top.addWidget(self.prev_button)
 62        top.addWidget(self.next_button)
 63        top.addWidget(self.case_check)
 64        top.addWidget(self.word_check)
 65        top.addWidget(close)
 66        self.replace_row = QWidget()
 67        bottom = QHBoxLayout(self.replace_row)
 68        bottom.setContentsMargins(6, 0, 6, 0)
 69        bottom.addWidget(self.replace_edit, 1)
 70        bottom.addWidget(self.replace_button)
 71        bottom.addWidget(self.replace_all_button)
 72        layout = QVBoxLayout(self)
 73        layout.setContentsMargins(0, 0, 0, 4)
 74        layout.setSpacing(4)
 75        layout.addLayout(top)
 76        layout.addWidget(self.replace_row)
 77
 78        self.find_edit.textChanged.connect(self._update_highlights)
 79        self.find_edit.returnPressed.connect(self.find_next)
 80        self.replace_edit.returnPressed.connect(self.replace_one)
 81        self.case_check.toggled.connect(self._update_highlights)
 82        self.word_check.toggled.connect(self._update_highlights)
 83        self.editor.textChanged.connect(self._update_highlights_if_visible)
 84        self.hide()
 85
 86    def open_bar(self, replace: bool = False):
 87        selected = self.editor.textCursor().selectedText()
 88        if selected and "
" not in selected:
 89            self.find_edit.setText(selected)
 90        self.replace_row.setVisible(replace and not self.editor.isReadOnly())
 91        self.show()
 92        self.find_edit.setFocus()
 93        self.find_edit.selectAll()
 94        self._update_highlights()
 95
 96    def close_bar(self):
 97        self.hide()
 98        self.editor.set_find_selections([])
 99        self.editor.setFocus()
100
101    def keyPressEvent(self, event):
102        if event.key() == Qt.Key_Escape:
103            self.close_bar()
104            return
105        if event.key() in (Qt.Key_Return, Qt.Key_Enter) and event.modifiers() & Qt.ShiftModifier:
106            self.find_previous()
107            return
108        super().keyPressEvent(event)
109
110    def _flags(self, backward: bool = False):
111        flags = QTextDocument.FindFlag(0)
112        if self.case_check.isChecked():
113            flags |= QTextDocument.FindCaseSensitively
114        if backward:
115            flags |= QTextDocument.FindBackward
116        return flags
117
118    def _needle(self):
119        """What to look for: the text, or with *Whole words* a regular
120        expression (Qt's own whole-word search counts "_" as a break, so
121        "count" would match in count_all)."""
122        text = self.find_edit.text()
123        if not self.word_check.isChecked():
124            return text
125        options = QRegularExpression.NoPatternOption
126        if not self.case_check.isChecked():
127            options |= QRegularExpression.CaseInsensitiveOption
128        return QRegularExpression(rf"(?<!\w){QRegularExpression.escape(text)}(?!\w)", options)
129
130    def find_next(self):
131        self._find(backward=False)
132
133    def find_previous(self):
134        self._find(backward=True)
135
136    def _find(self, backward: bool):
137        text = self.find_edit.text()
138        if not text:
139            return False
140        if not self.editor.find(self._needle(), self._flags(backward)):
141            # Wrap around.
142            cursor = self.editor.textCursor()
143            cursor.movePosition(QTextCursor.End if backward else QTextCursor.Start)
144            self.editor.setTextCursor(cursor)
145            if not self.editor.find(self._needle(), self._flags(backward)):
146                return False
147        self._update_count()
148        return True
149
150    def replace_one(self):
151        cursor = self.editor.textCursor()
152        text = self.find_edit.text()
153        if cursor.hasSelection() and self._matches(cursor.selectedText(), text):
154            cursor.insertText(self.replace_edit.text())
155        self.find_next()
156
157    def replace_all(self):
158        text = self.find_edit.text()
159        if not text:
160            return
161        doc = self.editor.document()
162        cursor = QTextCursor(doc)
163        cursor.beginEditBlock()
164        count = 0
165        found = doc.find(self._needle(), 0, self._flags())
166        while not found.isNull():
167            found.insertText(self.replace_edit.text())
168            count += 1
169            found = doc.find(self._needle(), found.position(), self._flags())
170        cursor.endEditBlock()
171        self.count_label.setText(f"{count} replaced")
172
173    def _matches(self, selected: str, text: str) -> bool:
174        return selected == text if self.case_check.isChecked() else selected.lower() == text.lower()
175
176    def _all_matches(self) -> list[tuple[int, int]]:
177        """The matches' (start, end) positions (not cursors: a cursor kept is
178        one more that Qt moves along at every edit), found in the text at
179        once (Qt's find, match by match, took a fifth of a second for a big
180        file's 2000)."""
181        needle = self.find_edit.text()
182        if not needle:
183            return []
184        text = self.editor.toPlainText()
185        if BEYOND_BMP_RE.search(text):  # (Qt counts those characters twice: its own find, then)
186            return self._qt_matches()
187        pattern = re.escape(needle)
188        if self.word_check.isChecked():
189            pattern = rf"(?<!\w){pattern}(?!\w)"
190        flags = 0 if self.case_check.isChecked() else re.IGNORECASE
191        out = []
192        for match in re.finditer(pattern, text, flags):
193            out.append(match.span())
194            if len(out) >= MAX_FIND_HIGHLIGHTS:
195                break
196        return out
197
198    def _qt_matches(self) -> list[tuple[int, int]]:
199        doc = self.editor.document()
200        out = []
201        found = doc.find(self._needle(), 0, self._flags())
202        while not found.isNull() and len(out) < MAX_FIND_HIGHLIGHTS:
203            out.append((found.selectionStart(), found.selectionEnd()))
204            found = doc.find(self._needle(), found.selectionEnd(), self._flags())
205        return out
206
207    def _update_highlights_if_visible(self):
208        if self.isVisible():
209            self._update_highlights()
210
211    def _update_highlights(self):
212        matches = self._all_matches()
213        self.editor.set_find_selections(matches)
214        self._matches_cache = matches
215        self._update_count()
216
217    def _update_count(self):
218        matches = getattr(self, "_matches_cache", [])
219        if not self.find_edit.text():
220            self.count_label.setText("")
221            return
222        if not matches:
223            self.count_label.setText("no matches")
224            return
225        pos = self.editor.textCursor().selectionStart()
226        index = next((i for i, (start, _end) in enumerate(matches) if start == pos), None)
227        more = "+" if len(matches) >= MAX_FIND_HIGHLIGHTS else ""
228        self.count_label.setText(f"{index + 1} of {len(matches)}{more}" if index is not None
229                                 else f"{len(matches)}{more} matches")
BEYOND_BMP_RE = re.compile('[𐀀-\U0010ffff]')
class FindBar(PySide6.QtWidgets.QWidget):
 29class FindBar(QWidget):
 30    """Find (and optionally replace) bar shown below the editor."""
 31
 32    def __init__(self, editor: CodeEditor):
 33        super().__init__()
 34        self.editor = editor
 35        self.find_edit = QLineEdit()
 36        self.find_edit.setPlaceholderText("Find")
 37        self.find_edit.setClearButtonEnabled(True)
 38        self.replace_edit = QLineEdit()
 39        self.replace_edit.setPlaceholderText("Replace with")
 40        self.case_check = QCheckBox("Match case")
 41        self.word_check = QCheckBox("Whole words")
 42        self.count_label = QLabel()
 43
 44        def button(text, tip, slot):
 45            b = QToolButton()
 46            b.setText(text)
 47            b.setToolTip(tip)
 48            b.setAutoRaise(True)
 49            b.clicked.connect(slot)
 50            return b
 51
 52        self.prev_button = button("‹", "Previous match (Shift+Enter)", self.find_previous)
 53        self.next_button = button("›", "Next match (Enter)", self.find_next)
 54        self.replace_button = button("Replace", "Replace this match", self.replace_one)
 55        self.replace_all_button = button("All", "Replace every match", self.replace_all)
 56        close = button("✕", "Close (Esc)", self.close_bar)
 57
 58        top = QHBoxLayout()
 59        top.setContentsMargins(6, 4, 6, 0)
 60        top.addWidget(self.find_edit, 1)
 61        top.addWidget(self.count_label)
 62        top.addWidget(self.prev_button)
 63        top.addWidget(self.next_button)
 64        top.addWidget(self.case_check)
 65        top.addWidget(self.word_check)
 66        top.addWidget(close)
 67        self.replace_row = QWidget()
 68        bottom = QHBoxLayout(self.replace_row)
 69        bottom.setContentsMargins(6, 0, 6, 0)
 70        bottom.addWidget(self.replace_edit, 1)
 71        bottom.addWidget(self.replace_button)
 72        bottom.addWidget(self.replace_all_button)
 73        layout = QVBoxLayout(self)
 74        layout.setContentsMargins(0, 0, 0, 4)
 75        layout.setSpacing(4)
 76        layout.addLayout(top)
 77        layout.addWidget(self.replace_row)
 78
 79        self.find_edit.textChanged.connect(self._update_highlights)
 80        self.find_edit.returnPressed.connect(self.find_next)
 81        self.replace_edit.returnPressed.connect(self.replace_one)
 82        self.case_check.toggled.connect(self._update_highlights)
 83        self.word_check.toggled.connect(self._update_highlights)
 84        self.editor.textChanged.connect(self._update_highlights_if_visible)
 85        self.hide()
 86
 87    def open_bar(self, replace: bool = False):
 88        selected = self.editor.textCursor().selectedText()
 89        if selected and "
" not in selected:
 90            self.find_edit.setText(selected)
 91        self.replace_row.setVisible(replace and not self.editor.isReadOnly())
 92        self.show()
 93        self.find_edit.setFocus()
 94        self.find_edit.selectAll()
 95        self._update_highlights()
 96
 97    def close_bar(self):
 98        self.hide()
 99        self.editor.set_find_selections([])
100        self.editor.setFocus()
101
102    def keyPressEvent(self, event):
103        if event.key() == Qt.Key_Escape:
104            self.close_bar()
105            return
106        if event.key() in (Qt.Key_Return, Qt.Key_Enter) and event.modifiers() & Qt.ShiftModifier:
107            self.find_previous()
108            return
109        super().keyPressEvent(event)
110
111    def _flags(self, backward: bool = False):
112        flags = QTextDocument.FindFlag(0)
113        if self.case_check.isChecked():
114            flags |= QTextDocument.FindCaseSensitively
115        if backward:
116            flags |= QTextDocument.FindBackward
117        return flags
118
119    def _needle(self):
120        """What to look for: the text, or with *Whole words* a regular
121        expression (Qt's own whole-word search counts "_" as a break, so
122        "count" would match in count_all)."""
123        text = self.find_edit.text()
124        if not self.word_check.isChecked():
125            return text
126        options = QRegularExpression.NoPatternOption
127        if not self.case_check.isChecked():
128            options |= QRegularExpression.CaseInsensitiveOption
129        return QRegularExpression(rf"(?<!\w){QRegularExpression.escape(text)}(?!\w)", options)
130
131    def find_next(self):
132        self._find(backward=False)
133
134    def find_previous(self):
135        self._find(backward=True)
136
137    def _find(self, backward: bool):
138        text = self.find_edit.text()
139        if not text:
140            return False
141        if not self.editor.find(self._needle(), self._flags(backward)):
142            # Wrap around.
143            cursor = self.editor.textCursor()
144            cursor.movePosition(QTextCursor.End if backward else QTextCursor.Start)
145            self.editor.setTextCursor(cursor)
146            if not self.editor.find(self._needle(), self._flags(backward)):
147                return False
148        self._update_count()
149        return True
150
151    def replace_one(self):
152        cursor = self.editor.textCursor()
153        text = self.find_edit.text()
154        if cursor.hasSelection() and self._matches(cursor.selectedText(), text):
155            cursor.insertText(self.replace_edit.text())
156        self.find_next()
157
158    def replace_all(self):
159        text = self.find_edit.text()
160        if not text:
161            return
162        doc = self.editor.document()
163        cursor = QTextCursor(doc)
164        cursor.beginEditBlock()
165        count = 0
166        found = doc.find(self._needle(), 0, self._flags())
167        while not found.isNull():
168            found.insertText(self.replace_edit.text())
169            count += 1
170            found = doc.find(self._needle(), found.position(), self._flags())
171        cursor.endEditBlock()
172        self.count_label.setText(f"{count} replaced")
173
174    def _matches(self, selected: str, text: str) -> bool:
175        return selected == text if self.case_check.isChecked() else selected.lower() == text.lower()
176
177    def _all_matches(self) -> list[tuple[int, int]]:
178        """The matches' (start, end) positions (not cursors: a cursor kept is
179        one more that Qt moves along at every edit), found in the text at
180        once (Qt's find, match by match, took a fifth of a second for a big
181        file's 2000)."""
182        needle = self.find_edit.text()
183        if not needle:
184            return []
185        text = self.editor.toPlainText()
186        if BEYOND_BMP_RE.search(text):  # (Qt counts those characters twice: its own find, then)
187            return self._qt_matches()
188        pattern = re.escape(needle)
189        if self.word_check.isChecked():
190            pattern = rf"(?<!\w){pattern}(?!\w)"
191        flags = 0 if self.case_check.isChecked() else re.IGNORECASE
192        out = []
193        for match in re.finditer(pattern, text, flags):
194            out.append(match.span())
195            if len(out) >= MAX_FIND_HIGHLIGHTS:
196                break
197        return out
198
199    def _qt_matches(self) -> list[tuple[int, int]]:
200        doc = self.editor.document()
201        out = []
202        found = doc.find(self._needle(), 0, self._flags())
203        while not found.isNull() and len(out) < MAX_FIND_HIGHLIGHTS:
204            out.append((found.selectionStart(), found.selectionEnd()))
205            found = doc.find(self._needle(), found.selectionEnd(), self._flags())
206        return out
207
208    def _update_highlights_if_visible(self):
209        if self.isVisible():
210            self._update_highlights()
211
212    def _update_highlights(self):
213        matches = self._all_matches()
214        self.editor.set_find_selections(matches)
215        self._matches_cache = matches
216        self._update_count()
217
218    def _update_count(self):
219        matches = getattr(self, "_matches_cache", [])
220        if not self.find_edit.text():
221            self.count_label.setText("")
222            return
223        if not matches:
224            self.count_label.setText("no matches")
225            return
226        pos = self.editor.textCursor().selectionStart()
227        index = next((i for i, (start, _end) in enumerate(matches) if start == pos), None)
228        more = "+" if len(matches) >= MAX_FIND_HIGHLIGHTS else ""
229        self.count_label.setText(f"{index + 1} of {len(matches)}{more}" if index is not None
230                                 else f"{len(matches)}{more} matches")

Find (and optionally replace) bar shown below the editor.

FindBar(editor: vibefpmgui.code_editor.CodeEditor)
32    def __init__(self, editor: CodeEditor):
33        super().__init__()
34        self.editor = editor
35        self.find_edit = QLineEdit()
36        self.find_edit.setPlaceholderText("Find")
37        self.find_edit.setClearButtonEnabled(True)
38        self.replace_edit = QLineEdit()
39        self.replace_edit.setPlaceholderText("Replace with")
40        self.case_check = QCheckBox("Match case")
41        self.word_check = QCheckBox("Whole words")
42        self.count_label = QLabel()
43
44        def button(text, tip, slot):
45            b = QToolButton()
46            b.setText(text)
47            b.setToolTip(tip)
48            b.setAutoRaise(True)
49            b.clicked.connect(slot)
50            return b
51
52        self.prev_button = button("‹", "Previous match (Shift+Enter)", self.find_previous)
53        self.next_button = button("›", "Next match (Enter)", self.find_next)
54        self.replace_button = button("Replace", "Replace this match", self.replace_one)
55        self.replace_all_button = button("All", "Replace every match", self.replace_all)
56        close = button("✕", "Close (Esc)", self.close_bar)
57
58        top = QHBoxLayout()
59        top.setContentsMargins(6, 4, 6, 0)
60        top.addWidget(self.find_edit, 1)
61        top.addWidget(self.count_label)
62        top.addWidget(self.prev_button)
63        top.addWidget(self.next_button)
64        top.addWidget(self.case_check)
65        top.addWidget(self.word_check)
66        top.addWidget(close)
67        self.replace_row = QWidget()
68        bottom = QHBoxLayout(self.replace_row)
69        bottom.setContentsMargins(6, 0, 6, 0)
70        bottom.addWidget(self.replace_edit, 1)
71        bottom.addWidget(self.replace_button)
72        bottom.addWidget(self.replace_all_button)
73        layout = QVBoxLayout(self)
74        layout.setContentsMargins(0, 0, 0, 4)
75        layout.setSpacing(4)
76        layout.addLayout(top)
77        layout.addWidget(self.replace_row)
78
79        self.find_edit.textChanged.connect(self._update_highlights)
80        self.find_edit.returnPressed.connect(self.find_next)
81        self.replace_edit.returnPressed.connect(self.replace_one)
82        self.case_check.toggled.connect(self._update_highlights)
83        self.word_check.toggled.connect(self._update_highlights)
84        self.editor.textChanged.connect(self._update_highlights_if_visible)
85        self.hide()

__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.

editor
find_edit
replace_edit
case_check
word_check
count_label
prev_button
next_button
replace_button
replace_all_button
replace_row
def open_bar(self, replace: bool = False):
87    def open_bar(self, replace: bool = False):
88        selected = self.editor.textCursor().selectedText()
89        if selected and "
" not in selected:
90            self.find_edit.setText(selected)
91        self.replace_row.setVisible(replace and not self.editor.isReadOnly())
92        self.show()
93        self.find_edit.setFocus()
94        self.find_edit.selectAll()
95        self._update_highlights()
def close_bar(self):
 97    def close_bar(self):
 98        self.hide()
 99        self.editor.set_find_selections([])
100        self.editor.setFocus()
def keyPressEvent(self, event):
102    def keyPressEvent(self, event):
103        if event.key() == Qt.Key_Escape:
104            self.close_bar()
105            return
106        if event.key() in (Qt.Key_Return, Qt.Key_Enter) and event.modifiers() & Qt.ShiftModifier:
107            self.find_previous()
108            return
109        super().keyPressEvent(event)

keyPressEvent(self, event: PySide6.QtGui.QKeyEvent, /) -> None

def find_next(self):
131    def find_next(self):
132        self._find(backward=False)
def find_previous(self):
134    def find_previous(self):
135        self._find(backward=True)
def replace_one(self):
151    def replace_one(self):
152        cursor = self.editor.textCursor()
153        text = self.find_edit.text()
154        if cursor.hasSelection() and self._matches(cursor.selectedText(), text):
155            cursor.insertText(self.replace_edit.text())
156        self.find_next()
def replace_all(self):
158    def replace_all(self):
159        text = self.find_edit.text()
160        if not text:
161            return
162        doc = self.editor.document()
163        cursor = QTextCursor(doc)
164        cursor.beginEditBlock()
165        count = 0
166        found = doc.find(self._needle(), 0, self._flags())
167        while not found.isNull():
168            found.insertText(self.replace_edit.text())
169            count += 1
170            found = doc.find(self._needle(), found.position(), self._flags())
171        cursor.endEditBlock()
172        self.count_label.setText(f"{count} replaced")
staticMetaObject = PySide6.QtCore.QMetaObject("FindBar" inherits "QWidget": )