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@udel.edu>
This commit is contained in:
Tal Einat 2020-11-02 05:59:52 +02:00 committed by GitHub
parent 74fa464b81
commit da7bb7b4d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 144 additions and 7 deletions

View File

@ -55,18 +55,50 @@ class Calltip:
self.open_calltip(False) self.open_calltip(False)
def open_calltip(self, evalfuncs): 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") hp = HyperParser(self.editwin, "insert")
sur_paren = hp.get_surrounding_brackets('(') sur_paren = hp.get_surrounding_brackets('(')
# If not inside parentheses, no calltip.
if not sur_paren: if not sur_paren:
self.remove_calltip_window()
return 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]) hp.set_index(sur_paren[0])
expression = hp.get_expression() try:
expression = hp.get_expression()
except ValueError:
expression = None
if not expression: 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 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): if not evalfuncs and (expression.find('(') != -1):
return return
argspec = self.fetch_tip(expression) argspec = self.fetch_tip(expression)
if not argspec: if not argspec:
return return

View File

@ -3,6 +3,9 @@
A gui object is anything with a master or parent parameter, which is A gui object is anything with a master or parent parameter, which is
typically required in spite of what the doc strings say. typically required in spite of what the doc strings say.
""" """
import re
from _tkinter import TclError
class Event: class Event:
'''Minimal mock with attributes for testing event handlers. '''Minimal mock with attributes for testing event handlers.
@ -22,6 +25,7 @@ class Event:
"Create event with attributes needed for test" "Create event with attributes needed for test"
self.__dict__.update(kwds) self.__dict__.update(kwds)
class Var: class Var:
"Use for String/Int/BooleanVar: incomplete" "Use for String/Int/BooleanVar: incomplete"
def __init__(self, master=None, value=None, name=None): def __init__(self, master=None, value=None, name=None):
@ -33,6 +37,7 @@ class Var:
def get(self): def get(self):
return self.value return self.value
class Mbox_func: class Mbox_func:
"""Generic mock for messagebox functions, which all have the same signature. """Generic mock for messagebox functions, which all have the same signature.
@ -50,6 +55,7 @@ class Mbox_func:
self.kwds = kwds self.kwds = kwds
return self.result # Set by tester for ask functions return self.result # Set by tester for ask functions
class Mbox: class Mbox:
"""Mock for tkinter.messagebox with an Mbox_func for each function. """Mock for tkinter.messagebox with an Mbox_func for each function.
@ -85,7 +91,6 @@ class Test(unittest.TestCase):
showinfo = Mbox_func() # None showinfo = Mbox_func() # None
showwarning = Mbox_func() # None showwarning = Mbox_func() # None
from _tkinter import TclError
class Text: class Text:
"""A semi-functional non-gui replacement for tkinter.Text text editors. """A semi-functional non-gui replacement for tkinter.Text text editors.
@ -154,6 +159,8 @@ class Text:
if char.endswith(' lineend') or char == 'end': if char.endswith(' lineend') or char == 'end':
return line, linelength return line, linelength
# Tk requires that ignored chars before ' lineend' be valid int # 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 # Out of bounds char becomes first or last index of line
char = int(char) char = int(char)
@ -177,7 +184,6 @@ class Text:
n -= 1 n -= 1
return n, len(self.data[n]) + endflag return n, len(self.data[n]) + endflag
def insert(self, index, chars): def insert(self, index, chars):
"Insert chars before the character at index." "Insert chars before the character at index."
@ -193,7 +199,6 @@ class Text:
self.data[line+1:line+1] = chars[1:] self.data[line+1:line+1] = chars[1:]
self.data[line+len(chars)-1] += after self.data[line+len(chars)-1] += after
def get(self, index1, index2=None): def get(self, index1, index2=None):
"Return slice from index1 to index2 (default is 'index1+1')." "Return slice from index1 to index2 (default is 'index1+1')."
@ -212,7 +217,6 @@ class Text:
lines.append(self.data[endline][:endchar]) lines.append(self.data[endline][:endchar])
return ''.join(lines) return ''.join(lines)
def delete(self, index1, index2=None): def delete(self, index1, index2=None):
'''Delete slice from index1 to index2 (default is 'index1+1'). '''Delete slice from index1 to index2 (default is 'index1+1').
@ -297,6 +301,7 @@ class Text:
"Bind to this widget at event sequence a call to function func." "Bind to this widget at event sequence a call to function func."
pass pass
class Entry: class Entry:
"Mock for tkinter.Entry." "Mock for tkinter.Entry."
def focus_set(self): def focus_set(self):

View File

@ -1,10 +1,12 @@
"Test calltip, coverage 60%" "Test calltip, coverage 76%"
from idlelib import calltip from idlelib import calltip
import unittest import unittest
from unittest.mock import Mock
import textwrap import textwrap
import types import types
import re import re
from idlelib.idle_test.mock_tk import Text
# Test Class TC is used in multiple get_argspec test methods # Test Class TC is used in multiple get_argspec test methods
@ -257,5 +259,100 @@ class Get_entityTest(unittest.TestCase):
self.assertIs(calltip.get_entity('int'), int) 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__': if __name__ == '__main__':
unittest.main(verbosity=2) unittest.main(verbosity=2)

View File

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