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

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: 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) 

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: 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 

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): 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() 

156 

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

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: 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true

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