blob: 869498d753a2cd97476fbb759b6e3e676e1996fa [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
Tal Einat39a33e92019-01-13 17:01:50 +020018import weakref
Tal Einat604e7b92018-09-25 15:10:14 +030019
20import tkinter as tk
21from tkinter.font import Font
22import tkinter.messagebox as tkMessageBox
23
24from idlelib.config import idleConf
25from idlelib.textview import view_text
26from idlelib.tooltip import Hovertip
27from idlelib import macosx
28
29
Tal Einat39a33e92019-01-13 17:01:50 +020030def count_lines_with_wrapping(s, linewidth=80):
Tal Einat604e7b92018-09-25 15:10:14 +030031 """Count the number of lines in a given string.
32
33 Lines are counted as if the string was wrapped so that lines are never over
34 linewidth characters long.
35
36 Tabs are considered tabwidth characters long.
37 """
Tal Einat39a33e92019-01-13 17:01:50 +020038 tabwidth = 8 # Currently always true in Shell.
Tal Einat604e7b92018-09-25 15:10:14 +030039 pos = 0
40 linecount = 1
41 current_column = 0
42
43 for m in re.finditer(r"[\t\n]", s):
Tal Einat39a33e92019-01-13 17:01:50 +020044 # Process the normal chars up to tab or newline.
Tal Einat604e7b92018-09-25 15:10:14 +030045 numchars = m.start() - pos
46 pos += numchars
47 current_column += numchars
48
Tal Einat39a33e92019-01-13 17:01:50 +020049 # Deal with tab or newline.
Tal Einat604e7b92018-09-25 15:10:14 +030050 if s[pos] == '\n':
Tal Einat39a33e92019-01-13 17:01:50 +020051 # Avoid the `current_column == 0` edge-case, and while we're
52 # at it, don't bother adding 0.
Tal Einat44a79cc2018-12-24 14:05:51 +020053 if current_column > linewidth:
Tal Einat39a33e92019-01-13 17:01:50 +020054 # If the current column was exactly linewidth, divmod
55 # would give (1,0), even though a new line hadn't yet
56 # been started. The same is true if length is any exact
57 # multiple of linewidth. Therefore, subtract 1 before
58 # dividing a non-empty line.
Tal Einat44a79cc2018-12-24 14:05:51 +020059 linecount += (current_column - 1) // linewidth
Tal Einat604e7b92018-09-25 15:10:14 +030060 linecount += 1
61 current_column = 0
62 else:
63 assert s[pos] == '\t'
64 current_column += tabwidth - (current_column % tabwidth)
65
Tal Einat39a33e92019-01-13 17:01:50 +020066 # If a tab passes the end of the line, consider the entire
67 # tab as being on the next line.
Tal Einat604e7b92018-09-25 15:10:14 +030068 if current_column > linewidth:
69 linecount += 1
70 current_column = tabwidth
71
Tal Einat39a33e92019-01-13 17:01:50 +020072 pos += 1 # After the tab or newline.
Tal Einat604e7b92018-09-25 15:10:14 +030073
Tal Einat39a33e92019-01-13 17:01:50 +020074 # Process remaining chars (no more tabs or newlines).
Tal Einat604e7b92018-09-25 15:10:14 +030075 current_column += len(s) - pos
Tal Einat39a33e92019-01-13 17:01:50 +020076 # Avoid divmod(-1, linewidth).
Tal Einat604e7b92018-09-25 15:10:14 +030077 if current_column > 0:
78 linecount += (current_column - 1) // linewidth
79 else:
Tal Einat39a33e92019-01-13 17:01:50 +020080 # Text ended with newline; don't count an extra line after it.
Tal Einat604e7b92018-09-25 15:10:14 +030081 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
Tal Einat39a33e92019-01-13 17:01:50 +0200104 # The base Text widget is needed to change text before iomark.
Tal Einat604e7b92018-09-25 15:10:14 +0300105 self.base_text = editwin.per.bottom
106
Tal Einat44a79cc2018-12-24 14:05:51 +0200107 line_plurality = "lines" if numoflines != 1 else "line"
108 button_text = f"Squeezed text ({numoflines} {line_plurality})."
Tal Einat604e7b92018-09-25 15:10:14 +0300109 tk.Button.__init__(self, text, text=button_text,
110 background="#FFFFC0", activebackground="#FFFFE0")
111
112 button_tooltip_text = (
113 "Double-click to expand, right-click for more options."
114 )
115 Hovertip(self, button_tooltip_text, hover_delay=80)
116
117 self.bind("<Double-Button-1>", self.expand)
118 if macosx.isAquaTk():
119 # AquaTk defines <2> as the right button, not <3>.
120 self.bind("<Button-2>", self.context_menu_event)
121 else:
122 self.bind("<Button-3>", self.context_menu_event)
Tal Einat39a33e92019-01-13 17:01:50 +0200123 self.selection_handle( # X windows only.
Tal Einat604e7b92018-09-25 15:10:14 +0300124 lambda offset, length: s[int(offset):int(offset) + int(length)])
125
126 self.is_dangerous = None
127 self.after_idle(self.set_is_dangerous)
128
129 def set_is_dangerous(self):
130 dangerous_line_len = 50 * self.text.winfo_width()
131 self.is_dangerous = (
132 self.numoflines > 1000 or
133 len(self.s) > 50000 or
134 any(
135 len(line_match.group(0)) >= dangerous_line_len
136 for line_match in re.finditer(r'[^\n]+', self.s)
137 )
138 )
139
140 def expand(self, event=None):
141 """expand event handler
142
143 This inserts the original text in place of the button in the Text
144 widget, removes the button and updates the Squeezer instance.
145
146 If the original text is dangerously long, i.e. expanding it could
147 cause a performance degradation, ask the user for confirmation.
148 """
149 if self.is_dangerous is None:
150 self.set_is_dangerous()
151 if self.is_dangerous:
152 confirm = tkMessageBox.askokcancel(
153 title="Expand huge output?",
154 message="\n\n".join([
155 "The squeezed output is very long: %d lines, %d chars.",
156 "Expanding it could make IDLE slow or unresponsive.",
157 "It is recommended to view or copy the output instead.",
158 "Really expand?"
159 ]) % (self.numoflines, len(self.s)),
160 default=tkMessageBox.CANCEL,
161 parent=self.text)
162 if not confirm:
163 return "break"
164
165 self.base_text.insert(self.text.index(self), self.s, self.tags)
166 self.base_text.delete(self)
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 = (
Tal Einat39a33e92019-01-13 17:01:50 +0200186 # Item structure: (label, method_name).
Tal Einat604e7b92018-09-25 15:10:14 +0300187 ('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
200class Squeezer:
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 """
Tal Einat39a33e92019-01-13 17:01:50 +0200206 _instance_weakref = None
207
Tal Einat604e7b92018-09-25 15:10:14 +0300208 @classmethod
209 def reload(cls):
210 """Load class variables from config."""
211 cls.auto_squeeze_min_lines = idleConf.GetOption(
212 "main", "PyShell", "auto-squeeze-min-lines",
213 type="int", default=50,
214 )
215
Tal Einat39a33e92019-01-13 17:01:50 +0200216 # Loading the font info requires a Tk root. IDLE doesn't rely
217 # on Tkinter's "default root", so the instance will reload
218 # font info using its editor windows's Tk root.
219 if cls._instance_weakref is not None:
220 instance = cls._instance_weakref()
221 if instance is not None:
222 instance.load_font()
223
Tal Einat604e7b92018-09-25 15:10:14 +0300224 def __init__(self, editwin):
225 """Initialize settings for Squeezer.
226
227 editwin is the shell's Editor window.
228 self.text is the editor window text widget.
229 self.base_test is the actual editor window Tk text widget, rather than
230 EditorWindow's wrapper.
231 self.expandingbuttons is the list of all buttons representing
232 "squeezed" output.
233 """
234 self.editwin = editwin
235 self.text = text = editwin.text
236
Tal Einat39a33e92019-01-13 17:01:50 +0200237 # Get the base Text widget of the PyShell object, used to change
238 # text before the iomark. PyShell deliberately disables changing
239 # text before the iomark via its 'text' attribute, which is
240 # actually a wrapper for the actual Text widget. Squeezer,
241 # however, needs to make such changes.
Tal Einat604e7b92018-09-25 15:10:14 +0300242 self.base_text = editwin.per.bottom
243
Tal Einat39a33e92019-01-13 17:01:50 +0200244 Squeezer._instance_weakref = weakref.ref(self)
245 self.load_font()
246
247 # Twice the text widget's border width and internal padding;
248 # pre-calculated here for the get_line_width() method.
249 self.window_width_delta = 2 * (
250 int(text.cget('border')) +
251 int(text.cget('padx'))
252 )
253
Tal Einat604e7b92018-09-25 15:10:14 +0300254 self.expandingbuttons = []
Tal Einat604e7b92018-09-25 15:10:14 +0300255
Tal Einat39a33e92019-01-13 17:01:50 +0200256 # Replace the PyShell instance's write method with a wrapper,
257 # which inserts an ExpandingButton instead of a long text.
258 def mywrite(s, tags=(), write=editwin.write):
259 # Only auto-squeeze text which has just the "stdout" tag.
260 if tags != "stdout":
261 return write(s, tags)
Tal Einat604e7b92018-09-25 15:10:14 +0300262
Tal Einat39a33e92019-01-13 17:01:50 +0200263 # Only auto-squeeze text with at least the minimum
264 # configured number of lines.
265 auto_squeeze_min_lines = self.auto_squeeze_min_lines
266 # First, a very quick check to skip very short texts.
267 if len(s) < auto_squeeze_min_lines:
268 return write(s, tags)
269 # Now the full line-count check.
270 numoflines = self.count_lines(s)
271 if numoflines < auto_squeeze_min_lines:
272 return write(s, tags)
Tal Einat604e7b92018-09-25 15:10:14 +0300273
Tal Einat39a33e92019-01-13 17:01:50 +0200274 # Create an ExpandingButton instance.
275 expandingbutton = ExpandingButton(s, tags, numoflines, self)
Tal Einat604e7b92018-09-25 15:10:14 +0300276
Tal Einat39a33e92019-01-13 17:01:50 +0200277 # Insert the ExpandingButton into the Text widget.
278 text.mark_gravity("iomark", tk.RIGHT)
279 text.window_create("iomark", window=expandingbutton,
280 padx=3, pady=5)
281 text.see("iomark")
282 text.update()
283 text.mark_gravity("iomark", tk.LEFT)
Tal Einat604e7b92018-09-25 15:10:14 +0300284
Tal Einat39a33e92019-01-13 17:01:50 +0200285 # Add the ExpandingButton to the Squeezer's list.
286 self.expandingbuttons.append(expandingbutton)
287
288 editwin.write = mywrite
Tal Einat604e7b92018-09-25 15:10:14 +0300289
290 def count_lines(self, s):
291 """Count the number of lines in a given text.
292
293 Before calculation, the tab width and line length of the text are
294 fetched, so that up-to-date values are used.
295
296 Lines are counted as if the string was wrapped so that lines are never
297 over linewidth characters long.
298
299 Tabs are considered tabwidth characters long.
300 """
Tal Einat39a33e92019-01-13 17:01:50 +0200301 linewidth = self.get_line_width()
302 return count_lines_with_wrapping(s, linewidth)
Tal Einat604e7b92018-09-25 15:10:14 +0300303
Tal Einat39a33e92019-01-13 17:01:50 +0200304 def get_line_width(self):
305 # The maximum line length in pixels: The width of the text
306 # widget, minus twice the border width and internal padding.
307 linewidth_pixels = \
308 self.base_text.winfo_width() - self.window_width_delta
Tal Einat604e7b92018-09-25 15:10:14 +0300309
Tal Einat39a33e92019-01-13 17:01:50 +0200310 # Divide the width of the Text widget by the font width,
311 # which is taken to be the width of '0' (zero).
312 # http://www.tcl.tk/man/tcl8.6/TkCmd/text.htm#M21
313 return linewidth_pixels // self.zero_char_width
Tal Einat604e7b92018-09-25 15:10:14 +0300314
Tal Einat39a33e92019-01-13 17:01:50 +0200315 def load_font(self):
316 text = self.base_text
317 self.zero_char_width = \
318 Font(text, font=text.cget('font')).measure('0')
Tal Einat604e7b92018-09-25 15:10:14 +0300319
320 def squeeze_current_text_event(self, event):
321 """squeeze-current-text event handler
322
323 Squeeze the block of text inside which contains the "insert" cursor.
324
325 If the insert cursor is not in a squeezable block of text, give the
326 user a small warning and do nothing.
327 """
Tal Einat39a33e92019-01-13 17:01:50 +0200328 # Set tag_name to the first valid tag found on the "insert" cursor.
Tal Einat604e7b92018-09-25 15:10:14 +0300329 tag_names = self.text.tag_names(tk.INSERT)
330 for tag_name in ("stdout", "stderr"):
331 if tag_name in tag_names:
332 break
333 else:
Tal Einat39a33e92019-01-13 17:01:50 +0200334 # The insert cursor doesn't have a "stdout" or "stderr" tag.
Tal Einat604e7b92018-09-25 15:10:14 +0300335 self.text.bell()
336 return "break"
337
Tal Einat39a33e92019-01-13 17:01:50 +0200338 # Find the range to squeeze.
Tal Einat604e7b92018-09-25 15:10:14 +0300339 start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
340 s = self.text.get(start, end)
341
Tal Einat39a33e92019-01-13 17:01:50 +0200342 # If the last char is a newline, remove it from the range.
Tal Einat604e7b92018-09-25 15:10:14 +0300343 if len(s) > 0 and s[-1] == '\n':
344 end = self.text.index("%s-1c" % end)
345 s = s[:-1]
346
Tal Einat39a33e92019-01-13 17:01:50 +0200347 # Delete the text.
Tal Einat604e7b92018-09-25 15:10:14 +0300348 self.base_text.delete(start, end)
349
Tal Einat39a33e92019-01-13 17:01:50 +0200350 # Prepare an ExpandingButton.
Tal Einat604e7b92018-09-25 15:10:14 +0300351 numoflines = self.count_lines(s)
352 expandingbutton = ExpandingButton(s, tag_name, numoflines, self)
353
354 # insert the ExpandingButton to the Text
355 self.text.window_create(start, window=expandingbutton,
356 padx=3, pady=5)
357
Tal Einat39a33e92019-01-13 17:01:50 +0200358 # Insert the ExpandingButton to the list of ExpandingButtons,
359 # while keeping the list ordered according to the position of
360 # the buttons in the Text widget.
Tal Einat604e7b92018-09-25 15:10:14 +0300361 i = len(self.expandingbuttons)
362 while i > 0 and self.text.compare(self.expandingbuttons[i-1],
363 ">", expandingbutton):
364 i -= 1
365 self.expandingbuttons.insert(i, expandingbutton)
366
367 return "break"
368
369
370Squeezer.reload()
371
372
373if __name__ == "__main__":
374 from unittest import main
375 main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False)
376
377 # Add htest.