Repository: spyder-ide/spyder

Issue:
More Find in Files improvements
![seleccion_002](https://cloud.githubusercontent.com/assets/365293/26038289/d1832ac6-38ca-11e7-8655-e1f1ae0d5417.png)

I used the new Find in Files after PR #4056 was merged, and these are my suggestions to improve your previous work @andfoy (which, by the way, was great!):

1. Results must be shown in ascending order, i.e. first line 1, then line 3, etc. Right now results are shown in descending order (please take a look at the screenshot).
2. We need to remove information about columns. For example, in the screenshot you can see two results about line 1 (one for column 5, the other for column 0). We simply need to report one entry per line (as it's done in `ack` or `ag`).
3. The name of the file must be in bold. Example (from the screenshot):

    **spam.txt** - /home/carlos/Projects/...
    **->** ...

4. The line number must in bold too, the text inside the line must use a monospaced font, and the results detected inside it must be in bold too. Example (from the screenshot):

    **3**:` lore ipsum sit dolor`**`spam`**

5. After a file is completely searched, its results need to be expanded automatically. This avoids the need of pressing the expand button every time you search for something (which becomes tiresome :-).

6. Icons for file types need to be changed to use `file-o` from FontAwesome:

    https://github.com/spyder-ide/spyder/blob/master/spyder/utils/icon_manager.py#L175


Discussion:
With respect to the column information, I decided to display all the results independently, because sometimes a search match can be found on files that contain a single text line, such as minified Javascript sources. On such cases, displaying the match elements may be more difficult if the result is shown on a single line
>We need to remove information about columns. For example, in the screenshot you can see two results about line 1 (one for column 5, the other for column 0). We simply need to report one entry per line (as it's done in ack or ag).

Yeah, I agree with @andfoy, I am not convinced with removing the column info.

Resolving patch:
diff --git a/spyder/utils/external/binaryornot/check.py b/spyder/utils/external/binaryornot/check.py
--- a/spyder/utils/external/binaryornot/check.py
+++ b/spyder/utils/external/binaryornot/check.py
@@ -23,7 +23,7 @@ def is_binary(filename):
     logger.debug('is_binary: %(filename)r', locals())
 
     # Check if the file extension is in a list of known binary types
-    binary_extensions = ['pyc', 'iso']
+    binary_extensions = ['pyc', 'iso', 'zip', 'pdf']
     for ext in binary_extensions:
         if filename.endswith(ext):
             return True
diff --git a/spyder/widgets/findinfiles.py b/spyder/widgets/findinfiles.py
--- a/spyder/widgets/findinfiles.py
+++ b/spyder/widgets/findinfiles.py
@@ -23,10 +23,12 @@
 
 # Third party imports
 from qtpy.compat import getexistingdirectory
-from qtpy.QtCore import QMutex, QMutexLocker, Qt, QThread, Signal, Slot
+from qtpy.QtGui import QAbstractTextDocumentLayout, QTextDocument
+from qtpy.QtCore import QMutex, QMutexLocker, Qt, QThread, Signal, Slot, QSize
 from qtpy.QtWidgets import (QHBoxLayout, QLabel, QRadioButton, QSizePolicy,
                             QTreeWidgetItem, QVBoxLayout, QWidget,
-                            QHeaderView)
+                            QStyledItemDelegate, QStyleOptionViewItem,
+                            QApplication, QStyle)
 
 # Local imports
 from spyder.config.base import _
@@ -37,6 +39,7 @@
 from spyder.widgets.comboboxes import PatternComboBox
 from spyder.widgets.onecolumntree import OneColumnTree
 
+from spyder.config.gui import get_font
 from spyder.widgets.waitingspinner import QWaitingSpinner
 
 
@@ -45,7 +48,7 @@ class SearchThread(QThread):
     sig_finished = Signal(bool)
     sig_current_file = Signal(str)
     sig_current_folder = Signal(str)
-    sig_file_match = Signal(dict, int)
+    sig_file_match = Signal(tuple, int)
     sig_out_print = Signal(object)
 
     def __init__(self, parent):
@@ -125,20 +128,48 @@ def find_files_in_path(self, path):
 
     def truncate_result(self, line, start, end):
         ellipsis = '...'
-        before_offset = 4
-        if len(line) > 80:
-            if start <= before_offset:
-                before_offset = 0
-                ellipsis = ''
-            trunc_line = ellipsis + line[start - before_offset:end + 4]
-        else:
-            trunc_line = line
+
+        left, match, right = line[:start], line[start:end], line[end:]
+        max_line_length = 40
+        offset = (len(line) - len(match)) // 2
+
+        left = left.split(' ')
+        num_left_words = len(left)
+
+        if num_left_words == 1:
+            left = left[0]
+            if len(left) > max_line_length:
+                left = ellipsis + left[-offset:]
+            left = [left]
+
+        right = right.split(' ')
+        num_right_words = len(right)
+
+        if num_right_words == 1:
+            right = right[0]
+            if len(right) > max_line_length:
+                right = right[:offset] + ellipsis
+            right = [right]
+
+        left = left[-3:]
+        right = right[:3]
+
+        if len(left) < num_left_words:
+            left = [ellipsis] + left
+
+        if len(right) < num_right_words:
+            right = right + [ellipsis]
+
+        left = ' '.join(left)
+        right = ' '.join(right)
+
+        trunc_line = '{0}<b>{1}</b>{2}'.format(left, match, right)
         return trunc_line
 
     def find_string_in_file(self, fname):
         self.error_flag = False
         self.sig_current_file.emit(fname)
-        results = {}
+        # results = {}
         try:
             for lineno, line in enumerate(open(fname, 'rb')):
                 for text, enc in self.texts:
@@ -156,28 +187,30 @@ def find_string_in_file(self, fname):
                     line_dec = line
                 if self.text_re:
                     for match in re.finditer(text, line):
-                        res = results.get(osp.abspath(fname), [])
                         displ_line = self.truncate_result(line_dec,
                                                           match.start(),
                                                           match.end())
-                        res.append((lineno + 1, match.start(), displ_line))
-                        results[osp.abspath(fname)] = res
                         self.total_matches += 1
+                        self.sig_file_match.emit((osp.abspath(fname),
+                                                  lineno + 1,
+                                                  match.start(), displ_line),
+                                                 self.total_matches)
                 else:
+                    found = line.find(text)
                     while found > -1:
-                        res = results.get(osp.abspath(fname), [])
+                        self.total_matches += 1
                         displ_line = self.truncate_result(line_dec,
                                                           found,
                                                           found + len(text))
-                        res.append((lineno + 1, found, displ_line))
-                        results[osp.abspath(fname)] = res
+
+                        self.sig_file_match.emit((osp.abspath(fname),
+                                                  lineno + 1,
+                                                  found, displ_line),
+                                                 self.total_matches)
                         for text, enc in self.texts:
                             found = line.find(text, found + 1)
                             if found > -1:
                                 break
-                        self.total_matches += 1
-            if len(results) > 0:
-                self.sig_file_match.emit(results, self.total_matches)
         except IOError as xxx_todo_changeme:
             (_errno, _strerror) = xxx_todo_changeme.args
             self.error_flag = _("permission denied errors were encountered")
@@ -230,13 +263,13 @@ def __init__(self, parent, search_text, search_text_regexp, search_path,
         self.more_options.setChecked(more_options)
 
         self.ok_button = create_toolbutton(self, text=_("Search"),
-                                           icon=ima.icon('DialogApplyButton'),
+                                           icon=ima.icon('find'),
                                            triggered=lambda: self.find.emit(),
                                            tip=_("Start search"),
                                            text_beside_icon=True)
         self.ok_button.clicked.connect(self.update_combos)
         self.stop_button = create_toolbutton(self, text=_("Stop"),
-                                             icon=ima.icon('stop'),
+                                             icon=ima.icon('editclear'),
                                              triggered=lambda:
                                              self.stop.emit(),
                                              tip=_("Stop search"),
@@ -266,28 +299,29 @@ def __init__(self, parent, search_text, search_text_regexp, search_path,
 
         # Layout 3
         hlayout3 = QHBoxLayout()
-        search_label = QLabel(_("Search on: "))
 
-        self.global_path_search = QRadioButton(_("Current Path"), self)
+        self.global_path_search = QRadioButton(_("Path"), self)
         self.global_path_search.setChecked(True)
         self.global_path_search.setToolTip(_("Search in all files and "
                                              "directories present on the"
                                              "current Spyder path"))
 
-        self.project_search = QRadioButton(_("Current Project"), self)
+        self.project_search = QRadioButton(_("Project"), self)
         self.project_search.setToolTip(_("Search in all files and "
                                          "directories present on the"
                                          "current project path (If opened)"))
 
         self.project_search.setEnabled(False)
 
-        self.file_search = QRadioButton(_("Current File"), self)
+        self.file_search = QRadioButton(_("File"), self)
         self.file_search.setToolTip(_("Search in current opened file"))
 
-        for wid in [search_label, self.global_path_search,
+        for wid in [self.global_path_search,
                     self.project_search, self.file_search]:
             hlayout3.addWidget(wid)
 
+        hlayout3.addStretch(1)
+
         self.search_text.valid.connect(lambda valid: self.find.emit())
         self.exclude_pattern.valid.connect(lambda valid: self.find.emit())
 
@@ -429,9 +463,92 @@ def keyPressEvent(self, event):
             QWidget.keyPressEvent(self, event)
 
 
-class ResultsHeader(QHeaderView):
+class LineMatchItem(QTreeWidgetItem):
+    def __init__(self, parent, lineno, colno, match):
+        self.lineno = lineno
+        self.colno = colno
+        self.match = match
+        QTreeWidgetItem.__init__(self, parent, [self.__repr__()],
+                                 QTreeWidgetItem.Type)
+
+    def __repr__(self):
+        match = to_text_string(self.match).rstrip()
+        font = get_font()
+        _str = to_text_string("<b>{1}</b> ({2}): "
+                              "<span style='font-family:{0};"
+                              "font-size:75%;'>{3}</span>")
+        return _str.format(font.family(), self.lineno, self.colno, match)
+
+    def __unicode__(self):
+        return self.__repr__()
+
+    def __str__(self):
+        return self.__repr__()
+
+    def __lt__(self, x):
+        return self.lineno < x.lineno
+
+    def __ge__(self, x):
+        return self.lineno >= x.lineno
+
+
+class FileMatchItem(QTreeWidgetItem):
+    def __init__(self, parent, filename):
+
+        self.filename = osp.basename(filename)
+
+        title = ('<b>{0}</b><br>'
+                 '<small><em>{1}</em>'
+                 '</small>'.format(osp.basename(filename),
+                                   osp.dirname(filename)))
+        QTreeWidgetItem.__init__(self, parent, [title], QTreeWidgetItem.Type)
+
+        self.setToolTip(0, filename)
+
+    def __lt__(self, x):
+        return self.filename < x.filename
+
+    def __ge__(self, x):
+        return self.filename >= x.filename
+
+
+class ItemDelegate(QStyledItemDelegate):
     def __init__(self, parent):
-        QHeaderView.__init__(self, parent)
+        QStyledItemDelegate.__init__(self, parent)
+
+    def paint(self, painter, option, index):
+        options = QStyleOptionViewItem(option)
+        self.initStyleOption(options, index)
+
+        style = (QApplication.style() if options.widget is None
+                 else options.widget.style())
+
+        doc = QTextDocument()
+        doc.setDocumentMargin(0)
+        doc.setHtml(options.text)
+
+        options.text = ""
+        style.drawControl(QStyle.CE_ItemViewItem, options, painter)
+
+        ctx = QAbstractTextDocumentLayout.PaintContext()
+
+        textRect = style.subElementRect(QStyle.SE_ItemViewItemText, options)
+        painter.save()
+
+        painter.translate(textRect.topLeft())
+        painter.setClipRect(textRect.translated(-textRect.topLeft()))
+        doc.documentLayout().draw(painter, ctx)
+        painter.restore()
+
+    def sizeHint(self, option, index):
+        options = QStyleOptionViewItem(option)
+        self.initStyleOption(options, index)
+
+        doc = QTextDocument()
+        doc.setHtml(options.text)
+        doc.setTextWidth(options.rect.width())
+
+        return QSize(doc.idealWidth(), doc.size().height())
 
 
 class ResultsBrowser(OneColumnTree):
@@ -443,11 +560,14 @@ def __init__(self, parent):
         self.error_flag = None
         self.completed = None
         self.data = None
+        self.files = None
         self.set_title('')
         self.root_items = None
+        self.sortByColumn(0, Qt.AscendingOrder)
         self.setSortingEnabled(True)
         self.header().setSectionsClickable(True)
-        # self.setHeaderLabel(_("Filename"))
+        self.setItemDelegate(ItemDelegate(self))
+        self.setUniformRowHeights(False)
 
     def activated(self, item):
         """Double-click event"""
@@ -464,15 +584,22 @@ def clear_title(self, search_text):
         self.clear()
         self.num_files = 0
         self.data = {}
+        self.files = {}
         self.search_text = search_text
         title = "'%s' - " % search_text
         text = _('String not found')
         self.set_title(title + text)
 
-    @Slot(dict, int)
+    @Slot(tuple, int)
     def append_result(self, results, num_matches):
         """Real-time update of search results"""
-        self.num_files += 1
+        filename, lineno, colno, line = results
+
+        if filename not in self.files:
+            item = FileMatchItem(self, filename)
+            self.files[filename] = item
+            self.num_files += 1
+
         search_text = self.search_text
         title = "'%s' - " % search_text
         nb_files = self.num_files
@@ -486,21 +613,11 @@ def append_result(self, results, num_matches):
             text = "%d %s %d %s" % (num_matches, text_matches,
                                     nb_files, text_files)
         self.set_title(title + text)
-        for filename in sorted(results.keys()):
-            file_item = QTreeWidgetItem(self, [osp.basename(filename) +
-                                        u" - " + osp.dirname(filename)],
-                                        QTreeWidgetItem.Type)
-            file_item.setToolTip(0, filename)
-            file_item.setIcon(0, get_filetype_icon(filename))
-            for lineno, colno, line in results[filename]:
-                item = QTreeWidgetItem(file_item,
-                                       [u"{0} ({1}): {2}".format(lineno,
-                                                                 colno,
-                                                                 line.rstrip()
-                                                                 )],
-                                       QTreeWidgetItem.Type)
-                item.setIcon(0, ima.icon('arrow'))
-                self.data[id(item)] = (filename, lineno, colno)
+
+        file_item = self.files[filename]
+        item = LineMatchItem(file_item, lineno, colno, line)
+
+        self.data[id(item)] = (filename, lineno, colno)
 
 
 class FileProgressBar(QWidget):
@@ -573,21 +690,9 @@ def __init__(self, parent,
 
         self.result_browser = ResultsBrowser(self)
 
-        collapse_btn = create_toolbutton(self)
-        collapse_btn.setDefaultAction(self.result_browser.collapse_all_action)
-        expand_btn = create_toolbutton(self)
-        expand_btn.setDefaultAction(self.result_browser.expand_all_action)
-        restore_btn = create_toolbutton(self)
-        restore_btn.setDefaultAction(self.result_browser.restore_action)
-
-        btn_layout = QVBoxLayout()
-        btn_layout.setAlignment(Qt.AlignTop)
-        for widget in [collapse_btn, expand_btn, restore_btn]:
-            btn_layout.addWidget(widget)
-
         hlayout = QHBoxLayout()
         hlayout.addWidget(self.result_browser)
-        hlayout.addLayout(btn_layout)
+        # hlayout.addLayout(btn_layout)
 
         layout = QVBoxLayout()
         left, _x, right, bottom = layout.getContentsMargins()
@@ -653,6 +758,7 @@ def search_complete(self, completed):
         self.find_options.ok_button.setEnabled(True)
         self.find_options.stop_button.setEnabled(False)
         self.status_bar.hide()
+        self.result_browser.expandAll()
         if self.search_thread is None:
             return
         self.sig_finished.emit()


Test patch: