From 87e59ac11ee074b0dc1bc864c74fac0660b27f6e Mon Sep 17 00:00:00 2001 From: Tal Einat Date: Sun, 5 Aug 2018 09:21:08 +0300 Subject: 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 --- Lib/idlelib/calltip.py | 2 +- Lib/idlelib/calltip_w.py | 202 +++++++++++--------- Lib/idlelib/idle_test/htest.py | 3 + Lib/idlelib/idle_test/test_calltip_w.py | 2 +- Lib/idlelib/idle_test/test_tooltip.py | 146 +++++++++++++++ Lib/idlelib/tooltip.py | 204 +++++++++++++++------ .../IDLE/2018-06-14-13-23-55.bpo-33839.ZlJzHa.rst | 1 + 7 files changed, 416 insertions(+), 144 deletions(-) create mode 100644 Lib/idlelib/idle_test/test_tooltip.py create mode 100644 Misc/NEWS.d/next/IDLE/2018-06-14-13-23-55.bpo-33839.ZlJzHa.rst diff --git a/Lib/idlelib/calltip.py b/Lib/idlelib/calltip.py index 596d2bc..758569a 100644 --- a/Lib/idlelib/calltip.py +++ b/Lib/idlelib/calltip.py @@ -51,7 +51,7 @@ class Calltip: self.open_calltip(False) def refresh_calltip_event(self, event): - if self.active_calltip and self.active_calltip.is_active(): + if self.active_calltip and self.active_calltip.tipwindow: self.open_calltip(False) def open_calltip(self, evalfuncs): diff --git a/Lib/idlelib/calltip_w.py b/Lib/idlelib/calltip_w.py index 1b1ffc5..7553dfe 100644 --- a/Lib/idlelib/calltip_w.py +++ b/Lib/idlelib/calltip_w.py @@ -1,111 +1,118 @@ -"""A calltip window class for Tkinter/IDLE. +"""A call-tip window class for Tkinter/IDLE. -After tooltip.py, which uses ideas gleaned from PySol -Used by calltip. +After tooltip.py, which uses ideas gleaned from PySol. +Used by calltip.py. """ -from tkinter import Toplevel, Label, LEFT, SOLID, TclError +from tkinter import Label, LEFT, SOLID, TclError -HIDE_VIRTUAL_EVENT_NAME = "<>" +from idlelib.tooltip import TooltipBase + +HIDE_EVENT = "<>" HIDE_SEQUENCES = ("", "") -CHECKHIDE_VIRTUAL_EVENT_NAME = "<>" +CHECKHIDE_EVENT = "<>" CHECKHIDE_SEQUENCES = ("", "") -CHECKHIDE_TIME = 100 # milliseconds +CHECKHIDE_TIME = 100 # milliseconds MARK_RIGHT = "calltipwindowregion_right" -class CalltipWindow: - def __init__(self, widget): - self.widget = widget - self.tipwindow = self.label = None - self.parenline = self.parencol = None - self.lastline = None +class CalltipWindow(TooltipBase): + """A call-tip widget for tkinter text widgets.""" + + def __init__(self, text_widget): + """Create a call-tip; shown by showtip(). + + text_widget: a Text widget with code for which call-tips are desired + """ + # Note: The Text widget will be accessible as self.anchor_widget + super(CalltipWindow, self).__init__(text_widget) + + self.label = self.text = None + self.parenline = self.parencol = self.lastline = None self.hideid = self.checkhideid = None self.checkhide_after_id = None - def position_window(self): - """Check if needs to reposition the window, and if so - do it.""" - curline = int(self.widget.index("insert").split('.')[0]) - if curline == self.lastline: - return - self.lastline = curline - self.widget.see("insert") + def get_position(self): + """Choose the position of the call-tip.""" + curline = int(self.anchor_widget.index("insert").split('.')[0]) if curline == self.parenline: - box = self.widget.bbox("%d.%d" % (self.parenline, - self.parencol)) + anchor_index = (self.parenline, self.parencol) else: - box = self.widget.bbox("%d.0" % curline) + anchor_index = (curline, 0) + box = self.anchor_widget.bbox("%d.%d" % anchor_index) if not box: - box = list(self.widget.bbox("insert")) + box = list(self.anchor_widget.bbox("insert")) # align to left of window box[0] = 0 box[2] = 0 - x = box[0] + self.widget.winfo_rootx() + 2 - y = box[1] + box[3] + self.widget.winfo_rooty() - self.tipwindow.wm_geometry("+%d+%d" % (x, y)) + return box[0] + 2, box[1] + box[3] + + def position_window(self): + "Reposition the window if needed." + curline = int(self.anchor_widget.index("insert").split('.')[0]) + if curline == self.lastline: + return + self.lastline = curline + self.anchor_widget.see("insert") + super(CalltipWindow, self).position_window() def showtip(self, text, parenleft, parenright): - """Show the calltip, bind events which will close it and reposition it. + """Show the call-tip, bind events which will close it and reposition it. + + text: the text to display in the call-tip + parenleft: index of the opening parenthesis in the text widget + parenright: index of the closing parenthesis in the text widget, + or the end of the line if there is no closing parenthesis """ # Only called in calltip.Calltip, where lines are truncated self.text = text if self.tipwindow or not self.text: return - self.widget.mark_set(MARK_RIGHT, parenright) + self.anchor_widget.mark_set(MARK_RIGHT, parenright) self.parenline, self.parencol = map( - int, self.widget.index(parenleft).split(".")) + int, self.anchor_widget.index(parenleft).split(".")) - self.tipwindow = tw = Toplevel(self.widget) - self.position_window() - # remove border on calltip window - tw.wm_overrideredirect(1) - try: - # This command is only needed and available on Tk >= 8.4.0 for OSX - # Without it, call tips intrude on the typing process by grabbing - # the focus. - tw.tk.call("::tk::unsupported::MacWindowStyle", "style", tw._w, - "help", "noActivates") - except TclError: - pass - self.label = Label(tw, text=self.text, justify=LEFT, + super(CalltipWindow, self).showtip() + + self._bind_events() + + def showcontents(self): + """Create the call-tip widget.""" + self.label = Label(self.tipwindow, text=self.text, justify=LEFT, background="#ffffe0", relief=SOLID, borderwidth=1, - font = self.widget['font']) + font=self.anchor_widget['font']) self.label.pack() - tw.update_idletasks() - tw.lift() # work around bug in Tk 8.5.18+ (issue #24570) - - self.checkhideid = self.widget.bind(CHECKHIDE_VIRTUAL_EVENT_NAME, - self.checkhide_event) - for seq in CHECKHIDE_SEQUENCES: - self.widget.event_add(CHECKHIDE_VIRTUAL_EVENT_NAME, seq) - self.widget.after(CHECKHIDE_TIME, self.checkhide_event) - self.hideid = self.widget.bind(HIDE_VIRTUAL_EVENT_NAME, - self.hide_event) - for seq in HIDE_SEQUENCES: - self.widget.event_add(HIDE_VIRTUAL_EVENT_NAME, seq) def checkhide_event(self, event=None): + """Handle CHECK_HIDE_EVENT: call hidetip or reschedule.""" if not self.tipwindow: - # If the event was triggered by the same event that unbinded + # If the event was triggered by the same event that unbound # this function, the function will be called nevertheless, # so do nothing in this case. return None - curline, curcol = map(int, self.widget.index("insert").split('.')) + + # Hide the call-tip if the insertion cursor moves outside of the + # parenthesis. + curline, curcol = map(int, self.anchor_widget.index("insert").split('.')) if curline < self.parenline or \ (curline == self.parenline and curcol <= self.parencol) or \ - self.widget.compare("insert", ">", MARK_RIGHT): + self.anchor_widget.compare("insert", ">", MARK_RIGHT): self.hidetip() return "break" - else: - self.position_window() - if self.checkhide_after_id is not None: - self.widget.after_cancel(self.checkhide_after_id) - self.checkhide_after_id = \ - self.widget.after(CHECKHIDE_TIME, self.checkhide_event) - return None + + # Not hiding the call-tip. + + self.position_window() + # Re-schedule this function to be called again in a short while. + if self.checkhide_after_id is not None: + self.anchor_widget.after_cancel(self.checkhide_after_id) + self.checkhide_after_id = \ + self.anchor_widget.after(CHECKHIDE_TIME, self.checkhide_event) + return None def hide_event(self, event): + """Handle HIDE_EVENT by calling hidetip.""" if not self.tipwindow: # See the explanation in checkhide_event. return None @@ -113,51 +120,76 @@ class CalltipWindow: return "break" def hidetip(self): + """Hide the call-tip.""" if not self.tipwindow: return - for seq in CHECKHIDE_SEQUENCES: - self.widget.event_delete(CHECKHIDE_VIRTUAL_EVENT_NAME, seq) - self.widget.unbind(CHECKHIDE_VIRTUAL_EVENT_NAME, self.checkhideid) - self.checkhideid = None - for seq in HIDE_SEQUENCES: - self.widget.event_delete(HIDE_VIRTUAL_EVENT_NAME, seq) - self.widget.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hideid) - self.hideid = None - - self.label.destroy() + try: + self.label.destroy() + except TclError: + pass self.label = None - self.tipwindow.destroy() - self.tipwindow = None - self.widget.mark_unset(MARK_RIGHT) self.parenline = self.parencol = self.lastline = None + try: + self.anchor_widget.mark_unset(MARK_RIGHT) + except TclError: + pass - def is_active(self): - return bool(self.tipwindow) + try: + self._unbind_events() + except (TclError, ValueError): + # ValueError may be raised by MultiCall + pass + + super(CalltipWindow, self).hidetip() + + def _bind_events(self): + """Bind event handlers.""" + self.checkhideid = self.anchor_widget.bind(CHECKHIDE_EVENT, + self.checkhide_event) + for seq in CHECKHIDE_SEQUENCES: + self.anchor_widget.event_add(CHECKHIDE_EVENT, seq) + self.anchor_widget.after(CHECKHIDE_TIME, self.checkhide_event) + self.hideid = self.anchor_widget.bind(HIDE_EVENT, + self.hide_event) + for seq in HIDE_SEQUENCES: + self.anchor_widget.event_add(HIDE_EVENT, seq) + + def _unbind_events(self): + """Unbind event handlers.""" + for seq in CHECKHIDE_SEQUENCES: + self.anchor_widget.event_delete(CHECKHIDE_EVENT, seq) + self.anchor_widget.unbind(CHECKHIDE_EVENT, self.checkhideid) + self.checkhideid = None + for seq in HIDE_SEQUENCES: + self.anchor_widget.event_delete(HIDE_EVENT, seq) + self.anchor_widget.unbind(HIDE_EVENT, self.hideid) + self.hideid = None def _calltip_window(parent): # htest # from tkinter import Toplevel, Text, LEFT, BOTH top = Toplevel(parent) - top.title("Test calltips") + top.title("Test call-tips") x, y = map(int, parent.geometry().split('+')[1:]) - top.geometry("200x100+%d+%d" % (x + 250, y + 175)) + top.geometry("250x100+%d+%d" % (x + 175, y + 150)) text = Text(top) text.pack(side=LEFT, fill=BOTH, expand=1) text.insert("insert", "string.split") top.update() - calltip = CalltipWindow(text) + calltip = CalltipWindow(text) def calltip_show(event): - calltip.showtip("(s=Hello world)", "insert", "end") + calltip.showtip("(s='Hello world')", "insert", "end") def calltip_hide(event): calltip.hidetip() text.event_add("<>", "(") text.event_add("<>", ")") text.bind("<>", calltip_show) text.bind("<>", calltip_hide) + text.focus_set() if __name__ == '__main__': diff --git a/Lib/idlelib/idle_test/htest.py b/Lib/idlelib/idle_test/htest.py index 95f6274..03bee51 100644 --- a/Lib/idlelib/idle_test/htest.py +++ b/Lib/idlelib/idle_test/htest.py @@ -80,11 +80,14 @@ AboutDialog_spec = { "are correctly displayed.\n [Close] to exit.", } +# TODO implement ^\; adding '' to function does not work. _calltip_window_spec = { 'file': 'calltip_w', 'kwds': {}, 'msg': "Typing '(' should display a calltip.\n" "Typing ') should hide the calltip.\n" + "So should moving cursor out of argument area.\n" + "Force-open-calltip does not work here.\n" } _module_browser_spec = { diff --git a/Lib/idlelib/idle_test/test_calltip_w.py b/Lib/idlelib/idle_test/test_calltip_w.py index 59e6967..a5ec76e 100644 --- a/Lib/idlelib/idle_test/test_calltip_w.py +++ b/Lib/idlelib/idle_test/test_calltip_w.py @@ -23,7 +23,7 @@ class CallTipWindowTest(unittest.TestCase): del cls.text, cls.root def test_init(self): - self.assertEqual(self.calltip.widget, self.text) + self.assertEqual(self.calltip.anchor_widget, self.text) if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_tooltip.py b/Lib/idlelib/idle_test/test_tooltip.py new file mode 100644 index 0000000..44ea111 --- /dev/null +++ b/Lib/idlelib/idle_test/test_tooltip.py @@ -0,0 +1,146 @@ +from idlelib.tooltip import TooltipBase, Hovertip +from test.support import requires +requires('gui') + +from functools import wraps +import time +from tkinter import Button, Tk, Toplevel +import unittest + + +def setUpModule(): + global root + root = Tk() + +def root_update(): + global root + root.update() + +def tearDownModule(): + global root + root.update_idletasks() + root.destroy() + del root + +def add_call_counting(func): + @wraps(func) + def wrapped_func(*args, **kwargs): + wrapped_func.call_args_list.append((args, kwargs)) + return func(*args, **kwargs) + wrapped_func.call_args_list = [] + return wrapped_func + + +def _make_top_and_button(testobj): + global root + top = Toplevel(root) + testobj.addCleanup(top.destroy) + top.title("Test tooltip") + button = Button(top, text='ToolTip test button') + button.pack() + testobj.addCleanup(button.destroy) + top.lift() + return top, button + + +class ToolTipBaseTest(unittest.TestCase): + def setUp(self): + self.top, self.button = _make_top_and_button(self) + + def test_base_class_is_unusable(self): + global root + top = Toplevel(root) + self.addCleanup(top.destroy) + + button = Button(top, text='ToolTip test button') + button.pack() + self.addCleanup(button.destroy) + + with self.assertRaises(NotImplementedError): + tooltip = TooltipBase(button) + tooltip.showtip() + + +class HovertipTest(unittest.TestCase): + def setUp(self): + self.top, self.button = _make_top_and_button(self) + + def test_showtip(self): + tooltip = Hovertip(self.button, 'ToolTip text') + self.addCleanup(tooltip.hidetip) + self.assertFalse(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + tooltip.showtip() + self.assertTrue(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + + def test_showtip_twice(self): + tooltip = Hovertip(self.button, 'ToolTip text') + self.addCleanup(tooltip.hidetip) + self.assertFalse(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + tooltip.showtip() + self.assertTrue(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + orig_tipwindow = tooltip.tipwindow + tooltip.showtip() + self.assertTrue(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + self.assertIs(tooltip.tipwindow, orig_tipwindow) + + def test_hidetip(self): + tooltip = Hovertip(self.button, 'ToolTip text') + self.addCleanup(tooltip.hidetip) + tooltip.showtip() + tooltip.hidetip() + self.assertFalse(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + + def test_showtip_on_mouse_enter_no_delay(self): + tooltip = Hovertip(self.button, 'ToolTip text', hover_delay=None) + self.addCleanup(tooltip.hidetip) + tooltip.showtip = add_call_counting(tooltip.showtip) + root_update() + self.assertFalse(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + self.button.event_generate('', x=0, y=0) + root_update() + self.assertTrue(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + self.assertGreater(len(tooltip.showtip.call_args_list), 0) + + def test_showtip_on_mouse_enter_hover_delay(self): + tooltip = Hovertip(self.button, 'ToolTip text', hover_delay=50) + self.addCleanup(tooltip.hidetip) + tooltip.showtip = add_call_counting(tooltip.showtip) + root_update() + self.assertFalse(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + self.button.event_generate('', x=0, y=0) + root_update() + self.assertFalse(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + time.sleep(0.1) + root_update() + self.assertTrue(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + self.assertGreater(len(tooltip.showtip.call_args_list), 0) + + def test_hidetip_on_mouse_leave(self): + tooltip = Hovertip(self.button, 'ToolTip text', hover_delay=None) + self.addCleanup(tooltip.hidetip) + tooltip.showtip = add_call_counting(tooltip.showtip) + root_update() + self.button.event_generate('', x=0, y=0) + root_update() + self.button.event_generate('', x=0, y=0) + root_update() + self.assertFalse(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + self.assertGreater(len(tooltip.showtip.call_args_list), 0) + + def test_dont_show_on_mouse_leave_before_delay(self): + tooltip = Hovertip(self.button, 'ToolTip text', hover_delay=50) + self.addCleanup(tooltip.hidetip) + tooltip.showtip = add_call_counting(tooltip.showtip) + root_update() + self.button.event_generate('', x=0, y=0) + root_update() + self.button.event_generate('', x=0, y=0) + root_update() + time.sleep(0.1) + root_update() + self.assertFalse(tooltip.tipwindow and tooltip.tipwindow.winfo_viewable()) + self.assertEqual(tooltip.showtip.call_args_list, []) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/tooltip.py b/Lib/idlelib/tooltip.py index 843fb4a..f54ea36 100644 --- a/Lib/idlelib/tooltip.py +++ b/Lib/idlelib/tooltip.py @@ -1,80 +1,167 @@ -# general purpose 'tooltip' routines - currently unused in idlelib -# (although the 'calltips' extension is partly based on this code) -# may be useful for some purposes in (or almost in ;) the current project scope -# Ideas gleaned from PySol +"""Tools for displaying tool-tips. +This includes: + * an abstract base-class for different kinds of tooltips + * a simple text-only Tooltip class +""" from tkinter import * -class ToolTipBase: - def __init__(self, button): - self.button = button - self.tipwindow = None - self.id = None - self.x = self.y = 0 - self._id1 = self.button.bind("", self.enter) - self._id2 = self.button.bind("", self.leave) - self._id3 = self.button.bind("", self.leave) +class TooltipBase(object): + """abstract base class for tooltips""" - def enter(self, event=None): - self.schedule() + def __init__(self, anchor_widget): + """Create a tooltip. - def leave(self, event=None): - self.unschedule() - self.hidetip() + anchor_widget: the widget next to which the tooltip will be shown - def schedule(self): - self.unschedule() - self.id = self.button.after(1500, self.showtip) + Note that a widget will only be shown when showtip() is called. + """ + self.anchor_widget = anchor_widget + self.tipwindow = None - def unschedule(self): - id = self.id - self.id = None - if id: - self.button.after_cancel(id) + def __del__(self): + self.hidetip() def showtip(self): + """display the tooltip""" if self.tipwindow: return - # The tip window must be completely outside the button; + self.tipwindow = tw = Toplevel(self.anchor_widget) + # show no border on the top level window + tw.wm_overrideredirect(1) + try: + # This command is only needed and available on Tk >= 8.4.0 for OSX. + # Without it, call tips intrude on the typing process by grabbing + # the focus. + tw.tk.call("::tk::unsupported::MacWindowStyle", "style", tw._w, + "help", "noActivates") + except TclError: + pass + + self.position_window() + self.showcontents() + self.tipwindow.update_idletasks() # Needed on MacOS -- see #34275. + self.tipwindow.lift() # work around bug in Tk 8.5.18+ (issue #24570) + + def position_window(self): + """(re)-set the tooltip's screen position""" + x, y = self.get_position() + root_x = self.anchor_widget.winfo_rootx() + x + root_y = self.anchor_widget.winfo_rooty() + y + self.tipwindow.wm_geometry("+%d+%d" % (root_x, root_y)) + + def get_position(self): + """choose a screen position for the tooltip""" + # The tip window must be completely outside the anchor widget; # otherwise when the mouse enters the tip window we get # a leave event and it disappears, and then we get an enter # event and it reappears, and so on forever :-( - x = self.button.winfo_rootx() + 20 - y = self.button.winfo_rooty() + self.button.winfo_height() + 1 - self.tipwindow = tw = Toplevel(self.button) - tw.wm_overrideredirect(1) - tw.wm_geometry("+%d+%d" % (x, y)) - self.showcontents() + # + # Note: This is a simplistic implementation; sub-classes will likely + # want to override this. + return 20, self.anchor_widget.winfo_height() + 1 - def showcontents(self, text="Your text here"): - # Override this in derived class - label = Label(self.tipwindow, text=text, justify=LEFT, - background="#ffffe0", relief=SOLID, borderwidth=1) - label.pack() + def showcontents(self): + """content display hook for sub-classes""" + # See ToolTip for an example + raise NotImplementedError def hidetip(self): + """hide the tooltip""" + # Note: This is called by __del__, so careful when overriding/extending tw = self.tipwindow self.tipwindow = None if tw: - tw.destroy() + try: + tw.destroy() + except TclError: + pass + + +class OnHoverTooltipBase(TooltipBase): + """abstract base class for tooltips, with delayed on-hover display""" + + def __init__(self, anchor_widget, hover_delay=1000): + """Create a tooltip with a mouse hover delay. + + anchor_widget: the widget next to which the tooltip will be shown + hover_delay: time to delay before showing the tooltip, in milliseconds -class ToolTip(ToolTipBase): - def __init__(self, button, text): - ToolTipBase.__init__(self, button) + Note that a widget will only be shown when showtip() is called, + e.g. after hovering over the anchor widget with the mouse for enough + time. + """ + super(OnHoverTooltipBase, self).__init__(anchor_widget) + self.hover_delay = hover_delay + + self._after_id = None + self._id1 = self.anchor_widget.bind("", self._show_event) + self._id2 = self.anchor_widget.bind("", self._hide_event) + self._id3 = self.anchor_widget.bind("