From 9e0d8a8dadc9fccdc8da9b709eab898ae559e20e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 24 Nov 2023 14:55:22 +0100 Subject: [PATCH] Don't hide tooltip when pointer is over it. (#13565) * Add failing test for #8638. * Don't hide tooltip when pointer is over tooltip. Fixes #8638 * Close the tooltip when pointer exits. If the pointer has been moved from the control to the tooltip, then out of the tooltip to another control, ensure that the tooltip is closed. * AdornedControl can be a standard CLR property. --- src/Avalonia.Controls/ToolTip.cs | 18 +-- src/Avalonia.Controls/ToolTipService.cs | 33 +++++- .../ToolTipTests.cs | 107 ++++++++++++++++++ 3 files changed, 150 insertions(+), 8 deletions(-) diff --git a/src/Avalonia.Controls/ToolTip.cs b/src/Avalonia.Controls/ToolTip.cs index 5c6e35b567..8e0ca62131 100644 --- a/src/Avalonia.Controls/ToolTip.cs +++ b/src/Avalonia.Controls/ToolTip.cs @@ -78,6 +78,9 @@ namespace Avalonia.Controls PlacementProperty.Changed.Subscribe(RecalculatePositionOnPropertyChanged); } + internal Control? AdornedControl { get; private set; } + internal event EventHandler? Closed; + /// /// Gets the value of the ToolTip.Tip attached property. /// @@ -214,14 +217,13 @@ namespace Avalonia.Controls { var control = (Control)e.Sender; var newValue = (bool)e.NewValue!; - ToolTip? toolTip; if (newValue) { var tip = GetTip(control); if (tip == null) return; - toolTip = control.GetValue(ToolTipProperty); + var toolTip = control.GetValue(ToolTipProperty); if (toolTip == null || (tip != toolTip && tip != toolTip.Content)) { toolTip?.Close(); @@ -231,15 +233,16 @@ namespace Avalonia.Controls toolTip.SetValue(ThemeVariant.RequestedThemeVariantProperty, control.ActualThemeVariant); } + toolTip.AdornedControl = control; toolTip.Open(control); + toolTip?.UpdatePseudoClasses(newValue); } - else + else if (control.GetValue(ToolTipProperty) is { } toolTip) { - toolTip = control.GetValue(ToolTipProperty); - toolTip?.Close(); + toolTip.AdornedControl = null; + toolTip.Close(); + toolTip?.UpdatePseudoClasses(newValue); } - - toolTip?.UpdatePseudoClasses(newValue); } private static void RecalculatePositionOnPropertyChanged(AvaloniaPropertyChangedEventArgs args) @@ -293,6 +296,7 @@ namespace Avalonia.Controls _popupHost.Dispose(); _popupHost = null; _popupHostChangedHandler?.Invoke(null); + Closed?.Invoke(this, EventArgs.Empty); } } diff --git a/src/Avalonia.Controls/ToolTipService.cs b/src/Avalonia.Controls/ToolTipService.cs index d983309a72..d7b0d1202c 100644 --- a/src/Avalonia.Controls/ToolTipService.cs +++ b/src/Avalonia.Controls/ToolTipService.cs @@ -1,7 +1,6 @@ using System; using Avalonia.Input; using Avalonia.Threading; -using Avalonia.VisualTree; namespace Avalonia.Controls { @@ -105,6 +104,11 @@ namespace Avalonia.Controls private void ControlPointerExited(object? sender, PointerEventArgs e) { var control = (Control)sender!; + + // If the control is showing a tooltip and the pointer is over the tooltip, don't close it. + if (control.GetValue(ToolTip.ToolTipProperty) is { } tooltip && tooltip.IsPointerOver) + return; + Close(control); } @@ -115,6 +119,27 @@ namespace Avalonia.Controls toolTip?.RecalculatePosition(control); } + private void ToolTipClosed(object? sender, EventArgs e) + { + if (sender is ToolTip toolTip) + { + toolTip.Closed -= ToolTipClosed; + toolTip.PointerExited -= ToolTipPointerExited; + } + } + + private void ToolTipPointerExited(object? sender, PointerEventArgs e) + { + // The pointer has exited the tooltip. Close the tooltip unless the pointer is over the + // adorned control. + if (sender is ToolTip toolTip && + toolTip.AdornedControl is { } control && + !control.IsPointerOver) + { + Close(control); + } + } + private void StartShowTimer(int showDelay, Control control) { _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(showDelay) }; @@ -129,6 +154,12 @@ namespace Avalonia.Controls if (control.IsAttachedToVisualTree) { ToolTip.SetIsOpen(control, true); + + if (control.GetValue(ToolTip.ToolTipProperty) is { } tooltip) + { + tooltip.Closed += ToolTipClosed; + tooltip.PointerExited += ToolTipPointerExited; + } } } diff --git a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs index 74e506105d..98a131752a 100644 --- a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs @@ -293,6 +293,113 @@ namespace Avalonia.Controls.UnitTests Assert.False(ToolTip.GetIsOpen(target)); } } + + [Fact] + public void Should_Not_Close_When_Pointer_Is_Moved_Over_ToolTip() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = new Window(); + + var target = new Decorator() + { + [ToolTip.TipProperty] = "Tip", + [ToolTip.ShowDelayProperty] = 0 + }; + + window.Content = target; + + window.ApplyStyling(); + window.ApplyTemplate(); + window.Presenter.ApplyTemplate(); + + _mouseHelper.Enter(target); + Assert.True(ToolTip.GetIsOpen(target)); + + var tooltip = Assert.IsType(target.GetValue(ToolTip.ToolTipProperty)); + _mouseHelper.Enter(tooltip); + _mouseHelper.Leave(target); + + Assert.True(ToolTip.GetIsOpen(target)); + } + } + + [Fact] + public void Should_Not_Close_When_Pointer_Is_Moved_From_ToolTip_To_Original_Control() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = new Window(); + + var target = new Decorator() + { + [ToolTip.TipProperty] = "Tip", + [ToolTip.ShowDelayProperty] = 0 + }; + + window.Content = target; + + window.ApplyStyling(); + window.ApplyTemplate(); + window.Presenter.ApplyTemplate(); + + _mouseHelper.Enter(target); + Assert.True(ToolTip.GetIsOpen(target)); + + var tooltip = Assert.IsType(target.GetValue(ToolTip.ToolTipProperty)); + _mouseHelper.Enter(tooltip); + _mouseHelper.Leave(target); + + Assert.True(ToolTip.GetIsOpen(target)); + + _mouseHelper.Enter(target); + _mouseHelper.Leave(tooltip); + + Assert.True(ToolTip.GetIsOpen(target)); + } + } + + [Fact] + public void Should_Close_When_Pointer_Is_Moved_From_ToolTip_To_Another_Control() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = new Window(); + + var target = new Decorator() + { + [ToolTip.TipProperty] = "Tip", + [ToolTip.ShowDelayProperty] = 0 + }; + + var other = new Decorator(); + + var panel = new StackPanel + { + Children = { target, other } + }; + + window.Content = panel; + + window.ApplyStyling(); + window.ApplyTemplate(); + window.Presenter.ApplyTemplate(); + + _mouseHelper.Enter(target); + Assert.True(ToolTip.GetIsOpen(target)); + + var tooltip = Assert.IsType(target.GetValue(ToolTip.ToolTipProperty)); + _mouseHelper.Enter(tooltip); + _mouseHelper.Leave(target); + + Assert.True(ToolTip.GetIsOpen(target)); + + _mouseHelper.Enter(other); + _mouseHelper.Leave(tooltip); + + Assert.False(ToolTip.GetIsOpen(target)); + } + } } internal class ToolTipViewModel