From a8738963cd28a6490b5fe740b341758a5b518f52 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Tue, 25 Aug 2026 14:26:44 +0000 Subject: [PATCH] Fix pointer capture cancellation on Win32 (#22054) * Fix pointer capture cancellation on Win32 A cancelled pen or mouse interaction was reported as an ordinary leave, and nothing released the capture that came with it, so the captured element never saw PointerCaptureLost. Win32 constants: POINTER_FLAG_CONFIDENCE and POINTER_FLAG_CANCELED were declared an order of magnitude too small - the SDK defines them as 0x4000 and 0x8000 - so the cancellation check in GetEventType could never match. Ctrl+Alt+Del or Win+L mid stroke therefore arrived as a normal button up, and the app committed an interaction the user had aborted. Win32 handling: WM_POINTERCAPTURECHANGED and POINTER_FLAG_CANCELED now map to CancelCapture for non touch pointers, matching what the legacy WM_CAPTURECHANGED path has always done. PenDevice: handle CancelCapture by releasing the capture while keeping the pointer alive, since the pen is still in range and a leave arrives separately. Pointer hardening: CaptureLost ends every capture the pointer holds, on the element and on a gesture recognizer, and the devices release through it instead of repeating the steps. Capturing to a recognizer clears Captured, so the null check in the platform path used to skip the whole release while a scroll, pull or swipe gesture owned the pointer, and a touch up never cleared the recognizer at all. Dispose releases as well rather than leaving that to every caller, a disposed pointer ignores further capture calls and asserts when a capturer is passed, and devices drop a pointer from their map before disposing it. Co-Authored-By: Claude Opus 5 (1M context) * Keep the explicit capture source in MouseTestHelper The helper captures through Pointer.Capture(target), which is an explicit capture, and used to release with Capture(null) - explicit as well. Releasing as implicit changed the source that capture changing handlers observe. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- src/Avalonia.Base/Input/MouseDevice.cs | 4 +- src/Avalonia.Base/Input/PenDevice.cs | 9 ++-- src/Avalonia.Base/Input/Pointer.cs | 53 +++++++++++++++++-- src/Avalonia.Base/Input/TouchDevice.cs | 12 ++--- .../Interop/UnmanagedMethods.cs | 4 +- .../Avalonia.Win32/WindowImpl.AppWndProc.cs | 14 ++++- tests/Avalonia.UnitTests/MouseTestHelper.cs | 4 +- tests/Avalonia.UnitTests/TouchTestHelper.cs | 4 +- 8 files changed, 75 insertions(+), 29 deletions(-) diff --git a/src/Avalonia.Base/Input/MouseDevice.cs b/src/Avalonia.Base/Input/MouseDevice.cs index 6bb5733a19..340a006ed1 100644 --- a/src/Avalonia.Base/Input/MouseDevice.cs +++ b/src/Avalonia.Base/Input/MouseDevice.cs @@ -220,9 +220,7 @@ namespace Avalonia.Input } finally { - _pointer.Capture(null, CaptureSource.Implicit); - _pointer.CaptureGestureRecognizer(null); - _pointer.IsGestureRecognitionSkipped = false; + _pointer.CaptureLost(CaptureSource.Implicit); _lastMouseDownButton = default; } return e.Handled; diff --git a/src/Avalonia.Base/Input/PenDevice.cs b/src/Avalonia.Base/Input/PenDevice.cs index f7211ea351..b7dba977d5 100644 --- a/src/Avalonia.Base/Input/PenDevice.cs +++ b/src/Avalonia.Base/Input/PenDevice.cs @@ -63,6 +63,9 @@ namespace Avalonia.Input case RawPointerEventType.LeaveWindow: shouldReleasePointer = true; break; + case RawPointerEventType.CancelCapture: + pointer.PlatformCaptureLost(); + break; case RawPointerEventType.LeftButtonDown: case RawPointerEventType.RightButtonDown: case RawPointerEventType.MiddleButtonDown: @@ -90,8 +93,8 @@ namespace Avalonia.Input { if (shouldReleasePointer) { - pointer.Dispose(); _pointers.Remove(e.RawPointerId); + pointer.Dispose(); } } } @@ -173,9 +176,7 @@ namespace Avalonia.Input } finally { - pointer.Capture(null, CaptureSource.Implicit); - pointer.CaptureGestureRecognizer(null); - pointer.IsGestureRecognitionSkipped = false; + pointer.CaptureLost(CaptureSource.Implicit); _lastMouseDownButton = default; } diff --git a/src/Avalonia.Base/Input/Pointer.cs b/src/Avalonia.Base/Input/Pointer.cs index 9b751baf12..47aab499ce 100644 --- a/src/Avalonia.Base/Input/Pointer.cs +++ b/src/Avalonia.Base/Input/Pointer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Avalonia.Input.GestureRecognizers; using Avalonia.VisualTree; @@ -18,6 +19,8 @@ namespace Avalonia.Input private static int s_NextFreePointerId = 1000; public static int GetNextFreeId() => s_NextFreePointerId++; + private bool _disposed; + public Pointer(int id, PointerType type, bool isPrimary) { Id = id; @@ -40,10 +43,25 @@ namespace Avalonia.Input } - internal void PlatformCaptureLost() + internal void PlatformCaptureLost() => CaptureLost(CaptureSource.Platform); + + /// + /// Ends every capture the pointer holds, on the element and on a gesture recognizer. + /// + internal void CaptureLost(CaptureSource source) + { + if (_disposed) + return; + + CaptureLostCore(source); + } + + private void CaptureLostCore(CaptureSource source) { if (Captured != null) - Capture(null, CaptureSource.Platform); + CaptureCore(null, source); + CaptureGestureRecognizerCore(null); + IsGestureRecognitionSkipped = false; } public void Capture(IInputElement? control) @@ -52,6 +70,17 @@ namespace Avalonia.Input } internal void Capture(IInputElement? control, CaptureSource source) + { + if (_disposed) + { + Debug.Assert(control is null, "Capturing a pointer that no longer exists."); + return; + } + + CaptureCore(control, source); + } + + private void CaptureCore(IInputElement? control, CaptureSource source) { var oldCapture = Captured; var oldSource = CaptureSource; @@ -148,7 +177,14 @@ namespace Avalonia.Input public void Dispose() { - // callers are responsible for calling Capture(null, source) with an appropriate source + if (_disposed) + return; + + // Mark the pointer gone first, so a capture lost handler can't capture it again. + // It no longer exists, so the platform is the only source the release can come from. + _disposed = true; + + CaptureLostCore(CaptureSource.Platform); } /// @@ -156,6 +192,17 @@ namespace Avalonia.Input /// /// The gesture recognizer. internal void CaptureGestureRecognizer(GestureRecognizer? gestureRecognizer) + { + if (_disposed) + { + Debug.Assert(gestureRecognizer is null, "Capturing a pointer that no longer exists to a gesture recognizer."); + return; + } + + CaptureGestureRecognizerCore(gestureRecognizer); + } + + private void CaptureGestureRecognizerCore(GestureRecognizer? gestureRecognizer) { if (CapturedGestureRecognizer != gestureRecognizer) { diff --git a/src/Avalonia.Base/Input/TouchDevice.cs b/src/Avalonia.Base/Input/TouchDevice.cs index 6429d9c0fc..dac12dedd8 100644 --- a/src/Avalonia.Base/Input/TouchDevice.cs +++ b/src/Avalonia.Base/Input/TouchDevice.cs @@ -109,20 +109,14 @@ namespace Avalonia.Input { target.RaiseEvent(e); } - pointer?.Capture(null, CaptureSource.Implicit); + pointer?.CaptureLost(CaptureSource.Implicit); } } if (args.Type == RawPointerEventType.TouchCancel) { _pointers.Remove(args.RawPointerId); - using (pointer) - { - pointer?.Capture(null, CaptureSource.Platform); - pointer?.CaptureGestureRecognizer(null); - if (pointer != null) - pointer.IsGestureRecognitionSkipped = false; - } + pointer?.Dispose(); } if (args.Type == RawPointerEventType.TouchUpdate) @@ -165,7 +159,7 @@ namespace Avalonia.Input internal void PlatformCaptureLost() { foreach (var pointer in _pointers.Values) - pointer.Capture(null); + pointer.PlatformCaptureLost(); } } } diff --git a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs index 18aa162608..0d3ed14bdc 100644 --- a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs +++ b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs @@ -987,8 +987,8 @@ namespace Avalonia.Win32.Interop POINTER_FLAG_FOURTHBUTTON = 0x00000080, POINTER_FLAG_FIFTHBUTTON = 0x00000100, POINTER_FLAG_PRIMARY = 0x00002000, - POINTER_FLAG_CONFIDENCE = 0x00000400, - POINTER_FLAG_CANCELED = 0x00000800, + POINTER_FLAG_CONFIDENCE = 0x00004000, + POINTER_FLAG_CANCELED = 0x00008000, POINTER_FLAG_DOWN = 0x00010000, POINTER_FLAG_UPDATE = 0x00020000, POINTER_FLAG_UP = 0x00040000, diff --git a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs index 711481d3d5..a8dbe219d2 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs @@ -583,7 +583,6 @@ namespace Avalonia.Win32 break; } case WindowsMessage.WM_POINTERLEAVE: - case WindowsMessage.WM_POINTERCAPTURECHANGED: { if (!_wmPointerEnabled) { @@ -594,6 +593,17 @@ namespace Avalonia.Win32 e = CreatePointerArgs(device, timestamp, eventType, point, modifiers, info.pointerId); break; } + case WindowsMessage.WM_POINTERCAPTURECHANGED: + { + if (!_wmPointerEnabled) + { + break; + } + GetDevicePointerInfo(wParam, out var device, out var info, out var point, out var modifiers, ref timestamp); + var eventType = device is TouchDevice ? RawPointerEventType.TouchCancel : RawPointerEventType.CancelCapture; + e = CreatePointerArgs(device, timestamp, eventType, point, modifiers, info.pointerId); + break; + } case WindowsMessage.WM_POINTERWHEEL: case WindowsMessage.WM_POINTERHWHEEL: { @@ -1300,7 +1310,7 @@ namespace Avalonia.Win32 var isTouch = info.pointerType == PointerInputType.PT_TOUCH; if (info.pointerFlags.HasFlag(PointerFlags.POINTER_FLAG_CANCELED)) { - return isTouch ? RawPointerEventType.TouchCancel : RawPointerEventType.LeaveWindow; + return isTouch ? RawPointerEventType.TouchCancel : RawPointerEventType.CancelCapture; } var eventType = ToEventType(info.ButtonChangeType, isTouch); diff --git a/tests/Avalonia.UnitTests/MouseTestHelper.cs b/tests/Avalonia.UnitTests/MouseTestHelper.cs index 082ae718f3..b8eeb2aa91 100644 --- a/tests/Avalonia.UnitTests/MouseTestHelper.cs +++ b/tests/Avalonia.UnitTests/MouseTestHelper.cs @@ -101,9 +101,7 @@ namespace Avalonia.UnitTests else target.RaiseEvent(e); - _pointer.Capture(null); - _pointer.CaptureGestureRecognizer(null); - _pointer.IsGestureRecognitionSkipped = false; + _pointer.CaptureLost(CaptureSource.Explicit); } else Move(target, source, position ?? default); diff --git a/tests/Avalonia.UnitTests/TouchTestHelper.cs b/tests/Avalonia.UnitTests/TouchTestHelper.cs index 7a13425997..73909cdd79 100644 --- a/tests/Avalonia.UnitTests/TouchTestHelper.cs +++ b/tests/Avalonia.UnitTests/TouchTestHelper.cs @@ -64,9 +64,7 @@ namespace Avalonia.UnitTests public void Cancel() { - _pointer.Capture(null); - _pointer.CaptureGestureRecognizer(null); - _pointer.IsGestureRecognitionSkipped = false; + _pointer.CaptureLost(CaptureSource.Platform); } } }