123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345 |
- """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-clicking 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 import messagebox
- 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):
- """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.
- """
- tabwidth = 8 # Currently always true in Shell.
- 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':
- # Avoid the `current_column == 0` edge-case, and while we're
- # at it, don't bother adding 0.
- if current_column > linewidth:
- # If the current column 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
- # dividing a non-empty line.
- linecount += (current_column - 1) // linewidth
- 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.
- # 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:
- # Text ended with 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 is needed to change text before iomark.
- self.base_text = editwin.per.bottom
- line_plurality = "lines" if numoflines != 1 else "line"
- button_text = f"Squeezed text ({numoflines} {line_plurality})."
- 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( # X windows only.
- 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 = messagebox.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=messagebox.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
- # Twice the text widget's border width and internal padding;
- # pre-calculated here for the get_line_width() method.
- self.window_width_delta = 2 * (
- int(text.cget('border')) +
- int(text.cget('padx'))
- )
- self.expandingbuttons = []
- # Replace the PyShell instance's 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.
- auto_squeeze_min_lines = self.auto_squeeze_min_lines
- # First, a very quick check to skip very short texts.
- if len(s) < auto_squeeze_min_lines:
- return write(s, tags)
- # Now the full line-count check.
- numoflines = self.count_lines(s)
- if numoflines < 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.
- """
- return count_lines_with_wrapping(s, self.editwin.width)
- 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.
|