Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 11 additions & 0 deletions src/Uno.UI.Runtime.Skia.WebAssembly.Browser/WasmCSS/uno.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ body {
Remarks 2: If applied only on the root of the app, this won't break the scrolling (unlike if applied on all UIElement).
*/
touch-action: none;
/*
Disable the browser's text-selection and long-press callout gestures.
On touch devices the browser starts a text-selection gesture on long-press (firing a haptic
buzz and showing selection/callout UI) before the cancelable 'contextmenu' event. Uno draws all
text and selection with Skia, so the browser selection is never useful and only conflicts with
the app's own gestures (e.g. press-and-hold to open a ContextFlyout). The hidden IME input
re-enables selection on itself so text editing keeps working.
*/
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}

.uno-root-element {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@
input.style.textShadow = "none";
input.style.overflow = "hidden";
input.style.pointerEvents = "none";
// Re-enable text selection on the IME input: the root disables it (uno.css) to suppress the
// browser's long-press selection gesture, but the input still needs it for editing/IME.
input.style.userSelect = "text";
(input.style as any).webkitUserSelect = "text";
input.style.zIndex = "99";
input.style.top = "0px";
input.style.left = "0px";
Expand Down
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
Loading