首页
/ PowerToys Find My Mouse 实现解析:从 Raw Input 状态机到 Windows.UI.Composition 聚光灯渲染

PowerToys Find My Mouse 实现解析:从 Raw Input 状态机到 Windows.UI.Composition 聚光灯渲染

2026-09-06 13:47:21作者:管翌锬

Find My Mouse 是 PowerToys 鼠标工具集(Mouse Utilities)中用于“找回鼠标指针”的工具:通过双击 Ctrl、晃动鼠标或自定义快捷键触发后,在指针位置渲染一个“聚光灯”效果,帮助用户在多显示器环境下快速定位光标。本文基于仓库中的模块文档与 src/modules/MouseUtils/FindMyMouse/ 源码,完整讲解该工具的启用流程、窗口与 Composition 视觉树结构、三种激活方式的状态机与摇动检测算法、聚光灯渲染细节、全部配置项及默认值,以及调试与 UI 自动化测试的验证方式。

1. 功能定位与模块组织

模块文档 doc/devdocs/modules/mouseutils/findmymouse.md 开篇即说明:Find My Mouse 基于 Raymond Chen 的 SuperSonar 工具改造而来,其核心是在激活时于光标位置显示聚光灯效果。模块父级文档 doc/devdocs/modules/mouseutils/readme.md 给出了 Mouse Utilities 的整体架构:Find My Mouse、Mouse Highlighter、Mouse Pointer Crosshairs 均作为独立线程运行在 PowerToys Runner 进程内,只有 Mouse Jump 是独立进程。

这一结论可以从 Runner 的模块加载列表中印证:Runner 启动时加载一组已知模块 DLL,其中包含 PowerToys.FindMyMouse.dll,见 src/runner/main.cpp 中的 knownModules 向量。因此 Find My Mouse 并不以独立进程存在,而是一个被 Runner 动态加载的 PowerToy 模块。

实现核心集中在 src/modules/MouseUtils/FindMyMouse/ 目录:

  • FindMyMouse.cpp:主实现(约 1100 行,文件头注释标明 “Based on Raymond Chen's SuperSonar.cpp”);
  • FindMyMouse.h:激活方式枚举、默认常量与 FindMyMouseSettings 结构体;
  • WinHookEventIDs.cpp:注册自定义窗口消息 WM_PRIV_SHORTCUT
  • trace.cpp:ETW 遥测(如 EnableFindMyMouseMousePointerFocused)。

2. 类结构:CRTP 基类 SuperSonar + 渲染子类 CompositionSpotlight

文档指出关键函数 s_WndProc 负责处理窗口消息。源码中这一职责由模板类 SuperSonar<D>(CRTP 模式)承担,见 FindMyMouse.cpp

template<typename D>
struct SuperSonar
{
    bool Initialize(HINSTANCE hinst);
    void Terminate();
protected:
    LRESULT WndProc(UINT message, WPARAM wParam, LPARAM lParam) noexcept
    {
        return BaseWndProc(message, wParam, lParam);
    }
    void BeforeMoveSonar() {}
    void AfterMoveSonar() {}
    void SetSonarVisibility(bool visible) = delete;   // 由派生类实现
    void UpdateMouseSnooping();
    bool IsForegroundAppExcluded();
    // ...
    static LRESULT CALLBACK s_WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
    void OnSonarInput(WPARAM flags, HRAWINPUT hInput);
    void OnSonarKeyboardInput(RAWINPUT const& input);
    void OnSonarMouseInput(RAWINPUT const& input);
    void DetectShake();
    void StartSonar(FindMyMouseActivationMethod activationMethod);
    void StopSonar();
};

设计要点:

  • 基类持有全部“逻辑”(窗口创建、Raw Input 解析、双击状态机、摇动检测、启停控制),并通过 Shim() 回调派生类的钩子方法;
  • 派生类 CompositionSpotlightFindMyMouse.cpp)只负责“呈现”:创建 XAML Island 与 Composition 视觉树、实现 SetSonarVisibility 的淡入淡出;
  • s_WndProcWM_NCCREATE 时通过 lpCreateParamsSuperSonar* 存入 GWLP_USERDATA,之后每条消息都转发到 Shim()->WndProc(),即文档中提到的消息入口。

BaseWndProcFindMyMouse.cpp)处理的消息集恰好对应文档“Event Handling”一节:

消息 处理
WM_CREATE 调用 OnSonarCreate()(注册键盘 Raw Input sink)并 UpdateMouseSnooping()
WM_DESTROY 调用 OnSonarDestroy(),内部 PostQuitMessage(0) 让消息循环优雅退出
WM_INPUT 分派到 OnSonarKeyboardInput / OnSonarMouseInput
WM_TIMERTIMER_ID_TRACK 调用 OnMouseTimer() 追踪指针移动/静止
WM_NCHITTEST 返回 HTTRANSPARENT,保证覆盖窗口完全不拦截鼠标
WM_PRIV_SHORTCUT 聚光灯未运行时 StartSonar(Shortcut),运行时则 StopSonar()(即“再按一次取消”)

3. 启用流程:后台线程 + 消息循环

文档 “Enabling Process” 描述的 enable()(创建后台线程运行 FindMyMouseMain)与 CompositionSpotlight 初始化,对应 FindMyMouse.cpp 的模块入口:

// Based on SuperSonar's original wWinMain.
int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings)
{
    if (m_sonar != nullptr)
    {
        Logger::error("A sonar instance was still working when trying to start a new one.");
        return 0;
    }

    CompositionSpotlight sonar;
    sonar.ApplySettings(settings, false);   // 文档中的 sonar.ApplySettings(settings, false)
    if (!sonar.Initialize(hinst))
    {
        Logger::error("Couldn't initialize a sonar instance.");
        return 0;
    }
    m_sonar = &sonar;

    InitializeWinhookEventIds();

    MSG msg;
    while (GetMessage(&msg, nullptr, 0, 0))
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }

    m_sonar = nullptr;
    return (int)msg.wParam;
}

可以归纳出启用链路与文档一致:

  1. 模块 enable() 置位 m_enabled、打点 Trace::EnableFindMyMouse(true),并 std::thread([=]() { FindMyMouseMain(m_hModule, m_findMyMouseSettings); }).detach() 在后台线程运行;
  2. FindMyMouseMain 先用 ApplySettings(settings, false) 把配置写入实例字段(此时尚无运行时 Composition 对象,故第二个参数为 false);
  3. Initialize() 注册窗口类并创建两个窗口:一个不可见的 static owner 窗口,和真正的覆盖窗口。

Initialize()FindMyMouse.cpp)中值得注意的细节:

  • 线程被设置为 DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,保证多显示器不同 DPI 下坐标换算正确;
  • 覆盖窗口样式为 WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW,窗口标题固定为 PowerToys Find My Mouse
  • 窗口类名 FindMyMouse 通过 GetClassInfoW 判重,避免重复注册。

窗口销毁走 Terminate()FindMyMouse.cpp):由于 Composition/XAML 资源绑定在该线程的 DispatcherQueue 上,销毁动作必须 TryEnqueue 回原线程执行 DestroyWindow(m_hwndOwner),随后 WM_DESTROY → OnSonarDestroy → PostQuitMessage 结束消息循环。这正是文档所说“收到 WM_DESTROY 时 sonar 实例被正确清理、消息循环优雅终止”的完整闭环。

模块对外 API 也很精简(FindMyMouse.h):FindMyMouseMain / FindMyMouseDisable / FindMyMouseIsEnabled / FindMyMouseApplySettings / GetSonarHwnd。其中 FindMyMouseApplySettings 在实例存在时以 applyToRuntimeObjects=true 调用,热更新配置;GetSonarHwnd 供快捷键系统投递自定义消息使用。

4. 激活机制一:双击 Ctrl 的 Raw Input 状态机

文档 “Activation Process” 第 1 步提到“全局低级键盘钩子监听双击 Ctrl”。实现上更精确地说:键盘事件通过 Raw Input(WM_INPUT + RIDEV_INPUTSINK)获得,这是文档 “listens for raw input events using WM_INPUT, which provides more precise and responsive input detection” 的落点。

OnSonarCreate()FindMyMouse.cpp)在窗口创建时注册键盘 Raw Input 设备:

RAWINPUTDEVICE keyboard{};
keyboard.usUsagePage = HID_USAGE_PAGE_GENERIC;
keyboard.usUsage = HID_USAGE_GENERIC_KEYBOARD;
keyboard.dwFlags = RIDEV_INPUTSINK;      // 即使本窗口非前台也能收到消息
keyboard.hwndTarget = m_hwnd;
return RegisterRawInputDevices(&keyboard, 1, sizeof(keyboard));

双击判定不是简单的“两次 keydown”,而是一台五状态机(FindMyMouse.cpp):

enum class SonarState
{
    Idle, ControlDown1, ControlUp1, ControlDown2, ControlUp2,
};

核心判定逻辑在 OnSonarKeyboardInput()FindMyMouse.cpp),规则逐条可验证:

  • 只处理 VK_CONTROL 且按键物理位置与激活方式匹配:DoubleLeftControlKey 要求 RI_KEY_E0 标志为 0(左 Ctrl),DoubleRightControlKey 则相反;
  • 两次按下之间的间隔必须满足 doubleClickInterval >= min(MIN_DOUBLE_CLICK_TIME, doubleClickTimeSetting / 5)<= doubleClickTimeSetting(系统双击时间设置),其中 MIN_DOUBLE_CLICK_TIME = 100ms 是硬下限,注释说明是为了“避免某些键盘发送快速连击”;
  • 两次按下之间光标位置必须保持不变IsEqual(m_lastKeyPos, ptCursor)),否则重置状态机并从当前时刻重新计时;
  • includeWinKey 为 true,则要求左/右 Win 键同时处于按下状态(KeyboardInputCanActivate() 通过 GetAsyncKeyState(VK_LWIN/VK_RWIN) 检查);
  • 一旦进入 ControlUp2 状态,再次按下 Ctrl 会直接 StopSonar()——即动画进行中按一次 Ctrl 也能取消。

此外,Shortcut 激活方式下收到按键释放(RI_KEY_BREAK)不会关闭已触发的聚光灯,这是为了避免“松开快捷键”误伤效果。

5. 激活机制二:鼠标摇动(Shake)检测算法

FindMyMouseActivationMethod 枚举(FindMyMouse.h)定义了 4 种方式,Shortcut = 3 为自定义快捷键,默认值为 DoubleLeftControlKey

enum struct FindMyMouseActivationMethod : int
{
    DoubleLeftControlKey = 0,
    DoubleRightControlKey = 1,
    ShakeMouse = 2,
    Shortcut = 3,
    EnumElements = 4,
};

摇动检测是源码中算法密度最高的部分。OnSonarMouseInput()FindMyMouse.cpp)在摇动模式下维护一段“移动历史” m_movementHistory

  • 只记录方向变化的移动段:若与上一段方向(GetSign(diff.x)GetSign(diff.y))相同,则合并到上一段,压缩高频消息;
  • 兼容绝对坐标输入(MOUSE_MOVE_ABSOLUTE,常见于虚拟机 / RDP 会话),通过相邻绝对坐标差分还原相对位移;
  • 方向改变时调用 DetectShake()

DetectShake()FindMyMouse.cpp)的算法注释写得很直白:“Has distance travelled been much greater than the diagonal of the rectangle containing the movement?” 具体为:

  1. 先剔除超过 m_shakeIntervalMs(默认 1000ms)的历史移动段;
  2. 累加路径总长 distanceTravelled(各段长度按勾股定理求和),并记录轨迹包围盒的 min/max 坐标;
  3. distanceTravelled < m_shakeMinimumDistance(默认 1000px)直接返回;
  4. 计算包围盒对角线 diagonal,当 distanceTravelled / diagonal > m_shakeFactor / 100(默认 400%,即路径长超过对角线 4 倍)时判定为摇动,清空历史并 StartSonar()

这套“路径长/包围盒对角线”比值法天然过滤了直线、缓弧等低曲率移动,而左右快速晃动会使比值迅速拉高。

鼠标侧 Raw Input 采用按需订阅策略:UpdateMouseSnooping()FindMyMouse.cpp)在“聚光灯运行中 / 状态机非 Idle / 摇动模式”三者之一成立时才注册鼠标 Raw Input sink(RIDEV_INPUTSINK),否则以 RIDEV_REMOVE 注销,从而在不使用时零开销。

6. 激活机制三:自定义快捷键与 WM_PRIV_SHORTCUT

当激活方式选择“自定义快捷键”时,Runner 侧的全局快捷键钩子(即文档中 OnHotkeyEx() 所在位置)把事件转换为一条注册过的私有窗口消息,见 WinHookEventIDs.cpp

void InitializeWinhookEventIds()
{
    std::call_once(init_flag, [&] {
        WM_PRIV_SHORTCUT = RegisterWindowMessage(L"{1365FFC7-A44E-4171-9692-A3EEF378AE60}");
    });
}

消息 ID 用 RegisterWindowMessage 加 GUID 命名空间,避免与其他模块冲突;快捷键处理端只需 PostMessageW(GetSonarHwnd(), WM_PRIV_SHORTCUT, ...)(文档 “Activation Process” 第 1 步引用的正是这段代码)。BaseWndProc 收到该消息后的 toggle 逻辑与文档引用一致(FindMyMouse.cpp):未运行时 StartSonar(Shortcut),运行中 StopSonar()

三条激活路径最终都汇聚到 StartSonar()FindMyMouse.cpp),它首先做两道拦截:

  • doNotActivateOnGameMode 为 true 且 detect_game_mode() 检测到 Windows 游戏模式,则放弃激活(默认开启);
  • 若当前前台进程命中排除列表(IsForegroundAppExcluded() 通过 get_process_path + check_excluded_app 按路径做大小写不敏感匹配),则放弃激活。

通过拦截后,窗口被 SetWindowPosHWND_TOPMOST 并覆盖整个虚拟屏幕SM_XVIRTUALSCREEN 等)。源码中有一处带注释的 HACK:窗口位置偏移 1 像素、尺寸缩小 2 像素——“否则当透明窗口铺满整屏时 Windows 会让任务栏透明效果出现闪烁”,这是覆盖全屏透明窗口与 DWM 合成交互的经验细节。

7. 聚光灯渲染:XAML Island + Composition 视觉树

文档 “Sonar Animation” 一句带过的“highlight (ripple/pulse)”实际由 CompositionSpotlight 用 Windows.UI.Composition 完成。OnCompositionCreate()FindMyMouse.cpp)按注释构建如下结构:

[window hwnd]  —— DesktopWindowXamlSource (XAML Island)
  \ Grid (透明背景,Stretch)
     \ ElementCompositionPreview 手插 Visual:
        [root] ContainerVisual (fills host, 初始 Opacity=0, 绑定隐式 Opacity 动画)
          \ LayerVisual
             \ [spotlight 圆形 ShapeVisual(位于背板之下,透过“洞”可见)]
             \ [backdrop:MaskBrush = 暗色源 + 径向渐变遮罩(中心透明、外圈不透明)]

渲染要点:

  • 聚光灯不是直接画圆,而是全屏暗色背板被一个“中心透明、边缘不透明”的径向渐变 Mask 遮罩出圆形透光区;透光区下方放置与 spotlightColor 一致的椭圆 ShapeVisual
  • 柔边宽度是固定的像素值:半径 < 300 时为 1px,≥ 300 时为 2px(featherPixels),并换算成渐变 stop 的 Offset
  • 缩放动画:触发瞬间聚光灯以 spotlightInitialZoom(默认 9 倍)半径出现,随后半径随 root Opacity 从 0→1 线性收缩到最终半径,由表达式动画 Lerp(Vector2(start), Vector2(end), Root.Opacity) 驱动(SetupRadiusAnimationsFindMyMouse.cpp),形成“由大变小聚拢”的视觉冲击;柔边 stop 的 Offset 也用表达式动画保持固定像素柔边;
  • 跟随指针AfterMoveSonar()FindMyMouse.cpp)把渐变 EllipseCenter 和圆形视觉的 Offset 更新到光标位置(除以 RasterizationScale() 换算 DIP);
  • 淡出与隐藏SetSonarVisibility(false) 时把 root Opacity 动画目标设为 0,动画完成回调 WM_OPACITY_ANIMATION_COMPLETED 后,若 Opacity < 0.01 则 ShowWindow(SW_HIDE)。若系统关闭了动画效果(GetAnimationsEnabled() 为假),时长直接取 1ms,保证效果可即时显隐。

自动关闭逻辑在 OnMouseTimer()FindMyMouse.cpp):聚光灯出现后等待第一次鼠标移动(SonarWaitingForMouseMove),之后每次移动重置 1000ms 的 TIMER_ID_TRACK 计时器;指针静止满 1 秒即 StopSonar()。按下任意鼠标按键也会立即 StopSonar()(见 OnSonarMouseInputusButtonFlags 分支)——这两点与 UI 测试用例的断言完全对应,见下文。

8. 配置项与默认值全表

FindMyMouseSettingsFindMyMouse.h)汇总了全部配置,默认常量在同文件顶部。结合设置 UI 侧 MouseUtilsViewModel.cs(属性读写 FindMyMouseSettingsConfig.Properties.* 并通知模块)与设置页 MouseUtilsPage.xaml,各配置项如下:

设置项 结构体字段 默认值 说明
激活方式 activationMethod DoubleLeftControlKey(0) 0=双击左 Ctrl,1=双击右 Ctrl,2=摇动鼠标,3=自定义快捷键(取值 ≥4 时 UI 侧回退为 0)
需同时按住 Win 键 includeWinKey false 为 true 时双击 Ctrl 须配合 Win 键按下
游戏模式不激活 doNotActivateOnGameMode true StartSonardetect_game_mode() 拦截
背板颜色 backgroundColor ARGB 128,0,0,0(50% 黑) 颜色透明度直接编码在 Alpha 通道(注释说明旧的 overlay_opacity 已并入 A 通道)
聚光灯颜色 spotlightColor ARGB 128,255,255,255(50% 白) UI 侧缺省回退 #80FFFFFF
聚光灯半径 spotlightRadius 100 DIP 单位;≥300 时柔边加宽到 2px
动画时长 animationDurationMs 500 ms 淡入时长;≤0 时取 1ms 兜底
初始缩放 spotlightInitialZoom 9 出现瞬间半径 = 半径 × 9 后收缩
摇动最小距离 shakeMinimumDistance 1000 px 路径总长低于该值不判定摇动
摇动时间窗口 shakeIntervalMs 1000 ms 只统计该窗口内的移动段
摇动因子 shakeFactor 400(%) 路径长/包围盒对角线须超过 400%
排除应用 excludedApps 前台进程路径命中则不激活
自定义快捷键 ActivationShortcut(UI 层) DefaultActivationShortcut 仅在激活方式为 Shortcut 时生效

ApplySettingsFindMyMouse.cpp)有两条路径:applyToRuntimeObjects=false 在初始化时直接写字段;true 则通过 DispatcherQueueControllerTryEnqueue 切回 UI 线程,先停掉半径/柔边表达式动画,再更新颜色、尺寸、EllipseCenter 并重新 SetupRadiusAnimations——这保证运行中修改设置即时生效且线程安全。注意 m_destroyed 标志防止队列回调落到已销毁的实例上。

另外,ViewModel 中 GPOWrapper.GetConfiguredFindMyMouseEnabledValue() 表明该工具的启用状态还可被组策略覆盖(对应仓库 doc/gpo 描述的 GPO 能力)。

9. 事件处理与生命周期小结

把文档 “Event Handling” 一节与源码对齐后,完整的事件矩阵为:

事件 行为
键盘:匹配激活键的双击模式 状态机推进,满足条件 StartSonar
键盘:其他按键(非 Ctrl 释放) StopSonar()
键盘:摇动模式下方向变化 合并/追加移动历史,DetectShake()
鼠标:任意按键(usButtonFlags 非 0) 立即 StopSonar()
鼠标:移动 摇动检测 + OnMouseTimer() 重置 1s 静止计时
WM_PRIV_SHORTCUT toggle:未运行则启动,运行中则取消
静止满 1s / 桌面切换(GetCursorPos 失败) StopSonar()
WM_DESTROY(禁用/关闭) PostQuitMessage 退出消息循环,m_sonar = nullptr

10. 调试与 UI 自动化验证

文档 “Debugging” 一节的实践建议(附加到 PowerToys Runner 进程、在 FindMyMouse.cpp 打断点、调试开销可能导致视觉效果卡顿)与“Runner 内线程”的架构结论一致——由于 Find My Mouse 不是独立进程,调试入口只能是 Runner。

仓库中的 UI 自动化测试 FindMyMouseTests.cs 对关键行为做了回归验证,可作为行为事实的补充证据:

  • TestEnableFindMyMouse:在设置页开启 Find My Mouse、选择 “Press Left Control twice” 并写入外观参数后,按左 Ctrl 两次断言聚光灯出现;再按任意键(SendKeys(Key.A))断言消失;再触发一次后执行鼠标左键点击断言消失——与源码中“其他按键取消 / 鼠标按键取消 / 1s 静止取消”三条退出路径一一对应;
  • TestFindMyMouseDifferentSettings:以自定义背板色(FF0000)与聚光灯色(0000FF)重复验证设置生效路径。

测试参数类 FindMyMouseSettings.csOverlayOpacity 等字段也印证了旧版 UI 的“透明度 + RGB”拆分已被源码注释所述的“透明度并入颜色 Alpha 通道”新模型取代。

11. 参考文件索引

内容 路径
模块开发文档(本文主体) doc/devdocs/modules/mouseutils/findmymouse.md
Mouse Utilities 模块总览 doc/devdocs/modules/mouseutils/readme.md
主实现(SuperSonar + CompositionSpotlight) src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp
激活枚举 / 默认值 / 设置结构体 src/modules/MouseUtils/FindMyMouse/FindMyMouse.h
私有消息 WM_PRIV_SHORTCUT 注册 src/modules/MouseUtils/FindMyMouse/WinHookEventIDs.cpp
Runner 模块加载列表 src/runner/main.cpp
设置视图模型 src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs
设置页 UI src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml
UI 自动化测试 src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs

综合来看,Find My Mouse 是“SuperSonar 逻辑内核 + WinAppSDK Composition 渲染层”的组合:Raw Input 提供低延迟且不被前台窗口抢占的输入通道,五状态机与“路径长/对角线”比值算法分别可靠地识别双击与摇动,而径向渐变 Mask + 表达式动画则用纯合成器路径实现了 9 倍缩放聚拢、半径收缩与淡入淡出的聚光灯效果,全程不依赖 GDI 位图绘制。

登录后查看全文
热门项目推荐
相关项目推荐