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