diff --git a/AGENTS.md b/AGENTS.md
index 7f8924fc..179ad35e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -70,6 +70,7 @@ g:\Users\2 Better\Desktop\design\
│ ├── AppThemeManager.cs # 窗口深浅色主题画刷注入管理器
│ ├── FullScreenHelper.cs # 独占全屏检测与 Windows Explorer 穿透识别
│ ├── ActiveWindowHelper.cs # 前台活动窗口探测器
+│ ├── WheelFocusSwitcher.cs # 鼠标呼出轮盘时的前台焦点对齐(解析呼出点所在窗口并激活)
│ ├── MemoryOptimizer.cs # 内存整理与工作集压缩工具
│ ├── I18n.cs # 多语言国际化字典 (zh-CN, zh-TW, en-US, ja-JP)
│ ├── Renderers/ # 轮盘切削形态渲染器策略族
@@ -218,6 +219,15 @@ g:\Users\2 Better\Desktop\design\
- **初始化不得产生系统副作用**:WPF 给 `CheckBox.IsChecked` 赋值时也可能触发 `Checked/Unchecked`。加载自启动状态时必须同时使用 `_isUpdatingUi`、`_isUiInitializing` 与 `_isLoadingAutoStartState` 防护,并比较已加载状态;只有用户实际修改开关时才能调用 `ConfigManager.SetAutoStart()`,严禁打开控制台时创建或删除计划任务。
- **显式退出模式**:`App.xaml` 必须保持 `ShutdownMode="OnExplicitShutdown"`,关闭最后一个设置窗口不能结束后台 Hook 与托盘进程;只有托盘退出、提权重启或明确的应用退出流程可以调用 `Shutdown()`。
+### 3.7 鼠标呼出轮盘时的前台焦点对齐
+- **触发键的点击被自己吞掉了,宿主窗口就不会被激活**。轮盘触发键(右键/中键/侧键)的 DOWN/UP 都由 `MouseHook` 拦截,被点中的那个窗口收不到任何鼠标消息,Windows 自然不会把它切到前台 —— 前台仍留在按下之前那个窗口上。多显示器场景下这条缺陷最刺眼:光标在显示器 2、轮盘也画在显示器 2,但松手后执行的动作(`SendInput` 注入的快捷键、窗口操作)全部落进显示器 1 上原来那个前台窗口。单屏上"右键点在背景窗口上"同样成立,只是不容易被注意到。
+- **修法**:`WheelFocusSwitcher.SwitchFocusToPoint(呼出点)` 在轮盘真正上屏之前调用一次(`GestureController.ShowRadialUI` 的最前面),把前台切到呼出点所在的顶层窗口;激活复用 `WindowTaskbarHelper.ActivateWindow`(前台锁的 `AttachThreadInput` / `Alt` 兜底都在那里),本类只负责"解析出该切给谁",不注入点击、不改窗口样式、不移动窗口。
+- **只服务「鼠标呼出轮盘」这一条路径**:`ShowRadialUI` 的 `switchFocusToWheelPoint` 参数只有鼠标触发传 `true`(拖动阈值路径看 `_kbTriggerWaiting`,长按路径看 `isMouseWaiting`)。键盘触发(穿透模式)与插件经 `IHostWheelService` 呼出的粘滞轮盘一律不动前台 —— 前者用户正在某个窗口里打字,后者连触发都不在钩子上,抢前台都是错的。
+- **⚠️ 光切前台还不够:轮盘方案也必须一起换过去。** 方案是按进程名取的(`ConfigManager.GetProfileForProcess`),而进程名过去一律取 `ActiveWindowHelper.GetActiveWindowProcessName()`(**前台**窗口)。跨显示器呼出时前台还在另一块屏幕上,于是第一次呼出拿到的是**另一个程序**的扇区配置,前台虽然被切过去了、用户却得**再呼出一次**才看到该有的选项。所以鼠标路径改用 `WheelFocusSwitcher.TryGetProcessNameAtPoint(呼出点)`(解析同一个目标窗口 → `ActiveWindowHelper.GetProcessNameForWindow`),键盘路径与解析失败时回落到前台窗口,行为不变。
+ - **解析必须在钩子线程上做、激活必须留到 UI 线程**:`ProcessMove(初始位置)` 在 `BeginInvoke` 之前就按 `_activeProfile` 算好了初始扇区并写进手势状态,松手执行的动作也读这一份 —— 方案若等到 UI 线程才换,初判扇区会按**另一个方案**的扇区数算(12 扇区方案算出 10,套到 8 扇区方案上),用户松手可能落到错的扇区或空扇区。所以 `TryGetProcessNameAtPoint` 刻意只读(`WindowFromPoint`/`GetWindowRect`/`GetWindowLongPtr` 之类,钩子已有同类调用先例),前台切换仍留在 `ShowRadialUI`(UI 线程)里。
+- **解析规则(判据一律保守,宁可不切也不切错)**:先 `WindowFromPoint` + `GetAncestor(GA_ROOT)`;命中的是自己人的窗口(轮盘 HWND 隐藏后仍占着原位置、轨迹浮层)或 no-activate 悬浮层时,沿 z 序自上而下兜底找真正压在下面的窗口。**兜底只对这两类成立**:命中的是 Shell 表面(桌面、任务栏)时绝不下钻 —— 桌面铺满整块屏幕、任务栏上方还压着最大化的程序,往下找到的第一个「矩形盖住该点」的窗口就是那个无关程序,在桌面/任务栏空白处呼出轮盘会把用户当前软件抢走;此时宁可不动前台。被排除的目标:StarPie 自身进程的窗口、`WS_EX_NOACTIVATE` / `WS_EX_TRANSPARENT` 窗口、最小化/不可见窗口、桌面与任务栏等 Shell 表面(类名清单见 `ShellWindowClasses`)。目标已是前台时直接返回,不做任何多余的置顶/还原动作。
+- **机器护栏**:`scratch/test_wheel_focus.ps1`(真摆几层窗口做端到端验证,含"探针自己那层确实压在最上面"的前置条件断言,另有一条拉真记事本断言"进程名取自呼出点而不是前台窗口",以及一条"Shell 表面上不做 z 序兜底、宁可不动前台")。这段逻辑够不着单测与 UI 套件 —— 它们要的是屏幕上真的压着几层窗口;变异测试验过:去掉"自己的窗口"判据、去掉 no-activate 判据、把进程名换回取前台窗口,各自会红掉对应的断言。
+
---
## 4. 🧩 插件系统:契约、边界与运行时实现
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb32b2af..86a32d1f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,11 @@
- **旧配置兼容**:Launch 插件在新参数缺失时兼容 RunAsStandardUser;显式的新值优先。参数回填不会自动写盘。
- **冷启动编辑支持**:编辑认领动作时仅按需激活当前已启用插件以取得声明,不改变启用或预加载偏好;参数校验本身仍不触发加载。
- **环境失败提示**:取消授权、能力不足或不支持所选模式时提供可见提示,避免将环境条件误计为插件缺陷。
+- **鼠标呼出轮盘时自动对齐前台焦点,并同步切到该窗口的轮盘方案**:触发键的按下/抬起一直被 StarPie 的低级鼠标钩子吞掉,被点中的窗口收不到点击、也就不会被 Windows 激活,于是前台留在原窗口上。多显示器场景下表现为:光标已经移到显示器 2、轮盘也画在显示器 2,松手后执行的动作(快捷键注入、窗口操作等)却全部落进显示器 1 上原来那个前台窗口。现在由新增的 `WheelFocusSwitcher` 在轮盘上屏之前把前台切到呼出点所在的顶层窗口。
+ - **轮盘方案(扇区选项)也一起跟过去**:方案按进程名取,而进程名原先一律取**前台**窗口 —— 跨屏呼出时前台还在另一块屏幕上,第一次呼出拿到的是**另一个程序**的配置,用户得再呼出一次才看到该有的选项。现改为鼠标路径按呼出点所在窗口取进程名(`WheelFocusSwitcher.TryGetProcessNameAtPoint` + `ActiveWindowHelper.GetProcessNameForWindow`),键盘路径与解析失败时仍回落到前台窗口。
+ - **只影响「用鼠标呼出轮盘」这一条路径**:键盘触发(穿透模式)与插件经 `IHostWheelService` 呼出的粘滞轮盘不改前台、也不改方案来源,其它既有行为一律不动。
+ - **判据保守**:StarPie 自身进程的窗口、`WS_EX_NOACTIVATE`/`WS_EX_TRANSPARENT` 悬浮层、最小化/不可见窗口、桌面与任务栏等 Shell 表面都不是切换目标(命中的是自己人的那层时,沿 z 序向下找真正压在下面的窗口);**命中的是桌面/任务栏等 Shell 表面时不做这层下钻**(桌面铺满屏幕、任务栏上方还压着最大化程序,下钻会把无关程序抢到前台),宁可不动前台。目标已是前台时不做多余动作。进程名解析刻意只读,前台切换仍留在 UI 线程(钩子线程不做激活)。
+ - **验证**:新增 `scratch/test_wheel_focus.ps1` 端到端探针(真摆几层窗口 + 前置条件断言 + 拉真记事本断言"进程名取自呼出点而非前台窗口"),变异测试确认去掉"自己的窗口"判据、去掉 no-activate 判据、或把进程名换回取前台窗口,都会红掉对应断言。
### 🧩 兼容性与升级要求
diff --git a/WinPieGestures/ActiveWindowHelper.cs b/WinPieGestures/ActiveWindowHelper.cs
index a9a7a524..f9ce3833 100644
--- a/WinPieGestures/ActiveWindowHelper.cs
+++ b/WinPieGestures/ActiveWindowHelper.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
@@ -25,7 +25,7 @@ public static class ActiveWindowHelper
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(nint hObject);
- private static nint _cachedForegroundHwnd = IntPtr.Zero;
+ private static nint _cachedWindow = IntPtr.Zero;
private static string _cachedProcessName = "unknown.exe";
private static long _cachedTick = 0;
private static readonly object _cacheLock = new object();
@@ -49,59 +49,96 @@ public static string GetActiveWindowInfo(out nint foregroundWindow)
long now = Environment.TickCount64;
lock (_cacheLock)
{
- if (foregroundWindow == _cachedForegroundHwnd && (now - _cachedTick) < 150)
+ if (foregroundWindow == _cachedWindow && (now - _cachedTick) < 150)
{
return _cachedProcessName;
}
}
- GetWindowThreadProcessId(foregroundWindow, out var lpdwProcessId);
- if (lpdwProcessId == 0)
+ return ResolveProcessName(foregroundWindow);
+ }
+ catch
+ {
+ return "unknown.exe";
+ }
+ }
+
+ ///
+ /// 指定窗口所在进程的可执行文件名(小写,与 同一格式)。
+ ///
+ /// 存在的理由:轮盘方案是按进程名取的,而「用鼠标呼出轮盘时该属于哪个程序」必须看
+ /// 呼出点所在的窗口,不是前台窗口 —— 多显示器上把光标移到另一块屏幕时,前台窗口还在原屏幕上,
+ /// 于是轮盘会错用另一个程序的方案(用户得呼出第二次才正确)。
+ ///
+ public static string GetProcessNameForWindow(nint window)
+ {
+ if (window == IntPtr.Zero)
+ {
+ return "unknown.exe";
+ }
+ try
+ {
+ long now = Environment.TickCount64;
+ lock (_cacheLock)
{
- return "unknown.exe";
+ // 与前台路径共用同一格缓存:键就是 hwnd,查的是别的窗口只会未命中并重算,不会读到错的名字。
+ if (window == _cachedWindow && (now - _cachedTick) < 150)
+ {
+ return _cachedProcessName;
+ }
}
+ return ResolveProcessName(window);
+ }
+ catch
+ {
+ return "unknown.exe";
+ }
+ }
+
+ private static string ResolveProcessName(nint window)
+ {
+ GetWindowThreadProcessId(window, out var lpdwProcessId);
+ if (lpdwProcessId == 0)
+ {
+ return "unknown.exe";
+ }
- nint hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, lpdwProcessId);
- if (hProcess != IntPtr.Zero)
+ nint hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, lpdwProcessId);
+ if (hProcess != IntPtr.Zero)
+ {
+ try
{
- try
+ uint size = 1024;
+ StringBuilder sb = new StringBuilder((int)size);
+ if (QueryFullProcessImageName(hProcess, 0, sb, ref size))
{
- uint size = 1024;
- StringBuilder sb = new StringBuilder((int)size);
- if (QueryFullProcessImageName(hProcess, 0, sb, ref size))
+ string fullPath = sb.ToString();
+ string fileName = Path.GetFileName(fullPath);
+ if (!string.IsNullOrEmpty(fileName))
{
- string fullPath = sb.ToString();
- string fileName = Path.GetFileName(fullPath);
- if (!string.IsNullOrEmpty(fileName))
+ string procName = fileName.ToLowerInvariant();
+ lock (_cacheLock)
{
- string procName = fileName.ToLowerInvariant();
- lock (_cacheLock)
- {
- _cachedForegroundHwnd = foregroundWindow;
- _cachedProcessName = procName;
- _cachedTick = Environment.TickCount64;
- }
- return procName;
+ _cachedWindow = window;
+ _cachedProcessName = procName;
+ _cachedTick = Environment.TickCount64;
}
+ return procName;
}
}
- finally
- {
- CloseHandle(hProcess);
- }
}
-
- lock (_cacheLock)
+ finally
{
- _cachedForegroundHwnd = foregroundWindow;
- _cachedProcessName = "unknown.exe";
- _cachedTick = Environment.TickCount64;
+ CloseHandle(hProcess);
}
- return "unknown.exe";
}
- catch
+
+ lock (_cacheLock)
{
- return "unknown.exe";
+ _cachedWindow = window;
+ _cachedProcessName = "unknown.exe";
+ _cachedTick = Environment.TickCount64;
}
+ return "unknown.exe";
}
}
diff --git a/WinPieGestures/GestureController.cs b/WinPieGestures/GestureController.cs
index d7a9ac8f..17d76bd1 100644
--- a/WinPieGestures/GestureController.cs
+++ b/WinPieGestures/GestureController.cs
@@ -1421,8 +1421,11 @@ private void LongPressTimerCallback(int generation)
_currentDpiScaleX = scaleX;
_currentDpiScaleY = scaleY;
}
- string processName = ActiveWindowHelper.GetActiveWindowProcessName();
- WheelProfile profile = ConfigManager.GetProfileForProcess(processName);
+ // 同拖动路径:鼠标呼出时轮盘方案必须按呼出点所在的窗口取(键盘触发保持原样,只读解析不切前台)。
+ string? processAtWheelPoint = isMouseWaiting
+ ? WheelFocusSwitcher.TryGetProcessNameAtPoint(_startPoint)
+ : null;
+ WheelProfile profile = ConfigManager.GetProfileForProcess(processAtWheelPoint ?? ActiveWindowHelper.GetActiveWindowProcessName());
long gestureVersion = GetCurrentGestureVersion();
Point startPoint = _startPoint;
((DispatcherObject)Application.Current).Dispatcher.BeginInvoke((Delegate)(Action)delegate
@@ -1433,7 +1436,7 @@ private void LongPressTimerCallback(int generation)
{
return;
}
- if (ShowRadialUI(startPoint, profile, gestureVersion))
+ if (ShowRadialUI(startPoint, profile, gestureVersion, switchFocusToWheelPoint: isMouseWaiting))
{
ProcessMove(startPoint);
ApplyPendingHighlight();
@@ -2005,13 +2008,21 @@ private void Hook_OnMouseMove(object? sender, MouseEventArgs e)
{
return;
}
+ // 本帧越过拖动阈值的呼出来源:键盘触发(穿透模式)时 _kbTriggerWaiting 为 true。
+ // 只有鼠标触发才允许抢前台焦点(见 ShowRadialUI 的 switchFocusToWheelPoint)。
+ bool switchFocusToWheelPoint = !_kbTriggerWaiting;
_kbTriggerWaiting = false;
_isWaitingForThreshold = false;
_isGestureActive = true;
_soundSessionId = SoundEffectManager.BeginSession(SoundSessionSource.NormalGesture);
CancelLongPressTimer(); // 拖动先于长按触发
- string activeWindowProcessName = ActiveWindowHelper.GetActiveWindowProcessName();
- _activeProfile = ConfigManager.GetProfileForProcess(activeWindowProcessName);
+ // 轮盘方案按「呼出点所在的窗口」取,而不是前台窗口:跨显示器呼出时前台窗口还在另一块屏幕上,
+ // 沿用它的进程会拿到另一个程序的方案,用户得呼出第二次才对(前台这时才被切过去)。
+ // 这里只做只读解析 —— 钩子线程绝不做前台切换,激活留在 ShowRadialUI 里。
+ string? processAtWheelPoint = switchFocusToWheelPoint
+ ? WheelFocusSwitcher.TryGetProcessNameAtPoint(_startPoint)
+ : null;
+ _activeProfile = ConfigManager.GetProfileForProcess(processAtWheelPoint ?? ActiveWindowHelper.GetActiveWindowProcessName());
Point center = _startPoint;
WheelProfile profile = _activeProfile;
Point initialPos = e.Position;
@@ -2027,7 +2038,7 @@ private void Hook_OnMouseMove(object? sender, MouseEventArgs e)
{
return;
}
- if (ShowRadialUI(center, profile, gestureVersion))
+ if (ShowRadialUI(center, profile, gestureVersion, switchFocusToWheelPoint))
{
ApplyPendingHighlight();
ApplyVolumePreview();
@@ -2175,7 +2186,12 @@ private void ProcessMove(Point currentPoint)
QueueHighlightUpdate(num4, num5, flag, flag2, GetCurrentGestureVersion());
}
- private bool ShowRadialUI(Point center, WheelProfile profile, long gestureVersion)
+ ///
+ /// 是否把前台焦点切到轮盘呼出点所在的窗口。只有「鼠标呼出轮盘」的路径传 true:
+ /// 触发键的点击已被本程序的鼠标钩子吞掉,宿主窗口收不到激活,多显示器下会让动作落到另一块屏幕的窗口上。
+ /// 键盘触发与插件粘滞轮盘一律传 false,前台语义保持原样。
+ ///
+ private bool ShowRadialUI(Point center, WheelProfile profile, long gestureVersion, bool switchFocusToWheelPoint)
{
profile.EnsureLayers();
profile.ActiveLayerIndex = 0;
@@ -2205,6 +2221,13 @@ private bool ShowRadialUI(Point center, WheelProfile profile, long gestureVersio
try
{
+ // 鼠标呼出轮盘:先把前台焦点对齐到呼出点所在的窗口,再上屏。
+ // 必须在 Present 之前完成 —— 轮盘一旦呈现,用户随时可能松手执行动作,
+ // 而动作(快捷键注入、窗口操作)作用的对象就是此刻的前台窗口。
+ if (switchFocusToWheelPoint)
+ {
+ WheelFocusSwitcher.SwitchFocusToPoint(center);
+ }
window.Present(center, profile, ConfigManager.ConfigurationRevision, gestureVersion);
lock (_uiUpdateSync)
{
diff --git a/WinPieGestures/WheelFocusSwitcher.cs b/WinPieGestures/WheelFocusSwitcher.cs
new file mode 100644
index 00000000..cc04d96e
--- /dev/null
+++ b/WinPieGestures/WheelFocusSwitcher.cs
@@ -0,0 +1,335 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Windows;
+
+namespace WinPieGestures;
+
+///
+/// 鼠标呼出轮盘时的前台焦点对齐。
+///
+/// 要解决的问题:触发键的按下/抬起被 StarPie 的低级鼠标钩子吞掉,宿主窗口因此收不到这次点击,
+/// Windows 也就不会把它激活 —— 前台焦点仍留在原窗口上。多显示器场景下表现为:光标已移到显示器 2
+/// 并在那里呼出轮盘,轮盘也画在显示器 2,但随后执行的动作(快捷键注入、窗口操作等)全部落到了
+/// 显示器 1 上原来那个前台窗口里,形成误操作。
+///
+/// 本类的边界:只服务于「鼠标呼出轮盘」这一条路径 —— 由
+/// GestureController.ShowRadialUI 在真正呈现轮盘之前调用一次。键盘触发路径、
+/// 插件经 IHostWheelService 呼出的粘滞轮盘、以及任何不是「用鼠标呼出轮盘」的路径都不经过这里,
+/// 它们的前台语义保持原样。
+///
+/// 行为:解析呼出点所在的顶层窗口,若它可被安全激活且尚不是前台窗口,就把它切到前台
+/// (复用 处理 Windows 前台锁)。
+/// 不做任何其它副作用:不注入点击、不改窗口样式、不移动窗口。
+///
+internal static class WheelFocusSwitcher
+{
+ [StructLayout(LayoutKind.Sequential)]
+ private struct POINT
+ {
+ public int X;
+ public int Y;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RECT
+ {
+ public int Left;
+ public int Top;
+ public int Right;
+ public int Bottom;
+ }
+
+ private const uint GA_ROOT = 2u;
+ private const uint GW_HWNDNEXT = 2u;
+ private const int GWL_EXSTYLE = -20;
+
+ // 竞态上限:z 序链理论上有限,这里只是防守异常情况下 GetWindow 返回自环。
+ private const int MaxZOrderWalk = 4096;
+
+ // 不接受激活的悬浮层(轮盘、轨迹浮层、HUD、OSD、输入法候选窗)与点击穿透窗口:
+ // 它们不是用户想聚焦的「界面」,真正的目标在其下方,因此跳过并继续向下找。
+ private const long WS_EX_NOACTIVATE = 0x08000000L;
+ private const long WS_EX_TRANSPARENT = 0x00000020L;
+
+ // 桌面、任务栏与 Shell 表面:把前台交给它们等于让用户当前软件失去焦点,没有任何收益。
+ private static readonly HashSet ShellWindowClasses = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "Progman",
+ "WorkerW",
+ "SHELLDLL_DefView",
+ "SysListView32",
+ "Shell_TrayWnd",
+ "Shell_SecondaryTrayWnd",
+ "NotifyIconOverflowWindow",
+ "TopLevelWindowForOverflowXamlIsland",
+ "Windows.UI.Core.CoreWindow",
+ "Shell_InputSwitchTopLevelWindow",
+ "MultitaskingViewFrame",
+ "ForegroundStaging"
+ };
+
+ [DllImport("user32.dll")]
+ private static extern nint WindowFromPoint(POINT point);
+
+ [DllImport("user32.dll")]
+ private static extern nint GetAncestor(nint hWnd, uint gaFlags);
+
+ [DllImport("user32.dll")]
+ private static extern nint GetTopWindow(nint hWnd);
+
+ [DllImport("user32.dll")]
+ private static extern nint GetWindow(nint hWnd, uint uCmd);
+
+ [DllImport("user32.dll")]
+ private static extern nint GetForegroundWindow();
+
+ [DllImport("user32.dll")]
+ private static extern nint GetShellWindow();
+
+ [DllImport("user32.dll")]
+ private static extern nint GetDesktopWindow();
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool IsWindow(nint hWnd);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool IsWindowVisible(nint hWnd);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool IsIconic(nint hWnd);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetWindowRect(nint hWnd, out RECT lpRect);
+
+ [DllImport("user32.dll")]
+ private static extern uint GetWindowThreadProcessId(nint hWnd, out uint lpdwProcessId);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetClassName(nint hWnd, StringBuilder lpClassName, int nMaxCount);
+
+ [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
+ private static extern nint GetWindowLongPtr64(nint hWnd, int nIndex);
+
+ [DllImport("user32.dll", EntryPoint = "GetWindowLong")]
+ private static extern nint GetWindowLong32(nint hWnd, int nIndex);
+
+ private static nint GetWindowLongPtr(nint hWnd, int nIndex)
+ {
+ return (IntPtr.Size == 8) ? GetWindowLongPtr64(hWnd, nIndex) : GetWindowLong32(hWnd, nIndex);
+ }
+
+ ///
+ /// 把前台焦点对齐到指定物理坐标所在的顶层窗口。
+ /// 返回该坐标解析出的目标窗口句柄( 表示没有可切换的目标);
+ /// 调用方可以据此判断「这次轮盘属于哪个窗口/哪个程序」。
+ /// 目标已经是前台时不做任何动作。
+ ///
+ public static nint SwitchFocusToPoint(Point physicalPoint)
+ {
+ try
+ {
+ POINT pt = new POINT
+ {
+ X = (int)Math.Round(physicalPoint.X),
+ Y = (int)Math.Round(physicalPoint.Y)
+ };
+ nint target = ResolveTargetWindow(pt);
+ if (target == IntPtr.Zero)
+ {
+ return IntPtr.Zero;
+ }
+ if (target == GetForegroundWindow())
+ {
+ // 前台已经落在轮盘呼出点所在的窗口上:无需切换,也不做任何置顶/还原动作。
+ return target;
+ }
+ bool activated = WindowTaskbarHelper.ActivateWindow(target);
+ if (activated)
+ {
+ AppLogger.LogDebug($"WheelFocusSwitcher: wheel focus moved to window 0x{target.ToInt64():X} at ({pt.X},{pt.Y}), process '{ActiveWindowHelper.GetProcessNameForWindow(target)}'.");
+ }
+ else
+ {
+ AppLogger.LogDebug($"WheelFocusSwitcher: failed to activate window 0x{target.ToInt64():X} at ({pt.X},{pt.Y}).");
+ }
+ return target;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.LogDebug($"WheelFocusSwitcher: {ex.GetType().Name}: {ex.Message}");
+ return IntPtr.Zero;
+ }
+ }
+
+ ///
+ /// 呼出点所在窗口的进程名(小写 exe 名,与 同一格式);
+ /// 解析不到合适目标、或进程名取不出来时返回 null(调用方按原路径回落到前台窗口)。
+ ///
+ /// 只读、不激活:鼠标钩子线程可以安全调用。轮盘方案按进程名取,
+ /// 所以这一步必须和前台切换看同一个窗口 —— 否则跨屏呼出时方案会取到另一块屏幕上那个旧前台窗口的进程,
+ /// 用户得呼出第二次才能拿到该有的选项。
+ ///
+ public static string? TryGetProcessNameAtPoint(Point physicalPoint)
+ {
+ try
+ {
+ POINT pt = new POINT
+ {
+ X = (int)Math.Round(physicalPoint.X),
+ Y = (int)Math.Round(physicalPoint.Y)
+ };
+ nint target = ResolveTargetWindow(pt);
+ if (target == IntPtr.Zero)
+ {
+ return null;
+ }
+ // 「取不出来」与「解析失败」对调用方是同一件事:都该回落到前台窗口的原行为。
+ // 顶层的 UWP 进程经常 OpenProcess 不到(Suspended/受保护),那时 GetProcessNameForWindow
+ // 只会给 "unknown.exe" —— 拿它去取方案等于把用户已经配好的方案换成默认方案。
+ string processName = ActiveWindowHelper.GetProcessNameForWindow(target);
+ return string.Equals(processName, "unknown.exe", StringComparison.OrdinalIgnoreCase) ? null : processName;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.LogDebug($"WheelFocusSwitcher: {ex.GetType().Name}: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// 解析呼出点下应当接管前台的顶层窗口;没有合适目标时返回 。
+ ///
+ private static nint ResolveTargetWindow(POINT pt)
+ {
+ // 首选 WindowFromPoint:与系统的命中判定完全一致(自动排除隐藏、禁用、逐像素透明的窗口,
+ // 也能正确处理 UWP 托管窗口与子窗口)。命中的是自己人的窗口时再走下面的兜底。
+ nint hit = WindowFromPoint(pt);
+ nint hitRoot = (hit != IntPtr.Zero) ? GetAncestor(hit, GA_ROOT) : IntPtr.Zero;
+ if (IsEligibleTarget(hitRoot, pt))
+ {
+ return hitRoot;
+ }
+
+ // 兜底只对「命中的那层本身不该接管前台」成立 —— 也就是 StarPie 自己的窗口(轮盘 HWND 隐藏后
+ // 仍占着原位置、轨迹浮层等)与 no-activate / 点击穿透的悬浮层,真正压在下面的那个窗口才是
+ // 用户手指底下的界面。EnumWindows/GetTopWindow 的顺序即 z 序。
+ //
+ // ⚠️ 命中的是 Shell 表面(桌面、任务栏)时绝不能兜底:桌面铺满整块屏幕,沿 z 序往下找到的
+ // 第一个「矩形盖住该点」的窗口就是压在桌面底下的那个最大化程序 —— 在桌面空白处或任务栏空白处
+ // 呼出轮盘,会把那个程序的窗口抢到前台,用户点的是桌面,前台却跳给了别的软件。
+ if (ShouldSearchBelow(hitRoot))
+ {
+ int steps = 0;
+ for (nint h = GetTopWindow(IntPtr.Zero); h != IntPtr.Zero && steps < MaxZOrderWalk; h = GetWindow(h, GW_HWNDNEXT))
+ {
+ steps++;
+ if (IsEligibleTarget(h, pt))
+ {
+ return h;
+ }
+ }
+ }
+ return IntPtr.Zero;
+ }
+
+ ///
+ /// 命中点被拒绝之后,是否还有必要沿 z 序往下找:只有当命中的那层是「不接管前台的覆盖层」时才找。
+ /// 桌面与任务栏是用户真正点在的东西(MouseHook 里任务栏那几下会放行原生点击),
+ /// 在它们上面往下钻只会钻到压在底下的无关程序。
+ ///
+ private static bool ShouldSearchBelow(nint hitRoot)
+ {
+ if (hitRoot == IntPtr.Zero)
+ {
+ return true;
+ }
+ if (IsShellSurface(hitRoot))
+ {
+ return false;
+ }
+ GetWindowThreadProcessId(hitRoot, out uint pid);
+ if (pid == (uint)Environment.ProcessId)
+ {
+ return true;
+ }
+ long exStyle = GetWindowLongPtr(hitRoot, GWL_EXSTYLE).ToInt64();
+ return (exStyle & WS_EX_NOACTIVATE) != 0L || (exStyle & WS_EX_TRANSPARENT) != 0L;
+ }
+
+ ///
+ /// 该顶层窗口是否可以作为「呼出轮盘的界面」接管前台。
+ /// 判据刻意保守:任何一条不满足都只是放弃切换(前台保持原样),绝不会切到错误窗口上。
+ ///
+ private static bool IsEligibleTarget(nint hWnd, POINT pt)
+ {
+ if (hWnd == IntPtr.Zero || !IsWindow(hWnd))
+ {
+ return false;
+ }
+ if (!IsWindowVisible(hWnd) || IsIconic(hWnd))
+ {
+ return false;
+ }
+ if (!ContainsPoint(hWnd, pt))
+ {
+ return false;
+ }
+ GetWindowThreadProcessId(hWnd, out uint pid);
+ if (pid == 0u || pid == (uint)Environment.ProcessId)
+ {
+ // StarPie 自身的窗口(轮盘、轨迹浮层、控制台)绝不作为焦点目标:
+ // 把前台切给自己没有任何意义,还会让随后注入的按键打回自己身上。
+ return false;
+ }
+ long exStyle = GetWindowLongPtr(hWnd, GWL_EXSTYLE).ToInt64();
+ if ((exStyle & WS_EX_NOACTIVATE) != 0L || (exStyle & WS_EX_TRANSPARENT) != 0L)
+ {
+ return false;
+ }
+ return !IsShellSurface(hWnd);
+ }
+
+ private static bool ContainsPoint(nint hWnd, POINT pt)
+ {
+ if (!GetWindowRect(hWnd, out RECT rect))
+ {
+ return false;
+ }
+ return pt.X >= rect.Left && pt.X < rect.Right && pt.Y >= rect.Top && pt.Y < rect.Bottom;
+ }
+
+ private static bool IsShellSurface(nint hWnd)
+ {
+ if (hWnd == GetShellWindow() || hWnd == GetDesktopWindow())
+ {
+ return true;
+ }
+ StringBuilder sb = new StringBuilder(128);
+ if (GetClassName(hWnd, sb, sb.Capacity) <= 0)
+ {
+ return false;
+ }
+ return ShellWindowClasses.Contains(sb.ToString());
+ }
+
+ #region 诊断与测试切缝 (Diagnostics & Test Seams)
+ ///
+ /// 仅供 scratch/test_wheel_focus 无界面地断言「这个呼出点解析到了哪个窗口」。
+ /// 只解析、不激活,因此探针不会改动任何窗口的前台状态或 z 序。
+ ///
+ internal static nint TestResolveTargetWindow(double physicalX, double physicalY)
+ {
+ return ResolveTargetWindow(new POINT
+ {
+ X = (int)Math.Round(physicalX),
+ Y = (int)Math.Round(physicalY)
+ });
+ }
+ #endregion
+}
diff --git a/WinPieGestures/WinPieGestures.csproj b/WinPieGestures/WinPieGestures.csproj
index 674fbd70..61ed565b 100644
--- a/WinPieGestures/WinPieGestures.csproj
+++ b/WinPieGestures/WinPieGestures.csproj
@@ -49,6 +49,7 @@
+
diff --git a/scratch/Directory.Build.props b/scratch/Directory.Build.props
index 1018fbba..86a904c1 100644
--- a/scratch/Directory.Build.props
+++ b/scratch/Directory.Build.props
@@ -1,5 +1,5 @@
-
+
obj\$(MSBuildProjectName)\
bin\$(MSBuildProjectName)\
diff --git a/scratch/test_wheel_focus.cs b/scratch/test_wheel_focus.cs
new file mode 100644
index 00000000..ea3af726
--- /dev/null
+++ b/scratch/test_wheel_focus.cs
@@ -0,0 +1,820 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Drawing;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+using System.Windows.Forms;
+using WinPieGestures;
+
+///
+/// StarPie「鼠标呼出轮盘时前台焦点自动对齐」()的端到端验证。
+///
+/// 一次性工具,不参与主程序构建(scratch 目录性质)。用法:
+/// powershell -ExecutionPolicy Bypass -File scratch\test_wheel_focus.ps1
+///
+/// 为什么必须有它:这段逻辑的三条判据(只认别人的窗口、跳过 no-activate 悬浮层、沿 z 序兜底)
+/// 全部够不着单元测试与 UI 回归套件 —— 它们要的是「屏幕上真的压着几层窗口」。
+/// 这里用子进程拉起几个真窗口,把轮盘窗口那种「压在最上面又不是目标」的处境原地复现出来。
+///
+/// 每条用例都自带前置条件断言(见 ):探针摆的那层窗口
+/// 必须真的是该点上最上面的窗口,否则用例会因为「下面那层被解析出来」而假绿。
+///
+/// 注意:运行期间会短暂改动前台窗口(几秒),结束后恢复运行前的那个前台窗口。
+///
+internal static class WheelFocusProbe
+{
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RECT
+ {
+ public int Left;
+ public int Top;
+ public int Right;
+ public int Bottom;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct POINT
+ {
+ public int X;
+ public int Y;
+ }
+
+ private delegate bool EnumWindowsProc(nint hWnd, nint lParam);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, nint lParam);
+
+ [DllImport("user32.dll")]
+ private static extern nint GetForegroundWindow();
+
+ [DllImport("user32.dll")]
+ private static extern nint WindowFromPoint(POINT point);
+
+ [DllImport("user32.dll")]
+ private static extern nint GetAncestor(nint hWnd, uint gaFlags);
+
+ [DllImport("user32.dll")]
+ private static extern nint GetTopWindow(nint hWnd);
+
+ [DllImport("user32.dll")]
+ private static extern nint GetWindow(nint hWnd, uint uCmd);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetClassName(nint hWnd, StringBuilder lpClassName, int nMaxCount);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool IsWindowVisible(nint hWnd);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool IsWindow(nint hWnd);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetWindowRect(nint hWnd, out RECT lpRect);
+
+ [DllImport("user32.dll")]
+ private static extern uint GetWindowThreadProcessId(nint hWnd, out uint lpdwProcessId);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetWindowText(nint hWnd, StringBuilder lpString, int nMaxCount);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern nint FindWindow(string? lpClassName, string? lpWindowName);
+
+ [DllImport("user32.dll")]
+ private static extern nint GetShellWindow();
+
+ [DllImport("user32.dll")]
+ private static extern nint GetDesktopWindow();
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern bool SetWindowPos(nint hWnd, nint hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
+
+ [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
+ private static extern nint GetWindowLongPtr64(nint hWnd, int nIndex);
+
+ [DllImport("user32.dll", EntryPoint = "GetWindowLong")]
+ private static extern nint GetWindowLong32(nint hWnd, int nIndex);
+
+ private static nint GetWindowLongPtr(nint hWnd, int nIndex)
+ {
+ return (IntPtr.Size == 8) ? GetWindowLongPtr64(hWnd, nIndex) : GetWindowLong32(hWnd, nIndex);
+ }
+
+ private const int GWL_EXSTYLE = -20;
+ private const int WS_EX_NOACTIVATE = 0x08000000;
+ private const int WS_EX_TOOLWINDOW = 0x00000080;
+ private static readonly nint HWND_TOPMOST = new nint(-1);
+ private const uint SWP_NOSIZE = 0x0001;
+ private const uint SWP_NOMOVE = 0x0002;
+ private const uint SWP_NOZORDER = 0x0004;
+ private const uint SWP_NOACTIVATE = 0x0010;
+
+ // 轮盘呼出点:A 窗口 (60,80,420,300) 的中心。
+ private const int PointX = 270;
+ private const int PointY = 230;
+
+ private static int _failures;
+ private static int _checks;
+
+ private static bool Trace => Environment.GetEnvironmentVariable("TFW_TRACE") == "1";
+
+ [STAThread]
+ private static int Main(string[] args)
+ {
+ Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);
+ if (args.Length > 0 && string.Equals(args[0], "--child", StringComparison.OrdinalIgnoreCase))
+ {
+ return RunChildWindow(args);
+ }
+ if (args.Length > 0 && string.Equals(args[0], "--diag", StringComparison.OrdinalIgnoreCase))
+ {
+ return RunDiag(args);
+ }
+ if (args.Length > 0 && string.Equals(args[0], "--dump", StringComparison.OrdinalIgnoreCase))
+ {
+ // 不建窗口的坐标扫描:只回答「这个点上命中的是谁、解析成了谁」。
+ foreach (string pair in (args.Length > 1 ? args[1] : string.Empty).Split(';'))
+ {
+ string[] xy = pair.Split(',');
+ int dx = int.Parse(xy[0]);
+ int dy = int.Parse(xy[1]);
+ nint dh = WindowFromPoint(new POINT { X = dx, Y = dy });
+ nint dr = GetAncestor(dh, 2u);
+ StringBuilder dc = new StringBuilder(128);
+ GetClassName(dr, dc, dc.Capacity);
+ GetWindowThreadProcessId(dr, out uint dpid);
+ long dex = GetWindowLongPtr(dr, GWL_EXSTYLE).ToInt64();
+ Console.WriteLine($"({dx},{dy}) hit=0x{dh.ToInt64():X} root=0x{dr.ToInt64():X} 类={dc} pid={dpid} ex=0x{dex:X} 解析=0x{WheelFocusSwitcher.TestResolveTargetWindow(dx, dy).ToInt64():X}");
+ }
+ return 0;
+ }
+ return RunProbe();
+ }
+
+ // ------------------------------------------------------------------
+ // 子进程模式:在指定矩形上摆一个真实窗口,供父进程当作「别的程序的窗口 / 悬浮层」。
+ // ------------------------------------------------------------------
+ private static int RunChildWindow(string[] args)
+ {
+ string title = ArgValue(args, "--title") ?? "TFW_CHILD";
+ Rectangle bounds = ParseRect(ArgValue(args, "--rect") ?? "0,0,200,150");
+ bool noActivate = HasFlag(args, "--noactivate");
+ bool topmost = HasFlag(args, "--topmost");
+
+ ProbeForm form = new ProbeForm(noActivate)
+ {
+ Text = title,
+ FormBorderStyle = FormBorderStyle.FixedSingle,
+ StartPosition = FormStartPosition.Manual,
+ // ShowInTaskbar 一律为 true:WinForms 把它设成 false 时会把窗体挂到隐藏的
+ // "parking window" 之下,于是它不再是 z 序里的顶层窗口 —— 那样就复现不出
+ // 「轮盘 HWND 压在目标上面」的处境了(no-activate 变体靠 WS_EX_TOOLWINDOW 不进任务栏)。
+ ShowInTaskbar = true,
+ TopMost = topmost,
+ BackColor = noActivate ? Color.FromArgb(255, 240, 200) : Color.FromArgb(200, 225, 255)
+ };
+ form.Bounds = bounds;
+ Application.Run(form);
+ return 0;
+ }
+
+ ///
+ /// 诊断模式:在本进程里摆一个窗口,然后打印指定点上系统的真实命中结果与解析结果,
+ /// 用来回答「探针摆的这个窗口到底有没有进 z 序 / 有没有被窗口解析器选中」。
+ /// test_wheel_focus.exe --diag --rect 170,130,200,150 --topmost --at 270,230
+ ///
+ private static int RunDiag(string[] args)
+ {
+ Rectangle bounds = ParseRect(ArgValue(args, "--rect") ?? "170,130,200,150");
+ string[] at = (ArgValue(args, "--at") ?? $"{PointX},{PointY}").Split(',');
+
+ ProbeForm form = new ProbeForm(HasFlag(args, "--noactivate"))
+ {
+ Text = "TFW_DIAG",
+ FormBorderStyle = FormBorderStyle.FixedSingle,
+ StartPosition = FormStartPosition.Manual,
+ ShowInTaskbar = true,
+ TopMost = HasFlag(args, "--topmost")
+ };
+ form.Bounds = bounds;
+ form.Show();
+ Pump();
+ form.EnsureTopmost();
+ Thread.Sleep(200);
+ Pump();
+
+ int x = int.Parse(at[0]);
+ int y = int.Parse(at[1]);
+ Console.WriteLine($"本进程 PID: {Environment.ProcessId}");
+ Console.WriteLine($"本进程窗口: 0x{form.Handle.ToInt64():X} {DescribeWindow(form.Handle, new POINT { X = x, Y = y })}");
+ Console.WriteLine(DescribePoint(x, y));
+ Console.WriteLine($"WheelFocusSwitcher 解析结果 = 0x{WheelFocusSwitcher.TestResolveTargetWindow(x, y).ToInt64():X}");
+ form.Close();
+ return 0;
+ }
+
+ private sealed class ProbeForm : Form
+ {
+ private readonly bool _noActivate;
+
+ public ProbeForm(bool noActivate)
+ {
+ _noActivate = noActivate;
+ }
+
+ protected override CreateParams CreateParams
+ {
+ get
+ {
+ CreateParams cp = base.CreateParams;
+ if (_noActivate)
+ {
+ cp.ExStyle |= WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW;
+ }
+ return cp;
+ }
+ }
+
+ /// 显示后强制一次 HWND_TOPMOST(仅在真正有消息循环的进程里有效,见 EnsureTopmost 注释)。
+ protected override void OnShown(EventArgs e)
+ {
+ base.OnShown(e);
+ EnsureTopmost();
+ }
+
+ ///
+ /// 强制置顶。注意:在没有消息循环的进程里(本探针的父进程)SetWindowPos(HWND_TOPMOST)
+ /// 会返回 true 却落不到扩展样式上(实测 ex 仍无 WS_EX_TOPMOST),所以父进程一侧改用
+ /// 「把窗口激活到最前」来保证它真的压在目标窗口上面。
+ ///
+ public void EnsureTopmost()
+ {
+ if (TopMost && Handle != IntPtr.Zero)
+ {
+ bool ok = SetWindowPos(Handle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
+ if (Trace)
+ {
+ Console.WriteLine($" [trace] SetWindowPos(TOPMOST) ok={ok} err={Marshal.GetLastWin32Error()} ex=0x{GetWindowLongPtr(Handle, GWL_EXSTYLE).ToInt64():X}");
+ }
+ }
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // 探针
+ // ------------------------------------------------------------------
+ private static int RunProbe()
+ {
+ Console.WriteLine("== StarPie 鼠标呼出轮盘 · 前台焦点对齐验证 ==");
+ Console.WriteLine($"屏幕: {Screen.PrimaryScreen!.Bounds} 工作区: {Screen.PrimaryScreen.WorkingArea}");
+ Console.WriteLine($"呼出点: ({PointX},{PointY})(A 窗口的中心)");
+ Console.WriteLine("提示:若桌面上恰好有置顶窗口压在该点上,本套用例会以前置条件失败的方式报出来,请先关掉。");
+ Console.WriteLine();
+
+ nint originalForeground = GetForegroundWindow();
+ List children = new List();
+ List