From da7bb7b4d769350c5fd03e6cfb16b23dc265ed72 Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Mon, 2 Nov 2020 05:59:52 +0200 Subject: [PATCH] bpo-40511: Stop unwanted flashing of IDLE calltips (GH-20910) They were occurring with both repeated 'force-calltip' invocations and by typing parentheses in expressions, strings, and comments in the argument code. Co-authored-by: Terry Jan Reedy --- Lib/idlelib/calltip.py | 36 ++++++- Lib/idlelib/idle_test/mock_tk.py | 13 ++- Lib/idlelib/idle_test/test_calltip.py | 99 ++++++++++++++++++- .../2020-06-16-12-16-13.bpo-40511.XkihpM.rst | 3 + 4 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2020-06-16-12-16-13.bpo-40511.XkihpM.rst diff --git a/Lib/idlelib/calltip.py b/Lib/idlelib/calltip.py index b02f87207d8db1..549e224015cccb 100644 --- a/Lib/idlelib/calltip.py +++ b/Lib/idlelib/calltip.py @@ -55,18 +55,50 @@ def refresh_calltip_event(self, event): self.open_calltip(False) def open_calltip(self, evalfuncs): - self.remove_calltip_window() + """Maybe close an existing calltip and maybe open a new calltip. + Called from (force_open|try_open|refresh)_calltip_event functions. + """ hp = HyperParser(self.editwin, "insert") sur_paren = hp.get_surrounding_brackets('(') + + # If not inside parentheses, no calltip. if not sur_paren: + self.remove_calltip_window() return + + # If a calltip is shown for the current parentheses, do + # nothing. + if self.active_calltip: + opener_line, opener_col = map(int, sur_paren[0].split('.')) + if ( + (opener_line, opener_col) == + (self.active_calltip.parenline, self.active_calltip.parencol) + ): + return + hp.set_index(sur_paren[0]) - expression = hp.get_expression() + try: + expression = hp.get_expression() + except ValueError: + expression = None if not expression: + # No expression before the opening parenthesis, e.g. + # because it's in a string or the opener for a tuple: + # Do nothing. return + + # At this point, the current index is after an opening + # parenthesis, in a section of code, preceded by a valid + # expression. If there is a calltip shown, it's not for the + # same index and should be closed. + self.remove_calltip_window() + + # Simple, fast heuristic: If the preceding expression includes + # an opening parenthesis, it likely includes a function call. if not evalfuncs and (expression.find('(') != -1): return + argspec = self.fetch_tip(expression) if not argspec: return diff --git a/Lib/idlelib/idle_test/mock_tk.py b/Lib/idlelib/idle_test/mock_tk.py index 576f7d5d609e4d..b736bd001da87f 100644 --- a/Lib/idlelib/idle_test/mock_tk.py +++ b/Lib/idlelib/idle_test/mock_tk.py @@ -3,6 +3,9 @@ A gui object is anything with a master or parent parameter, which is typically required in spite of what the doc strings say. """ +import re +from _tkinter import TclError + class Event: '''Minimal mock with attributes for testing event handlers. @@ -22,6 +25,7 @@ def __init__(self, **kwds): "Create event with attributes needed for test" self.__dict__.update(kwds) + class Var: "Use for String/Int/BooleanVar: incomplete" def __init__(self, master=None, value=None, name=None): @@ -33,6 +37,7 @@ def set(self, value): def get(self): return self.value + class Mbox_func: """Generic mock for messagebox functions, which all have the same signature. @@ -50,6 +55,7 @@ def __call__(self, title, message, *args, **kwds): self.kwds = kwds return self.result # Set by tester for ask functions + class Mbox: """Mock for tkinter.messagebox with an Mbox_func for each function. @@ -85,7 +91,6 @@ def tearDownClass(cls): showinfo = Mbox_func() # None showwarning = Mbox_func() # None -from _tkinter import TclError class Text: """A semi-functional non-gui replacement for tkinter.Text text editors. @@ -154,6 +159,8 @@ def _decode(self, index, endflag=0): if char.endswith(' lineend') or char == 'end': return line, linelength # Tk requires that ignored chars before ' lineend' be valid int + if m := re.fullmatch(r'end-(\d*)c', char, re.A): # Used by hyperparser. + return line, linelength - int(m.group(1)) # Out of bounds char becomes first or last index of line char = int(char) @@ -177,7 +184,6 @@ def _endex(self, endflag): n -= 1 return n, len(self.data[n]) + endflag - def insert(self, index, chars): "Insert chars before the character at index." @@ -193,7 +199,6 @@ def insert(self, index, chars): self.data[line+1:line+1] = chars[1:] self.data[line+len(chars)-1] += after - def get(self, index1, index2=None): "Return slice from index1 to index2 (default is 'index1+1')." @@ -212,7 +217,6 @@ def get(self, index1, index2=None): lines.append(self.data[endline][:endchar]) return ''.join(lines) - def delete(self, index1, index2=None): '''Delete slice from index1 to index2 (default is 'index1+1'). @@ -297,6 +301,7 @@ def bind(sequence=None, func=None, add=None): "Bind to this widget at event sequence a call to function func." pass + class Entry: "Mock for tkinter.Entry." def focus_set(self): diff --git a/Lib/idlelib/idle_test/test_calltip.py b/Lib/idlelib/idle_test/test_calltip.py index 4d53df17d8cc7c..489b6899baf424 100644 --- a/Lib/idlelib/idle_test/test_calltip.py +++ b/Lib/idlelib/idle_test/test_calltip.py @@ -1,10 +1,12 @@ -"Test calltip, coverage 60%" +"Test calltip, coverage 76%" from idlelib import calltip import unittest +from unittest.mock import Mock import textwrap import types import re +from idlelib.idle_test.mock_tk import Text # Test Class TC is used in multiple get_argspec test methods @@ -257,5 +259,100 @@ def test_good_entity(self): self.assertIs(calltip.get_entity('int'), int) +# Test the 9 Calltip methods. +# open_calltip is about half the code; the others are fairly trivial. +# The default mocks are what are needed for open_calltip. + +class mock_Shell(): + "Return mock sufficient to pass to hyperparser." + def __init__(self, text): + text.tag_prevrange = Mock(return_value=None) + self.text = text + self.prompt_last_line = ">>> " + self.indentwidth = 4 + self.tabwidth = 8 + + +class mock_TipWindow: + def __init__(self): + pass + + def showtip(self, text, parenleft, parenright): + self.args = parenleft, parenright + self.parenline, self.parencol = map(int, parenleft.split('.')) + + +class WrappedCalltip(calltip.Calltip): + def _make_tk_calltip_window(self): + return mock_TipWindow() + + def remove_calltip_window(self, event=None): + if self.active_calltip: # Setup to None. + self.active_calltip = None + self.tips_removed += 1 # Setup to 0. + + def fetch_tip(self, expression): + return 'tip' + + +class CalltipTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.text = Text() + cls.ct = WrappedCalltip(mock_Shell(cls.text)) + + def setUp(self): + self.text.delete('1.0', 'end') # Insert and call + self.ct.active_calltip = None + # Test .active_calltip, +args + self.ct.tips_removed = 0 + + def open_close(self, testfunc): + # Open-close template with testfunc called in between. + opentip = self.ct.open_calltip + self.text.insert(1.0, 'f(') + opentip(False) + self.tip = self.ct.active_calltip + testfunc(self) ### + self.text.insert('insert', ')') + opentip(False) + self.assertIsNone(self.ct.active_calltip, None) + + def test_open_close(self): + def args(self): + self.assertEqual(self.tip.args, ('1.1', '1.end')) + self.open_close(args) + + def test_repeated_force(self): + def force(self): + for char in 'abc': + self.text.insert('insert', 'a') + self.ct.open_calltip(True) + self.ct.open_calltip(True) + self.assertIs(self.ct.active_calltip, self.tip) + self.open_close(force) + + def test_repeated_parens(self): + def parens(self): + for context in "a", "'": + with self.subTest(context=context): + self.text.insert('insert', context) + for char in '(()())': + self.text.insert('insert', char) + self.assertIs(self.ct.active_calltip, self.tip) + self.text.insert('insert', "'") + self.open_close(parens) + + def test_comment_parens(self): + def comment(self): + self.text.insert('insert', "# ") + for char in '(()())': + self.text.insert('insert', char) + self.assertIs(self.ct.active_calltip, self.tip) + self.text.insert('insert', "\n") + self.open_close(comment) + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Misc/NEWS.d/next/IDLE/2020-06-16-12-16-13.bpo-40511.XkihpM.rst b/Misc/NEWS.d/next/IDLE/2020-06-16-12-16-13.bpo-40511.XkihpM.rst new file mode 100644 index 00000000000000..cc967981381769 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2020-06-16-12-16-13.bpo-40511.XkihpM.rst @@ -0,0 +1,3 @@ +Typing opening and closing parentheses inside the parentheses of a function +call will no longer cause unnecessary "flashing" off and on of an existing +open call-tip, e.g. when typed in a string literal.