[Python-checkins] bpo-40511: Stop unwanted flashing of IDLE calltips (GH-20910)

miss-islington webhook-mailer at python.org
Sun Nov 1 23:49:47 EST 2020


https://github.com/python/cpython/commit/79e9f06149f92798a8e11e3f1c62dad171312ab3
commit: 79e9f06149f92798a8e11e3f1c62dad171312ab3
branch: 3.9
author: Miss Skeleton (bot) <31488909+miss-islington at users.noreply.github.com>
committer: miss-islington <31488909+miss-islington at users.noreply.github.com>
date: 2020-11-01T20:49:39-08:00
summary:

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 <tjreedy at udel.edu>
(cherry picked from commit da7bb7b4d769350c5fd03e6cfb16b23dc265ed72)

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

files:
A Misc/NEWS.d/next/IDLE/2020-06-16-12-16-13.bpo-40511.XkihpM.rst
M Lib/idlelib/calltip.py
M Lib/idlelib/idle_test/mock_tk.py
M Lib/idlelib/idle_test/test_calltip.py

diff --git a/Lib/idlelib/calltip.py b/Lib/idlelib/calltip.py
index b02f87207d8db..549e224015ccc 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 576f7d5d609e4..b736bd001da87 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 4d53df17d8cc7..489b6899baf42 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 0000000000000..cc96798138176
--- /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.



More information about the Python-checkins mailing list