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