diff --git a/PowerToys.slnx b/PowerToys.slnx
index e6b34c2baa..d71f338f89 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -605,6 +605,10 @@
+
+
+
+
diff --git a/src/common/UITestAutomation.Next/KeyboardHelper.cs b/src/common/UITestAutomation.Next/KeyboardHelper.cs
index eeab43a831..28f3dba746 100644
--- a/src/common/UITestAutomation.Next/KeyboardHelper.cs
+++ b/src/common/UITestAutomation.Next/KeyboardHelper.cs
@@ -11,6 +11,8 @@ namespace Microsoft.PowerToys.UITest.Next;
public enum Key : byte
{
Ctrl = 0x11,
+ LCtrl = 0xA2,
+ RCtrl = 0xA3,
Shift = 0x10,
LShift = 0xA0,
Alt = 0x12,
@@ -81,6 +83,7 @@ public enum Key : byte
F10 = 0x79,
F11 = 0x7A,
F12 = 0x7B,
+ OemPeriod = 0xBE,
}
///
@@ -122,7 +125,9 @@ public static class KeyboardHelper
keybd_event(VK_LWIN, 0, 0, UIntPtr.Zero);
winDown = true;
break;
- case Key.Ctrl: chord.Append('^'); break;
+ case Key.Ctrl:
+ case Key.LCtrl:
+ case Key.RCtrl: chord.Append('^'); break;
case Key.Shift:
case Key.LShift: chord.Append('+'); break;
case Key.Alt: chord.Append('%'); break;
@@ -153,6 +158,7 @@ public static class KeyboardHelper
case Key.F10: chord.Append("{F10}"); break;
case Key.F11: chord.Append("{F11}"); break;
case Key.F12: chord.Append("{F12}"); break;
+ case Key.OemPeriod: chord.Append('.'); break;
default:
// Letter / digit keys map to their lowercase character for SendKeys.
chord.Append(((char)k).ToString().ToLowerInvariant());
@@ -210,6 +216,7 @@ public static class KeyboardHelper
}
private static bool IsExtended(Key key) => key is
+ Key.RCtrl or
Key.Left or Key.Up or Key.Right or Key.Down or
Key.Home or Key.End or Key.PageUp or Key.PageDown or
Key.Insert or Key.Delete;
diff --git a/src/common/UITestAutomation.Next/MouseHelper.cs b/src/common/UITestAutomation.Next/MouseHelper.cs
index 7a0db7b7a0..f829a6064a 100644
--- a/src/common/UITestAutomation.Next/MouseHelper.cs
+++ b/src/common/UITestAutomation.Next/MouseHelper.cs
@@ -41,6 +41,7 @@ public static class MouseHelper
private const uint INPUT_MOUSE = 0;
+ private const uint MouseEventMove = 0x01;
private const uint MOUSEEVENTF_LEFTDOWN = 0x02;
private const uint MOUSEEVENTF_LEFTUP = 0x04;
private const uint MOUSEEVENTF_RIGHTDOWN = 0x08;
@@ -52,10 +53,10 @@ public static class MouseHelper
private const int ClickDelayMs = 100;
private const int WheelTick = 120;
- [DllImport("user32.dll")]
+ [DllImport("user32.dll", SetLastError = true)]
private static extern bool SetCursorPos(int x, int y);
- [DllImport("user32.dll")]
+ [DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorPos(out POINT lpPoint);
@@ -63,12 +64,48 @@ public static class MouseHelper
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
/// Move the OS cursor to absolute screen coordinates.
- public static void MoveTo(int x, int y) => SetCursorPos(x, y);
+ public static void MoveTo(int x, int y)
+ {
+ if (!SetCursorPos(x, y))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error());
+ }
+ }
+
+ ///
+ /// Move the cursor by a relative delta using real mouse input. Stepped movement is useful for
+ /// utilities that observe low-level or raw mouse movement rather than only the final position.
+ ///
+ public static void MoveBy(int deltaX, int deltaY, int steps = 1, int delayMs = 15)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(steps);
+ ArgumentOutOfRangeException.ThrowIfNegative(delayMs);
+
+ var previousX = 0;
+ var previousY = 0;
+ for (var step = 1; step <= steps; step++)
+ {
+ var targetX = (int)Math.Round((double)deltaX * step / steps);
+ var targetY = (int)Math.Round((double)deltaY * step / steps);
+ SendMouseInput(MouseEventMove, dx: targetX - previousX, dy: targetY - previousY);
+ previousX = targetX;
+ previousY = targetY;
+
+ if (delayMs > 0 && step < steps)
+ {
+ Thread.Sleep(delayMs);
+ }
+ }
+ }
/// Current cursor position in screen pixels.
public static (int X, int Y) GetMousePosition()
{
- GetCursorPos(out var p);
+ if (!GetCursorPos(out var p))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error());
+ }
+
return (p.X, p.Y);
}
@@ -178,7 +215,7 @@ public static class MouseHelper
/// Button and wheel events fire at the current cursor position, so
/// only carries the wheel delta for MOUSEEVENTF_WHEEL.
///
- private static void SendMouseInput(uint flags, int data = 0)
+ private static void SendMouseInput(uint flags, int data = 0, int dx = 0, int dy = 0)
{
var inputs = new INPUT[]
{
@@ -187,8 +224,8 @@ public static class MouseHelper
Type = INPUT_MOUSE,
Mi = new MOUSEINPUT
{
- Dx = 0,
- Dy = 0,
+ Dx = dx,
+ Dy = dy,
MouseData = (uint)data,
DwFlags = flags,
Time = 0,
diff --git a/src/common/UITestAutomation.Next/NamedEventHelper.cs b/src/common/UITestAutomation.Next/NamedEventHelper.cs
index a01b5321b8..cfe8e3099f 100644
--- a/src/common/UITestAutomation.Next/NamedEventHelper.cs
+++ b/src/common/UITestAutomation.Next/NamedEventHelper.cs
@@ -19,6 +19,21 @@ public static class NamedEventHelper
/// Toggles the FancyZones layout editor open/closed.
public const string FancyZonesEditorToggle = @"Local\FancyZones-ToggleEditorEvent-1e174338-06a3-472b-874d-073b21c62f14";
+ /// Triggers Find My Mouse.
+ public const string FindMyMouseTrigger = @"Local\FindMyMouseTriggerEvent-5a9dc5f4-1c74-4f2f-a66f-1b9b6a2f9b23";
+
+ /// Toggles Mouse Highlighter.
+ public const string MouseHighlighterToggle = @"Local\MouseHighlighterTriggerEvent-1e3c9c3d-3fdf-4f9a-9a52-31c9b3c3a8f4";
+
+ /// Toggles Mouse Pointer Crosshairs.
+ public const string MouseCrosshairsToggle = @"Local\MouseCrosshairsTriggerEvent-0d4c7f92-0a5c-4f5c-b64b-8a2a2f7e0b21";
+
+ /// Shows the Mouse Jump preview.
+ public const string MouseJumpShowPreview = @"Local\MouseJumpEvent-aa0be051-3396-4976-b7ba-1a9cc7d236a5";
+
+ /// Toggles Cursor Wrap.
+ public const string CursorWrapToggle = @"Local\CursorWrapTriggerEvent-1f8452b5-4e6e-45b3-8b09-13f14a5900c9";
+
/// Set an existing named event. Returns false when no module currently owns it.
public static bool TrySignal(string name)
{
@@ -40,6 +55,33 @@ public static class NamedEventHelper
}
}
+ /// Whether a named event currently exists.
+ public static bool Exists(string name)
+ {
+ try
+ {
+ if (!EventWaitHandle.TryOpenExisting(name, out var handle))
+ {
+ return false;
+ }
+
+ handle.Dispose();
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ /// Wait until a module creates a named event without signaling it.
+ public static bool WaitUntilAvailable(string name, int timeoutMS = 15_000, int pollIntervalMS = 250) =>
+ WaitForAvailability(name, expected: true, timeoutMS, pollIntervalMS);
+
+ /// Wait until a module closes a named event.
+ public static bool WaitUntilUnavailable(string name, int timeoutMS = 15_000, int pollIntervalMS = 250) =>
+ WaitForAvailability(name, expected: false, timeoutMS, pollIntervalMS);
+
/// Wait until a module has created the named event, then signal it.
public static bool WaitAndSignal(string name, int timeoutMS = 15_000, int pollIntervalMS = 250)
{
@@ -59,4 +101,23 @@ public static class NamedEventHelper
Thread.Sleep(pollIntervalMS);
}
}
+
+ private static bool WaitForAvailability(string name, bool expected, int timeoutMS, int pollIntervalMS)
+ {
+ var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
+ while (true)
+ {
+ if (Exists(name) == expected)
+ {
+ return true;
+ }
+
+ if (DateTime.UtcNow >= deadline)
+ {
+ return false;
+ }
+
+ Thread.Sleep(pollIntervalMS);
+ }
+ }
}
diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/AssemblyInfo.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/AssemblyInfo.cs
new file mode 100644
index 0000000000..8c5a6eed6d
--- /dev/null
+++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/AssemblyInfo.cs
@@ -0,0 +1,7 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[assembly: DoNotParallelize]
diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/CursorWrapTests.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/CursorWrapTests.cs
new file mode 100644
index 0000000000..40cb4a9dae
--- /dev/null
+++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/CursorWrapTests.cs
@@ -0,0 +1,337 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.PowerToys.UITest.Next;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace MouseUtils.UITests;
+
+[TestClass]
+public class CursorWrapTests : UITestBase
+{
+ private const string ModuleName = "CursorWrap";
+ private static readonly IDisposable ModuleSettings = SettingsConfigHelper.PreserveModuleSettings(ModuleName);
+
+ public CursorWrapTests()
+ : base(PowerToysModule.PowerToysSettings, enableModules: new[] { ModuleName })
+ {
+ }
+
+ [ClassCleanup]
+ public static void RestoreModuleSettings() => ModuleSettings.Dispose();
+
+ protected override void PrepareTestState()
+ {
+ var configuration = TestContext.TestName switch
+ {
+ nameof(HorizontalOnlyWrapsOnlyHorizontalEdges) => new CursorWrapConfiguration(WrapMode: 2),
+ nameof(VerticalOnlyWrapsOnlyVerticalEdges) => new CursorWrapConfiguration(WrapMode: 1),
+ nameof(CtrlActivationModeRequiresCtrl) => new CursorWrapConfiguration(ActivationMode: 1),
+ nameof(ShiftActivationModeRequiresShift) => new CursorWrapConfiguration(ActivationMode: 2),
+ nameof(SingleMonitorSuppressionBlocksWrapping) => new CursorWrapConfiguration(DisableOnSingleMonitor: true),
+ nameof(AutoActivateStartsWrappingWithoutShortcut) => new CursorWrapConfiguration(AutoActivate: true),
+ nameof(ChangedShortcutTogglesWrapping) => new CursorWrapConfiguration(ShortcutCode: (int)Key.Y),
+ _ => new CursorWrapConfiguration(),
+ };
+
+ MouseUtilsTestHelper.ReplaceModuleSettings(ModuleName, CreateSettings(configuration));
+ }
+
+ [TestCleanup]
+ public async Task CleanupInput()
+ {
+ await CaptureFailureArtifactsBeforeCleanupAsync();
+ MouseHelper.LeftUp();
+ MouseHelper.RightUp();
+ KeyboardHelper.ReleaseKey(Key.Ctrl);
+ KeyboardHelper.ReleaseKey(Key.Shift);
+ KeyboardHelper.ReleaseKey(Key.Alt);
+ KeyboardHelper.ReleaseKey(Key.LWin);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("CursorWrap")]
+ public void ShortcutTogglesWrappingAndModuleDisableStopsIt()
+ {
+ MouseUtilsTestHelper.NavigateToMouseUtilities(this);
+ var toggle = Session.Find(By.Name("CursorWrap"), 5_000);
+ toggle.Toggle(true);
+ Assert.IsTrue(toggle.WaitForProperty("ToggleState", "On", 5_000), "CursorWrap toggle did not reach On.");
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilAvailable(NamedEventHelper.CursorWrapToggle),
+ "CursorWrap did not create its trigger event after being enabled.");
+
+ MouseUtilsTestHelper.Step(this, "Activating CursorWrap with Win+Alt+U");
+ KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.U);
+ AssertWraps(CursorEdge.Left);
+
+ MouseUtilsTestHelper.Step(this, "Deactivating CursorWrap with Win+Alt+U");
+ KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.U);
+ AssertDoesNotWrap(CursorEdge.Left);
+
+ toggle.Toggle(false);
+ Assert.IsTrue(toggle.WaitForProperty("ToggleState", "Off", 5_000), "CursorWrap toggle did not reach Off.");
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilUnavailable(NamedEventHelper.CursorWrapToggle),
+ "CursorWrap trigger event remained available after the module was disabled.");
+ AssertDoesNotWrap(CursorEdge.Left);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("CursorWrap")]
+ public void BothModeWrapsAllEdges()
+ {
+ ActivateWithNamedEvent();
+ foreach (var edge in Enum.GetValues())
+ {
+ AssertWraps(edge);
+ }
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("CursorWrap")]
+ public void HorizontalOnlyWrapsOnlyHorizontalEdges()
+ {
+ ActivateWithNamedEvent();
+ AssertWraps(CursorEdge.Left);
+ AssertWraps(CursorEdge.Right);
+ AssertDoesNotWrap(CursorEdge.Top);
+ AssertDoesNotWrap(CursorEdge.Bottom);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("CursorWrap")]
+ public void VerticalOnlyWrapsOnlyVerticalEdges()
+ {
+ ActivateWithNamedEvent();
+ AssertWraps(CursorEdge.Top);
+ AssertWraps(CursorEdge.Bottom);
+ AssertDoesNotWrap(CursorEdge.Left);
+ AssertDoesNotWrap(CursorEdge.Right);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("CursorWrap")]
+ public void CtrlActivationModeRequiresCtrl()
+ {
+ ActivateWithNamedEvent();
+ AssertDoesNotWrap(CursorEdge.Left);
+ AssertWraps(CursorEdge.Left, Key.Ctrl);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("CursorWrap")]
+ public void ShiftActivationModeRequiresShift()
+ {
+ ActivateWithNamedEvent();
+ AssertDoesNotWrap(CursorEdge.Top);
+ AssertWraps(CursorEdge.Top, Key.Shift);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("CursorWrap")]
+ public void DragSuppressionBlocksWrappingWhileLeftButtonIsDown()
+ {
+ ActivateWithNamedEvent();
+ AssertDoesNotWrap(CursorEdge.Left, holdLeftButton: true);
+ AssertWraps(CursorEdge.Left);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("CursorWrap")]
+ public void SingleMonitorSuppressionBlocksWrapping()
+ {
+ Assert.AreEqual(1, MonitorInfo.Count, "This scenario requires the single-monitor VM profile.");
+ ActivateWithNamedEvent();
+ foreach (var edge in Enum.GetValues())
+ {
+ AssertDoesNotWrap(edge);
+ }
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("CursorWrap")]
+ public void AutoActivateStartsWrappingWithoutShortcut()
+ {
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilAvailable(NamedEventHelper.CursorWrapToggle),
+ "CursorWrap did not create its trigger event.");
+ AssertWraps(CursorEdge.Right);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("CursorWrap")]
+ public void ChangedShortcutTogglesWrapping()
+ {
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilAvailable(NamedEventHelper.CursorWrapToggle),
+ "CursorWrap did not create its trigger event.");
+
+ KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.U);
+ AssertDoesNotWrap(CursorEdge.Left);
+
+ KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.Y);
+ AssertWraps(CursorEdge.Left);
+ }
+
+ private static string CreateSettings(CursorWrapConfiguration configuration) => $$"""
+ {
+ "name": "CursorWrap",
+ "version": "1.0",
+ "properties": {
+ "activation_shortcut": { "win": true, "ctrl": false, "alt": true, "shift": false, "code": {{configuration.ShortcutCode}}, "key": "" },
+ "auto_activate": { "value": {{configuration.AutoActivate.ToString().ToLowerInvariant()}} },
+ "disable_wrap_during_drag": { "value": {{configuration.DisableDuringDrag.ToString().ToLowerInvariant()}} },
+ "wrap_mode": { "value": {{configuration.WrapMode}} },
+ "activation_mode": { "value": {{configuration.ActivationMode}} },
+ "disable_cursor_wrap_on_single_monitor": { "value": {{configuration.DisableOnSingleMonitor.ToString().ToLowerInvariant()}} }
+ }
+ }
+ """;
+
+ private void ActivateWithNamedEvent()
+ {
+ MouseUtilsTestHelper.Step(this, "Activating CursorWrap through its named event");
+ Assert.IsTrue(
+ NamedEventHelper.WaitAndSignal(NamedEventHelper.CursorWrapToggle),
+ "CursorWrap did not create or respond to its trigger event.");
+ }
+
+ private void AssertWraps(CursorEdge edge, Key? heldKey = null, bool holdLeftButton = false)
+ {
+ var monitor = MonitorInfo.GetPrimary()!;
+ var attempts = new List<(int X, int Y)>();
+ for (var attempt = 1; attempt <= 3; attempt++)
+ {
+ var after = MoveAcrossEdge(edge, heldKey, holdLeftButton);
+ attempts.Add(after);
+ var wrapped = edge switch
+ {
+ CursorEdge.Left => after.X >= monitor.Right - 10,
+ CursorEdge.Right => after.X <= monitor.Left + 10,
+ CursorEdge.Top => after.Y >= monitor.Bottom - 10,
+ CursorEdge.Bottom => after.Y <= monitor.Top + 10,
+ _ => false,
+ };
+ if (wrapped)
+ {
+ return;
+ }
+
+ Thread.Sleep(100);
+ }
+
+ Assert.Fail($"Cursor did not wrap from {edge} after three complete crossings; final positions: {string.Join(", ", attempts.Select(position => $"({position.X},{position.Y})"))}.");
+ }
+
+ private void AssertDoesNotWrap(CursorEdge edge, Key? heldKey = null, bool holdLeftButton = false)
+ {
+ var after = MoveAcrossEdge(edge, heldKey, holdLeftButton);
+ var monitor = MonitorInfo.GetPrimary()!;
+ var stayed = edge switch
+ {
+ CursorEdge.Left => after.X <= monitor.Left + 10,
+ CursorEdge.Right => after.X >= monitor.Right - 10,
+ CursorEdge.Top => after.Y <= monitor.Top + 10,
+ CursorEdge.Bottom => after.Y >= monitor.Bottom - 10,
+ _ => false,
+ };
+
+ Assert.IsTrue(stayed, $"Cursor unexpectedly wrapped from {edge}; final position was ({after.X},{after.Y}).");
+ }
+
+ private (int X, int Y) MoveAcrossEdge(CursorEdge edge, Key? heldKey, bool holdLeftButton)
+ {
+ var monitor = MonitorInfo.GetPrimary();
+ Assert.IsNotNull(monitor, "No primary monitor was reported.");
+ var centerX = monitor.Left + (monitor.Width / 2);
+ var centerY = monitor.Top + (monitor.Height / 2);
+ MouseHelper.MoveTo(centerX, centerY);
+ MouseHelper.MoveBy(2, 2);
+
+ var start = edge switch
+ {
+ CursorEdge.Left => (monitor.Left + 120, centerY),
+ CursorEdge.Right => (monitor.Right - 121, centerY),
+ CursorEdge.Top => (centerX, monitor.Top + 120),
+ CursorEdge.Bottom => (centerX, monitor.Bottom - 121),
+ _ => throw new ArgumentOutOfRangeException(nameof(edge)),
+ };
+ var inward = edge switch
+ {
+ CursorEdge.Left => (2, 0),
+ CursorEdge.Right => (-2, 0),
+ CursorEdge.Top => (0, 2),
+ CursorEdge.Bottom => (0, -2),
+ _ => throw new ArgumentOutOfRangeException(nameof(edge)),
+ };
+ var outward = edge switch
+ {
+ CursorEdge.Left => (-500, 0),
+ CursorEdge.Right => (500, 0),
+ CursorEdge.Top => (0, -500),
+ CursorEdge.Bottom => (0, 500),
+ _ => throw new ArgumentOutOfRangeException(nameof(edge)),
+ };
+
+ MouseHelper.MoveTo(start.Item1, start.Item2);
+ if (heldKey.HasValue)
+ {
+ KeyboardHelper.PressKey(heldKey.Value);
+ }
+
+ if (holdLeftButton)
+ {
+ MouseHelper.LeftDown();
+ }
+
+ try
+ {
+ MouseHelper.MoveBy(inward.Item1, inward.Item2);
+ MouseHelper.MoveBy(outward.Item1, outward.Item2);
+ Thread.Sleep(150);
+ var after = MouseHelper.GetMousePosition();
+ MouseUtilsTestHelper.Step(this, $"Cursor {edge} crossing ended at ({after.X},{after.Y})");
+ return after;
+ }
+ finally
+ {
+ if (holdLeftButton)
+ {
+ MouseHelper.LeftUp();
+ }
+
+ if (heldKey.HasValue)
+ {
+ KeyboardHelper.ReleaseKey(heldKey.Value);
+ }
+ }
+ }
+
+ private sealed record CursorWrapConfiguration(
+ bool AutoActivate = false,
+ bool DisableDuringDrag = true,
+ int WrapMode = 0,
+ int ActivationMode = 0,
+ bool DisableOnSingleMonitor = false,
+ int ShortcutCode = (int)Key.U);
+
+ private enum CursorEdge
+ {
+ Left,
+ Right,
+ Top,
+ Bottom,
+ }
+}
diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/FindMyMouseTests.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/FindMyMouseTests.cs
new file mode 100644
index 0000000000..5c0fbf4d27
--- /dev/null
+++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/FindMyMouseTests.cs
@@ -0,0 +1,418 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Drawing;
+using Microsoft.PowerToys.UITest.Next;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace MouseUtils.UITests;
+
+[TestClass]
+public class FindMyMouseTests : UITestBase
+{
+ private const string ModuleName = "FindMyMouse";
+ private const string ToggleId = "MouseUtils_FindMyMouseToggleId";
+ private const string WindowClass = "FindMyMouse";
+ private static readonly IDisposable ModuleSettings = SettingsConfigHelper.PreserveModuleSettings(ModuleName);
+ private static IDisposable? clientAreaAnimations;
+
+ public FindMyMouseTests()
+ : base(PowerToysModule.PowerToysSettings, enableModules: new[] { ModuleName })
+ {
+ }
+
+ [ClassInitialize]
+ public static void PrepareClass(TestContext testContext)
+ {
+ _ = testContext;
+ clientAreaAnimations = MouseUtilsTestHelper.PreserveClientAreaAnimationsEnabled();
+ }
+
+ [ClassCleanup]
+ public static void RestoreClassState()
+ {
+ try
+ {
+ clientAreaAnimations?.Dispose();
+ }
+ finally
+ {
+ ModuleSettings.Dispose();
+ }
+ }
+
+ protected override void PrepareTestState()
+ {
+ var configuration = TestContext.TestName switch
+ {
+ nameof(AppearanceColorsRadiusAndAlphaAreApplied) => new FindMyMouseConfiguration(
+ BackgroundColor: "#80FF0000",
+ SpotlightColor: "#8000FF00",
+ Radius: 80,
+ AnimationDurationMs: 1,
+ InitialZoom: 1),
+ nameof(InitialZoomIsApplied) => new FindMyMouseConfiguration(
+ BackgroundColor: "#FFFF0000",
+ SpotlightColor: "#FF00FF00",
+ Radius: 40,
+ AnimationDurationMs: 2_000,
+ InitialZoom: 1),
+ nameof(AnimationDurationIsApplied) => new FindMyMouseConfiguration(
+ BackgroundColor: "#FFFF0000",
+ SpotlightColor: "#FF00FF00",
+ Radius: 40,
+ AnimationDurationMs: 10_000,
+ InitialZoom: 9),
+ nameof(RightControlActivates) => new FindMyMouseConfiguration(ActivationMethod: 1),
+ nameof(CustomShortcutActivates) => new FindMyMouseConfiguration(ActivationMethod: 3),
+ nameof(IncludeWinKeyGatesDoubleControlActivation) => new FindMyMouseConfiguration(IncludeWinKey: true),
+ nameof(ExcludedForegroundAppBlocksActivation) => new FindMyMouseConfiguration(ExcludedApps: "PowerToys.Settings.exe"),
+ _ => new FindMyMouseConfiguration(),
+ };
+
+ MouseUtilsTestHelper.ReplaceModuleSettings(ModuleName, CreateSettings(configuration));
+ }
+
+ [TestCleanup]
+ public async Task CleanupInput()
+ {
+ await CaptureFailureArtifactsBeforeCleanupAsync();
+ MouseHelper.LeftUp();
+ MouseHelper.RightUp();
+ KeyboardHelper.ReleaseKey(Key.LCtrl);
+ KeyboardHelper.ReleaseKey(Key.RCtrl);
+ KeyboardHelper.ReleaseKey(Key.LWin);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #1")]
+ [TestCategory("Mouse Utils #2")]
+ [TestCategory("Mouse Utils #3")]
+ [TestCategory("Mouse Utils #4")]
+ public void ActivationAndKeyboardMouseDismissal()
+ {
+ MouseUtilsTestHelper.NavigateToMouseUtilities(this);
+ MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, true);
+ var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+
+ using (var keyboardDismissal = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()))
+ {
+ DoubleTap(Key.LCtrl);
+ Assert.IsTrue(keyboardDismissal.Wait(5_000), "Double left Ctrl did not show Find My Mouse.");
+ KeyboardHelper.SendKey(Key.A);
+ Assert.IsTrue(keyboardDismissal.WaitForHidden(5_000), "A keyboard key did not dismiss Find My Mouse.");
+ }
+
+ using var mouseDismissal = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64());
+ DoubleTap(Key.LCtrl);
+ Assert.IsTrue(mouseDismissal.Wait(5_000), "Second double left Ctrl did not show Find My Mouse.");
+ MouseHelper.LeftClick();
+ Assert.IsTrue(mouseDismissal.WaitForHidden(5_000), "A mouse button did not dismiss Find My Mouse.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #5")]
+ [TestCategory("Mouse Utils #6")]
+ public void DisabledModuleRejectsActivationAndReenableWorks()
+ {
+ MouseUtilsTestHelper.NavigateToMouseUtilities(this);
+ var toggle = MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, false);
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilUnavailable(NamedEventHelper.FindMyMouseTrigger),
+ "Find My Mouse trigger event remained available after disabling the module.");
+
+ using (var disabledWatcher = new WindowShowWatcher(WindowClass))
+ {
+ DoubleTap(Key.LCtrl);
+ Assert.IsFalse(disabledWatcher.Wait(1_500), "Find My Mouse appeared while disabled.");
+ }
+
+ _ = toggle;
+ MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, true);
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilAvailable(NamedEventHelper.FindMyMouseTrigger),
+ "Find My Mouse trigger event was not recreated after enabling.");
+ var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+ using var enabledWatcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64());
+ TriggerUntilShown(enabledWatcher, () => DoubleTap(Key.LCtrl), "double left Ctrl after re-enabling");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #10")]
+ [TestCategory("Mouse Utils #11")]
+ [TestCategory("Mouse Utils #12")]
+ public void AppearanceColorsRadiusAndAlphaAreApplied()
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+ var spotlightPoint = (X: centerX + 40, Y: centerY);
+ var backgroundPoint = (X: centerX + 120, Y: centerY);
+ var spotlightBase = GetStablePixel(spotlightPoint.X, spotlightPoint.Y);
+ var backgroundBase = GetStablePixel(backgroundPoint.X, backgroundPoint.Y);
+ var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+ using var watcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64());
+
+ Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.FindMyMouseTrigger), "Find My Mouse trigger event was unavailable.");
+ Assert.IsTrue(watcher.Wait(5_000), "Find My Mouse did not show for appearance validation.");
+
+ var expectedSpotlight = Blend(Color.Lime, spotlightBase, 128);
+ var expectedBackground = Blend(Color.Red, backgroundBase, 128);
+ AssertPixelNear(spotlightPoint.X, spotlightPoint.Y, expectedSpotlight, "spotlight color/alpha inside the configured radius");
+ AssertPixelNear(backgroundPoint.X, backgroundPoint.Y, expectedBackground, "background color/alpha outside the configured radius");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void InitialZoomIsApplied()
+ {
+ MouseUtilsTestHelper.RunWithClientAreaAnimationsEnabled(() =>
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+ var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+ using var watcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64());
+
+ Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.FindMyMouseTrigger), "Find My Mouse trigger event was unavailable.");
+ Assert.IsTrue(watcher.Wait(5_000), "Find My Mouse did not show for initial-zoom validation.");
+ var result = WaitHelper.WaitForStable(
+ () => new
+ {
+ Inside = WindowHelper.GetPixelColor(centerX + 20, centerY),
+ Outside = WindowHelper.GetPixelColor(centerX + 80, centerY),
+ },
+ sample => sample is not null && sample.Inside.G > sample.Inside.R && sample.Outside.R > sample.Outside.G,
+ 5_000,
+ requiredConsecutiveMatches: 2,
+ pollIntervalMS: 25);
+
+ Assert.IsTrue(result.Succeeded, $"The configured 1x initial zoom did not keep the 80px probe outside the 40px spotlight. Last sample: {result.LastObservation}.");
+ });
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void AnimationDurationIsApplied()
+ {
+ MouseUtilsTestHelper.RunWithClientAreaAnimationsEnabled(() =>
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+ var probeX = centerX + 80;
+ _ = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.FindMyMouseTrigger), "Find My Mouse trigger event was unavailable.");
+ var initialSpotlight = WaitHelper.WaitForStable(
+ () => new
+ {
+ Center = WindowHelper.GetPixelColor(centerX + 20, centerY),
+ Probe = WindowHelper.GetPixelColor(probeX, centerY),
+ },
+ sample => sample is not null && sample.Center.G > sample.Center.R && sample.Probe.G > sample.Probe.R,
+ 8_000,
+ pollIntervalMS: 20);
+ Assert.IsTrue(
+ initialSpotlight.Succeeded,
+ $"The 9x initial spotlight never covered the 80px probe; last sample: {initialSpotlight.LastObservation}.");
+ var finalRadius = WaitHelper.WaitForStable(
+ () => new
+ {
+ Center = WindowHelper.GetPixelColor(centerX + 20, centerY),
+ Probe = WindowHelper.GetPixelColor(probeX, centerY),
+ },
+ sample => sample is not null && sample.Center.G > sample.Center.R && sample.Probe.R > sample.Probe.G,
+ 12_000,
+ requiredConsecutiveMatches: 3,
+ pollIntervalMS: 20);
+ Assert.IsTrue(finalRadius.Succeeded, "The spotlight did not reach its configured 40px radius after the 10-second animation.");
+ var crossingTime = stopwatch.Elapsed;
+ Assert.IsTrue(
+ crossingTime >= TimeSpan.FromSeconds(3) && crossingTime <= TimeSpan.FromSeconds(12),
+ $"Configured 10-second spotlight animation crossed the 80px probe after {crossingTime.TotalSeconds:F1}s; expected 3-12s for the eased transition.");
+ });
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void RightControlActivates()
+ {
+ AssertActivation(() => DoubleTap(Key.RCtrl), "double right Ctrl");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void CustomShortcutActivates()
+ {
+ AssertActivation(() => KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.F), "Win+Shift+F");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void IncludeWinKeyGatesDoubleControlActivation()
+ {
+ var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+ using (var withoutWin = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()))
+ {
+ DoubleTap(Key.LCtrl);
+ Assert.IsFalse(withoutWin.Wait(1_500), "Double Ctrl activated despite the required Windows key not being held.");
+ }
+
+ using var withWin = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64());
+ KeyboardHelper.PressKey(Key.LWin);
+ try
+ {
+ DoubleTap(Key.LCtrl);
+ }
+ finally
+ {
+ KeyboardHelper.ReleaseKey(Key.LWin);
+ }
+
+ Assert.IsTrue(withWin.Wait(5_000), "Win plus double Ctrl did not activate Find My Mouse.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void ExcludedForegroundAppBlocksActivation()
+ {
+ var settingsHwnd = new IntPtr(Session.WindowHandle);
+ Assert.IsTrue(WindowControl.WaitForForeground(settingsHwnd, 5_000, 2), "Settings could not become foreground for the exclusion precondition.");
+ using (var excluded = new WindowShowWatcher(WindowClass))
+ {
+ Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.FindMyMouseTrigger), "Find My Mouse trigger event was unavailable.");
+ Assert.IsFalse(excluded.Wait(1_500), "Find My Mouse activated while excluded PowerToys.Settings.exe owned foreground.");
+ }
+
+ using var notepad = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("notepad.exe") { UseShellExecute = true });
+ Assert.IsNotNull(notepad, "Could not start the allowed foreground Notepad fixture.");
+ try
+ {
+ var notepadWindow = WindowsFinder.WaitForWindowByProcess("notepad", 10_000);
+ Assert.IsNotNull(notepadWindow, "Notepad did not create a visible window.");
+ Assert.IsTrue(
+ WindowControl.WaitForForeground(new IntPtr(notepadWindow.WindowHandle), 5_000, 2),
+ $"Notepad could not become foreground. Current foreground: {WindowControl.GetForegroundWindowInfo()}.");
+
+ using var allowed = new WindowShowWatcher(WindowClass);
+ Assert.IsTrue(NamedEventHelper.TrySignal(NamedEventHelper.FindMyMouseTrigger), "Find My Mouse trigger event disappeared.");
+ Assert.IsTrue(allowed.Wait(5_000), "Find My Mouse remained blocked after an allowed app gained foreground.");
+ }
+ finally
+ {
+ WindowControl.TryKillProcessTreeByNameAndWait("notepad", 5_000);
+ }
+ }
+
+ private static string CreateSettings(FindMyMouseConfiguration configuration) => $$"""
+ {
+ "name": "FindMyMouse",
+ "version": "1.1",
+ "properties": {
+ "activation_method": { "value": {{configuration.ActivationMethod}} },
+ "include_win_key": { "value": {{configuration.IncludeWinKey.ToString().ToLowerInvariant()}} },
+ "activation_shortcut": { "win": true, "ctrl": false, "alt": false, "shift": true, "code": 70, "key": "" },
+ "do_not_activate_on_game_mode": { "value": false },
+ "background_color": { "value": "{{configuration.BackgroundColor}}" },
+ "spotlight_color": { "value": "{{configuration.SpotlightColor}}" },
+ "spotlight_radius": { "value": {{configuration.Radius}} },
+ "animation_duration_ms": { "value": {{configuration.AnimationDurationMs}} },
+ "spotlight_initial_zoom": { "value": {{configuration.InitialZoom}} },
+ "excluded_apps": { "value": "{{configuration.ExcludedApps}}" },
+ "shaking_minimum_distance": { "value": 100 },
+ "shaking_interval_ms": { "value": 2000 },
+ "shaking_factor": { "value": 150 }
+ }
+ }
+ """;
+
+ private static void DoubleTap(Key controlKey)
+ {
+ KeyboardHelper.SendKey(controlKey);
+ Thread.Sleep(150);
+ KeyboardHelper.SendKey(controlKey);
+ }
+
+ private void AssertActivation(Action trigger, string description)
+ {
+ var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+ using var watcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64());
+ TriggerUntilShown(watcher, trigger, description);
+ }
+
+ private static void TriggerUntilShown(WindowShowWatcher watcher, Action trigger, string description)
+ {
+ for (var attempt = 1; attempt <= 3; attempt++)
+ {
+ if (watcher.Wait(0))
+ {
+ return;
+ }
+
+ trigger();
+ if (watcher.Wait(5_000))
+ {
+ return;
+ }
+ }
+
+ Assert.Fail($"Find My Mouse did not activate from {description} after three attempts.");
+ }
+
+ private static Color Blend(Color foreground, Color background, int alpha)
+ {
+ var inverse = 255 - alpha;
+ return Color.FromArgb(
+ ((foreground.R * alpha) + (background.R * inverse) + 127) / 255,
+ ((foreground.G * alpha) + (background.G * inverse) + 127) / 255,
+ ((foreground.B * alpha) + (background.B * inverse) + 127) / 255);
+ }
+
+ private static Color GetStablePixel(int x, int y)
+ {
+ Color? previous = null;
+ var result = WaitHelper.WaitForStable(
+ () => WindowHelper.GetPixelColor(x, y),
+ color =>
+ {
+ var matchesPrevious = previous.HasValue && color.ToArgb() == previous.Value.ToArgb();
+ previous = color;
+ return matchesPrevious;
+ },
+ 2_000,
+ requiredConsecutiveMatches: 4,
+ pollIntervalMS: 100);
+ return result.LastObservation;
+ }
+
+ private static void AssertPixelNear(int x, int y, Color expected, string description)
+ {
+ const int tolerance = 4;
+ var result = WaitHelper.WaitForStable(
+ () => WindowHelper.GetPixelColor(x, y),
+ actual =>
+ Math.Abs(actual.R - expected.R) <= tolerance &&
+ Math.Abs(actual.G - expected.G) <= tolerance &&
+ Math.Abs(actual.B - expected.B) <= tolerance,
+ 5_000,
+ requiredConsecutiveMatches: 2,
+ pollIntervalMS: 100);
+ Assert.IsTrue(result.Succeeded, $"Unexpected {description} at ({x},{y}). Expected {expected}; observed {result.LastObservation}.");
+ }
+
+ private sealed record FindMyMouseConfiguration(
+ int ActivationMethod = 0,
+ bool IncludeWinKey = false,
+ string BackgroundColor = "#FFFF0000",
+ string SpotlightColor = "#FF00FF00",
+ int Radius = 80,
+ int AnimationDurationMs = 1,
+ int InitialZoom = 1,
+ string ExcludedApps = "");
+}
diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseHighlighterTests.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseHighlighterTests.cs
new file mode 100644
index 0000000000..9dcd693164
--- /dev/null
+++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseHighlighterTests.cs
@@ -0,0 +1,613 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Drawing;
+using Microsoft.PowerToys.UITest.Next;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace MouseUtils.UITests;
+
+[TestClass]
+public class MouseHighlighterTests : UITestBase
+{
+ private const string ModuleName = "MouseHighlighter";
+ private const string ToggleId = "MouseUtils_MouseHighlighterToggleId";
+ private const string WindowClass = "MouseHighlighter";
+ private static readonly IDisposable ModuleSettings = SettingsConfigHelper.PreserveModuleSettings(ModuleName);
+ private static IDisposable? clientAreaAnimations;
+
+ public MouseHighlighterTests()
+ : base(PowerToysModule.PowerToysSettings, enableModules: new[] { ModuleName })
+ {
+ }
+
+ [ClassInitialize]
+ public static void PrepareClass(TestContext testContext)
+ {
+ _ = testContext;
+ clientAreaAnimations = MouseUtilsTestHelper.PreserveClientAreaAnimationsEnabled();
+ }
+
+ [ClassCleanup]
+ public static void RestoreClassState()
+ {
+ try
+ {
+ clientAreaAnimations?.Dispose();
+ }
+ finally
+ {
+ ModuleSettings.Dispose();
+ }
+ }
+
+ protected override void PrepareTestState()
+ {
+ var configuration = TestContext.TestName switch
+ {
+ nameof(ChangedShortcutTogglesAndDisabledModuleRejectsActivation) => new HighlighterConfiguration(ShortcutCode: (int)Key.O),
+ nameof(CircleAlphaRadiusAndFadeTimingAreApplied) => new HighlighterConfiguration(
+ LeftColor: "#80FF0000",
+ RightColor: "#8000FF00",
+ Radius: 100,
+ FadeDelayMs: 2_000,
+ FadeDurationMs: 4_000),
+ nameof(SpotlightModeUsesAlwaysColorAndRadius) => new HighlighterConfiguration(
+ AlwaysColor: "#80FF0000",
+ Radius: 80,
+ SpotlightMode: true),
+ nameof(RippleQuickClickUsesSizeIntensityAndDuration) => RippleConfiguration(),
+ nameof(RippleHeldIndicatorFollowsDragWhenEnabled) => RippleConfiguration(showDragTrail: true),
+ nameof(RippleHeldIndicatorStaysAtPressWhenDisabled) => RippleConfiguration(showDragTrail: false),
+ nameof(RippleRightReleasePulseIsDrawn) => RippleConfiguration(showReleasePulse: true),
+ nameof(AutoActivateStartsVisible) => new HighlighterConfiguration(AutoActivate: true),
+ _ => new HighlighterConfiguration(),
+ };
+
+ MouseUtilsTestHelper.ReplaceModuleSettings(ModuleName, CreateSettings(configuration));
+ }
+
+ [TestCleanup]
+ public async Task CleanupInput()
+ {
+ await CaptureFailureArtifactsBeforeCleanupAsync();
+ MouseHelper.LeftUp();
+ MouseHelper.RightUp();
+ KeyboardHelper.ReleaseKey(Key.LWin);
+ KeyboardHelper.ReleaseKey(Key.Shift);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #17")]
+ [TestCategory("Mouse Utils #18")]
+ [TestCategory("Mouse Utils #19")]
+ [TestCategory("Mouse Utils #20")]
+ public void CircleClicksAndDragsFollowCursor()
+ {
+ var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ using var activationWatcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64());
+ KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.H);
+ Assert.IsTrue(activationWatcher.Wait(5_000), "The default Win+Shift+H shortcut did not show Mouse Highlighter.");
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+
+ MouseHelper.LeftDown();
+ AssertPixelNear(centerX + 25, centerY, Color.Red, "left-button circle");
+ MouseHelper.MoveBy(180, 100, steps: 20, delayMs: 20);
+ var moved = MouseHelper.GetMousePosition();
+ AssertColorNearPoint(moved.X, moved.Y, Color.Red, 45, "left-button circle did not follow the drag");
+ MouseHelper.LeftUp();
+
+ MouseHelper.MoveTo(centerX, centerY);
+ MouseHelper.RightDown();
+ AssertPixelNear(centerX + 25, centerY, Color.Lime, "right-button circle");
+ MouseHelper.MoveBy(-180, 100, steps: 20, delayMs: 20);
+ moved = MouseHelper.GetMousePosition();
+ AssertColorNearPoint(moved.X, moved.Y, Color.Lime, 45, "right-button circle did not follow the drag");
+ MouseHelper.RightUp();
+
+ KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.H);
+ Assert.IsTrue(activationWatcher.WaitForHidden(5_000), "The default shortcut did not hide Mouse Highlighter.");
+ using var deactivatedClick = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64());
+ MouseHelper.LeftClickAt(centerX, centerY);
+ Assert.IsFalse(deactivatedClick.Wait(1_000), "A click showed Mouse Highlighter after the shortcut toggled it off.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #21")]
+ [TestCategory("Mouse Utils #22")]
+ public void ChangedShortcutTogglesAndDisabledModuleRejectsActivation()
+ {
+ var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+ using (var enabledWatcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64()))
+ {
+ KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.O);
+ Assert.IsTrue(enabledWatcher.Wait(5_000), "Changed Win+Shift+O shortcut did not show Mouse Highlighter.");
+ KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.O);
+ Assert.IsTrue(enabledWatcher.WaitForHidden(5_000), "Changed shortcut did not hide Mouse Highlighter.");
+ }
+
+ MouseUtilsTestHelper.NavigateToMouseUtilities(this);
+ MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, false);
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilUnavailable(NamedEventHelper.MouseHighlighterToggle),
+ "Mouse Highlighter trigger event remained available after disabling the module.");
+ using var disabledWatcher = new WindowShowWatcher(WindowClass);
+ KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.O);
+ Assert.IsFalse(disabledWatcher.Wait(1_500), "Mouse Highlighter appeared from its shortcut while disabled.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #23")]
+ [TestCategory("Mouse Utils #24")]
+ public void CircleAlphaRadiusAndFadeTimingAreApplied()
+ {
+ MouseUtilsTestHelper.RunWithClientAreaAnimationsEnabled(() =>
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+ var inner = (X: centerX + 60, Y: centerY);
+ var fadePoint = (X: centerX + 35, Y: centerY);
+ var outer = (X: centerX + 130, Y: centerY);
+ var innerBase = GetStablePixel(inner.X, inner.Y);
+ var fadeBase = GetStablePixel(fadePoint.X, fadePoint.Y);
+ var outerBase = GetStablePixel(outer.X, outer.Y);
+ Activate();
+
+ MouseHelper.LeftDown();
+ var expectedInner = Blend(Color.Red, innerBase, 128);
+ var expectedFadePoint = Blend(Color.Red, fadeBase, 128);
+ AssertPixelNear(inner.X, inner.Y, expectedInner, "semi-transparent left circle inside its 100px radius and 70px pressed radius");
+ AssertPixelNear(fadePoint.X, fadePoint.Y, expectedFadePoint, "semi-transparent left circle inside its pressed radius");
+ AssertPixelNear(outer.X, outer.Y, outerBase, "desktop outside the configured 100px radius");
+ var fullContrast = ColorDistance(expectedFadePoint, fadeBase);
+ var fadeStopwatch = System.Diagnostics.Stopwatch.StartNew();
+ MouseHelper.LeftUp();
+
+ var fadeStarted = WaitHelper.WaitForStable(
+ () => WindowHelper.GetPixelColor(fadePoint.X, fadePoint.Y),
+ color => ColorDistance(color, fadeBase) <= fullContrast * 0.9,
+ 4_000,
+ pollIntervalMS: 20);
+ Assert.IsTrue(fadeStarted.Succeeded, "The circle did not begin fading after the button was released.");
+ var fadeStartedAt = fadeStopwatch.Elapsed;
+ Assert.IsTrue(
+ fadeStartedAt >= TimeSpan.FromSeconds(1.4) && fadeStartedAt <= TimeSpan.FromSeconds(3.5),
+ $"Configured 2-second fade delay began after {fadeStartedAt.TotalSeconds:F1}s; expected 1.4-3.5s.");
+ var fadeCompleted = WaitHelper.WaitForStable(
+ () => WindowHelper.GetPixelColor(fadePoint.X, fadePoint.Y),
+ color => IsNear(color, fadeBase, 5),
+ 7_000,
+ requiredConsecutiveMatches: 3,
+ pollIntervalMS: 50);
+ Assert.IsTrue(fadeCompleted.Succeeded, "Circle did not return to the desktop color after its configured fade duration.");
+ var observedFadeDuration = fadeStopwatch.Elapsed - fadeStartedAt;
+ Assert.IsTrue(
+ observedFadeDuration >= TimeSpan.FromSeconds(1.5) && observedFadeDuration <= TimeSpan.FromSeconds(6.5),
+ $"Configured 4-second fade completed {observedFadeDuration.TotalSeconds:F1}s after its first visible transition; expected 1.5-6.5s.");
+
+ MouseHelper.RightDown();
+ var expectedRight = Blend(Color.Lime, fadeBase, 128);
+ AssertPixelNear(fadePoint.X, fadePoint.Y, expectedRight, "semi-transparent right circle inside its 70px radius");
+ MouseHelper.RightUp();
+ });
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void SpotlightModeUsesAlwaysColorAndRadius()
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+ var inside = (X: centerX + 40, Y: centerY);
+ var outside = (X: centerX + 120, Y: centerY);
+ var insideBase = GetStablePixel(inside.X, inside.Y);
+ var outsideBase = GetStablePixel(outside.X, outside.Y);
+ MouseHelper.MoveBy(160, 80, steps: 20, delayMs: 20);
+ var calibratedTarget = MouseHelper.GetMousePosition();
+ var movedInside = (X: calibratedTarget.X + 30, Y: calibratedTarget.Y);
+ var movedOutside = (X: calibratedTarget.X + 120, Y: calibratedTarget.Y);
+ var movedInsideBase = GetStablePixel(movedInside.X, movedInside.Y);
+ var movedOutsideBase = GetStablePixel(movedOutside.X, movedOutside.Y);
+ MouseHelper.MoveTo(centerX, centerY);
+ Activate();
+
+ AssertPixelNear(inside.X, inside.Y, insideBase, "transparent Spotlight hole inside the configured radius");
+ AssertPixelNear(outside.X, outside.Y, Blend(Color.Red, outsideBase, 128), "Spotlight tint outside the configured radius");
+
+ MouseHelper.MoveBy(160, 80, steps: 20, delayMs: 20);
+ var moved = MouseHelper.GetMousePosition();
+ Assert.IsTrue(
+ Distance(moved.X, moved.Y, calibratedTarget.X, calibratedTarget.Y) <= 10,
+ $"Calibrated relative movement ended at ({calibratedTarget.X},{calibratedTarget.Y}), but overlay movement ended at ({moved.X},{moved.Y}).");
+ AssertPixelNear(movedInside.X, movedInside.Y, movedInsideBase, "transparent Spotlight hole after cursor movement");
+ AssertPixelNear(movedOutside.X, movedOutside.Y, Blend(Color.Red, movedOutsideBase, 128), "Spotlight tint after cursor movement");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void RippleQuickClickUsesSizeIntensityAndDuration()
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ var nearPoint = (X: centerX + 10, Y: centerY);
+ var farPoint = (X: centerX + 100, Y: centerY);
+ var defaultMaximumRadius = (int)Math.Ceiling(60 * 1.4);
+ var configuredMaximumRadius = (int)Math.Ceiling(120 * 1.4);
+ var captureRadius = configuredMaximumRadius + 10;
+ MouseHelper.MoveTo(centerX - 250, centerY - 200);
+ var nearBase = GetStablePixel(nearPoint.X, nearPoint.Y);
+ var farBase = GetStablePixel(farPoint.X, farPoint.Y);
+ using var outerBaseline = CaptureSquare(centerX, centerY, captureRadius);
+ MouseHelper.MoveTo(centerX, centerY);
+ Activate();
+
+ MouseHelper.LeftClick();
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ MouseHelper.MoveTo(centerX + 250, centerY + 200);
+ var expectedHighIntensityGlow = Blend(Color.Magenta, nearBase, 95);
+ Assert.IsTrue(
+ WaitHelper.WaitForStable(
+ () => WindowHelper.GetPixelColor(nearPoint.X, nearPoint.Y),
+ color => IsNear(color, expectedHighIntensityGlow, 20),
+ 350,
+ requiredConsecutiveMatches: 1,
+ pollIntervalMS: 10).Succeeded,
+ "The configured high-intensity Ripple glow did not render near the click point.");
+ if (stopwatch.ElapsedMilliseconds < 900)
+ {
+ Thread.Sleep(900 - (int)stopwatch.ElapsedMilliseconds);
+ }
+
+ using var rippleFrame = CaptureSquare(centerX, centerY, captureRadius);
+ var changedOuterSamples = CountRipplePixels(
+ outerBaseline,
+ rippleFrame,
+ minimumRadius: defaultMaximumRadius + 4,
+ maximumRadius: configuredMaximumRadius + 4);
+ var missingRippleMessage = $"The configured 120px, 1.8-second Ripple was absent outside the default maximum radius after 900ms; " +
+ $"annulus={defaultMaximumRadius + 4}-{configuredMaximumRadius + 4}px, changed pixels={changedOuterSamples}.";
+ Assert.IsTrue(changedOuterSamples >= 12, missingRippleMessage);
+ Assert.IsTrue(
+ WaitHelper.WaitForStable(
+ () => new
+ {
+ Near = WindowHelper.GetPixelColor(nearPoint.X, nearPoint.Y),
+ Far = WindowHelper.GetPixelColor(farPoint.X, farPoint.Y),
+ },
+ sample => sample is not null && IsNear(sample.Near, nearBase, 5) && IsNear(sample.Far, farBase, 5),
+ 2_000,
+ requiredConsecutiveMatches: 5,
+ pollIntervalMS: 50).Succeeded,
+ "Ripple remained after its configured 1.8-second duration.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void RippleHeldIndicatorFollowsDragWhenEnabled()
+ {
+ AssertRippleDragTrail(expectedToFollow: true);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void RippleHeldIndicatorStaysAtPressWhenDisabled()
+ {
+ AssertRippleDragTrail(expectedToFollow: false);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void RippleRightReleasePulseIsDrawn()
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+ Activate();
+
+ MouseHelper.RightDown();
+ Thread.Sleep(350);
+ const int captureRadius = 55;
+ using var heldFrame = CaptureSquare(centerX, centerY, captureRadius);
+ MouseHelper.RightUp();
+ MouseHelper.MoveTo(centerX + 250, centerY + 200);
+ var releasePulse = WaitHelper.WaitForStable(
+ () =>
+ {
+ using var current = CaptureSquare(centerX, centerY, captureRadius);
+ return CountAxisPixelsTowardColor(heldFrame, current, Color.Yellow, minimumRadius: 18, maximumRadius: 50);
+ },
+ changedPixels => changedPixels >= 20,
+ 1_500,
+ pollIntervalMS: 20);
+ Assert.IsTrue(
+ releasePulse.Succeeded,
+ $"Right-button release did not draw the configured yellow crosshair lines; maximum qualifying axis pixels={releasePulse.LastObservation}.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void AutoActivateStartsVisible()
+ {
+ Assert.IsTrue(
+ WaitHelper.WaitForStable(
+ () => WindowControl.IsAnyWindowOfClassVisible(WindowClass),
+ visible => visible,
+ 10_000,
+ requiredConsecutiveMatches: 3).Succeeded,
+ "Mouse Highlighter did not start visible when auto-activate was enabled.");
+ }
+
+ private static HighlighterConfiguration RippleConfiguration(bool showDragTrail = true, bool showReleasePulse = true) => new(
+ LeftColor: "#FFFF00FF",
+ RightColor: "#FFFFFF00",
+ AlwaysColor: "#00000000",
+ RippleMode: true,
+ RippleSize: 120,
+ RippleIntensity: 1.35,
+ RippleDurationMs: 1_800,
+ RippleShowDragTrail: showDragTrail,
+ RippleShowReleasePulse: showReleasePulse);
+
+ private static string CreateSettings(HighlighterConfiguration configuration) => $$"""
+ {
+ "name": "MouseHighlighter",
+ "version": "1.2",
+ "properties": {
+ "activation_shortcut": { "win": true, "ctrl": false, "alt": false, "shift": true, "code": {{configuration.ShortcutCode}}, "key": "" },
+ "left_button_click_color": { "value": "{{configuration.LeftColor}}" },
+ "right_button_click_color": { "value": "{{configuration.RightColor}}" },
+ "always_color": { "value": "{{configuration.AlwaysColor}}" },
+ "highlight_radius": { "value": {{configuration.Radius}} },
+ "highlight_fade_delay_ms": { "value": {{configuration.FadeDelayMs}} },
+ "highlight_fade_duration_ms": { "value": {{configuration.FadeDurationMs}} },
+ "auto_activate": { "value": {{configuration.AutoActivate.ToString().ToLowerInvariant()}} },
+ "spotlight_mode": { "value": {{configuration.SpotlightMode.ToString().ToLowerInvariant()}} },
+ "ripple_mode": { "value": {{configuration.RippleMode.ToString().ToLowerInvariant()}} },
+ "ripple_size": { "value": {{configuration.RippleSize}} },
+ "ripple_intensity": { "value": {{configuration.RippleIntensity.ToString(System.Globalization.CultureInfo.InvariantCulture)}} },
+ "ripple_duration_ms": { "value": {{configuration.RippleDurationMs}} },
+ "ripple_show_drag_trail": { "value": {{configuration.RippleShowDragTrail.ToString().ToLowerInvariant()}} },
+ "ripple_show_release_pulse": { "value": {{configuration.RippleShowReleasePulse.ToString().ToLowerInvariant()}} }
+ }
+ }
+ """;
+
+ private static void Activate()
+ {
+ Assert.IsTrue(
+ NamedEventHelper.WaitAndSignal(NamedEventHelper.MouseHighlighterToggle),
+ "Mouse Highlighter did not create or respond to its trigger event.");
+ Assert.IsTrue(
+ WaitHelper.WaitForStable(
+ () => WindowControl.IsAnyWindowOfClassVisible(WindowClass),
+ visible => visible,
+ 5_000,
+ requiredConsecutiveMatches: 2).Succeeded,
+ "Mouse Highlighter window did not become visible.");
+ }
+
+ private void AssertRippleDragTrail(bool expectedToFollow)
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+ Activate();
+
+ MouseHelper.LeftDown();
+ Thread.Sleep(350);
+ AssertRippleRingNear(centerX, centerY, "held Ripple indicator did not appear at the press point");
+ MouseHelper.MoveBy(180, 80, steps: 20, delayMs: 20);
+ var moved = MouseHelper.GetMousePosition();
+ if (expectedToFollow)
+ {
+ AssertRippleRingNear(moved.X, moved.Y, "held Ripple indicator did not follow the drag");
+ }
+ else
+ {
+ AssertRippleRingNear(centerX, centerY, "held Ripple indicator moved despite drag trail being disabled");
+ }
+
+ MouseHelper.LeftUp();
+ }
+
+ private static void AssertRippleRingNear(int centerX, int centerY, string message)
+ {
+ Assert.IsTrue(
+ WaitHelper.WaitForStable(
+ () => Enumerable.Range(42, 28)
+ .SelectMany(radius => new[]
+ {
+ WindowHelper.GetPixelColor(centerX + radius, centerY),
+ WindowHelper.GetPixelColor(centerX - radius, centerY),
+ WindowHelper.GetPixelColor(centerX, centerY + radius),
+ WindowHelper.GetPixelColor(centerX, centerY - radius),
+ })
+ .Count(color => color.R > color.G + 30 && color.B > color.G + 30),
+ count => count >= 2,
+ 1_500,
+ requiredConsecutiveMatches: 1,
+ pollIntervalMS: 30).Succeeded,
+ message);
+ }
+
+ private static void AssertColorNearPoint(int centerX, int centerY, Color expected, int searchRadius, string message)
+ {
+ Assert.IsTrue(
+ WaitHelper.WaitForStable(
+ () => Enumerable.Range(-searchRadius, (searchRadius * 2) + 1)
+ .Where(offset => offset % 5 == 0)
+ .SelectMany(offset => new[]
+ {
+ WindowHelper.GetPixelColor(centerX + offset, centerY),
+ WindowHelper.GetPixelColor(centerX, centerY + offset),
+ })
+ .Any(color => IsNear(color, expected, 5)),
+ found => found,
+ 2_000,
+ requiredConsecutiveMatches: 1,
+ pollIntervalMS: 50).Succeeded,
+ message);
+ }
+
+ private static Color GetStablePixel(int x, int y)
+ {
+ Color? previous = null;
+ var result = WaitHelper.WaitForStable(
+ () => WindowHelper.GetPixelColor(x, y),
+ color =>
+ {
+ var matchesPrevious = previous.HasValue && color.ToArgb() == previous.Value.ToArgb();
+ previous = color;
+ return matchesPrevious;
+ },
+ 2_000,
+ requiredConsecutiveMatches: 4,
+ pollIntervalMS: 100);
+ return result.LastObservation;
+ }
+
+ private static double Distance(int x1, int y1, int x2, int y2)
+ {
+ var deltaX = x2 - x1;
+ var deltaY = y2 - y1;
+ return Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
+ }
+
+ private static Bitmap CaptureSquare(int centerX, int centerY, int radius)
+ {
+ var bitmap = new Bitmap((radius * 2) + 1, (radius * 2) + 1);
+ try
+ {
+ using var graphics = Graphics.FromImage(bitmap);
+ graphics.CopyFromScreen(centerX - radius, centerY - radius, 0, 0, bitmap.Size);
+ return bitmap;
+ }
+ catch
+ {
+ bitmap.Dispose();
+ throw;
+ }
+ }
+
+ private static int CountRipplePixels(Bitmap baseline, Bitmap current, int minimumRadius, int maximumRadius)
+ {
+ var center = baseline.Width / 2;
+ var minimumSquared = minimumRadius * minimumRadius;
+ var maximumSquared = maximumRadius * maximumRadius;
+ var count = 0;
+ for (var y = 0; y < baseline.Height; y++)
+ {
+ var deltaY = y - center;
+ for (var x = 0; x < baseline.Width; x++)
+ {
+ var deltaX = x - center;
+ var distanceSquared = (deltaX * deltaX) + (deltaY * deltaY);
+ if (distanceSquared < minimumSquared || distanceSquared > maximumSquared)
+ {
+ continue;
+ }
+
+ var before = baseline.GetPixel(x, y);
+ var after = current.GetPixel(x, y);
+ if (ColorDistance(after, Color.Magenta) <= ColorDistance(before, Color.Magenta) - 6)
+ {
+ count++;
+ }
+ }
+ }
+
+ return count;
+ }
+
+ private static int CountAxisPixelsTowardColor(Bitmap baseline, Bitmap current, Color target, int minimumRadius, int maximumRadius)
+ {
+ var center = baseline.Width / 2;
+ var count = 0;
+ for (var radius = minimumRadius; radius <= maximumRadius; radius++)
+ {
+ for (var bandOffset = -2; bandOffset <= 2; bandOffset++)
+ {
+ var points = new[]
+ {
+ (X: center + radius, Y: center + bandOffset),
+ (X: center - radius, Y: center + bandOffset),
+ (X: center + bandOffset, Y: center + radius),
+ (X: center + bandOffset, Y: center - radius),
+ };
+ foreach (var point in points)
+ {
+ var beforeDistance = ColorDistance(baseline.GetPixel(point.X, point.Y), target);
+ var afterDistance = ColorDistance(current.GetPixel(point.X, point.Y), target);
+ if (afterDistance <= 60 && afterDistance <= beforeDistance - 20)
+ {
+ count++;
+ }
+ }
+ }
+ }
+
+ return count;
+ }
+
+ private static double ColorDistance(Color first, Color second)
+ {
+ var red = first.R - second.R;
+ var green = first.G - second.G;
+ var blue = first.B - second.B;
+ return Math.Sqrt((red * red) + (green * green) + (blue * blue));
+ }
+
+ private static Color Blend(Color foreground, Color background, int alpha)
+ {
+ var inverse = 255 - alpha;
+ return Color.FromArgb(
+ ((foreground.R * alpha) + (background.R * inverse) + 127) / 255,
+ ((foreground.G * alpha) + (background.G * inverse) + 127) / 255,
+ ((foreground.B * alpha) + (background.B * inverse) + 127) / 255);
+ }
+
+ private static void AssertPixelNear(int x, int y, Color expected, string description)
+ {
+ Assert.IsTrue(
+ WaitHelper.WaitForStable(
+ () => WindowHelper.GetPixelColor(x, y),
+ color => IsNear(color, expected, 5),
+ 5_000,
+ requiredConsecutiveMatches: 2,
+ pollIntervalMS: 50).Succeeded,
+ $"Unexpected {description} at ({x},{y}); expected {expected}, observed {WindowHelper.GetPixelColor(x, y)}.");
+ }
+
+ private static void AssertColorNear(Color actual, Color expected, int tolerance, string message) =>
+ Assert.IsTrue(IsNear(actual, expected, tolerance), $"{message}. Expected {expected}; observed {actual}.");
+
+ private static bool IsNear(Color actual, Color expected, int tolerance) =>
+ Math.Abs(actual.R - expected.R) <= tolerance &&
+ Math.Abs(actual.G - expected.G) <= tolerance &&
+ Math.Abs(actual.B - expected.B) <= tolerance;
+
+ private sealed record HighlighterConfiguration(
+ int ShortcutCode = (int)Key.H,
+ string LeftColor = "#FFFF0000",
+ string RightColor = "#FF00FF00",
+ string AlwaysColor = "#00000000",
+ int Radius = 50,
+ int FadeDelayMs = 100,
+ int FadeDurationMs = 300,
+ bool AutoActivate = false,
+ bool SpotlightMode = false,
+ bool RippleMode = false,
+ int RippleSize = 60,
+ double RippleIntensity = 0.7,
+ int RippleDurationMs = 480,
+ bool RippleShowDragTrail = true,
+ bool RippleShowReleasePulse = true);
+}
diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseJumpTests.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseJumpTests.cs
new file mode 100644
index 0000000000..4f928f7aa1
--- /dev/null
+++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseJumpTests.cs
@@ -0,0 +1,346 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Diagnostics;
+using System.Drawing;
+using Microsoft.PowerToys.UITest.Next;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace MouseUtils.UITests;
+
+[TestClass]
+public class MouseJumpTests : UITestBase
+{
+ private const string ModuleName = "MouseJump";
+ private const string ProcessName = "PowerToys.MouseJump.WinUI3";
+ private const string WindowTitle = "MouseJump.WinUI3";
+ private const string ToggleId = "MouseUtils_MouseJumpToggleId";
+ private static readonly IDisposable ModuleSettings = SettingsConfigHelper.PreserveModuleSettings(ModuleName);
+
+ public MouseJumpTests()
+ : base(PowerToysModule.PowerToysSettings, enableModules: new[] { ModuleName })
+ {
+ }
+
+ protected override IReadOnlyList StaleProcessNames { get; } = new[]
+ {
+ "PowerToys",
+ "PowerToys.Settings",
+ "PowerToys.FancyZonesEditor",
+ ProcessName,
+ "PowerToys.MouseJumpUI",
+ };
+
+ [ClassCleanup]
+ public static void RestoreModuleSettings() => ModuleSettings.Dispose();
+
+ protected override void PrepareTestState()
+ {
+ var configuration = TestContext.TestName switch
+ {
+ nameof(ChangedShortcutActivatesPreview) => new MouseJumpConfiguration(ShortcutCode: (int)Key.Z),
+ nameof(ThumbnailSizeSetsPreviewBounds) => new MouseJumpConfiguration(Width: 640, Height: 480, PreviewType: "Compact"),
+ nameof(CustomPreviewStyleRendersConfiguredColors) => new MouseJumpConfiguration(
+ Width: 640,
+ Height: 480,
+ PreviewType: "Custom",
+ BackgroundColor1: "#FF00FF",
+ BackgroundColor2: "#FF00FF",
+ BorderThickness: 12,
+ BorderColor: "#00FF00",
+ BorderPadding: 12,
+ BezelThickness: 12,
+ BezelColor: "#FFFF00",
+ ScreenMargin: 10,
+ ScreenColor1: "#00FFFF",
+ ScreenColor2: "#00FFFF"),
+ _ => new MouseJumpConfiguration(),
+ };
+
+ MouseUtilsTestHelper.ReplaceModuleSettings(ModuleName, CreateSettings(configuration));
+ }
+
+ [TestCleanup]
+ public async Task CleanupWindows()
+ {
+ await CaptureFailureArtifactsBeforeCleanupAsync();
+ KeyboardHelper.SendKeys(Key.Esc);
+ WindowControl.TryKillProcessTreeByNameAndWait("notepad", 5_000);
+ WindowControl.TryKillProcessTreeByNameAndWait(ProcessName, 10_000);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #39")]
+ public void SettingsPageLoadsAndProcessRuns()
+ {
+ MouseUtilsTestHelper.NavigateToMouseUtilities(this);
+ var toggle = MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, true);
+ Assert.IsTrue(toggle.IsOn, "Mouse Jump toggle should be on.");
+ Assert.IsTrue(WaitForProcess(expected: true), "Mouse Jump WinUI3 process did not start.");
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilAvailable(NamedEventHelper.MouseJumpShowPreview),
+ "Mouse Jump did not create its show-preview event.");
+ var hwnd = WaitForPreviewHwnd();
+ Assert.AreNotEqual(IntPtr.Zero, hwnd, "Mouse Jump did not create its hidden preview HWND.");
+
+ using var shortcutWatcher = new WindowShowWatcher(GetPreviewWindow().ClassName, hwnd.ToInt64());
+ KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.D);
+ Assert.IsTrue(shortcutWatcher.Wait(10_000), "The default Win+Shift+D shortcut did not show Mouse Jump.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #39")]
+ [TestCategory("Mouse Utils #41")]
+ [TestCategory("Mouse Utils #45")]
+ public void PreviewClickMovesCursorAndDisableStopsActivation()
+ {
+ var preview = ShowPreview();
+ var previewClassName = GetPreviewWindow().ClassName;
+ var bounds = WindowHelper.GetWindowBounds(new IntPtr(preview.WindowHandle));
+ var clickX = bounds.Left + ((bounds.Right - bounds.Left) / 2);
+ var clickY = bounds.Top + ((bounds.Bottom - bounds.Top) / 2);
+ using (var watcher = new WindowShowWatcher(GetPreviewWindow().ClassName, preview.WindowHandle))
+ {
+ var previewHwnd = new IntPtr(preview.WindowHandle);
+ WindowControl.WaitForForeground(previewHwnd, 2_000);
+ Assert.IsTrue(
+ WindowControl.IsPointOwnedByWindow(previewHwnd, clickX, clickY),
+ $"Mouse Jump did not own its preview midpoint ({clickX},{clickY}) before the click.");
+ MouseHelper.LeftClickAt(clickX, clickY);
+ Assert.IsTrue(watcher.WaitForHidden(5_000), "Clicking the Mouse Jump preview did not hide it.");
+ }
+
+ var primary = MonitorInfo.GetPrimary();
+ Assert.IsNotNull(primary, "No primary monitor was reported.");
+ var expectedX = primary.Left + (primary.Width / 2);
+ var expectedY = primary.Top + (primary.Height / 2);
+ var cursor = MouseHelper.GetMousePosition();
+ Assert.IsTrue(
+ Distance(cursor.X, cursor.Y, expectedX, expectedY) <= 20,
+ $"Preview-center click mapped to ({cursor.X},{cursor.Y}), expected primary midpoint ({expectedX},{expectedY}).");
+
+ MouseUtilsTestHelper.NavigateToMouseUtilities(this);
+ MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, false);
+ Assert.IsTrue(WaitForProcess(expected: false), "Mouse Jump process did not exit after disabling the module.");
+ using var disabledActivation = new WindowShowWatcher(previewClassName);
+ KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.D);
+ Assert.IsFalse(disabledActivation.Wait(2_000), "Mouse Jump preview appeared while the module was disabled.");
+ Assert.IsFalse(WaitForProcess(expected: true, timeoutMs: 1_000), "Mouse Jump process restarted while the module was disabled.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #40")]
+ public void ChangedShortcutActivatesPreview()
+ {
+ var hwnd = WaitForPreviewHwnd();
+ var className = GetPreviewWindow().ClassName;
+ using (var defaultShortcut = new WindowShowWatcher(className, hwnd.ToInt64()))
+ {
+ KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.D);
+ Assert.IsFalse(defaultShortcut.Wait(1_500), "The old Mouse Jump shortcut still showed the preview.");
+ }
+
+ using var changedShortcut = new WindowShowWatcher(className, hwnd.ToInt64());
+ KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.Z);
+ Assert.IsTrue(changedShortcut.Wait(10_000), "Changed Win+Shift+Z shortcut did not show Mouse Jump.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void FocusLossDismissesPreview()
+ {
+ var preview = ShowPreview();
+ Assert.IsTrue(
+ WindowControl.WaitForForeground(new IntPtr(preview.WindowHandle), 5_000, 2),
+ $"Mouse Jump was not foreground before the focus-loss transition. Current foreground: {WindowControl.GetForegroundWindowInfo()}.");
+ using var focusWatcher = new WindowShowWatcher(GetPreviewWindow().ClassName, preview.WindowHandle);
+ using var notepad = Process.Start(new ProcessStartInfo("notepad.exe") { UseShellExecute = true });
+ Assert.IsNotNull(notepad, "Could not start the focus-loss Notepad fixture.");
+ var notepadWindow = WindowsFinder.WaitForWindowByProcess("notepad", 10_000);
+ Assert.IsNotNull(notepadWindow, "Notepad did not create a visible window.");
+ Assert.IsTrue(
+ WindowControl.WaitForForeground(new IntPtr(notepadWindow.WindowHandle), 5_000, 2),
+ "Notepad could not gain foreground to exercise Mouse Jump focus-loss dismissal.");
+ Assert.IsTrue(focusWatcher.WaitForHidden(5_000), "Mouse Jump did not dismiss after losing focus.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void ThumbnailSizeSetsPreviewBounds()
+ {
+ var preview = ShowPreview();
+ var bounds = WindowHelper.GetWindowBounds(new IntPtr(preview.WindowHandle));
+ var width = bounds.Right - bounds.Left;
+ var height = bounds.Bottom - bounds.Top;
+ Assert.IsTrue(width is >= 620 and <= 650, $"Configured 640px preview width rendered as {width}px.");
+ Assert.IsTrue(height <= 480, $"Configured 480px maximum preview height rendered as {height}px.");
+
+ var primary = MonitorInfo.GetPrimary();
+ Assert.IsNotNull(primary, "No primary monitor was reported.");
+ var renderedContentAspectRatio = (width - 12d) / (height - 12d);
+ var displayAspectRatio = primary.Width / (double)primary.Height;
+ Assert.IsTrue(
+ Math.Abs(renderedContentAspectRatio - displayAspectRatio) <= 0.02,
+ $"Preview content aspect ratio {renderedContentAspectRatio:F3} did not preserve display ratio {displayAspectRatio:F3}.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void CustomPreviewStyleRendersConfiguredColors()
+ {
+ var preview = ShowPreview();
+ var path = Path.Combine(TestContext.TestResultsDirectory ?? Path.GetTempPath(), $"mouse-jump-custom-{Guid.NewGuid():N}.png");
+ WindowHelper.CaptureVisibleWindow(new IntPtr(preview.WindowHandle), path);
+ try
+ {
+ using var image = new Bitmap(path);
+ var colors = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["#FF00FF"] = 0,
+ ["#00FF00"] = 0,
+ ["#FFFF00"] = 0,
+ };
+
+ for (var y = 0; y < image.Height; y += 2)
+ {
+ for (var x = 0; x < image.Width; x += 2)
+ {
+ var pixel = image.GetPixel(x, y);
+ foreach (var key in colors.Keys.ToArray())
+ {
+ var expected = ColorTranslator.FromHtml(key);
+ if (IsNear(pixel, expected, 4))
+ {
+ colors[key]++;
+ }
+ }
+ }
+ }
+
+ Assert.IsTrue(colors["#FF00FF"] > 100, $"Custom canvas background was not rendered; magenta sample count={colors["#FF00FF"]}.");
+ Assert.IsTrue(colors["#00FF00"] > 100, $"Custom canvas border was not rendered; green sample count={colors["#00FF00"]}.");
+ Assert.IsTrue(colors["#FFFF00"] > 100, $"Custom screen bezel was not rendered; yellow sample count={colors["#FFFF00"]}.");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ private static string CreateSettings(MouseJumpConfiguration configuration) => $$"""
+ {
+ "name": "MouseJump",
+ "version": "1.1",
+ "properties": {
+ "activation_shortcut": { "win": true, "ctrl": false, "alt": false, "shift": true, "code": {{configuration.ShortcutCode}}, "key": "" },
+ "thumbnail_size": { "width": {{configuration.Width}}, "height": {{configuration.Height}} },
+ "preview_type": "{{configuration.PreviewType}}",
+ "background_color_1": "{{configuration.BackgroundColor1}}",
+ "background_color_2": "{{configuration.BackgroundColor2}}",
+ "border_thickness": {{configuration.BorderThickness}},
+ "border_color": "{{configuration.BorderColor}}",
+ "border_3d_depth": 0,
+ "border_padding": {{configuration.BorderPadding}},
+ "bezel_thickness": {{configuration.BezelThickness}},
+ "bezel_color": "{{configuration.BezelColor}}",
+ "bezel_3d_depth": 0,
+ "screen_margin": {{configuration.ScreenMargin}},
+ "screen_color_1": "{{configuration.ScreenColor1}}",
+ "screen_color_2": "{{configuration.ScreenColor2}}"
+ }
+ }
+ """;
+
+ private static Session ShowPreview()
+ {
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilAvailable(NamedEventHelper.MouseJumpShowPreview),
+ "Mouse Jump module did not create its show-preview event.");
+ if (!WaitForProcess(expected: true, timeoutMs: 1_000))
+ {
+ KeyboardHelper.SendKeys(Key.LWin, Key.Shift, Key.D);
+ Assert.IsTrue(WaitForProcess(expected: true), "Mouse Jump WinUI3 process did not start after activation.");
+ }
+
+ Assert.AreNotEqual(IntPtr.Zero, WaitForPreviewHwnd(), "Mouse Jump hidden preview HWND was not created.");
+
+ for (var attempt = 1; attempt <= 3; attempt++)
+ {
+ Assert.IsTrue(
+ NamedEventHelper.WaitAndSignal(NamedEventHelper.MouseJumpShowPreview, 10_000),
+ "Mouse Jump show-preview event was unavailable.");
+ var preview = WindowsFinder.WaitForWindowByApp(
+ ProcessName,
+ window => window.Title.Equals(WindowTitle, StringComparison.OrdinalIgnoreCase),
+ timeoutMS: 5_000);
+ if (preview is not null)
+ {
+ WindowControl.WaitForForeground(new IntPtr(preview.WindowHandle), 2_000);
+ return preview;
+ }
+ }
+
+ Assert.Fail("Mouse Jump preview did not become visible after three show-event attempts.");
+ return null!;
+ }
+
+ private static WindowControl.ProcessWindow GetPreviewWindow()
+ {
+ var processIds = Process.GetProcessesByName(ProcessName).Select(process => process.Id).ToArray();
+ return WindowControl.EnumerateProcessWindows(processIds)
+ .FirstOrDefault(window => window.Title.Equals(WindowTitle, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static IntPtr WaitForPreviewHwnd()
+ {
+ var result = WaitHelper.WaitForStable(
+ GetPreviewWindow,
+ window => window.Hwnd != IntPtr.Zero,
+ 15_000,
+ requiredConsecutiveMatches: 2,
+ pollIntervalMS: 100);
+ return result.LastObservation.Hwnd;
+ }
+
+ private static bool WaitForProcess(bool expected, int timeoutMs = 15_000)
+ {
+ return WaitHelper.WaitForStable(
+ () => Process.GetProcessesByName(ProcessName).Length > 0,
+ running => running == expected,
+ timeoutMs,
+ requiredConsecutiveMatches: 2,
+ pollIntervalMS: 100).Succeeded;
+ }
+
+ private static double Distance(int x1, int y1, int x2, int y2)
+ {
+ var deltaX = x2 - x1;
+ var deltaY = y2 - y1;
+ return Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
+ }
+
+ private static bool IsNear(Color actual, Color expected, int tolerance) =>
+ Math.Abs(actual.R - expected.R) <= tolerance &&
+ Math.Abs(actual.G - expected.G) <= tolerance &&
+ Math.Abs(actual.B - expected.B) <= tolerance;
+
+ private sealed record MouseJumpConfiguration(
+ int ShortcutCode = (int)Key.D,
+ int Width = 800,
+ int Height = 600,
+ string PreviewType = "Bezelled",
+ string BackgroundColor1 = "#0D57D2",
+ string BackgroundColor2 = "#0344C0",
+ int BorderThickness = 6,
+ string BorderColor = "#0078D4",
+ int BorderPadding = 4,
+ int BezelThickness = 12,
+ string BezelColor = "#222222",
+ int ScreenMargin = 4,
+ string ScreenColor1 = "#191970",
+ string ScreenColor2 = "#191970");
+ }
diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/MousePointerCrosshairsTests.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/MousePointerCrosshairsTests.cs
new file mode 100644
index 0000000000..ed0a2c930a
--- /dev/null
+++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/MousePointerCrosshairsTests.cs
@@ -0,0 +1,414 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Drawing;
+using Microsoft.PowerToys.UITest.Next;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace MouseUtils.UITests;
+
+[TestClass]
+public class MousePointerCrosshairsTests : UITestBase
+{
+ private const string ModuleName = "MousePointerCrosshairs";
+ private const string ToggleId = "MouseUtils_MousePointerCrosshairsToggleId";
+ private const string WindowClass = "MousePointerCrosshairs";
+ private const string CrosshairsColor = "#FF0000";
+
+ private static readonly IDisposable ModuleSettings = SettingsConfigHelper.PreserveModuleSettings(ModuleName);
+
+ public MousePointerCrosshairsTests()
+ : base(PowerToysModule.PowerToysSettings, enableModules: new[] { ModuleName })
+ {
+ }
+
+ protected override void PrepareTestState()
+ {
+ var configuration = TestContext.TestName switch
+ {
+ "DisabledModuleRejectsActivationAndChangedShortcutWorks" => new CrosshairsConfiguration(ShortcutCode: (int)Key.O),
+ "HorizontalOrientationFixedLengthAndBorderAreApplied" => new CrosshairsConfiguration(
+ Color: "#00FF00",
+ Radius: 35,
+ Thickness: 12,
+ BorderColor: "#0000FF",
+ BorderSize: 6,
+ Orientation: 2,
+ FixedLengthEnabled: true,
+ FixedLength: 120),
+ nameof(OpacityBlendsWithDesktop) => new CrosshairsConfiguration(Opacity: 50),
+ "AutoActivateStartsVisible" => new CrosshairsConfiguration(AutoActivate: true),
+ _ => new CrosshairsConfiguration(),
+ };
+
+ MouseUtilsTestHelper.ReplaceModuleSettings(ModuleName, CreateSettings(configuration));
+ }
+
+ [ClassCleanup]
+ public static void RestoreModuleSettings() => ModuleSettings.Dispose();
+
+ [TestCleanup]
+ public async Task CleanupInput()
+ {
+ await CaptureFailureArtifactsBeforeCleanupAsync();
+ MouseHelper.LeftUp();
+ MouseHelper.RightUp();
+ KeyboardHelper.ReleaseKey(Key.Ctrl);
+ KeyboardHelper.ReleaseKey(Key.Shift);
+ KeyboardHelper.ReleaseKey(Key.Alt);
+ KeyboardHelper.ReleaseKey(Key.LWin);
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #29")]
+ [TestCategory("Mouse Utils #30")]
+ public void ActivationTracksCursorAndHides()
+ {
+ MouseUtilsTestHelper.NavigateToMouseUtilities(this);
+ MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, true);
+ Key[] activationKeys = [Key.LWin, Key.Alt, Key.P];
+
+ var crosshairsWindow = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+ Assert.IsFalse(crosshairsWindow.IsVisible, "Crosshairs should start hidden before activation.");
+
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+
+ MouseUtilsTestHelper.Step(this, "Signaling the Crosshairs named event as a positive detector control");
+ using (var detectorControl = new WindowShowWatcher(WindowClass, crosshairsWindow.Hwnd.ToInt64()))
+ {
+ Assert.IsTrue(
+ NamedEventHelper.WaitAndSignal(NamedEventHelper.MouseCrosshairsToggle, 10_000),
+ "The Crosshairs named event was not created by the enabled module.");
+ Assert.IsTrue(
+ detectorControl.Wait(5_000),
+ "The known-good named event did not produce a Crosshairs SHOW event.");
+ AssertCrosshairsAt(centerX, centerY);
+
+ Assert.IsTrue(
+ NamedEventHelper.TrySignal(NamedEventHelper.MouseCrosshairsToggle),
+ "The Crosshairs named event disappeared before the positive control could hide the overlay.");
+ Assert.IsTrue(
+ detectorControl.WaitForHidden(5_000),
+ $"The positive-control Crosshairs HWND did not hide. Events: {string.Join(", ", detectorControl.Events)}");
+ }
+
+ using var watcher = new WindowShowWatcher(WindowClass, crosshairsWindow.Hwnd.ToInt64());
+ MouseUtilsTestHelper.Step(this, "Sending the Crosshairs shortcut after the module-ready positive control");
+ KeyboardHelper.SendKeys(activationKeys);
+ var shown = watcher.Wait(10_000);
+
+ MouseUtilsTestHelper.Step(this, $"Crosshairs window events: {string.Join(", ", watcher.Events)}");
+ Assert.IsTrue(shown, "Crosshairs did not emit a SHOW event after the activation shortcut.");
+ AssertCrosshairsAt(centerX, centerY);
+
+ MouseUtilsTestHelper.Step(this, "Moving the cursor with relative input and checking the new crosshair origin");
+ MouseHelper.MoveBy(160, 100, steps: 10);
+ var moved = MouseHelper.GetMousePosition();
+ Assert.IsTrue(
+ Math.Abs(moved.X - centerX) > 50 || Math.Abs(moved.Y - centerY) > 50,
+ $"Relative input did not move the cursor far enough: ({centerX},{centerY}) -> ({moved.X},{moved.Y}).");
+ var movedOrigin = AssertCrosshairsNear(moved.X, moved.Y);
+ Assert.IsTrue(
+ Math.Abs(movedOrigin.X - centerX) > 50 || Math.Abs(movedOrigin.Y - centerY) > 50,
+ $"Crosshairs did not follow the cursor away from ({centerX},{centerY}); observed origin ({movedOrigin.X},{movedOrigin.Y}).");
+
+ MouseUtilsTestHelper.Step(this, "Sending the shortcut again and waiting for the exact Crosshairs HWND to hide");
+ KeyboardHelper.SendKeys(activationKeys);
+ Assert.IsTrue(
+ watcher.WaitForHidden(5_000),
+ $"Crosshairs HWND 0x{crosshairsWindow.Hwnd:X} did not hide. Events: {string.Join(", ", watcher.Events)}");
+ Assert.IsFalse(WindowControl.IsAnyWindowOfClassVisible(WindowClass), "Crosshairs remained visible after the second shortcut.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #31")]
+ [TestCategory("Mouse Utils #32")]
+ public void DisabledModuleRejectsActivationAndChangedShortcutWorks()
+ {
+ MouseUtilsTestHelper.NavigateToMouseUtilities(this);
+ var toggle = MouseUtilsTestHelper.SetModuleEnabled(this, ToggleId, false);
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilUnavailable(NamedEventHelper.MouseCrosshairsToggle),
+ "Crosshairs trigger event remained available after disabling the module.");
+
+ using (var disabledWatcher = new WindowShowWatcher(WindowClass))
+ {
+ KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.O);
+ Assert.IsFalse(disabledWatcher.Wait(1_500), "The changed shortcut showed Crosshairs while the module was disabled.");
+ }
+
+ toggle.Toggle(true);
+ Assert.IsTrue(toggle.WaitForProperty("ToggleState", "On", 5_000), "Crosshairs toggle did not return to On.");
+ Assert.IsTrue(
+ NamedEventHelper.WaitUntilAvailable(NamedEventHelper.MouseCrosshairsToggle),
+ "Crosshairs trigger event was not recreated after enabling the module.");
+ var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+ using var enabledWatcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64());
+ KeyboardHelper.SendKeys(Key.LWin, Key.Alt, Key.O);
+ Assert.IsTrue(enabledWatcher.Wait(5_000), "The changed Win+Alt+O shortcut did not show Crosshairs.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ [TestCategory("Mouse Utils #33")]
+ public void HorizontalOrientationFixedLengthAndBorderAreApplied()
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+ Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.MouseCrosshairsToggle), "Crosshairs trigger event was unavailable.");
+
+ var result = WaitHelper.WaitForStable(
+ () => new
+ {
+ Gap = WindowHelper.GetPixelColorHex(centerX + 25, centerY),
+ BorderStart = WindowHelper.GetPixelColorHex(centerX + 31, centerY),
+ Core = WindowHelper.GetPixelColorHex(centerX + 45, centerY),
+ CoreThickness = WindowHelper.GetPixelColorHex(centerX + 60, centerY + 5),
+ BorderThickness = WindowHelper.GetPixelColorHex(centerX + 60, centerY + 9),
+ OutsideThickness = WindowHelper.GetPixelColorHex(centerX + 60, centerY + 13),
+ CoreEnd = WindowHelper.GetPixelColorHex(centerX + 150, centerY),
+ BorderEnd = WindowHelper.GetPixelColorHex(centerX + 158, centerY),
+ Vertical = WindowHelper.GetPixelColorHex(centerX, centerY - 60),
+ Beyond = WindowHelper.GetPixelColorHex(centerX + 165, centerY),
+ },
+ sample => sample is not null &&
+ !sample.Gap.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) &&
+ sample.BorderStart.Equals("#0000FF", StringComparison.OrdinalIgnoreCase) &&
+ sample.Core.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) &&
+ sample.CoreThickness.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) &&
+ sample.BorderThickness.Equals("#0000FF", StringComparison.OrdinalIgnoreCase) &&
+ !sample.OutsideThickness.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) &&
+ !sample.OutsideThickness.Equals("#0000FF", StringComparison.OrdinalIgnoreCase) &&
+ sample.CoreEnd.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) &&
+ sample.BorderEnd.Equals("#0000FF", StringComparison.OrdinalIgnoreCase) &&
+ !sample.Vertical.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) &&
+ !sample.Vertical.Equals("#0000FF", StringComparison.OrdinalIgnoreCase) &&
+ !sample.Beyond.Equals("#00FF00", StringComparison.OrdinalIgnoreCase) &&
+ !sample.Beyond.Equals("#0000FF", StringComparison.OrdinalIgnoreCase),
+ timeoutMS: 5_000,
+ pollIntervalMS: 100);
+
+ Assert.IsTrue(result.Succeeded, $"Horizontal fixed-length Crosshairs geometry did not match. Last sample: {result.LastObservation}.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void OpacityBlendsWithDesktop()
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+ var probeX = centerX + 60;
+ Color? previous = null;
+ var baseline = WaitHelper.WaitForStable(
+ () => WindowHelper.GetPixelColor(probeX, centerY),
+ color =>
+ {
+ var matchesPrevious = previous.HasValue && color.ToArgb() == previous.Value.ToArgb();
+ previous = color;
+ return matchesPrevious;
+ },
+ 2_000,
+ requiredConsecutiveMatches: 4,
+ pollIntervalMS: 100).LastObservation;
+
+ Assert.IsTrue(NamedEventHelper.WaitAndSignal(NamedEventHelper.MouseCrosshairsToggle), "Crosshairs trigger event was unavailable.");
+ var expected = Blend(Color.Red, baseline, 128);
+ var result = WaitHelper.WaitForStable(
+ () => WindowHelper.GetPixelColor(probeX, centerY),
+ color => IsNear(color, expected, 5),
+ 5_000,
+ requiredConsecutiveMatches: 3,
+ pollIntervalMS: 100);
+
+ Assert.IsTrue(result.Succeeded, $"50% Crosshairs opacity did not blend as expected. Expected {expected}; observed {result.LastObservation}.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void AutoActivateStartsVisible()
+ {
+ var result = WaitHelper.WaitForStable(
+ () => WindowControl.IsAnyWindowOfClassVisible(WindowClass),
+ visible => visible,
+ timeoutMS: 10_000,
+ requiredConsecutiveMatches: 3,
+ pollIntervalMS: 100);
+ Assert.IsTrue(result.Succeeded, "Crosshairs did not start visible when auto-activate was enabled.");
+ }
+
+ [TestMethod]
+ [TestCategory("MouseUtils")]
+ public void GlidingCursorMovesAndEscapeCancels()
+ {
+ WindowHelper.MinimizeWindow(new IntPtr(Session.WindowHandle));
+ var (centerX, centerY) = WindowHelper.GetScreenCenter();
+ MouseHelper.MoveTo(centerX, centerY);
+ var window = MouseUtilsTestHelper.WaitForWindowClass(WindowClass);
+ using var watcher = new WindowShowWatcher(WindowClass, window.Hwnd.ToInt64());
+
+ KeyboardHelper.PressKey(Key.LWin);
+ KeyboardHelper.PressKey(Key.Alt);
+ try
+ {
+ KeyboardHelper.SendKey(Key.OemPeriod);
+ }
+ finally
+ {
+ KeyboardHelper.ReleaseKey(Key.Alt);
+ KeyboardHelper.ReleaseKey(Key.LWin);
+ }
+
+ Assert.IsTrue(watcher.Wait(5_000), "The gliding-cursor shortcut did not show Crosshairs.");
+ var reset = WaitHelper.WaitForStable(
+ MouseHelper.GetMousePosition,
+ position => position.X < centerX - 100,
+ 5_000,
+ requiredConsecutiveMatches: 1,
+ pollIntervalMS: 20);
+ Assert.IsTrue(reset.Succeeded, $"Gliding cursor did not reset to the left side. Last position: {reset.LastObservation}.");
+ var resetPosition = reset.LastObservation;
+ var moved = WaitHelper.WaitForStable(
+ MouseHelper.GetMousePosition,
+ position => position.X >= resetPosition.X + 100,
+ 5_000,
+ requiredConsecutiveMatches: 2,
+ pollIntervalMS: 20);
+ Assert.IsTrue(moved.Succeeded, $"Gliding cursor did not travel at least 100px after reset. Reset: {resetPosition}; last: {moved.LastObservation}.");
+
+ KeyboardHelper.SendKeys(Key.Esc);
+ Assert.IsTrue(watcher.WaitForHidden(5_000), "Escape did not hide the gliding-cursor Crosshairs.");
+ var stoppedAt = MouseHelper.GetMousePosition();
+ var stopped = WaitHelper.WaitForStable(
+ MouseHelper.GetMousePosition,
+ position => Math.Abs(position.X - stoppedAt.X) <= 2 && Math.Abs(position.Y - stoppedAt.Y) <= 2,
+ 2_000,
+ requiredConsecutiveMatches: 5,
+ pollIntervalMS: 100);
+ Assert.IsTrue(stopped.Succeeded, "The cursor continued gliding after Escape.");
+ }
+
+ private static void AssertCrosshairsAt(int cursorX, int cursorY)
+ {
+ var result = WaitHelper.WaitForStable(
+ () => new[]
+ {
+ WindowHelper.GetPixelColorHex(cursorX - 60, cursorY),
+ WindowHelper.GetPixelColorHex(cursorX + 60, cursorY),
+ WindowHelper.GetPixelColorHex(cursorX, cursorY - 60),
+ WindowHelper.GetPixelColorHex(cursorX, cursorY + 60),
+ },
+ colors => colors is not null && colors.All(color => color.Equals(CrosshairsColor, StringComparison.OrdinalIgnoreCase)),
+ timeoutMS: 5_000,
+ requiredConsecutiveMatches: 3,
+ pollIntervalMS: 100);
+
+ Assert.IsTrue(
+ result.Succeeded,
+ $"Expected red Crosshairs arms around ({cursorX},{cursorY}); last samples: {string.Join(", ", result.LastObservation ?? Array.Empty())}");
+ }
+
+ private static CrosshairsOrigin AssertCrosshairsNear(int cursorX, int cursorY)
+ {
+ const int searchRadius = 80;
+ const int maximumLag = 64;
+ var result = WaitHelper.WaitForStable(
+ () => FindCrosshairsOriginNear(cursorX, cursorY, searchRadius),
+ origin => origin is not null &&
+ Math.Abs(origin.X - cursorX) <= maximumLag &&
+ Math.Abs(origin.Y - cursorY) <= maximumLag,
+ timeoutMS: 5_000,
+ requiredConsecutiveMatches: 1,
+ pollIntervalMS: 100);
+
+ Assert.IsTrue(
+ result.Succeeded,
+ $"Expected Crosshairs within {maximumLag}px of ({cursorX},{cursorY}); last observed origin: {result.LastObservation}.");
+ return result.LastObservation!;
+ }
+
+ private static CrosshairsOrigin? FindCrosshairsOriginNear(int cursorX, int cursorY, int searchRadius)
+ {
+ var verticalProbeY = cursorY - searchRadius;
+ var horizontalProbeX = cursorX - searchRadius;
+ var verticalPixels = new List();
+ var horizontalPixels = new List();
+
+ for (var offset = -searchRadius; offset <= searchRadius; offset += 2)
+ {
+ if (WindowHelper.GetPixelColorHex(cursorX + offset, verticalProbeY).Equals(CrosshairsColor, StringComparison.OrdinalIgnoreCase))
+ {
+ verticalPixels.Add(cursorX + offset);
+ }
+
+ if (WindowHelper.GetPixelColorHex(horizontalProbeX, cursorY + offset).Equals(CrosshairsColor, StringComparison.OrdinalIgnoreCase))
+ {
+ horizontalPixels.Add(cursorY + offset);
+ }
+ }
+
+ return verticalPixels.Count > 0 && horizontalPixels.Count > 0
+ ? new CrosshairsOrigin((int)verticalPixels.Average(), (int)horizontalPixels.Average())
+ : null;
+ }
+
+ private static Color Blend(Color foreground, Color background, int alpha)
+ {
+ var inverse = 255 - alpha;
+ return Color.FromArgb(
+ ((foreground.R * alpha) + (background.R * inverse) + 127) / 255,
+ ((foreground.G * alpha) + (background.G * inverse) + 127) / 255,
+ ((foreground.B * alpha) + (background.B * inverse) + 127) / 255);
+ }
+
+ private static bool IsNear(Color actual, Color expected, int tolerance) =>
+ Math.Abs(actual.R - expected.R) <= tolerance &&
+ Math.Abs(actual.G - expected.G) <= tolerance &&
+ Math.Abs(actual.B - expected.B) <= tolerance;
+
+ private static string CreateSettings(CrosshairsConfiguration configuration) => $$"""
+ {
+ "name": "MousePointerCrosshairs",
+ "version": "1.0",
+ "properties": {
+ "activation_shortcut": { "win": true, "ctrl": false, "alt": true, "shift": false, "code": {{configuration.ShortcutCode}}, "key": "" },
+ "gliding_cursor_activation_shortcut": { "win": true, "ctrl": false, "alt": true, "shift": false, "code": 190, "key": "" },
+ "crosshairs_color": { "value": "{{configuration.Color}}" },
+ "crosshairs_opacity": { "value": {{configuration.Opacity}} },
+ "crosshairs_radius": { "value": {{configuration.Radius}} },
+ "crosshairs_thickness": { "value": {{configuration.Thickness}} },
+ "crosshairs_border_color": { "value": "{{configuration.BorderColor}}" },
+ "crosshairs_border_size": { "value": {{configuration.BorderSize}} },
+ "crosshairs_orientation": { "value": {{configuration.Orientation}} },
+ "crosshairs_auto_hide": { "value": {{configuration.AutoHide.ToString().ToLowerInvariant()}} },
+ "crosshairs_is_fixed_length_enabled": { "value": {{configuration.FixedLengthEnabled.ToString().ToLowerInvariant()}} },
+ "crosshairs_fixed_length": { "value": {{configuration.FixedLength}} },
+ "auto_activate": { "value": {{configuration.AutoActivate.ToString().ToLowerInvariant()}} },
+ "gliding_travel_speed": { "value": 25 },
+ "gliding_delay_speed": { "value": 5 }
+ }
+ }
+ """;
+
+ private sealed record CrosshairsOrigin(int X, int Y);
+
+ private sealed record CrosshairsConfiguration(
+ int ShortcutCode = (int)Key.P,
+ string Color = "#FF0000",
+ int Opacity = 100,
+ int Radius = 20,
+ int Thickness = 9,
+ string BorderColor = "#00FF00",
+ int BorderSize = 0,
+ int Orientation = 0,
+ bool AutoHide = false,
+ bool FixedLengthEnabled = false,
+ int FixedLength = 100,
+ bool AutoActivate = false);
+}
diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtils.UITests.Next.csproj b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtils.UITests.Next.csproj
new file mode 100644
index 0000000000..195e442fb2
--- /dev/null
+++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtils.UITests.Next.csproj
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Exe
+ net10.0-windows10.0.26100.0
+ enable
+ enable
+ false
+ false
+ MouseUtils.UITests
+ MouseUtils.UITests.Next
+ app.manifest
+
+ true
+ true
+ false
+
+
+ false
+
+
+
+ $(RepoRoot)$(Platform)\$(Configuration)\tests\MouseUtils.UITests.Next\
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtilsTestHelper.cs b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtilsTestHelper.cs
new file mode 100644
index 0000000000..ac63585930
--- /dev/null
+++ b/src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtilsTestHelper.cs
@@ -0,0 +1,268 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Runtime.ExceptionServices;
+using System.Runtime.InteropServices;
+using System.Text.Json.Nodes;
+using Microsoft.PowerToys.UITest.Next;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace MouseUtils.UITests;
+
+internal static class MouseUtilsTestHelper
+{
+ private static readonly string[] ShortcutSeparators = [" + ", "+", " "];
+
+ internal const string InputOutputNavItemId = "InputOutputNavItem";
+ internal const string MouseUtilitiesNavItemId = "MouseUtilitiesNavItem";
+
+ internal static void NavigateToMouseUtilities(UITestBase testBase)
+ {
+ Step(testBase, "Navigating to Mouse Utilities settings");
+ if (!testBase.Session.Has(By.AccessibilityId(MouseUtilitiesNavItemId), 500))
+ {
+ testBase.Session.Find(By.AccessibilityId(InputOutputNavItemId), 5_000).Click(msPostAction: 500);
+ }
+
+ testBase.Session.Find(By.AccessibilityId(MouseUtilitiesNavItemId), 5_000).Click(msPostAction: 800);
+ }
+
+ internal static ToggleSwitch SetModuleEnabled(UITestBase testBase, string toggleId, bool enabled)
+ {
+ Step(testBase, $"Setting {toggleId} to {(enabled ? "On" : "Off")}");
+ var expectedState = enabled ? "On" : "Off";
+ ToggleSwitch? toggle = null;
+ for (var attempt = 1; attempt <= 3; attempt++)
+ {
+ toggle = testBase.Session.Find(By.AccessibilityId(toggleId), 10_000);
+ var actualState = toggle.GetProperty("ToggleState");
+ if (actualState.Equals(expectedState, StringComparison.OrdinalIgnoreCase))
+ {
+ return toggle;
+ }
+
+ var oppositeState = enabled ? "Off" : "On";
+ if (!actualState.Equals(oppositeState, StringComparison.OrdinalIgnoreCase))
+ {
+ Step(testBase, $"{toggleId} returned unreadable ToggleState '{actualState}' on attempt {attempt}/3; reacquiring it");
+ if (toggle.WaitForProperty("ToggleState", expectedState, 3_000))
+ {
+ return toggle;
+ }
+
+ continue;
+ }
+
+ toggle.Invoke(msPostAction: 500);
+ if (toggle.WaitForProperty("ToggleState", expectedState, 15_000))
+ {
+ return toggle;
+ }
+
+ Step(testBase, $"{toggleId} did not reach {expectedState} on attempt {attempt}/3; reacquiring it");
+ }
+
+ Assert.Fail($"{toggleId} did not reach {expectedState} after three coordinate-free attempts.");
+ return toggle!;
+ }
+
+ internal static WindowControl.ProcessWindow WaitForWindowClass(string className, int timeoutMs = 10_000)
+ {
+ var result = WaitHelper.WaitForStable(
+ () => WindowControl.EnumerateAllWindows().FirstOrDefault(window =>
+ window.ClassName.Equals(className, StringComparison.OrdinalIgnoreCase)),
+ window => window.Hwnd != IntPtr.Zero,
+ timeoutMs,
+ requiredConsecutiveMatches: 2,
+ pollIntervalMS: 100);
+
+ Assert.IsTrue(result.Succeeded, $"Top-level window class '{className}' did not appear within {timeoutMs} ms.");
+ return result.LastObservation;
+ }
+
+ internal static Key[] ReadShortcut(UITestBase testBase, string groupId, int ordinal = 0)
+ {
+ Element? group = null;
+ for (var attempt = 0; attempt < 12 && group is null; attempt++)
+ {
+ group = testBase.Session.FindAll(By.AccessibilityId(groupId), 0).FirstOrDefault();
+ if (group is null)
+ {
+ MouseHelper.ScrollDown();
+ Thread.Sleep(150);
+ }
+ }
+
+ Assert.IsNotNull(group, $"Settings group '{groupId}' was not found after scrolling the Mouse Utilities page.");
+ group.ScrollIntoView();
+ var buttons = testBase.Session.FindAll