blob: be1538a25fdedfc53281985398606dc427c67713 [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.
Xtreakd9677f32019-06-03 09:51:15 +05309Double-clicking this button will remove it and insert the original text instead.
Tal Einat604e7b92018-09-25 15:10:14 +030010Middle-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
Tal Einat604e7b92018-09-25 15:10:14 +030020import tkinter.messagebox as tkMessageBox
21
22from idlelib.config import idleConf
23from idlelib.textview import view_text
24from idlelib.tooltip import Hovertip
25from idlelib import macosx
26
27
Tal Einat39a33e92019-01-13 17:01:50 +020028def count_lines_with_wrapping(s, linewidth=80):
Tal Einat604e7b92018-09-25 15:10:14 +030029 """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 """
Tal Einat39a33e92019-01-13 17:01:50 +020036 tabwidth = 8 # Currently always true in Shell.
Tal Einat604e7b92018-09-25 15:10:14 +030037 pos = 0
38 linecount = 1
39 current_column = 0
40
41 for m in re.finditer(r"[\t\n]", s):
Tal Einat39a33e92019-01-13 17:01:50 +020042 # Process the normal chars up to tab or newline.
Tal Einat604e7b92018-09-25 15:10:14 +030043 numchars = m.start() - pos
44 pos += numchars
45 current_column += numchars
46
Tal Einat39a33e92019-01-13 17:01:50 +020047 # Deal with tab or newline.
Tal Einat604e7b92018-09-25 15:10:14 +030048 if s[pos] == '\n':
Tal Einat39a33e92019-01-13 17:01:50 +020049 # Avoid the `current_column == 0` edge-case, and while we're
50 # at it, don't bother adding 0.
Tal Einat44a79cc2018-12-24 14:05:51 +020051 if current_column > linewidth:
Tal Einat39a33e92019-01-13 17:01:50 +020052 # 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.
Tal Einat44a79cc2018-12-24 14:05:51 +020057 linecount += (current_column - 1) // linewidth
Tal Einat604e7b92018-09-25 15:10:14 +030058 linecount += 1
59 current_column = 0
60 else:
61 assert s[pos] == '\t'
62 current_column += tabwidth - (current_column % tabwidth)
63
Tal Einat39a33e92019-01-13 17:01:50 +020064 # If a tab passes the end of the line, consider the entire
65 # tab as being on the next line.
Tal Einat604e7b92018-09-25 15:10:14 +030066 if current_column > linewidth:
67 linecount += 1
68 current_column = tabwidth
69
Tal Einat39a33e92019-01-13 17:01:50 +020070 pos += 1 # After the tab or newline.
Tal Einat604e7b92018-09-25 15:10:14 +030071
Tal Einat39a33e92019-01-13 17:01:50 +020072 # Process remaining chars (no more tabs or newlines).
Tal Einat604e7b92018-09-25 15:10:14 +030073 current_column += len(s) - pos
Tal Einat39a33e92019-01-13 17:01:50 +020074 # Avoid divmod(-1, linewidth).
Tal Einat604e7b92018-09-25 15:10:14 +030075 if current_column > 0:
76 linecount += (current_column - 1) // linewidth
77 else:
Tal Einat39a33e92019-01-13 17:01:50 +020078 # Text ended with newline; don't count an extra line after it.
Tal Einat604e7b92018-09-25 15:10:14 +030079 linecount -= 1
80
81 return linecount
82
83
84class ExpandingButton(tk.Button):
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
Tal Einat39a33e92019-01-13 17:01:50 +0200102 # The base Text widget is needed to change text before iomark.
Tal Einat604e7b92018-09-25 15:10:14 +0300103 self.base_text = editwin.per.bottom
104
Tal Einat44a79cc2018-12-24 14:05:51 +0200105 line_plurality = "lines" if numoflines != 1 else "line"
106 button_text = f"Squeezed text ({numoflines} {line_plurality})."
Tal Einat604e7b92018-09-25 15:10:14 +0300107 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)
Tal Einat39a33e92019-01-13 17:01:50 +0200121 self.selection_handle( # X windows only.
Tal Einat604e7b92018-09-25 15:10:14 +0300122 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 = tkMessageBox.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=tkMessageBox.CANCEL,
159 parent=self.text)
160 if not confirm:
161 return "break"
162
163 self.base_text.insert(self.text.index(self), self.s, self.tags)
164 self.base_text.delete(self)
165 self.squeezer.expandingbuttons.remove(self)
166
167 def copy(self, event=None):
168 """copy event handler
169
170 Copy the original text to the clipboard.
171 """
172 self.clipboard_clear()
173 self.clipboard_append(self.s)
174
175 def view(self, event=None):
176 """view event handler
177
178 View the original text in a separate text viewer window.
179 """
180 view_text(self.text, "Squeezed Output Viewer", self.s,
181 modal=False, wrap='none')
182
183 rmenu_specs = (
Tal Einat39a33e92019-01-13 17:01:50 +0200184 # Item structure: (label, method_name).
Tal Einat604e7b92018-09-25 15:10:14 +0300185 ('copy', 'copy'),
186 ('view', 'view'),
187 )
188
189 def context_menu_event(self, event):
190 self.text.mark_set("insert", "@%d,%d" % (event.x, event.y))
191 rmenu = tk.Menu(self.text, tearoff=0)
192 for label, method_name in self.rmenu_specs:
193 rmenu.add_command(label=label, command=getattr(self, method_name))
194 rmenu.tk_popup(event.x_root, event.y_root)
195 return "break"
196
197
198class Squeezer:
199 """Replace long outputs in the shell with a simple button.
200
201 This avoids IDLE's shell slowing down considerably, and even becoming
202 completely unresponsive, when very long outputs are written.
203 """
204 @classmethod
205 def reload(cls):
206 """Load class variables from config."""
207 cls.auto_squeeze_min_lines = idleConf.GetOption(
208 "main", "PyShell", "auto-squeeze-min-lines",
209 type="int", default=50,
210 )
211
212 def __init__(self, editwin):
213 """Initialize settings for Squeezer.
214
215 editwin is the shell's Editor window.
216 self.text is the editor window text widget.
217 self.base_test is the actual editor window Tk text widget, rather than
218 EditorWindow's wrapper.
219 self.expandingbuttons is the list of all buttons representing
220 "squeezed" output.
221 """
222 self.editwin = editwin
223 self.text = text = editwin.text
224
Tal Einat39a33e92019-01-13 17:01:50 +0200225 # Get the base Text widget of the PyShell object, used to change
226 # text before the iomark. PyShell deliberately disables changing
227 # text before the iomark via its 'text' attribute, which is
228 # actually a wrapper for the actual Text widget. Squeezer,
229 # however, needs to make such changes.
Tal Einat604e7b92018-09-25 15:10:14 +0300230 self.base_text = editwin.per.bottom
231
Tal Einat39a33e92019-01-13 17:01:50 +0200232 # Twice the text widget's border width and internal padding;
233 # pre-calculated here for the get_line_width() method.
234 self.window_width_delta = 2 * (
235 int(text.cget('border')) +
236 int(text.cget('padx'))
237 )
238
Tal Einat604e7b92018-09-25 15:10:14 +0300239 self.expandingbuttons = []
Tal Einat604e7b92018-09-25 15:10:14 +0300240
Tal Einat39a33e92019-01-13 17:01:50 +0200241 # Replace the PyShell instance's write method with a wrapper,
242 # which inserts an ExpandingButton instead of a long text.
243 def mywrite(s, tags=(), write=editwin.write):
244 # Only auto-squeeze text which has just the "stdout" tag.
245 if tags != "stdout":
246 return write(s, tags)
Tal Einat604e7b92018-09-25 15:10:14 +0300247
Tal Einat39a33e92019-01-13 17:01:50 +0200248 # Only auto-squeeze text with at least the minimum
249 # configured number of lines.
250 auto_squeeze_min_lines = self.auto_squeeze_min_lines
251 # First, a very quick check to skip very short texts.
252 if len(s) < auto_squeeze_min_lines:
253 return write(s, tags)
254 # Now the full line-count check.
255 numoflines = self.count_lines(s)
256 if numoflines < auto_squeeze_min_lines:
257 return write(s, tags)
Tal Einat604e7b92018-09-25 15:10:14 +0300258
Tal Einat39a33e92019-01-13 17:01:50 +0200259 # Create an ExpandingButton instance.
260 expandingbutton = ExpandingButton(s, tags, numoflines, self)
Tal Einat604e7b92018-09-25 15:10:14 +0300261
Tal Einat39a33e92019-01-13 17:01:50 +0200262 # Insert the ExpandingButton into the Text widget.
263 text.mark_gravity("iomark", tk.RIGHT)
264 text.window_create("iomark", window=expandingbutton,
265 padx=3, pady=5)
266 text.see("iomark")
267 text.update()
268 text.mark_gravity("iomark", tk.LEFT)
Tal Einat604e7b92018-09-25 15:10:14 +0300269
Tal Einat39a33e92019-01-13 17:01:50 +0200270 # Add the ExpandingButton to the Squeezer's list.
271 self.expandingbuttons.append(expandingbutton)
272
273 editwin.write = mywrite
Tal Einat604e7b92018-09-25 15:10:14 +0300274
275 def count_lines(self, s):
276 """Count the number of lines in a given text.
277
278 Before calculation, the tab width and line length of the text are
279 fetched, so that up-to-date values are used.
280
281 Lines are counted as if the string was wrapped so that lines are never
282 over linewidth characters long.
283
284 Tabs are considered tabwidth characters long.
285 """
Tal Einatd4b4c002019-08-25 08:52:58 +0300286 return count_lines_with_wrapping(s, self.editwin.width)
Tal Einat604e7b92018-09-25 15:10:14 +0300287
288 def squeeze_current_text_event(self, event):
289 """squeeze-current-text event handler
290
291 Squeeze the block of text inside which contains the "insert" cursor.
292
293 If the insert cursor is not in a squeezable block of text, give the
294 user a small warning and do nothing.
295 """
Tal Einat39a33e92019-01-13 17:01:50 +0200296 # Set tag_name to the first valid tag found on the "insert" cursor.
Tal Einat604e7b92018-09-25 15:10:14 +0300297 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:
Tal Einat39a33e92019-01-13 17:01:50 +0200302 # The insert cursor doesn't have a "stdout" or "stderr" tag.
Tal Einat604e7b92018-09-25 15:10:14 +0300303 self.text.bell()
304 return "break"
305
Tal Einat39a33e92019-01-13 17:01:50 +0200306 # Find the range to squeeze.
Tal Einat604e7b92018-09-25 15:10:14 +0300307 start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
308 s = self.text.get(start, end)
309
Tal Einat39a33e92019-01-13 17:01:50 +0200310 # If the last char is a newline, remove it from the range.
Tal Einat604e7b92018-09-25 15:10:14 +0300311 if len(s) > 0 and s[-1] == '\n':
312 end = self.text.index("%s-1c" % end)
313 s = s[:-1]
314
Tal Einat39a33e92019-01-13 17:01:50 +0200315 # Delete the text.
Tal Einat604e7b92018-09-25 15:10:14 +0300316 self.base_text.delete(start, end)
317
Tal Einat39a33e92019-01-13 17:01:50 +0200318 # Prepare an ExpandingButton.
Tal Einat604e7b92018-09-25 15:10:14 +0300319 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
Tal Einat39a33e92019-01-13 17:01:50 +0200326 # 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.
Tal Einat604e7b92018-09-25 15:10:14 +0300329 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
338Squeezer.reload()
339
340
341if __name__ == "__main__":
342 from unittest import main
343 main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False)
344
345 # Add htest.