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