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