diff options
Diffstat (limited to 'Lib/idlelib/squeezer.py')
-rw-r--r-- | Lib/idlelib/squeezer.py | 355 |
1 files changed, 355 insertions, 0 deletions
diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py new file mode 100644 index 0000000000..f5aac813a1 --- /dev/null +++ b/Lib/idlelib/squeezer.py @@ -0,0 +1,355 @@ +"""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. |