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 "&": "&", 769 '"': """, 770 "'": "'", 771 ">": ">", 772 "<": "<", 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