fix: 修复滑动选择方向错位与边界/圆环外不命中(物理像素换算 + 按方向就近命中)

This commit is contained in:
2026-08-30 16:24:40 +08:00
parent ad98eb18dc
commit e4324f29cc
10 changed files with 242 additions and 13 deletions
+10
View File
@@ -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
+4 -3
View File
@@ -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%):滑动选择与点击轮盘外判定均按物理像素
换算命中;轮盘位置超出屏幕时按虚拟屏幕边界夹取。
- 全局快捷键若被其他程序占用,设置页会给出“注册失败”提示,请更换组合。
## 参考资料
+10
View File
@@ -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))
+35
View File
@@ -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
}
+61
View File
@@ -91,6 +91,14 @@ public partial class App : Application
return;
}
// 内部调试:--probe <x>,<y> 输出该物理坐标的滑动命中结果与地面真值对比后退出
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;
+19
View File
@@ -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;
/// <summary>
/// 滑动选择:按方向角选取最近的扇形。不区分间隙与圆环内外(快速滑出圆环仍可命中),
/// 仅在中心枢纽(半径小于 innerRadius)内返回 null(视为无方向)。
/// </summary>
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;
}
/// <summary>
/// 命中测试:返回点所在扇形索引;中心区域、圆环外或间隙处返回 null。
/// </summary>
+12
View File
@@ -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);
@@ -96,13 +96,17 @@ public class WheelWindowManager : IDisposable
{
if (cfg.General.SelectionMode == SelectionMode.Swipe)
{
var index = _window.HitTestScreenDip(DpiUtil.GetCursorPosDip());
// 按松开时鼠标所在方位选取最近扇形:滑出圆环外或落在间隙/边界也能命中
if (Win32Interop.TryGetCursorPos(out var cursorPx))
{
var index = _window.HitTestSwipePx(cursorPx);
if (index is int i && i >= 0 && i < cfg.Items.Count)
{
ExecuteAndHide(cfg.Items[i]);
return;
}
}
}
Hide(); // 按住显示:松开即隐藏
}
// 长按显示:松开不影响已显示的轮盘,等待选择 / Esc / 点击外部
@@ -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();
}
+15 -3
View File
@@ -79,13 +79,25 @@ public partial class RadialMenuWindow : Window
/// <summary>内部调试:模拟悬停指定扇形(用于快照验证中心文本居中)</summary>
internal void SimulateHover(int index) => Highlight(index);
/// <summary>滑动选择命中测试:屏幕坐标(DIP) → 扇形索引间隙/中心/圆环外返回 null</summary>
public int? HitTestScreenDip(Point screenDip)
/// <summary>精确命中测试:物理屏幕像素 → 扇形索引间隙/中心/圆环外返回 null(单击/双击用)</summary>
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));
}
/// <summary>滑动选择命中:物理屏幕像素 → 按方向角取最近扇形;中心枢纽内返回 null(无方向)</summary>
public int? HitTestSwipePx(Point screenPx)
{
var client = PointFromScreen(screenPx);
return WheelGeometry.PickSectorByAngle(client, CanvasCenter, InnerRadius, Math.Max(1, _items.Count));
}
/// <summary>内部调试/外部点击判定:物理屏幕像素 → 窗口客户区坐标</summary>
internal Point PointFromScreenPx(Point screenPx) => PointFromScreen(screenPx);
private static Brush GetSectorBrush(int index) =>
Application.Current?.TryFindResource($"SectorBrush{index % 12}") as Brush ?? Brushes.CornflowerBlue;
@@ -0,0 +1,64 @@
using System.Windows;
using OneClickRun.Helpers;
using Xunit;
namespace OneClickRun.Tests;
/// <summary>滑动选择:按方向角选取最近扇形的行为测试</summary>
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));
}
}