Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -375,5 +375,114 @@ public async Task When_RightClick_OriginalSource_Is_Correct()
Assert.IsTrue(innerButton.Equals(capturedOriginalSource) || VisualTreeUtils.FindVisualParentByType<Button>(capturedOriginalSource as DependencyObject) == innerButton,
"OriginalSource should be the inner button where right-click occurred");
}

[TestMethod]
[GitHubWorkItem("https://github.com/unoplatform/uno/issues/22229")]
public async Task When_Touch_LongPress_Opens_ContextFlyout_Without_Click()
{
var clickCount = 0;
var flyoutOpened = false;

var flyout = new MenuFlyout();
flyout.Items.Add(new MenuFlyoutItem { Text = "Item" });
flyout.Opened += (s, e) => flyoutOpened = true;

var button = new Button
{
Content = "Long press me",
Width = 160,
Height = 60,
ContextFlyout = flyout
};
button.Click += (s, e) => clickCount++;

await UITestHelper.Load(button);

try
{
var injector = InputInjector.TryCreate() ?? throw new InvalidOperationException("Failed to init InputInjector");
using var finger = injector.GetFinger();

var center = button.GetAbsoluteBounds().GetCenter();

// Press and hold without releasing. The holding gesture (800ms) opens the
// ContextFlyout for touch, which must also cancel the pending Click on release.
finger.Press(center);

for (var i = 0; i < 60 && !flyoutOpened; i++)
{
await Task.Delay(50);
await TestServices.WindowHelper.WaitForIdle();
}

Assert.IsTrue(flyoutOpened, "Long press should open the ContextFlyout");

finger.Release();
await TestServices.WindowHelper.WaitForIdle();

Assert.AreEqual(0, clickCount, "Long press must NOT click the button - it should behave like a right-tap (issue #22229)");
}
finally
{
flyout.Hide();
await TestServices.WindowHelper.WaitForIdle();
}
}

[TestMethod]
[GitHubWorkItem("https://github.com/unoplatform/uno/issues/22229")]
public async Task When_Touch_LongPress_ContextRequested_Handled_Does_Not_Click()
{
var clickCount = 0;
var contextRequestedCount = 0;

var button = new Button
{
Content = "Long press me",
Width = 160,
Height = 60
};

// Simulate a context menu being shown on long-press by handling ContextRequested,
// without a flyout that steals focus. On a real touch device the context menu does
// not clear the button's pressed state via focus, so the pending Click must instead
// be cancelled by releasing the pointer capture (issue #22229).
button.ContextRequested += (s, e) =>
{
contextRequestedCount++;
e.Handled = true;
};
button.Click += (s, e) => clickCount++;

await UITestHelper.Load(button);

try
{
var injector = InputInjector.TryCreate() ?? throw new InvalidOperationException("Failed to init InputInjector");
using var finger = injector.GetFinger();

var center = button.GetAbsoluteBounds().GetCenter();

finger.Press(center);

for (var i = 0; i < 60 && contextRequestedCount == 0; i++)
{
await Task.Delay(50);
await TestServices.WindowHelper.WaitForIdle();
}

Assert.AreEqual(1, contextRequestedCount, "Long press should raise ContextRequested");

finger.Release();
await TestServices.WindowHelper.WaitForIdle();

Assert.AreEqual(0, clickCount, "Long press must NOT click the button once a context menu was shown (issue #22229)");
}
finally
{
await TestServices.WindowHelper.WaitForIdle();
}
}

}
#endif
35 changes: 35 additions & 0 deletions src/Uno.UI/UI/Xaml/Internal/ContextMenuProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ internal partial class ContextMenuProcessor
private DispatcherTimer? _contextMenuTimer;
private Point _contextMenuOnHoldingTouchPoint = new(-1, -1);

// Uno specific: the touch pointer that triggered the holding gesture, so its capture can be
// released once the context menu is shown (see ReleaseContextMenuHoldingPointerCapture).
private uint _contextMenuOnHoldingPointerId;

public ContextMenuProcessor(ContentRoot contentRoot)
{
_contentRoot = contentRoot ?? throw new ArgumentNullException(nameof(contentRoot));
Expand Down Expand Up @@ -95,6 +99,32 @@ public void RaiseContextRequestedEvent(
if (args.Handled && isTouchInput)
{
_isContextMenuOnHolding = true;

// A context menu was shown in response to a touch press-and-hold. WinUI suppresses the
// pending Click/Tapped because opening the menu's popup steals the OS pointer capture
// (raising PointerCaptureLost on the pressing element). Uno's popups don't steal capture,
// so release it explicitly here, matching that behavior across all targets.
ReleaseContextMenuHoldingPointerCapture();
}
}

/// <summary>
/// Releases the explicit pointer capture held for the touch pointer that triggered the holding
/// gesture, raising PointerCaptureLost so the pressing element (e.g. a Button) clears its pressed
/// state and does not raise Click/Tapped on release (issue #22229).
/// </summary>
private void ReleaseContextMenuHoldingPointerCapture()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same minor style issue — (issue #22229) in the doc comment body. Drop it; issue context lives in the commit message and PR description, not in code prose.

Suggested change
private void ReleaseContextMenuHoldingPointerCapture()
/// Releases the explicit pointer capture held for the touch pointer that triggered the holding
/// gesture, raising PointerCaptureLost so the pressing element (e.g. a Button) clears its pressed
/// state and does not raise Click/Tapped on release.

{
if (PointerCapture.Any(out var captures))
{
foreach (var capture in captures)
{
if (capture.Pointer.PointerId == _contextMenuOnHoldingPointerId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When SetContextMenuOnHoldingPointer is never called (e.g. a code path that calls RaiseContextRequestedEvent with isTouchInput: true but skips SetContextMenuOnHoldingPointer), _contextMenuOnHoldingPointerId is 0. Iterating all captures looking for pointer ID 0 is harmless in practice (no real pointer uses ID 0), but an explicit early-return makes the invariant clear:

Suggested change
if (capture.Pointer.PointerId == _contextMenuOnHoldingPointerId
private void ReleaseContextMenuHoldingPointerCapture()
{
if (_contextMenuOnHoldingPointerId == 0)
{
return;
}
if (PointerCapture.Any(out var captures))

&& capture.ExplicitTarget is { } captureTarget)
{
captureTarget.ReleasePointerCaptureForContextMenu(capture.Pointer);
}
}
}
}

Expand Down Expand Up @@ -166,4 +196,9 @@ private static void OnContextRequestOnHoldingTimeout(object? sender, object e)
/// Sets the touch point for context menu on holding gesture.
/// </summary>
public void SetContextMenuOnHoldingTouchPoint(Point point) => _contextMenuOnHoldingTouchPoint = point;

/// <summary>
/// Sets the touch pointer id that triggered the holding gesture.
/// </summary>
public void SetContextMenuOnHoldingPointer(uint pointerId) => _contextMenuOnHoldingPointerId = pointerId;
}
30 changes: 30 additions & 0 deletions src/Uno.UI/UI/Xaml/UIElement.Pointers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@ internal HitTestability GetHitTestVisibility()
// but ContextRequestedEventArgs.TryGetPosition expects global coordinates.
var globalPosition = that.TransformToVisual(null).TransformPoint(args.Position);
contentRoot.InputManager.ContextMenuProcessor.SetContextMenuOnHoldingTouchPoint(globalPosition);
// Record the pointer so that, once the context menu is actually shown, its capture
// can be released to cancel the pending Click/Tapped on the pressing element.
contentRoot.InputManager.ContextMenuProcessor.SetContextMenuOnHoldingPointer(args.PointerId);
contentRoot.InputManager.ContextMenuProcessor.ProcessContextRequestOnHoldingGesture(src);
}
else if (args.HoldingState == HoldingState.Canceled)
Expand Down Expand Up @@ -1641,6 +1644,33 @@ public void ReleasePointerCaptures()

Release(PointerCaptureKind.Explicit);
}

/// <summary>
/// Releases the explicit pointer capture and raises an <em>unhandled</em> PointerCaptureLost so the
/// pressing control clears its pressed state. Used when a context menu is shown on a touch press-and-hold:
/// WinUI clears the pressed state via the OS pointer-capture-changed raised when the menu's popup steals
/// capture, but Uno's popups don't, so we do it explicitly (issue #22229).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor style (AGENTS.md): "Don't reference the current task, fix, or callers ('handles the case from issue #123') since those belong in the PR description and rot as the codebase evolves." Remove (issue #22229) from the prose; the [GitHubWorkItem] annotations on the tests are the right place for that cross-reference.

Suggested change
/// capture, but Uno's popups don't, so we do it explicitly (issue #22229).
/// Releases the explicit pointer capture and raises an <em>unhandled</em> PointerCaptureLost so the
/// pressing control clears its pressed state. Used when a context menu is shown on a touch press-and-hold:
/// WinUI clears the pressed state via the OS pointer-capture-changed raised when the menu's popup steals
/// capture, but Uno's popups don't, so we do it explicitly.

/// </summary>
/// <remarks>
/// The capture tracks the args from the PointerPressed, which the pressing control marks as Handled.
/// PointerCaptureLost reuses those args, so we reset Handled first - otherwise the control's class handler
/// (which only runs for unhandled events) never clears the pressed state.
/// </remarks>
internal void ReleasePointerCaptureForContextMenu(Pointer pointer)
{
if (PointerCapture.TryGet(pointer, out var capture))
{
foreach (var target in capture.GetTargets(PointerCaptureKind.Explicit))
{
if (target.LastDispatched is { } lastDispatched)
{
lastDispatched.Handled = false;
}
}
}

ReleasePointerCapture(pointer);
}
#endregion

private bool ValidateAndUpdateCapture(PointerRoutedEventArgs args)
Expand Down