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)
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])
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

View File

@ -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 @@ class Event:
"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 @@ class Var:
def get(self):
return self.value
class Mbox_func:
"""Generic mock for messagebox functions, which all have the same signature.
@ -50,6 +55,7 @@ class Mbox_func:
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 @@ class Test(unittest.TestCase):
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 @@ class Text:
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 @@ class Text:
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 @@ class Text:
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 @@ class Text:
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 @@ class Text:
"Bind to this widget at event sequence a call to function func."
pass
class Entry:
"Mock for tkinter.Entry."
def focus_set(self):

View File

@ -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 @@ class Get_entityTest(unittest.TestCase):
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)

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.