1# -*- coding: utf-8 -*-
2#
3# Copyright © Spyder Project Contributors
4# Licensed under the terms of the MIT License
5# (see spyder/__init__.py for details)
6
7"""Find in files widget"""
8
9# pylint: disable=C0103
10# pylint: disable=R0903
11# pylint: disable=R0911
12# pylint: disable=R0201
13
14# Standard library imports
15from __future__ import with_statement, print_function
16import fnmatch
17import os
18import os.path as osp
19import re
20import sys
21import math
22import traceback
23
24# Third party imports
25from qtpy.compat import getexistingdirectory
26from qtpy.QtGui import QAbstractTextDocumentLayout, QTextDocument
27from qtpy.QtCore import (QEvent, QMutex, QMutexLocker, QSize, Qt, QThread,
28                         Signal, Slot)
29from qtpy.QtWidgets import (QApplication, QComboBox, QHBoxLayout, QLabel,
30                            QMessageBox, QSizePolicy, QStyle,
31                            QStyledItemDelegate, QStyleOptionViewItem,
32                            QTreeWidgetItem, QVBoxLayout, QWidget)
33
34# Local imports
35from spyder.config.base import _
36from spyder.py3compat import to_text_string
37from spyder.utils import icon_manager as ima
38from spyder.utils.encoding import is_text_file, to_unicode_from_fs
39from spyder.utils.misc import getcwd_or_home
40from spyder.widgets.comboboxes import PatternComboBox
41from spyder.widgets.onecolumntree import OneColumnTree
42from spyder.utils.qthelpers import create_toolbutton, get_icon
43
44from spyder.config.gui import get_font
45from spyder.widgets.waitingspinner import QWaitingSpinner
46
47
48ON = 'on'
49OFF = 'off'
50
51CWD = 0
52PROJECT = 1
53FILE_PATH = 2
54SELECT_OTHER = 4
55CLEAR_LIST = 5
56EXTERNAL_PATHS = 7
57
58MAX_PATH_LENGTH = 60
59MAX_PATH_HISTORY = 15
60
61
62def truncate_path(text):
63    ellipsis = '...'
64    part_len = (MAX_PATH_LENGTH - len(ellipsis)) / 2.0
65    left_text = text[:int(math.ceil(part_len))]
66    right_text = text[-int(math.floor(part_len)):]
67    return left_text + ellipsis + right_text
68
69
70class SearchThread(QThread):
71    """Find in files search thread"""
72    sig_finished = Signal(bool)
73    sig_current_file = Signal(str)
74    sig_current_folder = Signal(str)
75    sig_file_match = Signal(tuple, int)
76    sig_out_print = Signal(object)
77
78    def __init__(self, parent):
79        QThread.__init__(self, parent)
80        self.mutex = QMutex()
81        self.stopped = None
82        self.results = None
83        self.pathlist = None
84        self.total_matches = None
85        self.error_flag = None
86        self.rootpath = None
87        self.python_path = None
88        self.hg_manifest = None
89        self.exclude = None
90        self.texts = None
91        self.text_re = None
92        self.completed = None
93        self.case_sensitive = True
94        self.get_pythonpath_callback = None
95        self.results = {}
96        self.total_matches = 0
97        self.is_file = False
98
99    def initialize(self, path, is_file, exclude,
100                   texts, text_re, case_sensitive):
101        self.rootpath = path
102        self.python_path = False
103        self.hg_manifest = False
104        self.exclude = re.compile(exclude)
105        self.texts = texts
106        self.text_re = text_re
107        self.is_file = is_file
108        self.stopped = False
109        self.completed = False
110        self.case_sensitive = case_sensitive
111
112    def run(self):
113        try:
114            self.filenames = []
115            if self.is_file:
116                self.find_string_in_file(self.rootpath)
117            else:
118                self.find_files_in_path(self.rootpath)
119        except Exception:
120            # Important note: we have to handle unexpected exceptions by
121            # ourselves because they won't be catched by the main thread
122            # (known QThread limitation/bug)
123            traceback.print_exc()
124            self.error_flag = _("Unexpected error: see internal console")
125        self.stop()
126        self.sig_finished.emit(self.completed)
127
128    def stop(self):
129        with QMutexLocker(self.mutex):
130            self.stopped = True
131
132    def find_files_in_path(self, path):
133        if self.pathlist is None:
134            self.pathlist = []
135        self.pathlist.append(path)
136        for path, dirs, files in os.walk(path):
137            with QMutexLocker(self.mutex):
138                if self.stopped:
139                    return False
140            try:
141                for d in dirs[:]:
142                    dirname = os.path.join(path, d)
143                    if re.search(self.exclude, dirname + os.sep):
144                        dirs.remove(d)
145                for f in files:
146                    filename = os.path.join(path, f)
147                    if re.search(self.exclude, filename):
148                        continue
149                    if is_text_file(filename):
150                        self.find_string_in_file(filename)
151            except re.error:
152                self.error_flag = _("invalid regular expression")
153                return False
154        return True
155
156    def find_string_in_file(self, fname):
157        self.error_flag = False
158        self.sig_current_file.emit(fname)
159        try:
160            for lineno, line in enumerate(open(fname, 'rb')):
161                for text, enc in self.texts:
162                    line_search = line
163                    if not self.case_sensitive:
164                        line_search = line_search.lower()
165                    if self.text_re:
166                        found = re.search(text, line_search)
167                        if found is not None:
168                            break
169                    else:
170                        found = line_search.find(text)
171                        if found > -1:
172                            break
173                try:
174                    line_dec = line.decode(enc)
175                except UnicodeDecodeError:
176                    line_dec = line
177                if not self.case_sensitive:
178                    line = line.lower()
179                if self.text_re:
180                    for match in re.finditer(text, line):
181                        self.total_matches += 1
182                        self.sig_file_match.emit((osp.abspath(fname),
183                                                  lineno + 1,
184                                                  match.start(),
185                                                  match.end(), line_dec),
186                                                 self.total_matches)
187                else:
188                    found = line.find(text)
189                    while found > -1:
190                        self.total_matches += 1
191                        self.sig_file_match.emit((osp.abspath(fname),
192                                                  lineno + 1,
193                                                  found,
194                                                  found + len(text), line_dec),
195                                                 self.total_matches)
196                        for text, enc in self.texts:
197                            found = line.find(text, found + 1)
198                            if found > -1:
199                                break
200        except IOError as xxx_todo_changeme:
201            (_errno, _strerror) = xxx_todo_changeme.args
202            self.error_flag = _("permission denied errors were encountered")
203        self.completed = True
204
205    def get_results(self):
206        return self.results, self.pathlist, self.total_matches, self.error_flag
207
208
209class SearchInComboBox(QComboBox):
210    """
211    Non editable combo box handling the path locations of the FindOptions
212    widget.
213    """
214    def __init__(self, external_path_history=[], parent=None):
215        super(SearchInComboBox, self).__init__(parent)
216        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
217        self.setToolTip(_('Search directory'))
218        self.setEditable(False)
219
220        self.path = ''
221        self.project_path = None
222        self.file_path = None
223        self.external_path = None
224
225        self.addItem(_("Current working directory"))
226        ttip = ("Search in all files and directories present on the current"
227                " Spyder path")
228        self.setItemData(0, ttip, Qt.ToolTipRole)
229
230        self.addItem(_("Project"))
231        ttip = _("Search in all files and directories present on the"
232                 " current project path (if opened)")
233        self.setItemData(1, ttip, Qt.ToolTipRole)
234        self.model().item(1, 0).setEnabled(False)
235
236        self.addItem(_("File").replace('&', ''))
237        ttip = _("Search in current opened file")
238        self.setItemData(2, ttip, Qt.ToolTipRole)
239
240        self.insertSeparator(3)
241
242        self.addItem(_("Select other directory"))
243        ttip = _("Search in other folder present on the file system")
244        self.setItemData(4, ttip, Qt.ToolTipRole)
245
246        self.addItem(_("Clear this list"))
247        ttip = _("Clear the list of other directories")
248        self.setItemData(5, ttip, Qt.ToolTipRole)
249
250        self.insertSeparator(6)
251
252        for path in external_path_history:
253            self.add_external_path(path)
254
255        self.currentIndexChanged.connect(self.path_selection_changed)
256        self.view().installEventFilter(self)
257
258    def add_external_path(self, path):
259        """
260        Adds an external path to the combobox if it exists on the file system.
261        If the path is already listed in the combobox, it is removed from its
262        current position and added back at the end. If the maximum number of
263        paths is reached, the oldest external path is removed from the list.
264        """
265        if not osp.exists(path):
266            return
267        self.removeItem(self.findText(path))
268        self.addItem(path)
269        self.setItemData(self.count() - 1, path, Qt.ToolTipRole)
270        while self.count() > MAX_PATH_HISTORY + EXTERNAL_PATHS:
271            self.removeItem(EXTERNAL_PATHS)
272
273    def get_external_paths(self):
274        """Returns a list of the external paths listed in the combobox."""
275        return [to_text_string(self.itemText(i))
276                for i in range(EXTERNAL_PATHS, self.count())]
277
278    def clear_external_paths(self):
279        """Remove all the external paths listed in the combobox."""
280        while self.count() > EXTERNAL_PATHS:
281            self.removeItem(EXTERNAL_PATHS)
282
283    def get_current_searchpath(self):
284        """
285        Returns the path corresponding to the currently selected item
286        in the combobox.
287        """
288        idx = self.currentIndex()
289        if idx == CWD:
290            return self.path
291        elif idx == PROJECT:
292            return self.project_path
293        elif idx == FILE_PATH:
294            return self.file_path
295        else:
296            return self.external_path
297
298    def is_file_search(self):
299        """Returns whether the current search path is a file."""
300        if self.currentIndex() == FILE_PATH:
301            return True
302        else:
303            return False
304
305    @Slot()
306    def path_selection_changed(self):
307        """Handles when the current index of the combobox changes."""
308        idx = self.currentIndex()
309        if idx == SELECT_OTHER:
310            external_path = self.select_directory()
311            if len(external_path) > 0:
312                self.add_external_path(external_path)
313                self.setCurrentIndex(self.count() - 1)
314            else:
315                self.setCurrentIndex(CWD)
316        elif idx == CLEAR_LIST:
317            reply = QMessageBox.question(
318                    self, _("Clear other directories"),
319                    _("Do you want to clear the list of other directories?"),
320                    QMessageBox.Yes | QMessageBox.No)
321            if reply == QMessageBox.Yes:
322                self.clear_external_paths()
323            self.setCurrentIndex(CWD)
324        elif idx >= EXTERNAL_PATHS:
325            self.external_path = to_text_string(self.itemText(idx))
326
327    @Slot()
328    def select_directory(self):
329        """Select directory"""
330        self.__redirect_stdio_emit(False)
331        directory = getexistingdirectory(
332                self, _("Select directory"), self.path)
333        if directory:
334            directory = to_unicode_from_fs(osp.abspath(directory))
335        self.__redirect_stdio_emit(True)
336        return directory
337
338    def set_project_path(self, path):
339        """
340        Sets the project path and disables the project search in the combobox
341        if the value of path is None.
342        """
343        if path is None:
344            self.project_path = None
345            self.model().item(PROJECT, 0).setEnabled(False)
346            if self.currentIndex() == PROJECT:
347                self.setCurrentIndex(CWD)
348        else:
349            path = osp.abspath(path)
350            self.project_path = path
351            self.model().item(PROJECT, 0).setEnabled(True)
352
353    def eventFilter(self, widget, event):
354        """Used to handle key events on the QListView of the combobox."""
355        if event.type() == QEvent.KeyPress and event.key() == Qt.Key_Delete:
356            index = self.view().currentIndex().row()
357            if index >= EXTERNAL_PATHS:
358                # Remove item and update the view.
359                self.removeItem(index)
360                self.showPopup()
361                # Set the view selection so that it doesn't bounce around.
362                new_index = min(self.count() - 1, index)
363                new_index = 0 if new_index < EXTERNAL_PATHS else new_index
364                self.view().setCurrentIndex(self.model().index(new_index, 0))
365                self.setCurrentIndex(new_index)
366            return True
367        return QComboBox.eventFilter(self, widget, event)
368
369    def __redirect_stdio_emit(self, value):
370        """
371        Searches through the parent tree to see if it is possible to emit the
372        redirect_stdio signal.
373        This logic allows to test the SearchInComboBox select_directory method
374        outside of the FindInFiles plugin.
375        """
376        parent = self.parent()
377        while parent is not None:
378            try:
379                parent.redirect_stdio.emit(value)
380            except AttributeError:
381                parent = parent.parent()
382            else:
383                break
384
385
386class FindOptions(QWidget):
387    """Find widget with options"""
388    REGEX_INVALID = "background-color:rgb(255, 175, 90);"
389    find = Signal()
390    stop = Signal()
391
392    def __init__(self, parent, search_text, search_text_regexp, search_path,
393                 exclude, exclude_idx, exclude_regexp,
394                 supported_encodings, in_python_path, more_options,
395                 case_sensitive, external_path_history):
396        QWidget.__init__(self, parent)
397
398        if search_path is None:
399            search_path = getcwd_or_home()
400
401        if not isinstance(search_text, (list, tuple)):
402            search_text = [search_text]
403        if not isinstance(search_path, (list, tuple)):
404            search_path = [search_path]
405        if not isinstance(exclude, (list, tuple)):
406            exclude = [exclude]
407        if not isinstance(external_path_history, (list, tuple)):
408            external_path_history = [external_path_history]
409
410        self.supported_encodings = supported_encodings
411
412        # Layout 1
413        hlayout1 = QHBoxLayout()
414        self.search_text = PatternComboBox(self, search_text,
415                                           _("Search pattern"))
416        self.edit_regexp = create_toolbutton(self,
417                                             icon=ima.icon('advanced'),
418                                             tip=_('Regular expression'))
419        self.case_button = create_toolbutton(self,
420                                             icon=get_icon("upper_lower.png"),
421                                             tip=_("Case Sensitive"))
422        self.case_button.setCheckable(True)
423        self.case_button.setChecked(case_sensitive)
424        self.edit_regexp.setCheckable(True)
425        self.edit_regexp.setChecked(search_text_regexp)
426        self.more_widgets = ()
427        self.more_options = create_toolbutton(self,
428                                              toggled=self.toggle_more_options)
429        self.more_options.setCheckable(True)
430        self.more_options.setChecked(more_options)
431
432        self.ok_button = create_toolbutton(self, text=_("Search"),
433                                           icon=ima.icon('find'),
434                                           triggered=lambda: self.find.emit(),
435                                           tip=_("Start search"),
436                                           text_beside_icon=True)
437        self.ok_button.clicked.connect(self.update_combos)
438        self.stop_button = create_toolbutton(self, text=_("Stop"),
439                                             icon=ima.icon('editclear'),
440                                             triggered=lambda:
441                                             self.stop.emit(),
442                                             tip=_("Stop search"),
443                                             text_beside_icon=True)
444        self.stop_button.setEnabled(False)
445        for widget in [self.search_text, self.edit_regexp, self.case_button,
446                       self.ok_button, self.stop_button, self.more_options]:
447            hlayout1.addWidget(widget)
448
449        # Layout 2
450        hlayout2 = QHBoxLayout()
451        self.exclude_pattern = PatternComboBox(self, exclude,
452                                               _("Excluded filenames pattern"))
453        if exclude_idx is not None and exclude_idx >= 0 \
454           and exclude_idx < self.exclude_pattern.count():
455            self.exclude_pattern.setCurrentIndex(exclude_idx)
456        self.exclude_regexp = create_toolbutton(self,
457                                                icon=ima.icon('advanced'),
458                                                tip=_('Regular expression'))
459        self.exclude_regexp.setCheckable(True)
460        self.exclude_regexp.setChecked(exclude_regexp)
461        exclude_label = QLabel(_("Exclude:"))
462        exclude_label.setBuddy(self.exclude_pattern)
463        for widget in [exclude_label, self.exclude_pattern,
464                       self.exclude_regexp]:
465            hlayout2.addWidget(widget)
466
467        # Layout 3
468        hlayout3 = QHBoxLayout()
469
470        search_on_label = QLabel(_("Search in:"))
471        self.path_selection_combo = SearchInComboBox(
472                external_path_history, parent)
473
474        hlayout3.addWidget(search_on_label)
475        hlayout3.addWidget(self.path_selection_combo)
476
477        self.search_text.valid.connect(lambda valid: self.find.emit())
478        self.exclude_pattern.valid.connect(lambda valid: self.find.emit())
479
480        vlayout = QVBoxLayout()
481        vlayout.setContentsMargins(0, 0, 0, 0)
482        vlayout.addLayout(hlayout1)
483        vlayout.addLayout(hlayout2)
484        vlayout.addLayout(hlayout3)
485        self.more_widgets = (hlayout2,)
486        self.toggle_more_options(more_options)
487        self.setLayout(vlayout)
488
489        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
490
491    @Slot(bool)
492    def toggle_more_options(self, state):
493        for layout in self.more_widgets:
494            for index in range(layout.count()):
495                if state and self.isVisible() or not state:
496                    layout.itemAt(index).widget().setVisible(state)
497        if state:
498            icon = ima.icon('options_less')
499            tip = _('Hide advanced options')
500        else:
501            icon = ima.icon('options_more')
502            tip = _('Show advanced options')
503        self.more_options.setIcon(icon)
504        self.more_options.setToolTip(tip)
505
506    def update_combos(self):
507        self.search_text.lineEdit().returnPressed.emit()
508        self.exclude_pattern.lineEdit().returnPressed.emit()
509
510    def set_search_text(self, text):
511        if text:
512            self.search_text.add_text(text)
513            self.search_text.lineEdit().selectAll()
514        self.search_text.setFocus()
515
516    def get_options(self, all=False):
517        # Getting options
518        self.search_text.lineEdit().setStyleSheet("")
519        self.exclude_pattern.lineEdit().setStyleSheet("")
520
521        utext = to_text_string(self.search_text.currentText())
522        if not utext:
523            return
524        try:
525            texts = [(utext.encode('utf-8'), 'utf-8')]
526        except UnicodeEncodeError:
527            texts = []
528            for enc in self.supported_encodings:
529                try:
530                    texts.append((utext.encode(enc), enc))
531                except UnicodeDecodeError:
532                    pass
533        text_re = self.edit_regexp.isChecked()
534        exclude = to_text_string(self.exclude_pattern.currentText())
535        exclude_re = self.exclude_regexp.isChecked()
536        case_sensitive = self.case_button.isChecked()
537        python_path = False
538
539        if not case_sensitive:
540            texts = [(text[0].lower(), text[1]) for text in texts]
541
542        file_search = self.path_selection_combo.is_file_search()
543        path = self.path_selection_combo.get_current_searchpath()
544
545        # Finding text occurrences
546        if not exclude_re:
547            exclude = fnmatch.translate(exclude)
548        else:
549            try:
550                exclude = re.compile(exclude)
551            except Exception:
552                exclude_edit = self.exclude_pattern.lineEdit()
553                exclude_edit.setStyleSheet(self.REGEX_INVALID)
554                return None
555
556        if text_re:
557            try:
558                texts = [(re.compile(x[0]), x[1]) for x in texts]
559            except Exception:
560                self.search_text.lineEdit().setStyleSheet(self.REGEX_INVALID)
561                return None
562
563        if all:
564            search_text = [to_text_string(self.search_text.itemText(index))
565                           for index in range(self.search_text.count())]
566            exclude = [to_text_string(self.exclude_pattern.itemText(index))
567                       for index in range(self.exclude_pattern.count())]
568            path_history = self.path_selection_combo.get_external_paths()
569            exclude_idx = self.exclude_pattern.currentIndex()
570            more_options = self.more_options.isChecked()
571            return (search_text, text_re, [],
572                    exclude, exclude_idx, exclude_re,
573                    python_path, more_options, case_sensitive, path_history)
574        else:
575            return (path, file_search, exclude, texts, text_re, case_sensitive)
576
577    @property
578    def path(self):
579        return self.path_selection_combo.path
580
581    def set_directory(self, directory):
582        self.path_selection_combo.path = osp.abspath(directory)
583
584    @property
585    def project_path(self):
586        return self.path_selection_combo.project_path
587
588    def set_project_path(self, path):
589        self.path_selection_combo.set_project_path(path)
590
591    def disable_project_search(self):
592        self.path_selection_combo.set_project_path(None)
593
594    @property
595    def file_path(self):
596        return self.path_selection_combo.file_path
597
598    def set_file_path(self, path):
599        self.path_selection_combo.file_path = path
600
601    def keyPressEvent(self, event):
602        """Reimplemented to handle key events"""
603        ctrl = event.modifiers() & Qt.ControlModifier
604        shift = event.modifiers() & Qt.ShiftModifier
605        if event.key() in (Qt.Key_Enter, Qt.Key_Return):
606            self.find.emit()
607        elif event.key() == Qt.Key_F and ctrl and shift:
608            # Toggle find widgets
609            self.parent().toggle_visibility.emit(not self.isVisible())
610        else:
611            QWidget.keyPressEvent(self, event)
612
613
614class LineMatchItem(QTreeWidgetItem):
615    def __init__(self, parent, lineno, colno, match):
616        self.lineno = lineno
617        self.colno = colno
618        self.match = match
619        QTreeWidgetItem.__init__(self, parent, [self.__repr__()],
620                                 QTreeWidgetItem.Type)
621
622    def __repr__(self):
623        match = to_text_string(self.match).rstrip()
624        font = get_font()
625        _str = to_text_string("<b>{1}</b> ({2}): "
626                              "<span style='font-family:{0};"
627                              "font-size:75%;'>{3}</span>")
628        return _str.format(font.family(), self.lineno, self.colno, match)
629
630    def __unicode__(self):
631        return self.__repr__()
632
633    def __str__(self):
634        return self.__repr__()
635
636    def __lt__(self, x):
637        return self.lineno < x.lineno
638
639    def __ge__(self, x):
640        return self.lineno >= x.lineno
641
642
643class FileMatchItem(QTreeWidgetItem):
644    def __init__(self, parent, filename, sorting):
645
646        self.sorting = sorting
647        self.filename = osp.basename(filename)
648
649        title_format = to_text_string('<b>{0}</b><br>'
650                                      '<small><em>{1}</em>'
651                                      '</small>')
652        title = (title_format.format(osp.basename(filename),
653                                     osp.dirname(filename)))
654        QTreeWidgetItem.__init__(self, parent, [title], QTreeWidgetItem.Type)
655
656        self.setToolTip(0, filename)
657
658    def __lt__(self, x):
659        if self.sorting['status'] == ON:
660            return self.filename < x.filename
661        else:
662            return False
663
664    def __ge__(self, x):
665        if self.sorting['status'] == ON:
666            return self.filename >= x.filename
667        else:
668            return False
669
670
671class ItemDelegate(QStyledItemDelegate):
672    def __init__(self, parent):
673        QStyledItemDelegate.__init__(self, parent)
674
675    def paint(self, painter, option, index):
676        options = QStyleOptionViewItem(option)
677        self.initStyleOption(options, index)
678
679        style = (QApplication.style() if options.widget is None
680                 else options.widget.style())
681
682        doc = QTextDocument()
683        doc.setDocumentMargin(0)
684        doc.setHtml(options.text)
685
686        options.text = ""
687        style.drawControl(QStyle.CE_ItemViewItem, options, painter)
688
689        ctx = QAbstractTextDocumentLayout.PaintContext()
690
691        textRect = style.subElementRect(QStyle.SE_ItemViewItemText, options)
692        painter.save()
693
694        painter.translate(textRect.topLeft())
695        painter.setClipRect(textRect.translated(-textRect.topLeft()))
696        doc.documentLayout().draw(painter, ctx)
697        painter.restore()
698
699    def sizeHint(self, option, index):
700        options = QStyleOptionViewItem(option)
701        self.initStyleOption(options, index)
702
703        doc = QTextDocument()
704        doc.setHtml(options.text)
705        doc.setTextWidth(options.rect.width())
706
707        return QSize(doc.idealWidth(), doc.size().height())
708
709
710class ResultsBrowser(OneColumnTree):
711    def __init__(self, parent):
712        OneColumnTree.__init__(self, parent)
713        self.search_text = None
714        self.results = None
715        self.total_matches = None
716        self.error_flag = None
717        self.completed = None
718        self.sorting = {}
719        self.data = None
720        self.files = None
721        self.set_title('')
722        self.set_sorting(OFF)
723        self.setSortingEnabled(False)
724        self.root_items = None
725        self.sortByColumn(0, Qt.AscendingOrder)
726        self.setItemDelegate(ItemDelegate(self))
727        self.setUniformRowHeights(False)
728        self.header().sectionClicked.connect(self.sort_section)
729
730    def activated(self, item):
731        """Double-click event"""
732        itemdata = self.data.get(id(self.currentItem()))
733        if itemdata is not None:
734            filename, lineno, colno = itemdata
735            self.parent().edit_goto.emit(filename, lineno, self.search_text)
736
737    def set_sorting(self, flag):
738        """Enable result sorting after search is complete."""
739        self.sorting['status'] = flag
740        self.header().setSectionsClickable(flag == ON)
741
742    @Slot(int)
743    def sort_section(self, idx):
744        self.setSortingEnabled(True)
745
746    def clicked(self, item):
747        """Click event"""
748        self.activated(item)
749
750    def clear_title(self, search_text):
751        self.clear()
752        self.setSortingEnabled(False)
753        self.num_files = 0
754        self.data = {}
755        self.files = {}
756        self.set_sorting(OFF)
757        self.search_text = search_text
758        title = "'%s' - " % search_text
759        text = _('String not found')
760        self.set_title(title + text)
761
762    def truncate_result(self, line, start, end):
763        ellipsis = '...'
764        max_line_length = 80
765        max_num_char_fragment = 40
766
767        html_escape_table = {
768            "&": "&amp;",
769            '"': "&quot;",
770            "'": "&apos;",
771            ">": "&gt;",
772            "<": "&lt;",
773        }
774
775        def html_escape(text):
776            """Produce entities within text."""
777            return "".join(html_escape_table.get(c, c) for c in text)
778
779        line = to_text_string(line)
780        left, match, right = line[:start], line[start:end], line[end:]
781
782        if len(line) > max_line_length:
783            offset = (len(line) - len(match)) // 2
784
785            left = left.split(' ')
786            num_left_words = len(left)
787
788            if num_left_words == 1:
789                left = left[0]
790                if len(left) > max_num_char_fragment:
791                    left = ellipsis + left[-offset:]
792                left = [left]
793
794            right = right.split(' ')
795            num_right_words = len(right)
796
797            if num_right_words == 1:
798                right = right[0]
799                if len(right) > max_num_char_fragment:
800                    right = right[:offset] + ellipsis
801                right = [right]
802
803            left = left[-4:]
804            right = right[:4]
805
806            if len(left) < num_left_words:
807                left = [ellipsis] + left
808
809            if len(right) < num_right_words:
810                right = right + [ellipsis]
811
812            left = ' '.join(left)
813            right = ' '.join(right)
814
815            if len(left) > max_num_char_fragment:
816                left = ellipsis + left[-30:]
817
818            if len(right) > max_num_char_fragment:
819                right = right[:30] + ellipsis
820
821        line_match_format = to_text_string('{0}<b>{1}</b>{2}')
822        left = html_escape(left)
823        right = html_escape(right)
824        match = html_escape(match)
825        trunc_line = line_match_format.format(left, match, right)
826        return trunc_line
827
828    @Slot(tuple, int)
829    def append_result(self, results, num_matches):
830        """Real-time update of search results"""
831        filename, lineno, colno, match_end, line = results
832
833        if filename not in self.files:
834            file_item = FileMatchItem(self, filename, self.sorting)
835            file_item.setExpanded(True)
836            self.files[filename] = file_item
837            self.num_files += 1
838
839        search_text = self.search_text
840        title = "'%s' - " % search_text
841        nb_files = self.num_files
842        if nb_files == 0:
843            text = _('String not found')
844        else:
845            text_matches = _('matches in')
846            text_files = _('file')
847            if nb_files > 1:
848                text_files += 's'
849            text = "%d %s %d %s" % (num_matches, text_matches,
850                                    nb_files, text_files)
851        self.set_title(title + text)
852
853        file_item = self.files[filename]
854        line = self.truncate_result(line, colno, match_end)
855        item = LineMatchItem(file_item, lineno, colno, line)
856        self.data[id(item)] = (filename, lineno, colno)
857
858
859class FileProgressBar(QWidget):
860    """Simple progress spinner with a label"""
861
862    def __init__(self, parent):
863        QWidget.__init__(self, parent)
864
865        self.status_text = QLabel(self)
866        self.spinner = QWaitingSpinner(self, centerOnParent=False)
867        self.spinner.setNumberOfLines(12)
868        self.spinner.setInnerRadius(2)
869        layout = QHBoxLayout()
870        layout.addWidget(self.spinner)
871        layout.addWidget(self.status_text)
872        self.setLayout(layout)
873
874    @Slot(str)
875    def set_label_path(self, path, folder=False):
876        text = truncate_path(path)
877        if not folder:
878            status_str = _(u' Scanning: {0}').format(text)
879        else:
880            status_str = _(u' Searching for files in folder: {0}').format(text)
881        self.status_text.setText(status_str)
882
883    def reset(self):
884        self.status_text.setText(_("  Searching for files..."))
885
886    def showEvent(self, event):
887        """Override show event to start waiting spinner."""
888        QWidget.showEvent(self, event)
889        self.spinner.start()
890
891    def hideEvent(self, event):
892        """Override hide event to stop waiting spinner."""
893        QWidget.hideEvent(self, event)
894        self.spinner.stop()
895
896
897class FindInFilesWidget(QWidget):
898    """
899    Find in files widget
900    """
901    sig_finished = Signal()
902
903    def __init__(self, parent,
904                 search_text=r"# ?TODO|# ?FIXME|# ?XXX",
905                 search_text_regexp=True, search_path=None,
906                 exclude=r"\.pyc$|\.orig$|\.hg|\.svn", exclude_idx=None,
907                 exclude_regexp=True,
908                 supported_encodings=("utf-8", "iso-8859-1", "cp1252"),
909                 in_python_path=False, more_options=False,
910                 case_sensitive=True, external_path_history=[]):
911        QWidget.__init__(self, parent)
912
913        self.setWindowTitle(_('Find in files'))
914
915        self.search_thread = None
916        self.search_path = ''
917        self.get_pythonpath_callback = None
918
919        self.status_bar = FileProgressBar(self)
920        self.status_bar.hide()
921        self.find_options = FindOptions(self, search_text, search_text_regexp,
922                                        search_path,
923                                        exclude, exclude_idx, exclude_regexp,
924                                        supported_encodings, in_python_path,
925                                        more_options, case_sensitive,
926                                        external_path_history)
927        self.find_options.find.connect(self.find)
928        self.find_options.stop.connect(self.stop_and_reset_thread)
929
930        self.result_browser = ResultsBrowser(self)
931
932        hlayout = QHBoxLayout()
933        hlayout.addWidget(self.result_browser)
934
935        layout = QVBoxLayout()
936        left, _x, right, bottom = layout.getContentsMargins()
937        layout.setContentsMargins(left, 0, right, bottom)
938        layout.addWidget(self.find_options)
939        layout.addLayout(hlayout)
940        layout.addWidget(self.status_bar)
941        self.setLayout(layout)
942
943    def set_search_text(self, text):
944        """Set search pattern"""
945        self.find_options.set_search_text(text)
946
947    def find(self):
948        """Call the find function"""
949        options = self.find_options.get_options()
950        if options is None:
951            return
952        self.stop_and_reset_thread(ignore_results=True)
953        self.search_thread = SearchThread(self)
954        self.search_thread.get_pythonpath_callback = (
955            self.get_pythonpath_callback)
956        self.search_thread.sig_finished.connect(self.search_complete)
957        self.search_thread.sig_current_file.connect(
958            lambda x: self.status_bar.set_label_path(x, folder=False)
959        )
960        self.search_thread.sig_current_folder.connect(
961            lambda x: self.status_bar.set_label_path(x, folder=True)
962        )
963        self.search_thread.sig_file_match.connect(
964            self.result_browser.append_result
965        )
966        self.search_thread.sig_out_print.connect(
967            lambda x: sys.stdout.write(str(x) + "\n")
968        )
969        self.status_bar.reset()
970        self.result_browser.clear_title(
971            self.find_options.search_text.currentText())
972        self.search_thread.initialize(*options)
973        self.search_thread.start()
974        self.find_options.ok_button.setEnabled(False)
975        self.find_options.stop_button.setEnabled(True)
976        self.status_bar.show()
977
978    def stop_and_reset_thread(self, ignore_results=False):
979        """Stop current search thread and clean-up"""
980        if self.search_thread is not None:
981            if self.search_thread.isRunning():
982                if ignore_results:
983                    self.search_thread.sig_finished.disconnect(
984                        self.search_complete)
985                self.search_thread.stop()
986                self.search_thread.wait()
987            self.search_thread.setParent(None)
988            self.search_thread = None
989
990    def closing_widget(self):
991        """Perform actions before widget is closed"""
992        self.stop_and_reset_thread(ignore_results=True)
993
994    def search_complete(self, completed):
995        """Current search thread has finished"""
996        self.result_browser.set_sorting(ON)
997        self.find_options.ok_button.setEnabled(True)
998        self.find_options.stop_button.setEnabled(False)
999        self.status_bar.hide()
1000        self.result_browser.expandAll()
1001        if self.search_thread is None:
1002            return
1003        self.sig_finished.emit()
1004        found = self.search_thread.get_results()
1005        self.stop_and_reset_thread()
1006        if found is not None:
1007            results, pathlist, nb, error_flag = found
1008            self.result_browser.show()
1009
1010
1011def test():
1012    """Run Find in Files widget test"""
1013    from spyder.utils.qthelpers import qapplication
1014    from os.path import dirname
1015    app = qapplication()
1016    widget = FindInFilesWidget(None)
1017    widget.resize(640, 480)
1018    widget.show()
1019    external_paths = [
1020            dirname(__file__),
1021            dirname(dirname(__file__)),
1022            dirname(dirname(dirname(__file__))),
1023            dirname(dirname(dirname(dirname(__file__))))
1024            ]
1025    for path in external_paths:
1026        widget.find_options.path_selection_combo.add_external_path(path)
1027    sys.exit(app.exec_())
1028
1029
1030if __name__ == '__main__':
1031    test()
1032