Skip to content

Commit e32f2d3

Browse files
committed
Fix macOS tooltip flicker and overlap
1 parent 6d14bab commit e32f2d3

File tree

2 files changed

+116
-16
lines changed

2 files changed

+116
-16
lines changed

ardupilot_methodic_configurator/frontend_tkinter_show.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@
1313
import tkinter as tk
1414
from platform import system as platform_system
1515
from tkinter import messagebox, ttk
16-
from typing import Any, NamedTuple, Optional, cast
16+
from typing import Any, ClassVar, NamedTuple, Optional, cast
1717
from weakref import WeakKeyDictionary
1818

1919
from ardupilot_methodic_configurator import _
2020

2121
# Tooltip positioning constants
2222
TOOLTIP_MAX_OFFSET = 100 # Maximum horizontal offset from widget edge
2323
TOOLTIP_VERTICAL_OFFSET = 10 # Vertical offset when positioning above widget
24+
TOOLTIP_SHOW_DELAY_MS = 250 # Delay before showing to avoid flicker while moving across dense UIs
25+
TOOLTIP_HIDE_DELAY_MS = 75 # Small delay prevents leave/enter jitter from leaving stale tooltips behind
2426

2527

2628
class MonitorBounds(NamedTuple):
@@ -456,6 +458,8 @@ class Tooltip:
456458
Creates a tooltip that appears when the mouse hovers over a widget and disappears when the mouse leaves the widget.
457459
"""
458460

461+
_active_tooltip: ClassVar[Optional["Tooltip"]] = None
462+
459463
def __init__( # pylint: disable=too-many-arguments, too-many-positional-arguments
460464
self,
461465
widget: tk.Widget,
@@ -470,15 +474,17 @@ def __init__( # pylint: disable=too-many-arguments, too-many-positional-argumen
470474
self.position_below: bool = position_below
471475
self.toplevel_class = toplevel_class or tk.Toplevel
472476
self.hide_timer: Optional[str] = None
477+
self.show_timer: Optional[str] = None
473478

474479
# Bind the <Enter> and <Leave> events to show and hide the tooltip
475480
if platform_system() == "Darwin":
476-
# On macOS, only create the tooltip when the mouse enters the widget
481+
# On macOS, defer tooltip creation slightly to avoid flashing while
482+
# moving through dense tables and controls.
477483
if tag_name and isinstance(self.widget, tk.Text):
478-
self.widget.tag_bind(tag_name, "<Enter>", self.create_show, "+")
484+
self.widget.tag_bind(tag_name, "<Enter>", self.schedule_show, "+")
479485
self.widget.tag_bind(tag_name, "<Leave>", self.destroy_hide, "+")
480486
else:
481-
self.widget.bind("<Enter>", self.create_show, "+")
487+
self.widget.bind("<Enter>", self.schedule_show, "+")
482488
self.widget.bind("<Leave>", self.destroy_hide, "+")
483489
else:
484490
if tag_name and isinstance(self.widget, tk.Text):
@@ -499,25 +505,50 @@ def __init__( # pylint: disable=too-many-arguments, too-many-positional-argumen
499505
self.tooltip.bind("<Enter>", self._cancel_hide)
500506
self.tooltip.bind("<Leave>", self.hide)
501507

508+
def _cancel_show(self) -> None:
509+
"""Cancel any pending show timer."""
510+
if self.show_timer:
511+
self.widget.after_cancel(self.show_timer)
512+
self.show_timer = None
513+
502514
def show(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
503515
"""On non-macOS, tooltip already exists, show it on events."""
504516
self._cancel_hide()
517+
self._hide_active_tooltip()
505518
if self.tooltip:
506519
self.position_tooltip()
507520
self.tooltip.deiconify()
521+
Tooltip._active_tooltip = self
508522

509523
def _cancel_hide(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
510524
"""Cancel the hide timer."""
511525
if self.hide_timer:
512526
self.widget.after_cancel(self.hide_timer)
513527
self.hide_timer = None
514528

529+
def _hide_active_tooltip(self) -> None:
530+
"""Hide another active tooltip before showing this one."""
531+
if Tooltip._active_tooltip and Tooltip._active_tooltip is not self:
532+
Tooltip._active_tooltip.force_hide()
533+
534+
def schedule_show(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
535+
"""Delay tooltip creation slightly to avoid flicker during pointer movement."""
536+
self._cancel_hide()
537+
self._cancel_show()
538+
self.show_timer = self.widget.after(TOOLTIP_SHOW_DELAY_MS, self.create_show)
539+
515540
def create_show(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
516541
"""On macOS, only create the tooltip when the mouse enters the widget."""
542+
self._cancel_show()
543+
self._cancel_hide()
544+
self._hide_active_tooltip()
545+
517546
if self.tooltip:
547+
Tooltip._active_tooltip = self
518548
return # Avoid redundant tooltip creation
519549

520550
self.tooltip = cast("tk.Toplevel", self.toplevel_class(self.widget))
551+
self.tooltip.withdraw()
521552

522553
try:
523554
self.tooltip.tk.call(
@@ -537,9 +568,8 @@ def create_show(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002
537568
)
538569
tooltip_label.pack()
539570
self.position_tooltip()
540-
# Bind to tooltip to prevent hiding when mouse is over it
541-
self.tooltip.bind("<Enter>", self._cancel_hide)
542-
self.tooltip.bind("<Leave>", self.destroy_hide)
571+
self.tooltip.deiconify()
572+
Tooltip._active_tooltip = self
543573

544574
def position_tooltip(self) -> None:
545575
"""Position tooltip within monitor bounds, handling widget destruction gracefully."""
@@ -580,20 +610,32 @@ def position_tooltip(self) -> None:
580610
def hide(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
581611
"""Hide the tooltip after a delay on non-macOS."""
582612
self._cancel_hide()
583-
self.hide_timer = self.widget.after(10, self._do_hide)
613+
self.hide_timer = self.widget.after(TOOLTIP_HIDE_DELAY_MS, self._do_hide)
584614

585615
def _do_hide(self) -> None:
586616
"""Actually hide or destroy the tooltip depending on platform."""
587617
if self.tooltip:
588618
self.tooltip.withdraw()
619+
if Tooltip._active_tooltip is self:
620+
Tooltip._active_tooltip = None
589621
self.hide_timer = None
590622

591-
def destroy_hide(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
592-
"""On macOS, fully destroy the tooltip when the mouse leaves the widget."""
623+
def force_hide(self) -> None:
624+
"""Immediately hide or destroy the tooltip, depending on platform."""
625+
self._cancel_show()
593626
self._cancel_hide()
594627
if self.tooltip:
595-
self.tooltip.destroy()
596-
self.tooltip = None
628+
if platform_system() == "Darwin":
629+
self.tooltip.destroy()
630+
self.tooltip = None
631+
else:
632+
self.tooltip.withdraw()
633+
if Tooltip._active_tooltip is self:
634+
Tooltip._active_tooltip = None
635+
636+
def destroy_hide(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
637+
"""On macOS, fully destroy the tooltip when the mouse leaves the widget."""
638+
self.force_hide()
597639

598640

599641
def show_tooltip(widget: tk.Widget, text: str, position_below: bool = True) -> Tooltip:

tests/bdd_frontend_tkinter_show.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
from ardupilot_methodic_configurator.frontend_tkinter_show import (
2626
MonitorBounds,
27+
TOOLTIP_HIDE_DELAY_MS,
28+
TOOLTIP_SHOW_DELAY_MS,
2729
Tooltip,
2830
calculate_tooltip_position,
2931
get_monitor_bounds,
@@ -656,6 +658,13 @@ def test_system_accepts_large_multi_monitor_setups(self, mock_widget: MagicMock)
656658
class TestTooltipFunctionality: # pylint: disable=too-many-public-methods
657659
"""Test the Tooltip class and related functions."""
658660

661+
@pytest.fixture(autouse=True)
662+
def reset_active_tooltip(self) -> None:
663+
"""Ensure tooltip singleton state does not leak across tests."""
664+
Tooltip._active_tooltip = None
665+
yield
666+
Tooltip._active_tooltip = None
667+
659668
@pytest.fixture
660669
def mock_widget(self) -> MagicMock:
661670
"""Create a mock widget for testing."""
@@ -766,7 +775,7 @@ def test_tooltip_initialization_macos(self, mock_widget: MagicMock) -> None:
766775
# Check that tooltip is None initially
767776
assert tooltip.tooltip is None
768777
# Check bindings
769-
mock_widget.bind.assert_any_call("<Enter>", tooltip.create_show, "+")
778+
mock_widget.bind.assert_any_call("<Enter>", tooltip.schedule_show, "+")
770779
mock_widget.bind.assert_any_call("<Leave>", tooltip.destroy_hide, "+")
771780

772781
def test_tooltip_create_show_on_macos(self, mock_widget: MagicMock, mock_toplevel: MagicMock) -> None:
@@ -781,14 +790,18 @@ def test_tooltip_create_show_on_macos(self, mock_widget: MagicMock, mock_topleve
781790
patch("ardupilot_methodic_configurator.frontend_tkinter_show.platform_system", return_value="Darwin"),
782791
patch("tkinter.Toplevel", return_value=mock_toplevel),
783792
patch("tkinter.ttk.Label") as mock_label,
793+
patch(
794+
"ardupilot_methodic_configurator.frontend_tkinter_show.get_monitor_bounds",
795+
return_value=MonitorBounds(0, 0, 1920, 1080),
796+
),
784797
):
785798
tooltip = Tooltip(mock_widget, "Test text")
786799
tooltip.create_show()
787800

788801
assert tooltip.tooltip == mock_toplevel
789802
mock_label.assert_called_once()
790-
mock_toplevel.bind.assert_any_call("<Enter>", tooltip._cancel_hide)
791-
mock_toplevel.bind.assert_any_call("<Leave>", tooltip.destroy_hide)
803+
mock_toplevel.withdraw.assert_called_once()
804+
mock_toplevel.deiconify.assert_called_once()
792805

793806
def test_tooltip_position_tooltip(self, mock_widget, mock_toplevel) -> None:
794807
"""
@@ -853,7 +866,7 @@ def test_tooltip_hide(self, mock_widget, mock_toplevel) -> None:
853866

854867
tooltip.hide()
855868

856-
mock_widget.after.assert_called_once_with(10, tooltip._do_hide)
869+
mock_widget.after.assert_called_once_with(TOOLTIP_HIDE_DELAY_MS, tooltip._do_hide)
857870

858871
def test_tooltip_cancel_hide(self, mock_widget) -> None:
859872
"""
@@ -893,6 +906,47 @@ def test_tooltip_destroy_hide_on_macos(self, mock_widget: MagicMock, mock_toplev
893906
mock_toplevel.destroy.assert_called_once()
894907
assert tooltip.tooltip is None
895908

909+
def test_tooltip_schedule_show_on_macos(self, mock_widget: MagicMock) -> None:
910+
"""
911+
Test tooltip show is scheduled on macOS instead of appearing immediately.
912+
913+
GIVEN: macOS platform
914+
WHEN: Mouse enters widget
915+
THEN: Tooltip creation is delayed slightly
916+
"""
917+
with patch("ardupilot_methodic_configurator.frontend_tkinter_show.platform_system", return_value="Darwin"):
918+
tooltip = Tooltip(mock_widget, "Test text")
919+
920+
tooltip.schedule_show()
921+
922+
mock_widget.after.assert_called_once_with(TOOLTIP_SHOW_DELAY_MS, tooltip.create_show)
923+
924+
def test_tooltip_create_show_hides_previous_active_tooltip(self, mock_widget: MagicMock, mock_toplevel: MagicMock) -> None:
925+
"""
926+
Test macOS only keeps one active tooltip visible at a time.
927+
928+
GIVEN: Another tooltip is already active
929+
WHEN: A new tooltip is shown
930+
THEN: The previous tooltip is hidden first
931+
"""
932+
previous_tooltip = MagicMock()
933+
934+
with (
935+
patch("ardupilot_methodic_configurator.frontend_tkinter_show.platform_system", return_value="Darwin"),
936+
patch("tkinter.Toplevel", return_value=mock_toplevel),
937+
patch("tkinter.ttk.Label"),
938+
patch(
939+
"ardupilot_methodic_configurator.frontend_tkinter_show.get_monitor_bounds",
940+
return_value=MonitorBounds(0, 0, 1920, 1080),
941+
),
942+
):
943+
Tooltip._active_tooltip = previous_tooltip
944+
tooltip = Tooltip(mock_widget, "Test text")
945+
tooltip.create_show()
946+
947+
previous_tooltip.force_hide.assert_called_once()
948+
Tooltip._active_tooltip = None
949+
896950
def test_tooltip_handles_widget_destruction_during_positioning(self, mock_widget, mock_toplevel) -> None:
897951
"""
898952
Test tooltip handles widget destruction during positioning.
@@ -966,6 +1020,10 @@ def test_tooltip_create_show_avoids_redundant_creation(self, mock_widget, mock_t
9661020
patch("ardupilot_methodic_configurator.frontend_tkinter_show.platform_system", return_value="Darwin"),
9671021
patch("tkinter.Toplevel", return_value=mock_toplevel) as mock_toplevel_class,
9681022
patch("tkinter.ttk.Label"),
1023+
patch(
1024+
"ardupilot_methodic_configurator.frontend_tkinter_show.get_monitor_bounds",
1025+
return_value=MonitorBounds(0, 0, 1920, 1080),
1026+
),
9691027
):
9701028
tooltip = Tooltip(mock_widget, "Test text")
9711029
tooltip.create_show()

0 commit comments

Comments
 (0)