blob: 929c3fd3a507f46cd5934080054d44863fe55e1b [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
Terry Jan Reedy879986d2021-01-25 06:33:18 -050020from tkinter import messagebox
Tal Einat604e7b92018-09-25 15:10:14 +030021
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:
Terry Jan Reedy879986d2021-01-25 06:33:18 -0500150 confirm = messagebox.askokcancel(
Tal Einat604e7b92018-09-25 15:10:14 +0300151 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)),
Terry Jan Reedy879986d2021-01-25 06:33:18 -0500158 default=messagebox.CANCEL,
Tal Einat604e7b92018-09-25 15:10:14 +0300159 parent=self.text)
160 if not confirm:
161 return "break"
162
Tal Einat15d38612021-04-29 01:27:55 +0300163 index = self.text.index(self)
164 self.base_text.insert(index, self.s, self.tags)
Tal Einat604e7b92018-09-25 15:10:14 +0300165 self.base_text.delete(self)
Tal Einat15d38612021-04-29 01:27:55 +0300166 self.editwin.on_squeezed_expand(index, self.s, self.tags)
Tal Einat604e7b92018-09-25 15:10:14 +0300167 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 """
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
Tal Einat39a33e92019-01-13 17:01:50 +0200227 # 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.
Tal Einat604e7b92018-09-25 15:10:14 +0300232 self.base_text = editwin.per.bottom
233
Tal Einat39a33e92019-01-13 17:01:50 +0200234 # 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
Tal Einat604e7b92018-09-25 15:10:14 +0300241 self.expandingbuttons = []
Tal Einat604e7b92018-09-25 15:10:14 +0300242
Tal Einat39a33e92019-01-13 17:01:50 +0200243 # 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)
Tal Einat604e7b92018-09-25 15:10:14 +0300249
Tal Einat39a33e92019-01-13 17:01:50 +0200250 # 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)
Tal Einat604e7b92018-09-25 15:10:14 +0300260
Tal Einat39a33e92019-01-13 17:01:50 +0200261 # Create an ExpandingButton instance.
262 expandingbutton = ExpandingButton(s, tags, numoflines, self)
Tal Einat604e7b92018-09-25 15:10:14 +0300263
Tal Einat39a33e92019-01-13 17:01:50 +0200264 # 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)
Tal Einat604e7b92018-09-25 15:10:14 +0300271
Tal Einat39a33e92019-01-13 17:01:50 +0200272 # Add the ExpandingButton to the Squeezer's list.
273 self.expandingbuttons.append(expandingbutton)
274
275 editwin.write = mywrite
Tal Einat604e7b92018-09-25 15:10:14 +0300276
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 """
Tal Einatd4b4c002019-08-25 08:52:58 +0300288 return count_lines_with_wrapping(s, self.editwin.width)
Tal Einat604e7b92018-09-25 15:10:14 +0300289
Tal Einat15d38612021-04-29 01:27:55 +0300290 def squeeze_current_text(self):
291 """Squeeze the text block where the insertion cursor is.
Tal Einat604e7b92018-09-25 15:10:14 +0300292
Tal Einat15d38612021-04-29 01:27:55 +0300293 If the cursor is not in a squeezable block of text, give the
Tal Einat604e7b92018-09-25 15:10:14 +0300294 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.