Browse Source

Added ToolTip.ShowOnDisabled and ToolTip.ServiceEnabled (#14928)

ToolTipService now processes raw mouse input, to allow tooltips on disabled controls
Updated tests and control catalog
pull/15258/head
Tom Edwards 2 years ago
committed by GitHub
parent
commit
e947675113
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 25
      samples/ControlCatalog/Pages/ToolTipPage.xaml
  2. 39
      src/Avalonia.Base/Input/InputExtensions.cs
  3. 18
      src/Avalonia.Base/Input/MouseDevice.cs
  4. 6
      src/Avalonia.Base/Input/PenDevice.cs
  5. 2
      src/Avalonia.Base/Input/PointerOverPreProcessor.cs
  6. 2
      src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs
  7. 4
      src/Avalonia.Base/Input/TouchDevice.cs
  8. 4
      src/Avalonia.Base/Rendering/IRenderer.cs
  9. 1
      src/Avalonia.Controls/Application.cs
  10. 9
      src/Avalonia.Controls/IToolTipService.cs
  11. 44
      src/Avalonia.Controls/ToolTip.cs
  12. 158
      src/Avalonia.Controls/ToolTipService.cs
  13. 43
      src/Avalonia.Controls/TopLevel.cs
  14. 235
      tests/Avalonia.Controls.UnitTests/ToolTipTests.cs
  15. 3
      tests/Avalonia.UnitTests/UnitTestApplication.cs

25
samples/ControlCatalog/Pages/ToolTipPage.xaml

@ -5,10 +5,14 @@
Spacing="4">
<TextBlock Classes="h2">A control which pops up a hint when a control is hovered</TextBlock>
<Grid RowDefinitions="Auto,Auto,Auto"
<Grid RowDefinitions="Auto,Auto,Auto,Auto"
ColumnDefinitions="Auto,Auto"
Margin="0,16,0,0"
HorizontalAlignment="Center">
<ToggleSwitch Margin="5"
HorizontalAlignment="Center"
IsChecked="{Binding Path=(ToolTip.ServiceEnabled), RelativeSource={RelativeSource AncestorType=UserControl}}"
Content="Enable ToolTip service" />
<Border Grid.Column="0"
Grid.Row="1"
Background="{DynamicResource SystemAccentColor}"
@ -21,6 +25,7 @@
Margin="5"
Grid.Row="0"
IsChecked="{Binding ElementName=Border, Path=(ToolTip.IsOpen)}"
HorizontalAlignment="Center"
Content="ToolTip Open" />
<Border Name="Border"
Grid.Column="1"
@ -38,7 +43,6 @@
<TextBlock>ToolTip bottom placement</TextBlock>
</Border>
<Border Grid.Row="2"
Grid.ColumnSpan="2"
Background="{DynamicResource SystemAccentColor}"
Margin="5"
Padding="50"
@ -62,6 +66,23 @@
</Border.Styles>
<TextBlock>Moving offset</TextBlock>
</Border>
<Button Grid.Row="2" Grid.Column="1"
IsEnabled="False"
ToolTip.ShowOnDisabled="True"
ToolTip.Tip="This control is disabled"
Margin="5"
Padding="50">
<TextBlock>ToolTip on a disabled control</TextBlock>
</Button>
<Border Grid.Row="3"
Background="{DynamicResource SystemAccentColor}"
Margin="5"
Padding="50"
ToolTip.Tip="Outer tooltip">
<TextBlock Background="{StaticResource SystemAccentColorDark1}" Padding="10" ToolTip.Tip="Inner tooltip" VerticalAlignment="Center">Nested ToolTips</TextBlock>
</Border>
</Grid>
</StackPanel>
</UserControl>

39
src/Avalonia.Base/Input/InputExtensions.cs

@ -13,36 +13,45 @@ namespace Avalonia.Input
public static class InputExtensions
{
private static readonly Func<Visual, bool> s_hitTestDelegate = IsHitTestVisible;
private static readonly Func<Visual, bool> s_hitTestEnabledOnlyDelegate = IsHitTestVisible_EnabledOnly;
/// <summary>
/// Returns the active input elements at a point on an <see cref="IInputElement"/>.
/// </summary>
/// <param name="element">The element to test.</param>
/// <param name="p">The point on <paramref name="element"/>.</param>
/// <param name="enabledElementsOnly">Whether to only return elements for which <see cref="IInputElement.IsEffectivelyEnabled"/> is true.</param>
/// <returns>
/// The active input elements found at the point, ordered topmost first.
/// </returns>
public static IEnumerable<IInputElement> GetInputElementsAt(this IInputElement element, Point p)
public static IEnumerable<IInputElement> GetInputElementsAt(this IInputElement element, Point p, bool enabledElementsOnly = true)
{
element = element ?? throw new ArgumentNullException(nameof(element));
return (element as Visual)?.GetVisualsAt(p, s_hitTestDelegate).Cast<IInputElement>() ??
return (element as Visual)?.GetVisualsAt(p, enabledElementsOnly ? s_hitTestEnabledOnlyDelegate : s_hitTestDelegate).Cast<IInputElement>() ??
Enumerable.Empty<IInputElement>();
}
/// <inheritdoc cref="GetInputElementsAt(IInputElement, Point, bool)"/>
public static IEnumerable<IInputElement> GetInputElementsAt(this IInputElement element, Point p) => GetInputElementsAt(element, p, true);
/// <summary>
/// Returns the topmost active input element at a point on an <see cref="IInputElement"/>.
/// </summary>
/// <param name="element">The element to test.</param>
/// <param name="p">The point on <paramref name="element"/>.</param>
/// <param name="enabledElementsOnly">Whether to only return elements for which <see cref="IInputElement.IsEffectivelyEnabled"/> is true.</param>
/// <returns>The topmost <see cref="IInputElement"/> at the specified position.</returns>
public static IInputElement? InputHitTest(this IInputElement element, Point p)
public static IInputElement? InputHitTest(this IInputElement element, Point p, bool enabledElementsOnly = true)
{
element = element ?? throw new ArgumentNullException(nameof(element));
return (element as Visual)?.GetVisualAt(p, s_hitTestDelegate) as IInputElement;
return (element as Visual)?.GetVisualAt(p, enabledElementsOnly ? s_hitTestEnabledOnlyDelegate : s_hitTestDelegate) as IInputElement;
}
/// <inheritdoc cref="InputHitTest(IInputElement, Point, bool)"/>
public static IInputElement? InputHitTest(this IInputElement element, Point p) => InputHitTest(element, p, true);
/// <summary>
/// Returns the topmost active input element at a point on an <see cref="IInputElement"/>.
/// </summary>
@ -52,26 +61,26 @@ namespace Avalonia.Input
/// A filter predicate. If the predicate returns false then the visual and all its
/// children will be excluded from the results.
/// </param>
/// <param name="enabledElementsOnly">Whether to only return elements for which <see cref="IInputElement.IsEffectivelyEnabled"/> is true.</param>
/// <returns>The topmost <see cref="IInputElement"/> at the specified position.</returns>
public static IInputElement? InputHitTest(
this IInputElement element,
Point p,
Func<Visual, bool> filter)
Func<Visual, bool> filter,
bool enabledElementsOnly = true)
{
element = element ?? throw new ArgumentNullException(nameof(element));
filter = filter ?? throw new ArgumentNullException(nameof(filter));
var hitTestDelegate = enabledElementsOnly ? s_hitTestEnabledOnlyDelegate : s_hitTestDelegate;
return (element as Visual)?.GetVisualAt(p, x => s_hitTestDelegate(x) && filter(x)) as IInputElement;
return (element as Visual)?.GetVisualAt(p, x => hitTestDelegate(x) && filter(x)) as IInputElement;
}
private static bool IsHitTestVisible(Visual visual)
{
var element = visual as IInputElement;
return element != null &&
visual.IsVisible &&
element.IsHitTestVisible &&
element.IsEffectivelyEnabled &&
visual.IsAttachedToVisualTree;
}
/// <inheritdoc cref="InputHitTest(IInputElement, Point, Func{Visual, bool}, bool)"/>
public static IInputElement? InputHitTest(this IInputElement element, Point p, Func<Visual, bool> filter) => InputHitTest(element, p, filter, true);
private static bool IsHitTestVisible(Visual visual) => visual is { IsVisible: true, IsAttachedToVisualTree: true } and IInputElement { IsHitTestVisible: true };
private static bool IsHitTestVisible_EnabledOnly(Visual visual) => IsHitTestVisible(visual) && visual is IInputElement { IsEffectivelyEnabled: true };
}
}

18
src/Avalonia.Base/Input/MouseDevice.cs

@ -75,9 +75,9 @@ namespace Avalonia.Input
case RawPointerEventType.XButton1Down:
case RawPointerEventType.XButton2Down:
if (ButtonCount(props) > 1)
e.Handled = MouseMove(mouse, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.IntermediatePoints, e.InputHitTestResult);
e.Handled = MouseMove(mouse, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.IntermediatePoints, e.InputHitTestResult.firstEnabledAncestor);
else
e.Handled = MouseDown(mouse, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult);
e.Handled = MouseDown(mouse, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult.firstEnabledAncestor);
break;
case RawPointerEventType.LeftButtonUp:
case RawPointerEventType.RightButtonUp:
@ -85,24 +85,24 @@ namespace Avalonia.Input
case RawPointerEventType.XButton1Up:
case RawPointerEventType.XButton2Up:
if (ButtonCount(props) != 0)
e.Handled = MouseMove(mouse, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.IntermediatePoints, e.InputHitTestResult);
e.Handled = MouseMove(mouse, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.IntermediatePoints, e.InputHitTestResult.firstEnabledAncestor);
else
e.Handled = MouseUp(mouse, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult);
e.Handled = MouseUp(mouse, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult.firstEnabledAncestor);
break;
case RawPointerEventType.Move:
e.Handled = MouseMove(mouse, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.IntermediatePoints, e.InputHitTestResult);
e.Handled = MouseMove(mouse, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.IntermediatePoints, e.InputHitTestResult.firstEnabledAncestor);
break;
case RawPointerEventType.Wheel:
e.Handled = MouseWheel(mouse, e.Timestamp, e.Root, e.Position, props, ((RawMouseWheelEventArgs)e).Delta, keyModifiers, e.InputHitTestResult);
e.Handled = MouseWheel(mouse, e.Timestamp, e.Root, e.Position, props, ((RawMouseWheelEventArgs)e).Delta, keyModifiers, e.InputHitTestResult.firstEnabledAncestor);
break;
case RawPointerEventType.Magnify:
e.Handled = GestureMagnify(mouse, e.Timestamp, e.Root, e.Position, props, ((RawPointerGestureEventArgs)e).Delta, keyModifiers, e.InputHitTestResult);
e.Handled = GestureMagnify(mouse, e.Timestamp, e.Root, e.Position, props, ((RawPointerGestureEventArgs)e).Delta, keyModifiers, e.InputHitTestResult.firstEnabledAncestor);
break;
case RawPointerEventType.Rotate:
e.Handled = GestureRotate(mouse, e.Timestamp, e.Root, e.Position, props, ((RawPointerGestureEventArgs)e).Delta, keyModifiers, e.InputHitTestResult);
e.Handled = GestureRotate(mouse, e.Timestamp, e.Root, e.Position, props, ((RawPointerGestureEventArgs)e).Delta, keyModifiers, e.InputHitTestResult.firstEnabledAncestor);
break;
case RawPointerEventType.Swipe:
e.Handled = GestureSwipe(mouse, e.Timestamp, e.Root, e.Position, props, ((RawPointerGestureEventArgs)e).Delta, keyModifiers, e.InputHitTestResult);
e.Handled = GestureSwipe(mouse, e.Timestamp, e.Root, e.Position, props, ((RawPointerGestureEventArgs)e).Delta, keyModifiers, e.InputHitTestResult.firstEnabledAncestor);
break;
}
}

6
src/Avalonia.Base/Input/PenDevice.cs

@ -64,17 +64,17 @@ namespace Avalonia.Input
shouldReleasePointer = true;
break;
case RawPointerEventType.LeftButtonDown:
e.Handled = PenDown(pointer, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult);
e.Handled = PenDown(pointer, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult.firstEnabledAncestor);
break;
case RawPointerEventType.LeftButtonUp:
if (_releasePointerOnPenUp)
{
shouldReleasePointer = true;
}
e.Handled = PenUp(pointer, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult);
e.Handled = PenUp(pointer, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult.firstEnabledAncestor);
break;
case RawPointerEventType.Move:
e.Handled = PenMove(pointer, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult, e.IntermediatePoints);
e.Handled = PenMove(pointer, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult.firstEnabledAncestor, e.IntermediatePoints);
break;
}
}

2
src/Avalonia.Base/Input/PointerOverPreProcessor.cs

@ -67,7 +67,7 @@ namespace Avalonia.Input
else if (pointerDevice.TryGetPointer(args) is { } pointer &&
pointer.Type != PointerType.Touch)
{
var element = pointer.Captured ?? args.InputHitTestResult;
var element = pointer.Captured ?? args.InputHitTestResult.firstEnabledAncestor;
SetPointerOver(pointer, args.Root, element, args.Timestamp, args.Position,
new PointerPointProperties(args.InputModifiers, args.Type.ToUpdateKind()),

2
src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs

@ -123,7 +123,7 @@ namespace Avalonia.Input.Raw
/// </summary>
public Lazy<IReadOnlyList<RawPointerPoint>?>? IntermediatePoints { get; set; }
internal IInputElement? InputHitTestResult { get; set; }
internal (IInputElement? element, IInputElement? firstEnabledAncestor) InputHitTestResult { get; set; }
}
[PrivateApi]

4
src/Avalonia.Base/Input/TouchDevice.cs

@ -44,14 +44,14 @@ namespace Avalonia.Input
{
if (args.Type == RawPointerEventType.TouchEnd)
return;
var hit = args.InputHitTestResult;
var hit = args.InputHitTestResult.firstEnabledAncestor;
_pointers[args.RawPointerId] = pointer = new Pointer(Pointer.GetNextFreeId(),
PointerType.Touch, _pointers.Count == 0);
pointer.Capture(hit);
}
var target = pointer.Captured ?? args.InputHitTestResult ?? args.Root;
var target = pointer.Captured ?? args.InputHitTestResult.firstEnabledAncestor ?? args.Root;
var gestureTarget = pointer.CapturedGestureRecognizer?.Target;
var updateKind = args.Type.ToUpdateKind();
var keyModifier = args.InputModifiers.ToKeyModifiers();

4
src/Avalonia.Base/Rendering/IRenderer.cs

@ -87,7 +87,7 @@ namespace Avalonia.Rendering
/// children will be excluded from the results.
/// </param>
/// <returns>The visuals at the specified point, topmost first.</returns>
IEnumerable<Visual> HitTest(Point p, Visual root, Func<Visual, bool> filter);
IEnumerable<Visual> HitTest(Point p, Visual root, Func<Visual, bool>? filter);
/// <summary>
/// Hit tests a location to find first visual at the specified point.
@ -99,6 +99,6 @@ namespace Avalonia.Rendering
/// children will be excluded from the results.
/// </param>
/// <returns>The visual at the specified point, topmost first.</returns>
Visual? HitTestFirst(Point p, Visual root, Func<Visual, bool> filter);
Visual? HitTestFirst(Point p, Visual root, Func<Visual, bool>? filter);
}
}

1
src/Avalonia.Controls/Application.cs

@ -268,6 +268,7 @@ namespace Avalonia
.Bind<IThemeVariantHost>().ToConstant(this)
.Bind<IFocusManager>().ToConstant(focusManager)
.Bind<IInputManager>().ToConstant(InputManager)
.Bind< IToolTipService>().ToConstant(new ToolTipService(InputManager))
.Bind<IKeyboardNavigationHandler>().ToTransient<KeyboardNavigationHandler>()
.Bind<IDragDropDevice>().ToConstant(DragDropDevice.Instance);

9
src/Avalonia.Controls/IToolTipService.cs

@ -0,0 +1,9 @@
using Avalonia.Metadata;
namespace Avalonia.Controls;
[Unstable, PrivateApi]
internal interface IToolTipService
{
void Update(Visual? candidateToolTipHost);
}

44
src/Avalonia.Controls/ToolTip.cs

@ -55,6 +55,18 @@ namespace Avalonia.Controls
public static readonly AttachedProperty<int> ShowDelayProperty =
AvaloniaProperty.RegisterAttached<ToolTip, Control, int>("ShowDelay", 400);
/// <summary>
/// Defines the ToolTip.ShowOnDisabled property.
/// </summary>
public static readonly AttachedProperty<bool> ShowOnDisabledProperty =
AvaloniaProperty.RegisterAttached<ToolTip, Control, bool>("ShowOnDisabled", defaultValue: false, inherits: true);
/// <summary>
/// Defines the ToolTip.ServiceEnabled property.
/// </summary>
public static readonly AttachedProperty<bool> ServiceEnabledProperty =
AvaloniaProperty.RegisterAttached<ToolTip, Control, bool>("ServiceEnabled", defaultValue: true, inherits: true);
/// <summary>
/// Stores the current <see cref="ToolTip"/> instance in the control.
/// </summary>
@ -69,8 +81,6 @@ namespace Avalonia.Controls
/// </summary>
static ToolTip()
{
TipProperty.Changed.Subscribe(ToolTipService.Instance.TipChanged);
IsOpenProperty.Changed.Subscribe(ToolTipService.Instance.TipOpenChanged);
IsOpenProperty.Changed.Subscribe(IsOpenChanged);
HorizontalOffsetProperty.Changed.Subscribe(RecalculatePositionOnPropertyChanged);
@ -213,6 +223,36 @@ namespace Avalonia.Controls
element.SetValue(ShowDelayProperty, value);
}
/// <summary>
/// Gets whether a control will display a tooltip even if it disabled.
/// </summary>
/// <param name="element">The control to get the property from.</param>
public static bool GetShowOnDisabled(Control element) =>
element.GetValue(ShowOnDisabledProperty);
/// <summary>
/// Sets whether a control will display a tooltip even if it disabled.
/// </summary>
/// <param name="element">The control to get the property from.</param>
/// <param name="value">Whether the control is to display a tooltip even if it disabled.</param>
public static void SetShowOnDisabled(Control element, bool value) =>
element.SetValue(ShowOnDisabledProperty, value);
/// <summary>
/// Gets whether showing and hiding of a control's tooltip will be automatically controlled by Avalonia.
/// </summary>
/// <param name="element">The control to get the property from.</param>
public static bool GetServiceEnabled(Control element) =>
element.GetValue(ServiceEnabledProperty);
/// <summary>
/// Sets whether showing and hiding of a control's tooltip will be automatically controlled by Avalonia.
/// </summary>
/// <param name="element">The control to get the property from.</param>
/// <param name="value">Whether the control is to display a tooltip even if it disabled.</param>
public static void SetServiceEnabled(Control element, bool value) =>
element.SetValue(ServiceEnabledProperty, value);
private static void IsOpenChanged(AvaloniaPropertyChangedEventArgs e)
{
var control = (Control)e.Sender;

158
src/Avalonia.Controls/ToolTipService.cs

@ -1,6 +1,7 @@
using System;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Input.Raw;
using Avalonia.Reactive;
using Avalonia.Threading;
namespace Avalonia.Controls
@ -8,44 +9,95 @@ namespace Avalonia.Controls
/// <summary>
/// Handles <see cref="ToolTip"/> interaction with controls.
/// </summary>
internal sealed class ToolTipService
internal sealed class ToolTipService : IToolTipService, IDisposable
{
public static ToolTipService Instance { get; } = new ToolTipService();
private readonly IDisposable _subscriptions;
private Control? _tipControl;
private DispatcherTimer? _timer;
private ToolTipService() { }
public ToolTipService(IInputManager inputManager)
{
_subscriptions = new CompositeDisposable(
inputManager.Process.Subscribe(InputManager_OnProcess),
ToolTip.ServiceEnabledProperty.Changed.Subscribe(ServiceEnabledChanged),
ToolTip.TipProperty.Changed.Subscribe(TipChanged),
ToolTip.IsOpenProperty.Changed.Subscribe(TipOpenChanged));
}
/// <summary>
/// called when the <see cref="ToolTip.TipProperty"/> property changes on a control.
/// </summary>
/// <param name="e">The event args.</param>
internal void TipChanged(AvaloniaPropertyChangedEventArgs e)
public void Dispose() => _subscriptions.Dispose();
private void InputManager_OnProcess(RawInputEventArgs e)
{
var control = (Control)e.Sender;
if (e is RawPointerEventArgs pointerEvent)
{
switch (pointerEvent.Type)
{
case RawPointerEventType.Move:
Update(pointerEvent.InputHitTestResult.element as Visual);
break;
case RawPointerEventType.LeftButtonDown:
case RawPointerEventType.RightButtonDown:
case RawPointerEventType.MiddleButtonDown:
case RawPointerEventType.XButton1Down:
case RawPointerEventType.XButton2Down:
StopTimer();
_tipControl?.ClearValue(ToolTip.IsOpenProperty);
break;
}
}
}
if (e.OldValue != null)
public void Update(Visual? candidateToolTipHost)
{
while (candidateToolTipHost != null)
{
control.PointerEntered -= ControlPointerEntered;
control.PointerExited -= ControlPointerExited;
control.RemoveHandler(InputElement.PointerPressedEvent, ControlPointerPressed);
if (candidateToolTipHost is Control control)
{
if (!ToolTip.GetServiceEnabled(control))
return;
if (ToolTip.GetTip(control) != null && (control.IsEffectivelyEnabled || ToolTip.GetShowOnDisabled(control)))
break;
}
candidateToolTipHost = candidateToolTipHost?.VisualParent;
}
if (e.NewValue != null)
var newControl = candidateToolTipHost as Control;
if (newControl == _tipControl)
{
control.PointerEntered += ControlPointerEntered;
control.PointerExited += ControlPointerExited;
control.AddHandler(InputElement.PointerPressedEvent, ControlPointerPressed,
RoutingStrategies.Bubble | RoutingStrategies.Tunnel | RoutingStrategies.Direct, true);
return;
}
OnTipControlChanged(_tipControl, newControl);
_tipControl = newControl;
}
private void ServiceEnabledChanged(AvaloniaPropertyChangedEventArgs<bool> args)
{
if (args.Sender == _tipControl && !ToolTip.GetServiceEnabled(_tipControl))
{
StopTimer();
}
}
/// <summary>
/// called when the <see cref="ToolTip.TipProperty"/> property changes on a control.
/// </summary>
/// <param name="e">The event args.</param>
private void TipChanged(AvaloniaPropertyChangedEventArgs e)
{
var control = (Control)e.Sender;
if (ToolTip.GetIsOpen(control) && e.NewValue != e.OldValue && !(e.NewValue is ToolTip))
{
if (e.NewValue is null)
{
Close(control);
}
else
else
{
if (control.GetValue(ToolTip.ToolTipProperty) is { } tip)
{
@ -55,7 +107,7 @@ namespace Avalonia.Controls
}
}
internal void TipOpenChanged(AvaloniaPropertyChangedEventArgs e)
private void TipOpenChanged(AvaloniaPropertyChangedEventArgs e)
{
var control = (Control)e.Sender;
@ -64,13 +116,13 @@ namespace Avalonia.Controls
control.DetachedFromVisualTree += ControlDetaching;
control.EffectiveViewportChanged += ControlEffectiveViewportChanged;
}
else if(e.OldValue is true && e.NewValue is false)
else if (e.OldValue is true && e.NewValue is false)
{
control.DetachedFromVisualTree -= ControlDetaching;
control.EffectiveViewportChanged -= ControlEffectiveViewportChanged;
}
}
private void ControlDetaching(object? sender, VisualTreeAttachmentEventArgs e)
{
var control = (Control)sender!;
@ -79,49 +131,31 @@ namespace Avalonia.Controls
Close(control);
}
/// <summary>
/// Called when the pointer enters a control with an attached tooltip.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The event args.</param>
private void ControlPointerEntered(object? sender, PointerEventArgs e)
private void OnTipControlChanged(Control? oldValue, Control? newValue)
{
StopTimer();
var control = (Control)sender!;
var showDelay = ToolTip.GetShowDelay(control);
if (showDelay == 0)
if (oldValue != null)
{
Open(control);
// 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);
}
else
if (newValue != null)
{
StartShowTimer(showDelay, control);
var showDelay = ToolTip.GetShowDelay(newValue);
if (showDelay == 0)
{
Open(newValue);
}
else
{
StartShowTimer(showDelay, newValue);
}
}
}
/// <summary>
/// Called when the pointer leaves a control with an attached tooltip.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The event args.</param>
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);
}
private void ControlPointerPressed(object? sender, PointerPressedEventArgs e)
{
StopTimer();
(sender as AvaloniaObject)?.ClearValue(ToolTip.IsOpenProperty);
}
private void ControlEffectiveViewportChanged(object? sender, Layout.EffectiveViewportChangedEventArgs e)
{
var control = (Control)sender!;
@ -140,11 +174,9 @@ namespace Avalonia.Controls
private void ToolTipPointerExited(object? sender, PointerEventArgs e)
{
// The pointer has exited the tooltip. Close the tooltip unless the pointer is over the
// The pointer has exited the tooltip. Close the tooltip unless the current tooltip source is still the
// adorned control.
if (sender is ToolTip toolTip &&
toolTip.AdornedControl is { } control &&
!control.IsPointerOver)
if (sender is ToolTip { AdornedControl: { } control } && control != _tipControl)
{
Close(control);
}
@ -152,7 +184,7 @@ namespace Avalonia.Controls
private void StartShowTimer(int showDelay, Control control)
{
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(showDelay) };
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(showDelay), Tag = (this, control) };
_timer.Tick += (o, e) => Open(control);
_timer.Start();
}
@ -175,8 +207,6 @@ namespace Avalonia.Controls
private void Close(Control control)
{
StopTimer();
ToolTip.SetIsOpen(control, false);
}

43
src/Avalonia.Controls/TopLevel.cs

@ -122,6 +122,7 @@ namespace Avalonia.Controls
);
private readonly IInputManager? _inputManager;
private readonly IToolTipService? _tooltipService;
private readonly IAccessKeyHandler? _accessKeyHandler;
private readonly IKeyboardNavigationHandler? _keyboardNavigationHandler;
private readonly IGlobalStyles? _globalStyles;
@ -182,6 +183,8 @@ namespace Avalonia.Controls
newInputElement.PropertyChanged += topLevel.PointerOverElementOnPropertyChanged;
}
});
ToolTip.ServiceEnabledProperty.Changed.Subscribe(OnToolTipServiceEnabledChanged);
}
/// <summary>
@ -211,6 +214,7 @@ namespace Avalonia.Controls
_accessKeyHandler = TryGetService<IAccessKeyHandler>(dependencyResolver);
_inputManager = TryGetService<IInputManager>(dependencyResolver);
_tooltipService = TryGetService<IToolTipService>(dependencyResolver);
_keyboardNavigationHandler = TryGetService<IKeyboardNavigationHandler>(dependencyResolver);
_globalStyles = TryGetService<IGlobalStyles>(dependencyResolver);
_applicationThemeHost = TryGetService<IThemeVariantHost>(dependencyResolver);
@ -833,7 +837,9 @@ namespace Avalonia.Controls
var (topLevel, e) = (ValueTuple<TopLevel, RawInputEventArgs>)state!;
if (e is RawPointerEventArgs pointerArgs)
{
pointerArgs.InputHitTestResult = topLevel.InputHitTest(pointerArgs.Position);
var hitTestElement = topLevel.InputHitTest(pointerArgs.Position, enabledElementsOnly: false);
pointerArgs.InputHitTestResult = (hitTestElement, FirstEnabledAncestor(hitTestElement));
}
topLevel._inputManager?.ProcessInput(e);
@ -847,6 +853,17 @@ namespace Avalonia.Controls
}
}
private static IInputElement? FirstEnabledAncestor(IInputElement? hitTestElement)
{
var candidate = hitTestElement;
while (candidate?.IsEffectivelyEnabled == false)
{
candidate = (candidate as Visual)?.Parent as IInputElement;
}
return candidate;
}
private void PointerOverElementOnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == CursorProperty && sender is InputElement inputElement)
@ -863,6 +880,30 @@ namespace Avalonia.Controls
private void SceneInvalidated(object? sender, SceneInvalidatedEventArgs e)
{
_pointerOverPreProcessor?.SceneInvalidated(e.DirtyRect);
UpdateToolTip(e.DirtyRect);
}
private static void OnToolTipServiceEnabledChanged(AvaloniaPropertyChangedEventArgs<bool> args)
{
if (args.GetNewValue<bool>()
&& args.Priority != BindingPriority.Inherited
&& args.Sender is Visual visual
&& GetTopLevel(visual) is { } topLevel)
{
topLevel.UpdateToolTip(visual.Bounds.Translate((Vector)visual.TranslatePoint(default, topLevel)!));
}
}
private void UpdateToolTip(Rect dirtyRect)
{
if (_tooltipService != null && _pointerOverPreProcessor?.LastPosition is { } lastPos)
{
var clientPoint = this.PointToClient(lastPos);
if (dirtyRect.Contains(clientPoint))
{
_tooltipService.Update(HitTester.HitTestFirst(clientPoint, this, null));
}
}
}
void PlatformImpl_LostFocus()

235
tests/Avalonia.Controls.UnitTests/ToolTipTests.cs

@ -1,45 +1,26 @@
using System;
using System.Reactive.Disposables;
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Rendering;
using Avalonia.Threading;
using Avalonia.UnitTests;
using Avalonia.Utilities;
using Avalonia.VisualTree;
using Moq;
using Xunit;
namespace Avalonia.Controls.UnitTests
{
public class TolTipTests
public class ToolTipTests
{
private MouseTestHelper _mouseHelper = new MouseTestHelper();
private static readonly MouseDevice s_mouseDevice = new(new Pointer(0, PointerType.Mouse, true));
[Fact]
public void Should_Not_Open_On_Detached_Control()
{
//issue #3188
var control = new Decorator()
{
[ToolTip.TipProperty] = "Tip",
[ToolTip.ShowDelayProperty] = 0
};
Assert.False(control.IsAttachedToVisualTree);
//here in issue #3188 exception is raised
_mouseHelper.Enter(control);
Assert.False(ToolTip.GetIsOpen(control));
}
[Fact]
public void Should_Close_When_Control_Detaches()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
var window = new Window();
var panel = new Panel();
var target = new Decorator()
@ -50,15 +31,7 @@ namespace Avalonia.Controls.UnitTests
panel.Children.Add(target);
window.Content = panel;
window.ApplyStyling();
window.ApplyTemplate();
window.Presenter.ApplyTemplate();
Assert.True(target.IsAttachedToVisualTree);
_mouseHelper.Enter(target);
SetupWindowAndActivateToolTip(panel, target);
Assert.True(ToolTip.GetIsOpen(target));
@ -71,27 +44,22 @@ namespace Avalonia.Controls.UnitTests
[Fact]
public void Should_Close_When_Tip_Is_Opened_And_Detached_From_Visual_Tree()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
var xaml = @"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
<Panel x:Name='PART_panel'>
<Decorator x:Name='PART_target' ToolTip.Tip='{Binding Tip}' ToolTip.ShowDelay='0' />
</Panel>
</Window>";
var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
window.DataContext = new ToolTipViewModel();
window.ApplyTemplate();
window.Presenter.ApplyTemplate();
var target = new Decorator
{
[!ToolTip.TipProperty] = new Binding("Tip"),
[ToolTip.ShowDelayProperty] = 0,
};
var target = window.Find<Decorator>("PART_target");
var panel = window.Find<Panel>("PART_panel");
Assert.True(target.IsAttachedToVisualTree);
var panel = new Panel();
panel.Children.Add(target);
var mouseEnter = SetupWindowAndGetMouseEnterAction(panel);
_mouseHelper.Enter(target);
panel.DataContext = new ToolTipViewModel();
mouseEnter(target);
Assert.True(ToolTip.GetIsOpen(target));
@ -104,25 +72,15 @@ namespace Avalonia.Controls.UnitTests
[Fact]
public void Should_Open_On_Pointer_Enter()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
var window = new Window();
var target = new Decorator()
{
[ToolTip.TipProperty] = "Tip",
[ToolTip.ShowDelayProperty] = 0
};
window.Content = target;
window.ApplyStyling();
window.ApplyTemplate();
window.Presenter.ApplyTemplate();
Assert.True(target.IsAttachedToVisualTree);
_mouseHelper.Enter(target);
SetupWindowAndActivateToolTip(target);
Assert.True(ToolTip.GetIsOpen(target));
}
@ -131,28 +89,19 @@ namespace Avalonia.Controls.UnitTests
[Fact]
public void Content_Should_Update_When_Tip_Property_Changes_And_Already_Open()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
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);
SetupWindowAndActivateToolTip(target);
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);
}
@ -161,25 +110,15 @@ namespace Avalonia.Controls.UnitTests
[Fact]
public void Should_Open_On_Pointer_Enter_With_Delay()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
var window = new Window();
var target = new Decorator()
{
[ToolTip.TipProperty] = "Tip",
[ToolTip.ShowDelayProperty] = 1
};
window.Content = target;
window.ApplyStyling();
window.ApplyTemplate();
window.Presenter.ApplyTemplate();
Assert.True(target.IsAttachedToVisualTree);
_mouseHelper.Enter(target);
SetupWindowAndActivateToolTip(target);
var timer = Assert.Single(Dispatcher.SnapshotTimersForUnitTests());
Assert.Equal(TimeSpan.FromMilliseconds(1), timer.Interval);
@ -268,23 +207,15 @@ namespace Avalonia.Controls.UnitTests
[Fact]
public void Should_Close_On_Null_Tip()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
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);
SetupWindowAndActivateToolTip(target);
Assert.True(ToolTip.GetIsOpen(target));
@ -297,28 +228,23 @@ namespace Avalonia.Controls.UnitTests
[Fact]
public void Should_Not_Close_When_Pointer_Is_Moved_Over_ToolTip()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
var window = new Window();
var target = new Decorator()
{
[ToolTip.TipProperty] = "Tip",
[ToolTip.ShowDelayProperty] = 0
};
window.Content = target;
var mouseEnter = SetupWindowAndGetMouseEnterAction(target);
window.ApplyStyling();
window.ApplyTemplate();
window.Presenter.ApplyTemplate();
mouseEnter(target);
_mouseHelper.Enter(target);
Assert.True(ToolTip.GetIsOpen(target));
var tooltip = Assert.IsType<ToolTip>(target.GetValue(ToolTip.ToolTipProperty));
_mouseHelper.Enter(tooltip);
_mouseHelper.Leave(target);
mouseEnter(tooltip);
Assert.True(ToolTip.GetIsOpen(target));
}
@ -327,33 +253,25 @@ namespace Avalonia.Controls.UnitTests
[Fact]
public void Should_Not_Close_When_Pointer_Is_Moved_From_ToolTip_To_Original_Control()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
var window = new Window();
var target = new Decorator()
{
[ToolTip.TipProperty] = "Tip",
[ToolTip.ShowDelayProperty] = 0
};
window.Content = target;
window.ApplyStyling();
window.ApplyTemplate();
window.Presenter.ApplyTemplate();
var mouseEnter = SetupWindowAndGetMouseEnterAction(target);
_mouseHelper.Enter(target);
mouseEnter(target);
Assert.True(ToolTip.GetIsOpen(target));
var tooltip = Assert.IsType<ToolTip>(target.GetValue(ToolTip.ToolTipProperty));
_mouseHelper.Enter(tooltip);
_mouseHelper.Leave(target);
mouseEnter(tooltip);
Assert.True(ToolTip.GetIsOpen(target));
_mouseHelper.Enter(target);
_mouseHelper.Leave(tooltip);
mouseEnter(target);
Assert.True(ToolTip.GetIsOpen(target));
}
@ -362,10 +280,8 @@ namespace Avalonia.Controls.UnitTests
[Fact]
public void Should_Close_When_Pointer_Is_Moved_From_ToolTip_To_Another_Control()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
using (UnitTestApplication.Start(TestServices.FocusableWindow))
{
var window = new Window();
var target = new Decorator()
{
[ToolTip.TipProperty] = "Tip",
@ -379,27 +295,74 @@ namespace Avalonia.Controls.UnitTests
Children = { target, other }
};
window.Content = panel;
window.ApplyStyling();
window.ApplyTemplate();
window.Presenter.ApplyTemplate();
var mouseEnter = SetupWindowAndGetMouseEnterAction(panel);
_mouseHelper.Enter(target);
mouseEnter(target);
Assert.True(ToolTip.GetIsOpen(target));
var tooltip = Assert.IsType<ToolTip>(target.GetValue(ToolTip.ToolTipProperty));
_mouseHelper.Enter(tooltip);
_mouseHelper.Leave(target);
mouseEnter(tooltip);
Assert.True(ToolTip.GetIsOpen(target));
_mouseHelper.Enter(other);
_mouseHelper.Leave(tooltip);
mouseEnter(other);
Assert.False(ToolTip.GetIsOpen(target));
}
}
private Action<Control> SetupWindowAndGetMouseEnterAction(Control windowContent, [CallerMemberName] string testName = null)
{
var windowImpl = MockWindowingPlatform.CreateWindowMock();
var hitTesterMock = new Mock<IHitTester>();
var window = new Window(windowImpl.Object)
{
HitTesterOverride = hitTesterMock.Object,
Content = windowContent,
Title = testName,
};
window.ApplyStyling();
window.ApplyTemplate();
window.Presenter.ApplyTemplate();
window.Show();
Assert.True(windowContent.IsAttachedToVisualTree);
Assert.True(windowContent.IsMeasureValid);
Assert.True(windowContent.IsVisible);
var controlIds = new Dictionary<Control, int>();
return control =>
{
Point point;
if (control == null)
{
point = default;
}
else
{
if (!controlIds.TryGetValue(control, out int id))
{
id = controlIds[control] = controlIds.Count;
}
point = new Point(id, int.MaxValue);
}
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,
RawPointerEventType.Move, point, RawInputModifiers.None));
Assert.True(control == null || control.IsPointerOver);
};
}
private void SetupWindowAndActivateToolTip(Control windowContent, Control targetOverride = null, [CallerMemberName] string testName = null) =>
SetupWindowAndGetMouseEnterAction(windowContent, testName)(targetOverride ?? windowContent);
}
internal class ToolTipViewModel

3
tests/Avalonia.UnitTests/UnitTestApplication.cs

@ -53,6 +53,8 @@ namespace Avalonia.UnitTests
Dispatcher.UIThread.RunJobs();
}
((ToolTipService)AvaloniaLocator.Current.GetService<IToolTipService>())?.Dispose();
scope.Dispose();
Dispatcher.ResetForUnitTests();
SynchronizationContext.SetSynchronizationContext(oldContext);
@ -67,6 +69,7 @@ namespace Avalonia.UnitTests
.Bind<IGlobalClock>().ToConstant(Services.GlobalClock)
.BindToSelf<IGlobalStyles>(this)
.Bind<IInputManager>().ToConstant(Services.InputManager)
.Bind<IToolTipService>().ToConstant(Services.InputManager == null ? null : new ToolTipService(Services.InputManager))
.Bind<IKeyboardDevice>().ToConstant(Services.KeyboardDevice?.Invoke())
.Bind<IMouseDevice>().ToConstant(Services.MouseDevice?.Invoke())
.Bind<IKeyboardNavigationHandler>().ToFunc(Services.KeyboardNavigation ?? (() => null))

Loading…
Cancel
Save