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:
parent
74fa464b81
commit
da7bb7b4d7
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
Loading…
Reference in New Issue