diff --git a/src/Avalonia.Controls/ToolTip.cs b/src/Avalonia.Controls/ToolTip.cs index c4bf961afd..99bebb79ef 100644 --- a/src/Avalonia.Controls/ToolTip.cs +++ b/src/Avalonia.Controls/ToolTip.cs @@ -55,6 +55,12 @@ namespace Avalonia.Controls public static readonly AttachedProperty ShowDelayProperty = AvaloniaProperty.RegisterAttached("ShowDelay", 400); + /// + /// Defines the ToolTip.BetweenShowDelay property. + /// + public static readonly AttachedProperty BetweenShowDelayProperty = + AvaloniaProperty.RegisterAttached("BetweenShowDelay", 100); + /// /// Defines the ToolTip.ShowOnDisabled property. /// @@ -223,6 +229,24 @@ namespace Avalonia.Controls element.SetValue(ShowDelayProperty, value); } + /// + /// Gets the number of milliseconds since the last tooltip closed during which the tooltip of will open immediately, + /// or a negative value indicating that the tooltip will always wait for before opening. + /// + /// The control to get the property from. + public static int GetBetweenShowDelay(Control element) => element.GetValue(BetweenShowDelayProperty); + + /// + /// Sets the number of milliseconds since the last tooltip closed during which the tooltip of will open immediately. + /// + /// + /// Setting a negative value disables the immediate opening behaviour. The tooltip of will then always wait until + /// elapses before showing. + /// + /// The control to get the property from. + /// The number of milliseconds to set, or a negative value to disable the behaviour. + public static void SetBetweenShowDelay(Control element, int value) => element.SetValue(BetweenShowDelayProperty, value); + /// /// Gets whether a control will display a tooltip even if it disabled. /// @@ -299,6 +323,8 @@ namespace Avalonia.Controls IPopupHost? IPopupHostProvider.PopupHost => _popupHost; + internal IPopupHost? PopupHost => _popupHost; + event Action? IPopupHostProvider.PopupHostChanged { add => _popupHostChangedHandler += value; diff --git a/src/Avalonia.Controls/ToolTipService.cs b/src/Avalonia.Controls/ToolTipService.cs index 77e314e52d..c77f08a837 100644 --- a/src/Avalonia.Controls/ToolTipService.cs +++ b/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; diff --git a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs index ddfc2b7b72..8b76c99df3 100644 --- a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs +++ b/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 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>())) .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);