diff --git a/CHANGELOG.md b/CHANGELOG.md index d3cd4cf..e2cbafc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ ## [Unreleased] +### Fixed + +- 修复滑动选择在混合 DPI 多显示器(主屏 125% + 副屏 100%)下命中方向错位的问题: + `Window.PointFromScreen` 期望物理设备像素,此前误传 DIP 导致“执行的指令与滑动方向不同”; + 现滑动选择与点击轮盘外判定均直接使用物理像素,并在 125%/100% 双屏上以 13 点探针 + 验证命中结果与地面真值完全一致。 +- 修复滑动选择在部分方向不执行的问题:松开快捷键时鼠标位于圆环外、扇形间隙或边界处不再返回空, + 改为按方向角就近命中扇形(滑出圆环外仍可命中,仅中心枢纽内视为无方向),并新增 + `PickSectorByAngle` 单元测试与 `scripts/probe-swipe.ps1` 探针脚本。 + ## [1.0.0] - 2026-08-30 ### Added diff --git a/README.md b/README.md index 9bcfd77..e0bca4a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ - 按住显示:按住时显示、松开即隐藏。 - **三种选择方式**: - 单击选择 / 双击选择; - - 滑动选择(仅“按住显示”下可用):按住快捷键移动鼠标,松开时执行鼠标所在方位的指令。 + - 滑动选择(仅“按住显示”下可用):按住快捷键移动鼠标,松开时按鼠标所在方位就近命中扇形; + 滑出圆环外、落在扇形间隙或边界处同样能按方向命中,仅中心枢纽内视为无方向。 - **四类指令**: - 打开软件(.exe / .lnk / .bat / .cmd,支持启动参数); - 打开文件夹(资源管理器); @@ -152,8 +153,8 @@ dotnet publish src/OneClickRun/OneClickRun.csproj -c Release -r win-x64 --self-c - WPF 透明窗口无法承载系统级 Acrylic 模糊(与逐像素透明互斥),扇形采用**亚克力风格**: 80% 透明度渐变叠层 + 高光描边,Win10 / Win11 表现一致。 -- 混合 DPI 多显示器下,鼠标位置模式的坐标换算以鼠标所在显示器为准,极端组合(主屏 100%、 - 副屏 200% 且轮盘跨越屏幕边界)时以屏幕边界夹取处理。 +- 混合 DPI 多显示器已验证(主屏 125% + 副屏 100%):滑动选择与点击轮盘外判定均按物理像素 + 换算命中;轮盘位置超出屏幕时按虚拟屏幕边界夹取。 - 全局快捷键若被其他程序占用,设置页会给出“注册失败”提示,请更换组合。 ## 参考资料 diff --git a/scripts/check-display.ps1 b/scripts/check-display.ps1 new file mode 100644 index 0000000..41811c9 --- /dev/null +++ b/scripts/check-display.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = 'Continue' +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$screens = [System.Windows.Forms.Screen]::AllScreens +foreach ($s in $screens) { + Write-Output ("Screen: Bounds={0} Primary={1} WorkingArea={2}" -f $s.Bounds, $s.Primary, $s.WorkingArea) +} +Write-Output ("PrimaryCenterPx: {0}" -f ([System.Windows.Forms.Screen]::PrimaryScreen.Bounds | ForEach-Object { '{0},{1}' -f ($_.X + $_.Width/2), ($_.Y + $_.Height/2) })) +$dpi = Get-ItemProperty 'HKCU:Control PanelDesktopWindowMetrics' -Name AppliedDPI -ErrorAction SilentlyContinue +Write-Output ("AppliedDPI(reg): {0} -> {1}%" -f $dpi.AppliedDPI, ($dpi.AppliedDPI / 96 * 100)) diff --git a/scripts/probe-swipe.ps1 b/scripts/probe-swipe.ps1 new file mode 100644 index 0000000..d5b806c --- /dev/null +++ b/scripts/probe-swipe.ps1 @@ -0,0 +1,35 @@ +# 滑动选择探针:对若干物理坐标输出 实际命中 vs 地面真值 +$ErrorActionPreference = 'Stop' +$exe = 'E:\002_Demo\One_click_run_wpf\src\OneClickRun\bin\Debug\net8.0-windows\OneClickRun.exe' +$logFile = Join-Path $env:APPDATA ('OneClickRun\logs\app-{0}.log' -f (Get-Date -Format 'yyyyMMdd')) + +# 清理本路径残留实例 +Get-Process -Name 'OneClickRun' -ErrorAction SilentlyContinue | + Where-Object { $_.Path -eq $exe } | + ForEach-Object { Stop-Process -Id $_.Id -Force } + +$points = @( + '1024,576', # 轮盘中心(枢纽,应无方向) + '1174,576', # 右,环内 + '1024,426', # 上,环内 + '874,576', # 左,环内 + '1024,726', # 下,环内 + '1424,576', # 右,环外 + '1024,176', # 上,环外 + '1244,796', # 右下,环外 + '624,576', # 左,环外 + '824,776', # 左下,环外 + '-500,900', # 副屏(左侧显示器,100%) + '-960,1153', # 副屏中心 + '0,613' # 副屏右边缘 +) + +foreach ($pt in $points) { + $p = Start-Process -FilePath $exe -ArgumentList @('--probe', $pt) -PassThru + if (-not $p.WaitForExit(15000)) { Stop-Process -Id $p.Id -Force; Write-Output "$pt => 超时"; continue } + if (-not (Test-Path $logFile)) { Write-Output "$pt => 无日志文件 $logFile"; continue } + $lines = Get-Content $logFile + $probeLine = $lines | Where-Object { $_ -like '*Probe: *' } | Select-Object -Last 1 + if ($probeLine) { Write-Output $probeLine } else { Write-Output "$pt => 无 Probe 行" } + Start-Sleep -Milliseconds 300 +} diff --git a/src/OneClickRun/App.xaml.cs b/src/OneClickRun/App.xaml.cs index d411654..3935496 100644 --- a/src/OneClickRun/App.xaml.cs +++ b/src/OneClickRun/App.xaml.cs @@ -91,6 +91,14 @@ public partial class App : Application return; } + // 内部调试:--probe , 输出该物理坐标的滑动命中结果与地面真值对比后退出 + var probeValue = GetArgValue("--probe"); + if (probeValue != null && ParsePoint(probeValue) is Point probePoint) + { + RunProbeMode((int)probePoint.X, (int)probePoint.Y); + return; + } + if (Config.WasFirstRun) { Logger.Info("首次运行,显示设置窗口"); @@ -124,6 +132,14 @@ public partial class App : Application return value != null && int.TryParse(value, out var parsed) ? parsed : null; } + private static Point? ParsePoint(string text) + { + var parts = text.Split(',', StringSplitOptions.TrimEntries); + return parts.Length == 2 && int.TryParse(parts[0], out var x) && int.TryParse(parts[1], out var y) + ? new Point(x, y) + : null; + } + private string? GetArgValue(string name) { for (var i = 0; i < _startupArgs.Length - 1; i++) @@ -149,6 +165,51 @@ public partial class App : Application }, DispatcherPriority.ApplicationIdle); } + private void RunProbeMode(int px, int py) + { + _snapshotMode = true; + Wheel!.Show(); + Dispatcher.BeginInvoke(async () => + { + await Task.Delay(800); + try { ProbeSwipeHit(px, py); } + catch (Exception ex) { Logger.Error("探针失败", ex); } + Shutdown(); + }, DispatcherPriority.ApplicationIdle); + } + + private void ProbeSwipeHit(int px, int py) + { + var window = Wheel!.Window; + var items = Config.Current.Items.Count; + var cursorPx = new Point(px, py); + + // 应用实际坐标链:物理px → PointFromScreen → 命中 + var client = window.PointFromScreenPx(cursorPx); + var actualSwipe = window.HitTestSwipePx(cursorPx); + var actualHit = window.HitTestScreenPx(cursorPx); + + // 地面真值:窗口 HWND 物理矩形 → 物理圆心 → 原始物理坐标直接做几何运算 + var hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle; + if (!Win32Interop.GetWindowRect(hwnd, out var rect)) + { + Logger.Info($"Probe: px=({px},{py}) 获取窗口矩形失败"); + return; + } + var physCenter = new Point((rect.Left + rect.Right) / 2.0, (rect.Top + rect.Bottom) / 2.0); + var winScale = DpiUtil.GetScale(physCenter); + var expectedSwipe = WheelGeometry.PickSectorByAngle(cursorPx, physCenter, + RadialMenuWindow.InnerRadius * winScale, Math.Max(1, items)); + var expectedHit = WheelGeometry.HitTest(cursorPx, physCenter, + RadialMenuWindow.InnerRadius * winScale, RadialMenuWindow.OuterRadius * winScale, + RadialMenuWindow.GapWidth * winScale, Math.Max(1, items)); + + Logger.Info($"Probe: px=({px},{py}) winRect=({rect.Left},{rect.Top},{rect.Right},{rect.Bottom}) " + + $"physCenter=({physCenter.X:0.#},{physCenter.Y:0.#}) winScale={winScale:0.###} " + + $"client=({client.X:0.#},{client.Y:0.#}) " + + $"swipe={actualSwipe} expectedSwipe={expectedSwipe} hit={actualHit} expectedHit={expectedHit} items={items}"); + } + private void SnapshotWheel(string path) { var window = Wheel!.Window; diff --git a/src/OneClickRun/Helpers/WheelGeometry.cs b/src/OneClickRun/Helpers/WheelGeometry.cs index f5da00f..f00bebe 100644 --- a/src/OneClickRun/Helpers/WheelGeometry.cs +++ b/src/OneClickRun/Helpers/WheelGeometry.cs @@ -78,6 +78,25 @@ public static class WheelGeometry public static double SectorMidAngleDeg(int index, int count) => (SectorStartAngle(index, count) + TwoPi / count / 2) * 180 / Math.PI; + /// + /// 滑动选择:按方向角选取最近的扇形。不区分间隙与圆环内外(快速滑出圆环仍可命中), + /// 仅在中心枢纽(半径小于 innerRadius)内返回 null(视为无方向)。 + /// + public static int? PickSectorByAngle(Point point, Point center, double innerRadius, int count) + { + if (count <= 0) return null; + var dx = point.X - center.X; + var dy = point.Y - center.Y; + var rho = Math.Sqrt(dx * dx + dy * dy); + if (rho < innerRadius) return null; // 中心枢纽:无方向 + if (count == 1) return 0; + var phi = Math.Atan2(dy, dx); + var rel = ((phi - StartAngle) % TwoPi + TwoPi) % TwoPi; + var span = TwoPi / count; + var index = (int)(rel / span) % count; + return index; + } + /// /// 命中测试:返回点所在扇形索引;中心区域、圆环外或间隙处返回 null。 /// diff --git a/src/OneClickRun/Helpers/Win32Interop.cs b/src/OneClickRun/Helpers/Win32Interop.cs index ccfdaeb..e525771 100644 --- a/src/OneClickRun/Helpers/Win32Interop.cs +++ b/src/OneClickRun/Helpers/Win32Interop.cs @@ -35,6 +35,15 @@ public static class Win32Interop public POINT(int x, int y) { X = x; Y = y; } } + [StructLayout(LayoutKind.Sequential)] + public struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + [StructLayout(LayoutKind.Sequential)] public struct MSLLHOOKSTRUCT { @@ -59,6 +68,9 @@ public static class Win32Interop [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT lpPoint); + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [DllImport("user32.dll")] public static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId); diff --git a/src/OneClickRun/Services/WheelWindowManager.cs b/src/OneClickRun/Services/WheelWindowManager.cs index 601ca32..9acfd59 100644 --- a/src/OneClickRun/Services/WheelWindowManager.cs +++ b/src/OneClickRun/Services/WheelWindowManager.cs @@ -96,11 +96,15 @@ public class WheelWindowManager : IDisposable { if (cfg.General.SelectionMode == SelectionMode.Swipe) { - var index = _window.HitTestScreenDip(DpiUtil.GetCursorPosDip()); - if (index is int i && i >= 0 && i < cfg.Items.Count) + // 按松开时鼠标所在方位选取最近扇形:滑出圆环外或落在间隙/边界也能命中 + if (Win32Interop.TryGetCursorPos(out var cursorPx)) { - ExecuteAndHide(cfg.Items[i]); - return; + var index = _window.HitTestSwipePx(cursorPx); + if (index is int i && i >= 0 && i < cfg.Items.Count) + { + ExecuteAndHide(cfg.Items[i]); + return; + } } } Hide(); // 按住显示:松开即隐藏 @@ -157,9 +161,10 @@ public class WheelWindowManager : IDisposable private void OnGlobalLeftButtonDown(Point screenPx) { if (!_shown) return; - var dip = DpiUtil.PxToDip(screenPx); - if (dip.X < _window.Left || dip.X > _window.Left + _window.Width || - dip.Y < _window.Top || dip.Y > _window.Top + _window.Height) + // PointFromScreen 使用物理像素,直接判定是否点击在窗口客户区外 + var client = _window.PointFromScreenPx(screenPx); + if (client.X < 0 || client.Y < 0 || + client.X > _window.Width || client.Y > _window.Height) Hide(); } diff --git a/src/OneClickRun/Views/RadialMenuWindow.xaml.cs b/src/OneClickRun/Views/RadialMenuWindow.xaml.cs index a665341..4fc2492 100644 --- a/src/OneClickRun/Views/RadialMenuWindow.xaml.cs +++ b/src/OneClickRun/Views/RadialMenuWindow.xaml.cs @@ -79,13 +79,25 @@ public partial class RadialMenuWindow : Window /// 内部调试:模拟悬停指定扇形(用于快照验证中心文本居中) internal void SimulateHover(int index) => Highlight(index); - /// 滑动选择命中测试:屏幕坐标(DIP) → 扇形索引,间隙/中心/圆环外返回 null - public int? HitTestScreenDip(Point screenDip) + /// 精确命中测试:物理屏幕像素 → 扇形索引;间隙/中心/圆环外返回 null(单击/双击用) + public int? HitTestScreenPx(Point screenPx) { - var client = PointFromScreen(screenDip); + // 注意:Window.PointFromScreen 期望的是物理设备像素(Device 坐标), + // 传入 DIP 会在非 100% 缩放的显示器上产生方向性偏移 + var client = PointFromScreen(screenPx); return WheelGeometry.HitTest(client, CanvasCenter, InnerRadius, OuterRadius, GapWidth, Math.Max(1, _items.Count)); } + /// 滑动选择命中:物理屏幕像素 → 按方向角取最近扇形;中心枢纽内返回 null(无方向) + public int? HitTestSwipePx(Point screenPx) + { + var client = PointFromScreen(screenPx); + return WheelGeometry.PickSectorByAngle(client, CanvasCenter, InnerRadius, Math.Max(1, _items.Count)); + } + + /// 内部调试/外部点击判定:物理屏幕像素 → 窗口客户区坐标 + internal Point PointFromScreenPx(Point screenPx) => PointFromScreen(screenPx); + private static Brush GetSectorBrush(int index) => Application.Current?.TryFindResource($"SectorBrush{index % 12}") as Brush ?? Brushes.CornflowerBlue; diff --git a/tests/OneClickRun.Tests/WheelGeometrySwipeTests.cs b/tests/OneClickRun.Tests/WheelGeometrySwipeTests.cs new file mode 100644 index 0000000..d983eac --- /dev/null +++ b/tests/OneClickRun.Tests/WheelGeometrySwipeTests.cs @@ -0,0 +1,64 @@ +using System.Windows; +using OneClickRun.Helpers; +using Xunit; + +namespace OneClickRun.Tests; + +/// 滑动选择:按方向角选取最近扇形的行为测试 +public class WheelGeometrySwipeTests +{ + private const double InnerR = 70; + private static readonly Point Center = new(300, 300); + + private static Point At(double angleDeg, double radius) => + WheelGeometry.PointOnCircle(Center, radius, angleDeg * Math.PI / 180); + + [Theory] + [InlineData(4, -90, 0)] // 上 → 扇形0 + [InlineData(4, 0, 1)] // 右 → 扇形1 + [InlineData(4, 90, 2)] // 下 → 扇形2 + [InlineData(4, 180, 3)] // 左 → 扇形3 + [InlineData(3, -90, 0)] // 上 → 扇形0 + [InlineData(3, 0, 0)] // 右 → 扇形0(3:00 位于扇形0 区域内) + [InlineData(3, 90, 1)] // 下 → 扇形1 + [InlineData(3, 180, 2)] // 左 → 扇形2 + [InlineData(12, 15, 3)] // 12 扇区:15° 方向 → rel=105°,span=30° → 扇形3 + public void PicksSectorByDirection(int count, double angleDeg, int expected) + { + var point = At(angleDeg, (InnerR + 220) / 2); + Assert.Equal(expected, WheelGeometry.PickSectorByAngle(point, Center, InnerR, count)); + } + + [Fact] + public void CenterHubReturnsNull() + { + Assert.Null(WheelGeometry.PickSectorByAngle(Center, Center, InnerR, 4)); + Assert.Null(WheelGeometry.PickSectorByAngle(At(45, InnerR - 5), Center, InnerR, 4)); + } + + [Fact] + public void BeyondOuterRadiusStillPicksByDirection() + { + // 快速滑动滑出圆环外:仍按方向命中 + Assert.Equal(0, WheelGeometry.PickSectorByAngle(At(-45, 800), Center, InnerR, 4)); + Assert.Equal(2, WheelGeometry.PickSectorByAngle(At(135, 2000), Center, InnerR, 4)); + } + + [Fact] + public void GapAndBoundaryPickNearestSector() + { + // 4 扇区:89°/91° 位于 90° 边界两侧的间隙内,应就近归属扇形1/扇形2 + Assert.Equal(1, WheelGeometry.PickSectorByAngle(At(89, 145), Center, InnerR, 4)); + Assert.Equal(2, WheelGeometry.PickSectorByAngle(At(91, 145), Center, InnerR, 4)); + // 恰好正下方:边界平局 → 扇形2 + Assert.Equal(2, WheelGeometry.PickSectorByAngle(At(90, 145), Center, InnerR, 4)); + } + + [Fact] + public void SingleSectorPicksAnyDirection() + { + Assert.Equal(0, WheelGeometry.PickSectorByAngle(At(0, 300), Center, InnerR, 1)); + Assert.Equal(0, WheelGeometry.PickSectorByAngle(At(213, 300), Center, InnerR, 1)); + Assert.Null(WheelGeometry.PickSectorByAngle(Center, Center, InnerR, 1)); + } +}