17db96d56Sopenharmony_ci"""An IDLE extension to avoid having very long texts printed in the shell.
27db96d56Sopenharmony_ci
37db96d56Sopenharmony_ciA common problem in IDLE's interactive shell is printing of large amounts of
47db96d56Sopenharmony_citext into the shell. This makes looking at the previous history difficult.
57db96d56Sopenharmony_ciWorse, this can cause IDLE to become very slow, even to the point of being
67db96d56Sopenharmony_cicompletely unusable.
77db96d56Sopenharmony_ci
87db96d56Sopenharmony_ciThis extension will automatically replace long texts with a small button.
97db96d56Sopenharmony_ciDouble-clicking this button will remove it and insert the original text instead.
107db96d56Sopenharmony_ciMiddle-clicking will copy the text to the clipboard. Right-clicking will open
117db96d56Sopenharmony_cithe text in a separate viewing window.
127db96d56Sopenharmony_ci
137db96d56Sopenharmony_ciAdditionally, any output can be manually "squeezed" by the user. This includes
147db96d56Sopenharmony_cioutput written to the standard error stream ("stderr"), such as exception
157db96d56Sopenharmony_cimessages and their tracebacks.
167db96d56Sopenharmony_ci"""
177db96d56Sopenharmony_ciimport re
187db96d56Sopenharmony_ci
197db96d56Sopenharmony_ciimport tkinter as tk
207db96d56Sopenharmony_cifrom tkinter import messagebox
217db96d56Sopenharmony_ci
227db96d56Sopenharmony_cifrom idlelib.config import idleConf
237db96d56Sopenharmony_cifrom idlelib.textview import view_text
247db96d56Sopenharmony_cifrom idlelib.tooltip import Hovertip
257db96d56Sopenharmony_cifrom idlelib import macosx
267db96d56Sopenharmony_ci
277db96d56Sopenharmony_ci
287db96d56Sopenharmony_cidef count_lines_with_wrapping(s, linewidth=80):
297db96d56Sopenharmony_ci    """Count the number of lines in a given string.
307db96d56Sopenharmony_ci
317db96d56Sopenharmony_ci    Lines are counted as if the string was wrapped so that lines are never over
327db96d56Sopenharmony_ci    linewidth characters long.
337db96d56Sopenharmony_ci
347db96d56Sopenharmony_ci    Tabs are considered tabwidth characters long.
357db96d56Sopenharmony_ci    """
367db96d56Sopenharmony_ci    tabwidth = 8  # Currently always true in Shell.
377db96d56Sopenharmony_ci    pos = 0
387db96d56Sopenharmony_ci    linecount = 1
397db96d56Sopenharmony_ci    current_column = 0
407db96d56Sopenharmony_ci
417db96d56Sopenharmony_ci    for m in re.finditer(r"[\t\n]", s):
427db96d56Sopenharmony_ci        # Process the normal chars up to tab or newline.
437db96d56Sopenharmony_ci        numchars = m.start() - pos
447db96d56Sopenharmony_ci        pos += numchars
457db96d56Sopenharmony_ci        current_column += numchars
467db96d56Sopenharmony_ci
477db96d56Sopenharmony_ci        # Deal with tab or newline.
487db96d56Sopenharmony_ci        if s[pos] == '\n':
497db96d56Sopenharmony_ci            # Avoid the `current_column == 0` edge-case, and while we're
507db96d56Sopenharmony_ci            # at it, don't bother adding 0.
517db96d56Sopenharmony_ci            if current_column > linewidth:
527db96d56Sopenharmony_ci                # If the current column was exactly linewidth, divmod
537db96d56Sopenharmony_ci                # would give (1,0), even though a new line hadn't yet
547db96d56Sopenharmony_ci                # been started. The same is true if length is any exact
557db96d56Sopenharmony_ci                # multiple of linewidth. Therefore, subtract 1 before
567db96d56Sopenharmony_ci                # dividing a non-empty line.
577db96d56Sopenharmony_ci                linecount += (current_column - 1) // linewidth
587db96d56Sopenharmony_ci            linecount += 1
597db96d56Sopenharmony_ci            current_column = 0
607db96d56Sopenharmony_ci        else:
617db96d56Sopenharmony_ci            assert s[pos] == '\t'
627db96d56Sopenharmony_ci            current_column += tabwidth - (current_column % tabwidth)
637db96d56Sopenharmony_ci
647db96d56Sopenharmony_ci            # If a tab passes the end of the line, consider the entire
657db96d56Sopenharmony_ci            # tab as being on the next line.
667db96d56Sopenharmony_ci            if current_column > linewidth:
677db96d56Sopenharmony_ci                linecount += 1
687db96d56Sopenharmony_ci                current_column = tabwidth
697db96d56Sopenharmony_ci
707db96d56Sopenharmony_ci        pos += 1 # After the tab or newline.
717db96d56Sopenharmony_ci
727db96d56Sopenharmony_ci    # Process remaining chars (no more tabs or newlines).
737db96d56Sopenharmony_ci    current_column += len(s) - pos
747db96d56Sopenharmony_ci    # Avoid divmod(-1, linewidth).
757db96d56Sopenharmony_ci    if current_column > 0:
767db96d56Sopenharmony_ci        linecount += (current_column - 1) // linewidth
777db96d56Sopenharmony_ci    else:
787db96d56Sopenharmony_ci        # Text ended with newline; don't count an extra line after it.
797db96d56Sopenharmony_ci        linecount -= 1
807db96d56Sopenharmony_ci
817db96d56Sopenharmony_ci    return linecount
827db96d56Sopenharmony_ci
837db96d56Sopenharmony_ci
847db96d56Sopenharmony_ciclass ExpandingButton(tk.Button):
857db96d56Sopenharmony_ci    """Class for the "squeezed" text buttons used by Squeezer
867db96d56Sopenharmony_ci
877db96d56Sopenharmony_ci    These buttons are displayed inside a Tk Text widget in place of text. A
887db96d56Sopenharmony_ci    user can then use the button to replace it with the original text, copy
897db96d56Sopenharmony_ci    the original text to the clipboard or view the original text in a separate
907db96d56Sopenharmony_ci    window.
917db96d56Sopenharmony_ci
927db96d56Sopenharmony_ci    Each button is tied to a Squeezer instance, and it knows to update the
937db96d56Sopenharmony_ci    Squeezer instance when it is expanded (and therefore removed).
947db96d56Sopenharmony_ci    """
957db96d56Sopenharmony_ci    def __init__(self, s, tags, numoflines, squeezer):
967db96d56Sopenharmony_ci        self.s = s
977db96d56Sopenharmony_ci        self.tags = tags
987db96d56Sopenharmony_ci        self.numoflines = numoflines
997db96d56Sopenharmony_ci        self.squeezer = squeezer
1007db96d56Sopenharmony_ci        self.editwin = editwin = squeezer.editwin
1017db96d56Sopenharmony_ci        self.text = text = editwin.text
1027db96d56Sopenharmony_ci        # The base Text widget is needed to change text before iomark.
1037db96d56Sopenharmony_ci        self.base_text = editwin.per.bottom
1047db96d56Sopenharmony_ci
1057db96d56Sopenharmony_ci        line_plurality = "lines" if numoflines != 1 else "line"
1067db96d56Sopenharmony_ci        button_text = f"Squeezed text ({numoflines} {line_plurality})."
1077db96d56Sopenharmony_ci        tk.Button.__init__(self, text, text=button_text,
1087db96d56Sopenharmony_ci                           background="#FFFFC0", activebackground="#FFFFE0")
1097db96d56Sopenharmony_ci
1107db96d56Sopenharmony_ci        button_tooltip_text = (
1117db96d56Sopenharmony_ci            "Double-click to expand, right-click for more options."
1127db96d56Sopenharmony_ci        )
1137db96d56Sopenharmony_ci        Hovertip(self, button_tooltip_text, hover_delay=80)
1147db96d56Sopenharmony_ci
1157db96d56Sopenharmony_ci        self.bind("<Double-Button-1>", self.expand)
1167db96d56Sopenharmony_ci        if macosx.isAquaTk():
1177db96d56Sopenharmony_ci            # AquaTk defines <2> as the right button, not <3>.
1187db96d56Sopenharmony_ci            self.bind("<Button-2>", self.context_menu_event)
1197db96d56Sopenharmony_ci        else:
1207db96d56Sopenharmony_ci            self.bind("<Button-3>", self.context_menu_event)
1217db96d56Sopenharmony_ci        self.selection_handle(  # X windows only.
1227db96d56Sopenharmony_ci            lambda offset, length: s[int(offset):int(offset) + int(length)])
1237db96d56Sopenharmony_ci
1247db96d56Sopenharmony_ci        self.is_dangerous = None
1257db96d56Sopenharmony_ci        self.after_idle(self.set_is_dangerous)
1267db96d56Sopenharmony_ci
1277db96d56Sopenharmony_ci    def set_is_dangerous(self):
1287db96d56Sopenharmony_ci        dangerous_line_len = 50 * self.text.winfo_width()
1297db96d56Sopenharmony_ci        self.is_dangerous = (
1307db96d56Sopenharmony_ci            self.numoflines > 1000 or
1317db96d56Sopenharmony_ci            len(self.s) > 50000 or
1327db96d56Sopenharmony_ci            any(
1337db96d56Sopenharmony_ci                len(line_match.group(0)) >= dangerous_line_len
1347db96d56Sopenharmony_ci                for line_match in re.finditer(r'[^\n]+', self.s)
1357db96d56Sopenharmony_ci            )
1367db96d56Sopenharmony_ci        )
1377db96d56Sopenharmony_ci
1387db96d56Sopenharmony_ci    def expand(self, event=None):
1397db96d56Sopenharmony_ci        """expand event handler
1407db96d56Sopenharmony_ci
1417db96d56Sopenharmony_ci        This inserts the original text in place of the button in the Text
1427db96d56Sopenharmony_ci        widget, removes the button and updates the Squeezer instance.
1437db96d56Sopenharmony_ci
1447db96d56Sopenharmony_ci        If the original text is dangerously long, i.e. expanding it could
1457db96d56Sopenharmony_ci        cause a performance degradation, ask the user for confirmation.
1467db96d56Sopenharmony_ci        """
1477db96d56Sopenharmony_ci        if self.is_dangerous is None:
1487db96d56Sopenharmony_ci            self.set_is_dangerous()
1497db96d56Sopenharmony_ci        if self.is_dangerous:
1507db96d56Sopenharmony_ci            confirm = messagebox.askokcancel(
1517db96d56Sopenharmony_ci                title="Expand huge output?",
1527db96d56Sopenharmony_ci                message="\n\n".join([
1537db96d56Sopenharmony_ci                    "The squeezed output is very long: %d lines, %d chars.",
1547db96d56Sopenharmony_ci                    "Expanding it could make IDLE slow or unresponsive.",
1557db96d56Sopenharmony_ci                    "It is recommended to view or copy the output instead.",
1567db96d56Sopenharmony_ci                    "Really expand?"
1577db96d56Sopenharmony_ci                ]) % (self.numoflines, len(self.s)),
1587db96d56Sopenharmony_ci                default=messagebox.CANCEL,
1597db96d56Sopenharmony_ci                parent=self.text)
1607db96d56Sopenharmony_ci            if not confirm:
1617db96d56Sopenharmony_ci                return "break"
1627db96d56Sopenharmony_ci
1637db96d56Sopenharmony_ci        index = self.text.index(self)
1647db96d56Sopenharmony_ci        self.base_text.insert(index, self.s, self.tags)
1657db96d56Sopenharmony_ci        self.base_text.delete(self)
1667db96d56Sopenharmony_ci        self.editwin.on_squeezed_expand(index, self.s, self.tags)
1677db96d56Sopenharmony_ci        self.squeezer.expandingbuttons.remove(self)
1687db96d56Sopenharmony_ci
1697db96d56Sopenharmony_ci    def copy(self, event=None):
1707db96d56Sopenharmony_ci        """copy event handler
1717db96d56Sopenharmony_ci
1727db96d56Sopenharmony_ci        Copy the original text to the clipboard.
1737db96d56Sopenharmony_ci        """
1747db96d56Sopenharmony_ci        self.clipboard_clear()
1757db96d56Sopenharmony_ci        self.clipboard_append(self.s)
1767db96d56Sopenharmony_ci
1777db96d56Sopenharmony_ci    def view(self, event=None):
1787db96d56Sopenharmony_ci        """view event handler
1797db96d56Sopenharmony_ci
1807db96d56Sopenharmony_ci        View the original text in a separate text viewer window.
1817db96d56Sopenharmony_ci        """
1827db96d56Sopenharmony_ci        view_text(self.text, "Squeezed Output Viewer", self.s,
1837db96d56Sopenharmony_ci                  modal=False, wrap='none')
1847db96d56Sopenharmony_ci
1857db96d56Sopenharmony_ci    rmenu_specs = (
1867db96d56Sopenharmony_ci        # Item structure: (label, method_name).
1877db96d56Sopenharmony_ci        ('copy', 'copy'),
1887db96d56Sopenharmony_ci        ('view', 'view'),
1897db96d56Sopenharmony_ci    )
1907db96d56Sopenharmony_ci
1917db96d56Sopenharmony_ci    def context_menu_event(self, event):
1927db96d56Sopenharmony_ci        self.text.mark_set("insert", "@%d,%d" % (event.x, event.y))
1937db96d56Sopenharmony_ci        rmenu = tk.Menu(self.text, tearoff=0)
1947db96d56Sopenharmony_ci        for label, method_name in self.rmenu_specs:
1957db96d56Sopenharmony_ci            rmenu.add_command(label=label, command=getattr(self, method_name))
1967db96d56Sopenharmony_ci        rmenu.tk_popup(event.x_root, event.y_root)
1977db96d56Sopenharmony_ci        return "break"
1987db96d56Sopenharmony_ci
1997db96d56Sopenharmony_ci
2007db96d56Sopenharmony_ciclass Squeezer:
2017db96d56Sopenharmony_ci    """Replace long outputs in the shell with a simple button.
2027db96d56Sopenharmony_ci
2037db96d56Sopenharmony_ci    This avoids IDLE's shell slowing down considerably, and even becoming
2047db96d56Sopenharmony_ci    completely unresponsive, when very long outputs are written.
2057db96d56Sopenharmony_ci    """
2067db96d56Sopenharmony_ci    @classmethod
2077db96d56Sopenharmony_ci    def reload(cls):
2087db96d56Sopenharmony_ci        """Load class variables from config."""
2097db96d56Sopenharmony_ci        cls.auto_squeeze_min_lines = idleConf.GetOption(
2107db96d56Sopenharmony_ci            "main", "PyShell", "auto-squeeze-min-lines",
2117db96d56Sopenharmony_ci            type="int", default=50,
2127db96d56Sopenharmony_ci        )
2137db96d56Sopenharmony_ci
2147db96d56Sopenharmony_ci    def __init__(self, editwin):
2157db96d56Sopenharmony_ci        """Initialize settings for Squeezer.
2167db96d56Sopenharmony_ci
2177db96d56Sopenharmony_ci        editwin is the shell's Editor window.
2187db96d56Sopenharmony_ci        self.text is the editor window text widget.
2197db96d56Sopenharmony_ci        self.base_test is the actual editor window Tk text widget, rather than
2207db96d56Sopenharmony_ci            EditorWindow's wrapper.
2217db96d56Sopenharmony_ci        self.expandingbuttons is the list of all buttons representing
2227db96d56Sopenharmony_ci            "squeezed" output.
2237db96d56Sopenharmony_ci        """
2247db96d56Sopenharmony_ci        self.editwin = editwin
2257db96d56Sopenharmony_ci        self.text = text = editwin.text
2267db96d56Sopenharmony_ci
2277db96d56Sopenharmony_ci        # Get the base Text widget of the PyShell object, used to change
2287db96d56Sopenharmony_ci        # text before the iomark. PyShell deliberately disables changing
2297db96d56Sopenharmony_ci        # text before the iomark via its 'text' attribute, which is
2307db96d56Sopenharmony_ci        # actually a wrapper for the actual Text widget. Squeezer,
2317db96d56Sopenharmony_ci        # however, needs to make such changes.
2327db96d56Sopenharmony_ci        self.base_text = editwin.per.bottom
2337db96d56Sopenharmony_ci
2347db96d56Sopenharmony_ci        # Twice the text widget's border width and internal padding;
2357db96d56Sopenharmony_ci        # pre-calculated here for the get_line_width() method.
2367db96d56Sopenharmony_ci        self.window_width_delta = 2 * (
2377db96d56Sopenharmony_ci            int(text.cget('border')) +
2387db96d56Sopenharmony_ci            int(text.cget('padx'))
2397db96d56Sopenharmony_ci        )
2407db96d56Sopenharmony_ci
2417db96d56Sopenharmony_ci        self.expandingbuttons = []
2427db96d56Sopenharmony_ci
2437db96d56Sopenharmony_ci        # Replace the PyShell instance's write method with a wrapper,
2447db96d56Sopenharmony_ci        # which inserts an ExpandingButton instead of a long text.
2457db96d56Sopenharmony_ci        def mywrite(s, tags=(), write=editwin.write):
2467db96d56Sopenharmony_ci            # Only auto-squeeze text which has just the "stdout" tag.
2477db96d56Sopenharmony_ci            if tags != "stdout":
2487db96d56Sopenharmony_ci                return write(s, tags)
2497db96d56Sopenharmony_ci
2507db96d56Sopenharmony_ci            # Only auto-squeeze text with at least the minimum
2517db96d56Sopenharmony_ci            # configured number of lines.
2527db96d56Sopenharmony_ci            auto_squeeze_min_lines = self.auto_squeeze_min_lines
2537db96d56Sopenharmony_ci            # First, a very quick check to skip very short texts.
2547db96d56Sopenharmony_ci            if len(s) < auto_squeeze_min_lines:
2557db96d56Sopenharmony_ci                return write(s, tags)
2567db96d56Sopenharmony_ci            # Now the full line-count check.
2577db96d56Sopenharmony_ci            numoflines = self.count_lines(s)
2587db96d56Sopenharmony_ci            if numoflines < auto_squeeze_min_lines:
2597db96d56Sopenharmony_ci                return write(s, tags)
2607db96d56Sopenharmony_ci
2617db96d56Sopenharmony_ci            # Create an ExpandingButton instance.
2627db96d56Sopenharmony_ci            expandingbutton = ExpandingButton(s, tags, numoflines, self)
2637db96d56Sopenharmony_ci
2647db96d56Sopenharmony_ci            # Insert the ExpandingButton into the Text widget.
2657db96d56Sopenharmony_ci            text.mark_gravity("iomark", tk.RIGHT)
2667db96d56Sopenharmony_ci            text.window_create("iomark", window=expandingbutton,
2677db96d56Sopenharmony_ci                               padx=3, pady=5)
2687db96d56Sopenharmony_ci            text.see("iomark")
2697db96d56Sopenharmony_ci            text.update()
2707db96d56Sopenharmony_ci            text.mark_gravity("iomark", tk.LEFT)
2717db96d56Sopenharmony_ci
2727db96d56Sopenharmony_ci            # Add the ExpandingButton to the Squeezer's list.
2737db96d56Sopenharmony_ci            self.expandingbuttons.append(expandingbutton)
2747db96d56Sopenharmony_ci
2757db96d56Sopenharmony_ci        editwin.write = mywrite
2767db96d56Sopenharmony_ci
2777db96d56Sopenharmony_ci    def count_lines(self, s):
2787db96d56Sopenharmony_ci        """Count the number of lines in a given text.
2797db96d56Sopenharmony_ci
2807db96d56Sopenharmony_ci        Before calculation, the tab width and line length of the text are
2817db96d56Sopenharmony_ci        fetched, so that up-to-date values are used.
2827db96d56Sopenharmony_ci
2837db96d56Sopenharmony_ci        Lines are counted as if the string was wrapped so that lines are never
2847db96d56Sopenharmony_ci        over linewidth characters long.
2857db96d56Sopenharmony_ci
2867db96d56Sopenharmony_ci        Tabs are considered tabwidth characters long.
2877db96d56Sopenharmony_ci        """
2887db96d56Sopenharmony_ci        return count_lines_with_wrapping(s, self.editwin.width)
2897db96d56Sopenharmony_ci
2907db96d56Sopenharmony_ci    def squeeze_current_text(self):
2917db96d56Sopenharmony_ci        """Squeeze the text block where the insertion cursor is.
2927db96d56Sopenharmony_ci
2937db96d56Sopenharmony_ci        If the cursor is not in a squeezable block of text, give the
2947db96d56Sopenharmony_ci        user a small warning and do nothing.
2957db96d56Sopenharmony_ci        """
2967db96d56Sopenharmony_ci        # Set tag_name to the first valid tag found on the "insert" cursor.
2977db96d56Sopenharmony_ci        tag_names = self.text.tag_names(tk.INSERT)
2987db96d56Sopenharmony_ci        for tag_name in ("stdout", "stderr"):
2997db96d56Sopenharmony_ci            if tag_name in tag_names:
3007db96d56Sopenharmony_ci                break
3017db96d56Sopenharmony_ci        else:
3027db96d56Sopenharmony_ci            # The insert cursor doesn't have a "stdout" or "stderr" tag.
3037db96d56Sopenharmony_ci            self.text.bell()
3047db96d56Sopenharmony_ci            return "break"
3057db96d56Sopenharmony_ci
3067db96d56Sopenharmony_ci        # Find the range to squeeze.
3077db96d56Sopenharmony_ci        start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
3087db96d56Sopenharmony_ci        s = self.text.get(start, end)
3097db96d56Sopenharmony_ci
3107db96d56Sopenharmony_ci        # If the last char is a newline, remove it from the range.
3117db96d56Sopenharmony_ci        if len(s) > 0 and s[-1] == '\n':
3127db96d56Sopenharmony_ci            end = self.text.index("%s-1c" % end)
3137db96d56Sopenharmony_ci            s = s[:-1]
3147db96d56Sopenharmony_ci
3157db96d56Sopenharmony_ci        # Delete the text.
3167db96d56Sopenharmony_ci        self.base_text.delete(start, end)
3177db96d56Sopenharmony_ci
3187db96d56Sopenharmony_ci        # Prepare an ExpandingButton.
3197db96d56Sopenharmony_ci        numoflines = self.count_lines(s)
3207db96d56Sopenharmony_ci        expandingbutton = ExpandingButton(s, tag_name, numoflines, self)
3217db96d56Sopenharmony_ci
3227db96d56Sopenharmony_ci        # insert the ExpandingButton to the Text
3237db96d56Sopenharmony_ci        self.text.window_create(start, window=expandingbutton,
3247db96d56Sopenharmony_ci                                padx=3, pady=5)
3257db96d56Sopenharmony_ci
3267db96d56Sopenharmony_ci        # Insert the ExpandingButton to the list of ExpandingButtons,
3277db96d56Sopenharmony_ci        # while keeping the list ordered according to the position of
3287db96d56Sopenharmony_ci        # the buttons in the Text widget.
3297db96d56Sopenharmony_ci        i = len(self.expandingbuttons)
3307db96d56Sopenharmony_ci        while i > 0 and self.text.compare(self.expandingbuttons[i-1],
3317db96d56Sopenharmony_ci                                          ">", expandingbutton):
3327db96d56Sopenharmony_ci            i -= 1
3337db96d56Sopenharmony_ci        self.expandingbuttons.insert(i, expandingbutton)
3347db96d56Sopenharmony_ci
3357db96d56Sopenharmony_ci        return "break"
3367db96d56Sopenharmony_ci
3377db96d56Sopenharmony_ci
3387db96d56Sopenharmony_ciSqueezer.reload()
3397db96d56Sopenharmony_ci
3407db96d56Sopenharmony_ci
3417db96d56Sopenharmony_ciif __name__ == "__main__":
3427db96d56Sopenharmony_ci    from unittest import main
3437db96d56Sopenharmony_ci    main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False)
3447db96d56Sopenharmony_ci
3457db96d56Sopenharmony_ci    # Add htest.
346