Browse Source

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
pull/21452/head
Julien Lebosquain 4 months ago
committed by GitHub
parent
commit
fea03ca936
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      src/Avalonia.Base/Input/IInputRoot.cs
  2. 14
      src/Avalonia.Base/Input/Pointer.cs
  3. 8
      src/Avalonia.Base/Input/PointerOverPreProcessor.cs
  4. 25
      src/Avalonia.Controls/PresentationSource/PresentationSource.Cursor.cs
  5. 4
      src/Avalonia.Controls/PresentationSource/PresentationSource.cs
  6. 115
      tests/Avalonia.Controls.UnitTests/PresentationSourceTests.cs
  7. 7
      tests/Avalonia.RenderTests/TestRenderRoot.cs
  8. 5
      tests/Avalonia.UnitTests/TestRoot.cs

7
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.
/// </summary>
internal IInputElement? PointerOverElement { get; set; }
internal IInputElement? CursorElement { get; set; }
internal ITextInputMethodImpl? InputMethod { get; }
@ -45,5 +47,10 @@ namespace Avalonia.Input
/// Any other non-<see cref="WindowDecorationsElementRole.None"/> value indicates a specific non-client role (titlebar, resize grip, etc.).
/// </returns>
internal WindowDecorationsElementRole? HitTestChromeElement(Point point) => null;
/// <summary>
/// Ask for the pointer-over element to be refreshed (usually after a capture change).
/// </summary>
internal void PointerOverInvalidated();
}
}

14
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)

8
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;

25
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();
}
/// <summary>
/// This should only be used by InProcessDragSource
/// </summary>
@ -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);
}
}
}

4
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;
}
/// <summary>

115
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<ICursorImpl?>()))
.Callback<ICursorImpl?>(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);
}
}

7
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;

5
tests/Avalonia.UnitTests/TestRoot.cs

@ -78,6 +78,7 @@ namespace Avalonia.UnitTests
public IPlatformSettings? PlatformSettings => AvaloniaLocator.Current.GetService<IPlatformSettings>();
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()
{
}
}
}

Loading…
Cancel
Save