[Python-checkins] bpo-37530: simplify, optimize and clean up IDLE code context (GH-14675)

Miss Islington (bot) webhook-mailer at python.org
Wed Jul 17 04:44:30 EDT 2019


https://github.com/python/cpython/commit/bb79ab84c258566bcba89a87eb549fbc8643f882
commit: bb79ab84c258566bcba89a87eb549fbc8643f882
branch: 3.7
author: Miss Islington (bot) <31488909+miss-islington at users.noreply.github.com>
committer: GitHub <noreply at github.com>
date: 2019-07-17T01:44:18-07:00
summary:

bpo-37530: simplify, optimize and clean up IDLE code context (GH-14675)


* Only create CodeContext instances for "real" editors windows, but
  not e.g. shell or output windows.
* Remove configuration update Tk event fired every second, by having
  the editor window ask its code context widget to update when
  necessary, i.e. upon font or highlighting updates.
* When code context isn't being shown, avoid having a Tk event fired
  every 100ms to check whether the code context needs to be updated.
* Use the editor window's getlineno() method where applicable.
* Update font of the code context widget before the main text widget
(cherry picked from commit 7036e1de3a87d36c7ef41b8a2b44ed6fc4d34be2)

Co-authored-by: Tal Einat <taleinat at gmail.com>

files:
A Misc/NEWS.d/next/IDLE/2019-07-11-00-05-31.bpo-37530.AuyCyD.rst
M Lib/idlelib/codecontext.py
M Lib/idlelib/editor.py
M Lib/idlelib/idle_test/test_codecontext.py
M Lib/idlelib/outwin.py

diff --git a/Lib/idlelib/codecontext.py b/Lib/idlelib/codecontext.py
index 2aed76de7fb6..9bd0fa1753fc 100644
--- a/Lib/idlelib/codecontext.py
+++ b/Lib/idlelib/codecontext.py
@@ -19,8 +19,6 @@
 
 BLOCKOPENERS = {"class", "def", "elif", "else", "except", "finally", "for",
                 "if", "try", "while", "with", "async"}
-UPDATEINTERVAL = 100 # millisec
-CONFIGUPDATEINTERVAL = 1000 # millisec
 
 
 def get_spaces_firstword(codeline, c=re.compile(r"^(\s*)(\w*)")):
@@ -44,13 +42,13 @@ def get_line_info(codeline):
 
 class CodeContext:
     "Display block context above the edit window."
+    UPDATEINTERVAL = 100  # millisec
 
     def __init__(self, editwin):
         """Initialize settings for context block.
 
         editwin is the Editor window for the context block.
         self.text is the editor window text widget.
-        self.textfont is the editor window font.
 
         self.context displays the code context text above the editor text.
           Initially None, it is toggled via <<toggle-code-context>>.
@@ -65,29 +63,26 @@ def __init__(self, editwin):
         """
         self.editwin = editwin
         self.text = editwin.text
-        self.textfont = self.text["font"]
-        self.contextcolors = CodeContext.colors
         self.context = None
         self.topvisible = 1
         self.info = [(0, -1, "", False)]
-        # Start two update cycles, one for context lines, one for font changes.
-        self.t1 = self.text.after(UPDATEINTERVAL, self.timer_event)
-        self.t2 = self.text.after(CONFIGUPDATEINTERVAL, self.config_timer_event)
+        self.t1 = None
 
     @classmethod
     def reload(cls):
         "Load class variables from config."
         cls.context_depth = idleConf.GetOption("extensions", "CodeContext",
-                                       "maxlines", type="int", default=15)
-        cls.colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context')
+                                               "maxlines", type="int",
+                                               default=15)
 
     def __del__(self):
         "Cancel scheduled events."
-        try:
-            self.text.after_cancel(self.t1)
-            self.text.after_cancel(self.t2)
-        except:
-            pass
+        if self.t1 is not None:
+            try:
+                self.text.after_cancel(self.t1)
+            except tkinter.TclError:
+                pass
+            self.t1 = None
 
     def toggle_code_context_event(self, event=None):
         """Toggle code context display.
@@ -96,7 +91,7 @@ def toggle_code_context_event(self, event=None):
         window text (toggle on).  If it does exist, destroy it (toggle off).
         Return 'break' to complete the processing of the binding.
         """
-        if not self.context:
+        if self.context is None:
             # Calculate the border width and horizontal padding required to
             # align the context with the text in the main Text widget.
             #
@@ -111,21 +106,23 @@ def toggle_code_context_event(self, event=None):
                 padx += widget.tk.getint(widget.cget('padx'))
                 border += widget.tk.getint(widget.cget('border'))
             self.context = tkinter.Text(
-                    self.editwin.top, font=self.textfont,
-                    bg=self.contextcolors['background'],
-                    fg=self.contextcolors['foreground'],
-                    height=1,
-                    width=1,  # Don't request more than we get.
-                    padx=padx, border=border, relief=SUNKEN, state='disabled')
+                self.editwin.top, font=self.text['font'],
+                height=1,
+                width=1,  # Don't request more than we get.
+                padx=padx, border=border, relief=SUNKEN, state='disabled')
+            self.update_highlight_colors()
             self.context.bind('<ButtonRelease-1>', self.jumptoline)
             # Pack the context widget before and above the text_frame widget,
             # thus ensuring that it will appear directly above text_frame.
             self.context.pack(side=TOP, fill=X, expand=False,
-                            before=self.editwin.text_frame)
+                              before=self.editwin.text_frame)
             menu_status = 'Hide'
+            self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event)
         else:
             self.context.destroy()
             self.context = None
+            self.text.after_cancel(self.t1)
+            self.t1 = None
             menu_status = 'Show'
         self.editwin.update_menu_label(menu='options', index='* Code Context',
                                        label=f'{menu_status} Code Context')
@@ -169,7 +166,7 @@ def update_code_context(self):
         be retrieved and the context area will be updated with the code,
         up to the number of maxlines.
         """
-        new_topvisible = int(self.text.index("@0,0").split('.')[0])
+        new_topvisible = self.editwin.getlineno("@0,0")
         if self.topvisible == new_topvisible:      # Haven't scrolled.
             return
         if self.topvisible < new_topvisible:       # Scroll down.
@@ -217,21 +214,19 @@ def jumptoline(self, event=None):
 
     def timer_event(self):
         "Event on editor text widget triggered every UPDATEINTERVAL ms."
-        if self.context:
+        if self.context is not None:
             self.update_code_context()
-        self.t1 = self.text.after(UPDATEINTERVAL, self.timer_event)
-
-    def config_timer_event(self):
-        "Event on editor text widget triggered every CONFIGUPDATEINTERVAL ms."
-        newtextfont = self.text["font"]
-        if (self.context and (newtextfont != self.textfont or
-                            CodeContext.colors != self.contextcolors)):
-            self.textfont = newtextfont
-            self.contextcolors = CodeContext.colors
-            self.context["font"] = self.textfont
-            self.context['background'] = self.contextcolors['background']
-            self.context['foreground'] = self.contextcolors['foreground']
-        self.t2 = self.text.after(CONFIGUPDATEINTERVAL, self.config_timer_event)
+            self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event)
+
+    def update_font(self, font):
+        if self.context is not None:
+            self.context['font'] = font
+
+    def update_highlight_colors(self):
+        if self.context is not None:
+            colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context')
+            self.context['background'] = colors['background']
+            self.context['foreground'] = colors['foreground']
 
 
 CodeContext.reload()
diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py
index 9b5364f0c774..b972e3db8461 100644
--- a/Lib/idlelib/editor.py
+++ b/Lib/idlelib/editor.py
@@ -62,6 +62,8 @@ class EditorWindow(object):
     filesystemencoding = sys.getfilesystemencoding()  # for file names
     help_url = None
 
+    allow_codecontext = True
+
     def __init__(self, flist=None, filename=None, key=None, root=None):
         # Delay import: runscript imports pyshell imports EditorWindow.
         from idlelib.runscript import ScriptBinding
@@ -247,6 +249,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
         self.good_load = False
         self.set_indentation_params(False)
         self.color = None # initialized below in self.ResetColorizer
+        self.codecontext = None
         if filename:
             if os.path.exists(filename) and not os.path.isdir(filename):
                 if io.loadfile(filename):
@@ -312,8 +315,10 @@ def __init__(self, flist=None, filename=None, key=None, root=None):
         text.bind("<<refresh-calltip>>", ctip.refresh_calltip_event)
         text.bind("<<force-open-calltip>>", ctip.force_open_calltip_event)
         text.bind("<<zoom-height>>", self.ZoomHeight(self).zoom_height_event)
-        text.bind("<<toggle-code-context>>",
-                  self.CodeContext(self).toggle_code_context_event)
+        if self.allow_codecontext:
+            self.codecontext = self.CodeContext(self)
+            text.bind("<<toggle-code-context>>",
+                      self.codecontext.toggle_code_context_event)
 
     def _filename_to_unicode(self, filename):
         """Return filename as BMP unicode so displayable in Tk."""
@@ -773,6 +778,9 @@ def ResetColorizer(self):
         self._addcolorizer()
         EditorWindow.color_config(self.text)
 
+        if self.codecontext is not None:
+            self.codecontext.update_highlight_colors()
+
     IDENTCHARS = string.ascii_letters + string.digits + "_"
 
     def colorize_syntax_error(self, text, pos):
@@ -790,7 +798,12 @@ def ResetFont(self):
         "Update the text widgets' font if it is changed"
         # Called from configdialog.py
 
-        self.text['font'] = idleConf.GetFont(self.root, 'main','EditorWindow')
+        new_font = idleConf.GetFont(self.root, 'main', 'EditorWindow')
+        # Update the code context widget first, since its height affects
+        # the height of the text widget.  This avoids double re-rendering.
+        if self.codecontext is not None:
+            self.codecontext.update_font(new_font)
+        self.text['font'] = new_font
 
     def RemoveKeybindings(self):
         "Remove the keybindings before they are changed."
diff --git a/Lib/idlelib/idle_test/test_codecontext.py b/Lib/idlelib/idle_test/test_codecontext.py
index 6c6893580f42..05d3209db568 100644
--- a/Lib/idlelib/idle_test/test_codecontext.py
+++ b/Lib/idlelib/idle_test/test_codecontext.py
@@ -2,6 +2,7 @@
 
 from idlelib import codecontext
 import unittest
+import unittest.mock
 from test.support import requires
 from tkinter import Tk, Frame, Text, TclError
 
@@ -42,6 +43,9 @@ def __init__(self, root, frame, text):
         self.text = text
         self.label = ''
 
+    def getlineno(self, index):
+        return int(float(self.text.index(index)))
+
     def update_menu_label(self, **kwargs):
         self.label = kwargs['label']
 
@@ -75,6 +79,18 @@ def setUp(self):
         self.text.yview(0)
         self.cc = codecontext.CodeContext(self.editor)
 
+        self.highlight_cfg = {"background": '#abcdef',
+                              "foreground": '#123456'}
+        orig_idleConf_GetHighlight = codecontext.idleConf.GetHighlight
+        def mock_idleconf_GetHighlight(theme, element):
+            if element == 'context':
+                return self.highlight_cfg
+            return orig_idleConf_GetHighlight(theme, element)
+        patcher = unittest.mock.patch.object(
+            codecontext.idleConf, 'GetHighlight', mock_idleconf_GetHighlight)
+        patcher.start()
+        self.addCleanup(patcher.stop)
+
     def tearDown(self):
         if self.cc.context:
             self.cc.context.destroy()
@@ -89,30 +105,24 @@ def test_init(self):
 
         eq(cc.editwin, ed)
         eq(cc.text, ed.text)
-        eq(cc.textfont, ed.text['font'])
+        eq(cc.text['font'], ed.text['font'])
         self.assertIsNone(cc.context)
         eq(cc.info, [(0, -1, '', False)])
         eq(cc.topvisible, 1)
-        eq(self.root.tk.call('after', 'info', self.cc.t1)[1], 'timer')
-        eq(self.root.tk.call('after', 'info', self.cc.t2)[1], 'timer')
+        self.assertIsNone(self.cc.t1)
 
     def test_del(self):
         self.cc.__del__()
-        with self.assertRaises(TclError) as msg:
-            self.root.tk.call('after', 'info', self.cc.t1)
-            self.assertIn("doesn't exist", msg)
-        with self.assertRaises(TclError) as msg:
-            self.root.tk.call('after', 'info', self.cc.t2)
-            self.assertIn("doesn't exist", msg)
-        # For coverage on the except.  Have to delete because the
-        # above Tcl error is caught by after_cancel.
-        del self.cc.t1, self.cc.t2
+
+    def test_del_with_timer(self):
+        timer = self.cc.t1 = self.text.after(10000, lambda: None)
         self.cc.__del__()
+        with self.assertRaises(TclError) as cm:
+            self.root.tk.call('after', 'info', timer)
+        self.assertIn("doesn't exist", str(cm.exception))
 
     def test_reload(self):
         codecontext.CodeContext.reload()
-        self.assertEqual(self.cc.colors, {'background': 'lightgray',
-                                          'foreground': '#000000'})
         self.assertEqual(self.cc.context_depth, 15)
 
     def test_toggle_code_context_event(self):
@@ -127,16 +137,18 @@ def test_toggle_code_context_event(self):
         # Toggle on.
         eq(toggle(), 'break')
         self.assertIsNotNone(cc.context)
-        eq(cc.context['font'], cc.textfont)
-        eq(cc.context['fg'], cc.colors['foreground'])
-        eq(cc.context['bg'], cc.colors['background'])
+        eq(cc.context['font'], self.text['font'])
+        eq(cc.context['fg'], self.highlight_cfg['foreground'])
+        eq(cc.context['bg'], self.highlight_cfg['background'])
         eq(cc.context.get('1.0', 'end-1c'), '')
         eq(cc.editwin.label, 'Hide Code Context')
+        eq(self.root.tk.call('after', 'info', self.cc.t1)[1], 'timer')
 
         # Toggle off.
         eq(toggle(), 'break')
         self.assertIsNone(cc.context)
         eq(cc.editwin.label, 'Show Code Context')
+        self.assertIsNone(self.cc.t1)
 
     def test_get_context(self):
         eq = self.assertEqual
@@ -227,7 +239,7 @@ def test_update_code_context(self):
                      (4, 4, '    def __init__(self, a, b):', 'def')])
         eq(cc.topvisible, 5)
         eq(cc.context.get('1.0', 'end-1c'), 'class C1():\n'
-                             '    def __init__(self, a, b):')
+                                            '    def __init__(self, a, b):')
 
         # Scroll down to line 11.  Last 'def' is removed.
         cc.text.yview(11)
@@ -239,9 +251,9 @@ def test_update_code_context(self):
                      (10, 8, '        elif a < b:', 'elif')])
         eq(cc.topvisible, 12)
         eq(cc.context.get('1.0', 'end-1c'), 'class C1():\n'
-                             '    def compare(self):\n'
-                             '        if a > b:\n'
-                             '        elif a < b:')
+                                            '    def compare(self):\n'
+                                            '        if a > b:\n'
+                                            '        elif a < b:')
 
         # No scroll.  No update, even though context_depth changed.
         cc.update_code_context()
@@ -253,9 +265,9 @@ def test_update_code_context(self):
                      (10, 8, '        elif a < b:', 'elif')])
         eq(cc.topvisible, 12)
         eq(cc.context.get('1.0', 'end-1c'), 'class C1():\n'
-                             '    def compare(self):\n'
-                             '        if a > b:\n'
-                             '        elif a < b:')
+                                            '    def compare(self):\n'
+                                            '        if a > b:\n'
+                                            '        elif a < b:')
 
         # Scroll up.
         cc.text.yview(5)
@@ -276,7 +288,7 @@ def test_jumptoline(self):
             cc.toggle_code_context_event()
 
         # Empty context.
-        cc.text.yview(f'{2}.0')
+        cc.text.yview('2.0')
         cc.update_code_context()
         eq(cc.topvisible, 2)
         cc.context.mark_set('insert', '1.5')
@@ -284,7 +296,7 @@ def test_jumptoline(self):
         eq(cc.topvisible, 1)
 
         # 4 lines of context showing.
-        cc.text.yview(f'{12}.0')
+        cc.text.yview('12.0')
         cc.update_code_context()
         eq(cc.topvisible, 12)
         cc.context.mark_set('insert', '3.0')
@@ -293,7 +305,7 @@ def test_jumptoline(self):
 
         # More context lines than limit.
         cc.context_depth = 2
-        cc.text.yview(f'{12}.0')
+        cc.text.yview('12.0')
         cc.update_code_context()
         eq(cc.topvisible, 12)
         cc.context.mark_set('insert', '1.0')
@@ -313,56 +325,72 @@ def test_timer_event(self, mock_update):
         self.cc.timer_event()
         mock_update.assert_called()
 
-    def test_config_timer_event(self):
+    def test_font(self):
         eq = self.assertEqual
         cc = self.cc
         save_font = cc.text['font']
-        save_colors = codecontext.CodeContext.colors
-        test_font = 'FakeFont'
+        test_font = 'TkFixedFont'
+
+        # Ensure code context is not active.
+        if cc.context is not None:
+            cc.toggle_code_context_event()
+
+        # Nothing breaks or changes with inactive code context.
+        cc.update_font(test_font)
+
+        # Activate code context, but no change to font.
+        cc.toggle_code_context_event()
+        eq(cc.context['font'], save_font)
+        # Call font update with the existing font.
+        cc.update_font(save_font)
+        eq(cc.context['font'], save_font)
+        cc.toggle_code_context_event()
+
+        # Change text widget font and activate code context.
+        cc.text['font'] = test_font
+        cc.toggle_code_context_event(test_font)
+        eq(cc.context['font'], test_font)
+
+        # Just call the font update.
+        cc.update_font(save_font)
+        eq(cc.context['font'], save_font)
+        cc.text['font'] = save_font
+
+    def test_highlight_colors(self):
+        eq = self.assertEqual
+        cc = self.cc
+        save_colors = dict(self.highlight_cfg)
         test_colors = {'background': '#222222', 'foreground': '#ffff00'}
 
         # Ensure code context is not active.
         if cc.context:
             cc.toggle_code_context_event()
 
-        # Nothing updates on inactive code context.
-        cc.text['font'] = test_font
-        codecontext.CodeContext.colors = test_colors
-        cc.config_timer_event()
-        eq(cc.textfont, save_font)
-        eq(cc.contextcolors, save_colors)
+        # Nothing breaks with inactive code context.
+        cc.update_highlight_colors()
 
-        # Activate code context, but no change to font or color.
+        # Activate code context, but no change to colors.
         cc.toggle_code_context_event()
-        cc.text['font'] = save_font
-        codecontext.CodeContext.colors = save_colors
-        cc.config_timer_event()
-        eq(cc.textfont, save_font)
-        eq(cc.contextcolors, save_colors)
-        eq(cc.context['font'], save_font)
         eq(cc.context['background'], save_colors['background'])
         eq(cc.context['foreground'], save_colors['foreground'])
 
-        # Active code context, change font.
-        cc.text['font'] = test_font
-        cc.config_timer_event()
-        eq(cc.textfont, test_font)
-        eq(cc.contextcolors, save_colors)
-        eq(cc.context['font'], test_font)
+        # Call colors update, but no change to font.
+        cc.update_highlight_colors()
         eq(cc.context['background'], save_colors['background'])
         eq(cc.context['foreground'], save_colors['foreground'])
+        cc.toggle_code_context_event()
 
-        # Active code context, change color.
-        cc.text['font'] = save_font
-        codecontext.CodeContext.colors = test_colors
-        cc.config_timer_event()
-        eq(cc.textfont, save_font)
-        eq(cc.contextcolors, test_colors)
-        eq(cc.context['font'], save_font)
+        # Change colors and activate code context.
+        self.highlight_cfg = test_colors
+        cc.toggle_code_context_event()
         eq(cc.context['background'], test_colors['background'])
         eq(cc.context['foreground'], test_colors['foreground'])
-        codecontext.CodeContext.colors = save_colors
-        cc.config_timer_event()
+
+        # Change colors and call highlight colors update.
+        self.highlight_cfg = save_colors
+        cc.update_highlight_colors()
+        eq(cc.context['background'], save_colors['background'])
+        eq(cc.context['foreground'], save_colors['foreground'])
 
 
 class HelperFunctionText(unittest.TestCase):
diff --git a/Lib/idlelib/outwin.py b/Lib/idlelib/outwin.py
index ecc53ef0195d..38c59bdf9b4e 100644
--- a/Lib/idlelib/outwin.py
+++ b/Lib/idlelib/outwin.py
@@ -74,6 +74,8 @@ class OutputWindow(EditorWindow):
         ("Go to file/line", "<<goto-file-line>>", None),
     ]
 
+    allow_codecontext = False
+
     def __init__(self, *args):
         EditorWindow.__init__(self, *args)
         self.text.bind("<<goto-file-line>>", self.goto_file_line)
diff --git a/Misc/NEWS.d/next/IDLE/2019-07-11-00-05-31.bpo-37530.AuyCyD.rst b/Misc/NEWS.d/next/IDLE/2019-07-11-00-05-31.bpo-37530.AuyCyD.rst
new file mode 100644
index 000000000000..0b80860b8fc2
--- /dev/null
+++ b/Misc/NEWS.d/next/IDLE/2019-07-11-00-05-31.bpo-37530.AuyCyD.rst
@@ -0,0 +1,3 @@
+Optimize code context to reduce unneeded background activity.
+Font and highlight changes now occur along with text changes
+instead of after a random delay.



More information about the Python-checkins mailing list