1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2009 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing a web search widget for the web browser.
8"""
9
10from PyQt5.QtCore import pyqtSignal, QUrl, QModelIndex, QTimer, Qt
11from PyQt5.QtGui import (
12    QStandardItem, QStandardItemModel, QFont, QIcon, QPixmap
13)
14from PyQt5.QtWidgets import QMenu, QCompleter
15from PyQt5.QtWebEngineWidgets import QWebEnginePage
16
17import UI.PixmapCache
18
19import Preferences
20
21from E5Gui.E5LineEdit import E5ClearableLineEdit, E5LineEditSide
22
23from .WebBrowserPage import WebBrowserPage
24
25
26class WebBrowserWebSearchWidget(E5ClearableLineEdit):
27    """
28    Class implementing a web search widget for the web browser.
29
30    @signal search(QUrl) emitted when the search should be done
31    """
32    search = pyqtSignal(QUrl)
33
34    def __init__(self, mainWindow, parent=None):
35        """
36        Constructor
37
38        @param mainWindow reference to the browser main window
39        @type WebBrowserWindow
40        @param parent reference to the parent widget
41        @type QWidget
42        """
43        super().__init__(parent)
44
45        from E5Gui.E5LineEditButton import E5LineEditButton
46        from .OpenSearch.OpenSearchManager import OpenSearchManager
47
48        self.__mw = mainWindow
49
50        self.__openSearchManager = OpenSearchManager(self)
51        self.__openSearchManager.currentEngineChanged.connect(
52            self.__currentEngineChanged)
53        self.__currentEngine = ""
54
55        self.__enginesMenu = QMenu(self)
56        self.__enginesMenu.triggered.connect(
57            self.__handleEnginesMenuActionTriggered)
58
59        self.__engineButton = E5LineEditButton(self)
60        self.__engineButton.setMenu(self.__enginesMenu)
61        self.addWidget(self.__engineButton, E5LineEditSide.LEFT)
62
63        self.__searchButton = E5LineEditButton(self)
64        self.__searchButton.setIcon(UI.PixmapCache.getIcon("webSearch"))
65        self.addWidget(self.__searchButton, E5LineEditSide.LEFT)
66
67        self.__model = QStandardItemModel(self)
68        self.__completer = QCompleter()
69        self.__completer.setModel(self.__model)
70        self.__completer.setCompletionMode(
71            QCompleter.CompletionMode.UnfilteredPopupCompletion)
72        self.__completer.setWidget(self)
73
74        self.__searchButton.clicked.connect(self.__searchButtonClicked)
75        self.textEdited.connect(self.__textEdited)
76        self.returnPressed.connect(self.__searchNow)
77        self.__completer.activated[QModelIndex].connect(
78            self.__completerActivated)
79        self.__completer.highlighted[QModelIndex].connect(
80            self.__completerHighlighted)
81        self.__enginesMenu.aboutToShow.connect(self.__showEnginesMenu)
82
83        self.__suggestionsItem = None
84        self.__suggestions = []
85        self.__suggestTimer = None
86        self.__suggestionsEnabled = Preferences.getWebBrowser(
87            "WebSearchSuggestions")
88
89        self.__recentSearchesItem = None
90        self.__recentSearches = []
91        self.__maxSavedSearches = 10
92
93        self.__engine = None
94        self.__loadSearches()
95        self.__setupCompleterMenu()
96        self.__currentEngineChanged()
97
98    def __searchNow(self):
99        """
100        Private slot to perform the web search.
101        """
102        searchText = self.text()
103        if not searchText:
104            return
105
106        import WebBrowser.WebBrowserWindow
107        if WebBrowser.WebBrowserWindow.WebBrowserWindow.isPrivate():
108            return
109
110        if searchText in self.__recentSearches:
111            self.__recentSearches.remove(searchText)
112        self.__recentSearches.insert(0, searchText)
113        if len(self.__recentSearches) > self.__maxSavedSearches:
114            self.__recentSearches = self.__recentSearches[
115                :self.__maxSavedSearches]
116        self.__setupCompleterMenu()
117
118        self.__mw.currentBrowser().setFocus()
119        self.__mw.currentBrowser().load(
120            self.__openSearchManager.currentEngine().searchUrl(searchText))
121
122    def __setupCompleterMenu(self):
123        """
124        Private method to create the completer menu.
125        """
126        if (
127            not self.__suggestions or
128            (self.__model.rowCount() > 0 and
129             self.__model.item(0) != self.__suggestionsItem)
130        ):
131            self.__model.clear()
132            self.__suggestionsItem = None
133        else:
134            self.__model.removeRows(1, self.__model.rowCount() - 1)
135
136        boldFont = QFont()
137        boldFont.setBold(True)
138
139        if self.__suggestions:
140            if self.__model.rowCount() == 0:
141                if not self.__suggestionsItem:
142                    self.__suggestionsItem = QStandardItem(
143                        self.tr("Suggestions"))
144                    self.__suggestionsItem.setFont(boldFont)
145                self.__model.appendRow(self.__suggestionsItem)
146
147            for suggestion in self.__suggestions:
148                self.__model.appendRow(QStandardItem(suggestion))
149
150        if not self.__recentSearches:
151            self.__recentSearchesItem = QStandardItem(
152                self.tr("No Recent Searches"))
153            self.__recentSearchesItem.setFont(boldFont)
154            self.__model.appendRow(self.__recentSearchesItem)
155        else:
156            self.__recentSearchesItem = QStandardItem(
157                self.tr("Recent Searches"))
158            self.__recentSearchesItem.setFont(boldFont)
159            self.__model.appendRow(self.__recentSearchesItem)
160            for recentSearch in self.__recentSearches:
161                self.__model.appendRow(QStandardItem(recentSearch))
162
163        view = self.__completer.popup()
164        view.setFixedHeight(view.sizeHintForRow(0) * self.__model.rowCount() +
165                            view.frameWidth() * 2)
166
167        self.__searchButton.setEnabled(
168            bool(self.__recentSearches or self.__suggestions))
169
170    def __completerActivated(self, index):
171        """
172        Private slot handling the selection of an entry from the completer.
173
174        @param index index of the item (QModelIndex)
175        """
176        if (
177            self.__suggestionsItem and
178            self.__suggestionsItem.index().row() == index.row()
179        ):
180            return
181
182        if (
183            self.__recentSearchesItem and
184            self.__recentSearchesItem.index().row() == index.row()
185        ):
186            return
187
188        self.__searchNow()
189
190    def __completerHighlighted(self, index):
191        """
192        Private slot handling the highlighting of an entry of the completer.
193
194        @param index index of the item (QModelIndex)
195        @return flah indicating a successful highlighting (boolean)
196        """
197        if (
198            self.__suggestionsItem and
199            self.__suggestionsItem.index().row() == index.row()
200        ):
201            return False
202
203        if (
204            self.__recentSearchesItem and
205            self.__recentSearchesItem.index().row() == index.row()
206        ):
207            return False
208
209        self.setText(index.data())
210        return True
211
212    def __textEdited(self, txt):
213        """
214        Private slot to handle changes of the search text.
215
216        @param txt search text (string)
217        """
218        if self.__suggestionsEnabled:
219            if self.__suggestTimer is None:
220                self.__suggestTimer = QTimer(self)
221                self.__suggestTimer.setSingleShot(True)
222                self.__suggestTimer.setInterval(200)
223                self.__suggestTimer.timeout.connect(self.__getSuggestions)
224            self.__suggestTimer.start()
225        else:
226            self.__completer.setCompletionPrefix(txt)
227            self.__completer.complete()
228
229    def __getSuggestions(self):
230        """
231        Private slot to get search suggestions from the configured search
232        engine.
233        """
234        searchText = self.text()
235        if searchText:
236            self.__openSearchManager.currentEngine().requestSuggestions(
237                searchText)
238
239    def __newSuggestions(self, suggestions):
240        """
241        Private slot to receive a new list of suggestions.
242
243        @param suggestions list of suggestions (list of strings)
244        """
245        self.__suggestions = suggestions
246        self.__setupCompleterMenu()
247        self.__completer.complete()
248
249    def __showEnginesMenu(self):
250        """
251        Private slot to handle the display of the engines menu.
252        """
253        self.__enginesMenu.clear()
254
255        from .OpenSearch.OpenSearchEngineAction import OpenSearchEngineAction
256        engineNames = self.__openSearchManager.allEnginesNames()
257        for engineName in engineNames:
258            engine = self.__openSearchManager.engine(engineName)
259            action = OpenSearchEngineAction(engine, self.__enginesMenu)
260            action.setData(engineName)
261            self.__enginesMenu.addAction(action)
262
263            if self.__openSearchManager.currentEngineName() == engineName:
264                action.setCheckable(True)
265                action.setChecked(True)
266
267        cb = self.__mw.currentBrowser()
268        from .Tools import Scripts
269        script = Scripts.getOpenSearchLinks()
270        cb.page().runJavaScript(
271            script, WebBrowserPage.SafeJsWorld, self.__showEnginesMenuCallback)
272
273    def __showEnginesMenuCallback(self, res):
274        """
275        Private method handling the open search links callback.
276
277        @param res result of the JavaScript
278        @type list of dict
279        """
280        cb = self.__mw.currentBrowser()
281        if res:
282            self.__enginesMenu.addSeparator()
283            for entry in res:
284                url = cb.url().resolved(QUrl(entry["url"]))
285                title = entry["title"]
286                if url.isEmpty():
287                    continue
288                if not title:
289                    title = cb.title()
290
291                action = self.__enginesMenu.addAction(
292                    self.tr("Add '{0}'").format(title))
293                action.setData(url)
294                action.setIcon(cb.icon())
295
296        self.__enginesMenu.addSeparator()
297        self.__enginesMenu.addAction(self.__mw.searchEnginesAction())
298
299        if self.__recentSearches:
300            act = self.__enginesMenu.addAction(
301                self.tr("Clear Recent Searches"))
302            act.setData("@@CLEAR@@")
303
304    def __handleEnginesMenuActionTriggered(self, action):
305        """
306        Private slot to handle an action of the menu being triggered.
307
308        @param action reference to the action that triggered
309        @type QAction
310        """
311        actData = action.data()
312        if isinstance(actData, QUrl):
313            # add search engine
314            self.__openSearchManager.addEngine(actData)
315        elif isinstance(actData, str):
316            # engine name or special action
317            if actData == "@@CLEAR@@":
318                self.clear()
319            else:
320                self.__openSearchManager.setCurrentEngineName(actData)
321
322    def __searchButtonClicked(self):
323        """
324        Private slot to show the search menu via the search button.
325        """
326        self.__setupCompleterMenu()
327        self.__completer.complete()
328
329    def clear(self):
330        """
331        Public method to clear all private data.
332        """
333        self.__recentSearches = []
334        self.__setupCompleterMenu()
335        super().clear()
336        self.clearFocus()
337
338    def preferencesChanged(self):
339        """
340        Public method to handle the change of preferences.
341        """
342        self.__suggestionsEnabled = Preferences.getWebBrowser(
343            "WebSearchSuggestions")
344        if not self.__suggestionsEnabled:
345            self.__suggestions = []
346            self.__setupCompleterMenu()
347
348    def saveSearches(self):
349        """
350        Public method to save the recently performed web searches.
351        """
352        Preferences.Prefs.settings.setValue(
353            'WebBrowser/WebSearches', self.__recentSearches)
354
355    def __loadSearches(self):
356        """
357        Private method to load the recently performed web searches.
358        """
359        searches = Preferences.Prefs.settings.value('WebBrowser/WebSearches')
360        if searches is not None:
361            self.__recentSearches = searches
362
363    def openSearchManager(self):
364        """
365        Public method to get a reference to the opensearch manager object.
366
367        @return reference to the opensearch manager object (OpenSearchManager)
368        """
369        return self.__openSearchManager
370
371    def __currentEngineChanged(self):
372        """
373        Private slot to track a change of the current search engine.
374        """
375        if self.__openSearchManager.engineExists(self.__currentEngine):
376            oldEngine = self.__openSearchManager.engine(self.__currentEngine)
377            oldEngine.imageChanged.disconnect(self.__engineImageChanged)
378            if self.__suggestionsEnabled:
379                oldEngine.suggestions.disconnect(self.__newSuggestions)
380
381        newEngine = self.__openSearchManager.currentEngine()
382        if newEngine.networkAccessManager() is None:
383            newEngine.setNetworkAccessManager(self.__mw.networkManager())
384        newEngine.imageChanged.connect(self.__engineImageChanged)
385        if self.__suggestionsEnabled:
386            newEngine.suggestions.connect(self.__newSuggestions)
387
388        self.setInactiveText(self.__openSearchManager.currentEngineName())
389        self.__currentEngine = self.__openSearchManager.currentEngineName()
390        self.__engineButton.setIcon(QIcon(QPixmap.fromImage(
391            self.__openSearchManager.currentEngine().image())))
392        self.__suggestions = []
393        self.__setupCompleterMenu()
394
395    def __engineImageChanged(self):
396        """
397        Private slot to handle a change of the current search engine icon.
398        """
399        self.__engineButton.setIcon(QIcon(QPixmap.fromImage(
400            self.__openSearchManager.currentEngine().image())))
401
402    def mousePressEvent(self, evt):
403        """
404        Protected method called by a mouse press event.
405
406        @param evt reference to the mouse event (QMouseEvent)
407        """
408        if evt.button() == Qt.MouseButton.XButton1:
409            self.__mw.currentBrowser().triggerPageAction(
410                QWebEnginePage.WebAction.Back)
411        elif evt.button() == Qt.MouseButton.XButton2:
412            self.__mw.currentBrowser().triggerPageAction(
413                QWebEnginePage.WebAction.Forward)
414        else:
415            super().mousePressEvent(evt)
416