1# -*- coding: utf-8 -*- 2 3# Copyright (c) 2012 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> 4# 5 6""" 7Module implementing a horizontal search widget for QTextEdit. 8""" 9 10import enum 11 12from PyQt5.QtCore import pyqtSlot, Qt, QMetaObject, QSize 13from PyQt5.QtGui import QPalette, QBrush, QColor, QTextDocument, QTextCursor 14from PyQt5.QtWidgets import ( 15 QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QCheckBox, 16 QToolButton, QSizePolicy 17) 18 19from E5Gui.E5ComboBox import E5ClearableComboBox 20 21import UI.PixmapCache 22 23 24class E5TextEditType(enum.Enum): 25 """ 26 Class defining the supported text edit types. 27 """ 28 UNKNOWN = 0 29 QTEXTEDIT = 1 30 QTEXTBROWSER = 2 31 QWEBENGINEVIEW = 3 32 33 34class E5TextEditSearchWidget(QWidget): 35 """ 36 Class implementing a horizontal search widget for QTextEdit. 37 """ 38 def __init__(self, parent=None, widthForHeight=True): 39 """ 40 Constructor 41 42 @param parent reference to the parent widget 43 @type QWidget 44 @param widthForHeight flag indicating to prefer width for height. 45 If this parameter is False, some widgets are shown in a third 46 line. 47 @type bool 48 """ 49 super().__init__(parent) 50 self.__setupUi(widthForHeight) 51 52 self.__textedit = None 53 self.__texteditType = E5TextEditType.UNKNOWN 54 self.__findBackwards = True 55 56 self.__defaultBaseColor = ( 57 self.findtextCombo.lineEdit().palette().color( 58 QPalette.ColorRole.Base) 59 ) 60 self.__defaultTextColor = ( 61 self.findtextCombo.lineEdit().palette().color( 62 QPalette.ColorRole.Text) 63 ) 64 65 self.findHistory = [] 66 67 self.findtextCombo.setCompleter(None) 68 self.findtextCombo.lineEdit().returnPressed.connect( 69 self.__findByReturnPressed) 70 71 self.__setSearchButtons(False) 72 self.infoLabel.hide() 73 74 self.setFocusProxy(self.findtextCombo) 75 76 def __setupUi(self, widthForHeight): 77 """ 78 Private method to generate the UI. 79 80 @param widthForHeight flag indicating to prefer width for height 81 @type bool 82 """ 83 self.setObjectName("E5TextEditSearchWidget") 84 85 self.verticalLayout = QVBoxLayout(self) 86 self.verticalLayout.setObjectName("verticalLayout") 87 self.verticalLayout.setContentsMargins(0, 0, 0, 0) 88 89 # row 1 of widgets 90 self.horizontalLayout1 = QHBoxLayout() 91 self.horizontalLayout1.setObjectName("horizontalLayout1") 92 93 self.label = QLabel(self) 94 self.label.setObjectName("label") 95 self.label.setText(self.tr("Find:")) 96 self.horizontalLayout1.addWidget(self.label) 97 98 self.findtextCombo = E5ClearableComboBox(self) 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.FindFlags(QTextDocument.FindFlag.FindBackward) 306 if backwards else 307 QTextDocument.FindFlags() 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 PyQt5.QtWebEngineWidgets import QWebEnginePage 339 340 findFlags = QWebEnginePage.FindFlags() 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() 379