mirror of
https://github.com/python/cpython.git
synced 2026-07-24 02:46:51 -04:00
5de11c6eba
gh-71956: Fix IDLE Replace All searching up without wrap around (GH-152737)
When the search direction is "Up" and "Wrap around" is off, Replace All
replaced only the first match above the current position (and all matches
below it). It now replaces all matches from the start of the text down to
the current position, consistently with the "Up" direction.
(cherry picked from commit ef27e5b310)
Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
313 lines
10 KiB
Python
313 lines
10 KiB
Python
"""Replace dialog for IDLE. Inherits SearchDialogBase for GUI.
|
|
Uses idlelib.searchengine.SearchEngine for search capability.
|
|
Defines various replace related functions like replace, replace all,
|
|
and replace+find.
|
|
"""
|
|
import re
|
|
|
|
from tkinter import StringVar, TclError
|
|
|
|
from idlelib.searchbase import SearchDialogBase
|
|
from idlelib import searchengine
|
|
|
|
|
|
def replace(text, insert_tags=None):
|
|
"""Create or reuse a singleton ReplaceDialog instance.
|
|
|
|
The singleton dialog saves user entries and preferences
|
|
across instances.
|
|
|
|
Args:
|
|
text: Text widget containing the text to be searched.
|
|
"""
|
|
root = text._root()
|
|
engine = searchengine.get(root)
|
|
if not hasattr(engine, "_replacedialog"):
|
|
engine._replacedialog = ReplaceDialog(root, engine)
|
|
dialog = engine._replacedialog
|
|
searchphrase = text.get("sel.first", "sel.last")
|
|
dialog.open(text, searchphrase, insert_tags=insert_tags)
|
|
|
|
|
|
class ReplaceDialog(SearchDialogBase):
|
|
"Dialog for finding and replacing a pattern in text."
|
|
|
|
title = "Replace Dialog"
|
|
icon = "Replace"
|
|
|
|
def __init__(self, root, engine):
|
|
"""Create search dialog for finding and replacing text.
|
|
|
|
Uses SearchDialogBase as the basis for the GUI and a
|
|
searchengine instance to prepare the search.
|
|
|
|
Attributes:
|
|
replvar: StringVar containing 'Replace with:' value.
|
|
replent: Entry widget for replvar. Created in
|
|
create_entries().
|
|
ok: Boolean used in searchengine.search_text to indicate
|
|
whether the search includes the selection.
|
|
"""
|
|
super().__init__(root, engine)
|
|
self.replvar = StringVar(root)
|
|
self.insert_tags = None
|
|
|
|
def open(self, text, searchphrase=None, *, insert_tags=None):
|
|
"""Make dialog visible on top of others and ready to use.
|
|
|
|
Also, set the search to include the current selection
|
|
(self.ok).
|
|
|
|
Args:
|
|
text: Text widget being searched.
|
|
searchphrase: String phrase to search.
|
|
"""
|
|
SearchDialogBase.open(self, text, searchphrase)
|
|
self.ok = True
|
|
self.insert_tags = insert_tags
|
|
|
|
def create_entries(self):
|
|
"Create base and additional label and text entry widgets."
|
|
SearchDialogBase.create_entries(self)
|
|
self.replent = self.make_entry("Replace with:", self.replvar)[0]
|
|
|
|
def create_command_buttons(self):
|
|
"""Create base and additional command buttons.
|
|
|
|
The additional buttons are for Find, Replace,
|
|
Replace+Find, and Replace All.
|
|
"""
|
|
SearchDialogBase.create_command_buttons(self)
|
|
self.make_button("Find", self.find_it)
|
|
self.make_button("Replace", self.replace_it)
|
|
self.make_button("Replace+Find", self.default_command, isdef=True)
|
|
self.make_button("Replace All", self.replace_all)
|
|
|
|
def find_it(self, event=None):
|
|
"Handle the Find button."
|
|
self.do_find(False)
|
|
|
|
def replace_it(self, event=None):
|
|
"""Handle the Replace button.
|
|
|
|
If the find is successful, then perform replace.
|
|
"""
|
|
if self.do_find(self.ok):
|
|
self.do_replace()
|
|
|
|
def default_command(self, event=None):
|
|
"""Handle the Replace+Find button as the default command.
|
|
|
|
First performs a replace and then, if the replace was
|
|
successful, a find next.
|
|
"""
|
|
if self.do_find(self.ok):
|
|
if self.do_replace(): # Only find next match if replace succeeded.
|
|
# A bad re can cause it to fail.
|
|
self.do_find(False)
|
|
|
|
def _replace_expand(self, m, repl):
|
|
"Expand replacement text if regular expression."
|
|
if self.engine.isre():
|
|
try:
|
|
new = m.expand(repl)
|
|
except re.PatternError:
|
|
self.engine.report_error(repl, 'Invalid Replace Expression')
|
|
new = None
|
|
else:
|
|
new = repl
|
|
|
|
return new
|
|
|
|
def replace_all(self, event=None):
|
|
"""Handle the Replace All button.
|
|
|
|
Search text for occurrences of the Find value and replace each
|
|
of them. The 'wrap around' and direction values control which
|
|
occurrences are replaced. With wrap around, every occurrence is
|
|
replaced. Without it, a forward search replaces occurrences from
|
|
the current position to the end of the text, and a backward search
|
|
replaces occurrences from the beginning of the text to the current
|
|
position. The replacement is always done top-to-bottom.
|
|
"""
|
|
prog = self.engine.getprog()
|
|
if not prog:
|
|
return
|
|
repl = self.replvar.get()
|
|
text = self.text
|
|
res = self.engine.search_text(text, prog)
|
|
if not res:
|
|
self.bell()
|
|
return
|
|
text.tag_remove("sel", "1.0", "end")
|
|
text.tag_remove("hit", "1.0", "end")
|
|
line = res[0]
|
|
col = res[1].start()
|
|
# For a backward search without wrap, replace top-to-bottom from
|
|
# the start of the text down to the first match at or above the
|
|
# current position (gh-71956). A mark tracks that stop point.
|
|
stop = None
|
|
if self.engine.iswrap():
|
|
line = 1
|
|
col = 0
|
|
elif self.engine.isback():
|
|
stop = "replace_all_stop"
|
|
text.mark_set(stop, "%d.%d" % (line, res[1].end()))
|
|
line = 1
|
|
col = 0
|
|
ok = True
|
|
first = last = None
|
|
# XXX ought to replace circular instead of top-to-bottom when wrapping
|
|
text.undo_block_start()
|
|
while res := self.engine.search_forward(
|
|
text, prog, line, col, wrap=False, ok=ok):
|
|
line, m = res
|
|
i, j = m.span()
|
|
if stop is not None and text.compare("%d.%d" % (line, i), ">=", stop):
|
|
break
|
|
orig = m.group()
|
|
new = self._replace_expand(m, repl)
|
|
if new is None:
|
|
break
|
|
first = "%d.%d" % (line, i)
|
|
last = "%d.%d" % (line, j)
|
|
if new == orig:
|
|
text.mark_set("insert", last)
|
|
else:
|
|
text.mark_set("insert", first)
|
|
if first != last:
|
|
text.delete(first, last)
|
|
if new:
|
|
text.insert(first, new, self.insert_tags)
|
|
col = i + len(new)
|
|
ok = False
|
|
if stop is not None:
|
|
text.mark_unset(stop)
|
|
text.undo_block_stop()
|
|
if first and last:
|
|
self.show_hit(first, last)
|
|
self.close()
|
|
|
|
def do_find(self, ok=False):
|
|
"""Search for and highlight next occurrence of pattern in text.
|
|
|
|
No text replacement is done with this option.
|
|
"""
|
|
if not self.engine.getprog():
|
|
return False
|
|
text = self.text
|
|
res = self.engine.search_text(text, None, ok)
|
|
if not res:
|
|
self.bell()
|
|
return False
|
|
line, m = res
|
|
i, j = m.span()
|
|
first = "%d.%d" % (line, i)
|
|
last = "%d.%d" % (line, j)
|
|
self.show_hit(first, last)
|
|
self.ok = True
|
|
return True
|
|
|
|
def do_replace(self):
|
|
"Replace search pattern in text with replacement value."
|
|
prog = self.engine.getprog()
|
|
if not prog:
|
|
return False
|
|
text = self.text
|
|
try:
|
|
first = pos = text.index("sel.first")
|
|
last = text.index("sel.last")
|
|
except TclError:
|
|
pos = None
|
|
if not pos:
|
|
first = last = pos = text.index("insert")
|
|
line, col = searchengine.get_line_col(pos)
|
|
chars = text.get("%d.0" % line, "%d.0" % (line+1))
|
|
m = prog.match(chars, col)
|
|
if not prog:
|
|
return False
|
|
new = self._replace_expand(m, self.replvar.get())
|
|
if new is None:
|
|
return False
|
|
text.mark_set("insert", first)
|
|
text.undo_block_start()
|
|
if m.group():
|
|
text.delete(first, last)
|
|
if new:
|
|
text.insert(first, new, self.insert_tags)
|
|
text.undo_block_stop()
|
|
self.show_hit(first, text.index("insert"))
|
|
self.ok = False
|
|
return True
|
|
|
|
def show_hit(self, first, last):
|
|
"""Highlight text between first and last indices.
|
|
|
|
Text is highlighted via the 'hit' tag and the marked
|
|
section is brought into view.
|
|
|
|
The colors from the 'hit' tag aren't currently shown
|
|
when the text is displayed. This is due to the 'sel'
|
|
tag being added first, so the colors in the 'sel'
|
|
config are seen instead of the colors for 'hit'.
|
|
"""
|
|
text = self.text
|
|
text.mark_set("insert", first)
|
|
text.tag_remove("sel", "1.0", "end")
|
|
text.tag_add("sel", first, last)
|
|
text.tag_remove("hit", "1.0", "end")
|
|
if first == last:
|
|
text.tag_add("hit", first)
|
|
else:
|
|
text.tag_add("hit", first, last)
|
|
text.see("insert")
|
|
text.update_idletasks()
|
|
|
|
def close(self, event=None):
|
|
"Close the dialog and remove hit tags."
|
|
SearchDialogBase.close(self, event)
|
|
self.text.tag_remove("hit", "1.0", "end")
|
|
self.insert_tags = None
|
|
|
|
|
|
def _replace_dialog(parent): # htest #
|
|
from tkinter import Toplevel, Text, END, SEL
|
|
from tkinter.ttk import Frame, Button
|
|
|
|
top = Toplevel(parent)
|
|
top.title("Test ReplaceDialog")
|
|
x, y = map(int, parent.geometry().split('+')[1:])
|
|
top.geometry("+%d+%d" % (x, y + 175))
|
|
|
|
# mock undo delegator methods
|
|
def undo_block_start():
|
|
pass
|
|
|
|
def undo_block_stop():
|
|
pass
|
|
|
|
frame = Frame(top)
|
|
frame.pack()
|
|
text = Text(frame, inactiveselectbackground='gray')
|
|
text.undo_block_start = undo_block_start
|
|
text.undo_block_stop = undo_block_stop
|
|
text.pack()
|
|
text.insert("insert","This is a sample sTring\nPlus MORE.")
|
|
text.focus_set()
|
|
|
|
def show_replace():
|
|
text.tag_add(SEL, "1.0", END)
|
|
replace(text)
|
|
text.tag_remove(SEL, "1.0", END)
|
|
|
|
button = Button(frame, text="Replace", command=show_replace)
|
|
button.pack()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
from unittest import main
|
|
main('idlelib.idle_test.test_replace', verbosity=2, exit=False)
|
|
|
|
from idlelib.idle_test.htest import run
|
|
run(_replace_dialog)
|