Repository: AvaloniaUI/Avalonia

Issue:
Display ToolTip on Control with IsEnabled=False
Right now it seems impossible to display a ToolTip properly if the control it's attached to isn't enabled. WPF has [ToolTipService.ShowOnDisabled](https://docs.microsoft.com/en-us/dotnet/api/system.windows.controls.tooltipservice.showondisabled?view=netcore-3.1)

cibuild0006898


Hints:
A little more complicated than it might seem because we rely on `PointerEnter` to show tooltips and that event isn't fired when a control is disabled. WPF hooks into the [global `InputManager`](https://github.com/dotnet/wpf/blob/4a3c96a6c6548c4d37ccdcbb44712ef3c147b58f/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/PopupControlService.cs#L35) to bypass this, we'd need to do something similar. This feature isn't available in UWP it seems.
mmm, I think it's possible make a PseudoGlobalInputManager, getting the last window/control parent and getting its Pointer Events (I don't know if that is good for the performance/scalability).
Are there any workarounds for this? I'm trying to show a tooltip for a disabled ListBoxItem.
did anyone find a workaround for this? 
@jpgarza93 Here's an attached property workaround. It checks if an attached control is under the pointer from its `TopLevel` and if its disabled it'll show its tooltip. When none are under the pointer it'll clear any that were manually shown:

```
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using System.Linq;
using System.Reactive.Linq;

namespace Example {
    public static class ShowDisabledTooltipExtension {
        #region Constructors
        static ShowDisabledTooltipExtension() {
            ShowOnDisabledProperty.Changed.AddClassHandler<Control>((x, y) => HandleShowOnDisabledChanged(x, y));
        }

        #endregion

        #region Properties

        #region ShowOnDisabled AvaloniaProperty
        public static bool GetShowOnDisabled(AvaloniaObject obj) {
            return obj.GetValue(ShowOnDisabledProperty);
        }

        public static void SetShowOnDisabled(AvaloniaObject obj, bool value) {
            obj.SetValue(ShowOnDisabledProperty, value);
        }

        public static readonly AttachedProperty<bool> ShowOnDisabledProperty =
            AvaloniaProperty.RegisterAttached<object, Control, bool>(
                "ShowOnDisabled",
                false, false);

        private static void HandleShowOnDisabledChanged(Control control, AvaloniaPropertyChangedEventArgs e) {
            if (e.NewValue is bool isEnabledVal && isEnabledVal) {
                control.DetachedFromVisualTree += AttachedControl_DetachedFromVisualOrExtension;
                control.AttachedToVisualTree += AttachedControl_AttachedToVisualTree;
                if (control.IsInitialized) {
                    // enabled after visual attached
                    AttachedControl_AttachedToVisualTree(control, null);
                }
            } else {
                AttachedControl_DetachedFromVisualOrExtension(control, null);
            }

        }

        private static void AttachedControl_AttachedToVisualTree(object sender, VisualTreeAttachmentEventArgs e) {
            if (sender is not Control control) {
                return;
            }
            var tl = TopLevel.GetTopLevel(control);
			// NOTE pointermove needed to be tunneled for me but you may not need to...
            tl.AddHandler(TopLevel.PointerMovedEvent, TopLevel_PointerMoved, RoutingStrategies.Tunnel);
        }


        private static void AttachedControl_DetachedFromVisualOrExtension(object s, VisualTreeAttachmentEventArgs e) {
            if (s is not Control control) {
                return;
            }
            control.DetachedFromVisualTree -= AttachedControl_DetachedFromVisualOrExtension;
            control.AttachedToVisualTree -= AttachedControl_AttachedToVisualTree;
            if (TopLevel.GetTopLevel(control) is not TopLevel tl) {
                return;
            }
            tl.RemoveHandler(TopLevel.PointerMovedEvent, TopLevel_PointerMoved);
        }

        private static void TopLevel_PointerMoved(object sender, global::Avalonia.Input.PointerEventArgs e) {
            if (sender is not Control tl) {
                return;
            }
            var attached_controls =
                tl.GetVisualDescendants().Where(x => GetShowOnDisabled(x)).Cast<Control>();

            // find disabled children under pointer w/ this extension enabled
            var disabled_child_under_pointer =
                attached_controls
                    .FirstOrDefault(x =>
                        x.Bounds.Contains(e.GetPosition(x.Parent as Visual)) &&
                        x.IsEffectivelyVisible &&
                        !x.IsEnabled);
            if (disabled_child_under_pointer != null) {
                // manually show tooltip
                ToolTip.SetIsOpen(disabled_child_under_pointer, true);
            }
            var disabled_tooltips_to_hide =
                    attached_controls
                        .Where(x =>
                            ToolTip.GetIsOpen(x) &&
                            x != disabled_child_under_pointer &&
                            !x.IsEnabled);
            foreach (var dcst in disabled_tooltips_to_hide) {
                ToolTip.SetIsOpen(dcst, false);
            }
        }
        #endregion

        #endregion
    }
}
```

To use just set `ShowOnDisabled` to `true` on the control with the tooltip. Example:

```
<Button local:ShowDisabledTooltipExtension.ShowOnDisabled="True"
                IsEnabled="False"
                Content="Test Button"
                ToolTip.Tip="Disabled tooltip" />
```

> @jpgarza93 Here's an attached property workaround. It checks if an attached control is under the pointer from its `TopLevel` and if its disabled it'll show its tooltip. When none are under the pointer it'll clear any that were manually shown
> 

Thank you for this @tkefauver!!! 

In my case, I had to change the two `!x.IsEnabled` lines to `!x.IsEffectivelyEnabled`.  
This is because I'm using the tooltip to show a user-facing explanation why the control is disabled (i.e. "you can't click this unless you've done this other thing first"), which comes from a bound custom command type, 
and when the control is disabled because a bound `ICommand`'s `CanExecute()` returned false, 
the control's `IsEnabled` stays true, but its `IsEffectivelyEnabled` becomes false.

In case anyone wants to know how to do a command like that, here's the code:

RelayCommandWithReason.cs: 
``` csharp
public interface IReportReasonCantExecute
{
    string ReasonCantExecute { set; }
}

public class RelayCommandWithReason : 
    IRelayCommand /* from CommunityToolkit.MVVM */, 
    IReportReasonCantExecute, 
    INotifyPropertyChanged, INotifyPropertyChanging
{
    #region INotifyPropertyChange interfaces

    public event PropertyChangedEventHandler? PropertyChanged;
    public event PropertyChangingEventHandler? PropertyChanging;

    #endregion

    #region IRelayCommand interface

    public event EventHandler? CanExecuteChanged;

    public void NotifyCanExecuteChanged()
    {
        CanExecuteChanged?.Invoke(this, EventArgs.Empty);
    }

    public void Execute(object? parameter)
    {
        _execute?.Invoke(parameter);
    }

    public bool CanExecute(object? parameter)
    {
        if (_canExecuteWithReason != null)
        {
            bool canExecute = _canExecuteWithReason(parameter, this);

            if (canExecute)
            {
                ReasonCantExecute = null;
            }

            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ReasonCantExecute)));
            return canExecute;
        }

        return true;
    }

    #endregion

    public delegate bool CanExecuteWithReason(object? parameter, IReportReasonCantExecute reporter);

    private string? _reasonCantExecute;
    public string? ReasonCantExecute
    {
        get => _reasonCantExecute;
        set
        {
            if (_reasonCantExecute != value)
            {
                PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(ReasonCantExecute)));
                _reasonCantExecute = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ReasonCantExecute)));
            }
        }
    }

    private readonly Action<object?>? _execute;
    private readonly CanExecuteWithReason? _canExecuteWithReason;

    public RelayCommandWithReason(Action<object?> execute, CanExecuteWithReason canExecuteWithReason)
    {
        _execute = execute ?? throw new ArgumentNullException(nameof(execute));
        _canExecuteWithReason = canExecuteWithReason;
    }
}
```

in the view model: 
``` csharp
        public ICommand DoTheThingCommand => new RelayCommandWithReason(this.DoTheThing, this.CanDoTheThing);

        internal bool CanDoTheThing(object? parameter, IReportReasonCantExecute reporter)
        {
            if (CommonProcessObject.SomethingIUsed)
            {
                reporter.ReasonCantExecute = "You can't do the thing yet.";
                return false;
            }

            return true;
        }

        public void DoTheThing(object? parameter)
        {
            // do the thing
        }
```

in the view: 
``` xaml
	<Button Content="Do the thing"
                Command="{Binding DoTheThingCommand}"
                local:ShowDisabledTooltipExtension.ShowOnDisabled="true"
                ToolTip.Tip="{Binding $self.((local:RelayCommandWithReason)Command).ReasonCantExecute}"
                CommandParameter="{Binding CommonProcessObject.SomethingIUsed}" />
```

result: 
![image](https://github.com/AvaloniaUI/Avalonia/assets/2152220/8a447f76-a6c1-40e1-b08e-2375997128d7)

(The only thing to keep in mind is that because `SomethingIUsed` was used in the condition, I had to pass it in as the `CommandParameter`, even though I didn't actually need to use it as `parameter` as it's already directly accessible to that viewmodel... but otherwise, CanExecute won't get re-evaluated when `SomethingIUsed` changes. That'd be true of a regular CanExecute without reason too though; with the toolkit RelayCommand the expectation is to adorn the property with `[NotifyCanExecuteChangedFor(nameof(DoTheThingCommand))]`, but that couples the property to the command and this property is in a child property of an ObservableObject in a base class many views use, so not an option 😅)

Patch:
diff --git a/samples/ControlCatalog/Pages/ToolTipPage.xaml b/samples/ControlCatalog/Pages/ToolTipPage.xaml
index 013c176bca5..fa40bc1a0fa 100644
--- a/samples/ControlCatalog/Pages/ToolTipPage.xaml
+++ b/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>
diff --git a/src/Avalonia.Base/Input/InputExtensions.cs b/src/Avalonia.Base/Input/InputExtensions.cs
index 73dcef9e95a..a5107588c9e 100644
--- a/src/Avalonia.Base/Input/InputExtensions.cs
+++ b/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 @@ public static IEnumerable<IInputElement> GetInputElementsAt(this IInputElement e
         /// 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 };
     }
 }
diff --git a/src/Avalonia.Base/Input/MouseDevice.cs b/src/Avalonia.Base/Input/MouseDevice.cs
index 0cabf02566a..b97036c7ddf 100644
--- a/src/Avalonia.Base/Input/MouseDevice.cs
+++ b/src/Avalonia.Base/Input/MouseDevice.cs
@@ -75,9 +75,9 @@ private void ProcessRawEvent(RawPointerEventArgs e)
                 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 @@ private void ProcessRawEvent(RawPointerEventArgs e)
                 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;
             }
         }
diff --git a/src/Avalonia.Base/Input/PenDevice.cs b/src/Avalonia.Base/Input/PenDevice.cs
index 7637de05acc..128a49e13b9 100644
--- a/src/Avalonia.Base/Input/PenDevice.cs
+++ b/src/Avalonia.Base/Input/PenDevice.cs
@@ -64,17 +64,17 @@ private void ProcessRawEvent(RawPointerEventArgs e)
                         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;
                 }
             }
diff --git a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs
index 347ba35a411..d5a716ba602 100644
--- a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs
+++ b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs
@@ -67,7 +67,7 @@ public void OnNext(RawInputEventArgs value)
                 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()),
diff --git a/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs b/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs
index 595f4c0c661..1a13d371126 100644
--- a/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs
+++ b/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs
@@ -123,7 +123,7 @@ public Point Position
         /// </summary>
         public Lazy<IReadOnlyList<RawPointerPoint>?>? IntermediatePoints { get; set; }
 
-        internal IInputElement? InputHitTestResult { get; set; }
+        internal (IInputElement? element, IInputElement? firstEnabledAncestor) InputHitTestResult { get; set; }
     }
 
     [PrivateApi]
diff --git a/src/Avalonia.Base/Input/TouchDevice.cs b/src/Avalonia.Base/Input/TouchDevice.cs
index 04c444f441a..026af11b058 100644
--- a/src/Avalonia.Base/Input/TouchDevice.cs
+++ b/src/Avalonia.Base/Input/TouchDevice.cs
@@ -44,14 +44,14 @@ public void ProcessRawEvent(RawInputEventArgs ev)
             {
                 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();
diff --git a/src/Avalonia.Base/Rendering/IRenderer.cs b/src/Avalonia.Base/Rendering/IRenderer.cs
index 227ff538971..bd5aff8e6be 100644
--- a/src/Avalonia.Base/Rendering/IRenderer.cs
+++ b/src/Avalonia.Base/Rendering/IRenderer.cs
@@ -87,7 +87,7 @@ public interface IHitTester
         /// 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 @@ public interface IHitTester
         /// 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);
     }
 }
diff --git a/src/Avalonia.Controls/Application.cs b/src/Avalonia.Controls/Application.cs
index 6b8ddc15eaa..e48711efdb4 100644
--- a/src/Avalonia.Controls/Application.cs
+++ b/src/Avalonia.Controls/Application.cs
@@ -268,6 +268,7 @@ public virtual void RegisterServices()
                 .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);
 
diff --git a/src/Avalonia.Controls/IToolTipService.cs b/src/Avalonia.Controls/IToolTipService.cs
new file mode 100644
index 00000000000..31884348502
--- /dev/null
+++ b/src/Avalonia.Controls/IToolTipService.cs
@@ -0,0 +1,9 @@
+﻿using Avalonia.Metadata;
+
+namespace Avalonia.Controls;
+
+[Unstable, PrivateApi]
+internal interface IToolTipService
+{
+    void Update(Visual? candidateToolTipHost);
+}
diff --git a/src/Avalonia.Controls/ToolTip.cs b/src/Avalonia.Controls/ToolTip.cs
index 8e0ca62131d..85362258eb3 100644
--- a/src/Avalonia.Controls/ToolTip.cs
+++ b/src/Avalonia.Controls/ToolTip.cs
@@ -55,6 +55,18 @@ public class ToolTip : ContentControl, IPopupHostProvider
         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 @@ public class ToolTip : ContentControl, IPopupHostProvider
         /// </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 @@ public static void SetShowDelay(Control element, int value)
             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;
diff --git a/src/Avalonia.Controls/ToolTipService.cs b/src/Avalonia.Controls/ToolTipService.cs
index 9e75026d791..77e314e52dd 100644
--- a/src/Avalonia.Controls/ToolTipService.cs
+++ b/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 @@ internal void TipChanged(AvaloniaPropertyChangedEventArgs e)
             }
         }
 
-        internal void TipOpenChanged(AvaloniaPropertyChangedEventArgs e)
+        private void TipOpenChanged(AvaloniaPropertyChangedEventArgs e)
         {
             var control = (Control)e.Sender;
 
@@ -64,13 +116,13 @@ internal void TipOpenChanged(AvaloniaPropertyChangedEventArgs e)
                 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 @@ private void ControlDetaching(object? sender, VisualTreeAttachmentEventArgs e)
             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 @@ private void ToolTipClosed(object? sender, EventArgs e)
 
         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 @@ toolTip.AdornedControl is { } control &&
 
         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 @@ private void Open(Control control)
 
         private void Close(Control control)
         {
-            StopTimer();
-
             ToolTip.SetIsOpen(control, false);
         }
 
diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs
index 70e531a7897..7fcbd544e9e 100644
--- a/src/Avalonia.Controls/TopLevel.cs
+++ b/src/Avalonia.Controls/TopLevel.cs
@@ -122,6 +122,7 @@ private static readonly WeakEvent<IResourceHost, ResourcesChangedEventArgs>
             );
 
         private readonly IInputManager? _inputManager;
+        private readonly IToolTipService? _tooltipService;
         private readonly IAccessKeyHandler? _accessKeyHandler;
         private readonly IKeyboardNavigationHandler? _keyboardNavigationHandler;
         private readonly IGlobalStyles? _globalStyles;
@@ -182,6 +183,8 @@ static TopLevel()
                     newInputElement.PropertyChanged += topLevel.PointerOverElementOnPropertyChanged;
                 }
             });
+
+            ToolTip.ServiceEnabledProperty.Changed.Subscribe(OnToolTipServiceEnabledChanged);
         }
 
         /// <summary>
@@ -211,6 +214,7 @@ public TopLevel(ITopLevelImpl impl, IAvaloniaDependencyResolver? dependencyResol
 
             _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 @@ private void HandleInput(RawInputEventArgs e)
                     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 @@ private void HandleInput(RawInputEventArgs e)
             }
         }
 
+        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 @@ private void GlobalActualThemeVariantChanged(object? sender, EventArgs e)
         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()


Test patch:
diff --git a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs
index 98a131752a4..ddfc2b7b722 100644
--- a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs
+++ b/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 @@ public void Should_Close_When_Control_Detaches()
                 
                 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 @@ public void Should_Close_When_Control_Detaches()
         [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 @@ public void Should_Close_When_Tip_Is_Opened_And_Detached_From_Visual_Tree()
         [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 @@ public void Should_Open_On_Pointer_Enter()
         [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 @@ public void Content_Should_Update_When_Tip_Property_Changes_And_Already_Open()
         [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 @@ public void Clearing_IsOpen_Should_Remove_Open_Class()
         [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 @@ public void Should_Close_On_Null_Tip()
         [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 @@ public void Should_Not_Close_When_Pointer_Is_Moved_Over_ToolTip()
         [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 @@ public void Should_Not_Close_When_Pointer_Is_Moved_From_ToolTip_To_Original_Cont
         [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 @@ public void Should_Close_When_Pointer_Is_Moved_From_ToolTip_To_Another_Control()
                     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
diff --git a/tests/Avalonia.UnitTests/UnitTestApplication.cs b/tests/Avalonia.UnitTests/UnitTestApplication.cs
index 18ccff75f0b..18b1bb64611 100644
--- a/tests/Avalonia.UnitTests/UnitTestApplication.cs
+++ b/tests/Avalonia.UnitTests/UnitTestApplication.cs
@@ -53,6 +53,8 @@ public static IDisposable Start(TestServices services = null)
                     Dispatcher.UIThread.RunJobs();
                 }
 
+                ((ToolTipService)AvaloniaLocator.Current.GetService<IToolTipService>())?.Dispose();
+
                 scope.Dispose();
                 Dispatcher.ResetForUnitTests();
                 SynchronizationContext.SetSynchronizationContext(oldContext);
@@ -67,6 +69,7 @@ public override void RegisterServices()
                 .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))