• Home
  • History
  • Annotate
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""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
18import weakref
19
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
30def count_lines_with_wrapping(s, linewidth=80):
31    """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    """
38    tabwidth = 8  # Currently always true in Shell.
39    pos = 0
40    linecount = 1
41    current_column = 0
42
43    for m in re.finditer(r"[\t\n]", s):
44        # Process the normal chars up to tab or newline.
45        numchars = m.start() - pos
46        pos += numchars
47        current_column += numchars
48
49        # Deal with tab or newline.
50        if s[pos] == '\n':
51            # Avoid the `current_column == 0` edge-case, and while we're
52            # at it, don't bother adding 0.
53            if current_column > linewidth:
54                # 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.
59                linecount += (current_column - 1) // linewidth
60            linecount += 1
61            current_column = 0
62        else:
63            assert s[pos] == '\t'
64            current_column += tabwidth - (current_column % tabwidth)
65
66            # If a tab passes the end of the line, consider the entire
67            # tab as being on the next line.
68            if current_column > linewidth:
69                linecount += 1
70                current_column = tabwidth
71
72        pos += 1 # After the tab or newline.
73
74    # Process remaining chars (no more tabs or newlines).
75    current_column += len(s) - pos
76    # Avoid divmod(-1, linewidth).
77    if current_column > 0:
78        linecount += (current_column - 1) // linewidth
79    else:
80        # Text ended with newline; don't count an extra line after it.
81        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
104        # The base Text widget is needed to change text before iomark.
105        self.base_text = editwin.per.bottom
106
107        line_plurality = "lines" if numoflines != 1 else "line"
108        button_text = f"Squeezed text ({numoflines} {line_plurality})."
109        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)
123        self.selection_handle(  # X windows only.
124            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 = (
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
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    _instance_weakref = None
207
208    @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
216        # 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
224    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
237        # 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.
242        self.base_text = editwin.per.bottom
243
244        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
254        self.expandingbuttons = []
255
256        # 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)
262
263            # 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)
273
274            # Create an ExpandingButton instance.
275            expandingbutton = ExpandingButton(s, tags, numoflines, self)
276
277            # 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)
284
285            # Add the ExpandingButton to the Squeezer's list.
286            self.expandingbuttons.append(expandingbutton)
287
288        editwin.write = mywrite
289
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        """
301        linewidth = self.get_line_width()
302        return count_lines_with_wrapping(s, linewidth)
303
304    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
309
310        # 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
314
315    def load_font(self):
316        text = self.base_text
317        self.zero_char_width = \
318            Font(text, font=text.cget('font')).measure('0')
319
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        """
328        # Set tag_name to the first valid tag found on the "insert" cursor.
329        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:
334            # The insert cursor doesn't have a "stdout" or "stderr" tag.
335            self.text.bell()
336            return "break"
337
338        # Find the range to squeeze.
339        start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
340        s = self.text.get(start, end)
341
342        # If the last char is a newline, remove it from the range.
343        if len(s) > 0 and s[-1] == '\n':
344            end = self.text.index("%s-1c" % end)
345            s = s[:-1]
346
347        # Delete the text.
348        self.base_text.delete(start, end)
349
350        # Prepare an ExpandingButton.
351        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
358        # 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.
361        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.
378