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
Tom Edwards 1 year ago
parent
commit
e947675113

+ 23 - 2
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>

+ 24 - 15
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 };
     }
 }

+ 9 - 9
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;
             }
         }

+ 3 - 3
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;
                 }
             }

+ 1 - 1
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()),

+ 1 - 1
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]

+ 2 - 2
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();

+ 2 - 2
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 - 0
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 - 0
src/Avalonia.Controls/IToolTipService.cs

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

+ 42 - 2
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;

+ 94 - 64
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);
         }
 

+ 42 - 1
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()

+ 99 - 136
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 - 0
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))