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