| """An IDLE extension to avoid having very long texts printed in the shell. |
| |
| A common problem in IDLE's interactive shell is printing of large amounts of |
| text into the shell. This makes looking at the previous history difficult. |
| Worse, this can cause IDLE to become very slow, even to the point of being |
| completely unusable. |
| |
| This extension will automatically replace long texts with a small button. |
| Double-cliking this button will remove it and insert the original text instead. |
| Middle-clicking will copy the text to the clipboard. Right-clicking will open |
| the text in a separate viewing window. |
| |
| Additionally, any output can be manually "squeezed" by the user. This includes |
| output written to the standard error stream ("stderr"), such as exception |
| messages and their tracebacks. |
| """ |
| import re |
| |
| import tkinter as tk |
| from tkinter.font import Font |
| import tkinter.messagebox as tkMessageBox |
| |
| from idlelib.config import idleConf |
| from idlelib.textview import view_text |
| from idlelib.tooltip import Hovertip |
| from idlelib import macosx |
| |
| |
| def count_lines_with_wrapping(s, linewidth=80, tabwidth=8): |
| """Count the number of lines in a given string. |
| |
| Lines are counted as if the string was wrapped so that lines are never over |
| linewidth characters long. |
| |
| Tabs are considered tabwidth characters long. |
| """ |
| pos = 0 |
| linecount = 1 |
| current_column = 0 |
| |
| for m in re.finditer(r"[\t\n]", s): |
| # process the normal chars up to tab or newline |
| numchars = m.start() - pos |
| pos += numchars |
| current_column += numchars |
| |
| # deal with tab or newline |
| if s[pos] == '\n': |
| linecount += 1 |
| current_column = 0 |
| else: |
| assert s[pos] == '\t' |
| current_column += tabwidth - (current_column % tabwidth) |
| |
| # if a tab passes the end of the line, consider the entire tab as |
| # being on the next line |
| if current_column > linewidth: |
| linecount += 1 |
| current_column = tabwidth |
| |
| pos += 1 # after the tab or newline |
| |
| # avoid divmod(-1, linewidth) |
| if current_column > 0: |
| # If the length was exactly linewidth, divmod would give (1,0), |
| # even though a new line hadn't yet been started. The same is true |
| # if length is any exact multiple of linewidth. Therefore, subtract |
| # 1 before doing divmod, and later add 1 to the column to |
| # compensate. |
| lines, column = divmod(current_column - 1, linewidth) |
| linecount += lines |
| current_column = column + 1 |
| |
| # process remaining chars (no more tabs or newlines) |
| current_column += len(s) - pos |
| # avoid divmod(-1, linewidth) |
| if current_column > 0: |
| linecount += (current_column - 1) // linewidth |
| else: |
| # the text ended with a newline; don't count an extra line after it |
| linecount -= 1 |
| |
| return linecount |
| |
| |
| class ExpandingButton(tk.Button): |
| """Class for the "squeezed" text buttons used by Squeezer |
| |
| These buttons are displayed inside a Tk Text widget in place of text. A |
| user can then use the button to replace it with the original text, copy |
| the original text to the clipboard or view the original text in a separate |
| window. |
| |
| Each button is tied to a Squeezer instance, and it knows to update the |
| Squeezer instance when it is expanded (and therefore removed). |
| """ |
| def __init__(self, s, tags, numoflines, squeezer): |
| self.s = s |
| self.tags = tags |
| self.numoflines = numoflines |
| self.squeezer = squeezer |
| self.editwin = editwin = squeezer.editwin |
| self.text = text = editwin.text |
| |
| # the base Text widget of the PyShell object, used to change text |
| # before the iomark |
| self.base_text = editwin.per.bottom |
| |
| button_text = "Squeezed text (%d lines)." % self.numoflines |
| tk.Button.__init__(self, text, text=button_text, |
| background="#FFFFC0", activebackground="#FFFFE0") |
| |
| button_tooltip_text = ( |
| "Double-click to expand, right-click for more options." |
| ) |
| Hovertip(self, button_tooltip_text, hover_delay=80) |
| |
| self.bind("<Double-Button-1>", self.expand) |
| if macosx.isAquaTk(): |
| # AquaTk defines <2> as the right button, not <3>. |
| self.bind("<Button-2>", self.context_menu_event) |
| else: |
| self.bind("<Button-3>", self.context_menu_event) |
| self.selection_handle( |
| lambda offset, length: s[int(offset):int(offset) + int(length)]) |
| |
| self.is_dangerous = None |
| self.after_idle(self.set_is_dangerous) |
| |
| def set_is_dangerous(self): |
| dangerous_line_len = 50 * self.text.winfo_width() |
| self.is_dangerous = ( |
| self.numoflines > 1000 or |
| len(self.s) > 50000 or |
| any( |
| len(line_match.group(0)) >= dangerous_line_len |
| for line_match in re.finditer(r'[^\n]+', self.s) |
| ) |
| ) |
| |
| def expand(self, event=None): |
| """expand event handler |
| |
| This inserts the original text in place of the button in the Text |
| widget, removes the button and updates the Squeezer instance. |
| |
| If the original text is dangerously long, i.e. expanding it could |
| cause a performance degradation, ask the user for confirmation. |
| """ |
| if self.is_dangerous is None: |
| self.set_is_dangerous() |
| if self.is_dangerous: |
| confirm = tkMessageBox.askokcancel( |
| title="Expand huge output?", |
| message="\n\n".join([ |
| "The squeezed output is very long: %d lines, %d chars.", |
| "Expanding it could make IDLE slow or unresponsive.", |
| "It is recommended to view or copy the output instead.", |
| "Really expand?" |
| ]) % (self.numoflines, len(self.s)), |
| default=tkMessageBox.CANCEL, |
| parent=self.text) |
| if not confirm: |
| return "break" |
| |
| self.base_text.insert(self.text.index(self), self.s, self.tags) |
| self.base_text.delete(self) |
| self.squeezer.expandingbuttons.remove(self) |
| |
| def copy(self, event=None): |
| """copy event handler |
| |
| Copy the original text to the clipboard. |
| """ |
| self.clipboard_clear() |
| self.clipboard_append(self.s) |
| |
| def view(self, event=None): |
| """view event handler |
| |
| View the original text in a separate text viewer window. |
| """ |
| view_text(self.text, "Squeezed Output Viewer", self.s, |
| modal=False, wrap='none') |
| |
| rmenu_specs = ( |
| # item structure: (label, method_name) |
| ('copy', 'copy'), |
| ('view', 'view'), |
| ) |
| |
| def context_menu_event(self, event): |
| self.text.mark_set("insert", "@%d,%d" % (event.x, event.y)) |
| rmenu = tk.Menu(self.text, tearoff=0) |
| for label, method_name in self.rmenu_specs: |
| rmenu.add_command(label=label, command=getattr(self, method_name)) |
| rmenu.tk_popup(event.x_root, event.y_root) |
| return "break" |
| |
| |
| class Squeezer: |
| """Replace long outputs in the shell with a simple button. |
| |
| This avoids IDLE's shell slowing down considerably, and even becoming |
| completely unresponsive, when very long outputs are written. |
| """ |
| @classmethod |
| def reload(cls): |
| """Load class variables from config.""" |
| cls.auto_squeeze_min_lines = idleConf.GetOption( |
| "main", "PyShell", "auto-squeeze-min-lines", |
| type="int", default=50, |
| ) |
| |
| def __init__(self, editwin): |
| """Initialize settings for Squeezer. |
| |
| editwin is the shell's Editor window. |
| self.text is the editor window text widget. |
| self.base_test is the actual editor window Tk text widget, rather than |
| EditorWindow's wrapper. |
| self.expandingbuttons is the list of all buttons representing |
| "squeezed" output. |
| """ |
| self.editwin = editwin |
| self.text = text = editwin.text |
| |
| # Get the base Text widget of the PyShell object, used to change text |
| # before the iomark. PyShell deliberately disables changing text before |
| # the iomark via its 'text' attribute, which is actually a wrapper for |
| # the actual Text widget. Squeezer, however, needs to make such changes. |
| self.base_text = editwin.per.bottom |
| |
| self.expandingbuttons = [] |
| from idlelib.pyshell import PyShell # done here to avoid import cycle |
| if isinstance(editwin, PyShell): |
| # If we get a PyShell instance, replace its write method with a |
| # wrapper, which inserts an ExpandingButton instead of a long text. |
| def mywrite(s, tags=(), write=editwin.write): |
| # only auto-squeeze text which has just the "stdout" tag |
| if tags != "stdout": |
| return write(s, tags) |
| |
| # only auto-squeeze text with at least the minimum |
| # configured number of lines |
| numoflines = self.count_lines(s) |
| if numoflines < self.auto_squeeze_min_lines: |
| return write(s, tags) |
| |
| # create an ExpandingButton instance |
| expandingbutton = ExpandingButton(s, tags, numoflines, |
| self) |
| |
| # insert the ExpandingButton into the Text widget |
| text.mark_gravity("iomark", tk.RIGHT) |
| text.window_create("iomark", window=expandingbutton, |
| padx=3, pady=5) |
| text.see("iomark") |
| text.update() |
| text.mark_gravity("iomark", tk.LEFT) |
| |
| # add the ExpandingButton to the Squeezer's list |
| self.expandingbuttons.append(expandingbutton) |
| |
| editwin.write = mywrite |
| |
| def count_lines(self, s): |
| """Count the number of lines in a given text. |
| |
| Before calculation, the tab width and line length of the text are |
| fetched, so that up-to-date values are used. |
| |
| Lines are counted as if the string was wrapped so that lines are never |
| over linewidth characters long. |
| |
| Tabs are considered tabwidth characters long. |
| """ |
| # Tab width is configurable |
| tabwidth = self.editwin.get_tk_tabwidth() |
| |
| # Get the Text widget's size |
| linewidth = self.editwin.text.winfo_width() |
| # Deduct the border and padding |
| linewidth -= 2*sum([int(self.editwin.text.cget(opt)) |
| for opt in ('border', 'padx')]) |
| |
| # Get the Text widget's font |
| font = Font(self.editwin.text, name=self.editwin.text.cget('font')) |
| # Divide the size of the Text widget by the font's width. |
| # According to Tk8.5 docs, the Text widget's width is set |
| # according to the width of its font's '0' (zero) character, |
| # so we will use this as an approximation. |
| # see: http://www.tcl.tk/man/tcl8.5/TkCmd/text.htm#M-width |
| linewidth //= font.measure('0') |
| |
| return count_lines_with_wrapping(s, linewidth, tabwidth) |
| |
| def squeeze_current_text_event(self, event): |
| """squeeze-current-text event handler |
| |
| Squeeze the block of text inside which contains the "insert" cursor. |
| |
| If the insert cursor is not in a squeezable block of text, give the |
| user a small warning and do nothing. |
| """ |
| # set tag_name to the first valid tag found on the "insert" cursor |
| tag_names = self.text.tag_names(tk.INSERT) |
| for tag_name in ("stdout", "stderr"): |
| if tag_name in tag_names: |
| break |
| else: |
| # the insert cursor doesn't have a "stdout" or "stderr" tag |
| self.text.bell() |
| return "break" |
| |
| # find the range to squeeze |
| start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c") |
| s = self.text.get(start, end) |
| |
| # if the last char is a newline, remove it from the range |
| if len(s) > 0 and s[-1] == '\n': |
| end = self.text.index("%s-1c" % end) |
| s = s[:-1] |
| |
| # delete the text |
| self.base_text.delete(start, end) |
| |
| # prepare an ExpandingButton |
| numoflines = self.count_lines(s) |
| expandingbutton = ExpandingButton(s, tag_name, numoflines, self) |
| |
| # insert the ExpandingButton to the Text |
| self.text.window_create(start, window=expandingbutton, |
| padx=3, pady=5) |
| |
| # insert the ExpandingButton to the list of ExpandingButtons, while |
| # keeping the list ordered according to the position of the buttons in |
| # the Text widget |
| i = len(self.expandingbuttons) |
| while i > 0 and self.text.compare(self.expandingbuttons[i-1], |
| ">", expandingbutton): |
| i -= 1 |
| self.expandingbuttons.insert(i, expandingbutton) |
| |
| return "break" |
| |
| |
| Squeezer.reload() |
| |
| |
| if __name__ == "__main__": |
| from unittest import main |
| main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False) |
| |
| # Add htest. |