Skip to content

Commit c8535d3

Browse files
committed
fix: use Process.MainWindowHandle for reliable window repositioning on WinUI 3
- GetActiveWindow/GetForegroundWindow return null (0x0) for XAML-island child HWNDs when thread focus changes during test execution - Use Process.GetCurrentProcess().MainWindowHandle to reliably find the top-level window for SetWindowPos repositioning - Ensures EnsureClientAreaOnScreen actually moves the root window when test UI elements extend off-screen (Y=-280 clamped to Y=0 by SetCursorPos) - Add cursor-verification guard in Tap() that throws descriptive InvalidOperationException when SetCursorPos cannot reach the target - Remove unused GetParent P/Invoke declaration
1 parent 6c64ced commit c8535d3

2 files changed

Lines changed: 167 additions & 17 deletions

File tree

src/TestApp/PointersInjectionTests.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Linq;
44
using System.Threading.Tasks;
55
using Windows.Devices.Input;
6+
using Windows.Foundation;
67
using Microsoft.VisualStudio.TestTools.UnitTesting;
78

89
using Microsoft.UI.Xaml;
@@ -40,6 +41,10 @@ public async Task When_TapCoordinates()
4041

4142
InputInjectorHelper.Current.Tap(elt);
4243

44+
// On WinUI 3, InputInjector queues events into the OS input queue.
45+
// Yield to let the message loop process them before asserting.
46+
await Task.Yield();
47+
4348
Assert.IsTrue(clicked);
4449
}
4550

@@ -65,11 +70,23 @@ public async Task When_TapCoordinates_Sequential()
6570
await UnitTestsUIContentHelper.WaitForLoaded(button1);
6671
await UnitTestsUIContentHelper.WaitForLoaded(button2);
6772

73+
var center1 = button1.TransformToVisual(null)
74+
.TransformPoint(new Point(button1.ActualSize.X / 2.0, button1.ActualSize.Y / 2.0));
75+
var center2 = button2.TransformToVisual(null)
76+
.TransformPoint(new Point(button2.ActualSize.X / 2.0, button2.ActualSize.Y / 2.0));
77+
var scale = button1.XamlRoot?.RasterizationScale ?? -1;
78+
6879
InputInjectorHelper.Current.Tap(button1);
69-
Assert.IsTrue(clicked1, "First button should have been clicked");
80+
await Task.Yield();
81+
Assert.IsTrue(clicked1,
82+
$"First button should have been clicked. " +
83+
$"Btn1=({center1.X:F1},{center1.Y:F1}), Btn2=({center2.X:F1},{center2.Y:F1}), Scale={scale}");
7084

7185
InputInjectorHelper.Current.Tap(button2);
72-
Assert.IsTrue(clicked2, "Second button should have been clicked");
86+
await Task.Yield();
87+
Assert.IsTrue(clicked2,
88+
$"Second button should have been clicked. " +
89+
$"Btn1=({center1.X:F1},{center1.Y:F1}), Btn2=({center2.X:F1},{center2.Y:F1}), Scale={scale}");
7390
}
7491

7592
[TestMethod]

src/Uno.UI.RuntimeTests.Engine.Library/Library/Helpers/InputInjectorHelperExtensions.cs

Lines changed: 148 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Linq;
1010
using System.Runtime.CompilerServices;
1111
#if !HAS_UNO
12+
using System.Diagnostics;
1213
using System.Runtime.InteropServices;
1314
#endif
1415
using Windows.Foundation;
@@ -29,13 +30,35 @@ public static void Tap(this InputInjectorHelper injector, UIElement elt)
2930
if (injector.CurrentPointerType == PointerDeviceType.Mouse)
3031
{
3132
// On WinUI 3, InputInjector uses relative mouse deltas from the physical OS cursor.
32-
// The tracked position does not correspond to the physical cursor location, so
33-
// MoveTo (which computes deltas from tracked position) misses the target.
34-
// Use Win32 SetCursorPos to position the cursor at the exact element center,
35-
// then inject Press/Release at that location.
33+
// Use Win32 SetCursorPos for absolute screen positioning, then inject a zero-delta
34+
// Move so the WinUI input system registers the cursor at the target before button events.
3635
var scale = elt.XamlRoot?.RasterizationScale ?? 1.0;
3736
injector.InjectMouseInput(injector.Mouse.ReleaseAny());
38-
PositionCursorAtClientCoordinates(center.X, center.Y, scale);
37+
var screenPt = GetOnScreenPoint(center.X, center.Y, scale);
38+
SetCursorPos(screenPt.x, screenPt.y);
39+
40+
// Verify the cursor reached the target. SetCursorPos silently clamps
41+
// to virtual screen bounds; if the cursor drifted, the click would miss.
42+
GetCursorPos(out var actual);
43+
if (Math.Abs(actual.x - screenPt.x) > 2 || Math.Abs(actual.y - screenPt.y) > 2)
44+
{
45+
var processHwnd = Process.GetCurrentProcess().MainWindowHandle;
46+
var diagHwnd = GetActiveWindow();
47+
GetWindowRect(processHwnd, out var procRect);
48+
throw new InvalidOperationException(
49+
$"Cannot tap element: cursor could not reach screen ({screenPt.x},{screenPt.y}). " +
50+
$"Actual=({actual.x},{actual.y}). " +
51+
$"Center DIPs=({center.X:F1},{center.Y:F1}), Scale={scale}. " +
52+
$"ProcessHWND=0x{processHwnd.ToInt64():X}, ActiveHWND=0x{diagHwnd.ToInt64():X}. " +
53+
$"ProcessRect=({procRect.left},{procRect.top},{procRect.right},{procRect.bottom}).");
54+
}
55+
56+
injector.InjectMouseInput(new InjectedInputMouseInfo
57+
{
58+
MouseOptions = InjectedInputMouseOptions.Move,
59+
DeltaX = 0,
60+
DeltaY = 0
61+
});
3962
injector.Mouse.SetTrackedPosition(center.X, center.Y);
4063
injector.InjectMouseInput(injector.Mouse.Press());
4164
injector.InjectMouseInput(injector.Mouse.Release());
@@ -185,35 +208,145 @@ private struct POINT
185208
public int y;
186209
}
187210

211+
[StructLayout(LayoutKind.Sequential)]
212+
private struct RECT
213+
{
214+
public int left, top, right, bottom;
215+
}
216+
188217
[DllImport("user32.dll")]
189218
private static extern bool SetCursorPos(int X, int Y);
190219

191220
[DllImport("user32.dll")]
192221
private static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
193222

223+
[DllImport("user32.dll")]
224+
private static extern bool GetCursorPos(out POINT lpPoint);
225+
194226
[DllImport("user32.dll")]
195227
private static extern IntPtr GetActiveWindow();
196228

229+
[DllImport("user32.dll")]
230+
private static extern IntPtr GetForegroundWindow();
231+
232+
[DllImport("user32.dll")]
233+
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
234+
235+
[DllImport("user32.dll")]
236+
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
237+
int X, int Y, int cx, int cy, uint uFlags);
238+
239+
[DllImport("user32.dll")]
240+
private static extern int GetSystemMetrics(int nIndex);
241+
242+
[DllImport("user32.dll")]
243+
private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags);
244+
245+
private const uint GA_ROOT = 2;
246+
247+
private const uint SWP_NOSIZE = 0x0001;
248+
private const uint SWP_NOZORDER = 0x0004;
249+
private const uint SWP_NOACTIVATE = 0x0010;
250+
private const int SM_XVIRTUALSCREEN = 76;
251+
private const int SM_YVIRTUALSCREEN = 77;
252+
private const int SM_CXVIRTUALSCREEN = 78;
253+
private const int SM_CYVIRTUALSCREEN = 79;
254+
197255
/// <summary>
198-
/// Positions the OS cursor at the given window-client-relative DIP coordinates.
199-
/// Converts from DIPs to physical pixels using the rasterization scale,
200-
/// then from client coordinates to screen coordinates using ClientToScreen.
256+
/// Converts window-client-relative DIP coordinates to screen pixel coordinates.
257+
/// If the computed screen position falls outside the virtual screen bounds
258+
/// (e.g., the window extends off-screen), the window is repositioned first
259+
/// to ensure the point is reachable by cursor and touch input.
201260
/// </summary>
202-
private static void PositionCursorAtClientCoordinates(double dipX, double dipY, double rasterizationScale)
261+
private static POINT GetOnScreenPoint(double clientDipX, double clientDipY, double scale)
203262
{
204263
var hwnd = GetActiveWindow();
205264
if (hwnd == IntPtr.Zero)
206265
{
207-
return;
266+
hwnd = GetForegroundWindow();
267+
}
268+
if (hwnd == IntPtr.Zero)
269+
{
270+
hwnd = Process.GetCurrentProcess().MainWindowHandle;
208271
}
209272

210-
var point = new POINT
273+
var clientPixel = new POINT
211274
{
212-
x = (int)(dipX * rasterizationScale),
213-
y = (int)(dipY * rasterizationScale)
275+
x = (int)(clientDipX * scale),
276+
y = (int)(clientDipY * scale)
214277
};
215-
ClientToScreen(hwnd, ref point);
216-
SetCursorPos(point.x, point.y);
278+
279+
if (hwnd == IntPtr.Zero)
280+
{
281+
return clientPixel;
282+
}
283+
284+
var screenPoint = clientPixel;
285+
ClientToScreen(hwnd, ref screenPoint);
286+
287+
// If the screen position is outside the virtual screen, SetCursorPos
288+
// will clamp the cursor and touch injection will miss the target.
289+
// This happens when the test runner window extends beyond the visible
290+
// screen area (common in automated/CI environments or multi-monitor setups).
291+
if (!IsPointOnVirtualScreen(screenPoint.x, screenPoint.y))
292+
{
293+
EnsureClientAreaOnScreen(hwnd);
294+
295+
// Recompute screen coordinates after the window move.
296+
screenPoint = clientPixel;
297+
ClientToScreen(hwnd, ref screenPoint);
298+
}
299+
300+
return screenPoint;
301+
}
302+
303+
private static bool IsPointOnVirtualScreen(int x, int y)
304+
{
305+
int left = GetSystemMetrics(SM_XVIRTUALSCREEN);
306+
int top = GetSystemMetrics(SM_YVIRTUALSCREEN);
307+
int width = GetSystemMetrics(SM_CXVIRTUALSCREEN);
308+
int height = GetSystemMetrics(SM_CYVIRTUALSCREEN);
309+
return x >= left && x < left + width && y >= top && y < top + height;
310+
}
311+
312+
/// <summary>
313+
/// Moves the top-level ancestor window so the given child HWND's client area
314+
/// origin maps to screen (100, 100), ensuring elements near the top-left
315+
/// of the client area are reachable by the OS cursor.
316+
/// SetWindowPos on child windows only repositions them within their parent,
317+
/// so we must find and move the root ancestor.
318+
/// </summary>
319+
private static void EnsureClientAreaOnScreen(IntPtr hwnd)
320+
{
321+
// Use Process.MainWindowHandle to reliably get the top-level window.
322+
// GetActiveWindow returns a XAML-island child HWND, and GetAncestor(GA_ROOT)
323+
// may not traverse the WinUI 3 HWND hierarchy correctly.
324+
var rootHwnd = Process.GetCurrentProcess().MainWindowHandle;
325+
if (rootHwnd == IntPtr.Zero)
326+
{
327+
rootHwnd = GetAncestor(hwnd, GA_ROOT);
328+
}
329+
if (rootHwnd == IntPtr.Zero)
330+
{
331+
return; // Cannot determine top-level window; skip repositioning.
332+
}
333+
334+
var clientOrigin = new POINT();
335+
ClientToScreen(hwnd, ref clientOrigin);
336+
337+
GetWindowRect(rootHwnd, out var windowRect);
338+
339+
// Shift the root window so the child's client area origin
340+
// lands at screen (100, 100).
341+
int newLeft = windowRect.left + (100 - clientOrigin.x);
342+
int newTop = windowRect.top + (100 - clientOrigin.y);
343+
344+
SetWindowPos(rootHwnd, IntPtr.Zero, newLeft, newTop, 0, 0,
345+
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
346+
347+
// Allow the OS to fully process the window position change
348+
// before subsequent ClientToScreen calls read updated coordinates.
349+
System.Threading.Thread.Sleep(500);
217350
}
218351
#endif
219352
}

0 commit comments

Comments
 (0)