Coverage for vibefpmgui/find_bar.py: 93%
179 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"""The editor's find and replace bar."""
3from __future__ import annotations
5import re
6from typing import TYPE_CHECKING
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)
20from .editor_shared import MAX_FIND_HIGHLIGHTS
22BEYOND_BMP_RE = re.compile("[\U00010000-\U0010FFFF]") # (characters Qt counts as two)
24if TYPE_CHECKING:
25 from .code_editor import CodeEditor
28class FindBar(QWidget):
29 """Find (and optionally replace) bar shown below the editor."""
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()
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
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)
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)
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()
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()
96 def close_bar(self):
97 self.hide()
98 self.editor.set_find_selections([])
99 self.editor.setFocus()
101 def keyPressEvent(self, event):
102 if event.key() == Qt.Key_Escape: 102 ↛ 105line 102 didn't jump to line 105 because the condition on line 102 was always true
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)
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
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)
130 def find_next(self):
131 self._find(backward=False)
133 def find_previous(self):
134 self._find(backward=True)
136 def _find(self, backward: bool):
137 text = self.find_edit.text()
138 if not text: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
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)): 145 ↛ 147line 145 didn't jump to line 147 because the condition on line 145 was always true
146 return False
147 self._update_count()
148 return True
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): 153 ↛ 155line 153 didn't jump to line 155 because the condition on line 153 was always true
154 cursor.insertText(self.replace_edit.text())
155 self.find_next()
157 def replace_all(self):
158 text = self.find_edit.text()
159 if not text: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
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")
173 def _matches(self, selected: str, text: str) -> bool:
174 return selected == text if self.case_check.isChecked() else selected.lower() == text.lower()
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: 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true
195 break
196 return out
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
207 def _update_highlights_if_visible(self):
208 if self.isVisible():
209 self._update_highlights()
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()
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")