Browse Source

Tooltips now open immediately if another tooltip is open, or closed within ToolTip.BetweenShowDuration (#15259)

Fixed glitches revealed by opening tooltips immediately
Test both popup and overlay tooltips
Tom Edwards 1 year ago
parent
commit
80c4740759

+ 26 - 0
src/Avalonia.Controls/ToolTip.cs

@@ -55,6 +55,12 @@ namespace Avalonia.Controls
         public static readonly AttachedProperty<int> ShowDelayProperty =
             AvaloniaProperty.RegisterAttached<ToolTip, Control, int>("ShowDelay", 400);
 
+        /// <summary>
+        /// Defines the ToolTip.BetweenShowDelay property.
+        /// </summary>
+        public static readonly AttachedProperty<int> BetweenShowDelayProperty =
+            AvaloniaProperty.RegisterAttached<ToolTip, Control, int>("BetweenShowDelay", 100);
+
         /// <summary>
         /// Defines the ToolTip.ShowOnDisabled property.
         /// </summary>
@@ -223,6 +229,24 @@ namespace Avalonia.Controls
             element.SetValue(ShowDelayProperty, value);
         }
 
+        /// <summary>
+        /// Gets the number of milliseconds since the last tooltip closed during which the tooltip of <paramref name="element"/> will open immediately,
+        /// or a negative value indicating that the tooltip will always wait for <see cref="ShowDelayProperty"/> before opening.
+        /// </summary>
+        /// <param name="element">The control to get the property from.</param>
+        public static int GetBetweenShowDelay(Control element) => element.GetValue(BetweenShowDelayProperty);
+
+        /// <summary>
+        /// Sets the number of milliseconds since the last tooltip closed during which the tooltip of <paramref name="element"/> will open immediately.
+        /// </summary>
+        /// <remarks>
+        /// Setting a negative value disables the immediate opening behaviour. The tooltip of <paramref name="element"/> will then always wait until 
+        /// <see cref="ShowDelayProperty"/> elapses before showing.
+        /// </remarks>
+        /// <param name="element">The control to get the property from.</param>
+        /// <param name="value">The number of milliseconds to set, or a negative value to disable the behaviour.</param>
+        public static void SetBetweenShowDelay(Control element, int value) => element.SetValue(BetweenShowDelayProperty, value);
+
         /// <summary>
         /// Gets whether a control will display a tooltip even if it disabled.
         /// </summary>
@@ -299,6 +323,8 @@ namespace Avalonia.Controls
         
         IPopupHost? IPopupHostProvider.PopupHost => _popupHost;
 
+        internal IPopupHost? PopupHost => _popupHost;
+
         event Action<IPopupHost?>? IPopupHostProvider.PopupHostChanged 
         { 
             add => _popupHostChangedHandler += value; 

+ 36 - 7
src/Avalonia.Controls/ToolTipService.cs

@@ -14,6 +14,7 @@ namespace Avalonia.Controls
         private readonly IDisposable _subscriptions;
 
         private Control? _tipControl;
+        private long _lastTipCloseTime;
         private DispatcherTimer? _timer;
 
         public ToolTipService(IInputManager inputManager)
@@ -25,12 +26,21 @@ namespace Avalonia.Controls
                 ToolTip.IsOpenProperty.Changed.Subscribe(TipOpenChanged));
         }
 
-        public void Dispose() => _subscriptions.Dispose();
+        public void Dispose()
+        {
+            StopTimer();
+            _subscriptions.Dispose();
+        }
 
         private void InputManager_OnProcess(RawInputEventArgs e)
         {
             if (e is RawPointerEventArgs pointerEvent)
             {
+                if (e.Root == _tipControl?.GetValue(ToolTip.ToolTipProperty)?.PopupHost)
+                {
+                    return; // pointer is over the current tooltip
+                }
+
                 switch (pointerEvent.Type)
                 {
                     case RawPointerEventType.Move:
@@ -50,8 +60,13 @@ namespace Avalonia.Controls
 
         public void Update(Visual? candidateToolTipHost)
         {
+            var currentToolTip = _tipControl?.GetValue(ToolTip.ToolTipProperty);
+
             while (candidateToolTipHost != null)
             {
+                if (candidateToolTipHost == currentToolTip) // when OverlayPopupHost is in use, the tooltip is in the same window as the host control
+                    return;
+
                 if (candidateToolTipHost is Control control)
                 {
                     if (!ToolTip.GetServiceEnabled(control))
@@ -135,16 +150,29 @@ namespace Avalonia.Controls
         {
             StopTimer();
 
-            if (oldValue != null)
+            var closedPreviousTip = false; // avoid race conditions by remembering whether we closed a tooltip in the current call.
+
+            if (oldValue != null && ToolTip.GetIsOpen(oldValue))
             {
-                // If the control is showing a tooltip and the pointer is over the tooltip, don't close it.
-                if (oldValue.GetValue(ToolTip.ToolTipProperty) is not { IsPointerOver: true })
-                    Close(oldValue);
+                Close(oldValue);
+                closedPreviousTip = true;
             }
 
-            if (newValue != null)
+            if (newValue != null && !ToolTip.GetIsOpen(newValue))
             {
-                var showDelay = ToolTip.GetShowDelay(newValue);
+                var betweenShowDelay = ToolTip.GetBetweenShowDelay(newValue);
+
+                int showDelay;
+
+                if (betweenShowDelay >= 0 && (closedPreviousTip || (DateTime.UtcNow.Ticks - _lastTipCloseTime) <= betweenShowDelay * TimeSpan.TicksPerMillisecond))
+                {
+                    showDelay = 0;
+                }
+                else
+                {
+                    showDelay = ToolTip.GetShowDelay(newValue);
+                }
+
                 if (showDelay == 0)
                 {
                     Open(newValue);
@@ -165,6 +193,7 @@ namespace Avalonia.Controls
 
         private void ToolTipClosed(object? sender, EventArgs e)
         {
+            _lastTipCloseTime = DateTime.UtcNow.Ticks;
             if (sender is ToolTip toolTip)
             {
                 toolTip.Closed -= ToolTipClosed;

+ 79 - 22
tests/Avalonia.Controls.UnitTests/ToolTipTests.cs

@@ -12,39 +12,52 @@ using Xunit;
 
 namespace Avalonia.Controls.UnitTests
 {
-    public class ToolTipTests
+    public class ToolTipTests_Popup : ToolTipTests
     {
+        protected override TestServices ConfigureServices(TestServices baseServices) => baseServices;
+    }
+
+    public class ToolTipTests_Overlay : ToolTipTests
+    {
+        protected override TestServices ConfigureServices(TestServices baseServices) =>
+            baseServices.With(windowingPlatform: new MockWindowingPlatform(popupImpl: window => null));
+    }
+
+    public abstract class ToolTipTests
+    {
+        protected abstract TestServices ConfigureServices(TestServices baseServices);
+
         private static readonly MouseDevice s_mouseDevice = new(new Pointer(0, PointerType.Mouse, true));
 
         [Fact]
         public void Should_Close_When_Control_Detaches()
         {
-            using (UnitTestApplication.Start(TestServices.FocusableWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)))
             {
                 var panel = new Panel();
-                
+
                 var target = new Decorator()
                 {
                     [ToolTip.TipProperty] = "Tip",
                     [ToolTip.ShowDelayProperty] = 0
                 };
-                
+
                 panel.Children.Add(target);
 
                 SetupWindowAndActivateToolTip(panel, target);
 
                 Assert.True(ToolTip.GetIsOpen(target));
-                
+
                 panel.Children.Remove(target);
-                
+
                 Assert.False(ToolTip.GetIsOpen(target));
             }
         }
-        
+
         [Fact]
         public void Should_Close_When_Tip_Is_Opened_And_Detached_From_Visual_Tree()
         {
-            using (UnitTestApplication.Start(TestServices.FocusableWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)))
             {
                 var target = new Decorator
                 {
@@ -64,7 +77,7 @@ namespace Avalonia.Controls.UnitTests
                 Assert.True(ToolTip.GetIsOpen(target));
 
                 panel.Children.Remove(target);
-                
+
                 Assert.False(ToolTip.GetIsOpen(target));
             }
         }
@@ -72,7 +85,7 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Should_Open_On_Pointer_Enter()
         {
-            using (UnitTestApplication.Start(TestServices.FocusableWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)))
             {
                 var target = new Decorator()
                 {
@@ -85,11 +98,11 @@ namespace Avalonia.Controls.UnitTests
                 Assert.True(ToolTip.GetIsOpen(target));
             }
         }
-        
+
         [Fact]
         public void Content_Should_Update_When_Tip_Property_Changes_And_Already_Open()
         {
-            using (UnitTestApplication.Start(TestServices.FocusableWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)))
             {
                 var target = new Decorator()
                 {
@@ -101,7 +114,7 @@ namespace Avalonia.Controls.UnitTests
 
                 Assert.True(ToolTip.GetIsOpen(target));
                 Assert.Equal("Tip", target.GetValue(ToolTip.ToolTipProperty).Content);
-                
+
                 ToolTip.SetTip(target, "Tip1");
                 Assert.Equal("Tip1", target.GetValue(ToolTip.ToolTipProperty).Content);
             }
@@ -110,7 +123,7 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Should_Open_On_Pointer_Enter_With_Delay()
         {
-            using (UnitTestApplication.Start(TestServices.FocusableWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)))
             {
                 var target = new Decorator()
                 {
@@ -133,7 +146,7 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Open_Class_Should_Not_Initially_Be_Added()
         {
-            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.StyledWindow)))
             {
                 var toolTip = new ToolTip();
                 var window = new Window();
@@ -156,7 +169,7 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Setting_IsOpen_Should_Add_Open_Class()
         {
-            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.StyledWindow)))
             {
                 var toolTip = new ToolTip();
                 var window = new Window();
@@ -181,7 +194,7 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Clearing_IsOpen_Should_Remove_Open_Class()
         {
-            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.StyledWindow)))
             {
                 var toolTip = new ToolTip();
                 var window = new Window();
@@ -207,7 +220,7 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Should_Close_On_Null_Tip()
         {
-            using (UnitTestApplication.Start(TestServices.FocusableWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)))
             {
                 var target = new Decorator()
                 {
@@ -228,7 +241,7 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Should_Not_Close_When_Pointer_Is_Moved_Over_ToolTip()
         {
-            using (UnitTestApplication.Start(TestServices.FocusableWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)))
             {
                 var target = new Decorator()
                 {
@@ -253,7 +266,7 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Should_Not_Close_When_Pointer_Is_Moved_From_ToolTip_To_Original_Control()
         {
-            using (UnitTestApplication.Start(TestServices.FocusableWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)))
             {
                 var target = new Decorator()
                 {
@@ -280,7 +293,7 @@ namespace Avalonia.Controls.UnitTests
         [Fact]
         public void Should_Close_When_Pointer_Is_Moved_From_ToolTip_To_Another_Control()
         {
-            using (UnitTestApplication.Start(TestServices.FocusableWindow))
+            using (UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)))
             {
                 var target = new Decorator()
                 {
@@ -311,6 +324,50 @@ namespace Avalonia.Controls.UnitTests
             }
         }
 
+        [Fact]
+        public void New_ToolTip_Replaces_Other_ToolTip_Immediately()
+        {
+            using var app = UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow));
+            
+            var target = new Decorator()
+            {
+                [ToolTip.TipProperty] = "Tip",
+                [ToolTip.ShowDelayProperty] = 0
+            };
+
+            var other = new Decorator()
+            {
+                [ToolTip.TipProperty] = "Tip",
+                [ToolTip.ShowDelayProperty] = (int) TimeSpan.FromHours(1).TotalMilliseconds,
+            };
+
+            var panel = new StackPanel
+            {
+                Children = { target, other }
+            };
+
+            var mouseEnter = SetupWindowAndGetMouseEnterAction(panel);
+            
+            mouseEnter(other);
+            Assert.False(ToolTip.GetIsOpen(other)); // long delay
+
+            mouseEnter(target);
+            Assert.True(ToolTip.GetIsOpen(target)); // no delay
+
+            mouseEnter(other);
+            Assert.True(ToolTip.GetIsOpen(other)); // delay skipped, a tooltip was already open
+
+            // Now disable the between-show system
+
+            mouseEnter(target);
+            Assert.True(ToolTip.GetIsOpen(target));
+
+            ToolTip.SetBetweenShowDelay(other, -1);
+
+            mouseEnter(other);
+            Assert.False(ToolTip.GetIsOpen(other));
+        }
+
         private Action<Control> SetupWindowAndGetMouseEnterAction(Control windowContent, [CallerMemberName] string testName = null)
         {
             var windowImpl = MockWindowingPlatform.CreateWindowMock();
@@ -354,7 +411,7 @@ namespace Avalonia.Controls.UnitTests
                 hitTesterMock.Setup(m => m.HitTestFirst(point, window, It.IsAny<Func<Visual, bool>>()))
                     .Returns(control);
 
-                windowImpl.Object.Input(new RawPointerEventArgs(s_mouseDevice, (ulong)DateTime.Now.Ticks, window,
+                windowImpl.Object.Input(new RawPointerEventArgs(s_mouseDevice, (ulong)DateTime.Now.Ticks, (IInputRoot)control?.VisualRoot ?? window,
                         RawPointerEventType.Move, point, RawInputModifiers.None));
 
                 Assert.True(control == null || control.IsPointerOver);