blob: 8960356799a480539b72340d37524689fdc63a48 [file] [log] [blame]
Tal Einat604e7b92018-09-25 15:10:14 +03001"""An IDLE extension to avoid having very long texts printed in the shell.
2
3A common problem in IDLE's interactive shell is printing of large amounts of
4text into the shell. This makes looking at the previous history difficult.
5Worse, this can cause IDLE to become very slow, even to the point of being
6completely unusable.
7
8This extension will automatically replace long texts with a small button.
9Double-cliking this button will remove it and insert the original text instead.
10Middle-clicking will copy the text to the clipboard. Right-clicking will open
11the text in a separate viewing window.
12
13Additionally, any output can be manually "squeezed" by the user. This includes
14output written to the standard error stream ("stderr"), such as exception
15messages and their tracebacks.
16"""
17import re
18
19import tkinter as tk
20from tkinter.font import Font
21import tkinter.messagebox as tkMessageBox
22
23from idlelib.config import idleConf
24from idlelib.textview import view_text
25from idlelib.tooltip import Hovertip
26from idlelib import macosx
27
28
29def count_lines_with_wrapping(s, linewidth=80, tabwidth=8):
30 """Count the number of lines in a given string.
31
32 Lines are counted as if the string was wrapped so that lines are never over
33 linewidth characters long.
34
35 Tabs are considered tabwidth characters long.
36 """
37 pos = 0
38 linecount = 1
39 current_column = 0
40
41 for m in re.finditer(r"[\t\n]", s):
42 # process the normal chars up to tab or newline
43 numchars = m.start() - pos
44 pos += numchars
45 current_column += numchars
46
47 # deal with tab or newline
48 if s[pos] == '\n':
Tal Einat44a79cc2018-12-24 14:05:51 +020049 # Avoid the `current_column == 0` edge-case, and while we're at it,
50 # don't bother adding 0.
51 if current_column > linewidth:
52 # If the current column was exactly linewidth, divmod would give
53 # (1,0), even though a new line hadn't yet been started. The same
54 # is true if length is any exact multiple of linewidth. Therefore,
55 # subtract 1 before dividing a non-empty line.
56 linecount += (current_column - 1) // linewidth
Tal Einat604e7b92018-09-25 15:10:14 +030057 linecount += 1
58 current_column = 0
59 else:
60 assert s[pos] == '\t'
61 current_column += tabwidth - (current_column % tabwidth)
62
63 # if a tab passes the end of the line, consider the entire tab as
64 # being on the next line
65 if current_column > linewidth:
66 linecount += 1
67 current_column = tabwidth
68
69 pos += 1 # after the tab or newline
70
Tal Einat604e7b92018-09-25 15:10:14 +030071 # process remaining chars (no more tabs or newlines)
72 current_column += len(s) - pos
73 # avoid divmod(-1, linewidth)
74 if current_column > 0:
75 linecount += (current_column - 1) // linewidth
76 else:
77 # the text ended with a newline; don't count an extra line after it
78 linecount -= 1
79
80 return linecount
81
82
83class ExpandingButton(tk.Button):
84 """Class for the "squeezed" text buttons used by Squeezer
85
86 These buttons are displayed inside a Tk Text widget in place of text. A
87 user can then use the button to replace it with the original text, copy
88 the original text to the clipboard or view the original text in a separate
89 window.
90
91 Each button is tied to a Squeezer instance, and it knows to update the
92 Squeezer instance when it is expanded (and therefore removed).
93 """
94 def __init__(self, s, tags, numoflines, squeezer):
95 self.s = s
96 self.tags = tags
97 self.numoflines = numoflines
98 self.squeezer = squeezer
99 self.editwin = editwin = squeezer.editwin
100 self.text = text = editwin.text
101
102 # the base Text widget of the PyShell object, used to change text
103 # before the iomark
104 self.base_text = editwin.per.bottom
105
Tal Einat44a79cc2018-12-24 14:05:51 +0200106 line_plurality = "lines" if numoflines != 1 else "line"
107 button_text = f"Squeezed text ({numoflines} {line_plurality})."
Tal Einat604e7b92018-09-25 15:10:14 +0300108 tk.Button.__init__(self, text, text=button_text,
109 background="#FFFFC0", activebackground="#FFFFE0")
110
111 button_tooltip_text = (
112 "Double-click to expand, right-click for more options."
113 )
114 Hovertip(self, button_tooltip_text, hover_delay=80)
115
116 self.bind("<Double-Button-1>", self.expand)
117 if macosx.isAquaTk():
118 # AquaTk defines <2> as the right button, not <3>.
119 self.bind("<Button-2>", self.context_menu_event)
120 else:
121 self.bind("<Button-3>", self.context_menu_event)
122 self.selection_handle(
123 lambda offset, length: s[int(offset):int(offset) + int(length)])
124
125 self.is_dangerous = None
126 self.after_idle(self.set_is_dangerous)
127
128 def set_is_dangerous(self):
129 dangerous_line_len = 50 * self.text.winfo_width()
130 self.is_dangerous = (
131 self.numoflines > 1000 or
132 len(self.s) > 50000 or
133 any(
134 len(line_match.group(0)) >= dangerous_line_len
135 for line_match in re.finditer(r'[^\n]+', self.s)
136 )
137 )
138
139 def expand(self, event=None):
140 """expand event handler
141
142 This inserts the original text in place of the button in the Text
143 widget, removes the button and updates the Squeezer instance.
144
145 If the original text is dangerously long, i.e. expanding it could
146 cause a performance degradation, ask the user for confirmation.
147 """
148 if self.is_dangerous is None:
149 self.set_is_dangerous()
150 if self.is_dangerous:
151 confirm = tkMessageBox.askokcancel(
152 title="Expand huge output?",
153 message="\n\n".join([
154 "The squeezed output is very long: %d lines, %d chars.",
155 "Expanding it could make IDLE slow or unresponsive.",
156 "It is recommended to view or copy the output instead.",
157 "Really expand?"
158 ]) % (self.numoflines, len(self.s)),
159 default=tkMessageBox.CANCEL,
160 parent=self.text)
161 if not confirm:
162 return "break"
163
164 self.base_text.insert(self.text.index(self), self.s, self.tags)
165 self.base_text.delete(self)
166 self.squeezer.expandingbuttons.remove(self)
167
168 def copy(self, event=None):
169 """copy event handler
170
171 Copy the original text to the clipboard.
172 """
173 self.clipboard_clear()
174 self.clipboard_append(self.s)
175
176 def view(self, event=None):
177 """view event handler
178
179 View the original text in a separate text viewer window.
180 """
181 view_text(self.text, "Squeezed Output Viewer", self.s,
182 modal=False, wrap='none')
183
184 rmenu_specs = (
185 # item structure: (label, method_name)
186 ('copy', 'copy'),
187 ('view', 'view'),
188 )
189
190 def context_menu_event(self, event):
191 self.text.mark_set("insert", "@%d,%d" % (event.x, event.y))
192 rmenu = tk.Menu(self.text, tearoff=0)
193 for label, method_name in self.rmenu_specs:
194 rmenu.add_command(label=label, command=getattr(self, method_name))
195 rmenu.tk_popup(event.x_root, event.y_root)
196 return "break"
197
198
199class Squeezer:
200 """Replace long outputs in the shell with a simple button.
201
202 This avoids IDLE's shell slowing down considerably, and even becoming
203 completely unresponsive, when very long outputs are written.
204 """
205 @classmethod
206 def reload(cls):
207 """Load class variables from config."""
208 cls.auto_squeeze_min_lines = idleConf.GetOption(
209 "main", "PyShell", "auto-squeeze-min-lines",
210 type="int", default=50,
211 )
212
213 def __init__(self, editwin):
214 """Initialize settings for Squeezer.
215
216 editwin is the shell's Editor window.
217 self.text is the editor window text widget.
218 self.base_test is the actual editor window Tk text widget, rather than
219 EditorWindow's wrapper.
220 self.expandingbuttons is the list of all buttons representing
221 "squeezed" output.
222 """
223 self.editwin = editwin
224 self.text = text = editwin.text
225
226 # Get the base Text widget of the PyShell object, used to change text
227 # before the iomark. PyShell deliberately disables changing text before
228 # the iomark via its 'text' attribute, which is actually a wrapper for
229 # the actual Text widget. Squeezer, however, needs to make such changes.
230 self.base_text = editwin.per.bottom
231
232 self.expandingbuttons = []
233 from idlelib.pyshell import PyShell # done here to avoid import cycle
234 if isinstance(editwin, PyShell):
235 # If we get a PyShell instance, replace its write method with a
236 # wrapper, which inserts an ExpandingButton instead of a long text.
237 def mywrite(s, tags=(), write=editwin.write):
238 # only auto-squeeze text which has just the "stdout" tag
239 if tags != "stdout":
240 return write(s, tags)
241
242 # only auto-squeeze text with at least the minimum
243 # configured number of lines
244 numoflines = self.count_lines(s)
245 if numoflines < self.auto_squeeze_min_lines:
246 return write(s, tags)
247
248 # create an ExpandingButton instance
249 expandingbutton = ExpandingButton(s, tags, numoflines,
250 self)
251
252 # insert the ExpandingButton into the Text widget
253 text.mark_gravity("iomark", tk.RIGHT)
254 text.window_create("iomark", window=expandingbutton,
255 padx=3, pady=5)
256 text.see("iomark")
257 text.update()
258 text.mark_gravity("iomark", tk.LEFT)
259
260 # add the ExpandingButton to the Squeezer's list
261 self.expandingbuttons.append(expandingbutton)
262
263 editwin.write = mywrite
264
265 def count_lines(self, s):
266 """Count the number of lines in a given text.
267
268 Before calculation, the tab width and line length of the text are
269 fetched, so that up-to-date values are used.
270
271 Lines are counted as if the string was wrapped so that lines are never
272 over linewidth characters long.
273
274 Tabs are considered tabwidth characters long.
275 """
276 # Tab width is configurable
277 tabwidth = self.editwin.get_tk_tabwidth()
278
279 # Get the Text widget's size
280 linewidth = self.editwin.text.winfo_width()
281 # Deduct the border and padding
282 linewidth -= 2*sum([int(self.editwin.text.cget(opt))
283 for opt in ('border', 'padx')])
284
285 # Get the Text widget's font
286 font = Font(self.editwin.text, name=self.editwin.text.cget('font'))
287 # Divide the size of the Text widget by the font's width.
288 # According to Tk8.5 docs, the Text widget's width is set
289 # according to the width of its font's '0' (zero) character,
290 # so we will use this as an approximation.
291 # see: http://www.tcl.tk/man/tcl8.5/TkCmd/text.htm#M-width
292 linewidth //= font.measure('0')
293
294 return count_lines_with_wrapping(s, linewidth, tabwidth)
295
296 def squeeze_current_text_event(self, event):
297 """squeeze-current-text event handler
298
299 Squeeze the block of text inside which contains the "insert" cursor.
300
301 If the insert cursor is not in a squeezable block of text, give the
302 user a small warning and do nothing.
303 """
304 # set tag_name to the first valid tag found on the "insert" cursor
305 tag_names = self.text.tag_names(tk.INSERT)
306 for tag_name in ("stdout", "stderr"):
307 if tag_name in tag_names:
308 break
309 else:
310 # the insert cursor doesn't have a "stdout" or "stderr" tag
311 self.text.bell()
312 return "break"
313
314 # find the range to squeeze
315 start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
316 s = self.text.get(start, end)
317
318 # if the last char is a newline, remove it from the range
319 if len(s) > 0 and s[-1] == '\n':
320 end = self.text.index("%s-1c" % end)
321 s = s[:-1]
322
323 # delete the text
324 self.base_text.delete(start, end)
325
326 # prepare an ExpandingButton
327 numoflines = self.count_lines(s)
328 expandingbutton = ExpandingButton(s, tag_name, numoflines, self)
329
330 # insert the ExpandingButton to the Text
331 self.text.window_create(start, window=expandingbutton,
332 padx=3, pady=5)
333
334 # insert the ExpandingButton to the list of ExpandingButtons, while
335 # keeping the list ordered according to the position of the buttons in
336 # the Text widget
337 i = len(self.expandingbuttons)
338 while i > 0 and self.text.compare(self.expandingbuttons[i-1],
339 ">", expandingbutton):
340 i -= 1
341 self.expandingbuttons.insert(i, expandingbutton)
342
343 return "break"
344
345
346Squeezer.reload()
347
348
349if __name__ == "__main__":
350 from unittest import main
351 main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False)
352
353 # Add htest.