1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2009 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing a special completer for the history.
8"""
9
10import re
11
12from PyQt5.QtCore import Qt, QTimer, QSortFilterProxyModel
13from PyQt5.QtWidgets import QTableView, QAbstractItemView, QCompleter
14
15from .HistoryModel import HistoryModel
16from .HistoryFilterModel import HistoryFilterModel
17
18
19class HistoryCompletionView(QTableView):
20    """
21    Class implementing a special completer view for history based completions.
22    """
23    def __init__(self, parent=None):
24        """
25        Constructor
26
27        @param parent reference to the parent widget (QWidget)
28        """
29        super().__init__(parent)
30
31        self.horizontalHeader().hide()
32        self.verticalHeader().hide()
33
34        self.setShowGrid(False)
35
36        self.setSelectionBehavior(
37            QAbstractItemView.SelectionBehavior.SelectRows)
38        self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
39        self.setTextElideMode(Qt.TextElideMode.ElideRight)
40
41        metrics = self.fontMetrics()
42        self.verticalHeader().setDefaultSectionSize(metrics.height())
43
44    def resizeEvent(self, evt):
45        """
46        Protected method handling resize events.
47
48        @param evt reference to the resize event (QResizeEvent)
49        """
50        self.horizontalHeader().resizeSection(0, 0.65 * self.width())
51        self.horizontalHeader().setStretchLastSection(True)
52
53        super().resizeEvent(evt)
54
55    def sizeHintForRow(self, row):
56        """
57        Public method to give a size hint for rows.
58
59        @param row row number (integer)
60        @return desired row height (integer)
61        """
62        metrics = self.fontMetrics()
63        return metrics.height()
64
65
66class HistoryCompletionModel(QSortFilterProxyModel):
67    """
68    Class implementing a special model for history based completions.
69    """
70    HistoryCompletionRole = HistoryFilterModel.MaxRole + 1
71
72    def __init__(self, parent=None):
73        """
74        Constructor
75
76        @param parent reference to the parent object (QObject)
77        """
78        super().__init__(parent)
79
80        self.__searchString = ""
81        self.__searchMatcher = None
82        self.__wordMatcher = None
83        self.__isValid = False
84
85        self.setDynamicSortFilter(True)
86
87    def data(self, index, role=Qt.ItemDataRole.DisplayRole):
88        """
89        Public method to get data from the model.
90
91        @param index index of history entry to get data for (QModelIndex)
92        @param role data role (integer)
93        @return history entry data
94        """
95        # If the model is valid, tell QCompleter that everything we have
96        # filtered matches what the user typed; if not, nothing matches
97        if role == self.HistoryCompletionRole and index.isValid():
98            if self.isValid():
99                return "t"
100            else:
101                return "f"
102
103        if role == Qt.ItemDataRole.DisplayRole:
104            if index.column() == 0:
105                role = HistoryModel.UrlStringRole
106            else:
107                role = HistoryModel.TitleRole
108
109        return QSortFilterProxyModel.data(self, index, role)
110
111    def searchString(self):
112        """
113        Public method to get the current search string.
114
115        @return current search string (string)
116        """
117        return self.__searchString
118
119    def setSearchString(self, sstring):
120        """
121        Public method to set the current search string.
122
123        @param sstring new search string (string)
124        """
125        if sstring != self.__searchString:
126            self.__searchString = sstring
127            self.__searchMatcher = re.compile(
128                re.escape(self.__searchString), re.IGNORECASE)
129            self.__wordMatcher = re.compile(
130                r"\b" + re.escape(self.__searchString), re.IGNORECASE)
131            self.invalidateFilter()
132
133    def isValid(self):
134        """
135        Public method to check the model for validity.
136
137        @return flag indicating a valid status (boolean)
138        """
139        return self.__isValid
140
141    def setValid(self, valid):
142        """
143        Public method to set the model's validity.
144
145        @param valid flag indicating the new valid status (boolean)
146        """
147        if valid == self.__isValid:
148            return
149
150        self.__isValid = valid
151
152        # tell the history completer that the model has changed
153        self.dataChanged.emit(self.index(0, 0), self.index(0,
154                              self.rowCount() - 1))
155
156    def filterAcceptsRow(self, sourceRow, sourceParent):
157        """
158        Public method to determine, if the row is acceptable.
159
160        @param sourceRow row number in the source model (integer)
161        @param sourceParent index of the source item (QModelIndex)
162        @return flag indicating acceptance (boolean)
163        """
164        if self.__searchMatcher is not None:
165            # Do a case-insensitive substring match against both the url and
166            # title. It's already ensured, that the user doesn't accidentally
167            # use regexp metacharacters (s. setSearchString()).
168            idx = self.sourceModel().index(sourceRow, 0, sourceParent)
169
170            url = self.sourceModel().data(idx, HistoryModel.UrlStringRole)
171            if self.__searchMatcher.search(url) is not None:
172                return True
173
174            title = self.sourceModel().data(idx, HistoryModel.TitleRole)
175            if self.__searchMatcher.search(title) is not None:
176                return True
177
178        return False
179
180    def lessThan(self, left, right):
181        """
182        Public method used to sort the displayed items.
183
184        It implements a special sorting function based on the history entry's
185        frequency giving a bonus to hits that match on a word boundary so that
186        e.g. "dot.python-projects.org" is a better result for typing "dot" than
187        "slashdot.org". However, it only looks for the string in the host name,
188        not the entire URL, since while it makes sense to e.g. give
189        "www.phoronix.com" a bonus for "ph", it does NOT make sense to give
190        "www.yadda.com/foo.php" the bonus.
191
192        @param left index of left item
193        @type QModelIndex
194        @param right index of right item
195        @type QModelIndex
196        @return true, if left is less than right
197        @rtype bool
198        """
199        frequency_L = self.sourceModel().data(
200            left, HistoryFilterModel.FrequencyRole)
201        url_L = self.sourceModel().data(left, HistoryModel.UrlRole).host()
202        title_L = self.sourceModel().data(left, HistoryModel.TitleRole)
203
204        if (
205            self.__wordMatcher is not None and
206            (bool(self.__wordMatcher.search(url_L)) or
207             bool(self.__wordMatcher.search(title_L)))
208        ):
209            frequency_L *= 2
210
211        frequency_R = self.sourceModel().data(
212            right, HistoryFilterModel.FrequencyRole)
213        url_R = self.sourceModel().data(right, HistoryModel.UrlRole).host()
214        title_R = self.sourceModel().data(right, HistoryModel.TitleRole)
215
216        if (
217            self.__wordMatcher is not None and
218            (bool(self.__wordMatcher.search(url_R)) or
219             bool(self.__wordMatcher.search(title_R)))
220        ):
221            frequency_R *= 2
222
223        # Sort results in descending frequency-derived score.
224        return frequency_R < frequency_L
225
226
227class HistoryCompleter(QCompleter):
228    """
229    Class implementing a completer for the browser history.
230    """
231    def __init__(self, model, parent=None):
232        """
233        Constructor
234
235        @param model reference to the model (QAbstractItemModel)
236        @param parent reference to the parent object (QObject)
237        """
238        super().__init__(model, parent)
239
240        self.setPopup(HistoryCompletionView())
241
242        # Completion should be against the faked role.
243        self.setCompletionRole(HistoryCompletionModel.HistoryCompletionRole)
244
245        # Since the completion role is faked, advantage of the sorted-model
246        # optimizations in QCompleter can be taken.
247        self.setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive)
248        self.setModelSorting(
249            QCompleter.ModelSorting.CaseSensitivelySortedModel)
250
251        self.__searchString = ""
252        self.__filterTimer = QTimer(self)
253        self.__filterTimer.setSingleShot(True)
254        self.__filterTimer.timeout.connect(self.__updateFilter)
255
256    def pathFromIndex(self, idx):
257        """
258        Public method to get a path for a given index.
259
260        @param idx reference to the index (QModelIndex)
261        @return the actual URL from the history (string)
262        """
263        return self.model().data(idx, HistoryModel.UrlStringRole)
264
265    def splitPath(self, path):
266        """
267        Public method to split the given path into strings, that are used to
268        match at each level in the model.
269
270        @param path path to be split (string)
271        @return list of path elements (list of strings)
272        """
273        if path == self.__searchString:
274            return ["t"]
275
276        # Queue an update to the search string. Wait a bit, so that if the user
277        # is quickly typing, the completer doesn't try to complete until they
278        # pause.
279        if self.__filterTimer.isActive():
280            self.__filterTimer.stop()
281        self.__filterTimer.start(150)
282
283        # If the previous search results are not a superset of the current
284        # search results, tell the model that it is not valid yet.
285        if not path.startswith(self.__searchString):
286            self.model().setValid(False)
287
288        self.__searchString = path
289
290        # The actual filtering is done by the HistoryCompletionModel. Just
291        # return a short dummy here so that QCompleter thinks everything
292        # matched.
293        return ["t"]
294
295    def __updateFilter(self):
296        """
297        Private slot to update the search string.
298        """
299        completionModel = self.model()
300
301        # Tell the HistoryCompletionModel about the new search string.
302        completionModel.setSearchString(self.__searchString)
303
304        # Sort the model.
305        completionModel.sort(0)
306
307        # Mark it valid.
308        completionModel.setValid(True)
309
310        # Now update the QCompleter widget, but only if the user is still
311        # typing a URL.
312        if self.widget() is not None and self.widget().hasFocus():
313            self.complete()
314