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