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. |
Xtreak | d9677f3 | 2019-06-03 09:51:15 +0530 | [diff] [blame] | 9 | Double-clicking this button will remove it and insert the original text instead. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 |
| 18 | |
| 19 | import tkinter as tk |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 20 | import tkinter.messagebox as tkMessageBox |
| 21 | |
| 22 | from idlelib.config import idleConf |
| 23 | from idlelib.textview import view_text |
| 24 | from idlelib.tooltip import Hovertip |
| 25 | from idlelib import macosx |
| 26 | |
| 27 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 28 | def count_lines_with_wrapping(s, linewidth=80): |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | """ |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 36 | tabwidth = 8 # Currently always true in Shell. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 37 | pos = 0 |
| 38 | linecount = 1 |
| 39 | current_column = 0 |
| 40 | |
| 41 | for m in re.finditer(r"[\t\n]", s): |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 42 | # Process the normal chars up to tab or newline. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 43 | numchars = m.start() - pos |
| 44 | pos += numchars |
| 45 | current_column += numchars |
| 46 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 47 | # Deal with tab or newline. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 48 | if s[pos] == '\n': |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 49 | # Avoid the `current_column == 0` edge-case, and while we're |
| 50 | # at it, don't bother adding 0. |
Tal Einat | 44a79cc | 2018-12-24 14:05:51 +0200 | [diff] [blame] | 51 | if current_column > linewidth: |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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. |
Tal Einat | 44a79cc | 2018-12-24 14:05:51 +0200 | [diff] [blame] | 57 | linecount += (current_column - 1) // linewidth |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 58 | linecount += 1 |
| 59 | current_column = 0 |
| 60 | else: |
| 61 | assert s[pos] == '\t' |
| 62 | current_column += tabwidth - (current_column % tabwidth) |
| 63 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 64 | # If a tab passes the end of the line, consider the entire |
| 65 | # tab as being on the next line. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 66 | if current_column > linewidth: |
| 67 | linecount += 1 |
| 68 | current_column = tabwidth |
| 69 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 70 | pos += 1 # After the tab or newline. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 71 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 72 | # Process remaining chars (no more tabs or newlines). |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 73 | current_column += len(s) - pos |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 74 | # Avoid divmod(-1, linewidth). |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 75 | if current_column > 0: |
| 76 | linecount += (current_column - 1) // linewidth |
| 77 | else: |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 78 | # Text ended with newline; don't count an extra line after it. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 79 | linecount -= 1 |
| 80 | |
| 81 | return linecount |
| 82 | |
| 83 | |
| 84 | class 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 |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 102 | # The base Text widget is needed to change text before iomark. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 103 | self.base_text = editwin.per.bottom |
| 104 | |
Tal Einat | 44a79cc | 2018-12-24 14:05:51 +0200 | [diff] [blame] | 105 | line_plurality = "lines" if numoflines != 1 else "line" |
| 106 | button_text = f"Squeezed text ({numoflines} {line_plurality})." |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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) |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 121 | self.selection_handle( # X windows only. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 = ( |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 184 | # Item structure: (label, method_name). |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | |
| 198 | class 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 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 230 | self.base_text = editwin.per.bottom |
| 231 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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 | |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 239 | self.expandingbuttons = [] |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 240 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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) |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 247 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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) |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 258 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 259 | # Create an ExpandingButton instance. |
| 260 | expandingbutton = ExpandingButton(s, tags, numoflines, self) |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 261 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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) |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 269 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 270 | # Add the ExpandingButton to the Squeezer's list. |
| 271 | self.expandingbuttons.append(expandingbutton) |
| 272 | |
| 273 | editwin.write = mywrite |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | """ |
Tal Einat | d4b4c00 | 2019-08-25 08:52:58 +0300 | [diff] [blame^] | 286 | return count_lines_with_wrapping(s, self.editwin.width) |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | """ |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 296 | # 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] | 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: |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 302 | # The insert cursor doesn't have a "stdout" or "stderr" tag. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 303 | self.text.bell() |
| 304 | return "break" |
| 305 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 306 | # Find the range to squeeze. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 307 | start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c") |
| 308 | s = self.text.get(start, end) |
| 309 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 310 | # If the last char is a newline, remove it from the range. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 311 | if len(s) > 0 and s[-1] == '\n': |
| 312 | end = self.text.index("%s-1c" % end) |
| 313 | s = s[:-1] |
| 314 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 315 | # Delete the text. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 316 | self.base_text.delete(start, end) |
| 317 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 318 | # Prepare an ExpandingButton. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | |
Tal Einat | 39a33e9 | 2019-01-13 17:01:50 +0200 | [diff] [blame] | 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. |
Tal Einat | 604e7b9 | 2018-09-25 15:10:14 +0300 | [diff] [blame] | 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 | |
| 338 | Squeezer.reload() |
| 339 | |
| 340 | |
| 341 | if __name__ == "__main__": |
| 342 | from unittest import main |
| 343 | main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False) |
| 344 | |
| 345 | # Add htest. |