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