initial commit

This commit is contained in:
Gleb Khmyznikov
2026-08-28 14:07:34 -07:00
parent 885459a2f4
commit 38b5e7b485
14 changed files with 2661 additions and 68 deletions

View File

@@ -605,6 +605,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/MouseUtils/MouseUtils.UITests.Next/MouseUtils.UITests.Next.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/keyboardmanager/Tests/">
<Project Path="src/modules/keyboardmanager/KeyboardManagerEditorTest/KeyboardManagerEditorTest.vcxproj" Id="62173d9a-6724-4c00-a1c8-fb646480a9ec" />

View File

@@ -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,
}
/// <summary>
@@ -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;

View File

@@ -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);
/// <summary>Move the OS cursor to absolute screen coordinates.</summary>
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());
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
/// <summary>Current cursor position in screen pixels.</summary>
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 <paramref name="data"/>
/// only carries the wheel delta for <c>MOUSEEVENTF_WHEEL</c>.
/// </summary>
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,

View File

@@ -19,6 +19,21 @@ public static class NamedEventHelper
/// <summary>Toggles the FancyZones layout editor open/closed.</summary>
public const string FancyZonesEditorToggle = @"Local\FancyZones-ToggleEditorEvent-1e174338-06a3-472b-874d-073b21c62f14";
/// <summary>Triggers Find My Mouse.</summary>
public const string FindMyMouseTrigger = @"Local\FindMyMouseTriggerEvent-5a9dc5f4-1c74-4f2f-a66f-1b9b6a2f9b23";
/// <summary>Toggles Mouse Highlighter.</summary>
public const string MouseHighlighterToggle = @"Local\MouseHighlighterTriggerEvent-1e3c9c3d-3fdf-4f9a-9a52-31c9b3c3a8f4";
/// <summary>Toggles Mouse Pointer Crosshairs.</summary>
public const string MouseCrosshairsToggle = @"Local\MouseCrosshairsTriggerEvent-0d4c7f92-0a5c-4f5c-b64b-8a2a2f7e0b21";
/// <summary>Shows the Mouse Jump preview.</summary>
public const string MouseJumpShowPreview = @"Local\MouseJumpEvent-aa0be051-3396-4976-b7ba-1a9cc7d236a5";
/// <summary>Toggles Cursor Wrap.</summary>
public const string CursorWrapToggle = @"Local\CursorWrapTriggerEvent-1f8452b5-4e6e-45b3-8b09-13f14a5900c9";
/// <summary>Set an existing named event. Returns false when no module currently owns it.</summary>
public static bool TrySignal(string name)
{
@@ -40,6 +55,33 @@ public static class NamedEventHelper
}
}
/// <summary>Whether a named event currently exists.</summary>
public static bool Exists(string name)
{
try
{
if (!EventWaitHandle.TryOpenExisting(name, out var handle))
{
return false;
}
handle.Dispose();
return true;
}
catch (Exception)
{
return false;
}
}
/// <summary>Wait until a module creates a named event without signaling it.</summary>
public static bool WaitUntilAvailable(string name, int timeoutMS = 15_000, int pollIntervalMS = 250) =>
WaitForAvailability(name, expected: true, timeoutMS, pollIntervalMS);
/// <summary>Wait until a module closes a named event.</summary>
public static bool WaitUntilUnavailable(string name, int timeoutMS = 15_000, int pollIntervalMS = 250) =>
WaitForAvailability(name, expected: false, timeoutMS, pollIntervalMS);
/// <summary>Wait until a module has created the named event, then signal it.</summary>
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);
}
}
}

View File

@@ -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]

View File

@@ -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<ToggleSwitch>(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<CursorEdge>())
{
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<CursorEdge>())
{
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,
}
}

View File

@@ -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 = "");
}

View File

@@ -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);
}

View File

@@ -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<string> 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<string, int>(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");
}

View File

@@ -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<string>())}");
}
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<int>();
var horizontalPixels = new List<int>();
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);
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>MouseUtils.UITests</RootNamespace>
<AssemblyName>MouseUtils.UITests.Next</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
<EnableMSTestRunner>true</EnableMSTestRunner>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<PropertyGroup>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\MouseUtils.UITests.Next\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<NavigationViewItem>(By.AccessibilityId(InputOutputNavItemId), 5_000).Click(msPostAction: 500);
}
testBase.Session.Find<NavigationViewItem>(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<ToggleSwitch>(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<Element>(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<Button>(By.AccessibilityId("EditButton"), 5_000)
.Where(button =>
button.X >= group!.X &&
button.X < group.X + group.Width &&
button.Y >= group.Y &&
button.Y < group.Y + group.Height)
.OrderBy(button => button.Y)
.ToList();
Assert.IsTrue(
buttons.Count > ordinal,
$"Expected shortcut button {ordinal} inside '{groupId}', but found {buttons.Count}.");
var shortcutText = buttons[ordinal].HelpText;
var keys = ParseShortcutText(shortcutText);
Assert.IsTrue(
keys.Any(key => key is not (Key.LWin or Key.Ctrl or Key.Shift or Key.Alt)),
$"Shortcut text '{shortcutText}' from '{groupId}' did not contain a main key.");
Step(testBase, $"Shortcut from {groupId}: '{shortcutText}' => [{string.Join(", ", keys)}]");
return keys;
}
internal static Key[] ParseShortcutText(string shortcutText)
{
var keys = new List<Key>();
foreach (var raw in shortcutText.Split(ShortcutSeparators, StringSplitOptions.RemoveEmptyEntries))
{
var token = raw.Trim();
Key? key = token.ToLowerInvariant() switch
{
"win" or "windows" => Key.LWin,
"ctrl" or "control" => Key.Ctrl,
"shift" => Key.Shift,
"alt" => Key.Alt,
_ when token.Length == 1 && token[0] is >= '0' and <= '9' =>
Enum.TryParse<Key>("Num" + token, out var number) ? number : null,
_ when token.Length > 0 && char.IsLetter(token[0]) && Enum.TryParse<Key>(token, true, out var parsed) => parsed,
_ => null,
};
if (key.HasValue)
{
keys.Add(key.Value);
}
}
return keys.ToArray();
}
internal static void ReplaceModuleSettings(string moduleName, string settingsJson)
{
SettingsConfigHelper.UpdateModuleSettings(
moduleName,
settingsJson,
current =>
{
var desired = JsonNode.Parse(settingsJson)?.AsObject()
?? throw new InvalidOperationException($"Settings seed for '{moduleName}' is not a JSON object.");
current.Clear();
foreach (var property in desired)
{
current[property.Key] = property.Value?.DeepClone();
}
});
}
internal static void RunWithClientAreaAnimationsEnabled(Action action)
{
const int spiGetClientAreaAnimation = 0x1042;
const int spiSetClientAreaAnimation = 0x1043;
var originalValue = 0;
Assert.IsTrue(
SystemParametersInfo(spiGetClientAreaAnimation, 0, ref originalValue, 0),
"Could not read the Windows client-area animation setting.");
Exception? actionFailure = null;
try
{
if (originalValue == 0)
{
var enabled = 1;
Assert.IsTrue(
SystemParametersInfo(spiSetClientAreaAnimation, 0, ref enabled, 0),
"Could not enable Windows client-area animations for the timing fixture.");
}
action();
}
catch (Exception ex)
{
actionFailure = ex;
}
var restored = true;
var restoreError = 0;
if (originalValue == 0)
{
var disabled = 0;
restored = SystemParametersInfo(spiSetClientAreaAnimation, 0, ref disabled, 0);
if (!restored)
{
restoreError = Marshal.GetLastWin32Error();
}
}
if (actionFailure is not null)
{
if (!restored)
{
throw new AggregateException(
"The test action failed and the Windows client-area animation setting could not be restored.",
actionFailure,
new InvalidOperationException($"SystemParametersInfoW failed with Win32 error {restoreError}."));
}
ExceptionDispatchInfo.Capture(actionFailure).Throw();
}
Assert.IsTrue(restored, $"Could not restore the Windows client-area animation setting. Win32 error: {restoreError}.");
}
internal static IDisposable PreserveClientAreaAnimationsEnabled()
{
const int spiGetClientAreaAnimation = 0x1042;
const int spiSetClientAreaAnimation = 0x1043;
var originalValue = 0;
Assert.IsTrue(
SystemParametersInfo(spiGetClientAreaAnimation, 0, ref originalValue, 0),
"Could not read the Windows client-area animation setting before module launch.");
if (originalValue == 0)
{
var enabled = 1;
Assert.IsTrue(
SystemParametersInfo(spiSetClientAreaAnimation, 0, ref enabled, 0),
"Could not enable Windows client-area animations before module launch.");
}
return new ClientAreaAnimationsSnapshot(originalValue);
}
internal static void Step(UITestBase testBase, string message) =>
testBase.TestContext.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}");
[DllImport("user32.dll", EntryPoint = "SystemParametersInfoW", SetLastError = true)]
private static extern bool SystemParametersInfo(int uiAction, int uiParam, ref int pvParam, int fWinIni);
private sealed class ClientAreaAnimationsSnapshot(int originalValue) : IDisposable
{
private bool disposed;
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
if (originalValue == 0)
{
const int spiSetClientAreaAnimation = 0x1043;
var disabled = 0;
var restored = SystemParametersInfo(spiSetClientAreaAnimation, 0, ref disabled, 0);
var error = restored ? 0 : Marshal.GetLastWin32Error();
Assert.IsTrue(restored, $"Could not restore the Windows client-area animation setting after the test class. Win32 error: {error}.");
}
}
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MouseUtils.UITests.Next.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -1,64 +1,94 @@
## [Mouse Utils](tests-checklist-template-mouse-utils-section.md)
## Mouse Utils
Find My Mouse:
* Enable FindMyMouse. Then, without moving your mouse:
- [x] Press Left Ctrl twice and verify the overlay appears.
- [x] Press any other key and verify the overlay disappears.
- [x] Press Left Ctrl twice and verify the overlay appears.
- [x] Press a mouse button and verify the overlay disappears.
* Disable FindMyMouse. Verify the overlay no longer appears when you press Left Ctrl twice.
* Enable FindMyMouse. Then, without moving your mouse:
- [x] Press Left Ctrl twice and verify the overlay appears.
* Enable the "Do not activate on game mode" option. Start playing a game that uses CG native full screen.
- [ ] Verify the overlay no longer appears when you press Left Ctrl twice.
* Disable the "Do not activate on game mode" option. Start playing the same game.
- [ ] Verify the overlay appears when you press Left Ctrl twice. (though it'll likely minimize the game)
* Test the different settings and verify they apply:
- [ ] Overlay opacity
- [x] Background color
- [x] Spotlight color
- [x] Spotlight radius
- [ ] Spotlight initial zoom (1x vs 9x will show the difference)
- [ ] Animation duration
- [ ] Change activation method to shake and activate by shaking your mouse pointer
- [ ] Excluded apps
The migrated suite is [MouseUtils.UITests.Next](../MouseUtils.UITests.Next/) and currently contains 40 tests. Checked items have an automated effect-based assertion. Unchecked items require physical hardware or desktop state that the single-monitor Hyper-V profile cannot establish faithfully.
Mouse Highlighter:
* Enable Mouse Highlighter. Then:
- [x] Press the activation shortcut and press left and right click somewhere, verifying the highlights are applied.
- [x] With left mouse button pressed, drag the mouse and verify the highlight is dragged with the pointer.
- [x] With right mouse button pressed, drag the mouse and verify the highlight is dragged with the pointer.
- [x] Press the activation shortcut again and verify no highlights appear when the mouse buttons are clicked.
- [x] Disable Mouse Highlighter and verify that the module is not activated when you press the activation shortcut.
* Test the different settings and verify they apply:
- [x] Change activation shortcut and test it
- [x] Left button highlight color
- [x] Right button highlight color
- [ ] Opacity
- [ ] Radius
- [ ] Fade delay
- [ ] Fade duration
### Find My Mouse
Mouse Pointer Crosshairs:
* Enable Mouse Pointer Crosshairs. Then:
- [x] Press the activation shortcut and verify the crosshairs appear, and that they follow the mouse around.
- [x] Press the activation shortcut again and verify the crosshairs disappear.
- [x] Disable Mouse Pointer Crosshairs and verify that the module is not activated when you press the activation shortcut.
* Test the different settings and verify they apply:
- [x] Change activation shortcut and test it
- [x] Crosshairs color
- [ ] Crosshairs opacity
- [ ] Crosshairs center radius
- [ ] Crosshairs thickness
- [ ] Crosshairs border color
- [ ] Crosshairs border size
- [x] Double-tap Left Ctrl and verify the overlay appears.
- [x] Press another key and verify the overlay disappears.
- [x] Activate again, press a mouse button, and verify the overlay disappears.
- [x] Disable the module and verify Left Ctrl no longer activates it.
- [x] Re-enable the module and verify activation works again.
- [ ] With "Do not activate on game mode" enabled, verify activation is blocked by an exclusive/native fullscreen game. Manual: requires a real D3D fullscreen foreground fixture.
- [ ] With "Do not activate on game mode" disabled, verify activation works over the same game. Manual: same fixture requirement.
- [x] Background and spotlight colors, including their current ARGB alpha values. This replaces the stale standalone "Overlay opacity" row.
- [x] Spotlight radius.
- [x] Spotlight initial zoom.
- [x] Animation duration.
- [ ] Shake activation. Manual: synthetic cursor movement is not equivalent to physical raw mouse input for the shake detector.
- [x] Excluded apps block activation only while an excluded process owns foreground.
- [x] Right Ctrl activation.
- [x] Custom shortcut activation.
- [x] Requiring the Windows key gates double-Ctrl activation.
Mouse Jump:
* Enable Mouse Jump. Then:
- [x] Press the activation shortcut and verify the screens preview appears.
- [x] Change activation shortcut and verify that new shortcut triggers Mouse Jump.
- [x] Click around the screen preview and ensure that mouse cursor jumped to clicked location.
- [ ] Reorder screens in Display settings and confirm that Mouse Jump reflects the change and still works correctly.
- [ ] Change scaling of screens and confirm that Mouse Jump still works correctly.
- [ ] Unplug additional monitors and confirm that Mouse Jump still works correctly.
- [x] Disable Mouse Jump and verify that the module is not activated when you press the activation shortcut.
Automated by [FindMyMouseTests.cs](../MouseUtils.UITests.Next/FindMyMouseTests.cs).
### Mouse Highlighter
- [x] Toggle the module with its activation shortcut and verify left/right click highlights.
- [x] Verify left-button and right-button highlights follow their respective drags.
- [x] Toggle the overlay off and verify click highlights disappear.
- [x] Disable the module and verify its shortcut cannot activate it.
- [x] Changed activation shortcut.
- [x] Left and right highlight colors, including their current ARGB alpha values. This replaces the stale standalone "Opacity" row.
- [x] Highlight radius.
- [x] Fade delay.
- [x] Fade duration.
- [x] Spotlight mode color, radius, and cursor tracking.
- [x] Ripple size, intensity, and duration.
- [x] Ripple drag-trail enabled and disabled behavior.
- [x] Ripple right-button release pulse.
- [x] Auto-activate.
Automated by [MouseHighlighterTests.cs](../MouseUtils.UITests.Next/MouseHighlighterTests.cs).
### Mouse Pointer Crosshairs
- [x] Activate Crosshairs and verify the overlay appears and tracks the cursor.
- [x] Activate again and verify the overlay disappears.
- [x] Disable the module and verify its shortcut cannot activate it.
- [x] Changed activation shortcut.
- [x] Crosshairs color.
- [x] Crosshairs opacity, validated against the blended desktop pixel color.
- [x] Center radius.
- [x] Thickness.
- [x] Border color.
- [x] Border size.
- [x] Horizontal orientation and fixed length.
- [x] Auto-activate.
- [x] Gliding Cursor activation, movement, and Escape cancellation.
- [ ] Auto-hide while the cursor is hidden. Manual: `ShowCursor` is thread-local and cannot create a valid cross-thread hidden-cursor fixture in the test host.
Automated by [MousePointerCrosshairsTests.cs](../MouseUtils.UITests.Next/MousePointerCrosshairsTests.cs).
### Mouse Jump
- [x] Load the settings page and verify the WinUI3 process, named event, and hidden preview HWND are ready.
- [x] Press the default activation shortcut and verify the preview appears.
- [x] Change the activation shortcut and verify only the new shortcut shows the preview.
- [x] Click the preview midpoint and verify the cursor maps to the primary display midpoint.
- [x] Disable the module and verify the shortcut neither shows the preview nor restarts the process.
- [x] Verify the preview dismisses when it loses focus.
- [x] Verify configured thumbnail bounds preserve the display aspect ratio.
- [x] Verify custom background, border, and bezel colors render.
- [ ] Reorder displays and verify the preview topology and click mapping. Manual: requires a multi-monitor VM/hardware profile.
- [ ] Change per-display scaling and verify preview topology and click mapping. Manual: requires mixed-DPI displays.
- [ ] Unplug and reconnect a display and verify live topology updates. Manual: requires hot-pluggable display hardware.
Automated by [MouseJumpTests.cs](../MouseUtils.UITests.Next/MouseJumpTests.cs).
### Cursor Wrap
- [x] Enable, activate, deactivate, and disable Cursor Wrap; verify wrapping follows module state.
- [x] Both mode wraps left, right, top, and bottom edges.
- [x] Horizontal-only mode wraps only left and right edges.
- [x] Vertical-only mode wraps only top and bottom edges.
- [x] Ctrl activation mode wraps only while Ctrl is held.
- [x] Shift activation mode wraps only while Shift is held.
- [x] "Disable during drag" blocks wrapping while the left button is held.
- [x] "Disable on single monitor" blocks all edges in the one-monitor profile.
- [x] Auto-activate starts wrapping without the toggle shortcut.
- [x] A changed activation shortcut replaces the default shortcut.
- [ ] Validate outer-edge polygons, adjacent inner edges, gaps, negative coordinates, and mixed DPI on multiple displays. Manual: requires representative multi-monitor layouts; use `CursorWrap/CursorWrapTests` for captured-layout simulation alongside hardware validation.
Automated by [CursorWrapTests.cs](../MouseUtils.UITests.Next/CursorWrapTests.cs).