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-clicking 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
18
19import tkinter as tk
20from tkinter import messagebox
21
22from idlelib.config import idleConf
23from idlelib.textview import view_text
24from idlelib.tooltip import Hovertip
25from idlelib import macosx
26
27
28def count_lines_with_wrapping(s, linewidth=80):
29    """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    """
36    tabwidth = 8  # Currently always true in Shell.
37    pos = 0
38    linecount = 1
39    current_column = 0
40
41    for m in re.finditer(r"[\t\n]", s):
42        # Process the normal chars up to tab or newline.
43        numchars = m.start() - pos
44        pos += numchars
45        current_column += numchars
46
47        # Deal with tab or newline.
48        if s[pos] == '\n':
49            # Avoid the `current_column == 0` edge-case, and while we're
50            # at it, don't bother adding 0.
51            if current_column > linewidth:
52                # 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.
57                linecount += (current_column - 1) // linewidth
58            linecount += 1
59            current_column = 0
60        else:
61            assert s[pos] == '\t'
62            current_column += tabwidth - (current_column % tabwidth)
63
64            # If a tab passes the end of the line, consider the entire
65            # tab as being on the next line.
66            if current_column > linewidth:
67                linecount += 1
68                current_column = tabwidth
69
70        pos += 1 # After the tab or newline.
71
72    # Process remaining chars (no more tabs or newlines).
73    current_column += len(s) - pos
74    # Avoid divmod(-1, linewidth).
75    if current_column > 0:
76        linecount += (current_column - 1) // linewidth
77    else:
78        # Text ended with newline; don't count an extra line after it.
79        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
102        # The base Text widget is needed to change text before iomark.
103        self.base_text = editwin.per.bottom
104
105        line_plurality = "lines" if numoflines != 1 else "line"
106        button_text = f"Squeezed text ({numoflines} {line_plurality})."
107        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)
121        self.selection_handle(  # X windows only.
122            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 = messagebox.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=messagebox.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 = (
184        # Item structure: (label, method_name).
185        ('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
225        # 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.
230        self.base_text = editwin.per.bottom
231
232        # 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
239        self.expandingbuttons = []
240
241        # 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)
247
248            # 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)
258
259            # Create an ExpandingButton instance.
260            expandingbutton = ExpandingButton(s, tags, numoflines, self)
261
262            # 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)
269
270            # Add the ExpandingButton to the Squeezer's list.
271            self.expandingbuttons.append(expandingbutton)
272
273        editwin.write = mywrite
274
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        """
286        return count_lines_with_wrapping(s, self.editwin.width)
287
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        """
296        # Set tag_name to the first valid tag found on the "insert" cursor.
297        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:
302            # The insert cursor doesn't have a "stdout" or "stderr" tag.
303            self.text.bell()
304            return "break"
305
306        # Find the range to squeeze.
307        start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
308        s = self.text.get(start, end)
309
310        # If the last char is a newline, remove it from the range.
311        if len(s) > 0 and s[-1] == '\n':
312            end = self.text.index("%s-1c" % end)
313            s = s[:-1]
314
315        # Delete the text.
316        self.base_text.delete(start, end)
317
318        # Prepare an ExpandingButton.
319        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
326        # 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.
329        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.
346