From 7036e1de3a87d36c7ef41b8a2b44ed6fc4d34be2 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Wed, 17 Jul 2019 11:15:53 +0300 Subject: 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 --- Lib/idlelib/codecontext.py | 71 +++++----- Lib/idlelib/editor.py | 19 ++- Lib/idlelib/idle_test/test_codecontext.py | 144 ++++++++++++--------- Lib/idlelib/outwin.py | 2 + .../IDLE/2019-07-11-00-05-31.bpo-37530.AuyCyD.rst | 3 + 5 files changed, 140 insertions(+), 99 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2019-07-11-00-05-31.bpo-37530.AuyCyD.rst diff --git a/Lib/idlelib/codecontext.py b/Lib/idlelib/codecontext.py index 2aed76d..9bd0fa1 100644 --- a/Lib/idlelib/codecontext.py +++ b/Lib/idlelib/codecontext.py @@ -19,8 +19,6 @@ from idlelib.config import idleConf 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 <>. @@ -65,29 +63,26 @@ class CodeContext: """ 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 @@ class CodeContext: 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 @@ class CodeContext: 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('', 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 @@ class CodeContext: 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 @@ class CodeContext: 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 9b5364f..b972e3d 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 @@ class EditorWindow(object): 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 @@ class EditorWindow(object): text.bind("<>", ctip.refresh_calltip_event) text.bind("<>", ctip.force_open_calltip_event) text.bind("<>", self.ZoomHeight(self).zoom_height_event) - text.bind("<>", - self.CodeContext(self).toggle_code_context_event) + if self.allow_codecontext: + self.codecontext = self.CodeContext(self) + text.bind("<>", + 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 @@ class EditorWindow(object): 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 @@ class EditorWindow(object): "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 6c68935..05d3209 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 @@ class DummyEditwin: 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 @@ class CodeContextTest(unittest.TestCase): 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 @@ class CodeContextTest(unittest.TestCase): 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 @@ class CodeContextTest(unittest.TestCase): # 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 @@ class CodeContextTest(unittest.TestCase): (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 @@ class CodeContextTest(unittest.TestCase): (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 @@ class CodeContextTest(unittest.TestCase): (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 @@ class CodeContextTest(unittest.TestCase): 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 @@ class CodeContextTest(unittest.TestCase): 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 @@ class CodeContextTest(unittest.TestCase): # 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 @@ class CodeContextTest(unittest.TestCase): 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 ecc53ef..38c59bd 100644 --- a/Lib/idlelib/outwin.py +++ b/Lib/idlelib/outwin.py @@ -74,6 +74,8 @@ class OutputWindow(EditorWindow): ("Go to file/line", "<>", None), ] + allow_codecontext = False + def __init__(self, *args): EditorWindow.__init__(self, *args) self.text.bind("<>", 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 0000000..0b80860 --- /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. -- cgit v0.12