|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2004 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a quick search for files. |
|
8 |
|
9 This is basically the FindFileNameDialog modified to support faster |
|
10 interactions. |
|
11 """ |
|
12 |
|
13 import os |
|
14 |
|
15 from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QEvent |
|
16 from PyQt5.QtWidgets import ( |
|
17 QWidget, QHeaderView, QApplication, QDialogButtonBox, QTreeWidgetItem |
|
18 ) |
|
19 |
|
20 from .Ui_QuickFindFile import Ui_QuickFindFile |
|
21 |
|
22 |
|
23 class 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() |