|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2012 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a horizontal search widget for QTextEdit. |
|
8 """ |
|
9 |
|
10 import enum |
|
11 |
|
12 from PyQt6.QtCore import pyqtSlot, pyqtSignal, Qt, QMetaObject, QSize |
|
13 from PyQt6.QtGui import QPalette, QTextDocument, QTextCursor |
|
14 from PyQt6.QtWidgets import ( |
|
15 QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QCheckBox, |
|
16 QToolButton, QSizePolicy |
|
17 ) |
|
18 |
|
19 import UI.PixmapCache |
|
20 |
|
21 |
|
22 class EricTextEditType(enum.Enum): |
|
23 """ |
|
24 Class defining the supported text edit types. |
|
25 """ |
|
26 UNKNOWN = 0 |
|
27 QTEXTEDIT = 1 |
|
28 QTEXTBROWSER = 2 |
|
29 QWEBENGINEVIEW = 3 |
|
30 |
|
31 |
|
32 class EricTextEditSearchWidget(QWidget): |
|
33 """ |
|
34 Class implementing a horizontal search widget for QTextEdit. |
|
35 |
|
36 @signal closePressed() emitted to indicate the closing of the widget via |
|
37 the close button |
|
38 """ |
|
39 closePressed = pyqtSignal() |
|
40 |
|
41 def __init__(self, parent=None, widthForHeight=True, enableClose=False): |
|
42 """ |
|
43 Constructor |
|
44 |
|
45 @param parent reference to the parent widget |
|
46 @type QWidget |
|
47 @param widthForHeight flag indicating to prefer width for height. |
|
48 If this parameter is False, some widgets are shown in a third |
|
49 line. |
|
50 @type bool |
|
51 @param enableClose flag indicating to show a close button |
|
52 @type bool |
|
53 """ |
|
54 super().__init__(parent) |
|
55 self.__setupUi(widthForHeight, enableClose) |
|
56 |
|
57 self.__textedit = None |
|
58 self.__texteditType = EricTextEditType.UNKNOWN |
|
59 self.__findBackwards = False |
|
60 |
|
61 self.__defaultBaseColor = ( |
|
62 self.findtextCombo.lineEdit().palette().color( |
|
63 QPalette.ColorRole.Base) |
|
64 ) |
|
65 self.__defaultTextColor = ( |
|
66 self.findtextCombo.lineEdit().palette().color( |
|
67 QPalette.ColorRole.Text) |
|
68 ) |
|
69 |
|
70 self.findHistory = [] |
|
71 |
|
72 self.findtextCombo.setCompleter(None) |
|
73 self.findtextCombo.lineEdit().returnPressed.connect( |
|
74 self.__findByReturnPressed) |
|
75 |
|
76 self.__setSearchButtons(False) |
|
77 self.infoLabel.hide() |
|
78 |
|
79 self.setFocusProxy(self.findtextCombo) |
|
80 |
|
81 def __setupUi(self, widthForHeight, enableClose): |
|
82 """ |
|
83 Private method to generate the UI. |
|
84 |
|
85 @param widthForHeight flag indicating to prefer width for height |
|
86 @type bool |
|
87 @param enableClose flag indicating to show a close button |
|
88 @type bool |
|
89 """ |
|
90 self.setObjectName("EricTextEditSearchWidget") |
|
91 |
|
92 self.verticalLayout = QVBoxLayout(self) |
|
93 self.verticalLayout.setObjectName("verticalLayout") |
|
94 self.verticalLayout.setContentsMargins(0, 0, 0, 0) |
|
95 |
|
96 # row 1 of widgets |
|
97 self.horizontalLayout1 = QHBoxLayout() |
|
98 self.horizontalLayout1.setObjectName("horizontalLayout1") |
|
99 |
|
100 if enableClose: |
|
101 self.closeButton = QToolButton(self) |
|
102 self.closeButton.setIcon(UI.PixmapCache.getIcon("close")) |
|
103 self.closeButton.clicked.connect(self.__closeButtonClicked) |
|
104 self.horizontalLayout1.addWidget(self.closeButton) |
|
105 else: |
|
106 self.closeButton = None |
|
107 |
|
108 self.label = QLabel(self) |
|
109 self.label.setObjectName("label") |
|
110 self.label.setText(self.tr("Find:")) |
|
111 self.horizontalLayout1.addWidget(self.label) |
|
112 |
|
113 self.findtextCombo = QComboBox(self) |
|
114 self.findtextCombo.setEditable(True) |
|
115 self.findtextCombo.lineEdit().setClearButtonEnabled(True) |
|
116 sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, |
|
117 QSizePolicy.Policy.Fixed) |
|
118 sizePolicy.setHorizontalStretch(0) |
|
119 sizePolicy.setVerticalStretch(0) |
|
120 sizePolicy.setHeightForWidth( |
|
121 self.findtextCombo.sizePolicy().hasHeightForWidth()) |
|
122 self.findtextCombo.setSizePolicy(sizePolicy) |
|
123 self.findtextCombo.setMinimumSize(QSize(100, 0)) |
|
124 self.findtextCombo.setEditable(True) |
|
125 self.findtextCombo.setInsertPolicy(QComboBox.InsertPolicy.InsertAtTop) |
|
126 self.findtextCombo.setDuplicatesEnabled(False) |
|
127 self.findtextCombo.setObjectName("findtextCombo") |
|
128 self.horizontalLayout1.addWidget(self.findtextCombo) |
|
129 |
|
130 # row 2 (maybe) of widgets |
|
131 self.horizontalLayout2 = QHBoxLayout() |
|
132 self.horizontalLayout2.setObjectName("horizontalLayout2") |
|
133 |
|
134 self.caseCheckBox = QCheckBox(self) |
|
135 self.caseCheckBox.setObjectName("caseCheckBox") |
|
136 self.caseCheckBox.setText(self.tr("Match case")) |
|
137 self.horizontalLayout2.addWidget(self.caseCheckBox) |
|
138 |
|
139 self.wordCheckBox = QCheckBox(self) |
|
140 self.wordCheckBox.setObjectName("wordCheckBox") |
|
141 self.wordCheckBox.setText(self.tr("Whole word")) |
|
142 self.horizontalLayout2.addWidget(self.wordCheckBox) |
|
143 |
|
144 # layout for the navigation buttons |
|
145 self.horizontalLayout3 = QHBoxLayout() |
|
146 self.horizontalLayout3.setSpacing(0) |
|
147 self.horizontalLayout3.setObjectName("horizontalLayout3") |
|
148 |
|
149 self.findPrevButton = QToolButton(self) |
|
150 self.findPrevButton.setObjectName("findPrevButton") |
|
151 self.findPrevButton.setToolTip(self.tr( |
|
152 "Press to find the previous occurrence")) |
|
153 self.findPrevButton.setIcon(UI.PixmapCache.getIcon("1leftarrow")) |
|
154 self.horizontalLayout3.addWidget(self.findPrevButton) |
|
155 |
|
156 self.findNextButton = QToolButton(self) |
|
157 self.findNextButton.setObjectName("findNextButton") |
|
158 self.findNextButton.setToolTip(self.tr( |
|
159 "Press to find the next occurrence")) |
|
160 self.findNextButton.setIcon(UI.PixmapCache.getIcon("1rightarrow")) |
|
161 self.horizontalLayout3.addWidget(self.findNextButton) |
|
162 |
|
163 self.horizontalLayout2.addLayout(self.horizontalLayout3) |
|
164 |
|
165 # info label (in row 2 or 3) |
|
166 self.infoLabel = QLabel(self) |
|
167 self.infoLabel.setText("") |
|
168 self.infoLabel.setObjectName("infoLabel") |
|
169 |
|
170 # place everything together |
|
171 self.verticalLayout.addLayout(self.horizontalLayout1) |
|
172 self.__addWidthForHeightLayout(widthForHeight) |
|
173 self.verticalLayout.addWidget(self.infoLabel) |
|
174 |
|
175 QMetaObject.connectSlotsByName(self) |
|
176 |
|
177 self.setTabOrder(self.findtextCombo, self.caseCheckBox) |
|
178 self.setTabOrder(self.caseCheckBox, self.wordCheckBox) |
|
179 self.setTabOrder(self.wordCheckBox, self.findPrevButton) |
|
180 self.setTabOrder(self.findPrevButton, self.findNextButton) |
|
181 |
|
182 def setWidthForHeight(self, widthForHeight): |
|
183 """ |
|
184 Public method to set the 'width for height'. |
|
185 |
|
186 @param widthForHeight flag indicating to prefer width |
|
187 @type bool |
|
188 """ |
|
189 if self.__widthForHeight: |
|
190 self.horizontalLayout1.takeAt(self.__widthForHeightLayoutIndex) |
|
191 else: |
|
192 self.verticalLayout.takeAt(self.__widthForHeightLayoutIndex) |
|
193 self.__addWidthForHeightLayout(widthForHeight) |
|
194 |
|
195 def __addWidthForHeightLayout(self, widthForHeight): |
|
196 """ |
|
197 Private method to set the middle part of the layout. |
|
198 |
|
199 @param widthForHeight flag indicating to prefer width |
|
200 @type bool |
|
201 """ |
|
202 if widthForHeight: |
|
203 self.horizontalLayout1.addLayout(self.horizontalLayout2) |
|
204 self.__widthForHeightLayoutIndex = 2 |
|
205 else: |
|
206 self.verticalLayout.insertLayout(1, self.horizontalLayout2) |
|
207 self.__widthForHeightLayoutIndex = 1 |
|
208 |
|
209 self.__widthForHeight = widthForHeight |
|
210 |
|
211 def attachTextEdit(self, textedit, editType=EricTextEditType.QTEXTEDIT): |
|
212 """ |
|
213 Public method to attach a QTextEdit or QWebEngineView widget. |
|
214 |
|
215 @param textedit reference to the edit widget to be attached |
|
216 @type QTextEdit, QTextBrowser or QWebEngineView |
|
217 @param editType type of the attached edit widget |
|
218 @type EricTextEditType |
|
219 """ |
|
220 if self.__textedit is not None: |
|
221 self.detachTextEdit() |
|
222 |
|
223 self.__textedit = textedit |
|
224 self.__texteditType = editType |
|
225 |
|
226 self.wordCheckBox.setVisible(editType in ( |
|
227 EricTextEditType.QTEXTEDIT, EricTextEditType.QTEXTBROWSER |
|
228 )) |
|
229 self.infoLabel.setVisible(editType == EricTextEditType.QWEBENGINEVIEW) |
|
230 if editType == EricTextEditType.QWEBENGINEVIEW: |
|
231 self.__textedit.page().findTextFinished.connect( |
|
232 self.__findTextFinished) |
|
233 |
|
234 def detachTextEdit(self): |
|
235 """ |
|
236 Public method to detach the current text edit. |
|
237 """ |
|
238 if self.__texteditType == EricTextEditType.QWEBENGINEVIEW: |
|
239 self.__textedit.page().findTextFinished.disconnect( |
|
240 self.__findTextFinished) |
|
241 |
|
242 self.__textedit = None |
|
243 self.__texteditType = EricTextEditType.UNKNOWN |
|
244 |
|
245 @pyqtSlot() |
|
246 def activate(self): |
|
247 """ |
|
248 Public slot to activate the widget. |
|
249 """ |
|
250 self.show() |
|
251 self.findtextCombo.setFocus( |
|
252 Qt.FocusReason.ActiveWindowFocusReason) |
|
253 self.findtextCombo.lineEdit().selectAll() |
|
254 |
|
255 @pyqtSlot() |
|
256 def deactivate(self): |
|
257 """ |
|
258 Public slot to deactivate the widget. |
|
259 """ |
|
260 if self.__textedit: |
|
261 self.__textedit.setFocus(Qt.FocusReason.ActiveWindowFocusReason) |
|
262 if self.__texteditType == EricTextEditType.QWEBENGINEVIEW: |
|
263 self.__textedit.findText("") |
|
264 if self.closeButton is not None: |
|
265 self.hide() |
|
266 self.closePressed.emit() |
|
267 |
|
268 @pyqtSlot() |
|
269 def __closeButtonClicked(self): |
|
270 """ |
|
271 Private slot to close the widget. |
|
272 |
|
273 Note: The widget is just hidden. |
|
274 """ |
|
275 self.deactivate() |
|
276 |
|
277 def keyPressEvent(self, event): |
|
278 """ |
|
279 Protected slot to handle key press events. |
|
280 |
|
281 @param event reference to the key press event |
|
282 @type QKeyEvent |
|
283 """ |
|
284 if self.__textedit: |
|
285 key = event.key() |
|
286 modifiers = event.modifiers() |
|
287 |
|
288 if key == Qt.Key.Key_Escape: |
|
289 self.deactivate() |
|
290 event.accept() |
|
291 |
|
292 elif key == Qt.Key.Key_F3: |
|
293 if modifiers == Qt.KeyboardModifier.NoModifier: |
|
294 # search forward |
|
295 self.on_findNextButton_clicked() |
|
296 event.accept() |
|
297 elif modifiers == Qt.KeyboardModifier.ShiftModifier: |
|
298 # search backward |
|
299 self.on_findPrevButton_clicked() |
|
300 event.accept() |
|
301 |
|
302 @pyqtSlot(str) |
|
303 def on_findtextCombo_editTextChanged(self, txt): |
|
304 """ |
|
305 Private slot to enable/disable the find buttons. |
|
306 |
|
307 @param txt text of the combobox |
|
308 @type str |
|
309 """ |
|
310 self.__setSearchButtons(txt != "") |
|
311 |
|
312 if self.__texteditType == EricTextEditType.QWEBENGINEVIEW: |
|
313 self.infoLabel.clear() |
|
314 else: |
|
315 self.infoLabel.hide() |
|
316 self.__setFindtextComboBackground(False) |
|
317 |
|
318 def __setSearchButtons(self, enabled): |
|
319 """ |
|
320 Private slot to set the state of the search buttons. |
|
321 |
|
322 @param enabled flag indicating the state |
|
323 @type bool |
|
324 """ |
|
325 self.findPrevButton.setEnabled(enabled) |
|
326 self.findNextButton.setEnabled(enabled) |
|
327 |
|
328 def __findByReturnPressed(self): |
|
329 """ |
|
330 Private slot to handle the returnPressed signal of the findtext |
|
331 combobox. |
|
332 """ |
|
333 self.__find(self.__findBackwards) |
|
334 |
|
335 @pyqtSlot() |
|
336 def on_findPrevButton_clicked(self): |
|
337 """ |
|
338 Private slot to find the previous occurrence. |
|
339 """ |
|
340 self.__find(True) |
|
341 |
|
342 @pyqtSlot() |
|
343 def on_findNextButton_clicked(self): |
|
344 """ |
|
345 Private slot to find the next occurrence. |
|
346 """ |
|
347 self.__find(False) |
|
348 |
|
349 @pyqtSlot() |
|
350 def findPrev(self): |
|
351 """ |
|
352 Public slot to find the previous occurrence of the current search term. |
|
353 """ |
|
354 self.on_findPrevButton_clicked() |
|
355 |
|
356 @pyqtSlot() |
|
357 def findNext(self): |
|
358 """ |
|
359 Public slot to find the next occurrence of the current search term. |
|
360 """ |
|
361 self.on_findNextButton_clicked() |
|
362 |
|
363 def __find(self, backwards): |
|
364 """ |
|
365 Private method to search the associated text edit. |
|
366 |
|
367 @param backwards flag indicating a backwards search |
|
368 @type bool |
|
369 """ |
|
370 if not self.__textedit: |
|
371 return |
|
372 |
|
373 self.infoLabel.clear() |
|
374 if self.__texteditType != EricTextEditType.QWEBENGINEVIEW: |
|
375 self.infoLabel.hide() |
|
376 self.__setFindtextComboBackground(False) |
|
377 |
|
378 txt = self.findtextCombo.currentText() |
|
379 if not txt: |
|
380 return |
|
381 self.__findBackwards = backwards |
|
382 |
|
383 # This moves any previous occurrence of this statement to the head |
|
384 # of the list and updates the combobox |
|
385 if txt in self.findHistory: |
|
386 self.findHistory.remove(txt) |
|
387 self.findHistory.insert(0, txt) |
|
388 self.findtextCombo.clear() |
|
389 self.findtextCombo.addItems(self.findHistory) |
|
390 |
|
391 if self.__texteditType in ( |
|
392 EricTextEditType.QTEXTBROWSER, EricTextEditType.QTEXTEDIT |
|
393 ): |
|
394 self.__findPrevNextQTextEdit(backwards) |
|
395 elif self.__texteditType == EricTextEditType.QWEBENGINEVIEW: |
|
396 self.__findPrevNextQWebEngineView(backwards) |
|
397 |
|
398 def __findPrevNextQTextEdit(self, backwards): |
|
399 """ |
|
400 Private method to to search the associated edit widget of |
|
401 type QTextEdit. |
|
402 |
|
403 @param backwards flag indicating a backwards search |
|
404 @type bool |
|
405 """ |
|
406 flags = ( |
|
407 QTextDocument.FindFlag.FindBackward |
|
408 if backwards else |
|
409 QTextDocument.FindFlag(0) |
|
410 ) |
|
411 if self.caseCheckBox.isChecked(): |
|
412 flags |= QTextDocument.FindFlag.FindCaseSensitively |
|
413 if self.wordCheckBox.isChecked(): |
|
414 flags |= QTextDocument.FindFlag.FindWholeWords |
|
415 |
|
416 ok = self.__textedit.find(self.findtextCombo.currentText(), flags) |
|
417 if not ok: |
|
418 # wrap around once |
|
419 cursor = self.__textedit.textCursor() |
|
420 if backwards: |
|
421 moveOp = QTextCursor.MoveOperation.End |
|
422 # move to end of document |
|
423 else: |
|
424 moveOp = QTextCursor.MoveOperation.Start |
|
425 # move to start of document |
|
426 cursor.movePosition(moveOp) |
|
427 self.__textedit.setTextCursor(cursor) |
|
428 ok = self.__textedit.find(self.findtextCombo.currentText(), flags) |
|
429 |
|
430 if not ok: |
|
431 self.infoLabel.setText( |
|
432 self.tr("'{0}' was not found.").format( |
|
433 self.findtextCombo.currentText()) |
|
434 ) |
|
435 self.infoLabel.show() |
|
436 self.__setFindtextComboBackground(True) |
|
437 |
|
438 def __findPrevNextQWebEngineView(self, backwards): |
|
439 """ |
|
440 Private method to to search the associated edit widget of |
|
441 type QWebEngineView. |
|
442 |
|
443 @param backwards flag indicating a backwards search |
|
444 @type bool |
|
445 """ |
|
446 from PyQt6.QtWebEngineCore import QWebEnginePage |
|
447 |
|
448 findFlags = QWebEnginePage.FindFlag(0) |
|
449 if self.caseCheckBox.isChecked(): |
|
450 findFlags |= QWebEnginePage.FindFlag.FindCaseSensitively |
|
451 if backwards: |
|
452 findFlags |= QWebEnginePage.FindFlag.FindBackward |
|
453 self.__textedit.findText(self.findtextCombo.currentText(), findFlags) |
|
454 |
|
455 def __setFindtextComboBackground(self, error): |
|
456 """ |
|
457 Private slot to change the findtext combo background to indicate |
|
458 errors. |
|
459 |
|
460 @param error flag indicating an error condition |
|
461 @type bool |
|
462 """ |
|
463 styleSheet = ( |
|
464 "color: #000000; background-color: #ff6666" |
|
465 if error else |
|
466 f"color: {self.__defaultTextColor};" |
|
467 f" background-color: {self.__defaultBaseColor}" |
|
468 ) |
|
469 self.findtextCombo.setStyleSheet(styleSheet) |
|
470 |
|
471 def __findTextFinished(self, result): |
|
472 """ |
|
473 Private slot handling the findTextFinished signal of the web page. |
|
474 |
|
475 @param result reference to the QWebEngineFindTextResult object of the |
|
476 last search |
|
477 @type QWebEngineFindTextResult |
|
478 """ |
|
479 if result.numberOfMatches() == 0: |
|
480 self.infoLabel.setText( |
|
481 self.tr("'{0}' was not found.").format( |
|
482 self.findtextCombo.currentText()) |
|
483 ) |
|
484 self.__setFindtextComboBackground(True) |
|
485 else: |
|
486 self.infoLabel.setText(self.tr("Match {0} of {1}").format( |
|
487 result.activeMatch(), result.numberOfMatches()) |
|
488 ) |
|
489 |
|
490 def showInfo(self, info): |
|
491 """ |
|
492 Public method to show some information in the info label. |
|
493 |
|
494 @param info informational text to be shown |
|
495 @type str |
|
496 """ |
|
497 self.infoLabel.setText(info) |
|
498 self.infoLabel.show() |