Repository: AvaloniaUI/Avalonia

Issue:
Add Control.ToolTipOpening/ToolTipClosing events
### Is your feature request related to a problem? Please describe.

WPF implements attached `ToolTipService.ToolTipOpening` and `ToolTipClosing` events that are `AddOwner`-ed on `FrameworkElement`, thereby adding [FrameworkElement.ToolTipOpening](https://learn.microsoft.com/en-us/dotnet/api/system.windows.frameworkelement.tooltipopening?view=windowsdesktop-8.0) and [ToolTipClosing](https://learn.microsoft.com/en-us/dotnet/api/system.windows.frameworkelement.tooltipclosing?view=windowsdesktop-8.0) events and related `OnToolTipXXX` methods on elements.

These events are handy, especially the opening one, because it allows a tooltip to be customized prior to display or alternatively canceled.  The customization ability is a feature we could use.

### Describe the solution you'd like

Make a similar design to WPF, at least in terms of adding the two events on Avalonia's `Control` class so that we can customize or cancel tooltips prior to display.  And the ToolTipClosing event is there as well in case people need it for some kind of cleanup.

### Describe alternatives you've considered

_No response_

### Additional context

_No response_


Hints:

Patch:
diff --git a/samples/ControlCatalog/Pages/ToolTipPage.xaml b/samples/ControlCatalog/Pages/ToolTipPage.xaml
index fa40bc1a0fa..8aaed903f36 100644
--- a/samples/ControlCatalog/Pages/ToolTipPage.xaml
+++ b/samples/ControlCatalog/Pages/ToolTipPage.xaml
@@ -77,12 +77,23 @@
             </Button>
 
           <Border Grid.Row="3"
+                  Grid.Column="0"
                   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>
+            
+          <Border Grid.Row="3"
+                  Grid.Column="1"
+                  Background="{DynamicResource SystemAccentColor}"
+                  Margin="5"
+                  Padding="50"
+                  ToolTip.ToolTipOpening="ToolTipOpening"
+                  ToolTip.Tip="Should never be visible">
+            <TextBlock VerticalAlignment="Center">ToolTip replaced on the fly</TextBlock>
+          </Border>
         </Grid>
     </StackPanel>
 </UserControl>
diff --git a/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs b/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs
index 202f83aaf19..0e8bf3a181d 100644
--- a/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs
@@ -1,4 +1,5 @@
 using Avalonia.Controls;
+using Avalonia.Interactivity;
 using Avalonia.Markup.Xaml;
 
 namespace ControlCatalog.Pages
@@ -14,5 +15,10 @@ private void InitializeComponent()
         {
             AvaloniaXamlLoader.Load(this);
         }
+
+        private void ToolTipOpening(object? sender, CancelRoutedEventArgs args)
+        {
+            ((Control)args.Source!).SetValue(ToolTip.TipProperty, "New tip set from ToolTipOpening.");
+        } 
     }
 }
diff --git a/src/Avalonia.Controls/ToolTip.cs b/src/Avalonia.Controls/ToolTip.cs
index 245b4d74b08..8bf0adedee5 100644
--- a/src/Avalonia.Controls/ToolTip.cs
+++ b/src/Avalonia.Controls/ToolTip.cs
@@ -3,6 +3,7 @@
 using Avalonia.Controls.Diagnostics;
 using Avalonia.Controls.Metadata;
 using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
 using Avalonia.Reactive;
 using Avalonia.Styling;
 
@@ -80,6 +81,28 @@ public class ToolTip : ContentControl, IPopupHostProvider
         internal static readonly AttachedProperty<ToolTip?> ToolTipProperty =
             AvaloniaProperty.RegisterAttached<ToolTip, Control, ToolTip?>("ToolTip");
 
+        /// <summary>
+        /// The event raised when a ToolTip is going to be shown on an element.
+        /// </summary>
+        /// <remarks>
+        /// To prevent a tooltip from appearing in the UI, your handler for ToolTipOpening can mark the event data handled.
+        /// Otherwise, the tooltip is displayed, using the value of the ToolTip property as the tooltip content.
+        /// Another possible scenario is that you could write a handler that resets the value of the ToolTip property for the element that is the event source, just before the tooltip is displayed.
+        /// ToolTipOpening will not be raised if the value of ToolTip is null or otherwise unset. Do not deliberately set ToolTip to null while a tooltip is open or opening; this will not have the effect of closing the tooltip, and will instead create an undesirable visual artifact in the UI.
+        /// </remarks>
+        public static readonly RoutedEvent<CancelRoutedEventArgs> ToolTipOpeningEvent =
+            RoutedEvent.Register<ToolTip, CancelRoutedEventArgs>("ToolTipOpening", RoutingStrategies.Direct);
+
+        /// <summary>
+        /// The event raised when a ToolTip on an element that was shown should now be hidden.
+        /// </summary>
+        /// <remarks>
+        /// Marking the ToolTipClosing event as handled does not cancel closing the tooltip.
+        /// Once the tooltip is displayed, closing the tooltip is done only in response to user interaction with the UI.
+        /// </remarks>
+        public static readonly RoutedEvent ToolTipClosingEvent =
+            RoutedEvent.Register<ToolTip, RoutedEventArgs>("ToolTipClosing", RoutingStrategies.Direct);
+    
         private Popup? _popup;
         private Action<IPopupHost?>? _popupHostChangedHandler;
         private CompositeDisposable? _subscriptions;
@@ -92,9 +115,6 @@ static ToolTip()
             IsOpenProperty.Changed.Subscribe(IsOpenChanged);
         }
 
-        internal Control? AdornedControl { get; private set; }
-        internal event EventHandler? Closed;
-
         /// <summary>
         /// Gets the value of the ToolTip.Tip attached property.
         /// </summary>
@@ -275,6 +295,38 @@ public static bool GetServiceEnabled(Control element) =>
         public static void SetServiceEnabled(Control element, bool value) => 
             element.SetValue(ServiceEnabledProperty, value);
 
+        /// <summary>
+        /// Adds a handler for the <see cref="ToolTipOpeningEvent"/> attached event.
+        /// </summary>
+        /// <param name="element"><see cref="Control"/> that listens to this event.</param>
+        /// <param name="handler">Event Handler to be added.</param>
+        public static void AddToolTipOpeningHandler(Control element, EventHandler<CancelRoutedEventArgs> handler) =>
+            element.AddHandler(ToolTipOpeningEvent, handler);
+
+        /// <summary>
+        /// Removes a handler for the <see cref="ToolTipOpeningEvent"/> attached event.
+        /// </summary>
+        /// <param name="element"><see cref="Control"/> that listens to this event.</param>
+        /// <param name="handler">Event Handler to be removed.</param>
+        public static void RemoveToolTipOpeningHandler(Control element, EventHandler<CancelRoutedEventArgs> handler) =>
+            element.RemoveHandler(ToolTipOpeningEvent, handler);
+
+        /// <summary>
+        /// Adds a handler for the <see cref="ToolTipClosingEvent"/> attached event.
+        /// </summary>
+        /// <param name="element"><see cref="Control"/> that listens to this event.</param>
+        /// <param name="handler">Event Handler to be removed.</param>
+        public static void AddToolTipClosingHandler(Control element, EventHandler<RoutedEventArgs> handler) =>
+            element.AddHandler(ToolTipClosingEvent, handler);
+
+        /// <summary>
+        /// Removes a handler for the <see cref="ToolTipClosingEvent"/> attached event.
+        /// </summary>
+        /// <param name="element"><see cref="Control"/> that listens to this event.</param>
+        /// <param name="handler">Event Handler to be removed.</param>
+        public static void RemoveToolTipClosingHandler(Control element, EventHandler<RoutedEventArgs> handler) =>
+            element.RemoveHandler(ToolTipClosingEvent, handler);
+
         private static void IsOpenChanged(AvaloniaPropertyChangedEventArgs e)
         {
             var control = (Control)e.Sender;
@@ -282,8 +334,20 @@ private static void IsOpenChanged(AvaloniaPropertyChangedEventArgs e)
 
             if (newValue)
             {
+                var args = new CancelRoutedEventArgs(ToolTipOpeningEvent);
+                control.RaiseEvent(args);
+                if (args.Cancel)
+                {
+                    control.SetCurrentValue(IsOpenProperty, false);
+                    return;
+                }
+
                 var tip = GetTip(control);
-                if (tip == null) return;
+                if (tip == null)
+                {
+                    control.SetCurrentValue(IsOpenProperty, false);
+                    return;
+                }
 
                 var toolTip = control.GetValue(ToolTipProperty);
                 if (toolTip == null || (tip != toolTip && tip != toolTip.Content))
@@ -292,25 +356,23 @@ private static void IsOpenChanged(AvaloniaPropertyChangedEventArgs e)
 
                     toolTip = tip as ToolTip ?? new ToolTip { Content = tip };
                     control.SetValue(ToolTipProperty, toolTip);
-                    toolTip.SetValue(ThemeVariant.RequestedThemeVariantProperty, control.ActualThemeVariant);
                 }
 
                 toolTip.AdornedControl = control;
                 toolTip.Open(control);
-                toolTip?.UpdatePseudoClasses(newValue);
             }
             else if (control.GetValue(ToolTipProperty) is { } toolTip)
             {
                 toolTip.AdornedControl = null;
                 toolTip.Close();
-                toolTip?.UpdatePseudoClasses(newValue);
             }
         }
 
-        IPopupHost? IPopupHostProvider.PopupHost => _popup?.Host;
-
+        internal Control? AdornedControl { get; private set; }
+        internal event EventHandler? Closed;
         internal IPopupHost? PopupHost => _popup?.Host;
 
+        IPopupHost? IPopupHostProvider.PopupHost => _popup?.Host;
         event Action<IPopupHost?>? IPopupHostProvider.PopupHostChanged 
         { 
             add => _popupHostChangedHandler += value; 
@@ -346,6 +408,13 @@ private void Open(Control control)
 
         private void Close()
         {
+            if (AdornedControl is { } adornedControl
+                && GetIsOpen(adornedControl))
+            {
+                var args = new RoutedEventArgs(ToolTipClosingEvent);
+                adornedControl.RaiseEvent(args);
+            }
+
             _subscriptions?.Dispose();
 
             if (_popup is not null)
@@ -366,12 +435,14 @@ private void OnPopupClosed(object? sender, EventArgs e)
             }
 
             _popupHostChangedHandler?.Invoke(null);
+            UpdatePseudoClasses(false);
             Closed?.Invoke(this, EventArgs.Empty);
         }
 
         private void OnPopupOpened(object? sender, EventArgs e)
         {
             _popupHostChangedHandler?.Invoke(((Popup)sender!).Host);
+            UpdatePseudoClasses(true);
         }
 
         private void UpdatePseudoClasses(bool newValue)
diff --git a/src/Avalonia.Controls/ToolTipService.cs b/src/Avalonia.Controls/ToolTipService.cs
index c3231fcde93..d01114be6fb 100644
--- a/src/Avalonia.Controls/ToolTipService.cs
+++ b/src/Avalonia.Controls/ToolTipService.cs
@@ -221,7 +221,8 @@ private void Open(Control control)
             {
                 ToolTip.SetIsOpen(control, true);
 
-                if (control.GetValue(ToolTip.ToolTipProperty) is { } tooltip)
+                // Value can be coerced back to false, need to double check.
+                if (ToolTip.GetIsOpen(control) && control.GetValue(ToolTip.ToolTipProperty) is { } tooltip)
                 {
                     tooltip.Closed += ToolTipClosed;
                     tooltip.PointerExited += ToolTipPointerExited;


Test patch:
diff --git a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs
index 8dafef7e182..6199a208720 100644
--- a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs
@@ -368,6 +368,87 @@ public void New_ToolTip_Replaces_Other_ToolTip_Immediately()
             Assert.False(ToolTip.GetIsOpen(other));
         }
 
+        [Fact]
+        public void ToolTip_Events_Order_Is_Defined()
+        {
+            using var app = UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow));
+
+            var tip = new ToolTip() { Content = "Tip" };
+            var target = new Decorator()
+            {
+                [ToolTip.TipProperty] = tip,
+                [ToolTip.ShowDelayProperty] = 0
+            };
+
+            var eventsOrder = new List<(string eventName, object sender, object source)>();
+
+            ToolTip.AddToolTipOpeningHandler(target,
+                (sender, args) => eventsOrder.Add(("Opening", sender, args.Source)));
+            ToolTip.AddToolTipClosingHandler(target,
+                (sender, args) => eventsOrder.Add(("Closing", sender, args.Source)));
+
+            SetupWindowAndActivateToolTip(target);
+
+            Assert.True(ToolTip.GetIsOpen(target));
+
+            target[ToolTip.TipProperty] = null;
+
+            Assert.False(ToolTip.GetIsOpen(target));
+
+            Assert.Equal(
+                new[]
+                {
+                    ("Opening", (object)target, (object)target),
+                    ("Closing", target, target)
+                },
+                eventsOrder);
+        }
+        
+        [Fact]
+        public void ToolTip_Is_Not_Opened_If_Opening_Event_Handled()
+        {
+            using var app = UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow));
+
+            var tip = new ToolTip() { Content = "Tip" };
+            var target = new Decorator()
+            {
+                [ToolTip.TipProperty] = tip,
+                [ToolTip.ShowDelayProperty] = 0
+            };
+
+            ToolTip.AddToolTipOpeningHandler(target,
+                (sender, args) => args.Cancel = true);
+
+            SetupWindowAndActivateToolTip(target);
+
+            Assert.False(ToolTip.GetIsOpen(target));
+        }
+
+        [Fact]
+        public void ToolTip_Can_Be_Replaced_On_The_Fly_Via_Opening_Event()
+        {
+            using var app = UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow));
+
+            var tip1 = new ToolTip() { Content = "Hi" };
+            var tip2 = new ToolTip() { Content = "Bye" };
+            var target = new Decorator()
+            {
+                [ToolTip.TipProperty] = tip1,
+                [ToolTip.ShowDelayProperty] = 0
+            };
+
+            ToolTip.AddToolTipOpeningHandler(target,
+                (sender, args) => target[ToolTip.TipProperty] = tip2);
+
+            SetupWindowAndActivateToolTip(target);
+
+            Assert.True(ToolTip.GetIsOpen(target));
+
+            target[ToolTip.TipProperty] = null;
+
+            Assert.False(ToolTip.GetIsOpen(target));
+		}
+
         [Fact]
         public void Should_Close_When_Pointer_Leaves_Window()
         {