1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2012 - 2021 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, Qt, QMetaObject, QSize |
|
13 from PyQt6.QtGui import QPalette, QBrush, QColor, 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 E5TextEditType(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 E5TextEditSearchWidget(QWidget): |
|
33 """ |
|
34 Class implementing a horizontal search widget for QTextEdit. |
|
35 """ |
|
36 def __init__(self, parent=None, widthForHeight=True): |
|
37 """ |
|
38 Constructor |
|
39 |
|
40 @param parent reference to the parent widget |
|
41 @type QWidget |
|
42 @param widthForHeight flag indicating to prefer width for height. |
|
43 If this parameter is False, some widgets are shown in a third |
|
44 line. |
|
45 @type bool |
|
46 """ |
|
47 super().__init__(parent) |
|
48 self.__setupUi(widthForHeight) |
|
49 |
|
50 self.__textedit = None |
|
51 self.__texteditType = E5TextEditType.UNKNOWN |
|
52 self.__findBackwards = True |
|
53 |
|
54 self.__defaultBaseColor = ( |
|
55 self.findtextCombo.lineEdit().palette().color( |
|
56 QPalette.ColorRole.Base) |
|
57 ) |
|
58 self.__defaultTextColor = ( |
|
59 self.findtextCombo.lineEdit().palette().color( |
|
60 QPalette.ColorRole.Text) |
|
61 ) |
|
62 |
|
63 self.findHistory = [] |
|
64 |
|
65 self.findtextCombo.setCompleter(None) |
|
66 self.findtextCombo.lineEdit().returnPressed.connect( |
|
67 self.__findByReturnPressed) |
|
68 |
|
69 self.__setSearchButtons(False) |
|
70 self.infoLabel.hide() |
|
71 |
|
72 self.setFocusProxy(self.findtextCombo) |
|
73 |
|
74 def __setupUi(self, widthForHeight): |
|
75 """ |
|
76 Private method to generate the UI. |
|
77 |
|
78 @param widthForHeight flag indicating to prefer width for height |
|
79 @type bool |
|
80 """ |
|
81 self.setObjectName("E5TextEditSearchWidget") |
|
82 |
|
83 self.verticalLayout = QVBoxLayout(self) |
|
84 self.verticalLayout.setObjectName("verticalLayout") |
|
85 self.verticalLayout.setContentsMargins(0, 0, 0, 0) |
|
86 |
|
87 # row 1 of widgets |
|
88 self.horizontalLayout1 = QHBoxLayout() |
|
89 self.horizontalLayout1.setObjectName("horizontalLayout1") |
|
90 |
|
91 self.label = QLabel(self) |
|
92 self.label.setObjectName("label") |
|
93 self.label.setText(self.tr("Find:")) |
|
94 self.horizontalLayout1.addWidget(self.label) |
|
95 |
|
96 self.findtextCombo = QComboBox(self) |
|
97 self.findtextCombo.setEditable(True) |
|
98 self.findtextCombo.lineEdit().setClearButtonEnabled(True) |
|
99 sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, |
|
100 QSizePolicy.Policy.Fixed) |
|
101 sizePolicy.setHorizontalStretch(0) |
|
102 sizePolicy.setVerticalStretch(0) |
|
103 sizePolicy.setHeightForWidth( |
|
104 self.findtextCombo.sizePolicy().hasHeightForWidth()) |
|
105 self.findtextCombo.setSizePolicy(sizePolicy) |
|
106 self.findtextCombo.setMinimumSize(QSize(100, 0)) |
|
107 self.findtextCombo.setEditable(True) |
|
108 self.findtextCombo.setInsertPolicy(QComboBox.InsertPolicy.InsertAtTop) |
|
109 self.findtextCombo.setDuplicatesEnabled(False) |
|
110 self.findtextCombo.setObjectName("findtextCombo") |
|
111 self.horizontalLayout1.addWidget(self.findtextCombo) |
|
112 |
|
113 # row 2 (maybe) of widgets |
|
114 self.horizontalLayout2 = QHBoxLayout() |
|
115 self.horizontalLayout2.setObjectName("horizontalLayout2") |
|
116 |
|
117 self.caseCheckBox = QCheckBox(self) |
|
118 self.caseCheckBox.setObjectName("caseCheckBox") |
|
119 self.caseCheckBox.setText(self.tr("Match case")) |
|
120 self.horizontalLayout2.addWidget(self.caseCheckBox) |
|
121 |
|
122 self.wordCheckBox = QCheckBox(self) |
|
123 self.wordCheckBox.setObjectName("wordCheckBox") |
|
124 self.wordCheckBox.setText(self.tr("Whole word")) |
|
125 self.horizontalLayout2.addWidget(self.wordCheckBox) |
|
126 |
|
127 # layout for the navigation buttons |
|
128 self.horizontalLayout3 = QHBoxLayout() |
|
129 self.horizontalLayout3.setSpacing(0) |
|
130 self.horizontalLayout3.setObjectName("horizontalLayout3") |
|
131 |
|
132 self.findPrevButton = QToolButton(self) |
|
133 self.findPrevButton.setObjectName("findPrevButton") |
|
134 self.findPrevButton.setToolTip(self.tr( |
|
135 "Press to find the previous occurrence")) |
|
136 self.findPrevButton.setIcon(UI.PixmapCache.getIcon("1leftarrow")) |
|
137 self.horizontalLayout3.addWidget(self.findPrevButton) |
|
138 |
|
139 self.findNextButton = QToolButton(self) |
|
140 self.findNextButton.setObjectName("findNextButton") |
|
141 self.findNextButton.setToolTip(self.tr( |
|
142 "Press to find the next occurrence")) |
|
143 self.findNextButton.setIcon(UI.PixmapCache.getIcon("1rightarrow")) |
|
144 self.horizontalLayout3.addWidget(self.findNextButton) |
|
145 |
|
146 self.horizontalLayout2.addLayout(self.horizontalLayout3) |
|
147 |
|
148 # info label (in row 2 or 3) |
|
149 self.infoLabel = QLabel(self) |
|
150 self.infoLabel.setText("") |
|
151 self.infoLabel.setObjectName("infoLabel") |
|
152 |
|
153 # place everything together |
|
154 self.verticalLayout.addLayout(self.horizontalLayout1) |
|
155 self.__addWidthForHeightLayout(widthForHeight) |
|
156 self.verticalLayout.addWidget(self.infoLabel) |
|
157 |
|
158 QMetaObject.connectSlotsByName(self) |
|
159 |
|
160 self.setTabOrder(self.findtextCombo, self.caseCheckBox) |
|
161 self.setTabOrder(self.caseCheckBox, self.wordCheckBox) |
|
162 self.setTabOrder(self.wordCheckBox, self.findPrevButton) |
|
163 self.setTabOrder(self.findPrevButton, self.findNextButton) |
|
164 |
|
165 def setWidthForHeight(self, widthForHeight): |
|
166 """ |
|
167 Public method to set the 'width for height'. |
|
168 |
|
169 @param widthForHeight flag indicating to prefer width |
|
170 @type bool |
|
171 """ |
|
172 if self.__widthForHeight: |
|
173 self.horizontalLayout1.takeAt(self.__widthForHeightLayoutIndex) |
|
174 else: |
|
175 self.verticalLayout.takeAt(self.__widthForHeightLayoutIndex) |
|
176 self.__addWidthForHeightLayout(widthForHeight) |
|
177 |
|
178 def __addWidthForHeightLayout(self, widthForHeight): |
|
179 """ |
|
180 Private method to set the middle part of the layout. |
|
181 |
|
182 @param widthForHeight flag indicating to prefer width |
|
183 @type bool |
|
184 """ |
|
185 if widthForHeight: |
|
186 self.horizontalLayout1.addLayout(self.horizontalLayout2) |
|
187 self.__widthForHeightLayoutIndex = 2 |
|
188 else: |
|
189 self.verticalLayout.insertLayout(1, self.horizontalLayout2) |
|
190 self.__widthForHeightLayoutIndex = 1 |
|
191 |
|
192 self.__widthForHeight = widthForHeight |
|
193 |
|
194 def attachTextEdit(self, textedit, editType=E5TextEditType.QTEXTEDIT): |
|
195 """ |
|
196 Public method to attach a QTextEdit widget. |
|
197 |
|
198 @param textedit reference to the edit widget to be attached |
|
199 @type QTextEdit, QWebEngineView or QWebView |
|
200 @param editType type of the attached edit widget |
|
201 @type E5TextEditType |
|
202 """ |
|
203 self.__textedit = textedit |
|
204 self.__texteditType = editType |
|
205 |
|
206 self.wordCheckBox.setVisible(editType == "QTextEdit") |
|
207 |
|
208 def keyPressEvent(self, event): |
|
209 """ |
|
210 Protected slot to handle key press events. |
|
211 |
|
212 @param event reference to the key press event (QKeyEvent) |
|
213 """ |
|
214 if self.__textedit and event.key() == Qt.Key.Key_Escape: |
|
215 self.__textedit.setFocus(Qt.FocusReason.ActiveWindowFocusReason) |
|
216 event.accept() |
|
217 |
|
218 @pyqtSlot(str) |
|
219 def on_findtextCombo_editTextChanged(self, txt): |
|
220 """ |
|
221 Private slot to enable/disable the find buttons. |
|
222 |
|
223 @param txt text of the combobox (string) |
|
224 """ |
|
225 self.__setSearchButtons(txt != "") |
|
226 |
|
227 self.infoLabel.hide() |
|
228 self.__setFindtextComboBackground(False) |
|
229 |
|
230 def __setSearchButtons(self, enabled): |
|
231 """ |
|
232 Private slot to set the state of the search buttons. |
|
233 |
|
234 @param enabled flag indicating the state (boolean) |
|
235 """ |
|
236 self.findPrevButton.setEnabled(enabled) |
|
237 self.findNextButton.setEnabled(enabled) |
|
238 |
|
239 def __findByReturnPressed(self): |
|
240 """ |
|
241 Private slot to handle the returnPressed signal of the findtext |
|
242 combobox. |
|
243 """ |
|
244 self.__find(self.__findBackwards) |
|
245 |
|
246 @pyqtSlot() |
|
247 def on_findPrevButton_clicked(self): |
|
248 """ |
|
249 Private slot to find the previous occurrence. |
|
250 """ |
|
251 self.__find(True) |
|
252 |
|
253 @pyqtSlot() |
|
254 def on_findNextButton_clicked(self): |
|
255 """ |
|
256 Private slot to find the next occurrence. |
|
257 """ |
|
258 self.__find(False) |
|
259 |
|
260 def __find(self, backwards): |
|
261 """ |
|
262 Private method to search the associated text edit. |
|
263 |
|
264 @param backwards flag indicating a backwards search (boolean) |
|
265 """ |
|
266 if not self.__textedit: |
|
267 return |
|
268 |
|
269 self.infoLabel.clear() |
|
270 self.infoLabel.hide() |
|
271 self.__setFindtextComboBackground(False) |
|
272 |
|
273 txt = self.findtextCombo.currentText() |
|
274 if not txt: |
|
275 return |
|
276 self.__findBackwards = backwards |
|
277 |
|
278 # This moves any previous occurrence of this statement to the head |
|
279 # of the list and updates the combobox |
|
280 if txt in self.findHistory: |
|
281 self.findHistory.remove(txt) |
|
282 self.findHistory.insert(0, txt) |
|
283 self.findtextCombo.clear() |
|
284 self.findtextCombo.addItems(self.findHistory) |
|
285 |
|
286 if self.__texteditType in ( |
|
287 E5TextEditType.QTEXTBROWSER, E5TextEditType.QTEXTEDIT |
|
288 ): |
|
289 ok = self.__findPrevNextQTextEdit(backwards) |
|
290 self.__findNextPrevCallback(ok) |
|
291 elif self.__texteditType == E5TextEditType.QWEBENGINEVIEW: |
|
292 self.__findPrevNextQWebEngineView(backwards) |
|
293 |
|
294 def __findPrevNextQTextEdit(self, backwards): |
|
295 """ |
|
296 Private method to to search the associated edit widget of |
|
297 type QTextEdit. |
|
298 |
|
299 @param backwards flag indicating a backwards search |
|
300 @type bool |
|
301 @return flag indicating the search result |
|
302 @rtype bool |
|
303 """ |
|
304 flags = ( |
|
305 QTextDocument.FindFlag.FindBackward |
|
306 if backwards else |
|
307 QTextDocument.FindFlag(0) |
|
308 ) |
|
309 if self.caseCheckBox.isChecked(): |
|
310 flags |= QTextDocument.FindFlag.FindCaseSensitively |
|
311 if self.wordCheckBox.isChecked(): |
|
312 flags |= QTextDocument.FindFlag.FindWholeWords |
|
313 |
|
314 ok = self.__textedit.find(self.findtextCombo.currentText(), flags) |
|
315 if not ok: |
|
316 # wrap around once |
|
317 cursor = self.__textedit.textCursor() |
|
318 if backwards: |
|
319 moveOp = QTextCursor.MoveOperation.End |
|
320 # move to end of document |
|
321 else: |
|
322 moveOp = QTextCursor.MoveOperation.Start |
|
323 # move to start of document |
|
324 cursor.movePosition(moveOp) |
|
325 self.__textedit.setTextCursor(cursor) |
|
326 ok = self.__textedit.find(self.findtextCombo.currentText(), flags) |
|
327 |
|
328 return ok |
|
329 |
|
330 def __findPrevNextQWebEngineView(self, backwards): |
|
331 """ |
|
332 Private method to to search the associated edit widget of |
|
333 type QWebEngineView. |
|
334 |
|
335 @param backwards flag indicating a backwards search |
|
336 @type bool |
|
337 """ |
|
338 from PyQt6.QtWebEngineWidgets import QWebEnginePage |
|
339 |
|
340 findFlags = QWebEnginePage.FindFlag(0) |
|
341 if self.caseCheckBox.isChecked(): |
|
342 findFlags |= QWebEnginePage.FindFlag.FindCaseSensitively |
|
343 if backwards: |
|
344 findFlags |= QWebEnginePage.FindFlag.FindBackward |
|
345 self.__textedit.findText(self.findtextCombo.currentText(), |
|
346 findFlags, self.__findNextPrevCallback) |
|
347 |
|
348 def __findNextPrevCallback(self, found): |
|
349 """ |
|
350 Private method to process the result of the last search. |
|
351 |
|
352 @param found flag indicating if the last search succeeded |
|
353 @type bool |
|
354 """ |
|
355 if not found: |
|
356 txt = self.findtextCombo.currentText() |
|
357 self.infoLabel.setText( |
|
358 self.tr("'{0}' was not found.").format(txt)) |
|
359 self.infoLabel.show() |
|
360 self.__setFindtextComboBackground(True) |
|
361 |
|
362 def __setFindtextComboBackground(self, error): |
|
363 """ |
|
364 Private slot to change the findtext combo background to indicate |
|
365 errors. |
|
366 |
|
367 @param error flag indicating an error condition (boolean) |
|
368 """ |
|
369 le = self.findtextCombo.lineEdit() |
|
370 p = le.palette() |
|
371 if error: |
|
372 p.setBrush(QPalette.ColorRole.Base, QBrush(QColor("#FF6666"))) |
|
373 p.setBrush(QPalette.ColorRole.Text, QBrush(QColor("#000000"))) |
|
374 else: |
|
375 p.setBrush(QPalette.ColorRole.Base, self.__defaultBaseColor) |
|
376 p.setBrush(QPalette.ColorRole.Text, self.__defaultTextColor) |
|
377 le.setPalette(p) |
|
378 le.update() |
|