diff --git a/Lib/idlelib/codecontext.py b/Lib/idlelib/codecontext.py index 2bfb2e988ff..efd163ed265 100644 --- a/Lib/idlelib/codecontext.py +++ b/Lib/idlelib/codecontext.py @@ -22,32 +22,49 @@ BLOCKOPENERS = {"class", "def", "elif", "else", "except", "finally", "for", UPDATEINTERVAL = 100 # millisec FONTUPDATEINTERVAL = 1000 # millisec + def getspacesfirstword(s, c=re.compile(r"^(\s*)(\w*)")): + "Extract the beginning whitespace and first word from s." return c.match(s).groups() class CodeContext: + "Display block context above the edit window." + bgcolor = "LightGray" fgcolor = "Black" 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.label displays the code context text above the editor text. + Initially None it is toggled via <>. + self.topvisible is the number of the top text line displayed. + self.info is a list of (line number, indent level, line text, + block keyword) tuples for the block structure above topvisible. + s self.info[0] is initialized a 'dummy' line which + # starts the toplevel 'block' of the module. + + self.t1 and self.t2 are two timer events on the editor text widget to + monitor for changes to the context text or editor font. + """ self.editwin = editwin self.text = editwin.text self.textfont = self.text["font"] self.label = None - # self.info is a list of (line number, indent level, line text, block - # keyword) tuples providing the block structure associated with - # self.topvisible (the linenumber of the line displayed at the top of - # the edit window). self.info[0] is initialized as a 'dummy' line which - # starts the toplevel 'block' of the module. - self.info = [(0, -1, "", False)] 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(FONTUPDATEINTERVAL, self.font_timer_event) @classmethod def reload(cls): + "Load class variables from config." cls.context_depth = idleConf.GetOption("extensions", "CodeContext", "numlines", type="int", default=3) ## cls.bgcolor = idleConf.GetOption("extensions", "CodeContext", @@ -56,6 +73,7 @@ class CodeContext: ## "fgcolor", type="str", default="Black") def __del__(self): + "Cancel scheduled events." try: self.text.after_cancel(self.t1) self.text.after_cancel(self.t2) @@ -63,6 +81,12 @@ class CodeContext: pass def toggle_code_context_event(self, event=None): + """Toggle code context display. + + If self.label doesn't exist, create it to match the size of the editor + window text (toggle on). If it does exist, destroy it (toggle off). + Return 'break' to complete the processing of the binding. + """ if not self.label: # Calculate the border width and horizontal padding required to # align the context with the text in the main Text widget. @@ -95,11 +119,10 @@ class CodeContext: return "break" def get_line_info(self, linenum): - """Get the line indent value, text, and any block start keyword + """Return tuple of (line indent value, text, and block start keyword). If the line does not start a block, the keyword value is False. The indentation of empty lines (or comment lines) is INFINITY. - """ text = self.text.get("%d.0" % linenum, "%d.end" % linenum) spaces, firstword = getspacesfirstword(text) @@ -111,11 +134,13 @@ class CodeContext: return indent, text, opener def get_context(self, new_topvisible, stopline=1, stopindent=0): - """Get context lines, starting at new_topvisible and working backwards. - - Stop when stopline or stopindent is reached. Return a tuple of context - data and the indent level at the top of the region inspected. + """Return a list of block line tuples and the 'last' indent. + The tuple fields are (linenum, indent, text, opener). + The list represents header lines from new_topvisible back to + stopline with successively shorter indents > stopindent. + The list is returned ordered by line number. + Last indent returned is the smallest indent observed. """ assert stopline > 0 lines = [] @@ -140,6 +165,11 @@ class CodeContext: def update_code_context(self): """Update context information and lines visible in the context pane. + No update is done if the text hasn't been scrolled. If the text + was scrolled, the lines that should be shown in the context will + be retrieved and the label widget will be updated with the code, + padded with blank lines so that the code appears on the bottom of + the context label. """ new_topvisible = int(self.text.index("@0,0").split('.')[0]) if self.topvisible == new_topvisible: # haven't scrolled @@ -151,7 +181,7 @@ class CodeContext: # between topvisible and new_topvisible: while self.info[-1][1] >= lastindent: del self.info[-1] - elif self.topvisible > new_topvisible: # scroll up + else: # self.topvisible > new_topvisible: # scroll up stopindent = self.info[-1][1] + 1 # retain only context info associated # with lines above new_topvisible: @@ -170,11 +200,13 @@ class CodeContext: self.label["text"] = '\n'.join(context_strings) def timer_event(self): + "Event on editor text widget triggered every UPDATEINTERVAL ms." if self.label: self.update_code_context() self.t1 = self.text.after(UPDATEINTERVAL, self.timer_event) def font_timer_event(self): + "Event on editor text widget triggered every FONTUPDATEINTERVAL ms." newtextfont = self.text["font"] if self.label and newtextfont != self.textfont: self.textfont = newtextfont @@ -183,3 +215,8 @@ class CodeContext: CodeContext.reload() + + +if __name__ == "__main__": # pragma: no cover + import unittest + unittest.main('idlelib.idle_test.test_codecontext', verbosity=2, exit=False) diff --git a/Lib/idlelib/idle_test/test_codecontext.py b/Lib/idlelib/idle_test/test_codecontext.py new file mode 100644 index 00000000000..448094eda7e --- /dev/null +++ b/Lib/idlelib/idle_test/test_codecontext.py @@ -0,0 +1,347 @@ +"""Test idlelib.codecontext. + +Coverage: 100% +""" + +import re + +import unittest +from unittest import mock +from test.support import requires +from tkinter import Tk, Frame, Text, TclError + +import idlelib.codecontext as codecontext +from idlelib import config + + +usercfg = codecontext.idleConf.userCfg +testcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} +code_sample = """\ + +class C1(): + # Class comment. + def __init__(self, a, b): + self.a = a + self.b = b + def compare(self): + if a > b: + return a + elif a < b: + return b + else: + return None +""" + + +class DummyEditwin: + def __init__(self, root, frame, text): + self.root = root + self.top = root + self.text_frame = frame + self.text = text + + +class CodeContextTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + frame = cls.frame = Frame(root) + text = cls.text = Text(frame) + text.insert('1.0', code_sample) + # Need to pack for creation of code context label widget. + frame.pack(side='left', fill='both', expand=1) + text.pack(side='top', fill='both', expand=1) + cls.editor = DummyEditwin(root, frame, text) + codecontext.idleConf.userCfg = testcfg + + @classmethod + def tearDownClass(cls): + codecontext.idleConf.userCfg = usercfg + cls.editor.text.delete('1.0', 'end') + del cls.editor, cls.frame, cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.cc = codecontext.CodeContext(self.editor) + + def tearDown(self): + if self.cc.label: + self.cc.label.destroy() + # Explicitly call __del__ to remove scheduled scripts. + self.cc.__del__() + del self.cc.label, self.cc + + def test_init(self): + eq = self.assertEqual + ed = self.editor + cc = self.cc + + eq(cc.editwin, ed) + eq(cc.text, ed.text) + eq(cc.textfont, ed.text['font']) + self.assertIsNone(cc.label) + 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') + + def test_del(self): + self.root.tk.call('after', 'info', self.cc.t1) + self.root.tk.call('after', 'info', self.cc.t2) + 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 + self.cc.__del__() + + def test_reload(self): + codecontext.CodeContext.reload() + self.assertEqual(self.cc.context_depth, 3) + + def test_toggle_code_context_event(self): + eq = self.assertEqual + cc = self.cc + toggle = cc.toggle_code_context_event + + # Make sure code context is off. + if cc.label: + toggle() + + # Toggle on. + eq(toggle(), 'break') + self.assertIsNotNone(cc.label) + eq(cc.label['font'], cc.textfont) + eq(cc.label['fg'], cc.fgcolor) + eq(cc.label['bg'], cc.bgcolor) + eq(cc.label['text'], '\n' * 2) + + # Toggle off. + eq(toggle(), 'break') + self.assertIsNone(cc.label) + + def test_get_line_info(self): + eq = self.assertEqual + gli = self.cc.get_line_info + + # Line 1 is not a BLOCKOPENER. + eq(gli(1), (codecontext.INFINITY, '', False)) + # Line 2 is a BLOCKOPENER without an indent. + eq(gli(2), (0, 'class C1():', 'class')) + # Line 3 is not a BLOCKOPENER and does not return the indent level. + eq(gli(3), (codecontext.INFINITY, ' # Class comment.', False)) + # Line 4 is a BLOCKOPENER and is indented. + eq(gli(4), (4, ' def __init__(self, a, b):', 'def')) + # Line 8 is a different BLOCKOPENER and is indented. + eq(gli(8), (8, ' if a > b:', 'if')) + + def test_get_context(self): + eq = self.assertEqual + gc = self.cc.get_context + + # stopline must be greater than 0. + with self.assertRaises(AssertionError): + gc(1, stopline=0) + + eq(gc(3), ([(2, 0, 'class C1():', 'class')], 0)) + + # Don't return comment. + eq(gc(4), ([(2, 0, 'class C1():', 'class')], 0)) + + # Two indentation levels and no comment. + eq(gc(5), ([(2, 0, 'class C1():', 'class'), + (4, 4, ' def __init__(self, a, b):', 'def')], 0)) + + # Only one 'def' is returned, not both at the same indent level. + eq(gc(10), ([(2, 0, 'class C1():', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if')], 0)) + + # With 'elif', also show the 'if' even though it's at the same level. + eq(gc(11), ([(2, 0, 'class C1():', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 0)) + + # Set stop_line to not go back to first line in source code. + # Return includes stop_line. + eq(gc(11, stopline=2), ([(2, 0, 'class C1():', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 0)) + eq(gc(11, stopline=3), ([(7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 4)) + eq(gc(11, stopline=8), ([(8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 8)) + + # Set stop_indent to test indent level to stop at. + eq(gc(11, stopindent=4), ([(7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 4)) + # Check that the 'if' is included. + eq(gc(11, stopindent=8), ([(8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 8)) + + def test_update_code_context(self): + eq = self.assertEqual + cc = self.cc + # Ensure code context is active. + if not cc.label: + cc.toggle_code_context_event() + + # Invoke update_code_context without scrolling - nothing happens. + self.assertIsNone(cc.update_code_context()) + eq(cc.info, [(0, -1, '', False)]) + eq(cc.topvisible, 1) + + # Scroll down to line 2. + cc.text.yview(2) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), (2, 0, 'class C1():', 'class')]) + eq(cc.topvisible, 3) + # context_depth is 3 so it pads with blank lines. + eq(cc.label['text'], '\n' + '\n' + 'class C1():') + + # Scroll down to line 3. Since it's a comment, nothing changes. + cc.text.yview(3) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), (2, 0, 'class C1():', 'class')]) + eq(cc.topvisible, 4) + eq(cc.label['text'], '\n' + '\n' + 'class C1():') + + # Scroll down to line 4. + cc.text.yview(4) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1():', 'class'), + (4, 4, ' def __init__(self, a, b):', 'def')]) + eq(cc.topvisible, 5) + eq(cc.label['text'], '\n' + 'class C1():\n' + ' def __init__(self, a, b):') + + # Scroll down to line 11. Last 'def' is removed. + cc.text.yview(11) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1():', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')]) + eq(cc.topvisible, 12) + eq(cc.label['text'], ' def compare(self):\n' + ' if a > b:\n' + ' elif a < b:') + + # No scroll. No update, even though context_depth changed. + cc.update_code_context() + cc.context_depth = 1 + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1():', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')]) + eq(cc.topvisible, 12) + eq(cc.label['text'], ' def compare(self):\n' + ' if a > b:\n' + ' elif a < b:') + + # Scroll up. + cc.text.yview(5) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1():', 'class'), + (4, 4, ' def __init__(self, a, b):', 'def')]) + eq(cc.topvisible, 6) + # context_depth is 1. + eq(cc.label['text'], ' def __init__(self, a, b):') + + @mock.patch.object(codecontext.CodeContext, 'update_code_context') + def test_timer_event(self, mock_update): + # Ensure code context is not active. + if self.cc.label: + self.cc.toggle_code_context_event() + self.cc.timer_event() + mock_update.assert_not_called() + + # Activate code context. + self.cc.toggle_code_context_event() + self.cc.timer_event() + mock_update.assert_called() + + def test_font_timer_event(self): + eq = self.assertEqual + cc = self.cc + save_font = cc.text['font'] + test_font = 'FakeFont' + + # Ensure code context is not active. + if cc.label: + cc.toggle_code_context_event() + + # Nothing updates on inactive code context. + cc.text['font'] = test_font + cc.font_timer_event() + eq(cc.textfont, save_font) + + # Activate code context, but no change to font. + cc.toggle_code_context_event() + cc.text['font'] = save_font + cc.font_timer_event() + eq(cc.textfont, save_font) + eq(cc.label['font'], save_font) + + # Active code context, change font. + cc.text['font'] = test_font + cc.font_timer_event() + eq(cc.textfont, test_font) + eq(cc.label['font'], test_font) + + cc.text['font'] = save_font + cc.font_timer_event() + + +class HelperFunctionText(unittest.TestCase): + + def test_getspacesfirstword(self): + get = codecontext.getspacesfirstword + test_lines = ( + (' first word', (' ', 'first')), + ('\tfirst word', ('\t', 'first')), + (' \u19D4\u19D2: ', (' ', '\u19D4\u19D2')), + ('no spaces', ('', 'no')), + ('', ('', '')), + ('# TEST COMMENT', ('', '')), + (' (continuation)', (' ', '')) + ) + for line, expected_output in test_lines: + self.assertEqual(get(line), expected_output) + + # Send the pattern in the call. + self.assertEqual(get(' (continuation)', + c=re.compile(r'^(\s*)([^\s]*)')), + (' ', '(continuation)')) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Misc/NEWS.d/next/IDLE/2018-02-12-08-08-45.bpo-32831.srDRvU.rst b/Misc/NEWS.d/next/IDLE/2018-02-12-08-08-45.bpo-32831.srDRvU.rst new file mode 100644 index 00000000000..583e341f94f --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2018-02-12-08-08-45.bpo-32831.srDRvU.rst @@ -0,0 +1 @@ +Add docstrings and tests for codecontext.