Skip to content

Commit

Permalink
bpo-40511: Stop unwanted flashing of IDLE calltips (GH-20910)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
taleinat and terryjreedy committed Nov 2, 2020
1 parent 74fa464 commit da7bb7b
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 7 deletions.
36 changes: 34 additions & 2 deletions Lib/idlelib/calltip.py
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions Lib/idlelib/idle_test/mock_tk.py
Expand Up @@ -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.
Expand All @@ -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):
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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."

Expand All @@ -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')."

Expand All @@ -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').
Expand Down Expand Up @@ -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):
Expand Down
99 changes: 98 additions & 1 deletion 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
Expand Down Expand Up @@ -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)
@@ -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.

0 comments on commit da7bb7b

Please sign in to comment.