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