Skip to content
11 changes: 2 additions & 9 deletions Lib/idlelib/calltip.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,19 +182,12 @@ def get_argspec(ob):
# If fob has no argument, use default callable argspec.
argspec = _default_callable_argspec

lines = (textwrap.wrap(argspec, _MAX_COLS, subsequent_indent=_INDENT)
if len(argspec) > _MAX_COLS else [argspec] if argspec else [])
lines = [argspec] if argspec else []

# Augment lines from docstring, if any, and join to get argspec.
doc = inspect.getdoc(ob)
if doc:
for line in doc.split('\n', _MAX_LINES)[:_MAX_LINES]:
line = line.strip()
if not line:
break
if len(line) > _MAX_COLS:
line = line[: _MAX_COLS - 3] + '...'
lines.append(line)
lines.extend(map(str.strip, doc.split('\n')))
argspec = '\n'.join(lines)

return argspec or _default_callable_argspec
Expand Down
29 changes: 27 additions & 2 deletions Lib/idlelib/calltip_w.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,26 @@
Used by calltip.py.
"""
from tkinter import Label, LEFT, SOLID, TclError
from tkinter.scrolledtext import ScrolledText

from idlelib.tooltip import TooltipBase

HIDE_EVENT = "<<calltipwindow-hide>>"
HIDE_SEQUENCES = ("<Key-Escape>", "<FocusOut>")
HIDE_SEQUENCES = ("<Key-Escape>",)
CHECKHIDE_EVENT = "<<calltipwindow-checkhide>>"
CHECKHIDE_SEQUENCES = ("<KeyRelease>", "<ButtonRelease>")
CHECKHIDE_TIME = 100 # milliseconds

MARK_RIGHT = "calltipwindowregion_right"


def _widget_size(widget):
widget.update()
width = widget.winfo_width()
height = widget.winfo_height()
return width, height


class CalltipWindow(TooltipBase):
"""A call-tip widget for tkinter text widgets."""

Expand Down Expand Up @@ -74,16 +82,31 @@ def showtip(self, text, parenleft, parenright):
int, self.anchor_widget.index(parenleft).split("."))

super().showtip()
self.tipwindow.wm_attributes("-topmost", 1)

self._bind_events()

def showcontents(self):
"""Create the call-tip widget."""
self.label = Label(self.tipwindow, text=self.text, justify=LEFT,
self.label = Label(self.tipwindow, text=self.text, font=self.anchor_widget['font'])
self.label.pack()
old_w, old_h = _widget_size(self.label)
self.label.forget()

self.label = ScrolledText(self.tipwindow, wrap="word",
background="#ffffd0", foreground="black",
relief=SOLID, borderwidth=1,
font=self.anchor_widget['font'])
self.label.insert("1.0", self.text)
self.label.config(state="disabled")
self.label.pack()
new_w, new_h = _widget_size(self.label)

if self.label.yview()[1] == 1: # already shown entire text
self.label.vbar.forget()

w, h = min(old_w, new_w), min(old_h, new_h)
self.tipwindow.geometry("%dx%d" % (w, h))

def checkhide_event(self, event=None):
"""Handle CHECK_HIDE_EVENT: call hidetip or reschedule."""
Expand Down Expand Up @@ -156,6 +179,8 @@ def _bind_events(self):
self.hide_event)
for seq in HIDE_SEQUENCES:
self.anchor_widget.event_add(HIDE_EVENT, seq)
if self.tipwindow:
self.tipwindow.bind("<Key-Escape>", self.hide_event)

def _unbind_events(self):
"""Unbind event handlers."""
Expand Down
55 changes: 32 additions & 23 deletions Lib/idlelib/idle_test/test_calltip.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,20 @@ class SB: __call__ = None
non-overlapping occurrences of the pattern in string by the
replacement repl. repl can be either a string or a callable;
if a string, backslash escapes in it are processed. If it is
a callable, it's passed the Match object and must return''')
a callable, it's passed the Match object and must return
a replacement string to be used.''')
tiptest(p.sub, '''\
(repl, string, count=0)
Return the string obtained by replacing the leftmost \
non-overlapping occurrences o...''')
non-overlapping occurrences of pattern in string by the replacement repl.''')

def test_signature_wrap(self):
if textwrap.TextWrapper.__doc__ is not None:
self.assertEqual(get_spec(textwrap.TextWrapper), '''\
(width=70, initial_indent='', subsequent_indent='', expand_tabs=True,
replace_whitespace=True, fix_sentence_endings=False, break_long_words=True,
drop_whitespace=True, break_on_hyphens=True, tabsize=8, *, max_lines=None,
placeholder=' [...]')
self.assertEqual(get_spec(textwrap.TextWrapper).split('\n\n')[0], '''\
(width=70, initial_indent='', subsequent_indent='', expand_tabs=True, \
replace_whitespace=True, fix_sentence_endings=False, break_long_words=True, \
drop_whitespace=True, break_on_hyphens=True, tabsize=8, *, max_lines=None, \
placeholder=' [...]')
Object for wrapping/filling text. The public interface consists of
the wrap() and fill() methods; the other methods are just there for
subclasses to override in order to tweak the default behaviour.
Expand All @@ -124,19 +125,15 @@ def bar(s='a'*100):
def baz(s='a'*100, z='b'*100):
pass

indent = calltip._INDENT

sfoo = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + indent + "aaaaaaaaa"\
"aaaaaaaaaa')"
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')"
sbar = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + indent + "aaaaaaaaa"\
"aaaaaaaaaa')\nHello Guido"
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')"\
"\nHello Guido"
sbaz = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + indent + "aaaaaaaaa"\
"aaaaaaaaaa', z='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\
"bbbbbbbbbbbbbbbbb\n" + indent + "bbbbbbbbbbbbbbbbbbbbbb"\
"bbbbbbbbbbbbbbbbbbbbbb')"
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"\
" z='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')"

for func,doc in [(foo, sfoo), (bar, sbar), (baz, sbaz)]:
with self.subTest(func=func, doc=doc):
Expand All @@ -145,29 +142,41 @@ def baz(s='a'*100, z='b'*100):
def test_docline_truncation(self):
def f(): pass
f.__doc__ = 'a'*300
self.assertEqual(get_spec(f), f"()\n{'a'*(calltip._MAX_COLS-3) + '...'}")
self.assertEqual(get_spec(f), f"()\n{f.__doc__}")

@unittest.skipIf(MISSING_C_DOCSTRINGS,
"Signature information for builtins requires docstrings")
def test_multiline_docstring(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we removing this test?

Copy link
Contributor Author

@znsoooo znsoooo Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the old tests, the first blank line was used as a separator, and the text after the first blank line was not displayed. However, now that I use the "ScrolledText" widget to display the document. So it is easy to show very long text, so I'm not limit the display to the content before the first blank line.
Do you have any suggestions? Do I need to keep this feature unchanged?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, to say:

  • Before we only showed partial signature.
  • Now we can show the full signature. So old tests are no more needed.

I would advise to keep tests that check whether multiline signatures from builtins are also correctly shown

# Test fewer lines than max.
self.assertEqual(get_spec(range),
"range(stop) -> range object\n"
"range(start, stop[, step]) -> range object")
self.assertEqual(get_spec(range), '''\
range(stop) -> range object
range(start, stop[, step]) -> range object

Return an object that produces a sequence of integers from start (inclusive)
to stop (exclusive) by step. range(i, j) produces i, i+1, i+2, ..., j-1.
start defaults to 0, and stop is omitted! range(4) produces 0, 1, 2, 3.
These are exactly the valid indices for a list of 4 elements.
When step is given, it specifies the increment (or decrement).''')

# Test max lines
self.assertEqual(get_spec(bytes), '''\
bytes(iterable_of_ints) -> bytes
bytes(string, encoding[, errors]) -> bytes
bytes(bytes_or_buffer) -> immutable copy of bytes_or_buffer
bytes(int) -> bytes object of size given by the parameter initialized with null bytes
bytes() -> empty bytes object''')
bytes() -> empty bytes object

Construct an immutable array of bytes from:
- an iterable yielding integers in range(256)
- a text string encoded using the specified encoding
- any object implementing the buffer API.
- an integer''')

def test_multiline_docstring_2(self):
# Test more than max lines
def f(): pass
f.__doc__ = 'a\n' * 15
self.assertEqual(get_spec(f), '()' + '\na' * calltip._MAX_LINES)
self.assertEqual(get_spec(f), '()\n' + f.__doc__[:-1])

def test_functions(self):
def t1(): 'doc'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"Calltip" windows now support text selection, scrolling and
avoid truncating their content (in particular, docstrings
are shown in full). Patch by Shixian Li.
Loading