diff --git a/samples/ControlCatalog/Pages/ToolTipPage.xaml b/samples/ControlCatalog/Pages/ToolTipPage.xaml index 013c176bca..fa40bc1a0f 100644 --- a/samples/ControlCatalog/Pages/ToolTipPage.xaml +++ b/samples/ControlCatalog/Pages/ToolTipPage.xaml @@ -5,10 +5,14 @@ Spacing="4"> A control which pops up a hint when a control is hovered - + ToolTip bottom placement Moving offset + + + + + Nested ToolTips + diff --git a/src/Avalonia.Base/Input/InputExtensions.cs b/src/Avalonia.Base/Input/InputExtensions.cs index 73dcef9e95..a5107588c9 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 s_hitTestDelegate = IsHitTestVisible; + private static readonly Func s_hitTestEnabledOnlyDelegate = IsHitTestVisible_EnabledOnly; /// /// Returns the active input elements at a point on an . /// /// The element to test. /// The point on . + /// Whether to only return elements for which is true. /// /// The active input elements found at the point, ordered topmost first. /// - public static IEnumerable GetInputElementsAt(this IInputElement element, Point p) + public static IEnumerable 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() ?? + return (element as Visual)?.GetVisualsAt(p, enabledElementsOnly ? s_hitTestEnabledOnlyDelegate : s_hitTestDelegate).Cast() ?? Enumerable.Empty(); } + + /// + public static IEnumerable GetInputElementsAt(this IInputElement element, Point p) => GetInputElementsAt(element, p, true); /// /// Returns the topmost active input element at a point on an . /// /// The element to test. /// The point on . + /// Whether to only return elements for which is true. /// The topmost at the specified position. - 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; } + /// + public static IInputElement? InputHitTest(this IInputElement element, Point p) => InputHitTest(element, p, true); + /// /// Returns the topmost active input element at a point on an . /// @@ -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. /// + /// Whether to only return elements for which is true. /// The topmost at the specified position. public static IInputElement? InputHitTest( this IInputElement element, Point p, - Func filter) + Func 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; - } + /// + public static IInputElement? InputHitTest(this IInputElement element, Point p, Func 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 0cabf02566..b97036c7dd 100644 --- a/src/Avalonia.Base/Input/MouseDevice.cs +++ b/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; } } diff --git a/src/Avalonia.Base/Input/PenDevice.cs b/src/Avalonia.Base/Input/PenDevice.cs index 7637de05ac..128a49e13b 100644 --- a/src/Avalonia.Base/Input/PenDevice.cs +++ b/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; } } diff --git a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs index 347ba35a41..d5a716ba60 100644 --- a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs +++ b/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()), diff --git a/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs b/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs index 595f4c0c66..1a13d37112 100644 --- a/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs +++ b/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs @@ -123,7 +123,7 @@ namespace Avalonia.Input.Raw /// public Lazy?>? 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 04c444f441..026af11b05 100644 --- a/src/Avalonia.Base/Input/TouchDevice.cs +++ b/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(); diff --git a/src/Avalonia.Base/Rendering/IRenderer.cs b/src/Avalonia.Base/Rendering/IRenderer.cs index 227ff53897..bd5aff8e6b 100644 --- a/src/Avalonia.Base/Rendering/IRenderer.cs +++ b/src/Avalonia.Base/Rendering/IRenderer.cs @@ -87,7 +87,7 @@ namespace Avalonia.Rendering /// children will be excluded from the results. /// /// The visuals at the specified point, topmost first. - IEnumerable HitTest(Point p, Visual root, Func filter); + IEnumerable HitTest(Point p, Visual root, Func? filter); /// /// 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. /// /// The visual at the specified point, topmost first. - Visual? HitTestFirst(Point p, Visual root, Func filter); + Visual? HitTestFirst(Point p, Visual root, Func? filter); } } diff --git a/src/Avalonia.Controls/Application.cs b/src/Avalonia.Controls/Application.cs index 6b8ddc15ea..e48711efdb 100644 --- a/src/Avalonia.Controls/Application.cs +++ b/src/Avalonia.Controls/Application.cs @@ -268,6 +268,7 @@ namespace Avalonia .Bind().ToConstant(this) .Bind().ToConstant(focusManager) .Bind().ToConstant(InputManager) + .Bind< IToolTipService>().ToConstant(new ToolTipService(InputManager)) .Bind().ToTransient() .Bind().ToConstant(DragDropDevice.Instance); diff --git a/src/Avalonia.Controls/IToolTipService.cs b/src/Avalonia.Controls/IToolTipService.cs new file mode 100644 index 0000000000..3188434850 --- /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 7f9a155ea5..c4bf961afd 100644 --- a/src/Avalonia.Controls/ToolTip.cs +++ b/src/Avalonia.Controls/ToolTip.cs @@ -55,6 +55,18 @@ namespace Avalonia.Controls public static readonly AttachedProperty ShowDelayProperty = AvaloniaProperty.RegisterAttached("ShowDelay", 400); + /// + /// Defines the ToolTip.ShowOnDisabled property. + /// + public static readonly AttachedProperty ShowOnDisabledProperty = + AvaloniaProperty.RegisterAttached("ShowOnDisabled", defaultValue: false, inherits: true); + + /// + /// Defines the ToolTip.ServiceEnabled property. + /// + public static readonly AttachedProperty ServiceEnabledProperty = + AvaloniaProperty.RegisterAttached("ServiceEnabled", defaultValue: true, inherits: true); + /// /// Stores the current instance in the control. /// @@ -69,8 +81,6 @@ namespace Avalonia.Controls /// 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); } + /// + /// Gets whether a control will display a tooltip even if it disabled. + /// + /// The control to get the property from. + public static bool GetShowOnDisabled(Control element) => + element.GetValue(ShowOnDisabledProperty); + + /// + /// Sets whether a control will display a tooltip even if it disabled. + /// + /// The control to get the property from. + /// Whether the control is to display a tooltip even if it disabled. + public static void SetShowOnDisabled(Control element, bool value) => + element.SetValue(ShowOnDisabledProperty, value); + + /// + /// Gets whether showing and hiding of a control's tooltip will be automatically controlled by Avalonia. + /// + /// The control to get the property from. + public static bool GetServiceEnabled(Control element) => + element.GetValue(ServiceEnabledProperty); + + /// + /// Sets whether showing and hiding of a control's tooltip will be automatically controlled by Avalonia. + /// + /// The control to get the property from. + /// Whether the control is to display a tooltip even if it disabled. + 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 9e75026d79..77e314e52d 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 /// /// Handles interaction with controls. /// - 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)); + } - /// - /// called when the property changes on a control. - /// - /// The event args. - 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 args) + { + if (args.Sender == _tipControl && !ToolTip.GetServiceEnabled(_tipControl)) + { + StopTimer(); + } + } + + /// + /// called when the property changes on a control. + /// + /// The event args. + 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); } - /// - /// Called when the pointer enters a control with an attached tooltip. - /// - /// The event sender. - /// The event args. - 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); + } } } - /// - /// Called when the pointer leaves a control with an attached tooltip. - /// - /// The event sender. - /// The event args. - 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); } diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index ee8bd951d0..404964d374 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/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); } /// @@ -211,6 +214,7 @@ namespace Avalonia.Controls _accessKeyHandler = TryGetService(dependencyResolver); _inputManager = TryGetService(dependencyResolver); + _tooltipService = TryGetService(dependencyResolver); _keyboardNavigationHandler = TryGetService(dependencyResolver); _globalStyles = TryGetService(dependencyResolver); _applicationThemeHost = TryGetService(dependencyResolver); @@ -833,7 +837,9 @@ namespace Avalonia.Controls var (topLevel, e) = (ValueTuple)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 args) + { + if (args.GetNewValue() + && 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() diff --git a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs index 98a131752a..ddfc2b7b72 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 @@ 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 = @" - - - - -"; - 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("PART_target"); - var panel = window.Find("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(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(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(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 SetupWindowAndGetMouseEnterAction(Control windowContent, [CallerMemberName] string testName = null) + { + var windowImpl = MockWindowingPlatform.CreateWindowMock(); + var hitTesterMock = new Mock(); + + 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(); + + 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>())) + .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 18ccff75f0..18b1bb6461 100644 --- a/tests/Avalonia.UnitTests/UnitTestApplication.cs +++ b/tests/Avalonia.UnitTests/UnitTestApplication.cs @@ -53,6 +53,8 @@ namespace Avalonia.UnitTests Dispatcher.UIThread.RunJobs(); } + ((ToolTipService)AvaloniaLocator.Current.GetService())?.Dispose(); + scope.Dispose(); Dispatcher.ResetForUnitTests(); SynchronizationContext.SetSynchronizationContext(oldContext); @@ -67,6 +69,7 @@ namespace Avalonia.UnitTests .Bind().ToConstant(Services.GlobalClock) .BindToSelf(this) .Bind().ToConstant(Services.InputManager) + .Bind().ToConstant(Services.InputManager == null ? null : new ToolTipService(Services.InputManager)) .Bind().ToConstant(Services.KeyboardDevice?.Invoke()) .Bind().ToConstant(Services.MouseDevice?.Invoke()) .Bind().ToFunc(Services.KeyboardNavigation ?? (() => null))