Skip to content

[3.8] bpo-40511: Stop unwanted flashing of IDLE calltips (GH-20910) #23094

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions Lib/idlelib/calltip.py
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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.