bpo-35196: Optimize Squeezer's write() interception (GH-10454)

The new functionality of Squeezer.reload() is also tested, along with some general
re-working of the tests in test_squeezer.py.
diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py
index 8960356..869498d 100644
--- a/Lib/idlelib/squeezer.py
+++ b/Lib/idlelib/squeezer.py
@@ -15,6 +15,7 @@
 messages and their tracebacks.
 """
 import re
+import weakref
 
 import tkinter as tk
 from tkinter.font import Font
@@ -26,7 +27,7 @@
 from idlelib import macosx
 
 
-def count_lines_with_wrapping(s, linewidth=80, tabwidth=8):
+def count_lines_with_wrapping(s, linewidth=80):
     """Count the number of lines in a given string.
 
     Lines are counted as if the string was wrapped so that lines are never over
@@ -34,25 +35,27 @@
 
     Tabs are considered tabwidth characters long.
     """
+    tabwidth = 8  # Currently always true in Shell.
     pos = 0
     linecount = 1
     current_column = 0
 
     for m in re.finditer(r"[\t\n]", s):
-        # process the normal chars up to tab or newline
+        # Process the normal chars up to tab or newline.
         numchars = m.start() - pos
         pos += numchars
         current_column += numchars
 
-        # deal with tab or newline
+        # Deal with tab or newline.
         if s[pos] == '\n':
-            # Avoid the `current_column == 0` edge-case, and while we're at it,
-            # don't bother adding 0.
+            # Avoid the `current_column == 0` edge-case, and while we're
+            # at it, don't bother adding 0.
             if current_column > linewidth:
-                # If the current column was exactly linewidth, divmod would give
-                # (1,0), even though a new line hadn't yet been started. The same
-                # is true if length is any exact multiple of linewidth. Therefore,
-                # subtract 1 before dividing a non-empty line.
+                # If the current column was exactly linewidth, divmod
+                # would give (1,0), even though a new line hadn't yet
+                # been started. The same is true if length is any exact
+                # multiple of linewidth. Therefore, subtract 1 before
+                # dividing a non-empty line.
                 linecount += (current_column - 1) // linewidth
             linecount += 1
             current_column = 0
@@ -60,21 +63,21 @@
             assert s[pos] == '\t'
             current_column += tabwidth - (current_column % tabwidth)
 
-            # if a tab passes the end of the line, consider the entire tab as
-            # being on the next line
+            # If a tab passes the end of the line, consider the entire
+            # tab as being on the next line.
             if current_column > linewidth:
                 linecount += 1
                 current_column = tabwidth
 
-        pos += 1 # after the tab or newline
+        pos += 1 # After the tab or newline.
 
-    # process remaining chars (no more tabs or newlines)
+    # Process remaining chars (no more tabs or newlines).
     current_column += len(s) - pos
-    # avoid divmod(-1, linewidth)
+    # Avoid divmod(-1, linewidth).
     if current_column > 0:
         linecount += (current_column - 1) // linewidth
     else:
-        # the text ended with a newline; don't count an extra line after it
+        # Text ended with newline; don't count an extra line after it.
         linecount -= 1
 
     return linecount
@@ -98,9 +101,7 @@
         self.squeezer = squeezer
         self.editwin = editwin = squeezer.editwin
         self.text = text = editwin.text
-
-        # the base Text widget of the PyShell object, used to change text
-        # before the iomark
+        # The base Text widget is needed to change text before iomark.
         self.base_text = editwin.per.bottom
 
         line_plurality = "lines" if numoflines != 1 else "line"
@@ -119,7 +120,7 @@
             self.bind("<Button-2>", self.context_menu_event)
         else:
             self.bind("<Button-3>", self.context_menu_event)
-        self.selection_handle(
+        self.selection_handle(  # X windows only.
             lambda offset, length: s[int(offset):int(offset) + int(length)])
 
         self.is_dangerous = None
@@ -182,7 +183,7 @@
                   modal=False, wrap='none')
 
     rmenu_specs = (
-        # item structure: (label, method_name)
+        # Item structure: (label, method_name).
         ('copy', 'copy'),
         ('view', 'view'),
     )
@@ -202,6 +203,8 @@
     This avoids IDLE's shell slowing down considerably, and even becoming
     completely unresponsive, when very long outputs are written.
     """
+    _instance_weakref = None
+
     @classmethod
     def reload(cls):
         """Load class variables from config."""
@@ -210,6 +213,14 @@
             type="int", default=50,
         )
 
+        # Loading the font info requires a Tk root. IDLE doesn't rely
+        # on Tkinter's "default root", so the instance will reload
+        # font info using its editor windows's Tk root.
+        if cls._instance_weakref is not None:
+            instance = cls._instance_weakref()
+            if instance is not None:
+                instance.load_font()
+
     def __init__(self, editwin):
         """Initialize settings for Squeezer.
 
@@ -223,44 +234,58 @@
         self.editwin = editwin
         self.text = text = editwin.text
 
-        # Get the base Text widget of the PyShell object, used to change text
-        # before the iomark. PyShell deliberately disables changing text before
-        # the iomark via its 'text' attribute, which is actually a wrapper for
-        # the actual Text widget. Squeezer, however, needs to make such changes.
+        # Get the base Text widget of the PyShell object, used to change
+        # text before the iomark. PyShell deliberately disables changing
+        # text before the iomark via its 'text' attribute, which is
+        # actually a wrapper for the actual Text widget. Squeezer,
+        # however, needs to make such changes.
         self.base_text = editwin.per.bottom
 
+        Squeezer._instance_weakref = weakref.ref(self)
+        self.load_font()
+
+        # Twice the text widget's border width and internal padding;
+        # pre-calculated here for the get_line_width() method.
+        self.window_width_delta = 2 * (
+            int(text.cget('border')) +
+            int(text.cget('padx'))
+        )
+
         self.expandingbuttons = []
-        from idlelib.pyshell import PyShell  # done here to avoid import cycle
-        if isinstance(editwin, PyShell):
-            # If we get a PyShell instance, replace its write method with a
-            # wrapper, which inserts an ExpandingButton instead of a long text.
-            def mywrite(s, tags=(), write=editwin.write):
-                # only auto-squeeze text which has just the "stdout" tag
-                if tags != "stdout":
-                    return write(s, tags)
 
-                # only auto-squeeze text with at least the minimum
-                # configured number of lines
-                numoflines = self.count_lines(s)
-                if numoflines < self.auto_squeeze_min_lines:
-                    return write(s, tags)
+        # Replace the PyShell instance's write method with a wrapper,
+        # which inserts an ExpandingButton instead of a long text.
+        def mywrite(s, tags=(), write=editwin.write):
+            # Only auto-squeeze text which has just the "stdout" tag.
+            if tags != "stdout":
+                return write(s, tags)
 
-                # create an ExpandingButton instance
-                expandingbutton = ExpandingButton(s, tags, numoflines,
-                                                  self)
+            # Only auto-squeeze text with at least the minimum
+            # configured number of lines.
+            auto_squeeze_min_lines = self.auto_squeeze_min_lines
+            # First, a very quick check to skip very short texts.
+            if len(s) < auto_squeeze_min_lines:
+                return write(s, tags)
+            # Now the full line-count check.
+            numoflines = self.count_lines(s)
+            if numoflines < auto_squeeze_min_lines:
+                return write(s, tags)
 
-                # insert the ExpandingButton into the Text widget
-                text.mark_gravity("iomark", tk.RIGHT)
-                text.window_create("iomark", window=expandingbutton,
-                                   padx=3, pady=5)
-                text.see("iomark")
-                text.update()
-                text.mark_gravity("iomark", tk.LEFT)
+            # Create an ExpandingButton instance.
+            expandingbutton = ExpandingButton(s, tags, numoflines, self)
 
-                # add the ExpandingButton to the Squeezer's list
-                self.expandingbuttons.append(expandingbutton)
+            # Insert the ExpandingButton into the Text widget.
+            text.mark_gravity("iomark", tk.RIGHT)
+            text.window_create("iomark", window=expandingbutton,
+                               padx=3, pady=5)
+            text.see("iomark")
+            text.update()
+            text.mark_gravity("iomark", tk.LEFT)
 
-            editwin.write = mywrite
+            # Add the ExpandingButton to the Squeezer's list.
+            self.expandingbuttons.append(expandingbutton)
+
+        editwin.write = mywrite
 
     def count_lines(self, s):
         """Count the number of lines in a given text.
@@ -273,25 +298,24 @@
 
         Tabs are considered tabwidth characters long.
         """
-        # Tab width is configurable
-        tabwidth = self.editwin.get_tk_tabwidth()
+        linewidth = self.get_line_width()
+        return count_lines_with_wrapping(s, linewidth)
 
-        # Get the Text widget's size
-        linewidth = self.editwin.text.winfo_width()
-        # Deduct the border and padding
-        linewidth -= 2*sum([int(self.editwin.text.cget(opt))
-                            for opt in ('border', 'padx')])
+    def get_line_width(self):
+        # The maximum line length in pixels: The width of the text
+        # widget, minus twice the border width and internal padding.
+        linewidth_pixels = \
+            self.base_text.winfo_width() - self.window_width_delta
 
-        # Get the Text widget's font
-        font = Font(self.editwin.text, name=self.editwin.text.cget('font'))
-        # Divide the size of the Text widget by the font's width.
-        # According to Tk8.5 docs, the Text widget's width is set
-        # according to the width of its font's '0' (zero) character,
-        # so we will use this as an approximation.
-        # see: http://www.tcl.tk/man/tcl8.5/TkCmd/text.htm#M-width
-        linewidth //= font.measure('0')
+        # Divide the width of the Text widget by the font width,
+        # which is taken to be the width of '0' (zero).
+        # http://www.tcl.tk/man/tcl8.6/TkCmd/text.htm#M21
+        return linewidth_pixels // self.zero_char_width
 
-        return count_lines_with_wrapping(s, linewidth, tabwidth)
+    def load_font(self):
+        text = self.base_text
+        self.zero_char_width = \
+            Font(text, font=text.cget('font')).measure('0')
 
     def squeeze_current_text_event(self, event):
         """squeeze-current-text event handler
@@ -301,29 +325,29 @@
         If the insert cursor is not in a squeezable block of text, give the
         user a small warning and do nothing.
         """
-        # set tag_name to the first valid tag found on the "insert" cursor
+        # Set tag_name to the first valid tag found on the "insert" cursor.
         tag_names = self.text.tag_names(tk.INSERT)
         for tag_name in ("stdout", "stderr"):
             if tag_name in tag_names:
                 break
         else:
-            # the insert cursor doesn't have a "stdout" or "stderr" tag
+            # The insert cursor doesn't have a "stdout" or "stderr" tag.
             self.text.bell()
             return "break"
 
-        # find the range to squeeze
+        # Find the range to squeeze.
         start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c")
         s = self.text.get(start, end)
 
-        # if the last char is a newline, remove it from the range
+        # If the last char is a newline, remove it from the range.
         if len(s) > 0 and s[-1] == '\n':
             end = self.text.index("%s-1c" % end)
             s = s[:-1]
 
-        # delete the text
+        # Delete the text.
         self.base_text.delete(start, end)
 
-        # prepare an ExpandingButton
+        # Prepare an ExpandingButton.
         numoflines = self.count_lines(s)
         expandingbutton = ExpandingButton(s, tag_name, numoflines, self)
 
@@ -331,9 +355,9 @@
         self.text.window_create(start, window=expandingbutton,
                                 padx=3, pady=5)
 
-        # insert the ExpandingButton to the list of ExpandingButtons, while
-        # keeping the list ordered according to the position of the buttons in
-        # the Text widget
+        # Insert the ExpandingButton to the list of ExpandingButtons,
+        # while keeping the list ordered according to the position of
+        # the buttons in the Text widget.
         i = len(self.expandingbuttons)
         while i > 0 and self.text.compare(self.expandingbuttons[i-1],
                                           ">", expandingbutton):