1# -*- coding: utf-8 -*- 2 3# Copyright (c) 2004 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> 4# 5 6""" 7Module implementing a quick search for files. 8 9This is basically the FindFileNameDialog modified to support faster 10interactions. 11""" 12 13import os 14 15from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QEvent 16from PyQt5.QtWidgets import ( 17 QWidget, QHeaderView, QApplication, QDialogButtonBox, QTreeWidgetItem 18) 19 20from .Ui_QuickFindFile import Ui_QuickFindFile 21 22 23class QuickFindFileDialog(QWidget, Ui_QuickFindFile): 24 """ 25 Class implementing the Quick Find File by Name Dialog. 26 27 This dialog provides a slightly more streamlined behaviour 28 than the standard FindFileNameDialog in that it tries to 29 match any name in the project against (fragmentary) bits of 30 file names. 31 32 @signal sourceFile(str) emitted to open a file in the editor 33 @signal designerFile(str) emitted to open a Qt-Designer file 34 @signal linguistFile(str) emitted to open a Qt translation file 35 """ 36 sourceFile = pyqtSignal(str) 37 designerFile = pyqtSignal(str) 38 linguistFile = pyqtSignal(str) 39 40 def __init__(self, project, parent=None): 41 """ 42 Constructor 43 44 @param project reference to the project object 45 @type Project 46 @param parent parent widget of this dialog 47 @type QWidget 48 """ 49 super().__init__(parent) 50 self.setupUi(self) 51 52 self.fileList.headerItem().setText(self.fileList.columnCount(), "") 53 self.fileNameEdit.returnPressed.connect( 54 self.on_fileNameEdit_returnPressed) 55 self.installEventFilter(self) 56 57 self.stopButton = self.buttonBox.addButton( 58 self.tr("Stop"), QDialogButtonBox.ButtonRole.ActionRole) 59 self.project = project 60 61 def eventFilter(self, source, event): 62 """ 63 Public method to handle event for another object. 64 65 @param source object to handle events for 66 @type QObject 67 @param event event to handle 68 @type QEvent 69 @return flag indicating that the event was handled 70 @rtype bool 71 """ 72 if event.type() == QEvent.Type.KeyPress: 73 74 # Anywhere in the dialog, make hitting escape cancel it 75 if event.key() == Qt.Key.Key_Escape: 76 self.close() 77 78 # Anywhere in the dialog, make hitting up/down choose next item 79 # Note: This doesn't really do anything, as other than the text 80 # input there's nothing that doesn't handle up/down already. 81 elif ( 82 event.key() == Qt.Key.Key_Up or 83 event.key() == Qt.Key.Key_Down 84 ): 85 current = self.fileList.currentItem() 86 index = self.fileList.indexOfTopLevelItem(current) 87 if event.key() == Qt.Key.Key_Up: 88 if index != 0: 89 self.fileList.setCurrentItem( 90 self.fileList.topLevelItem(index - 1)) 91 else: 92 if index < (self.fileList.topLevelItemCount() - 1): 93 self.fileList.setCurrentItem( 94 self.fileList.topLevelItem(index + 1)) 95 return QWidget.eventFilter(self, source, event) 96 97 def on_buttonBox_clicked(self, button): 98 """ 99 Private slot called by a button of the button box clicked. 100 101 @param button button that was clicked (QAbstractButton) 102 """ 103 if button == self.stopButton: 104 self.shouldStop = True 105 elif ( 106 button == 107 self.buttonBox.button(QDialogButtonBox.StandardButton.Open) 108 ): 109 self.__openFile() 110 111 def __openFile(self, itm=None): 112 """ 113 Private slot to open a file. 114 115 It emits the signal sourceFile or designerFile depending on the 116 file extension. 117 118 @param itm item to be opened 119 @type QTreeWidgetItem 120 @return flag indicating a file was opened 121 @rtype bool 122 """ 123 if itm is None: 124 itm = self.fileList.currentItem() 125 if itm is not None: 126 filePath = itm.text(1) 127 fileName = itm.text(0) 128 fullPath = os.path.join(self.project.ppath, filePath, fileName) 129 130 if fullPath.endswith('.ui'): 131 self.designerFile.emit(fullPath) 132 elif fullPath.endswith(('.ts', '.qm')): 133 self.linguistFile.emit(fullPath) 134 else: 135 self.sourceFile.emit(fullPath) 136 return True 137 138 return False 139 140 def __generateLocations(self): 141 """ 142 Private method to generate a set of locations that can be searched. 143 144 @yield set of files in our project 145 @ytype str 146 """ 147 for typ in ["SOURCES", "FORMS", "INTERFACES", "PROTOCOLS", "RESOURCES", 148 "TRANSLATIONS", "OTHERS"]: 149 entries = self.project.pdata.get(typ) 150 yield from entries[:] 151 152 def __sortedMatches(self, items, searchTerm): 153 """ 154 Private method to find the subset of items which match a search term. 155 156 @param items list of items to be scanned for the search term 157 @type list of str 158 @param searchTerm search term to be searched for 159 @type str 160 @return sorted subset of items which match searchTerm in 161 relevance order (i.e. the most likely match first) 162 @rtype list of tuple of bool, int and str 163 """ 164 fragments = searchTerm.split() 165 166 possible = [ 167 # matches, in_order, file name 168 ] 169 170 for entry in items: 171 count = 0 172 match_order = [] 173 for fragment in fragments: 174 index = entry.find(fragment) 175 if index == -1: 176 # try case-insensitive match 177 index = entry.lower().find(fragment.lower()) 178 if index != -1: 179 count += 1 180 match_order.append(index) 181 if count: 182 record = (count, match_order == sorted(match_order), entry) 183 if possible and count < possible[0][0]: 184 # ignore... 185 continue 186 elif possible and count > possible[0][0]: 187 # better than all previous matches, discard them and 188 # keep this 189 del possible[:] 190 possible.append(record) 191 192 ordered = [] 193 for (_, in_order, name) in possible: 194 try: 195 age = os.stat(os.path.join(self.project.ppath, name)).st_mtime 196 except OSError: 197 # skipping, because it doesn't appear to exist... 198 continue 199 ordered.append(( 200 in_order, # we want closer match first 201 - age, # then approximately "most recently edited" 202 name 203 )) 204 ordered.sort() 205 return ordered 206 207 def __searchFile(self): 208 """ 209 Private slot to handle the search. 210 """ 211 fileName = self.fileNameEdit.text().strip() 212 if not fileName: 213 self.fileList.clear() 214 return 215 216 ordered = self.__sortedMatches(self.__generateLocations(), fileName) 217 218 found = False 219 self.fileList.clear() 220 locations = {} 221 222 for _in_order, _age, name in ordered: 223 found = True 224 QTreeWidgetItem(self.fileList, [os.path.basename(name), 225 os.path.dirname(name)]) 226 QApplication.processEvents() 227 228 del locations 229 self.stopButton.setEnabled(False) 230 self.fileList.header().resizeSections( 231 QHeaderView.ResizeMode.ResizeToContents) 232 self.fileList.header().setStretchLastSection(True) 233 234 if found: 235 self.fileList.setCurrentItem(self.fileList.topLevelItem(0)) 236 237 def on_fileNameEdit_textChanged(self, text): 238 """ 239 Private slot to handle the textChanged signal of the file name edit. 240 241 @param text (ignored) 242 """ 243 self.__searchFile() 244 245 def on_fileNameEdit_returnPressed(self): 246 """ 247 Private slot to handle enter being pressed on the file name edit box. 248 """ 249 if self.__openFile(): 250 self.close() 251 252 def on_fileList_itemActivated(self, itm, column): 253 """ 254 Private slot to handle the double click on a file item. 255 256 It emits the signal sourceFile or designerFile depending on the 257 file extension. 258 259 @param itm the double clicked listview item (QTreeWidgetItem) 260 @param column column that was double clicked (integer) (ignored) 261 """ 262 self.__openFile(itm) 263 264 @pyqtSlot(QTreeWidgetItem, QTreeWidgetItem) 265 def on_fileList_currentItemChanged(self, current, previous): 266 """ 267 Private slot handling a change of the current item. 268 269 @param current current item (QTreeWidgetItem) 270 @param previous prevoius current item (QTreeWidgetItem) 271 """ 272 self.buttonBox.button(QDialogButtonBox.StandardButton.Open).setEnabled( 273 current is not None) 274 275 def show(self): 276 """ 277 Public method to enable/disable the project checkbox. 278 """ 279 self.fileNameEdit.selectAll() 280 self.fileNameEdit.setFocus() 281 282 super().show() 283