Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 1 | """An IDLE extension to avoid having very long texts printed in the shell. |
| 2 | |
| 3 | A common problem in IDLE's interactive shell is printing of large amounts of |
| 4 | text into the shell. This makes looking at the previous history difficult. |
| 5 | Worse, this can cause IDLE to become very slow, even to the point of being |
| 6 | completely unusable. |
| 7 | |
| 8 | This extension will automatically replace long texts with a small button. |
| 9 | Double-cliking this button will remove it and insert the original text instead. |
| 10 | Middle-clicking will copy the text to the clipboard. Right-clicking will open |
| 11 | the text in a separate viewing window. |
| 12 | |
| 13 | Additionally, any output can be manually "squeezed" by the user. This includes |
| 14 | output written to the standard error stream ("stderr"), such as exception |
| 15 | messages and their tracebacks. |
| 16 | """ |
| 17 | import re |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 18 | import weakref |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 19 | |
| 20 | import tkinter as tk |
| 21 | from tkinter.font import Font |
| 22 | import tkinter.messagebox as tkMessageBox |
| 23 | |
| 24 | from idlelib.config import idleConf |
| 25 | from idlelib.textview import view_text |
| 26 | from idlelib.tooltip import Hovertip |
| 27 | from idlelib import macosx |
| 28 | |
| 29 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 30 | def count_lines_with_wrapping(s, linewidth=80): |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | """ |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 38 | tabwidth = 8 # Currently always true in Shell. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 39 | pos = 0 |
| 40 | linecount = 1 |
| 41 | current_column = 0 |
| 42 | |
| 43 | for m in re.finditer(r"[\t\n]", s): |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 44 | # Process the normal chars up to tab or newline. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 45 | numchars = m.start() - pos |
| 46 | pos += numchars |
| 47 | current_column += numchars |
| 48 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 49 | # Deal with tab or newline. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 50 | if s[pos] == '\n': |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 51 | # Avoid the `current_column == 0` edge-case, and while we're |
| 52 | # at it, don't bother adding 0. |
Tal Einat | 44a79cc | 2018-12-24 14:05:51 +0200 | [diff] [blame] | 53 | if current_column > linewidth: |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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. |
Tal Einat | 44a79cc | 2018-12-24 14:05:51 +0200 | [diff] [blame] | 59 | linecount += (current_column - 1) // linewidth |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 60 | linecount += 1 |
| 61 | current_column = 0 |
| 62 | else: |
| 63 | assert s[pos] == '\t' |
| 64 | current_column += tabwidth - (current_column % tabwidth) |
| 65 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 66 | # If a tab passes the end of the line, consider the entire |
| 67 | # tab as being on the next line. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 68 | if current_column > linewidth: |
| 69 | linecount += 1 |
| 70 | current_column = tabwidth |
| 71 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 72 | pos += 1 # After the tab or newline. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 73 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 74 | # Process remaining chars (no more tabs or newlines). |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 75 | current_column += len(s) - pos |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 76 | # Avoid divmod(-1, linewidth). |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 77 | if current_column > 0: |
| 78 | linecount += (current_column - 1) // linewidth |
| 79 | else: |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 80 | # Text ended with newline; don't count an extra line after it. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 81 | linecount -= 1 |
| 82 | |
| 83 | return linecount |
| 84 | |
| 85 | |
| 86 | class 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 |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 104 | # The base Text widget is needed to change text before iomark. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 105 | self.base_text = editwin.per.bottom |
| 106 | |
Tal Einat | 44a79cc | 2018-12-24 14:05:51 +0200 | [diff] [blame] | 107 | line_plurality = "lines" if numoflines != 1 else "line" |
| 108 | button_text = f"Squeezed text ({numoflines} {line_plurality})." |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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) |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 123 | self.selection_handle( # X windows only. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 = ( |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 186 | # Item structure: (label, method_name). |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | |
| 200 | class 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 | """ |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 206 | _instance_weakref = None |
| 207 | |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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 | |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 242 | self.base_text = editwin.per.bottom |
| 243 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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 | |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 254 | self.expandingbuttons = [] |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 255 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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) |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 262 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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) |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 273 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 274 | # Create an ExpandingButton instance. |
| 275 | expandingbutton = ExpandingButton(s, tags, numoflines, self) |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 276 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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) |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 284 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 285 | # Add the ExpandingButton to the Squeezer's list. |
| 286 | self.expandingbuttons.append(expandingbutton) |
| 287 | |
| 288 | editwin.write = mywrite |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | """ |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 301 | linewidth = self.get_line_width() |
| 302 | return count_lines_with_wrapping(s, linewidth) |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 303 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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 |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 309 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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 |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 314 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 315 | def load_font(self): |
| 316 | text = self.base_text |
| 317 | self.zero_char_width = \ |
| 318 | Font(text, font=text.cget('font')).measure('0') |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | """ |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 328 | # Set tag_name to the first valid tag found on the "insert" cursor. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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: |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 334 | # The insert cursor doesn't have a "stdout" or "stderr" tag. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 335 | self.text.bell() |
| 336 | return "break" |
| 337 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 338 | # Find the range to squeeze. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 339 | start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c") |
| 340 | s = self.text.get(start, end) |
| 341 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 342 | # If the last char is a newline, remove it from the range. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 343 | if len(s) > 0 and s[-1] == '\n': |
| 344 | end = self.text.index("%s-1c" % end) |
| 345 | s = s[:-1] |
| 346 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 347 | # Delete the text. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 348 | self.base_text.delete(start, end) |
| 349 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 350 | # Prepare an ExpandingButton. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | |
| 370 | Squeezer.reload() |
| 371 | |
| 372 | |
| 373 | if __name__ == "__main__": |
| 374 | from unittest import main |
| 375 | main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False) |
| 376 | |
| 377 | # Add htest. |