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