Skip to content

Commit da7bb7b

Browse files
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 <[email protected]>
1 parent 74fa464 commit da7bb7b

File tree

4 files changed

+144
-7
lines changed

4 files changed

+144
-7
lines changed

Lib/idlelib/calltip.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,50 @@ def refresh_calltip_event(self, event):
5555
self.open_calltip(False)
5656

5757
def open_calltip(self, evalfuncs):
58-
self.remove_calltip_window()
58+
"""Maybe close an existing calltip and maybe open a new calltip.
5959
60+
Called from (force_open|try_open|refresh)_calltip_event functions.
61+
"""
6062
hp = HyperParser(self.editwin, "insert")
6163
sur_paren = hp.get_surrounding_brackets('(')
64+
65+
# If not inside parentheses, no calltip.
6266
if not sur_paren:
67+
self.remove_calltip_window()
6368
return
69+
70+
# If a calltip is shown for the current parentheses, do
71+
# nothing.
72+
if self.active_calltip:
73+
opener_line, opener_col = map(int, sur_paren[0].split('.'))
74+
if (
75+
(opener_line, opener_col) ==
76+
(self.active_calltip.parenline, self.active_calltip.parencol)
77+
):
78+
return
79+
6480
hp.set_index(sur_paren[0])
65-
expression = hp.get_expression()
81+
try:
82+
expression = hp.get_expression()
83+
except ValueError:
84+
expression = None
6685
if not expression:
86+
# No expression before the opening parenthesis, e.g.
87+
# because it's in a string or the opener for a tuple:
88+
# Do nothing.
6789
return
90+
91+
# At this point, the current index is after an opening
92+
# parenthesis, in a section of code, preceded by a valid
93+
# expression. If there is a calltip shown, it's not for the
94+
# same index and should be closed.
95+
self.remove_calltip_window()
96+
97+
# Simple, fast heuristic: If the preceding expression includes
98+
# an opening parenthesis, it likely includes a function call.
6899
if not evalfuncs and (expression.find('(') != -1):
69100
return
101+
70102
argspec = self.fetch_tip(expression)
71103
if not argspec:
72104
return

Lib/idlelib/idle_test/mock_tk.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
A gui object is anything with a master or parent parameter, which is
44
typically required in spite of what the doc strings say.
55
"""
6+
import re
7+
from _tkinter import TclError
8+
69

710
class Event:
811
'''Minimal mock with attributes for testing event handlers.
@@ -22,6 +25,7 @@ def __init__(self, **kwds):
2225
"Create event with attributes needed for test"
2326
self.__dict__.update(kwds)
2427

28+
2529
class Var:
2630
"Use for String/Int/BooleanVar: incomplete"
2731
def __init__(self, master=None, value=None, name=None):
@@ -33,6 +37,7 @@ def set(self, value):
3337
def get(self):
3438
return self.value
3539

40+
3641
class Mbox_func:
3742
"""Generic mock for messagebox functions, which all have the same signature.
3843
@@ -50,6 +55,7 @@ def __call__(self, title, message, *args, **kwds):
5055
self.kwds = kwds
5156
return self.result # Set by tester for ask functions
5257

58+
5359
class Mbox:
5460
"""Mock for tkinter.messagebox with an Mbox_func for each function.
5561
@@ -85,7 +91,6 @@ def tearDownClass(cls):
8591
showinfo = Mbox_func() # None
8692
showwarning = Mbox_func() # None
8793

88-
from _tkinter import TclError
8994

9095
class Text:
9196
"""A semi-functional non-gui replacement for tkinter.Text text editors.
@@ -154,6 +159,8 @@ def _decode(self, index, endflag=0):
154159
if char.endswith(' lineend') or char == 'end':
155160
return line, linelength
156161
# Tk requires that ignored chars before ' lineend' be valid int
162+
if m := re.fullmatch(r'end-(\d*)c', char, re.A): # Used by hyperparser.
163+
return line, linelength - int(m.group(1))
157164

158165
# Out of bounds char becomes first or last index of line
159166
char = int(char)
@@ -177,7 +184,6 @@ def _endex(self, endflag):
177184
n -= 1
178185
return n, len(self.data[n]) + endflag
179186

180-
181187
def insert(self, index, chars):
182188
"Insert chars before the character at index."
183189

@@ -193,7 +199,6 @@ def insert(self, index, chars):
193199
self.data[line+1:line+1] = chars[1:]
194200
self.data[line+len(chars)-1] += after
195201

196-
197202
def get(self, index1, index2=None):
198203
"Return slice from index1 to index2 (default is 'index1+1')."
199204

@@ -212,7 +217,6 @@ def get(self, index1, index2=None):
212217
lines.append(self.data[endline][:endchar])
213218
return ''.join(lines)
214219

215-
216220
def delete(self, index1, index2=None):
217221
'''Delete slice from index1 to index2 (default is 'index1+1').
218222
@@ -297,6 +301,7 @@ def bind(sequence=None, func=None, add=None):
297301
"Bind to this widget at event sequence a call to function func."
298302
pass
299303

304+
300305
class Entry:
301306
"Mock for tkinter.Entry."
302307
def focus_set(self):

Lib/idlelib/idle_test/test_calltip.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
"Test calltip, coverage 60%"
1+
"Test calltip, coverage 76%"
22

33
from idlelib import calltip
44
import unittest
5+
from unittest.mock import Mock
56
import textwrap
67
import types
78
import re
9+
from idlelib.idle_test.mock_tk import Text
810

911

1012
# Test Class TC is used in multiple get_argspec test methods
@@ -257,5 +259,100 @@ def test_good_entity(self):
257259
self.assertIs(calltip.get_entity('int'), int)
258260

259261

262+
# Test the 9 Calltip methods.
263+
# open_calltip is about half the code; the others are fairly trivial.
264+
# The default mocks are what are needed for open_calltip.
265+
266+
class mock_Shell():
267+
"Return mock sufficient to pass to hyperparser."
268+
def __init__(self, text):
269+
text.tag_prevrange = Mock(return_value=None)
270+
self.text = text
271+
self.prompt_last_line = ">>> "
272+
self.indentwidth = 4
273+
self.tabwidth = 8
274+
275+
276+
class mock_TipWindow:
277+
def __init__(self):
278+
pass
279+
280+
def showtip(self, text, parenleft, parenright):
281+
self.args = parenleft, parenright
282+
self.parenline, self.parencol = map(int, parenleft.split('.'))
283+
284+
285+
class WrappedCalltip(calltip.Calltip):
286+
def _make_tk_calltip_window(self):
287+
return mock_TipWindow()
288+
289+
def remove_calltip_window(self, event=None):
290+
if self.active_calltip: # Setup to None.
291+
self.active_calltip = None
292+
self.tips_removed += 1 # Setup to 0.
293+
294+
def fetch_tip(self, expression):
295+
return 'tip'
296+
297+
298+
class CalltipTest(unittest.TestCase):
299+
300+
@classmethod
301+
def setUpClass(cls):
302+
cls.text = Text()
303+
cls.ct = WrappedCalltip(mock_Shell(cls.text))
304+
305+
def setUp(self):
306+
self.text.delete('1.0', 'end') # Insert and call
307+
self.ct.active_calltip = None
308+
# Test .active_calltip, +args
309+
self.ct.tips_removed = 0
310+
311+
def open_close(self, testfunc):
312+
# Open-close template with testfunc called in between.
313+
opentip = self.ct.open_calltip
314+
self.text.insert(1.0, 'f(')
315+
opentip(False)
316+
self.tip = self.ct.active_calltip
317+
testfunc(self) ###
318+
self.text.insert('insert', ')')
319+
opentip(False)
320+
self.assertIsNone(self.ct.active_calltip, None)
321+
322+
def test_open_close(self):
323+
def args(self):
324+
self.assertEqual(self.tip.args, ('1.1', '1.end'))
325+
self.open_close(args)
326+
327+
def test_repeated_force(self):
328+
def force(self):
329+
for char in 'abc':
330+
self.text.insert('insert', 'a')
331+
self.ct.open_calltip(True)
332+
self.ct.open_calltip(True)
333+
self.assertIs(self.ct.active_calltip, self.tip)
334+
self.open_close(force)
335+
336+
def test_repeated_parens(self):
337+
def parens(self):
338+
for context in "a", "'":
339+
with self.subTest(context=context):
340+
self.text.insert('insert', context)
341+
for char in '(()())':
342+
self.text.insert('insert', char)
343+
self.assertIs(self.ct.active_calltip, self.tip)
344+
self.text.insert('insert', "'")
345+
self.open_close(parens)
346+
347+
def test_comment_parens(self):
348+
def comment(self):
349+
self.text.insert('insert', "# ")
350+
for char in '(()())':
351+
self.text.insert('insert', char)
352+
self.assertIs(self.ct.active_calltip, self.tip)
353+
self.text.insert('insert', "\n")
354+
self.open_close(comment)
355+
356+
260357
if __name__ == '__main__':
261358
unittest.main(verbosity=2)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Typing opening and closing parentheses inside the parentheses of a function
2+
call will no longer cause unnecessary "flashing" off and on of an existing
3+
open call-tip, e.g. when typed in a string literal.

0 commit comments

Comments
 (0)