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/Replace widget""" 8 9# pylint: disable=C0103 10# pylint: disable=R0903 11# pylint: disable=R0911 12# pylint: disable=R0201 13 14# Standard library imports 15import re 16 17# Third party imports 18from qtpy.QtCore import Qt, QTimer, Signal, Slot, QEvent 19from qtpy.QtGui import QTextCursor 20from qtpy.QtWidgets import (QGridLayout, QHBoxLayout, QLabel, 21 QSizePolicy, QWidget) 22 23# Local imports 24from spyder.config.base import _ 25from spyder.config.gui import config_shortcut 26from spyder.py3compat import to_text_string 27from spyder.utils import icon_manager as ima 28from spyder.utils.qthelpers import create_toolbutton, get_icon 29from spyder.widgets.comboboxes import PatternComboBox 30 31 32def is_position_sup(pos1, pos2): 33 """Return True is pos1 > pos2""" 34 return pos1 > pos2 35 36def is_position_inf(pos1, pos2): 37 """Return True is pos1 < pos2""" 38 return pos1 < pos2 39 40 41class FindReplace(QWidget): 42 """Find widget""" 43 STYLE = {False: "background-color:rgb(255, 175, 90);", 44 True: "", 45 None: "", 46 'regexp_error': "background-color:rgb(255, 80, 80);", 47 } 48 TOOLTIP = {False: _("No matches"), 49 True: _("Search string"), 50 None: _("Search string"), 51 'regexp_error': _("Regular expression error") 52 } 53 visibility_changed = Signal(bool) 54 return_shift_pressed = Signal() 55 return_pressed = Signal() 56 57 def __init__(self, parent, enable_replace=False): 58 QWidget.__init__(self, parent) 59 self.enable_replace = enable_replace 60 self.editor = None 61 self.is_code_editor = None 62 63 glayout = QGridLayout() 64 glayout.setContentsMargins(0, 0, 0, 0) 65 self.setLayout(glayout) 66 67 self.close_button = create_toolbutton(self, triggered=self.hide, 68 icon=ima.icon('DialogCloseButton')) 69 glayout.addWidget(self.close_button, 0, 0) 70 71 # Find layout 72 self.search_text = PatternComboBox(self, tip=_("Search string"), 73 adjust_to_minimum=False) 74 75 self.return_shift_pressed.connect( 76 lambda: 77 self.find(changed=False, forward=False, rehighlight=False, 78 multiline_replace_check = False)) 79 80 self.return_pressed.connect( 81 lambda: 82 self.find(changed=False, forward=True, rehighlight=False, 83 multiline_replace_check = False)) 84 85 self.search_text.lineEdit().textEdited.connect( 86 self.text_has_been_edited) 87 88 self.number_matches_text = QLabel(self) 89 self.previous_button = create_toolbutton(self, 90 triggered=self.find_previous, 91 icon=ima.icon('ArrowUp')) 92 self.next_button = create_toolbutton(self, 93 triggered=self.find_next, 94 icon=ima.icon('ArrowDown')) 95 self.next_button.clicked.connect(self.update_search_combo) 96 self.previous_button.clicked.connect(self.update_search_combo) 97 98 self.re_button = create_toolbutton(self, icon=ima.icon('advanced'), 99 tip=_("Regular expression")) 100 self.re_button.setCheckable(True) 101 self.re_button.toggled.connect(lambda state: self.find()) 102 103 self.case_button = create_toolbutton(self, 104 icon=get_icon("upper_lower.png"), 105 tip=_("Case Sensitive")) 106 self.case_button.setCheckable(True) 107 self.case_button.toggled.connect(lambda state: self.find()) 108 109 self.words_button = create_toolbutton(self, 110 icon=get_icon("whole_words.png"), 111 tip=_("Whole words")) 112 self.words_button.setCheckable(True) 113 self.words_button.toggled.connect(lambda state: self.find()) 114 115 self.highlight_button = create_toolbutton(self, 116 icon=get_icon("highlight.png"), 117 tip=_("Highlight matches")) 118 self.highlight_button.setCheckable(True) 119 self.highlight_button.toggled.connect(self.toggle_highlighting) 120 121 hlayout = QHBoxLayout() 122 self.widgets = [self.close_button, self.search_text, 123 self.number_matches_text, self.previous_button, 124 self.next_button, self.re_button, self.case_button, 125 self.words_button, self.highlight_button] 126 for widget in self.widgets[1:]: 127 hlayout.addWidget(widget) 128 glayout.addLayout(hlayout, 0, 1) 129 130 # Replace layout 131 replace_with = QLabel(_("Replace with:")) 132 self.replace_text = PatternComboBox(self, adjust_to_minimum=False, 133 tip=_('Replace string')) 134 self.replace_text.valid.connect( 135 lambda _: self.replace_find(focus_replace_text=True)) 136 self.replace_button = create_toolbutton(self, 137 text=_('Replace/find next'), 138 icon=ima.icon('DialogApplyButton'), 139 triggered=self.replace_find, 140 text_beside_icon=True) 141 self.replace_sel_button = create_toolbutton(self, 142 text=_('Replace selection'), 143 icon=ima.icon('DialogApplyButton'), 144 triggered=self.replace_find_selection, 145 text_beside_icon=True) 146 self.replace_sel_button.clicked.connect(self.update_replace_combo) 147 self.replace_sel_button.clicked.connect(self.update_search_combo) 148 149 self.replace_all_button = create_toolbutton(self, 150 text=_('Replace all'), 151 icon=ima.icon('DialogApplyButton'), 152 triggered=self.replace_find_all, 153 text_beside_icon=True) 154 self.replace_all_button.clicked.connect(self.update_replace_combo) 155 self.replace_all_button.clicked.connect(self.update_search_combo) 156 157 self.replace_layout = QHBoxLayout() 158 widgets = [replace_with, self.replace_text, self.replace_button, 159 self.replace_sel_button, self.replace_all_button] 160 for widget in widgets: 161 self.replace_layout.addWidget(widget) 162 glayout.addLayout(self.replace_layout, 1, 1) 163 self.widgets.extend(widgets) 164 self.replace_widgets = widgets 165 self.hide_replace() 166 167 self.search_text.setTabOrder(self.search_text, self.replace_text) 168 169 self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) 170 171 self.shortcuts = self.create_shortcuts(parent) 172 173 self.highlight_timer = QTimer(self) 174 self.highlight_timer.setSingleShot(True) 175 self.highlight_timer.setInterval(1000) 176 self.highlight_timer.timeout.connect(self.highlight_matches) 177 self.search_text.installEventFilter(self) 178 179 def eventFilter(self, widget, event): 180 """Event filter for search_text widget. 181 182 Emits signals when presing Enter and Shift+Enter. 183 This signals are used for search forward and backward. 184 Also, a crude hack to get tab working in the Find/Replace boxes. 185 """ 186 if event.type() == QEvent.KeyPress: 187 key = event.key() 188 shift = event.modifiers() & Qt.ShiftModifier 189 190 if key == Qt.Key_Return: 191 if shift: 192 self.return_shift_pressed.emit() 193 else: 194 self.return_pressed.emit() 195 196 if key == Qt.Key_Tab: 197 if self.search_text.hasFocus(): 198 self.replace_text.set_current_text( 199 self.search_text.currentText()) 200 self.focusNextChild() 201 202 return super(FindReplace, self).eventFilter(widget, event) 203 204 def create_shortcuts(self, parent): 205 """Create shortcuts for this widget""" 206 # Configurable 207 findnext = config_shortcut(self.find_next, context='_', 208 name='Find next', parent=parent) 209 findprev = config_shortcut(self.find_previous, context='_', 210 name='Find previous', parent=parent) 211 togglefind = config_shortcut(self.show, context='_', 212 name='Find text', parent=parent) 213 togglereplace = config_shortcut(self.show_replace, 214 context='_', name='Replace text', 215 parent=parent) 216 hide = config_shortcut(self.hide, context='_', name='hide find and replace', 217 parent=self) 218 219 return [findnext, findprev, togglefind, togglereplace, hide] 220 221 def get_shortcut_data(self): 222 """ 223 Returns shortcut data, a list of tuples (shortcut, text, default) 224 shortcut (QShortcut or QAction instance) 225 text (string): action/shortcut description 226 default (string): default key sequence 227 """ 228 return [sc.data for sc in self.shortcuts] 229 230 def update_search_combo(self): 231 self.search_text.lineEdit().returnPressed.emit() 232 233 def update_replace_combo(self): 234 self.replace_text.lineEdit().returnPressed.emit() 235 236 def toggle_replace_widgets(self): 237 if self.enable_replace: 238 # Toggle replace widgets 239 if self.replace_widgets[0].isVisible(): 240 self.hide_replace() 241 self.hide() 242 else: 243 self.show_replace() 244 if len(to_text_string(self.search_text.currentText()))>0: 245 self.replace_text.setFocus() 246 247 @Slot(bool) 248 def toggle_highlighting(self, state): 249 """Toggle the 'highlight all results' feature""" 250 if self.editor is not None: 251 if state: 252 self.highlight_matches() 253 else: 254 self.clear_matches() 255 256 def show(self, hide_replace=True): 257 """Overrides Qt Method""" 258 QWidget.show(self) 259 self.visibility_changed.emit(True) 260 self.change_number_matches() 261 if self.editor is not None: 262 if hide_replace: 263 if self.replace_widgets[0].isVisible(): 264 self.hide_replace() 265 text = self.editor.get_selected_text() 266 # When selecting several lines, and replace box is activated the 267 # text won't be replaced for the selection 268 if hide_replace or len(text.splitlines())<=1: 269 highlighted = True 270 # If no text is highlighted for search, use whatever word is 271 # under the cursor 272 if not text: 273 highlighted = False 274 try: 275 cursor = self.editor.textCursor() 276 cursor.select(QTextCursor.WordUnderCursor) 277 text = to_text_string(cursor.selectedText()) 278 except AttributeError: 279 # We can't do this for all widgets, e.g. WebView's 280 pass 281 282 # Now that text value is sorted out, use it for the search 283 if text and not self.search_text.currentText() or highlighted: 284 self.search_text.setEditText(text) 285 self.search_text.lineEdit().selectAll() 286 self.refresh() 287 else: 288 self.search_text.lineEdit().selectAll() 289 self.search_text.setFocus() 290 291 @Slot() 292 def hide(self): 293 """Overrides Qt Method""" 294 for widget in self.replace_widgets: 295 widget.hide() 296 QWidget.hide(self) 297 self.visibility_changed.emit(False) 298 if self.editor is not None: 299 self.editor.setFocus() 300 self.clear_matches() 301 302 def show_replace(self): 303 """Show replace widgets""" 304 self.show(hide_replace=False) 305 for widget in self.replace_widgets: 306 widget.show() 307 308 def hide_replace(self): 309 """Hide replace widgets""" 310 for widget in self.replace_widgets: 311 widget.hide() 312 313 def refresh(self): 314 """Refresh widget""" 315 if self.isHidden(): 316 if self.editor is not None: 317 self.clear_matches() 318 return 319 state = self.editor is not None 320 for widget in self.widgets: 321 widget.setEnabled(state) 322 if state: 323 self.find() 324 325 def set_editor(self, editor, refresh=True): 326 """ 327 Set associated editor/web page: 328 codeeditor.base.TextEditBaseWidget 329 browser.WebView 330 """ 331 self.editor = editor 332 # Note: This is necessary to test widgets/editor.py 333 # in Qt builds that don't have web widgets 334 try: 335 from qtpy.QtWebEngineWidgets import QWebEngineView 336 except ImportError: 337 QWebEngineView = type(None) 338 self.words_button.setVisible(not isinstance(editor, QWebEngineView)) 339 self.re_button.setVisible(not isinstance(editor, QWebEngineView)) 340 from spyder.widgets.sourcecode.codeeditor import CodeEditor 341 self.is_code_editor = isinstance(editor, CodeEditor) 342 self.highlight_button.setVisible(self.is_code_editor) 343 if refresh: 344 self.refresh() 345 if self.isHidden() and editor is not None: 346 self.clear_matches() 347 348 @Slot() 349 def find_next(self): 350 """Find next occurrence""" 351 state = self.find(changed=False, forward=True, rehighlight=False, 352 multiline_replace_check=False) 353 self.editor.setFocus() 354 self.search_text.add_current_text() 355 return state 356 357 @Slot() 358 def find_previous(self): 359 """Find previous occurrence""" 360 state = self.find(changed=False, forward=False, rehighlight=False, 361 multiline_replace_check=False) 362 self.editor.setFocus() 363 return state 364 365 def text_has_been_edited(self, text): 366 """Find text has been edited (this slot won't be triggered when 367 setting the search pattern combo box text programmatically)""" 368 self.find(changed=True, forward=True, start_highlight_timer=True) 369 370 def highlight_matches(self): 371 """Highlight found results""" 372 if self.is_code_editor and self.highlight_button.isChecked(): 373 text = self.search_text.currentText() 374 words = self.words_button.isChecked() 375 regexp = self.re_button.isChecked() 376 self.editor.highlight_found_results(text, words=words, 377 regexp=regexp) 378 379 def clear_matches(self): 380 """Clear all highlighted matches""" 381 if self.is_code_editor: 382 self.editor.clear_found_results() 383 384 def find(self, changed=True, forward=True, 385 rehighlight=True, start_highlight_timer=False, multiline_replace_check=True): 386 """Call the find function""" 387 # When several lines are selected in the editor and replace box is activated, 388 # dynamic search is deactivated to prevent changing the selection. Otherwise 389 # we show matching items. 390 def regexp_error_msg(pattern): 391 """Returns None if the pattern is a valid regular expression or 392 a string describing why the pattern is invalid. 393 """ 394 try: 395 re.compile(pattern) 396 except re.error as e: 397 return str(e) 398 return None 399 400 if multiline_replace_check and self.replace_widgets[0].isVisible() and \ 401 len(to_text_string(self.editor.get_selected_text()).splitlines())>1: 402 return None 403 text = self.search_text.currentText() 404 if len(text) == 0: 405 self.search_text.lineEdit().setStyleSheet("") 406 if not self.is_code_editor: 407 # Clears the selection for WebEngine 408 self.editor.find_text('') 409 self.change_number_matches() 410 return None 411 else: 412 case = self.case_button.isChecked() 413 words = self.words_button.isChecked() 414 regexp = self.re_button.isChecked() 415 found = self.editor.find_text(text, changed, forward, case=case, 416 words=words, regexp=regexp) 417 418 stylesheet = self.STYLE[found] 419 tooltip = self.TOOLTIP[found] 420 if not found and regexp: 421 error_msg = regexp_error_msg(text) 422 if error_msg: # special styling for regexp errors 423 stylesheet = self.STYLE['regexp_error'] 424 tooltip = self.TOOLTIP['regexp_error'] + ': ' + error_msg 425 self.search_text.lineEdit().setStyleSheet(stylesheet) 426 self.search_text.setToolTip(tooltip) 427 428 if self.is_code_editor and found: 429 if rehighlight or not self.editor.found_results: 430 self.highlight_timer.stop() 431 if start_highlight_timer: 432 self.highlight_timer.start() 433 else: 434 self.highlight_matches() 435 else: 436 self.clear_matches() 437 438 number_matches = self.editor.get_number_matches(text, case=case) 439 if hasattr(self.editor, 'get_match_number'): 440 match_number = self.editor.get_match_number(text, case=case) 441 else: 442 match_number = 0 443 self.change_number_matches(current_match=match_number, 444 total_matches=number_matches) 445 return found 446 447 @Slot() 448 def replace_find(self, focus_replace_text=False, replace_all=False): 449 """Replace and find""" 450 if (self.editor is not None): 451 replace_text = to_text_string(self.replace_text.currentText()) 452 search_text = to_text_string(self.search_text.currentText()) 453 re_pattern = None 454 if self.re_button.isChecked(): 455 try: 456 re_pattern = re.compile(search_text) 457 except re.error: 458 return # do nothing with an invalid regexp 459 case = self.case_button.isChecked() 460 first = True 461 cursor = None 462 while True: 463 if first: 464 # First found 465 seltxt = to_text_string(self.editor.get_selected_text()) 466 cmptxt1 = search_text if case else search_text.lower() 467 cmptxt2 = seltxt if case else seltxt.lower() 468 if re_pattern is None: 469 has_selected = self.editor.has_selected_text() 470 if has_selected and cmptxt1 == cmptxt2: 471 # Text was already found, do nothing 472 pass 473 else: 474 if not self.find(changed=False, forward=True, 475 rehighlight=False): 476 break 477 else: 478 if len(re_pattern.findall(cmptxt2)) > 0: 479 pass 480 else: 481 if not self.find(changed=False, forward=True, 482 rehighlight=False): 483 break 484 first = False 485 wrapped = False 486 position = self.editor.get_position('cursor') 487 position0 = position 488 cursor = self.editor.textCursor() 489 cursor.beginEditBlock() 490 else: 491 position1 = self.editor.get_position('cursor') 492 if is_position_inf(position1, 493 position0 + len(replace_text) - 494 len(search_text) + 1): 495 # Identify wrapping even when the replace string 496 # includes part of the search string 497 wrapped = True 498 if wrapped: 499 if position1 == position or \ 500 is_position_sup(position1, position): 501 # Avoid infinite loop: replace string includes 502 # part of the search string 503 break 504 if position1 == position0: 505 # Avoid infinite loop: single found occurrence 506 break 507 position0 = position1 508 if re_pattern is None: 509 cursor.removeSelectedText() 510 cursor.insertText(replace_text) 511 else: 512 seltxt = to_text_string(cursor.selectedText()) 513 cursor.removeSelectedText() 514 cursor.insertText(re_pattern.sub(replace_text, seltxt)) 515 if self.find_next(): 516 found_cursor = self.editor.textCursor() 517 cursor.setPosition(found_cursor.selectionStart(), 518 QTextCursor.MoveAnchor) 519 cursor.setPosition(found_cursor.selectionEnd(), 520 QTextCursor.KeepAnchor) 521 else: 522 break 523 if not replace_all: 524 break 525 if cursor is not None: 526 cursor.endEditBlock() 527 if focus_replace_text: 528 self.replace_text.setFocus() 529 530 @Slot() 531 def replace_find_all(self, focus_replace_text=False): 532 """Replace and find all matching occurrences""" 533 self.replace_find(focus_replace_text, replace_all=True) 534 535 536 @Slot() 537 def replace_find_selection(self, focus_replace_text=False): 538 """Replace and find in the current selection""" 539 if self.editor is not None: 540 replace_text = to_text_string(self.replace_text.currentText()) 541 search_text = to_text_string(self.search_text.currentText()) 542 case = self.case_button.isChecked() 543 words = self.words_button.isChecked() 544 re_flags = re.MULTILINE if case else re.IGNORECASE|re.MULTILINE 545 546 re_pattern = None 547 if self.re_button.isChecked(): 548 pattern = search_text 549 else: 550 pattern = re.escape(search_text) 551 replace_text = re.escape(replace_text) 552 if words: # match whole words only 553 pattern = r'\b{pattern}\b'.format(pattern=pattern) 554 try: 555 re_pattern = re.compile(pattern, flags=re_flags) 556 except re.error as e: 557 return # do nothing with an invalid regexp 558 559 selected_text = to_text_string(self.editor.get_selected_text()) 560 replacement = re_pattern.sub(replace_text, selected_text) 561 if replacement != selected_text: 562 cursor = self.editor.textCursor() 563 cursor.beginEditBlock() 564 cursor.removeSelectedText() 565 if not self.re_button.isChecked(): 566 replacement = re.sub(r'\\(?![nrtf])(.)', r'\1', replacement) 567 cursor.insertText(replacement) 568 cursor.endEditBlock() 569 if focus_replace_text: 570 self.replace_text.setFocus() 571 else: 572 self.editor.setFocus() 573 574 def change_number_matches(self, current_match=0, total_matches=0): 575 """Change number of match and total matches.""" 576 if current_match and total_matches: 577 matches_string = u"{} {} {}".format(current_match, _(u"of"), 578 total_matches) 579 self.number_matches_text.setText(matches_string) 580 elif total_matches: 581 matches_string = u"{} {}".format(total_matches, _(u"matches")) 582 self.number_matches_text.setText(matches_string) 583 else: 584 self.number_matches_text.setText(_(u"no matches")) 585