From fea03ca9363f7eb90dd27a2b744fb5c29ff015ee Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Thu, 28 May 2026 09:36:24 +0100 Subject: [PATCH] Make cursor respect the captured element (#21421) * Add failing test for cursor when pointer changes * Introduce PresentationSource.CursorElement * Update cursor on capture change * Set correct cursor when capture is released * Recompute pointer-over element immediately when capture changes --- src/Avalonia.Base/Input/IInputRoot.cs | 7 ++ src/Avalonia.Base/Input/Pointer.cs | 14 ++- .../Input/PointerOverPreProcessor.cs | 8 +- .../PresentationSource.Cursor.cs | 25 ++-- .../PresentationSource/PresentationSource.cs | 4 +- .../PresentationSourceTests.cs | 115 +++++++++++++++++- tests/Avalonia.RenderTests/TestRenderRoot.cs | 7 +- tests/Avalonia.UnitTests/TestRoot.cs | 5 + 8 files changed, 165 insertions(+), 20 deletions(-) diff --git a/src/Avalonia.Base/Input/IInputRoot.cs b/src/Avalonia.Base/Input/IInputRoot.cs index 13b7bae813..3445db69ff 100644 --- a/src/Avalonia.Base/Input/IInputRoot.cs +++ b/src/Avalonia.Base/Input/IInputRoot.cs @@ -22,6 +22,8 @@ namespace Avalonia.Input /// Gets or sets the input element that the pointer is currently over. /// internal IInputElement? PointerOverElement { get; set; } + + internal IInputElement? CursorElement { get; set; } internal ITextInputMethodImpl? InputMethod { get; } @@ -45,5 +47,10 @@ namespace Avalonia.Input /// Any other non- value indicates a specific non-client role (titlebar, resize grip, etc.). /// internal WindowDecorationsElementRole? HitTestChromeElement(Point point) => null; + + /// + /// Ask for the pointer-over element to be refreshed (usually after a capture change). + /// + internal void PointerOverInvalidated(); } } diff --git a/src/Avalonia.Base/Input/Pointer.cs b/src/Avalonia.Base/Input/Pointer.cs index cb0416f326..9b751baf12 100644 --- a/src/Avalonia.Base/Input/Pointer.cs +++ b/src/Avalonia.Base/Input/Pointer.cs @@ -102,10 +102,22 @@ namespace Avalonia.Input if (Captured != null) CaptureGestureRecognizer(null); - if(Captured == null && CapturedGestureRecognizer == null) + if (Captured == null && CapturedGestureRecognizer == null) { IsGestureRecognitionSkipped = false; } + + // Update the pointer-over + cursor immediately following the capture change + if (Type != PointerType.Touch) + { + var oldInputRoot = oldVisual?.PresentationSource?.InputRoot; + var newInputRoot = newVisual?.PresentationSource?.InputRoot; + + oldInputRoot?.PointerOverInvalidated(); + + if (oldInputRoot != newInputRoot) + newInputRoot?.PointerOverInvalidated(); + } } static IInputElement? GetNextCapture(Visual? parent) diff --git a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs index beb171467d..f5c9239ca3 100644 --- a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs +++ b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs @@ -138,11 +138,9 @@ namespace Avalonia.Input // Do not pass rootVisual, when we have unknown position, // so GetPosition won't return invalid values. -#pragma warning disable CS0618 var e = new PointerEventArgs(InputElement.PointerExitedEvent, element, pointer, position.HasValue ? root.RootElement : null, position.HasValue ? position.Value : default, timestamp, properties, inputModifiers); -#pragma warning restore CS0618 if (element is Visual v && !v.IsAttachedToVisualTree) { @@ -161,6 +159,8 @@ namespace Avalonia.Input } root.PointerOverElement = null; + root.CursorElement = pointer.Captured; + _lastActivePointerDevice = null; _currentPointer = null; } @@ -228,10 +228,9 @@ namespace Avalonia.Input el = root.PointerOverElement; -#pragma warning disable CS0618 var e = new PointerEventArgs(InputElement.PointerExitedEvent, el, pointer, root.RootElement, position, timestamp, properties, inputModifiers); -#pragma warning restore CS0618 + if (el is Visual v && branch != null && !v.IsAttachedToVisualTree) { ClearChildrenPointerOver(e, branch, false); @@ -246,6 +245,7 @@ namespace Avalonia.Input } el = root.PointerOverElement = element; + root.CursorElement = pointer.Captured ?? element; e.RoutedEvent = InputElement.PointerEnteredEvent; diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.Cursor.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.Cursor.cs index 6acc904626..79ae7025ee 100644 --- a/src/Avalonia.Controls/PresentationSource/PresentationSource.Cursor.cs +++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.Cursor.cs @@ -1,3 +1,4 @@ +using System; using Avalonia.Input; namespace Avalonia.Controls; @@ -14,7 +15,7 @@ internal partial class PresentationSource _cursor = cursor; UpdateCursor(); } - + /// /// This should only be used by InProcessDragSource /// @@ -23,24 +24,32 @@ internal partial class PresentationSource _cursorOverride = cursor; UpdateCursor(); } - - IInputElement? IInputRoot.PointerOverElement + + IInputElement? IInputRoot.PointerOverElement { get; set; } + + IInputElement? IInputRoot.CursorElement { - get => field; + get; set { + if (field == value) + return; + if (field is AvaloniaObject old) - old.PropertyChanged -= PointerOverElement_PropertyChanged; + old.PropertyChanged -= CursorElement_PropertyChanged; field = value; if (field is AvaloniaObject @new) - @new.PropertyChanged += PointerOverElement_PropertyChanged; + @new.PropertyChanged += CursorElement_PropertyChanged; SetCursor(value?.Cursor); } } - private void PointerOverElement_PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + void IInputRoot.PointerOverInvalidated() + => _pointerOverPreProcessor?.SceneInvalidated(new Rect(new Point(0, 0), Size.Infinity)); + + private void CursorElement_PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { if (e.Property == InputElement.CursorProperty) SetCursor((sender as IInputElement)?.Cursor); } -} \ No newline at end of file +} diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs index 37db4c4a06..c2d5516667 100644 --- a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs +++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs @@ -88,8 +88,8 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi PlatformImpl = null; _pointerOverPreProcessor?.OnCompleted(); _pointerOverPreProcessorSubscription?.Dispose(); - if (((IInputRoot)this).PointerOverElement is AvaloniaObject pointerOverElement) - pointerOverElement.PropertyChanged -= PointerOverElement_PropertyChanged; + if (((IInputRoot)this).CursorElement is AvaloniaObject cursorElement) + cursorElement.PropertyChanged -= CursorElement_PropertyChanged; } /// diff --git a/tests/Avalonia.Controls.UnitTests/PresentationSourceTests.cs b/tests/Avalonia.Controls.UnitTests/PresentationSourceTests.cs index 5e96e31f5f..97892e9b4c 100644 --- a/tests/Avalonia.Controls.UnitTests/PresentationSourceTests.cs +++ b/tests/Avalonia.Controls.UnitTests/PresentationSourceTests.cs @@ -3,12 +3,16 @@ using Avalonia.Controls.Chrome; using Avalonia.Controls.Platform; using Avalonia.Controls.Templates; using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Layout; using Avalonia.Markup.Xaml.Templates; using Avalonia.Media; +using Avalonia.Platform; using Avalonia.Styling; using Avalonia.Threading; using Avalonia.UnitTests; using Avalonia.VisualTree; +using Moq; using Xunit; namespace Avalonia.Controls.UnitTests; @@ -112,10 +116,7 @@ public sealed class PresentationSourceTests : ScopedTestBase }; window.Show(); - - Dispatcher.CurrentDispatcher.RunJobs(null, TestContext.Current.CancellationToken); - renderTimer.TriggerTick(); - Dispatcher.CurrentDispatcher.RunJobs(null, TestContext.Current.CancellationToken); + Render(renderTimer); var hitTestPoint = new Point(width / 2, height / 2); @@ -144,4 +145,110 @@ public sealed class PresentationSourceTests : ScopedTestBase } }; } + + [Fact] + public void Cursor_Should_Follow_Captured_Element() + { + using var app = UnitTestApplication.Start( + TestServices.StyledWindow.With(inputManager: new InputManager())); + + var captured = new Border + { + Background = Brushes.Red, + Width = 20, + Cursor = new Cursor(StandardCursorType.SizeWestEast) + }; + + var other1 = new Border + { + Background = Brushes.Blue, + Width = 100, + Cursor = new Cursor(StandardCursorType.Ibeam) + }; + + var other2 = new Border + { + Background = Brushes.Blue, + Width = 100, + Cursor = new Cursor(StandardCursorType.Cross) + }; + + ICursorImpl? currentCursor = null; + var renderTimer = new CompositorTestServices.ManualRenderTimer(); + var compositor = RendererMocks.CreateDummyCompositor(renderTimer); + var windowImpl = MockWindowingPlatform.CreateWindowMock(200, 100, compositor); + windowImpl + .Setup(w => w.SetCursor(It.IsAny())) + .Callback(cursor => currentCursor = cursor); + + var window = new Window(windowImpl.Object) + { + Content = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { captured, other1, other2 } + } + }; + + IPointer? pointer = null; + captured.PointerPressed += (_, e) => + { + e.Pointer.Capture(captured); + pointer = e.Pointer; + }; + + window.Show(); + Render(renderTimer); + + var mouse = new MouseDevice(); + var root = window.PresentationSource; + + // Press inside the first border: the pointer becomes captured and the cursor is its own. + windowImpl.Object.Input!(new RawPointerEventArgs( + mouse, 1, root, RawPointerEventType.LeftButtonDown, new Point(10, 50), + RawInputModifiers.LeftMouseButton)); + + Assert.NotNull(pointer); + Assert.Same(captured, pointer.Captured); + Assert.Same(captured.Cursor!.PlatformImpl, currentCursor); + var cursorWhileCaptured = currentCursor; + + // Drag over the other border. With the pointer still captured by the first border, + // PresentationSource.PointerOverElement changes (it becomes null), but the displayed + // cursor must keep coming from the captured element rather than following the new + // PointerOverElement. + windowImpl.Object.Input!(new RawPointerEventArgs( + mouse, 2, root, RawPointerEventType.Move, new Point(70, 50), + RawInputModifiers.LeftMouseButton)); + + Assert.Same(captured, pointer.Captured); + Assert.Same(cursorWhileCaptured, currentCursor); + + // Changing the captured element's cursor should still work. + var newCursor = new Cursor(StandardCursorType.Hand); + captured.Cursor = newCursor; + Assert.Same(newCursor.PlatformImpl, currentCursor); + + // Changing the capture explicitly should update the cursor. + pointer.Capture(other1); + Assert.Same(other1.Cursor!.PlatformImpl, currentCursor); + + // Move the pointer to an unrelated element and release the capture: + // it should reset the cursor to match that new element. + ((IInputRoot)root).PointerOverElement = other2; + + windowImpl.Object.Input!(new RawPointerEventArgs( + mouse, 2, root, RawPointerEventType.Move, new Point(120, 50), + RawInputModifiers.LeftMouseButton)); + + pointer.Capture(null); + Assert.Same(other2.Cursor!.PlatformImpl, currentCursor); + } + + private static void Render(CompositorTestServices.ManualRenderTimer renderTimer) + { + Dispatcher.CurrentDispatcher.RunJobs(null, TestContext.Current.CancellationToken); + renderTimer.TriggerTick(); + Dispatcher.CurrentDispatcher.RunJobs(null, TestContext.Current.CancellationToken); + } } diff --git a/tests/Avalonia.RenderTests/TestRenderRoot.cs b/tests/Avalonia.RenderTests/TestRenderRoot.cs index 4bbe560fb6..f4a0815e8a 100644 --- a/tests/Avalonia.RenderTests/TestRenderRoot.cs +++ b/tests/Avalonia.RenderTests/TestRenderRoot.cs @@ -70,10 +70,15 @@ namespace Avalonia.Skia.RenderTests public Point? PointToClient(PixelPoint point) => point.ToPoint(RenderScaling); public PixelPoint? PointToScreen(Point point) => PixelPoint.FromPoint(point, RenderScaling); - + + public void PointerOverInvalidated() + { + } + public IFocusManager? FocusManager { get; } public IPlatformSettings? PlatformSettings { get; } public IInputElement? PointerOverElement { get; set; } + public IInputElement? CursorElement { get; set; } public ITextInputMethodImpl? InputMethod { get; } public InputElement RootElement => this; public InputElement FocusRoot => this; diff --git a/tests/Avalonia.UnitTests/TestRoot.cs b/tests/Avalonia.UnitTests/TestRoot.cs index 5a34d301df..18edafed0a 100644 --- a/tests/Avalonia.UnitTests/TestRoot.cs +++ b/tests/Avalonia.UnitTests/TestRoot.cs @@ -78,6 +78,7 @@ namespace Avalonia.UnitTests public IPlatformSettings? PlatformSettings => AvaloniaLocator.Current.GetService(); public IInputElement? PointerOverElement { get; set; } + public IInputElement? CursorElement { get; set; } public ITextInputMethodImpl? InputMethod { get; } public InputElement RootElement => this; public InputElement FocusRoot => this; @@ -142,5 +143,9 @@ namespace Avalonia.UnitTests { return base.MeasureOverride(ClientSize); } + + public void PointerOverInvalidated() + { + } } }