1"""Replace dialog for IDLE. Inherits SearchDialogBase for GUI. 2Uses idlelib.searchengine.SearchEngine for search capability. 3Defines various replace related functions like replace, replace all, 4and replace+find. 5""" 6import re 7 8from tkinter import StringVar, TclError 9 10from idlelib.searchbase import SearchDialogBase 11from idlelib import searchengine 12 13 14def replace(text, insert_tags=None): 15 """Create or reuse a singleton ReplaceDialog instance. 16 17 The singleton dialog saves user entries and preferences 18 across instances. 19 20 Args: 21 text: Text widget containing the text to be searched. 22 """ 23 root = text._root() 24 engine = searchengine.get(root) 25 if not hasattr(engine, "_replacedialog"): 26 engine._replacedialog = ReplaceDialog(root, engine) 27 dialog = engine._replacedialog 28 dialog.open(text, insert_tags=insert_tags) 29 30 31class ReplaceDialog(SearchDialogBase): 32 "Dialog for finding and replacing a pattern in text." 33 34 title = "Replace Dialog" 35 icon = "Replace" 36 37 def __init__(self, root, engine): 38 """Create search dialog for finding and replacing text. 39 40 Uses SearchDialogBase as the basis for the GUI and a 41 searchengine instance to prepare the search. 42 43 Attributes: 44 replvar: StringVar containing 'Replace with:' value. 45 replent: Entry widget for replvar. Created in 46 create_entries(). 47 ok: Boolean used in searchengine.search_text to indicate 48 whether the search includes the selection. 49 """ 50 super().__init__(root, engine) 51 self.replvar = StringVar(root) 52 self.insert_tags = None 53 54 def open(self, text, insert_tags=None): 55 """Make dialog visible on top of others and ready to use. 56 57 Also, highlight the currently selected text and set the 58 search to include the current selection (self.ok). 59 60 Args: 61 text: Text widget being searched. 62 """ 63 SearchDialogBase.open(self, text) 64 try: 65 first = text.index("sel.first") 66 except TclError: 67 first = None 68 try: 69 last = text.index("sel.last") 70 except TclError: 71 last = None 72 first = first or text.index("insert") 73 last = last or first 74 self.show_hit(first, last) 75 self.ok = True 76 self.insert_tags = insert_tags 77 78 def create_entries(self): 79 "Create base and additional label and text entry widgets." 80 SearchDialogBase.create_entries(self) 81 self.replent = self.make_entry("Replace with:", self.replvar)[0] 82 83 def create_command_buttons(self): 84 """Create base and additional command buttons. 85 86 The additional buttons are for Find, Replace, 87 Replace+Find, and Replace All. 88 """ 89 SearchDialogBase.create_command_buttons(self) 90 self.make_button("Find", self.find_it) 91 self.make_button("Replace", self.replace_it) 92 self.make_button("Replace+Find", self.default_command, isdef=True) 93 self.make_button("Replace All", self.replace_all) 94 95 def find_it(self, event=None): 96 "Handle the Find button." 97 self.do_find(False) 98 99 def replace_it(self, event=None): 100 """Handle the Replace button. 101 102 If the find is successful, then perform replace. 103 """ 104 if self.do_find(self.ok): 105 self.do_replace() 106 107 def default_command(self, event=None): 108 """Handle the Replace+Find button as the default command. 109 110 First performs a replace and then, if the replace was 111 successful, a find next. 112 """ 113 if self.do_find(self.ok): 114 if self.do_replace(): # Only find next match if replace succeeded. 115 # A bad re can cause it to fail. 116 self.do_find(False) 117 118 def _replace_expand(self, m, repl): 119 "Expand replacement text if regular expression." 120 if self.engine.isre(): 121 try: 122 new = m.expand(repl) 123 except re.error: 124 self.engine.report_error(repl, 'Invalid Replace Expression') 125 new = None 126 else: 127 new = repl 128 129 return new 130 131 def replace_all(self, event=None): 132 """Handle the Replace All button. 133 134 Search text for occurrences of the Find value and replace 135 each of them. The 'wrap around' value controls the start 136 point for searching. If wrap isn't set, then the searching 137 starts at the first occurrence after the current selection; 138 if wrap is set, the replacement starts at the first line. 139 The replacement is always done top-to-bottom in the text. 140 """ 141 prog = self.engine.getprog() 142 if not prog: 143 return 144 repl = self.replvar.get() 145 text = self.text 146 res = self.engine.search_text(text, prog) 147 if not res: 148 self.bell() 149 return 150 text.tag_remove("sel", "1.0", "end") 151 text.tag_remove("hit", "1.0", "end") 152 line = res[0] 153 col = res[1].start() 154 if self.engine.iswrap(): 155 line = 1 156 col = 0 157 ok = True 158 first = last = None 159 # XXX ought to replace circular instead of top-to-bottom when wrapping 160 text.undo_block_start() 161 while res := self.engine.search_forward( 162 text, prog, line, col, wrap=False, ok=ok): 163 line, m = res 164 chars = text.get("%d.0" % line, "%d.0" % (line+1)) 165 orig = m.group() 166 new = self._replace_expand(m, repl) 167 if new is None: 168 break 169 i, j = m.span() 170 first = "%d.%d" % (line, i) 171 last = "%d.%d" % (line, j) 172 if new == orig: 173 text.mark_set("insert", last) 174 else: 175 text.mark_set("insert", first) 176 if first != last: 177 text.delete(first, last) 178 if new: 179 text.insert(first, new, self.insert_tags) 180 col = i + len(new) 181 ok = False 182 text.undo_block_stop() 183 if first and last: 184 self.show_hit(first, last) 185 self.close() 186 187 def do_find(self, ok=False): 188 """Search for and highlight next occurrence of pattern in text. 189 190 No text replacement is done with this option. 191 """ 192 if not self.engine.getprog(): 193 return False 194 text = self.text 195 res = self.engine.search_text(text, None, ok) 196 if not res: 197 self.bell() 198 return False 199 line, m = res 200 i, j = m.span() 201 first = "%d.%d" % (line, i) 202 last = "%d.%d" % (line, j) 203 self.show_hit(first, last) 204 self.ok = True 205 return True 206 207 def do_replace(self): 208 "Replace search pattern in text with replacement value." 209 prog = self.engine.getprog() 210 if not prog: 211 return False 212 text = self.text 213 try: 214 first = pos = text.index("sel.first") 215 last = text.index("sel.last") 216 except TclError: 217 pos = None 218 if not pos: 219 first = last = pos = text.index("insert") 220 line, col = searchengine.get_line_col(pos) 221 chars = text.get("%d.0" % line, "%d.0" % (line+1)) 222 m = prog.match(chars, col) 223 if not prog: 224 return False 225 new = self._replace_expand(m, self.replvar.get()) 226 if new is None: 227 return False 228 text.mark_set("insert", first) 229 text.undo_block_start() 230 if m.group(): 231 text.delete(first, last) 232 if new: 233 text.insert(first, new, self.insert_tags) 234 text.undo_block_stop() 235 self.show_hit(first, text.index("insert")) 236 self.ok = False 237 return True 238 239 def show_hit(self, first, last): 240 """Highlight text between first and last indices. 241 242 Text is highlighted via the 'hit' tag and the marked 243 section is brought into view. 244 245 The colors from the 'hit' tag aren't currently shown 246 when the text is displayed. This is due to the 'sel' 247 tag being added first, so the colors in the 'sel' 248 config are seen instead of the colors for 'hit'. 249 """ 250 text = self.text 251 text.mark_set("insert", first) 252 text.tag_remove("sel", "1.0", "end") 253 text.tag_add("sel", first, last) 254 text.tag_remove("hit", "1.0", "end") 255 if first == last: 256 text.tag_add("hit", first) 257 else: 258 text.tag_add("hit", first, last) 259 text.see("insert") 260 text.update_idletasks() 261 262 def close(self, event=None): 263 "Close the dialog and remove hit tags." 264 SearchDialogBase.close(self, event) 265 self.text.tag_remove("hit", "1.0", "end") 266 self.insert_tags = None 267 268 269def _replace_dialog(parent): # htest # 270 from tkinter import Toplevel, Text, END, SEL 271 from tkinter.ttk import Frame, Button 272 273 top = Toplevel(parent) 274 top.title("Test ReplaceDialog") 275 x, y = map(int, parent.geometry().split('+')[1:]) 276 top.geometry("+%d+%d" % (x, y + 175)) 277 278 # mock undo delegator methods 279 def undo_block_start(): 280 pass 281 282 def undo_block_stop(): 283 pass 284 285 frame = Frame(top) 286 frame.pack() 287 text = Text(frame, inactiveselectbackground='gray') 288 text.undo_block_start = undo_block_start 289 text.undo_block_stop = undo_block_stop 290 text.pack() 291 text.insert("insert","This is a sample sTring\nPlus MORE.") 292 text.focus_set() 293 294 def show_replace(): 295 text.tag_add(SEL, "1.0", END) 296 replace(text) 297 text.tag_remove(SEL, "1.0", END) 298 299 button = Button(frame, text="Replace", command=show_replace) 300 button.pack() 301 302if __name__ == '__main__': 303 from unittest import main 304 main('idlelib.idle_test.test_replace', verbosity=2, exit=False) 305 306 from idlelib.idle_test.htest import run 307 run(_replace_dialog) 308