Skip to content

Commit 2474cef

Browse files
bpo-33839: refactor IDLE's tooltips & calltips, add docstrings and tests (GH-7683)
* make CallTip and ToolTip sub-classes of a common abstract base class * remove ListboxToolTip (unused and ugly) * greatly increase test coverage * tested on Windows, Linux and macOS (cherry picked from commit 87e59ac) Co-authored-by: Tal Einat <[email protected]>
1 parent c018074 commit 2474cef

File tree

7 files changed

+416
-144
lines changed

7 files changed

+416
-144
lines changed

‎Lib/idlelib/calltip.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def try_open_calltip_event(self, event):
5151
self.open_calltip(False)
5252

5353
def refresh_calltip_event(self, event):
54-
if self.active_calltip and self.active_calltip.is_active():
54+
if self.active_calltip and self.active_calltip.tipwindow:
5555
self.open_calltip(False)
5656

5757
def open_calltip(self, evalfuncs):

‎Lib/idlelib/calltip_w.py‎

Lines changed: 117 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,163 +1,195 @@
1-
"""A calltip window class for Tkinter/IDLE.
1+
"""A call-tip window class for Tkinter/IDLE.
22
3-
After tooltip.py, which uses ideas gleaned from PySol
4-
Used by calltip.
3+
After tooltip.py, which uses ideas gleaned from PySol.
4+
Used by calltip.py.
55
"""
6-
from tkinter import Toplevel, Label, LEFT, SOLID, TclError
6+
from tkinter import Label, LEFT, SOLID, TclError
77

8-
HIDE_VIRTUAL_EVENT_NAME = "<<calltipwindow-hide>>"
8+
from idlelib.tooltip import TooltipBase
9+
10+
HIDE_EVENT = "<<calltipwindow-hide>>"
911
HIDE_SEQUENCES = ("<Key-Escape>", "<FocusOut>")
10-
CHECKHIDE_VIRTUAL_EVENT_NAME = "<<calltipwindow-checkhide>>"
12+
CHECKHIDE_EVENT = "<<calltipwindow-checkhide>>"
1113
CHECKHIDE_SEQUENCES = ("<KeyRelease>", "<ButtonRelease>")
12-
CHECKHIDE_TIME = 100 # milliseconds
14+
CHECKHIDE_TIME = 100 # milliseconds
1315

1416
MARK_RIGHT = "calltipwindowregion_right"
1517

16-
class CalltipWindow:
1718

18-
def __init__(self, widget):
19-
self.widget = widget
20-
self.tipwindow = self.label = None
21-
self.parenline = self.parencol = None
22-
self.lastline = None
19+
class CalltipWindow(TooltipBase):
20+
"""A call-tip widget for tkinter text widgets."""
21+
22+
def __init__(self, text_widget):
23+
"""Create a call-tip; shown by showtip().
24+
25+
text_widget: a Text widget with code for which call-tips are desired
26+
"""
27+
# Note: The Text widget will be accessible as self.anchor_widget
28+
super(CalltipWindow, self).__init__(text_widget)
29+
30+
self.label = self.text = None
31+
self.parenline = self.parencol = self.lastline = None
2332
self.hideid = self.checkhideid = None
2433
self.checkhide_after_id = None
2534

26-
def position_window(self):
27-
"""Check if needs to reposition the window, and if so - do it."""
28-
curline = int(self.widget.index("insert").split('.')[0])
29-
if curline == self.lastline:
30-
return
31-
self.lastline = curline
32-
self.widget.see("insert")
35+
def get_position(self):
36+
"""Choose the position of the call-tip."""
37+
curline = int(self.anchor_widget.index("insert").split('.')[0])
3338
if curline == self.parenline:
34-
box = self.widget.bbox("%d.%d" % (self.parenline,
35-
self.parencol))
39+
anchor_index = (self.parenline, self.parencol)
3640
else:
37-
box = self.widget.bbox("%d.0" % curline)
41+
anchor_index = (curline, 0)
42+
box = self.anchor_widget.bbox("%d.%d" % anchor_index)
3843
if not box:
39-
box = list(self.widget.bbox("insert"))
44+
box = list(self.anchor_widget.bbox("insert"))
4045
# align to left of window
4146
box[0] = 0
4247
box[2] = 0
43-
x = box[0] + self.widget.winfo_rootx() + 2
44-
y = box[1] + box[3] + self.widget.winfo_rooty()
45-
self.tipwindow.wm_geometry("+%d+%d" % (x, y))
48+
return box[0] + 2, box[1] + box[3]
49+
50+
def position_window(self):
51+
"Reposition the window if needed."
52+
curline = int(self.anchor_widget.index("insert").split('.')[0])
53+
if curline == self.lastline:
54+
return
55+
self.lastline = curline
56+
self.anchor_widget.see("insert")
57+
super(CalltipWindow, self).position_window()
4658

4759
def showtip(self, text, parenleft, parenright):
48-
"""Show the calltip, bind events which will close it and reposition it.
60+
"""Show the call-tip, bind events which will close it and reposition it.
61+
62+
text: the text to display in the call-tip
63+
parenleft: index of the opening parenthesis in the text widget
64+
parenright: index of the closing parenthesis in the text widget,
65+
or the end of the line if there is no closing parenthesis
4966
"""
5067
# Only called in calltip.Calltip, where lines are truncated
5168
self.text = text
5269
if self.tipwindow or not self.text:
5370
return
5471

55-
self.widget.mark_set(MARK_RIGHT, parenright)
72+
self.anchor_widget.mark_set(MARK_RIGHT, parenright)
5673
self.parenline, self.parencol = map(
57-
int, self.widget.index(parenleft).split("."))
74+
int, self.anchor_widget.index(parenleft).split("."))
5875

59-
self.tipwindow = tw = Toplevel(self.widget)
60-
self.position_window()
61-
# remove border on calltip window
62-
tw.wm_overrideredirect(1)
63-
try:
64-
# This command is only needed and available on Tk >= 8.4.0 for OSX
65-
# Without it, call tips intrude on the typing process by grabbing
66-
# the focus.
67-
tw.tk.call("::tk::unsupported::MacWindowStyle", "style", tw._w,
68-
"help", "noActivates")
69-
except TclError:
70-
pass
71-
self.label = Label(tw, text=self.text, justify=LEFT,
76+
super(CalltipWindow, self).showtip()
77+
78+
self._bind_events()
79+
80+
def showcontents(self):
81+
"""Create the call-tip widget."""
82+
self.label = Label(self.tipwindow, text=self.text, justify=LEFT,
7283
background="#ffffe0", relief=SOLID, borderwidth=1,
73-
font = self.widget['font'])
84+
font=self.anchor_widget['font'])
7485
self.label.pack()
75-
tw.update_idletasks()
76-
tw.lift() # work around bug in Tk 8.5.18+ (issue #24570)
77-
78-
self.checkhideid = self.widget.bind(CHECKHIDE_VIRTUAL_EVENT_NAME,
79-
self.checkhide_event)
80-
for seq in CHECKHIDE_SEQUENCES:
81-
self.widget.event_add(CHECKHIDE_VIRTUAL_EVENT_NAME, seq)
82-
self.widget.after(CHECKHIDE_TIME, self.checkhide_event)
83-
self.hideid = self.widget.bind(HIDE_VIRTUAL_EVENT_NAME,
84-
self.hide_event)
85-
for seq in HIDE_SEQUENCES:
86-
self.widget.event_add(HIDE_VIRTUAL_EVENT_NAME, seq)
8786

8887
def checkhide_event(self, event=None):
88+
"""Handle CHECK_HIDE_EVENT: call hidetip or reschedule."""
8989
if not self.tipwindow:
90-
# If the event was triggered by the same event that unbinded
90+
# If the event was triggered by the same event that unbound
9191
# this function, the function will be called nevertheless,
9292
# so do nothing in this case.
9393
return None
94-
curline, curcol = map(int, self.widget.index("insert").split('.'))
94+
95+
# Hide the call-tip if the insertion cursor moves outside of the
96+
# parenthesis.
97+
curline, curcol = map(int, self.anchor_widget.index("insert").split('.'))
9598
if curline < self.parenline or \
9699
(curline == self.parenline and curcol <= self.parencol) or \
97-
self.widget.compare("insert", ">", MARK_RIGHT):
100+
self.anchor_widget.compare("insert", ">", MARK_RIGHT):
98101
self.hidetip()
99102
return "break"
100-
else:
101-
self.position_window()
102-
if self.checkhide_after_id is not None:
103-
self.widget.after_cancel(self.checkhide_after_id)
104-
self.checkhide_after_id = \
105-
self.widget.after(CHECKHIDE_TIME, self.checkhide_event)
106-
return None
103+
104+
# Not hiding the call-tip.
105+
106+
self.position_window()
107+
# Re-schedule this function to be called again in a short while.
108+
if self.checkhide_after_id is not None:
109+
self.anchor_widget.after_cancel(self.checkhide_after_id)
110+
self.checkhide_after_id = \
111+
self.anchor_widget.after(CHECKHIDE_TIME, self.checkhide_event)
112+
return None
107113

108114
def hide_event(self, event):
115+
"""Handle HIDE_EVENT by calling hidetip."""
109116
if not self.tipwindow:
110117
# See the explanation in checkhide_event.
111118
return None
112119
self.hidetip()
113120
return "break"
114121

115122
def hidetip(self):
123+
"""Hide the call-tip."""
116124
if not self.tipwindow:
117125
return
118126

119-
for seq in CHECKHIDE_SEQUENCES:
120-
self.widget.event_delete(CHECKHIDE_VIRTUAL_EVENT_NAME, seq)
121-
self.widget.unbind(CHECKHIDE_VIRTUAL_EVENT_NAME, self.checkhideid)
122-
self.checkhideid = None
123-
for seq in HIDE_SEQUENCES:
124-
self.widget.event_delete(HIDE_VIRTUAL_EVENT_NAME, seq)
125-
self.widget.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hideid)
126-
self.hideid = None
127-
128-
self.label.destroy()
127+
try:
128+
self.label.destroy()
129+
except TclError:
130+
pass
129131
self.label = None
130-
self.tipwindow.destroy()
131-
self.tipwindow = None
132132

133-
self.widget.mark_unset(MARK_RIGHT)
134133
self.parenline = self.parencol = self.lastline = None
134+
try:
135+
self.anchor_widget.mark_unset(MARK_RIGHT)
136+
except TclError:
137+
pass
135138

136-
def is_active(self):
137-
return bool(self.tipwindow)
139+
try:
140+
self._unbind_events()
141+
except (TclError, ValueError):
142+
# ValueError may be raised by MultiCall
143+
pass
144+
145+
super(CalltipWindow, self).hidetip()
146+
147+
def _bind_events(self):
148+
"""Bind event handlers."""
149+
self.checkhideid = self.anchor_widget.bind(CHECKHIDE_EVENT,
150+
self.checkhide_event)
151+
for seq in CHECKHIDE_SEQUENCES:
152+
self.anchor_widget.event_add(CHECKHIDE_EVENT, seq)
153+
self.anchor_widget.after(CHECKHIDE_TIME, self.checkhide_event)
154+
self.hideid = self.anchor_widget.bind(HIDE_EVENT,
155+
self.hide_event)
156+
for seq in HIDE_SEQUENCES:
157+
self.anchor_widget.event_add(HIDE_EVENT, seq)
158+
159+
def _unbind_events(self):
160+
"""Unbind event handlers."""
161+
for seq in CHECKHIDE_SEQUENCES:
162+
self.anchor_widget.event_delete(CHECKHIDE_EVENT, seq)
163+
self.anchor_widget.unbind(CHECKHIDE_EVENT, self.checkhideid)
164+
self.checkhideid = None
165+
for seq in HIDE_SEQUENCES:
166+
self.anchor_widget.event_delete(HIDE_EVENT, seq)
167+
self.anchor_widget.unbind(HIDE_EVENT, self.hideid)
168+
self.hideid = None
138169

139170

140171
def _calltip_window(parent): # htest #
141172
from tkinter import Toplevel, Text, LEFT, BOTH
142173

143174
top = Toplevel(parent)
144-
top.title("Test calltips")
175+
top.title("Test call-tips")
145176
x, y = map(int, parent.geometry().split('+')[1:])
146-
top.geometry("200x100+%d+%d" % (x + 250, y + 175))
177+
top.geometry("250x100+%d+%d" % (x + 175, y + 150))
147178
text = Text(top)
148179
text.pack(side=LEFT, fill=BOTH, expand=1)
149180
text.insert("insert", "string.split")
150181
top.update()
151-
calltip = CalltipWindow(text)
152182

183+
calltip = CalltipWindow(text)
153184
def calltip_show(event):
154-
calltip.showtip("(s=Hello world)", "insert", "end")
185+
calltip.showtip("(s='Hello world')", "insert", "end")
155186
def calltip_hide(event):
156187
calltip.hidetip()
157188
text.event_add("<<calltip-show>>", "(")
158189
text.event_add("<<calltip-hide>>", ")")
159190
text.bind("<<calltip-show>>", calltip_show)
160191
text.bind("<<calltip-hide>>", calltip_hide)
192+
161193
text.focus_set()
162194

163195
if __name__ == '__main__':

‎Lib/idlelib/idle_test/htest.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,14 @@ def _wrapper(parent): # htest #
8080
"are correctly displayed.\n [Close] to exit.",
8181
}
8282

83+
# TODO implement ^\; adding '<Control-Key-\\>' to function does not work.
8384
_calltip_window_spec = {
8485
'file': 'calltip_w',
8586
'kwds': {},
8687
'msg': "Typing '(' should display a calltip.\n"
8788
"Typing ') should hide the calltip.\n"
89+
"So should moving cursor out of argument area.\n"
90+
"Force-open-calltip does not work here.\n"
8891
}
8992

9093
_module_browser_spec = {

‎Lib/idlelib/idle_test/test_calltip_w.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def tearDownClass(cls):
2323
del cls.text, cls.root
2424

2525
def test_init(self):
26-
self.assertEqual(self.calltip.widget, self.text)
26+
self.assertEqual(self.calltip.anchor_widget, self.text)
2727

2828
if __name__ == '__main__':
2929
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)