356 lines
13 KiB
Python
356 lines
13 KiB
Python
|
"""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.
|