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
release/11.1.0-beta2
Tom Edwards 2 years ago
committed by Max Katz
parent
commit
e30d6afd0b
  1. 26
      src/Avalonia.Controls/ToolTip.cs
  2. 43
      src/Avalonia.Controls/ToolTipService.cs
  3. 101
      tests/Avalonia.Controls.UnitTests/ToolTipTests.cs

26
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;

43
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;

101
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);

Loading…
Cancel
Save