diff --git a/Lib/idlelib/README.txt b/Lib/idlelib/README.txt index c784a1a0b6133bf..926dbdcade474d9 100644 --- a/Lib/idlelib/README.txt +++ b/Lib/idlelib/README.txt @@ -67,15 +67,15 @@ pyparse.py # Give information on code indentation pyshell.py # Start IDLE, manage shell, complete editor window query.py # Query user for information redirector.py # Intercept widget subcommands (for percolator) (nim). -replace.py # Search and replace pattern in text. rpc.py # Commuicate between idle and user processes (nim). rstrip.py # Strip trailing whitespace. run.py # Manage user code execution subprocess. runscript.py # Check and run user code. scrolledlist.py # Define scrolledlist widget for IDLE (nim). -search.py # Search for pattern in text. +searchbar.py # Search for pattern in text. searchbase.py # Define base for search, replace, and grep dialogs. searchengine.py # Define engine for all 3 search dialogs. +squeezer.py # "Squeeze" shell outputs and errors. stackviewer.py # View stack after exception. statusbar.py # Define status bar for windows (nim). tabbedpages.py # Define tabbed pages widget (nim). @@ -83,6 +83,7 @@ textview.py # Define read-only text widget (nim). tree.py # Define tree widger, used in browsers (nim). undo.py # Manage undo stack. windows.py # Manage window list and define listed top level. +windowsearchengine.py # Per-window search engine. zoomheight.py # Zoom window to full height of screen. Configuration @@ -148,11 +149,11 @@ Edit Paste # eEW.past Select All # eEW.select_all (+ see eEW.remove_selection) --- # Next 5 items use searchengine; dialogs use searchbase - Find # eEW.find_event, search.SearchDialog.find - Find Again # eEW.find_again_event, sSD.find_again - Find Selection # eEW.find_selection_event, sSD.find_selection + Find # searchbar.FindBar.show_event + Find Again # searchbar.FindBar.search_again_event + Find Selection # searchbar.FindBar.search_selection_event Find in Files... # eEW.find_in_files_event, grep - Replace... # eEW.replace_event, replace.ReplaceDialog.replace + Replace... # searchbar.ReplaceBar.show_event Go to Line # eEW.goto_line_event Show Completions # autocomplete extension and autocompleteWidow (&HP) Expand Word # autoexpand extension diff --git a/Lib/idlelib/config-main.def b/Lib/idlelib/config-main.def index 06e3c5adb0e35b6..d8796946cdafd45 100644 --- a/Lib/idlelib/config-main.def +++ b/Lib/idlelib/config-main.def @@ -89,3 +89,6 @@ name2= cyclic=1 [HelpFiles] + +[Search] +is-incremental= 1 diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py index 229dc8987433225..3d1a47f65694001 100644 --- a/Lib/idlelib/configdialog.py +++ b/Lib/idlelib/configdialog.py @@ -30,12 +30,13 @@ from idlelib.codecontext import CodeContext from idlelib.parenmatch import ParenMatch from idlelib.paragraph import FormatParagraph +from idlelib.searchbar import SearchBar from idlelib.squeezer import Squeezer changes = ConfigChanges() # Reload changed options in the following classes. reloadables = (AutoComplete, CodeContext, ParenMatch, FormatParagraph, - Squeezer) + SearchBar, Squeezer) class ConfigDialog(Toplevel): @@ -1819,6 +1820,9 @@ def create_page_general(self): frame_context: Frame context_title: Label (*)context_int: Entry - context_lines + frame_search: LabelFrame + frame_incremental_search: Frame + (*)incremental_search_on: Checkbutton - incremental_search frame_shell: LabelFrame frame_auto_squeeze_min_lines: Frame auto_squeeze_min_lines_title: Label @@ -1848,6 +1852,9 @@ def create_page_general(self): self.paren_bell = tracers.add( BooleanVar(self), ('extensions', 'ParenMatch', 'bell')) + self.incremental_search = tracers.add( + BooleanVar(self), ('main', 'Search', 'is-incremental')) + self.auto_squeeze_min_lines = tracers.add( StringVar(self), ('main', 'PyShell', 'auto-squeeze-min-lines')) @@ -1864,6 +1871,8 @@ def create_page_general(self): text=' Window Preferences') frame_editor = LabelFrame(self, borderwidth=2, relief=GROOVE, text=' Editor Preferences') + frame_search = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Search Preferences') frame_shell = LabelFrame(self, borderwidth=2, relief=GROOVE, text=' Shell Preferences') frame_help = LabelFrame(self, borderwidth=2, relief=GROOVE, @@ -1929,6 +1938,14 @@ def create_page_general(self): self.context_int = Entry( frame_context, textvariable=self.context_lines, width=3) + # Frame_search. + frame_incremental_search = Frame(frame_search, borderwidth=0) + self.incremental_search_on = Checkbutton( + frame_incremental_search, + text='Incremental Search (as-you-type)', + variable=self.incremental_search, + ) + # Frame_shell. frame_auto_squeeze_min_lines = Frame(frame_shell, borderwidth=0) auto_squeeze_min_lines_title = Label(frame_auto_squeeze_min_lines, @@ -1961,6 +1978,7 @@ def create_page_general(self): # Body. frame_window.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) frame_editor.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) + frame_search.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) frame_shell.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) frame_help.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) # frame_run. @@ -2002,6 +2020,10 @@ def create_page_general(self): context_title.pack(side=LEFT, anchor=W, padx=5, pady=5) self.context_int.pack(side=TOP, padx=5, pady=5) + # frame_incremental_search + frame_incremental_search.pack(side=TOP, padx=5, pady=0, fill=X) + self.incremental_search_on.pack(side=LEFT, padx=5, pady=5) + # frame_auto_squeeze_min_lines frame_auto_squeeze_min_lines.pack(side=TOP, padx=5, pady=0, fill=X) auto_squeeze_min_lines_title.pack(side=LEFT, anchor=W, padx=5, pady=5) @@ -2042,6 +2064,10 @@ def load_general_cfg(self): self.context_lines.set(idleConf.GetOption( 'extensions', 'CodeContext', 'maxlines', type='int')) + # Set variables for search. + self.incremental_search.set(idleConf.GetOption( + 'main', 'Search', 'is-incremental', type='bool')) + # Set variables for shell windows. self.auto_squeeze_min_lines.set(idleConf.GetOption( 'main', 'PyShell', 'auto-squeeze-min-lines', type='int')) diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 6689af64c429be1..4fcbbf7b45a95e4 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -21,8 +21,6 @@ from idlelib.multicall import MultiCallCreator from idlelib import pyparse from idlelib import query -from idlelib import replace -from idlelib import search from idlelib import window # The default tab setting for a Text widget, in average-width characters. @@ -56,6 +54,7 @@ class EditorWindow(object): from idlelib.paragraph import FormatParagraph from idlelib.parenmatch import ParenMatch from idlelib.rstrip import Rstrip + from idlelib.searchbar import SearchBar from idlelib.squeezer import Squeezer from idlelib.zoomheight import ZoomHeight @@ -161,11 +160,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None): text.bind("<>", lambda event: "break") text.bind("<>", self.select_all) text.bind("<>", self.remove_selection) - text.bind("<>", self.find_event) - text.bind("<>", self.find_again_event) text.bind("<>", self.find_in_files_event) - text.bind("<>", self.find_selection_event) - text.bind("<>", self.replace_event) text.bind("<>", self.goto_line_event) text.bind("<>",self.smart_backspace_event) text.bind("<>",self.newline_and_indent_event) @@ -317,6 +312,11 @@ def __init__(self, flist=None, filename=None, key=None, root=None): text.bind("<>", self.ZoomHeight(self).zoom_height_event) text.bind("<>", self.CodeContext(self).toggle_code_context_event) + searchbar = self.SearchBar(self) + text.bind("<>", searchbar.search_event) + text.bind("<>", searchbar.search_again_event) + text.bind("<>", searchbar.search_selection_event) + text.bind("<>", searchbar.replace_event) squeezer = self.Squeezer(self) text.bind("<>", squeezer.squeeze_current_text_event) @@ -627,26 +627,10 @@ def del_word_right(self, event): self.text.event_generate('') return "break" - def find_event(self, event): - search.find(self.text) - return "break" - - def find_again_event(self, event): - search.find_again(self.text) - return "break" - - def find_selection_event(self, event): - search.find_selection(self.text) - return "break" - def find_in_files_event(self, event): grep.grep(self.text, self.io, self.flist) return "break" - def replace_event(self, event): - replace.replace(self.text) - return "break" - def goto_line_event(self, event): text = self.text lineno = tkSimpleDialog.askinteger("Goto", diff --git a/Lib/idlelib/idle_test/htest.py b/Lib/idlelib/idle_test/htest.py index 8c1c24d070cc8fd..16b09f18a6c19a2 100644 --- a/Lib/idlelib/idle_test/htest.py +++ b/Lib/idlelib/idle_test/htest.py @@ -253,29 +253,6 @@ def _wrapper(parent): # htest # } -_replace_dialog_spec = { - 'file': 'replace', - 'kwds': {}, - 'msg': "Click the 'Replace' button.\n" - "Test various replace options in the 'Replace dialog'.\n" - "Click [Close] or [X] to close the 'Replace Dialog'." - } - -_search_dialog_spec = { - 'file': 'search', - 'kwds': {}, - 'msg': "Click the 'Search' button.\n" - "Test various search options in the 'Search dialog'.\n" - "Click [Close] or [X] to close the 'Search Dialog'." - } - -_searchbase_spec = { - 'file': 'searchbase', - 'kwds': {}, - 'msg': "Check the appearance of the base search dialog\n" - "Its only action is to close." - } - _scrolled_list_spec = { 'file': 'scrolledlist', 'kwds': {}, diff --git a/Lib/idlelib/idle_test/test_replace.py b/Lib/idlelib/idle_test/test_replace.py deleted file mode 100644 index c3c5d2eeb94998b..000000000000000 --- a/Lib/idlelib/idle_test/test_replace.py +++ /dev/null @@ -1,294 +0,0 @@ -"Test replace, coverage 78%." - -from idlelib.replace import ReplaceDialog -import unittest -from test.support import requires -requires('gui') -from tkinter import Tk, Text - -from unittest.mock import Mock -from idlelib.idle_test.mock_tk import Mbox -import idlelib.searchengine as se - -orig_mbox = se.tkMessageBox -showerror = Mbox.showerror - - -class ReplaceDialogTest(unittest.TestCase): - - @classmethod - def setUpClass(cls): - cls.root = Tk() - cls.root.withdraw() - se.tkMessageBox = Mbox - cls.engine = se.SearchEngine(cls.root) - cls.dialog = ReplaceDialog(cls.root, cls.engine) - cls.dialog.bell = lambda: None - cls.dialog.ok = Mock() - cls.text = Text(cls.root) - cls.text.undo_block_start = Mock() - cls.text.undo_block_stop = Mock() - cls.dialog.text = cls.text - - @classmethod - def tearDownClass(cls): - se.tkMessageBox = orig_mbox - del cls.text, cls.dialog, cls.engine - cls.root.destroy() - del cls.root - - def setUp(self): - self.text.insert('insert', 'This is a sample sTring') - - def tearDown(self): - self.engine.patvar.set('') - self.dialog.replvar.set('') - self.engine.wordvar.set(False) - self.engine.casevar.set(False) - self.engine.revar.set(False) - self.engine.wrapvar.set(True) - self.engine.backvar.set(False) - showerror.title = '' - showerror.message = '' - self.text.delete('1.0', 'end') - - def test_replace_simple(self): - # Test replace function with all options at default setting. - # Wrap around - True - # Regular Expression - False - # Match case - False - # Match word - False - # Direction - Forwards - text = self.text - equal = self.assertEqual - pv = self.engine.patvar - rv = self.dialog.replvar - replace = self.dialog.replace_it - - # test accessor method - self.engine.setpat('asdf') - equal(self.engine.getpat(), pv.get()) - - # text found and replaced - pv.set('a') - rv.set('asdf') - replace() - equal(text.get('1.8', '1.12'), 'asdf') - - # don't "match word" case - text.mark_set('insert', '1.0') - pv.set('is') - rv.set('hello') - replace() - equal(text.get('1.2', '1.7'), 'hello') - - # don't "match case" case - pv.set('string') - rv.set('world') - replace() - equal(text.get('1.23', '1.28'), 'world') - - # without "regular expression" case - text.mark_set('insert', 'end') - text.insert('insert', '\nline42:') - before_text = text.get('1.0', 'end') - pv.set(r'[a-z][\d]+') - replace() - after_text = text.get('1.0', 'end') - equal(before_text, after_text) - - # test with wrap around selected and complete a cycle - text.mark_set('insert', '1.9') - pv.set('i') - rv.set('j') - replace() - equal(text.get('1.8'), 'i') - equal(text.get('2.1'), 'j') - replace() - equal(text.get('2.1'), 'j') - equal(text.get('1.8'), 'j') - before_text = text.get('1.0', 'end') - replace() - after_text = text.get('1.0', 'end') - equal(before_text, after_text) - - # text not found - before_text = text.get('1.0', 'end') - pv.set('foobar') - replace() - after_text = text.get('1.0', 'end') - equal(before_text, after_text) - - # test access method - self.dialog.find_it(0) - - def test_replace_wrap_around(self): - text = self.text - equal = self.assertEqual - pv = self.engine.patvar - rv = self.dialog.replvar - replace = self.dialog.replace_it - self.engine.wrapvar.set(False) - - # replace candidate found both after and before 'insert' - text.mark_set('insert', '1.4') - pv.set('i') - rv.set('j') - replace() - equal(text.get('1.2'), 'i') - equal(text.get('1.5'), 'j') - replace() - equal(text.get('1.2'), 'i') - equal(text.get('1.20'), 'j') - replace() - equal(text.get('1.2'), 'i') - - # replace candidate found only before 'insert' - text.mark_set('insert', '1.8') - pv.set('is') - before_text = text.get('1.0', 'end') - replace() - after_text = text.get('1.0', 'end') - equal(before_text, after_text) - - def test_replace_whole_word(self): - text = self.text - equal = self.assertEqual - pv = self.engine.patvar - rv = self.dialog.replvar - replace = self.dialog.replace_it - self.engine.wordvar.set(True) - - pv.set('is') - rv.set('hello') - replace() - equal(text.get('1.0', '1.4'), 'This') - equal(text.get('1.5', '1.10'), 'hello') - - def test_replace_match_case(self): - equal = self.assertEqual - text = self.text - pv = self.engine.patvar - rv = self.dialog.replvar - replace = self.dialog.replace_it - self.engine.casevar.set(True) - - before_text = self.text.get('1.0', 'end') - pv.set('this') - rv.set('that') - replace() - after_text = self.text.get('1.0', 'end') - equal(before_text, after_text) - - pv.set('This') - replace() - equal(text.get('1.0', '1.4'), 'that') - - def test_replace_regex(self): - equal = self.assertEqual - text = self.text - pv = self.engine.patvar - rv = self.dialog.replvar - replace = self.dialog.replace_it - self.engine.revar.set(True) - - before_text = text.get('1.0', 'end') - pv.set(r'[a-z][\d]+') - rv.set('hello') - replace() - after_text = text.get('1.0', 'end') - equal(before_text, after_text) - - text.insert('insert', '\nline42') - replace() - equal(text.get('2.0', '2.8'), 'linhello') - - pv.set('') - replace() - self.assertIn('error', showerror.title) - self.assertIn('Empty', showerror.message) - - pv.set(r'[\d') - replace() - self.assertIn('error', showerror.title) - self.assertIn('Pattern', showerror.message) - - showerror.title = '' - showerror.message = '' - pv.set('[a]') - rv.set('test\\') - replace() - self.assertIn('error', showerror.title) - self.assertIn('Invalid Replace Expression', showerror.message) - - # test access method - self.engine.setcookedpat("?") - equal(pv.get(), "\\?") - - def test_replace_backwards(self): - equal = self.assertEqual - text = self.text - pv = self.engine.patvar - rv = self.dialog.replvar - replace = self.dialog.replace_it - self.engine.backvar.set(True) - - text.insert('insert', '\nis as ') - - pv.set('is') - rv.set('was') - replace() - equal(text.get('1.2', '1.4'), 'is') - equal(text.get('2.0', '2.3'), 'was') - replace() - equal(text.get('1.5', '1.8'), 'was') - replace() - equal(text.get('1.2', '1.5'), 'was') - - def test_replace_all(self): - text = self.text - pv = self.engine.patvar - rv = self.dialog.replvar - replace_all = self.dialog.replace_all - - text.insert('insert', '\n') - text.insert('insert', text.get('1.0', 'end')*100) - pv.set('is') - rv.set('was') - replace_all() - self.assertNotIn('is', text.get('1.0', 'end')) - - self.engine.revar.set(True) - pv.set('') - replace_all() - self.assertIn('error', showerror.title) - self.assertIn('Empty', showerror.message) - - pv.set('[s][T]') - rv.set('\\') - replace_all() - - self.engine.revar.set(False) - pv.set('text which is not present') - rv.set('foobar') - replace_all() - - def test_default_command(self): - text = self.text - pv = self.engine.patvar - rv = self.dialog.replvar - replace_find = self.dialog.default_command - equal = self.assertEqual - - pv.set('This') - rv.set('was') - replace_find() - equal(text.get('sel.first', 'sel.last'), 'was') - - self.engine.revar.set(True) - pv.set('') - replace_find() - - -if __name__ == '__main__': - unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_search.py b/Lib/idlelib/idle_test/test_search.py deleted file mode 100644 index de703c195cd2290..000000000000000 --- a/Lib/idlelib/idle_test/test_search.py +++ /dev/null @@ -1,80 +0,0 @@ -"Test search, coverage 69%." - -from idlelib import search -import unittest -from test.support import requires -requires('gui') -from tkinter import Tk, Text, BooleanVar -from idlelib import searchengine - -# Does not currently test the event handler wrappers. -# A usage test should simulate clicks and check highlighting. -# Tests need to be coordinated with SearchDialogBase tests -# to avoid duplication. - - -class SearchDialogTest(unittest.TestCase): - - @classmethod - def setUpClass(cls): - cls.root = Tk() - - @classmethod - def tearDownClass(cls): - cls.root.destroy() - del cls.root - - def setUp(self): - self.engine = searchengine.SearchEngine(self.root) - self.dialog = search.SearchDialog(self.root, self.engine) - self.dialog.bell = lambda: None - self.text = Text(self.root) - self.text.insert('1.0', 'Hello World!') - - def test_find_again(self): - # Search for various expressions - text = self.text - - self.engine.setpat('') - self.assertFalse(self.dialog.find_again(text)) - self.dialog.bell = lambda: None - - self.engine.setpat('Hello') - self.assertTrue(self.dialog.find_again(text)) - - self.engine.setpat('Goodbye') - self.assertFalse(self.dialog.find_again(text)) - - self.engine.setpat('World!') - self.assertTrue(self.dialog.find_again(text)) - - self.engine.setpat('Hello World!') - self.assertTrue(self.dialog.find_again(text)) - - # Regular expression - self.engine.revar = BooleanVar(self.root, True) - self.engine.setpat('W[aeiouy]r') - self.assertTrue(self.dialog.find_again(text)) - - def test_find_selection(self): - # Select some text and make sure it's found - text = self.text - # Add additional line to find - self.text.insert('2.0', 'Hello World!') - - text.tag_add('sel', '1.0', '1.4') # Select 'Hello' - self.assertTrue(self.dialog.find_selection(text)) - - text.tag_remove('sel', '1.0', 'end') - text.tag_add('sel', '1.6', '1.11') # Select 'World!' - self.assertTrue(self.dialog.find_selection(text)) - - text.tag_remove('sel', '1.0', 'end') - text.tag_add('sel', '1.0', '1.11') # Select 'Hello World!' - self.assertTrue(self.dialog.find_selection(text)) - - # Remove additional line - text.delete('2.0', 'end') - -if __name__ == '__main__': - unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/replace.py b/Lib/idlelib/replace.py deleted file mode 100644 index 83cf98756bdf812..000000000000000 --- a/Lib/idlelib/replace.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Replace dialog for IDLE. Inherits SearchDialogBase for GUI. -Uses idlelib.SearchEngine for search capability. -Defines various replace related functions like replace, replace all, -replace+find. -""" -import re - -from tkinter import StringVar, TclError - -from idlelib.searchbase import SearchDialogBase -from idlelib import searchengine - -def replace(text): - """Returns a singleton ReplaceDialog instance.The single dialog - saves user entries and preferences across instances.""" - root = text._root() - engine = searchengine.get(root) - if not hasattr(engine, "_replacedialog"): - engine._replacedialog = ReplaceDialog(root, engine) - dialog = engine._replacedialog - dialog.open(text) - - -class ReplaceDialog(SearchDialogBase): - - title = "Replace Dialog" - icon = "Replace" - - def __init__(self, root, engine): - SearchDialogBase.__init__(self, root, engine) - self.replvar = StringVar(root) - - def open(self, text): - """Display the replace dialog""" - SearchDialogBase.open(self, text) - try: - first = text.index("sel.first") - except TclError: - first = None - try: - last = text.index("sel.last") - except TclError: - last = None - first = first or text.index("insert") - last = last or first - self.show_hit(first, last) - self.ok = 1 - - def create_entries(self): - """Create label and text entry widgets""" - SearchDialogBase.create_entries(self) - self.replent = self.make_entry("Replace with:", self.replvar)[0] - - def create_command_buttons(self): - 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, 1) - self.make_button("Replace All", self.replace_all) - - def find_it(self, event=None): - self.do_find(0) - - def replace_it(self, event=None): - if self.do_find(self.ok): - self.do_replace() - - def default_command(self, event=None): - "Replace and 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(0) - - def _replace_expand(self, m, repl): - """ Helper function for expanding a regular expression - in the replace field, if needed. """ - if self.engine.isre(): - try: - new = m.expand(repl) - except re.error: - self.engine.report_error(repl, 'Invalid Replace Expression') - new = None - else: - new = repl - - return new - - def replace_all(self, event=None): - """Replace all instances of patvar with replvar in text""" - 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() - if self.engine.iswrap(): - line = 1 - col = 0 - ok = 1 - first = last = None - # XXX ought to replace circular instead of top-to-bottom when wrapping - text.undo_block_start() - while 1: - res = self.engine.search_forward(text, prog, line, col, 0, ok) - if not res: - break - line, m = res - chars = text.get("%d.0" % line, "%d.0" % (line+1)) - orig = m.group() - new = self._replace_expand(m, repl) - if new is None: - break - i, j = m.span() - 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) - col = i + len(new) - ok = 0 - text.undo_block_stop() - if first and last: - self.show_hit(first, last) - self.close() - - def do_find(self, ok=0): - 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 = 1 - return True - - def do_replace(self): - 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) - text.undo_block_stop() - self.show_hit(first, text.index("insert")) - self.ok = 0 - return True - - def show_hit(self, first, last): - """Highlight text from 'first' to 'last'. - 'first', 'last' - Text indices""" - 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): - SearchDialogBase.close(self, event) - self.text.tag_remove("hit", "1.0", "end") - - -def _replace_dialog(parent): # htest # - from tkinter import Toplevel, Text, END, SEL - from tkinter.ttk import Button - - box = Toplevel(parent) - box.title("Test ReplaceDialog") - x, y = map(int, parent.geometry().split('+')[1:]) - box.geometry("+%d+%d" % (x, y + 175)) - - # mock undo delegator methods - def undo_block_start(): - pass - - def undo_block_stop(): - pass - - text = Text(box, 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(box, 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) diff --git a/Lib/idlelib/search.py b/Lib/idlelib/search.py deleted file mode 100644 index 6223661c8e080a9..000000000000000 --- a/Lib/idlelib/search.py +++ /dev/null @@ -1,101 +0,0 @@ -from tkinter import TclError - -from idlelib import searchengine -from idlelib.searchbase import SearchDialogBase - -def _setup(text): - "Create or find the singleton SearchDialog instance." - root = text._root() - engine = searchengine.get(root) - if not hasattr(engine, "_searchdialog"): - engine._searchdialog = SearchDialog(root, engine) - return engine._searchdialog - -def find(text): - "Handle the editor edit menu item and corresponding event." - pat = text.get("sel.first", "sel.last") - return _setup(text).open(text, pat) # Open is inherited from SDBase. - -def find_again(text): - "Handle the editor edit menu item and corresponding event." - return _setup(text).find_again(text) - -def find_selection(text): - "Handle the editor edit menu item and corresponding event." - return _setup(text).find_selection(text) - - -class SearchDialog(SearchDialogBase): - - def create_widgets(self): - SearchDialogBase.create_widgets(self) - self.make_button("Find Next", self.default_command, 1) - - def default_command(self, event=None): - if not self.engine.getprog(): - return - self.find_again(self.text) - - def find_again(self, text): - if not self.engine.getpat(): - self.open(text) - return False - if not self.engine.getprog(): - return False - res = self.engine.search_text(text) - if res: - line, m = res - i, j = m.span() - first = "%d.%d" % (line, i) - last = "%d.%d" % (line, j) - try: - selfirst = text.index("sel.first") - sellast = text.index("sel.last") - if selfirst == first and sellast == last: - self.bell() - return False - except TclError: - pass - text.tag_remove("sel", "1.0", "end") - text.tag_add("sel", first, last) - text.mark_set("insert", self.engine.isback() and first or last) - text.see("insert") - return True - else: - self.bell() - return False - - def find_selection(self, text): - pat = text.get("sel.first", "sel.last") - if pat: - self.engine.setcookedpat(pat) - return self.find_again(text) - - -def _search_dialog(parent): # htest # - "Display search test box." - from tkinter import Toplevel, Text - from tkinter.ttk import Button - - box = Toplevel(parent) - box.title("Test SearchDialog") - x, y = map(int, parent.geometry().split('+')[1:]) - box.geometry("+%d+%d" % (x, y + 175)) - text = Text(box, inactiveselectbackground='gray') - text.pack() - text.insert("insert","This is a sample string.\n"*5) - - def show_find(): - text.tag_add('sel', '1.0', 'end') - _setup(text).open(text) - text.tag_remove('sel', '1.0', 'end') - - button = Button(box, text="Search (selection ignored)", command=show_find) - button.pack() - -if __name__ == '__main__': - from unittest import main - main('idlelib.idle_test.test_search', verbosity=2, exit=False) - - from idlelib.idle_test.htest import run - run(_search_dialog) diff --git a/Lib/idlelib/searchbar.py b/Lib/idlelib/searchbar.py new file mode 100644 index 000000000000000..af4734b328d2c6f --- /dev/null +++ b/Lib/idlelib/searchbar.py @@ -0,0 +1,662 @@ +"""SearchBar.py - An IDLE extension for searching for text in windows. + +The interface is a small bar which appears on the bottom of the window. The +search bar is closed when the user hits Escape or when the bar loses focus, +e.g. the user clicks elsewhere. (Tab traversal outside the search bar is +disabled.) + +This extension implements the usual search options, regular expressions, and +incremental searching. Also, while searching, all matches are highlighted. + +Incremental searching can be toggled on/off in the extensions configuration. + +""" + +# TODO: +# * With incremental search, add a small delay after the search expression has +# changed before searching the entire text, so that if one wants to search +# for "self" it doesn't search for "s", then "se", then "sel" and finally +# "self". + +import re +import string + +from tkinter import Button, Checkbutton, Entry, Frame, Label, StringVar +from tkinter import TOP, BOTTOM, LEFT, RIGHT, X, NONE + +from idlelib.config import idleConf +from idlelib.delegator import Delegator +import idlelib.searchengine as searchengine +import idlelib.windowsearchengine as windowsearchengine + + +class TkTextWidgetChangeTracer(Delegator): + """Traces insert and delete operations on a Tkinter Text widget. + + To trace a Text widget, create an instance of this class and insert it + as a filter into a Percolator to which the Text widget's methods are + redirected. (Such redirection is done using the WidgetRedirector class; + see EditorWindow.py for an example.) + + The constructor accepts a single argument: a callback which will be + called whenever the Text widget's contents change. + + Additionally, an instance can be deactivated and rectivated. When + inactive, an instance will not call its callback. + + """ + def __init__(self, callback, delegate=None): + self.callback = callback + self.is_active = True + Delegator.__init__(self, delegate) + + def activate(self): + self.is_active = True + + def deactivate(self): + self.is_active = False + + def insert(self, *args, **kwargs): + if self.delegate is not None: + self.delegate.insert(*args, **kwargs) + if self.is_active: + self.callback() + + def delete(self, *args, **kwargs): + if self.delegate is not None: + self.delegate.delete(*args, **kwargs) + if self.is_active: + self.callback() + + +class SearchBar(object): + is_incremental = True + + @classmethod + def reload(cls): + cls.is_incremental = idleConf.GetOption("main", "Search", + "is-incremental", type="bool", + default=False) + + def __init__(self, editwin): + self.fb = FindBar(editwin, editwin.status_bar) + self.rb = ReplaceBar(editwin, editwin.status_bar) + + def search_event(self, event=None): + return self.fb.show_event(event) + + def search_again_event(self, event=None): + return self.fb.search_again_event(event) + + def search_selection_event(self, event=None): + return self.fb.search_selection_event(event) + + def replace_event(self, event=None): + return self.rb.show_event(event) + + +def FindBar(editwin, pack_after): + return SearchBarWidget(editwin, pack_after, is_replace=False) + + +def ReplaceBar(editwin, pack_after): + return SearchBarWidget(editwin, pack_after, is_replace=True) + + +class SearchBarWidget: + def __init__(self, editwin, pack_after, is_replace=False): + self.editwin = editwin + self.is_replace = is_replace + self.pack_after = pack_after + + self.text = editwin.text + self.root = self.text._root() + self.engine = searchengine.get(self.root) + self.window_engine = windowsearchengine.get(editwin) + + self.top = editwin.top + + self._are_widgets_built = False + self.is_shown = False + self.prev_text_takefocus_value = None + + self.find_var = StringVar(self.root) + self.find_var.trace("w", self._search_expression_changed_callback) + + self.is_safe = False + self.is_safe_tracer = TkTextWidgetChangeTracer(self._make_unsafe) + + # The text widget's selection isn't shown when it doesn't have the + # focus. The "findsel" tag looks like the selection, and will be used + # while the search bar is visible. When the search bar is hidden, the + # original selection is updated as required. + # + # This also allows saving the original selection. If the search bar is + # hidden with no selected search hit, the original selection is + # restored. + self.text.tag_configure( + "findsel", + background=self.text.tag_cget("sel", "background"), + foreground=self.text.tag_cget("sel", "foreground"), + ) + + self._hide() + + def _show(self): + if not self._are_widgets_built: + self._build_widgets() + + if not self.is_shown: + self.orig_insert = self.text.index("insert") + orig_geom = self.top.wm_geometry() + self.bar_frame.pack(side=BOTTOM, fill=X, expand=1, pady=1, + after=self.pack_after) + # Reset the window's size only if it is in 'normal' state. + # On Windows, if this is done when the window is maximized + # ('zoomed' state), then the window will not return to its + # original size when it is unmaximized. + if self.top.wm_state() == 'normal': + self.top.wm_geometry(orig_geom) + # Ensure that the insertion point is still visible + self.top.update() + self.text.see("insert") + + self.window_engine.show_find_marks() + self.is_shown = True # must be _before_ reset_selection()! + # Add the "findsel" tag, which looks like the selection + self._reset_selection() + + def _hide(self): + if self._are_widgets_built and self.is_shown: + self.bar_frame.pack_forget() + #self.window_engine.reset() + self.window_engine.hide_find_marks() + + sel = self._get_selection() + self.is_shown = False # must be _after_ get_selection()! + if sel: + self._set_selection(sel[0], sel[1]) + self.text.mark_set("insert", sel[0]) + else: + self._reset_selection() + self.text.mark_set("insert", self.orig_insert) + self.text.see("insert") + self.editwin.set_line_and_column() + + self.text.tag_remove("findsel", "1.0", "end") + + def _search_expression_changed_callback(self, *args): + if self.is_shown and SearchBar.is_incremental: + if self.engine.isre(): + self.window_engine.reset() + self._clear_selection() + self.text.see("insert") + elif self._set_search_expression(): + self._search_event(start=self.text.index("insert")) + else: + self._clear_selection() + self.text.see("insert") + + def _make_safe(self): + if not self.is_safe: + self.is_safe = True + self.editwin.per.insertfilter(self.is_safe_tracer) + + def _make_unsafe(self): + if self.is_safe: + self.is_safe = False + self.editwin.per.removefilter(self.is_safe_tracer) + + def _build_widgets(self): + if self._are_widgets_built: + return + + def _make_entry(parent, label_text, var): + """Utility function for creating Entry widgets""" + label = Label(parent, text=label_text) + label.pack(side=LEFT, fill=NONE, expand=0) + entry = Entry(parent, textvariable=var, exportselection=0, + border=1) + entry.pack(side=LEFT, fill=X, expand=1) + entry.bind("", self.hide_event) + return entry + + def _make_checkbutton(parent, label_text, var): + """Utility function for creating Checkbutton widgets""" + btn = Checkbutton(parent, anchor="w", text=label_text, + variable=var) + btn.pack(side=LEFT, fill=NONE, expand=0) + btn.bind("", self.hide_event) + return btn + + def _make_button(parent, label_text, command): + """Utility function for creating Button widgets""" + btn = Button(parent, text=label_text, command=command) + btn.pack(side=LEFT, fill=NONE, expand=0) + btn.bind("", self.hide_event) + return btn + + entries = [] + + # Frame for the entire bar + self.bar_frame = Frame(self.top, border=1, relief="flat") + + # Frame for the 'Find:' / 'Replace:' entry + search options + self.find_frame = Frame(self.bar_frame, border=0) + + # 'Find:' / 'Replace:' entry + label = "Find:" if not self.is_replace else "Replace:" + self.find_ent = _make_entry(self.find_frame, + label, self.find_var) + self.find_ent.bind("", self._search_event) + entries.append(self.find_ent) + del label + + # Match case checkbutton + case_btn = _make_checkbutton(self.find_frame, + "Match case", self.engine.casevar) + if self.engine.iscase(): + case_btn.select() + case_btn.configure(command=self._search_expression_changed_callback) + + # Regular expression checkbutton + reg_btn = _make_checkbutton(self.find_frame, + "Reg-Exp", self.engine.revar) + if self.engine.isre(): + reg_btn.select() + reg_btn.configure(command=self._search_expression_changed_callback) + + # Whole word checkbutton + word_btn = _make_checkbutton(self.find_frame, + "Whole word", self.engine.wordvar) + if self.engine.isword(): + word_btn.select() + word_btn.configure(command=self._search_expression_changed_callback) + + # Wrap checkbutton + wrap_btn = _make_checkbutton(self.find_frame, + "Wrap around", self.engine.wrapvar) + if self.engine.iswrap(): + wrap_btn.select() + + # Direction checkbutton + direction_txt_var = StringVar(self.root) + def _update_direction_button(): + direction_txt_var.set("Down" if self.engine.isback() else "Up") + direction_btn = Checkbutton(self.find_frame, + textvariable=direction_txt_var, + variable=self.engine.backvar, + command=_update_direction_button, + indicatoron=0, + width=5) + direction_btn.config(selectcolor=direction_btn.cget("bg")) + direction_btn.pack(side=RIGHT, fill=NONE, expand=0) + Label(self.find_frame, text="Direction:").pack(side=RIGHT, + fill=NONE, + expand=0) + # TODO: remove? + if self.engine.isback(): + direction_btn.select() + else: + direction_btn.deselect() + _update_direction_button() + direction_btn.bind("", self.hide_event) + + self.find_frame.pack(side=TOP, fill=X, expand=1) + + if self.is_replace: + # Frame for the 'With:' entry + replace options + self.replace_frame = Frame(self.bar_frame, border=0) + + self.replace_with_var = StringVar(self.root) + self.replace_ent = _make_entry(self.replace_frame, "With:", + self.replace_with_var) + self.replace_ent.bind("", self._replace_event) + self.replace_ent.bind("", self._search_event) + entries.append(self.replace_ent) + + _make_button(self.replace_frame, "Find", + self._search_event) + _make_button(self.replace_frame, "Replace", + self._replace_event) + _make_button(self.replace_frame, "Replace All", + self._replace_all_event) + + self.replace_frame.pack(side=TOP, fill=X, expand=1) + + self._are_widgets_built = True + + + def _make_toggle_event(button): + def event(event, button=button): + button.invoke() + return "break" + return event + + for entry in entries: + entry.bind("", self._search_event) + entry.bind("", self._search_event) + entry.bind("", _make_toggle_event(reg_btn)) + entry.bind("", _make_toggle_event(case_btn)) + entry.bind("", _make_toggle_event(wrap_btn)) + entry.bind("", _make_toggle_event(direction_btn)) + + expander = EntryExpander(entry, self.text) + expander.bind("") + + # end of _build_widgets + + def _destroy_widgets(self): + if self._are_widgets_built: + self.bar_frame.destroy() + self._are_widgets_built = False + + def __del__(self): + self._destroy_widgets() + + def show_event(self, event): + # Put the current selection in the "Find:" entry + sel = self._get_selection() + if sel: + self.find_var.set(self.text.get(sel[0], sel[1])) + + # Now show the FindBar in all it's glory! + self._show() + + # Select all of the text in the "Find:"/"Replace:" entry + self.find_ent.selection_range(0, "end") + + # Hide the findbar if the focus is lost + self.bar_frame.bind("", self.hide_event) + + # Focus traversal (Tab or Shift-Tab) shouldn't return focus to + # the text widget + self.prev_text_takefocus_value = self.text.cget("takefocus") + self.text.config(takefocus=0) + + # Set the focus to the "Find:"/"Replace:" entry + self.find_ent.focus() + return "break" + + def hide_event(self, event=None): + self._hide() + self.text.config(takefocus=self.prev_text_takefocus_value) + self.text.focus() + return "break" + + def search_again_event(self, event): + if self.engine.getpat(): + return self._search_event(event) + else: + return self.show_event(event) + + def search_selection_event(self, event): + # Get the current selection. + sel = self._get_selection() + if not sel: + # No selection; beep and leave. + self.text.bell() + return "break" + + # Set the window's search engine's pattern to the current selection. + self.find_var.set(self.text.get(sel[0], sel[1])) + + # Search, temporarily overriding all search flags. + flags = {"re": False, + "case": True, + "word": False, + "wrap": True, + "back": False} + vars = {flag: getattr(self.engine, flag + 'var') for flag in flags} + orig_values = {flag: vars[flag].get() for flag in flags} + for flag, value in flags.items(): + orig_values[flag] = vars[flag].get() + vars[flag].set(value) + try: + return self._search_event(event) + finally: + for flag in flags: + vars[flag].set(orig_values[flag]) + + def _search_text(self, start): + if not self._set_search_expression(): + return None + + direction = not self.engine.isback() + wrap = self.engine.iswrap() + sel = self._get_selection() + + # The 'search_start' variable will be used instead of 'start' when + # searching, to allow starting the search from one character forward + # when a hit is already selected, thus avoiding repeatedly returning + # the same hit. + if start is None: + if sel: + search_start = start = sel[0] + if direction and \ + self.window_engine.match_range(sel[0], sel[1]): + search_start += "+1c" + else: + search_start = start = self.text.index("insert") + else: + search_start = start + + hit = self.window_engine.findnext(search_start, direction, wrap) + + # Ring the bell if the selection was found again. + if ( + hit and sel and start == sel[0] and + self.text.compare(hit[0], '==', sel[0]) and + self.text.compare(hit[1], '==', sel[1]) + ): + self.text.bell() + + return hit + + def _search_event(self, event=None, start=None): + res = self._search_text(start) + if res: + first, last = res + self._set_selection(first, last) + self.text.see(last) + self.text.see(first) + self.text.mark_set("insert", first) + self.editwin.set_line_and_column() + else: + # Don't clear the selection for "Find Again" and "Find Selection". + if self.is_shown: + self._clear_selection() + self.text.bell() + return "break" + + def _replace_event(self, event=None): + if not self._set_search_expression(): + return "break" + + # Replace if appropriate. + sel = self._get_selection() + if sel and self.window_engine.match_range(sel[0], sel[1]): + replace_with = self.replace_with_var.get() + self.is_safe_tracer.deactivate() + self.editwin.undo.undo_block_start() + if sel[0] != sel[1]: + self.text.delete(sel[0], sel[1]) + if replace_with: + self.text.insert(sel[0], replace_with) + self.editwin.undo.undo_block_stop() + self.is_safe_tracer.activate() + self.text.mark_set("insert", sel[0] + '+%dc' % len(replace_with)) + + # Now search for the next appearance. + return self._search_event(event) + + def _replace_all_event(self, event=None): + if not self._set_search_expression(): + self.text.bell() + return "break" + + replace_with = self.replace_with_var.get() + self.is_safe_tracer.deactivate() + self.editwin.undo.undo_block_start() + n_replaced = self.window_engine.replace_all(replace_with) + self.editwin.undo.undo_block_stop() + self.is_safe_tracer.activate() + if n_replaced == 0: + self.text.bell() + return "break" + + def _set_search_expression(self): + self.engine.setpat(self.find_var.get()) + + search_exp = self._get_search_expression() + if search_exp is None: + self.window_engine.reset() + self._make_unsafe() + return False + + if not (self.is_safe and + search_exp == self.window_engine.search_expression): + self.window_engine.set_search_expression(search_exp) + self._make_safe() + return True + + def _get_regexp(self): + if not self.engine.getpat(): + # If the search expression is empty, return None. + # (otherwise self.engine.getprog() pops up an error message) + return None + + return self.engine.getprog() + + def _get_search_expression(self): + if not self.engine.getpat(): + # If the search expression is empty, return None. + # (otherwise self.engine.getprog() pops up an error message) + return None + + if not (self.engine.isre() or self.engine.isword()): + return (self.engine.getpat(), self.engine.iscase()) + else: + return self.engine.getprog() + + ### Selection related methods + seltagname = property(lambda self: "findsel" if self.is_shown else "sel") + + def _clear_selection(self): + self.text.tag_remove(self.seltagname, "1.0", "end") + + def _set_selection(self, start, end): + self._clear_selection() + self.text.tag_add(self.seltagname, start, end) + + def _get_selection(self): + return self.text.tag_nextrange(self.seltagname, '1.0', 'end') + + def _reset_selection(self): + if self.is_shown: + sel = self.text.tag_nextrange("sel", '1.0', 'end') + if sel: + self._set_selection(sel[0], sel[1]) + else: + self._clear_selection() + + +class EntryExpander(object): + """Expands words in an entry, taking possible words from a text widget.""" + def __init__(self, entry, text): + self.text = text + self.entry = entry + self._reset() + + self.entry.bind('', self._reset) + + def _reset(self, event=None): + self._state = None + + def bind(self, event_string): + """Bind the given event to the word expansion.""" + self.entry.bind(event_string, self._expand_word_event) + + def _expand_word_event(self, event=None): + """Cycle through possible expansions for the current word.""" + # load the existing state or get all possible expansions + curinsert = self.entry.index("insert") + curline = self.entry.get() + if not self._state: + words = self._get_word_expansions() + index = 0 + else: + words, index, insert, line = self._state + if insert != curinsert or line != curline: + words = self._get_word_expansions() + index = 0 + + if not words: # no possible expansions + self.text.bell() + return "break" + + # get the next possible expansion + newword = words[index] + index = (index + 1) % len(words) + if index == 0: + self.text.bell() # Warn the user that we cycled around + + # remove the previous expansion and insert the new one + curword = self._get_current_word() + insert_index = int(self.entry.index("insert")) + self.entry.delete(str(insert_index - len(curword)), str(insert_index)) + self.entry.insert("insert", newword) + + # remember the current state for next time + curinsert = self.entry.index("insert") + curline = self.entry.get() + self._state = words, index, curinsert, curline + return "break" + + def _get_word_expansions(self): + """Get a list of unique expansion suggestions. + + The returned list will be sorted by distance of the expansions from the + current index in the text widget. + + The final item in the list will be the word currently in the entry + widget. + + """ + curword = self._get_current_word() + if not curword: + return [] + + # search for possible expansions in the text widget, sorting them by + # their distance from the current index + expansion_regexp = re.compile(r"\b" + curword + r"\w+\b") + before_text = self.text.get("1.0", "insert wordstart") + insert_index = len(before_text) + words = sorted( + (abs(match.start() - insert_index), match.group()) + for match in expansion_regexp.finditer( + self.text.get("1.0", "end-1c") + ) + ) + + # remove duplicate appearances of words, keeping the first, + # while keeping the current word for last + words_set = {curword} + def unique_words_iter(): + for match in words: + word = match[1] + if word not in words_set: + words_set.add(word) + yield word + words = list(unique_words_iter()) + words.append(curword) + return words + + _wordchars = string.ascii_letters + string.digits + "_" + def _get_current_word(self): + """Get the last word in the entry, ending at the 'insert' index.""" + line = self.entry.get() + i = j = self.entry.index("insert") + while i > 0 and line[i-1] in self._wordchars: + i -= 1 + return line[i:j] diff --git a/Lib/idlelib/windowsearchengine.py b/Lib/idlelib/windowsearchengine.py new file mode 100644 index 000000000000000..739bbf4594f2343 --- /dev/null +++ b/Lib/idlelib/windowsearchengine.py @@ -0,0 +1,355 @@ +# TODO: +# * Allow overlapping search hits in RegexpSearchEngine (?) +import re + +from idlelib.config import idleConf + + +def get(editwin): + """Return the WindowSearchEngine instance for the editor window. + + The single WindowSearchEngine saves state for the given window + while tracking changes to its contents, allowing to reuse + already computed search results when possible. + + If there is not a WindowSearchEngine already, make one. + + """ + if not hasattr(editwin, "_window_search_engine"): + editwin._window_search_engine = WindowSearchEngine(editwin.text) + return editwin._window_search_engine + + +class StringSearchEngine(object): + """A class for searching a Text widget for a given string. + + Provides a findnext() method which finds the next occurrence of the string + in the text relative to a given index and allows wrapping search, case + sensitive/insensitive search and searching forward or backward. + + When instantiated, an instance first marks all appearances of the string in + the text with a tag named "findmark". + + """ + def __init__(self, text_widget, string, case_sensitive=True): + self.text_widget = text_widget + self.string = string + self.case_sensitive = case_sensitive + self._mark_hits() + + def _search(self, start_index, direction=1, wrap=False): + """Internal search function.""" + # Use the underlying Text widget's search function. Otherwise + # we would have to copy at least some of the underlying text in order + # to search through it. + # + # The Text widget's search function wraps by default. To disallow + # wrapping we need to set stopindex appropriately, i.e. "end" for + # forward search or "1.0" for backward search. + return self.text_widget.search( + self.string, start_index, + backwards=not direction, + stopindex=(not wrap) and ("end" if direction else "1.0"), + nocase=not self.case_sensitive) + + def _mark_hits(self): + """Search the text, marking all matches with the "findmark" tag.""" + add_string_len_str = "+%dc" % len(self.string) + tag_add = self.text_widget.tag_add + + start_idx = self._search("1.0") + while start_idx: + tag_add("findmark", start_idx, start_idx + add_string_len_str) + start_idx = self._search(start_idx + "+1c") + + def search_range(self, range_, backward=False): + if not backward: + start_index, stop_index = range_ + else: + stop_index, start_index = range_ + hit = self.text_widget.search(self.string, start_index, + stopindex=stop_index, + backwards=backward, + nocase=(not self.case_sensitive)) + if hit: + return hit, hit + "+%dc" % len(self.string) + return None + + def findnext(self, start_index, direction=1, wrap=False): + """Find the next text sequence which matches the given string.""" + index = self._search(start_index, direction, wrap) + if index: + return index, index + "+%dc" % len(self.string) + return None # no hit found + + +class RegexpSearchEngine(object): + """A class for searching a Text widget for a given reg-exp. + + Provides a findnext() method which finds the next match for the reg-exp in + the text relative to a given index and allows wrapping search and searching + forward or backward. + + When instantiated, an instance first marks all matches for the reg-exp in + the text with a tag named "findmark". Later searches are very quick since + they just jump to the next occurrence of this tag. + + """ + def __init__(self, text, regexp): + self.text_widget = text + self.regexp = regexp + self._mark_hits() + + def _mark_hits(self): + """Search the text, marking all matches with the "findmark" tag.""" + # The search & tag algorithm has been optimized by counting the number + # of line breaks before each hit. This allows using precise index + # identifiers for the Text widget, instead of "1.0+c", + # which speeds up the setting of tags immensely. + text = self.text_widget.get("1.0", "end-1c") + prev = 0 + line = 1 + rfind = text.rfind + tag_add = self.text_widget.tag_add + for res in self.regexp.finditer(text): + start, end = res.span() + line += text[prev:start].count("\n") + prev = start + start_idx = "%d.%d" % (line, + start - (rfind("\n", 0, start) + 1)) + tag_add("findmark", start_idx, start_idx + "+%dc" % (end - start)) + + def search_range(self, range_, backward=False): + """Check whether the regular expression matches the text in the given + range of indices in the text widget. + + """ + start, end = range_ + + # For regexp starting and/or ending with \b, we need to look at one + # additional character before and/or after the given range. This is + # required in order to support "whole word" searches, where \b is added + # before and after the given search expression. + add_left = (self.regexp.pattern.startswith(r"\b") and + self.text_widget.compare(start, ">", "1.0")) + if add_left: + start = start + "-1c" + add_right = (self.regexp.pattern.endswith(r"\b") and + self.text_widget.compare(end, "<", "end")) + if add_right: + end = end + "+1c" + + text = self.text_widget.get(start, end) + + matches = self.regexp.finditer(text) + if backward: + matches = reversed(list(matches)) + for match in matches: + # Skip matches not in the given range. This would be caused by the + # addition of one character before and/or after the given range + # (see above). + if ( + (add_left and match.start() == 0) or + (add_right and match.end() == len(text)) + ): + continue + return (start + "+%dc" % match.start(), + start + "+%dc" % match.end()) + + return None # no valid match found + + def findnext(self, start, direction=1, wrap=True): + """Find the next text sequence which matches the given regexp. + + The 'next' sequence is the one after the selection or the insert + cursor, or before if the direction is up instead of down. + + """ + text_widget = self.text_widget # we're going to be using this often... + + index = start + + # Check if the current index is still inside a "findmark" tag. + # If it is, tag_nextrange will skip it, so we use tag_prevrange instead. + prev_range = text_widget.tag_prevrange("findmark", + start, start + ' linestart') + if prev_range and text_widget.compare(start, "<", prev_range[1]): + # We're still inside a findmark tag! This is a rare case, probably + # caused by adjacent hits. We'll play it safe: search this tag + # range for another match. + if direction: # searching forward + search_start, search_end = start, prev_range[1] + else: # searching backward + search_start, search_end = prev_range[0], start + # When searching backward we change the current index to the + # beginning of the range, so that if we continue searching (via + # tag_prevrange) we won't find this tag range again. + index = search_start + hit = self.search_range((search_start, search_end), backward=True) + if hit: + return hit + + # The current index isn't in a "findmark" tag range, or if it is then + # there are no subsequent matches in the range. Search for the next + # "findmark" tag range. + # + # Whenever we find a "findmark" tag range, we want to be sure to return + # a single precise match. Therefore search for regexp matches in the + # tag range, and if there are matches then return the first one. + + if direction: # searching forward + end = "end" + while True: + tag_range = text_widget.tag_nextrange("findmark", index, end) + if tag_range: + hit = self.search_range(tag_range) + if hit: + return hit + index = tag_range[1] + else: + if not wrap: + break + index, end = "1.0", start + wrap = False + else: # searching backward + end = "1.0" + while True: + tag_range = text_widget.tag_prevrange("findmark", index, end) + if tag_range: + hit = self.search_range(tag_range, backward=True) + if hit: + return hit + index = tag_range[0] + else: + if not wrap: + break + index, end = "end", start + wrap = False + + return None # no hit found + + +class WindowSearchEngine(object): + """A class for searching a Text widget. + + This can search for either plain strings or regular expressions, using + either StringSearchEngine or RegexpSearchEngine as appropriate. + + Provides a findnext() method which finds the next occurence of the string + in the text relative to a given index and allows wrapping search, case + sensitive/insensitive search and searching forward or backward. + + """ + def __init__(self, text_widget): + self.text_widget = text_widget + + self.hide_find_marks() # initialize "findmark" tag + self.reset() + + def __del__(self): + self.text_widget.tag_delete("findmark") + + def show_find_marks(self): + # Get the highlight colors for "hit" + # Do this here (and not in __init__) so that config changes take + # effect immediately + currentTheme = idleConf.CurrentTheme() + highlight_dict = idleConf.GetHighlight(currentTheme, "hit") + self.text_widget.tag_configure("findmark", + foreground=highlight_dict["foreground"], + background=highlight_dict["background"]) + + def hide_find_marks(self): + self.text_widget.tag_configure("findmark", + foreground="", + background="") + + def reset(self): + self.text_widget.tag_remove("findmark", "1.0", "end") + self.search_expression = None + self.search_engine = None + + def set_search_expression(self, search_exp): + self.reset() + self.search_expression = search_exp + if isinstance(search_exp, tuple): + string, case_sensitive = search_exp + self.engine = StringSearchEngine(self.text_widget, + string, case_sensitive) + else: + assert isinstance(search_exp, re.Pattern) + self.engine = RegexpSearchEngine(self.text_widget, search_exp) + + def match_range(self, start, end): + match = self.search_range(start, end) + return (match and + self.text_widget.compare(match[0], '==', start) and + self.text_widget.compare(match[1], '==', end)) + + + def search_range(self, start, end, backward=False): + return self.engine.search_range((start, end), backward) + + def findnext(self, start, direction=1, wrap=False): + return self.engine.findnext(start, direction, wrap) + + def replace_all(self, replace_with): + insert = self.text_widget.insert + delete = self.text_widget.delete + + n_replaced = 0 + hit = self.findnext("1.0", direction=1, wrap=False) + while hit: + n_replaced += 1 + first, last = hit + if first != last: + delete(first, last) + if replace_with: + insert(first, replace_with) + hit = self.findnext(first + "+%dc" % len(replace_with), + direction=1, wrap=False) + return n_replaced + + +def test_string_search_engine(): + from tkinter import Text, Tk + + root = Tk() + text_widget = Text(root) + engine = StringSearchEngine(text_widget) + + test_data = [ + ("abcabcabca", "abc", + [("1.0", "1.3"), ("1.3", "1.6"), ("1.6", "1.9")]), + ("if and only if", " if", + [("1.11", "1.14")]), + ("'''", "'", + [("1.0", "1.1"), ("1.1", "1.2"), ("1.2", "1.3")]), + ] + + results = [] + for text, string, indices in test_data: + text_widget.insert("1.0", text) + idx = "1.0" + i = 0 + while True: + res = engine.findnext(string, idx, wrap=False) + if not res: + if i < len(indices): + results.append(((False, 'ran out early'), + text, string, indices)) + else: + results.append((True, text, string, indices)) + break + start, end = res + if not (indices and + text_widget.compare(indices[i][0], '==', start) and + text_widget.compare(indices[i][1], '==', end)): + results.append(((False, 'did not find'), + text, string, indices)) + break + i += 1 + idx = start + "+1c" + text_widget.delete("1.0", "end") + return results +