blob: 869498d753a2cd97476fbb759b6e3e676e1996fa [file] [log] [blame]
Miss Islington (bot)321f28c2018-09-25 05:38:45 -07001"""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
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080018import weakref
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070019
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
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080030def count_lines_with_wrapping(s, linewidth=80):
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070031 """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 """
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080038 tabwidth = 8 # Currently always true in Shell.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070039 pos = 0
40 linecount = 1
41 current_column = 0
42
43 for m in re.finditer(r"[\t\n]", s):
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080044 # Process the normal chars up to tab or newline.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070045 numchars = m.start() - pos
46 pos += numchars
47 current_column += numchars
48
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080049 # Deal with tab or newline.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070050 if s[pos] == '\n':
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080051 # Avoid the `current_column == 0` edge-case, and while we're
52 # at it, don't bother adding 0.
Miss Islington (bot)0e0cc552018-12-24 04:21:11 -080053 if current_column > linewidth:
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080054 # 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.
Miss Islington (bot)0e0cc552018-12-24 04:21:11 -080059 linecount += (current_column - 1) // linewidth
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070060 linecount += 1
61 current_column = 0
62 else:
63 assert s[pos] == '\t'
64 current_column += tabwidth - (current_column % tabwidth)
65
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080066 # If a tab passes the end of the line, consider the entire
67 # tab as being on the next line.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070068 if current_column > linewidth:
69 linecount += 1
70 current_column = tabwidth
71
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080072 pos += 1 # After the tab or newline.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070073
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080074 # Process remaining chars (no more tabs or newlines).
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070075 current_column += len(s) - pos
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080076 # Avoid divmod(-1, linewidth).
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070077 if current_column > 0:
78 linecount += (current_column - 1) // linewidth
79 else:
Miss Islington (bot)47bd7772019-01-13 08:43:08 -080080 # Text ended with newline; don't count an extra line after it.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -070081 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
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800104 # The base Text widget is needed to change text before iomark.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700105 self.base_text = editwin.per.bottom
106
Miss Islington (bot)0e0cc552018-12-24 04:21:11 -0800107 line_plurality = "lines" if numoflines != 1 else "line"
108 button_text = f"Squeezed text ({numoflines} {line_plurality})."
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700109 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)
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800123 self.selection_handle( # X windows only.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700124 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 = (
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800186 # Item structure: (label, method_name).
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700187 ('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 """
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800206 _instance_weakref = None
207
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700208 @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
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800216 # 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
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700224 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
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800237 # 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.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700242 self.base_text = editwin.per.bottom
243
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800244 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
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700254 self.expandingbuttons = []
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700255
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800256 # 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)
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700262
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800263 # 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)
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700273
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800274 # Create an ExpandingButton instance.
275 expandingbutton = ExpandingButton(s, tags, numoflines, self)
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700276
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800277 # 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)
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700284
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800285 # Add the ExpandingButton to the Squeezer's list.
286 self.expandingbuttons.append(expandingbutton)
287
288 editwin.write = mywrite
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700289
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 """
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800301 linewidth = self.get_line_width()
302 return count_lines_with_wrapping(s, linewidth)
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700303
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800304 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
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700309
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800310 # 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
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700314
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800315 def load_font(self):
316 text = self.base_text
317 self.zero_char_width = \
318 Font(text, font=text.cget('font')).measure('0')
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700319
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 """
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800328 # Set tag_name to the first valid tag found on the "insert" cursor.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700329 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:
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800334 # The insert cursor doesn't have a "stdout" or "stderr" tag.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700335 self.text.bell()
336 return "break"
337
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800338 # Find the range to squeeze.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700339 start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
340 s = self.text.get(start, end)
341
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800342 # If the last char is a newline, remove it from the range.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700343 if len(s) > 0 and s[-1] == '\n':
344 end = self.text.index("%s-1c" % end)
345 s = s[:-1]
346
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800347 # Delete the text.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700348 self.base_text.delete(start, end)
349
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800350 # Prepare an ExpandingButton.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700351 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
Miss Islington (bot)47bd7772019-01-13 08:43:08 -0800358 # 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.
Miss Islington (bot)321f28c2018-09-25 05:38:45 -0700361 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.