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