summaryrefslogtreecommitdiff
path: root/Lib/idlelib/squeezer.py
diff options
context:
space:
mode:
Diffstat (limited to 'Lib/idlelib/squeezer.py')
-rw-r--r--Lib/idlelib/squeezer.py355
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.