Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PR: Implement "Replace in selection" in the Editor #4638

Merged
merged 5 commits into from
Jul 6, 2017
Merged
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 119 additions & 41 deletions spyder/widgets/findreplace.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ def __init__(self, parent, enable_replace=False):

self.return_shift_pressed.connect(
lambda:
self.find(changed=False, forward=False, rehighlight=False))
self.find(changed=False, forward=False, rehighlight=False,
multiline_replace_check = False))

self.return_pressed.connect(
lambda:
self.find(changed=False, forward=True, rehighlight=False))
self.find(changed=False, forward=True, rehighlight=False,
multiline_replace_check = False))

self.search_text.lineEdit().textEdited.connect(
self.text_has_been_edited)
Expand Down Expand Up @@ -124,18 +126,29 @@ def __init__(self, parent, enable_replace=False):
self.replace_text.valid.connect(
lambda _: self.replace_find(focus_replace_text=True))
self.replace_button = create_toolbutton(self,
text=_('Replace/find'),
text=_('Replace/find next'),
icon=ima.icon('DialogApplyButton'),
triggered=self.replace_find,
text_beside_icon=True)
self.replace_button.clicked.connect(self.update_replace_combo)
self.replace_button.clicked.connect(self.update_search_combo)

self.all_check = QCheckBox(_("Replace all"))
self.replace_sel_button = create_toolbutton(self,
text=_('Replace selection'),
icon=ima.icon('DialogApplyButton'),
triggered=self.replace_find_selection,
text_beside_icon=True)
self.replace_sel_button.clicked.connect(self.update_replace_combo)
self.replace_sel_button.clicked.connect(self.update_search_combo)

self.replace_all_button = create_toolbutton(self,
text=_('Replace all'),
icon=ima.icon('DialogApplyButton'),
triggered=self.replace_find_all,
text_beside_icon=True)
self.replace_all_button.clicked.connect(self.update_replace_combo)
self.replace_all_button.clicked.connect(self.update_search_combo)

self.replace_layout = QHBoxLayout()
widgets = [replace_with, self.replace_text, self.replace_button,
self.all_check]
self.replace_sel_button, self.replace_all_button]
for widget in widgets:
self.replace_layout.addWidget(widget)
glayout.addLayout(self.replace_layout, 1, 1)
Expand Down Expand Up @@ -215,7 +228,8 @@ def toggle_replace_widgets(self):
self.hide()
else:
self.show_replace()
self.replace_text.setFocus()
if len(to_text_string(self.search_text.currentText()))>0:
self.replace_text.setFocus()

@Slot(bool)
def toggle_highlighting(self, state):
Expand All @@ -226,32 +240,38 @@ def toggle_highlighting(self, state):
else:
self.clear_matches()

def show(self):
def show(self, hide_replace=True):
"""Overrides Qt Method"""
QWidget.show(self)
self.visibility_changed.emit(True)
if self.editor is not None:
if hide_replace:
if self.replace_widgets[0].isVisible():
self.hide_replace()
text = self.editor.get_selected_text()
highlighted = True
# If no text is highlighted for search, use whatever word is under
# the cursor
if not text:
highlighted = False
try:
cursor = self.editor.textCursor()
cursor.select(QTextCursor.WordUnderCursor)
text = to_text_string(cursor.selectedText())
except AttributeError:
# We can't do this for all widgets, e.g. WebView's
pass

# Now that text value is sorted out, use it for the search
if text and not self.search_text.currentText() or highlighted:
self.search_text.setEditText(text)
self.search_text.lineEdit().selectAll()
self.refresh()
else:
self.search_text.lineEdit().selectAll()
# When selecting several lines, and replace box is activated the
# text won't be replaced for the selection
if hide_replace or len(text.splitlines())<=1:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a comment for explaining this behavior is missing: "When selecting several lines, and replace box is activated the text won't be replaced for the selection"

highlighted = True
# If no text is highlighted for search, use whatever word is
# under the cursor
if not text:
highlighted = False
try:
cursor = self.editor.textCursor()
cursor.select(QTextCursor.WordUnderCursor)
text = to_text_string(cursor.selectedText())
except AttributeError:
# We can't do this for all widgets, e.g. WebView's
pass

# Now that text value is sorted out, use it for the search
if text and not self.search_text.currentText() or highlighted:
self.search_text.setEditText(text)
self.search_text.lineEdit().selectAll()
self.refresh()
else:
self.search_text.lineEdit().selectAll()
self.search_text.setFocus()

@Slot()
Expand All @@ -267,7 +287,7 @@ def hide(self):

def show_replace(self):
"""Show replace widgets"""
self.show()
self.show(hide_replace=False)
for widget in self.replace_widgets:
widget.show()

Expand Down Expand Up @@ -314,23 +334,25 @@ def set_editor(self, editor, refresh=True):
@Slot()
def find_next(self):
"""Find next occurrence"""
state = self.find(changed=False, forward=True, rehighlight=False)
state = self.find(changed=False, forward=True, rehighlight=False,
multiline_replace_check=False)
self.editor.setFocus()
self.search_text.add_current_text()
return state

@Slot()
def find_previous(self):
"""Find previous occurrence"""
state = self.find(changed=False, forward=False, rehighlight=False)
state = self.find(changed=False, forward=False, rehighlight=False,
multiline_replace_check=False)
self.editor.setFocus()
return state

def text_has_been_edited(self, text):
"""Find text has been edited (this slot won't be triggered when
setting the search pattern combo box text programmatically"""
setting the search pattern combo box text programmatically)"""
self.find(changed=True, forward=True, start_highlight_timer=True)

def highlight_matches(self):
"""Highlight found results"""
if self.is_code_editor and self.highlight_button.isChecked():
Expand All @@ -339,15 +361,21 @@ def highlight_matches(self):
regexp = self.re_button.isChecked()
self.editor.highlight_found_results(text, words=words,
regexp=regexp)

def clear_matches(self):
"""Clear all highlighted matches"""
if self.is_code_editor:
self.editor.clear_found_results()

def find(self, changed=True, forward=True,
rehighlight=True, start_highlight_timer=False):
rehighlight=True, start_highlight_timer=False, multiline_replace_check=True):
"""Call the find function"""
# When several lines are selected in the editor and replace box is activated,
# dynamic search is deactivated to prevent changing the selection. Otherwise
# we show matching items.
if multiline_replace_check and self.replace_widgets[0].isVisible() and \
len(to_text_string(self.editor.get_selected_text()).splitlines())>1:
return None
text = self.search_text.currentText()
if len(text) == 0:
self.search_text.lineEdit().setStyleSheet("")
Expand All @@ -374,7 +402,7 @@ def find(self, changed=True, forward=True,
return found

@Slot()
def replace_find(self, focus_replace_text=False):
def replace_find(self, focus_replace_text=False, replace_all=False):
"""Replace and find"""
if (self.editor is not None):
replace_text = to_text_string(self.replace_text.currentText())
Expand Down Expand Up @@ -444,10 +472,60 @@ def replace_find(self, focus_replace_text=False):
QTextCursor.KeepAnchor)
else:
break
if not self.all_check.isChecked():
if not replace_all:
break
self.all_check.setCheckState(Qt.Unchecked)
if cursor is not None:
cursor.endEditBlock()
if focus_replace_text:
self.replace_text.setFocus()

@Slot()
def replace_find_all(self, focus_replace_text=False):
"""Replace and find all matching occurrences"""
self.replace_find(focus_replace_text, replace_all=True)


@Slot()
def replace_find_selection(self, focus_replace_text=False):
Copy link
Member

@rlaverde rlaverde Jun 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if using a custom search is the best way, because the other searching methods use the findText function of QWebEngineView (they also use python regex when regex checkbox is activated).

Although I'm not sure if this could be implemented using QWebEngineView.findText because it doesn't support searching in a selection, also multiple selection should be managed adding more complexity.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True that there is a small risk that there are different results for the two regex implementations. I think the risk is relatively small because we don't highlight find results when the user chooses a selection. And there's always UNDO...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually a bigger inconsistency is that I didn't implement the whole word restriction. DOH! And it might ultimately be simpler to use the QT implementation because python's re doesn't handle whole words. Let me give this some thought over the weekend.

"""Replace and find in the current selection"""
def _word_replacer(match):
"""Used to replace only match objects that are whole
words when the user has selected whole word search"""
unchanged = match.group(0)
if match.start(0)>0 and re.search(r'\w', seltxt[match.start(0)-1], flags=word_flags):
return unchanged
if match.end(0)<len(seltxt) and re.search(r'\w', seltxt[match.end(0)+1], flags=word_flags):
return unchanged
if re.search(r'\W', unchanged, flags=word_flags):
return unchanged
return match.expand(replace_text)

if (self.editor is not None):
replace_text = to_text_string(self.replace_text.currentText())
search_text = to_text_string(self.search_text.currentText())
pattern = search_text if self.re_button.isChecked() else None
case = self.case_button.isChecked()
words = self.words_button.isChecked()
re_flags = re.MULTILINE if case else re.IGNORECASE|re.MULTILINE

cursor = self.editor.textCursor()
cursor.beginEditBlock()
seltxt = to_text_string(self.editor.get_selected_text())
if not pattern:
pattern = re.escape(search_text)
replace_text = re.escape(replace_text)
if words:
#If whole words is checked we need to check that each match
#is actually a whole word before replacing
word_flags = 0 if case else re.IGNORECASE
replacement = re.sub(pattern, _word_replacer, seltxt, flags=re_flags)
Copy link
Member

@rlaverde rlaverde Jun 27, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why _word_replacer is needed? I think this could be achieved changing the pattern, like other functions do:

if words:
    pattern = r"\b{}\b".format(pattern)

else:
replacement = re.sub(pattern, replace_text, seltxt, flags=re_flags)
if replacement != seltxt:
cursor.removeSelectedText()
cursor.insertText(replacement)
cursor.endEditBlock()
if focus_replace_text:
self.replace_text.setFocus()
else:
self.editor.setFocus()