From 189c2686e7243fbc0ca9e81d9a76ffde8deb253e Mon Sep 17 00:00:00 2001 From: Shixian Li Date: Sun, 26 Jun 2022 22:23:57 +0800 Subject: [PATCH 1/8] Feat: Use ScrolledText widget to show tooltip --- Lib/idlelib/calltip.py | 12 ++---- Lib/idlelib/calltip_w.py | 12 +++++- Lib/idlelib/idle_test/test_calltip.py | 62 ++++++++------------------- 3 files changed, 30 insertions(+), 56 deletions(-) diff --git a/Lib/idlelib/calltip.py b/Lib/idlelib/calltip.py index 40bc5a0ad798fe..db49ac0bea82ee 100644 --- a/Lib/idlelib/calltip.py +++ b/Lib/idlelib/calltip.py @@ -182,19 +182,13 @@ 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) + for line in doc.split('\n'): + lines.append(line.strip()) argspec = '\n'.join(lines) return argspec or _default_callable_argspec diff --git a/Lib/idlelib/calltip_w.py b/Lib/idlelib/calltip_w.py index 9386376058c791..5efe8983431038 100644 --- a/Lib/idlelib/calltip_w.py +++ b/Lib/idlelib/calltip_w.py @@ -4,11 +4,12 @@ Used by calltip.py. """ from tkinter import Label, LEFT, SOLID, TclError +from tkinter.scrolledtext import ScrolledText from idlelib.tooltip import TooltipBase HIDE_EVENT = "<>" -HIDE_SEQUENCES = ("", "") +HIDE_SEQUENCES = ("",) CHECKHIDE_EVENT = "<>" CHECKHIDE_SEQUENCES = ("", "") CHECKHIDE_TIME = 100 # milliseconds @@ -74,17 +75,22 @@ 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 = 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() + self.tipwindow.geometry('%dx%d' % (400, 120)) + def checkhide_event(self, event=None): """Handle CHECK_HIDE_EVENT: call hidetip or reschedule.""" if not self.tipwindow: @@ -156,6 +162,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("", self.hide_event) def _unbind_events(self): """Unbind event handlers.""" diff --git a/Lib/idlelib/idle_test/test_calltip.py b/Lib/idlelib/idle_test/test_calltip.py index 28c196a42672fc..a6a2c49a31db9e 100644 --- a/Lib/idlelib/idle_test/test_calltip.py +++ b/Lib/idlelib/idle_test/test_calltip.py @@ -93,24 +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...''') +Return the string obtained by replacing the leftmost non-overlapping \ +occurrences of pattern in string by the replacement repl.''') - def test_signature_wrap(self): + def test_signature(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=' [...]') -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. -If you want to completely replace the main wrapping algorithm, -you\'ll probably have to override _wrap_chunks().''') + self.assertEqual(get_spec(textwrap.TextWrapper).split('\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=' [...]')''') def test_properly_formatted(self): @@ -127,16 +123,14 @@ def baz(s='a'*100, z='b'*100): 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): @@ -145,29 +139,7 @@ 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) + '...'}") - - @unittest.skipIf(MISSING_C_DOCSTRINGS, - "Signature information for builtins requires docstrings") - def test_multiline_docstring(self): - # Test fewer lines than max. - self.assertEqual(get_spec(range), - "range(stop) -> range object\n" - "range(start, stop[, step]) -> range object") - - # 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''') - - 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%s" % ('a'*300)) def test_functions(self): def t1(): 'doc' From ab496a5614b9ca90496ef4ea27dee8d7f5f1ef68 Mon Sep 17 00:00:00 2001 From: Shixian Li Date: Sun, 21 Dec 2025 04:25:42 +0800 Subject: [PATCH 2/8] Feat: Auto resize tooltip window to best size --- Lib/idlelib/calltip_w.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/Lib/idlelib/calltip_w.py b/Lib/idlelib/calltip_w.py index 5efe8983431038..9753c00fbe30ba 100644 --- a/Lib/idlelib/calltip_w.py +++ b/Lib/idlelib/calltip_w.py @@ -17,6 +17,13 @@ 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.""" @@ -75,21 +82,30 @@ def showtip(self, text, parenleft, parenright): int, self.anchor_widget.index(parenleft).split(".")) super().showtip() - self.tipwindow.wm_attributes('-topmost', 1) + 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, font=self.anchor_widget['font']) + self.label.pack() + label_w, label_h = widget_size(self.label) # get the old version of tooltip window size + 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') + font=self.anchor_widget["font"]) + self.label.insert("1.0", self.text) + self.label.config(state="disabled") self.label.pack() + max_w, max_h = widget_size(self.label) + + if self.label.yview()[1] == 1: # already shown entire text + self.label.vbar.forget() - self.tipwindow.geometry('%dx%d' % (400, 120)) + self.tipwindow.geometry("%dx%d" % (min(label_w, max_w), min(label_h, max_h))) def checkhide_event(self, event=None): """Handle CHECK_HIDE_EVENT: call hidetip or reschedule.""" From 752344a25c47a7037133946495a3e487d9987499 Mon Sep 17 00:00:00 2001 From: Shixian Li Date: Sun, 21 Dec 2025 07:07:14 +0800 Subject: [PATCH 3/8] Adds news entries --- .../2025-12-21-07-02-44.gh-issue-94520.lqenne.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst diff --git a/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst b/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst new file mode 100644 index 00000000000000..e5b72e0e47f330 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst @@ -0,0 +1,11 @@ +Make CallTips selectable + +The text display widget in the "CalltipWindow" has been changed from +"tk.Label" to "ScrolledText", and now the text in the "Calltip" window can +be selected with mouse. The display size of the "CalltipWindow" is set to +the smaller value between the size when using the "tk.Label" widget and the +default size of "tk.Text". When the displayed text exceeds the display area +of the "ScrolledText" window, showing the vertical scrollbar; otherwise, +hiding the scrollbar. Since more text can be displayed, "argspec" is no +longer truncated, and the tests related to the max lines or text truncation +have been removed from the unit tests. Contributed by Shixian Li. From 3fc3894488609d0f4813fdedbad73c404a42d40f Mon Sep 17 00:00:00 2001 From: Shixian Li Date: Sat, 27 Dec 2025 18:44:33 +0800 Subject: [PATCH 4/8] Revert some tese cases --- Lib/idlelib/idle_test/test_calltip.py | 53 ++++++++++++++++--- ...5-12-21-07-02-44.gh-issue-94520.lqenne.rst | 13 ++--- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/Lib/idlelib/idle_test/test_calltip.py b/Lib/idlelib/idle_test/test_calltip.py index a6a2c49a31db9e..ce48cc2e70fc0a 100644 --- a/Lib/idlelib/idle_test/test_calltip.py +++ b/Lib/idlelib/idle_test/test_calltip.py @@ -97,16 +97,21 @@ class SB: __call__ = None a replacement string to be used.''') tiptest(p.sub, '''\ (repl, string, count=0) -Return the string obtained by replacing the leftmost non-overlapping \ -occurrences of pattern in string by the replacement repl.''') +Return the string obtained by replacing the leftmost \ +non-overlapping occurrences of pattern in string by the replacement repl.''') - def test_signature(self): + def test_signature_wrap(self): if textwrap.TextWrapper.__doc__ is not None: - self.assertEqual(get_spec(textwrap.TextWrapper).split('\n')[0], '''\ + 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=' [...]')''') +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. +If you want to completely replace the main wrapping algorithm, +you\'ll probably have to override _wrap_chunks().''') def test_properly_formatted(self): @@ -120,8 +125,6 @@ def bar(s='a'*100): def baz(s='a'*100, z='b'*100): pass - indent = calltip._INDENT - sfoo = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')" sbar = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ @@ -139,7 +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), "()\n%s" % ('a'*300)) + 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): + # Test fewer lines than max. + 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 + +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), '()\n' + f.__doc__[:-1]) def test_functions(self): def t1(): 'doc' diff --git a/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst b/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst index e5b72e0e47f330..f6ead891c26cf7 100644 --- a/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst +++ b/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst @@ -1,11 +1,6 @@ Make CallTips selectable -The text display widget in the "CalltipWindow" has been changed from -"tk.Label" to "ScrolledText", and now the text in the "Calltip" window can -be selected with mouse. The display size of the "CalltipWindow" is set to -the smaller value between the size when using the "tk.Label" widget and the -default size of "tk.Text". When the displayed text exceeds the display area -of the "ScrolledText" window, showing the vertical scrollbar; otherwise, -hiding the scrollbar. Since more text can be displayed, "argspec" is no -longer truncated, and the tests related to the max lines or text truncation -have been removed from the unit tests. Contributed by Shixian Li. +Use the "ScrolledText" widget instead of "tk.Label" to display the tooltips, +and now the text in the "Calltip" window can be selected with mouse, or +scrolled by mouse wheel. Docstring is no longer need to truncated, so some +test cases have been changed. Contributed by Shixian Li. From 71e262986fcad3978c8b0e4ed56883b9e8d5b733 Mon Sep 17 00:00:00 2001 From: Shixian Li Date: Sat, 27 Dec 2025 20:41:16 +0800 Subject: [PATCH 5/8] Update Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- .../IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst b/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst index f6ead891c26cf7..c5908606505ae6 100644 --- a/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst +++ b/Misc/NEWS.d/next/IDLE/2025-12-21-07-02-44.gh-issue-94520.lqenne.rst @@ -1,6 +1,3 @@ -Make CallTips selectable - -Use the "ScrolledText" widget instead of "tk.Label" to display the tooltips, -and now the text in the "Calltip" window can be selected with mouse, or -scrolled by mouse wheel. Docstring is no longer need to truncated, so some -test cases have been changed. Contributed by Shixian Li. +"Calltip" windows now support text selection, scrolling and +avoid truncating their content (in particular, docstrings +are shown in full). Patch by Shixian Li. From 3eb20acc79dbd11db6a0294ba6e442780238b465 Mon Sep 17 00:00:00 2001 From: Shixian Li Date: Sat, 27 Dec 2025 20:41:34 +0800 Subject: [PATCH 6/8] Update Lib/idlelib/calltip_w.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/idlelib/calltip_w.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/idlelib/calltip_w.py b/Lib/idlelib/calltip_w.py index 9753c00fbe30ba..0b923438fbe3e2 100644 --- a/Lib/idlelib/calltip_w.py +++ b/Lib/idlelib/calltip_w.py @@ -90,7 +90,7 @@ def showcontents(self): """Create the call-tip widget.""" self.label = Label(self.tipwindow, text=self.text, font=self.anchor_widget['font']) self.label.pack() - label_w, label_h = widget_size(self.label) # get the old version of tooltip window size + label_w, label_h = widget_size(self.label) self.label.forget() self.label = ScrolledText(self.tipwindow, wrap="word", From 77c87cabe88ef551fbda34d9054348336f7e085d Mon Sep 17 00:00:00 2001 From: Shixian Li Date: Sat, 27 Dec 2025 20:41:57 +0800 Subject: [PATCH 7/8] Update Lib/idlelib/calltip.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/idlelib/calltip.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/idlelib/calltip.py b/Lib/idlelib/calltip.py index db49ac0bea82ee..46492fe7133e1a 100644 --- a/Lib/idlelib/calltip.py +++ b/Lib/idlelib/calltip.py @@ -187,8 +187,7 @@ def get_argspec(ob): # Augment lines from docstring, if any, and join to get argspec. doc = inspect.getdoc(ob) if doc: - for line in doc.split('\n'): - lines.append(line.strip()) + lines.extend(map(str.strip, doc.split('\n'))) argspec = '\n'.join(lines) return argspec or _default_callable_argspec From 17599c706c674885597c859936bea1a353465263 Mon Sep 17 00:00:00 2001 From: Shixian Li Date: Sat, 27 Dec 2025 20:58:45 +0800 Subject: [PATCH 8/8] Style: Suggest by picnixz --- Lib/idlelib/calltip_w.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Lib/idlelib/calltip_w.py b/Lib/idlelib/calltip_w.py index 0b923438fbe3e2..83de19486b161b 100644 --- a/Lib/idlelib/calltip_w.py +++ b/Lib/idlelib/calltip_w.py @@ -17,7 +17,7 @@ MARK_RIGHT = "calltipwindowregion_right" -def widget_size(widget): +def _widget_size(widget): widget.update() width = widget.winfo_width() height = widget.winfo_height() @@ -90,22 +90,23 @@ def showcontents(self): """Create the call-tip widget.""" self.label = Label(self.tipwindow, text=self.text, font=self.anchor_widget['font']) self.label.pack() - label_w, label_h = widget_size(self.label) + 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"]) + font=self.anchor_widget['font']) self.label.insert("1.0", self.text) self.label.config(state="disabled") self.label.pack() - max_w, max_h = widget_size(self.label) + new_w, new_h = _widget_size(self.label) if self.label.yview()[1] == 1: # already shown entire text self.label.vbar.forget() - self.tipwindow.geometry("%dx%d" % (min(label_w, max_w), min(label_h, max_h))) + 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."""