1"""An IDLE extension to avoid having very long texts printed in the shell. 2 3A common problem in IDLE's interactive shell is printing of large amounts of 4text into the shell. This makes looking at the previous history difficult. 5Worse, this can cause IDLE to become very slow, even to the point of being 6completely unusable. 7 8This extension will automatically replace long texts with a small button. 9Double-cliking this button will remove it and insert the original text instead. 10Middle-clicking will copy the text to the clipboard. Right-clicking will open 11the text in a separate viewing window. 12 13Additionally, any output can be manually "squeezed" by the user. This includes 14output written to the standard error stream ("stderr"), such as exception 15messages and their tracebacks. 16""" 17import re 18import weakref 19 20import tkinter as tk 21from tkinter.font import Font 22import tkinter.messagebox as tkMessageBox 23 24from idlelib.config import idleConf 25from idlelib.textview import view_text 26from idlelib.tooltip import Hovertip 27from idlelib import macosx 28 29 30def count_lines_with_wrapping(s, linewidth=80): 31 """Count the number of lines in a given string. 32 33 Lines are counted as if the string was wrapped so that lines are never over 34 linewidth characters long. 35 36 Tabs are considered tabwidth characters long. 37 """ 38 tabwidth = 8 # Currently always true in Shell. 39 pos = 0 40 linecount = 1 41 current_column = 0 42 43 for m in re.finditer(r"[\t\n]", s): 44 # Process the normal chars up to tab or newline. 45 numchars = m.start() - pos 46 pos += numchars 47 current_column += numchars 48 49 # Deal with tab or newline. 50 if s[pos] == '\n': 51 # Avoid the `current_column == 0` edge-case, and while we're 52 # at it, don't bother adding 0. 53 if current_column > linewidth: 54 # If the current column was exactly linewidth, divmod 55 # would give (1,0), even though a new line hadn't yet 56 # been started. The same is true if length is any exact 57 # multiple of linewidth. Therefore, subtract 1 before 58 # dividing a non-empty line. 59 linecount += (current_column - 1) // linewidth 60 linecount += 1 61 current_column = 0 62 else: 63 assert s[pos] == '\t' 64 current_column += tabwidth - (current_column % tabwidth) 65 66 # If a tab passes the end of the line, consider the entire 67 # tab as being on the next line. 68 if current_column > linewidth: 69 linecount += 1 70 current_column = tabwidth 71 72 pos += 1 # After the tab or newline. 73 74 # Process remaining chars (no more tabs or newlines). 75 current_column += len(s) - pos 76 # Avoid divmod(-1, linewidth). 77 if current_column > 0: 78 linecount += (current_column - 1) // linewidth 79 else: 80 # Text ended with newline; don't count an extra line after it. 81 linecount -= 1 82 83 return linecount 84 85 86class ExpandingButton(tk.Button): 87 """Class for the "squeezed" text buttons used by Squeezer 88 89 These buttons are displayed inside a Tk Text widget in place of text. A 90 user can then use the button to replace it with the original text, copy 91 the original text to the clipboard or view the original text in a separate 92 window. 93 94 Each button is tied to a Squeezer instance, and it knows to update the 95 Squeezer instance when it is expanded (and therefore removed). 96 """ 97 def __init__(self, s, tags, numoflines, squeezer): 98 self.s = s 99 self.tags = tags 100 self.numoflines = numoflines 101 self.squeezer = squeezer 102 self.editwin = editwin = squeezer.editwin 103 self.text = text = editwin.text 104 # The base Text widget is needed to change text before iomark. 105 self.base_text = editwin.per.bottom 106 107 line_plurality = "lines" if numoflines != 1 else "line" 108 button_text = f"Squeezed text ({numoflines} {line_plurality})." 109 tk.Button.__init__(self, text, text=button_text, 110 background="#FFFFC0", activebackground="#FFFFE0") 111 112 button_tooltip_text = ( 113 "Double-click to expand, right-click for more options." 114 ) 115 Hovertip(self, button_tooltip_text, hover_delay=80) 116 117 self.bind("<Double-Button-1>", self.expand) 118 if macosx.isAquaTk(): 119 # AquaTk defines <2> as the right button, not <3>. 120 self.bind("<Button-2>", self.context_menu_event) 121 else: 122 self.bind("<Button-3>", self.context_menu_event) 123 self.selection_handle( # X windows only. 124 lambda offset, length: s[int(offset):int(offset) + int(length)]) 125 126 self.is_dangerous = None 127 self.after_idle(self.set_is_dangerous) 128 129 def set_is_dangerous(self): 130 dangerous_line_len = 50 * self.text.winfo_width() 131 self.is_dangerous = ( 132 self.numoflines > 1000 or 133 len(self.s) > 50000 or 134 any( 135 len(line_match.group(0)) >= dangerous_line_len 136 for line_match in re.finditer(r'[^\n]+', self.s) 137 ) 138 ) 139 140 def expand(self, event=None): 141 """expand event handler 142 143 This inserts the original text in place of the button in the Text 144 widget, removes the button and updates the Squeezer instance. 145 146 If the original text is dangerously long, i.e. expanding it could 147 cause a performance degradation, ask the user for confirmation. 148 """ 149 if self.is_dangerous is None: 150 self.set_is_dangerous() 151 if self.is_dangerous: 152 confirm = tkMessageBox.askokcancel( 153 title="Expand huge output?", 154 message="\n\n".join([ 155 "The squeezed output is very long: %d lines, %d chars.", 156 "Expanding it could make IDLE slow or unresponsive.", 157 "It is recommended to view or copy the output instead.", 158 "Really expand?" 159 ]) % (self.numoflines, len(self.s)), 160 default=tkMessageBox.CANCEL, 161 parent=self.text) 162 if not confirm: 163 return "break" 164 165 self.base_text.insert(self.text.index(self), self.s, self.tags) 166 self.base_text.delete(self) 167 self.squeezer.expandingbuttons.remove(self) 168 169 def copy(self, event=None): 170 """copy event handler 171 172 Copy the original text to the clipboard. 173 """ 174 self.clipboard_clear() 175 self.clipboard_append(self.s) 176 177 def view(self, event=None): 178 """view event handler 179 180 View the original text in a separate text viewer window. 181 """ 182 view_text(self.text, "Squeezed Output Viewer", self.s, 183 modal=False, wrap='none') 184 185 rmenu_specs = ( 186 # Item structure: (label, method_name). 187 ('copy', 'copy'), 188 ('view', 'view'), 189 ) 190 191 def context_menu_event(self, event): 192 self.text.mark_set("insert", "@%d,%d" % (event.x, event.y)) 193 rmenu = tk.Menu(self.text, tearoff=0) 194 for label, method_name in self.rmenu_specs: 195 rmenu.add_command(label=label, command=getattr(self, method_name)) 196 rmenu.tk_popup(event.x_root, event.y_root) 197 return "break" 198 199 200class Squeezer: 201 """Replace long outputs in the shell with a simple button. 202 203 This avoids IDLE's shell slowing down considerably, and even becoming 204 completely unresponsive, when very long outputs are written. 205 """ 206 _instance_weakref = None 207 208 @classmethod 209 def reload(cls): 210 """Load class variables from config.""" 211 cls.auto_squeeze_min_lines = idleConf.GetOption( 212 "main", "PyShell", "auto-squeeze-min-lines", 213 type="int", default=50, 214 ) 215 216 # Loading the font info requires a Tk root. IDLE doesn't rely 217 # on Tkinter's "default root", so the instance will reload 218 # font info using its editor windows's Tk root. 219 if cls._instance_weakref is not None: 220 instance = cls._instance_weakref() 221 if instance is not None: 222 instance.load_font() 223 224 def __init__(self, editwin): 225 """Initialize settings for Squeezer. 226 227 editwin is the shell's Editor window. 228 self.text is the editor window text widget. 229 self.base_test is the actual editor window Tk text widget, rather than 230 EditorWindow's wrapper. 231 self.expandingbuttons is the list of all buttons representing 232 "squeezed" output. 233 """ 234 self.editwin = editwin 235 self.text = text = editwin.text 236 237 # Get the base Text widget of the PyShell object, used to change 238 # text before the iomark. PyShell deliberately disables changing 239 # text before the iomark via its 'text' attribute, which is 240 # actually a wrapper for the actual Text widget. Squeezer, 241 # however, needs to make such changes. 242 self.base_text = editwin.per.bottom 243 244 Squeezer._instance_weakref = weakref.ref(self) 245 self.load_font() 246 247 # Twice the text widget's border width and internal padding; 248 # pre-calculated here for the get_line_width() method. 249 self.window_width_delta = 2 * ( 250 int(text.cget('border')) + 251 int(text.cget('padx')) 252 ) 253 254 self.expandingbuttons = [] 255 256 # Replace the PyShell instance's write method with a wrapper, 257 # which inserts an ExpandingButton instead of a long text. 258 def mywrite(s, tags=(), write=editwin.write): 259 # Only auto-squeeze text which has just the "stdout" tag. 260 if tags != "stdout": 261 return write(s, tags) 262 263 # Only auto-squeeze text with at least the minimum 264 # configured number of lines. 265 auto_squeeze_min_lines = self.auto_squeeze_min_lines 266 # First, a very quick check to skip very short texts. 267 if len(s) < auto_squeeze_min_lines: 268 return write(s, tags) 269 # Now the full line-count check. 270 numoflines = self.count_lines(s) 271 if numoflines < auto_squeeze_min_lines: 272 return write(s, tags) 273 274 # Create an ExpandingButton instance. 275 expandingbutton = ExpandingButton(s, tags, numoflines, self) 276 277 # Insert the ExpandingButton into the Text widget. 278 text.mark_gravity("iomark", tk.RIGHT) 279 text.window_create("iomark", window=expandingbutton, 280 padx=3, pady=5) 281 text.see("iomark") 282 text.update() 283 text.mark_gravity("iomark", tk.LEFT) 284 285 # Add the ExpandingButton to the Squeezer's list. 286 self.expandingbuttons.append(expandingbutton) 287 288 editwin.write = mywrite 289 290 def count_lines(self, s): 291 """Count the number of lines in a given text. 292 293 Before calculation, the tab width and line length of the text are 294 fetched, so that up-to-date values are used. 295 296 Lines are counted as if the string was wrapped so that lines are never 297 over linewidth characters long. 298 299 Tabs are considered tabwidth characters long. 300 """ 301 linewidth = self.get_line_width() 302 return count_lines_with_wrapping(s, linewidth) 303 304 def get_line_width(self): 305 # The maximum line length in pixels: The width of the text 306 # widget, minus twice the border width and internal padding. 307 linewidth_pixels = \ 308 self.base_text.winfo_width() - self.window_width_delta 309 310 # Divide the width of the Text widget by the font width, 311 # which is taken to be the width of '0' (zero). 312 # http://www.tcl.tk/man/tcl8.6/TkCmd/text.htm#M21 313 return linewidth_pixels // self.zero_char_width 314 315 def load_font(self): 316 text = self.base_text 317 self.zero_char_width = \ 318 Font(text, font=text.cget('font')).measure('0') 319 320 def squeeze_current_text_event(self, event): 321 """squeeze-current-text event handler 322 323 Squeeze the block of text inside which contains the "insert" cursor. 324 325 If the insert cursor is not in a squeezable block of text, give the 326 user a small warning and do nothing. 327 """ 328 # Set tag_name to the first valid tag found on the "insert" cursor. 329 tag_names = self.text.tag_names(tk.INSERT) 330 for tag_name in ("stdout", "stderr"): 331 if tag_name in tag_names: 332 break 333 else: 334 # The insert cursor doesn't have a "stdout" or "stderr" tag. 335 self.text.bell() 336 return "break" 337 338 # Find the range to squeeze. 339 start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c") 340 s = self.text.get(start, end) 341 342 # If the last char is a newline, remove it from the range. 343 if len(s) > 0 and s[-1] == '\n': 344 end = self.text.index("%s-1c" % end) 345 s = s[:-1] 346 347 # Delete the text. 348 self.base_text.delete(start, end) 349 350 # Prepare an ExpandingButton. 351 numoflines = self.count_lines(s) 352 expandingbutton = ExpandingButton(s, tag_name, numoflines, self) 353 354 # insert the ExpandingButton to the Text 355 self.text.window_create(start, window=expandingbutton, 356 padx=3, pady=5) 357 358 # Insert the ExpandingButton to the list of ExpandingButtons, 359 # while keeping the list ordered according to the position of 360 # the buttons in the Text widget. 361 i = len(self.expandingbuttons) 362 while i > 0 and self.text.compare(self.expandingbuttons[i-1], 363 ">", expandingbutton): 364 i -= 1 365 self.expandingbuttons.insert(i, expandingbutton) 366 367 return "break" 368 369 370Squeezer.reload() 371 372 373if __name__ == "__main__": 374 from unittest import main 375 main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False) 376 377 # Add htest. 378