--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/EricWidgets/EricTextEditSearchWidget.py Thu Jul 07 11:23:56 2022 +0200 @@ -0,0 +1,498 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2012 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a horizontal search widget for QTextEdit. +""" + +import enum + +from PyQt6.QtCore import pyqtSlot, pyqtSignal, Qt, QMetaObject, QSize +from PyQt6.QtGui import QPalette, QTextDocument, QTextCursor +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QCheckBox, + QToolButton, QSizePolicy +) + +import UI.PixmapCache + + +class EricTextEditType(enum.Enum): + """ + Class defining the supported text edit types. + """ + UNKNOWN = 0 + QTEXTEDIT = 1 + QTEXTBROWSER = 2 + QWEBENGINEVIEW = 3 + + +class EricTextEditSearchWidget(QWidget): + """ + Class implementing a horizontal search widget for QTextEdit. + + @signal closePressed() emitted to indicate the closing of the widget via + the close button + """ + closePressed = pyqtSignal() + + def __init__(self, parent=None, widthForHeight=True, enableClose=False): + """ + Constructor + + @param parent reference to the parent widget + @type QWidget + @param widthForHeight flag indicating to prefer width for height. + If this parameter is False, some widgets are shown in a third + line. + @type bool + @param enableClose flag indicating to show a close button + @type bool + """ + super().__init__(parent) + self.__setupUi(widthForHeight, enableClose) + + self.__textedit = None + self.__texteditType = EricTextEditType.UNKNOWN + self.__findBackwards = False + + self.__defaultBaseColor = ( + self.findtextCombo.lineEdit().palette().color( + QPalette.ColorRole.Base) + ) + self.__defaultTextColor = ( + self.findtextCombo.lineEdit().palette().color( + QPalette.ColorRole.Text) + ) + + self.findHistory = [] + + self.findtextCombo.setCompleter(None) + self.findtextCombo.lineEdit().returnPressed.connect( + self.__findByReturnPressed) + + self.__setSearchButtons(False) + self.infoLabel.hide() + + self.setFocusProxy(self.findtextCombo) + + def __setupUi(self, widthForHeight, enableClose): + """ + Private method to generate the UI. + + @param widthForHeight flag indicating to prefer width for height + @type bool + @param enableClose flag indicating to show a close button + @type bool + """ + self.setObjectName("EricTextEditSearchWidget") + + self.verticalLayout = QVBoxLayout(self) + self.verticalLayout.setObjectName("verticalLayout") + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + + # row 1 of widgets + self.horizontalLayout1 = QHBoxLayout() + self.horizontalLayout1.setObjectName("horizontalLayout1") + + if enableClose: + self.closeButton = QToolButton(self) + self.closeButton.setIcon(UI.PixmapCache.getIcon("close")) + self.closeButton.clicked.connect(self.__closeButtonClicked) + self.horizontalLayout1.addWidget(self.closeButton) + else: + self.closeButton = None + + self.label = QLabel(self) + self.label.setObjectName("label") + self.label.setText(self.tr("Find:")) + self.horizontalLayout1.addWidget(self.label) + + self.findtextCombo = QComboBox(self) + self.findtextCombo.setEditable(True) + self.findtextCombo.lineEdit().setClearButtonEnabled(True) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth( + self.findtextCombo.sizePolicy().hasHeightForWidth()) + self.findtextCombo.setSizePolicy(sizePolicy) + self.findtextCombo.setMinimumSize(QSize(100, 0)) + self.findtextCombo.setEditable(True) + self.findtextCombo.setInsertPolicy(QComboBox.InsertPolicy.InsertAtTop) + self.findtextCombo.setDuplicatesEnabled(False) + self.findtextCombo.setObjectName("findtextCombo") + self.horizontalLayout1.addWidget(self.findtextCombo) + + # row 2 (maybe) of widgets + self.horizontalLayout2 = QHBoxLayout() + self.horizontalLayout2.setObjectName("horizontalLayout2") + + self.caseCheckBox = QCheckBox(self) + self.caseCheckBox.setObjectName("caseCheckBox") + self.caseCheckBox.setText(self.tr("Match case")) + self.horizontalLayout2.addWidget(self.caseCheckBox) + + self.wordCheckBox = QCheckBox(self) + self.wordCheckBox.setObjectName("wordCheckBox") + self.wordCheckBox.setText(self.tr("Whole word")) + self.horizontalLayout2.addWidget(self.wordCheckBox) + + # layout for the navigation buttons + self.horizontalLayout3 = QHBoxLayout() + self.horizontalLayout3.setSpacing(0) + self.horizontalLayout3.setObjectName("horizontalLayout3") + + self.findPrevButton = QToolButton(self) + self.findPrevButton.setObjectName("findPrevButton") + self.findPrevButton.setToolTip(self.tr( + "Press to find the previous occurrence")) + self.findPrevButton.setIcon(UI.PixmapCache.getIcon("1leftarrow")) + self.horizontalLayout3.addWidget(self.findPrevButton) + + self.findNextButton = QToolButton(self) + self.findNextButton.setObjectName("findNextButton") + self.findNextButton.setToolTip(self.tr( + "Press to find the next occurrence")) + self.findNextButton.setIcon(UI.PixmapCache.getIcon("1rightarrow")) + self.horizontalLayout3.addWidget(self.findNextButton) + + self.horizontalLayout2.addLayout(self.horizontalLayout3) + + # info label (in row 2 or 3) + self.infoLabel = QLabel(self) + self.infoLabel.setText("") + self.infoLabel.setObjectName("infoLabel") + + # place everything together + self.verticalLayout.addLayout(self.horizontalLayout1) + self.__addWidthForHeightLayout(widthForHeight) + self.verticalLayout.addWidget(self.infoLabel) + + QMetaObject.connectSlotsByName(self) + + self.setTabOrder(self.findtextCombo, self.caseCheckBox) + self.setTabOrder(self.caseCheckBox, self.wordCheckBox) + self.setTabOrder(self.wordCheckBox, self.findPrevButton) + self.setTabOrder(self.findPrevButton, self.findNextButton) + + def setWidthForHeight(self, widthForHeight): + """ + Public method to set the 'width for height'. + + @param widthForHeight flag indicating to prefer width + @type bool + """ + if self.__widthForHeight: + self.horizontalLayout1.takeAt(self.__widthForHeightLayoutIndex) + else: + self.verticalLayout.takeAt(self.__widthForHeightLayoutIndex) + self.__addWidthForHeightLayout(widthForHeight) + + def __addWidthForHeightLayout(self, widthForHeight): + """ + Private method to set the middle part of the layout. + + @param widthForHeight flag indicating to prefer width + @type bool + """ + if widthForHeight: + self.horizontalLayout1.addLayout(self.horizontalLayout2) + self.__widthForHeightLayoutIndex = 2 + else: + self.verticalLayout.insertLayout(1, self.horizontalLayout2) + self.__widthForHeightLayoutIndex = 1 + + self.__widthForHeight = widthForHeight + + def attachTextEdit(self, textedit, editType=EricTextEditType.QTEXTEDIT): + """ + Public method to attach a QTextEdit or QWebEngineView widget. + + @param textedit reference to the edit widget to be attached + @type QTextEdit, QTextBrowser or QWebEngineView + @param editType type of the attached edit widget + @type EricTextEditType + """ + if self.__textedit is not None: + self.detachTextEdit() + + self.__textedit = textedit + self.__texteditType = editType + + self.wordCheckBox.setVisible(editType in ( + EricTextEditType.QTEXTEDIT, EricTextEditType.QTEXTBROWSER + )) + self.infoLabel.setVisible(editType == EricTextEditType.QWEBENGINEVIEW) + if editType == EricTextEditType.QWEBENGINEVIEW: + self.__textedit.page().findTextFinished.connect( + self.__findTextFinished) + + def detachTextEdit(self): + """ + Public method to detach the current text edit. + """ + if self.__texteditType == EricTextEditType.QWEBENGINEVIEW: + self.__textedit.page().findTextFinished.disconnect( + self.__findTextFinished) + + self.__textedit = None + self.__texteditType = EricTextEditType.UNKNOWN + + @pyqtSlot() + def activate(self): + """ + Public slot to activate the widget. + """ + self.show() + self.findtextCombo.setFocus( + Qt.FocusReason.ActiveWindowFocusReason) + self.findtextCombo.lineEdit().selectAll() + + @pyqtSlot() + def deactivate(self): + """ + Public slot to deactivate the widget. + """ + if self.__textedit: + self.__textedit.setFocus(Qt.FocusReason.ActiveWindowFocusReason) + if self.__texteditType == EricTextEditType.QWEBENGINEVIEW: + self.__textedit.findText("") + if self.closeButton is not None: + self.hide() + self.closePressed.emit() + + @pyqtSlot() + def __closeButtonClicked(self): + """ + Private slot to close the widget. + + Note: The widget is just hidden. + """ + self.deactivate() + + def keyPressEvent(self, event): + """ + Protected slot to handle key press events. + + @param event reference to the key press event + @type QKeyEvent + """ + if self.__textedit: + key = event.key() + modifiers = event.modifiers() + + if key == Qt.Key.Key_Escape: + self.deactivate() + event.accept() + + elif key == Qt.Key.Key_F3: + if modifiers == Qt.KeyboardModifier.NoModifier: + # search forward + self.on_findNextButton_clicked() + event.accept() + elif modifiers == Qt.KeyboardModifier.ShiftModifier: + # search backward + self.on_findPrevButton_clicked() + event.accept() + + @pyqtSlot(str) + def on_findtextCombo_editTextChanged(self, txt): + """ + Private slot to enable/disable the find buttons. + + @param txt text of the combobox + @type str + """ + self.__setSearchButtons(txt != "") + + if self.__texteditType == EricTextEditType.QWEBENGINEVIEW: + self.infoLabel.clear() + else: + self.infoLabel.hide() + self.__setFindtextComboBackground(False) + + def __setSearchButtons(self, enabled): + """ + Private slot to set the state of the search buttons. + + @param enabled flag indicating the state + @type bool + """ + self.findPrevButton.setEnabled(enabled) + self.findNextButton.setEnabled(enabled) + + def __findByReturnPressed(self): + """ + Private slot to handle the returnPressed signal of the findtext + combobox. + """ + self.__find(self.__findBackwards) + + @pyqtSlot() + def on_findPrevButton_clicked(self): + """ + Private slot to find the previous occurrence. + """ + self.__find(True) + + @pyqtSlot() + def on_findNextButton_clicked(self): + """ + Private slot to find the next occurrence. + """ + self.__find(False) + + @pyqtSlot() + def findPrev(self): + """ + Public slot to find the previous occurrence of the current search term. + """ + self.on_findPrevButton_clicked() + + @pyqtSlot() + def findNext(self): + """ + Public slot to find the next occurrence of the current search term. + """ + self.on_findNextButton_clicked() + + def __find(self, backwards): + """ + Private method to search the associated text edit. + + @param backwards flag indicating a backwards search + @type bool + """ + if not self.__textedit: + return + + self.infoLabel.clear() + if self.__texteditType != EricTextEditType.QWEBENGINEVIEW: + self.infoLabel.hide() + self.__setFindtextComboBackground(False) + + txt = self.findtextCombo.currentText() + if not txt: + return + self.__findBackwards = backwards + + # This moves any previous occurrence of this statement to the head + # of the list and updates the combobox + if txt in self.findHistory: + self.findHistory.remove(txt) + self.findHistory.insert(0, txt) + self.findtextCombo.clear() + self.findtextCombo.addItems(self.findHistory) + + if self.__texteditType in ( + EricTextEditType.QTEXTBROWSER, EricTextEditType.QTEXTEDIT + ): + self.__findPrevNextQTextEdit(backwards) + elif self.__texteditType == EricTextEditType.QWEBENGINEVIEW: + self.__findPrevNextQWebEngineView(backwards) + + def __findPrevNextQTextEdit(self, backwards): + """ + Private method to to search the associated edit widget of + type QTextEdit. + + @param backwards flag indicating a backwards search + @type bool + """ + flags = ( + QTextDocument.FindFlag.FindBackward + if backwards else + QTextDocument.FindFlag(0) + ) + if self.caseCheckBox.isChecked(): + flags |= QTextDocument.FindFlag.FindCaseSensitively + if self.wordCheckBox.isChecked(): + flags |= QTextDocument.FindFlag.FindWholeWords + + ok = self.__textedit.find(self.findtextCombo.currentText(), flags) + if not ok: + # wrap around once + cursor = self.__textedit.textCursor() + if backwards: + moveOp = QTextCursor.MoveOperation.End + # move to end of document + else: + moveOp = QTextCursor.MoveOperation.Start + # move to start of document + cursor.movePosition(moveOp) + self.__textedit.setTextCursor(cursor) + ok = self.__textedit.find(self.findtextCombo.currentText(), flags) + + if not ok: + self.infoLabel.setText( + self.tr("'{0}' was not found.").format( + self.findtextCombo.currentText()) + ) + self.infoLabel.show() + self.__setFindtextComboBackground(True) + + def __findPrevNextQWebEngineView(self, backwards): + """ + Private method to to search the associated edit widget of + type QWebEngineView. + + @param backwards flag indicating a backwards search + @type bool + """ + from PyQt6.QtWebEngineCore import QWebEnginePage + + findFlags = QWebEnginePage.FindFlag(0) + if self.caseCheckBox.isChecked(): + findFlags |= QWebEnginePage.FindFlag.FindCaseSensitively + if backwards: + findFlags |= QWebEnginePage.FindFlag.FindBackward + self.__textedit.findText(self.findtextCombo.currentText(), findFlags) + + def __setFindtextComboBackground(self, error): + """ + Private slot to change the findtext combo background to indicate + errors. + + @param error flag indicating an error condition + @type bool + """ + styleSheet = ( + "color: #000000; background-color: #ff6666" + if error else + f"color: {self.__defaultTextColor};" + f" background-color: {self.__defaultBaseColor}" + ) + self.findtextCombo.setStyleSheet(styleSheet) + + def __findTextFinished(self, result): + """ + Private slot handling the findTextFinished signal of the web page. + + @param result reference to the QWebEngineFindTextResult object of the + last search + @type QWebEngineFindTextResult + """ + if result.numberOfMatches() == 0: + self.infoLabel.setText( + self.tr("'{0}' was not found.").format( + self.findtextCombo.currentText()) + ) + self.__setFindtextComboBackground(True) + else: + self.infoLabel.setText(self.tr("Match {0} of {1}").format( + result.activeMatch(), result.numberOfMatches()) + ) + + def showInfo(self, info): + """ + Public method to show some information in the info label. + + @param info informational text to be shown + @type str + """ + self.infoLabel.setText(info) + self.infoLabel.show()