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
This commit is contained in:
Tal Einat 2019-07-17 11:15:53 +03:00 committed by GitHub
parent bd26a4466b
commit 7036e1de3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 139 additions and 98 deletions

View file

@ -19,8 +19,6 @@ from idlelib.config import idleConf
BLOCKOPENERS = {"class", "def", "elif", "else", "except", "finally", "for", BLOCKOPENERS = {"class", "def", "elif", "else", "except", "finally", "for",
"if", "try", "while", "with", "async"} "if", "try", "while", "with", "async"}
UPDATEINTERVAL = 100 # millisec
CONFIGUPDATEINTERVAL = 1000 # millisec
def get_spaces_firstword(codeline, c=re.compile(r"^(\s*)(\w*)")): def get_spaces_firstword(codeline, c=re.compile(r"^(\s*)(\w*)")):
@ -44,13 +42,13 @@ def get_line_info(codeline):
class CodeContext: class CodeContext:
"Display block context above the edit window." "Display block context above the edit window."
UPDATEINTERVAL = 100 # millisec
def __init__(self, editwin): def __init__(self, editwin):
"""Initialize settings for context block. """Initialize settings for context block.
editwin is the Editor window for the context block. editwin is the Editor window for the context block.
self.text is the editor window text widget. 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. self.context displays the code context text above the editor text.
Initially None, it is toggled via <<toggle-code-context>>. Initially None, it is toggled via <<toggle-code-context>>.
@ -65,29 +63,26 @@ class CodeContext:
""" """
self.editwin = editwin self.editwin = editwin
self.text = editwin.text self.text = editwin.text
self.textfont = self.text["font"]
self.contextcolors = CodeContext.colors
self.context = None self.context = None
self.topvisible = 1 self.topvisible = 1
self.info = [(0, -1, "", False)] self.info = [(0, -1, "", False)]
# Start two update cycles, one for context lines, one for font changes. self.t1 = None
self.t1 = self.text.after(UPDATEINTERVAL, self.timer_event)
self.t2 = self.text.after(CONFIGUPDATEINTERVAL, self.config_timer_event)
@classmethod @classmethod
def reload(cls): def reload(cls):
"Load class variables from config." "Load class variables from config."
cls.context_depth = idleConf.GetOption("extensions", "CodeContext", cls.context_depth = idleConf.GetOption("extensions", "CodeContext",
"maxlines", type="int", default=15) "maxlines", type="int",
cls.colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context') default=15)
def __del__(self): def __del__(self):
"Cancel scheduled events." "Cancel scheduled events."
try: if self.t1 is not None:
self.text.after_cancel(self.t1) try:
self.text.after_cancel(self.t2) self.text.after_cancel(self.t1)
except: except tkinter.TclError:
pass pass
self.t1 = None
def toggle_code_context_event(self, event=None): def toggle_code_context_event(self, event=None):
"""Toggle code context display. """Toggle code context display.
@ -96,7 +91,7 @@ class CodeContext:
window text (toggle on). If it does exist, destroy it (toggle off). window text (toggle on). If it does exist, destroy it (toggle off).
Return 'break' to complete the processing of the binding. 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 # Calculate the border width and horizontal padding required to
# align the context with the text in the main Text widget. # align the context with the text in the main Text widget.
# #
@ -111,21 +106,23 @@ class CodeContext:
padx += widget.tk.getint(widget.cget('padx')) padx += widget.tk.getint(widget.cget('padx'))
border += widget.tk.getint(widget.cget('border')) border += widget.tk.getint(widget.cget('border'))
self.context = tkinter.Text( self.context = tkinter.Text(
self.editwin.top, font=self.textfont, self.editwin.top, font=self.text['font'],
bg=self.contextcolors['background'], height=1,
fg=self.contextcolors['foreground'], width=1, # Don't request more than we get.
height=1, padx=padx, border=border, relief=SUNKEN, state='disabled')
width=1, # Don't request more than we get. self.update_highlight_colors()
padx=padx, border=border, relief=SUNKEN, state='disabled')
self.context.bind('<ButtonRelease-1>', self.jumptoline) self.context.bind('<ButtonRelease-1>', self.jumptoline)
# Pack the context widget before and above the text_frame widget, # Pack the context widget before and above the text_frame widget,
# thus ensuring that it will appear directly above text_frame. # thus ensuring that it will appear directly above text_frame.
self.context.pack(side=TOP, fill=X, expand=False, self.context.pack(side=TOP, fill=X, expand=False,
before=self.editwin.text_frame) before=self.editwin.text_frame)
menu_status = 'Hide' menu_status = 'Hide'
self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event)
else: else:
self.context.destroy() self.context.destroy()
self.context = None self.context = None
self.text.after_cancel(self.t1)
self.t1 = None
menu_status = 'Show' menu_status = 'Show'
self.editwin.update_menu_label(menu='options', index='* Code Context', self.editwin.update_menu_label(menu='options', index='* Code Context',
label=f'{menu_status} 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, be retrieved and the context area will be updated with the code,
up to the number of maxlines. 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. if self.topvisible == new_topvisible: # Haven't scrolled.
return return
if self.topvisible < new_topvisible: # Scroll down. if self.topvisible < new_topvisible: # Scroll down.
@ -217,21 +214,19 @@ class CodeContext:
def timer_event(self): def timer_event(self):
"Event on editor text widget triggered every UPDATEINTERVAL ms." "Event on editor text widget triggered every UPDATEINTERVAL ms."
if self.context: if self.context is not None:
self.update_code_context() self.update_code_context()
self.t1 = self.text.after(UPDATEINTERVAL, self.timer_event) self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event)
def config_timer_event(self): def update_font(self, font):
"Event on editor text widget triggered every CONFIGUPDATEINTERVAL ms." if self.context is not None:
newtextfont = self.text["font"] self.context['font'] = font
if (self.context and (newtextfont != self.textfont or
CodeContext.colors != self.contextcolors)): def update_highlight_colors(self):
self.textfont = newtextfont if self.context is not None:
self.contextcolors = CodeContext.colors colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context')
self.context["font"] = self.textfont self.context['background'] = colors['background']
self.context['background'] = self.contextcolors['background'] self.context['foreground'] = colors['foreground']
self.context['foreground'] = self.contextcolors['foreground']
self.t2 = self.text.after(CONFIGUPDATEINTERVAL, self.config_timer_event)
CodeContext.reload() CodeContext.reload()

View file

@ -62,6 +62,8 @@ class EditorWindow(object):
filesystemencoding = sys.getfilesystemencoding() # for file names filesystemencoding = sys.getfilesystemencoding() # for file names
help_url = None help_url = None
allow_codecontext = True
def __init__(self, flist=None, filename=None, key=None, root=None): def __init__(self, flist=None, filename=None, key=None, root=None):
# Delay import: runscript imports pyshell imports EditorWindow. # Delay import: runscript imports pyshell imports EditorWindow.
from idlelib.runscript import ScriptBinding from idlelib.runscript import ScriptBinding
@ -247,6 +249,7 @@ class EditorWindow(object):
self.good_load = False self.good_load = False
self.set_indentation_params(False) self.set_indentation_params(False)
self.color = None # initialized below in self.ResetColorizer self.color = None # initialized below in self.ResetColorizer
self.codecontext = None
if filename: if filename:
if os.path.exists(filename) and not os.path.isdir(filename): if os.path.exists(filename) and not os.path.isdir(filename):
if io.loadfile(filename): if io.loadfile(filename):
@ -312,8 +315,10 @@ class EditorWindow(object):
text.bind("<<refresh-calltip>>", ctip.refresh_calltip_event) text.bind("<<refresh-calltip>>", ctip.refresh_calltip_event)
text.bind("<<force-open-calltip>>", ctip.force_open_calltip_event) text.bind("<<force-open-calltip>>", ctip.force_open_calltip_event)
text.bind("<<zoom-height>>", self.ZoomHeight(self).zoom_height_event) text.bind("<<zoom-height>>", self.ZoomHeight(self).zoom_height_event)
text.bind("<<toggle-code-context>>", if self.allow_codecontext:
self.CodeContext(self).toggle_code_context_event) self.codecontext = self.CodeContext(self)
text.bind("<<toggle-code-context>>",
self.codecontext.toggle_code_context_event)
def _filename_to_unicode(self, filename): def _filename_to_unicode(self, filename):
"""Return filename as BMP unicode so displayable in Tk.""" """Return filename as BMP unicode so displayable in Tk."""
@ -773,6 +778,9 @@ class EditorWindow(object):
self._addcolorizer() self._addcolorizer()
EditorWindow.color_config(self.text) EditorWindow.color_config(self.text)
if self.codecontext is not None:
self.codecontext.update_highlight_colors()
IDENTCHARS = string.ascii_letters + string.digits + "_" IDENTCHARS = string.ascii_letters + string.digits + "_"
def colorize_syntax_error(self, text, pos): def colorize_syntax_error(self, text, pos):
@ -790,7 +798,12 @@ class EditorWindow(object):
"Update the text widgets' font if it is changed" "Update the text widgets' font if it is changed"
# Called from configdialog.py # 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): def RemoveKeybindings(self):
"Remove the keybindings before they are changed." "Remove the keybindings before they are changed."

View file

@ -2,6 +2,7 @@
from idlelib import codecontext from idlelib import codecontext
import unittest import unittest
import unittest.mock
from test.support import requires from test.support import requires
from tkinter import Tk, Frame, Text, TclError from tkinter import Tk, Frame, Text, TclError
@ -42,6 +43,9 @@ class DummyEditwin:
self.text = text self.text = text
self.label = '' self.label = ''
def getlineno(self, index):
return int(float(self.text.index(index)))
def update_menu_label(self, **kwargs): def update_menu_label(self, **kwargs):
self.label = kwargs['label'] self.label = kwargs['label']
@ -75,6 +79,18 @@ class CodeContextTest(unittest.TestCase):
self.text.yview(0) self.text.yview(0)
self.cc = codecontext.CodeContext(self.editor) 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): def tearDown(self):
if self.cc.context: if self.cc.context:
self.cc.context.destroy() self.cc.context.destroy()
@ -89,30 +105,24 @@ class CodeContextTest(unittest.TestCase):
eq(cc.editwin, ed) eq(cc.editwin, ed)
eq(cc.text, ed.text) eq(cc.text, ed.text)
eq(cc.textfont, ed.text['font']) eq(cc.text['font'], ed.text['font'])
self.assertIsNone(cc.context) self.assertIsNone(cc.context)
eq(cc.info, [(0, -1, '', False)]) eq(cc.info, [(0, -1, '', False)])
eq(cc.topvisible, 1) eq(cc.topvisible, 1)
eq(self.root.tk.call('after', 'info', self.cc.t1)[1], 'timer') self.assertIsNone(self.cc.t1)
eq(self.root.tk.call('after', 'info', self.cc.t2)[1], 'timer')
def test_del(self): def test_del(self):
self.cc.__del__() self.cc.__del__()
with self.assertRaises(TclError) as msg:
self.root.tk.call('after', 'info', self.cc.t1) def test_del_with_timer(self):
self.assertIn("doesn't exist", msg) timer = self.cc.t1 = self.text.after(10000, lambda: None)
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
self.cc.__del__() 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): def test_reload(self):
codecontext.CodeContext.reload() codecontext.CodeContext.reload()
self.assertEqual(self.cc.colors, {'background': 'lightgray',
'foreground': '#000000'})
self.assertEqual(self.cc.context_depth, 15) self.assertEqual(self.cc.context_depth, 15)
def test_toggle_code_context_event(self): def test_toggle_code_context_event(self):
@ -127,16 +137,18 @@ class CodeContextTest(unittest.TestCase):
# Toggle on. # Toggle on.
eq(toggle(), 'break') eq(toggle(), 'break')
self.assertIsNotNone(cc.context) self.assertIsNotNone(cc.context)
eq(cc.context['font'], cc.textfont) eq(cc.context['font'], self.text['font'])
eq(cc.context['fg'], cc.colors['foreground']) eq(cc.context['fg'], self.highlight_cfg['foreground'])
eq(cc.context['bg'], cc.colors['background']) eq(cc.context['bg'], self.highlight_cfg['background'])
eq(cc.context.get('1.0', 'end-1c'), '') eq(cc.context.get('1.0', 'end-1c'), '')
eq(cc.editwin.label, 'Hide Code Context') eq(cc.editwin.label, 'Hide Code Context')
eq(self.root.tk.call('after', 'info', self.cc.t1)[1], 'timer')
# Toggle off. # Toggle off.
eq(toggle(), 'break') eq(toggle(), 'break')
self.assertIsNone(cc.context) self.assertIsNone(cc.context)
eq(cc.editwin.label, 'Show Code Context') eq(cc.editwin.label, 'Show Code Context')
self.assertIsNone(self.cc.t1)
def test_get_context(self): def test_get_context(self):
eq = self.assertEqual eq = self.assertEqual
@ -227,7 +239,7 @@ class CodeContextTest(unittest.TestCase):
(4, 4, ' def __init__(self, a, b):', 'def')]) (4, 4, ' def __init__(self, a, b):', 'def')])
eq(cc.topvisible, 5) eq(cc.topvisible, 5)
eq(cc.context.get('1.0', 'end-1c'), 'class C1():\n' 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. # Scroll down to line 11. Last 'def' is removed.
cc.text.yview(11) cc.text.yview(11)
@ -239,9 +251,9 @@ class CodeContextTest(unittest.TestCase):
(10, 8, ' elif a < b:', 'elif')]) (10, 8, ' elif a < b:', 'elif')])
eq(cc.topvisible, 12) eq(cc.topvisible, 12)
eq(cc.context.get('1.0', 'end-1c'), 'class C1():\n' eq(cc.context.get('1.0', 'end-1c'), 'class C1():\n'
' def compare(self):\n' ' def compare(self):\n'
' if a > b:\n' ' if a > b:\n'
' elif a < b:') ' elif a < b:')
# No scroll. No update, even though context_depth changed. # No scroll. No update, even though context_depth changed.
cc.update_code_context() cc.update_code_context()
@ -253,9 +265,9 @@ class CodeContextTest(unittest.TestCase):
(10, 8, ' elif a < b:', 'elif')]) (10, 8, ' elif a < b:', 'elif')])
eq(cc.topvisible, 12) eq(cc.topvisible, 12)
eq(cc.context.get('1.0', 'end-1c'), 'class C1():\n' eq(cc.context.get('1.0', 'end-1c'), 'class C1():\n'
' def compare(self):\n' ' def compare(self):\n'
' if a > b:\n' ' if a > b:\n'
' elif a < b:') ' elif a < b:')
# Scroll up. # Scroll up.
cc.text.yview(5) cc.text.yview(5)
@ -276,7 +288,7 @@ class CodeContextTest(unittest.TestCase):
cc.toggle_code_context_event() cc.toggle_code_context_event()
# Empty context. # Empty context.
cc.text.yview(f'{2}.0') cc.text.yview('2.0')
cc.update_code_context() cc.update_code_context()
eq(cc.topvisible, 2) eq(cc.topvisible, 2)
cc.context.mark_set('insert', '1.5') cc.context.mark_set('insert', '1.5')
@ -284,7 +296,7 @@ class CodeContextTest(unittest.TestCase):
eq(cc.topvisible, 1) eq(cc.topvisible, 1)
# 4 lines of context showing. # 4 lines of context showing.
cc.text.yview(f'{12}.0') cc.text.yview('12.0')
cc.update_code_context() cc.update_code_context()
eq(cc.topvisible, 12) eq(cc.topvisible, 12)
cc.context.mark_set('insert', '3.0') cc.context.mark_set('insert', '3.0')
@ -293,7 +305,7 @@ class CodeContextTest(unittest.TestCase):
# More context lines than limit. # More context lines than limit.
cc.context_depth = 2 cc.context_depth = 2
cc.text.yview(f'{12}.0') cc.text.yview('12.0')
cc.update_code_context() cc.update_code_context()
eq(cc.topvisible, 12) eq(cc.topvisible, 12)
cc.context.mark_set('insert', '1.0') cc.context.mark_set('insert', '1.0')
@ -313,56 +325,72 @@ class CodeContextTest(unittest.TestCase):
self.cc.timer_event() self.cc.timer_event()
mock_update.assert_called() mock_update.assert_called()
def test_config_timer_event(self): def test_font(self):
eq = self.assertEqual eq = self.assertEqual
cc = self.cc cc = self.cc
save_font = cc.text['font'] save_font = cc.text['font']
save_colors = codecontext.CodeContext.colors test_font = 'TkFixedFont'
test_font = 'FakeFont'
# 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'} test_colors = {'background': '#222222', 'foreground': '#ffff00'}
# Ensure code context is not active. # Ensure code context is not active.
if cc.context: if cc.context:
cc.toggle_code_context_event() cc.toggle_code_context_event()
# Nothing updates on inactive code context. # Nothing breaks with inactive code context.
cc.text['font'] = test_font cc.update_highlight_colors()
codecontext.CodeContext.colors = test_colors
cc.config_timer_event()
eq(cc.textfont, save_font)
eq(cc.contextcolors, save_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.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['background'], save_colors['background'])
eq(cc.context['foreground'], save_colors['foreground']) eq(cc.context['foreground'], save_colors['foreground'])
# Active code context, change font. # Call colors update, but no change to font.
cc.text['font'] = test_font cc.update_highlight_colors()
cc.config_timer_event()
eq(cc.textfont, test_font)
eq(cc.contextcolors, save_colors)
eq(cc.context['font'], test_font)
eq(cc.context['background'], save_colors['background']) eq(cc.context['background'], save_colors['background'])
eq(cc.context['foreground'], save_colors['foreground']) eq(cc.context['foreground'], save_colors['foreground'])
cc.toggle_code_context_event()
# Active code context, change color. # Change colors and activate code context.
cc.text['font'] = save_font self.highlight_cfg = test_colors
codecontext.CodeContext.colors = test_colors cc.toggle_code_context_event()
cc.config_timer_event()
eq(cc.textfont, save_font)
eq(cc.contextcolors, test_colors)
eq(cc.context['font'], save_font)
eq(cc.context['background'], test_colors['background']) eq(cc.context['background'], test_colors['background'])
eq(cc.context['foreground'], test_colors['foreground']) 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): class HelperFunctionText(unittest.TestCase):

View file

@ -74,6 +74,8 @@ class OutputWindow(EditorWindow):
("Go to file/line", "<<goto-file-line>>", None), ("Go to file/line", "<<goto-file-line>>", None),
] ]
allow_codecontext = False
def __init__(self, *args): def __init__(self, *args):
EditorWindow.__init__(self, *args) EditorWindow.__init__(self, *args)
self.text.bind("<<goto-file-line>>", self.goto_file_line) self.text.bind("<<goto-file-line>>", self.goto_file_line)

View file

@ -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.