Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a69ac57bb | ||
|
|
e4324f29cc |
@@ -7,6 +7,18 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-08-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复滑动选择在混合 DPI 多显示器(主屏 125% + 副屏 100%)下命中方向错位的问题:
|
||||||
|
`Window.PointFromScreen` 期望物理设备像素,此前误传 DIP 导致“执行的指令与滑动方向不同”;
|
||||||
|
现滑动选择与点击轮盘外判定均直接使用物理像素,并在 125%/100% 双屏上以 13 点探针
|
||||||
|
验证命中结果与地面真值完全一致。
|
||||||
|
- 修复滑动选择在部分方向不执行的问题:松开快捷键时鼠标位于圆环外、扇形间隙或边界处不再返回空,
|
||||||
|
改为按方向角就近命中扇形(滑出圆环外仍可命中,仅中心枢纽内视为无方向),并新增
|
||||||
|
`PickSectorByAngle` 单元测试与 `scripts/probe-swipe.ps1` 探针脚本。
|
||||||
|
|
||||||
## [1.0.0] - 2026-08-30
|
## [1.0.0] - 2026-08-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
- 按住显示:按住时显示、松开即隐藏。
|
- 按住显示:按住时显示、松开即隐藏。
|
||||||
- **三种选择方式**:
|
- **三种选择方式**:
|
||||||
- 单击选择 / 双击选择;
|
- 单击选择 / 双击选择;
|
||||||
- 滑动选择(仅“按住显示”下可用):按住快捷键移动鼠标,松开时执行鼠标所在方位的指令。
|
- 滑动选择(仅“按住显示”下可用):按住快捷键移动鼠标,松开时按鼠标所在方位就近命中扇形;
|
||||||
|
滑出圆环外、落在扇形间隙或边界处同样能按方向命中,仅中心枢纽内视为无方向。
|
||||||
- **四类指令**:
|
- **四类指令**:
|
||||||
- 打开软件(.exe / .lnk / .bat / .cmd,支持启动参数);
|
- 打开软件(.exe / .lnk / .bat / .cmd,支持启动参数);
|
||||||
- 打开文件夹(资源管理器);
|
- 打开文件夹(资源管理器);
|
||||||
@@ -152,8 +153,8 @@ dotnet publish src/OneClickRun/OneClickRun.csproj -c Release -r win-x64 --self-c
|
|||||||
|
|
||||||
- WPF 透明窗口无法承载系统级 Acrylic 模糊(与逐像素透明互斥),扇形采用**亚克力风格**:
|
- WPF 透明窗口无法承载系统级 Acrylic 模糊(与逐像素透明互斥),扇形采用**亚克力风格**:
|
||||||
80% 透明度渐变叠层 + 高光描边,Win10 / Win11 表现一致。
|
80% 透明度渐变叠层 + 高光描边,Win10 / Win11 表现一致。
|
||||||
- 混合 DPI 多显示器下,鼠标位置模式的坐标换算以鼠标所在显示器为准,极端组合(主屏 100%、
|
- 混合 DPI 多显示器已验证(主屏 125% + 副屏 100%):滑动选择与点击轮盘外判定均按物理像素
|
||||||
副屏 200% 且轮盘跨越屏幕边界)时以屏幕边界夹取处理。
|
换算命中;轮盘位置超出屏幕时按虚拟屏幕边界夹取。
|
||||||
- 全局快捷键若被其他程序占用,设置页会给出“注册失败”提示,请更换组合。
|
- 全局快捷键若被其他程序占用,设置页会给出“注册失败”提示,请更换组合。
|
||||||
|
|
||||||
## 参考资料
|
## 参考资料
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
# 生成发布压缩包:框架依赖版 + 自包含版
|
# 生成发布压缩包:框架依赖版 + 自包含版
|
||||||
|
param([string]$Version = '1.0.0')
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = Split-Path -Parent $PSScriptRoot
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
$publish = Join-Path $root 'publish'
|
$publish = Join-Path $root 'publish'
|
||||||
|
|
||||||
Compress-Archive -Path (Join-Path $publish 'win-x64-fdd/*') -DestinationPath (Join-Path $publish 'OneClickRun-1.0.0-win-x64-fdd.zip') -Force
|
Compress-Archive -Path (Join-Path $publish 'win-x64-fdd/*') -DestinationPath (Join-Path $publish "OneClickRun-$Version-win-x64-fdd.zip") -Force
|
||||||
Compress-Archive -Path (Join-Path $publish 'win-x64-sc/*') -DestinationPath (Join-Path $publish 'OneClickRun-1.0.0-win-x64-sc.zip') -Force
|
Compress-Archive -Path (Join-Path $publish 'win-x64-sc/*') -DestinationPath (Join-Path $publish "OneClickRun-$Version-win-x64-sc.zip") -Force
|
||||||
|
|
||||||
Get-ChildItem (Join-Path $publish '*.zip') |
|
Get-ChildItem (Join-Path $publish '*.zip') |
|
||||||
Select-Object Name, @{n='MB';e={[math]::Round($_.Length/1MB,1)}}, @{n='Time';e={$_.LastWriteTime}} |
|
Select-Object Name, @{n='MB';e={[math]::Round($_.Length/1MB,1)}}, @{n='Time';e={$_.LastWriteTime}} |
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -91,6 +91,14 @@ public partial class App : Application
|
|||||||
return;
|
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)
|
if (Config.WasFirstRun)
|
||||||
{
|
{
|
||||||
Logger.Info("首次运行,显示设置窗口");
|
Logger.Info("首次运行,显示设置窗口");
|
||||||
@@ -124,6 +132,14 @@ public partial class App : Application
|
|||||||
return value != null && int.TryParse(value, out var parsed) ? parsed : null;
|
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)
|
private string? GetArgValue(string name)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < _startupArgs.Length - 1; i++)
|
for (var i = 0; i < _startupArgs.Length - 1; i++)
|
||||||
@@ -149,6 +165,51 @@ public partial class App : Application
|
|||||||
}, DispatcherPriority.ApplicationIdle);
|
}, 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)
|
private void SnapshotWheel(string path)
|
||||||
{
|
{
|
||||||
var window = Wheel!.Window;
|
var window = Wheel!.Window;
|
||||||
|
|||||||
@@ -78,6 +78,25 @@ public static class WheelGeometry
|
|||||||
public static double SectorMidAngleDeg(int index, int count) =>
|
public static double SectorMidAngleDeg(int index, int count) =>
|
||||||
(SectorStartAngle(index, count) + TwoPi / count / 2) * 180 / Math.PI;
|
(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>
|
/// <summary>
|
||||||
/// 命中测试:返回点所在扇形索引;中心区域、圆环外或间隙处返回 null。
|
/// 命中测试:返回点所在扇形索引;中心区域、圆环外或间隙处返回 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ public static class Win32Interop
|
|||||||
public POINT(int x, int y) { X = x; Y = y; }
|
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)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
public struct MSLLHOOKSTRUCT
|
public struct MSLLHOOKSTRUCT
|
||||||
{
|
{
|
||||||
@@ -59,6 +68,9 @@ public static class Win32Interop
|
|||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
public static extern bool GetCursorPos(out POINT lpPoint);
|
public static extern bool GetCursorPos(out POINT lpPoint);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
public static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId);
|
public static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.1</Version>
|
||||||
<Product>一键运行</Product>
|
<Product>一键运行</Product>
|
||||||
<AssemblyTitle>一键运行</AssemblyTitle>
|
<AssemblyTitle>一键运行</AssemblyTitle>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -96,11 +96,15 @@ public class WheelWindowManager : IDisposable
|
|||||||
{
|
{
|
||||||
if (cfg.General.SelectionMode == SelectionMode.Swipe)
|
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]);
|
var index = _window.HitTestSwipePx(cursorPx);
|
||||||
return;
|
if (index is int i && i >= 0 && i < cfg.Items.Count)
|
||||||
|
{
|
||||||
|
ExecuteAndHide(cfg.Items[i]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Hide(); // 按住显示:松开即隐藏
|
Hide(); // 按住显示:松开即隐藏
|
||||||
@@ -157,9 +161,10 @@ public class WheelWindowManager : IDisposable
|
|||||||
private void OnGlobalLeftButtonDown(Point screenPx)
|
private void OnGlobalLeftButtonDown(Point screenPx)
|
||||||
{
|
{
|
||||||
if (!_shown) return;
|
if (!_shown) return;
|
||||||
var dip = DpiUtil.PxToDip(screenPx);
|
// PointFromScreen 使用物理像素,直接判定是否点击在窗口客户区外
|
||||||
if (dip.X < _window.Left || dip.X > _window.Left + _window.Width ||
|
var client = _window.PointFromScreenPx(screenPx);
|
||||||
dip.Y < _window.Top || dip.Y > _window.Top + _window.Height)
|
if (client.X < 0 || client.Y < 0 ||
|
||||||
|
client.X > _window.Width || client.Y > _window.Height)
|
||||||
Hide();
|
Hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,13 +79,25 @@ public partial class RadialMenuWindow : Window
|
|||||||
/// <summary>内部调试:模拟悬停指定扇形(用于快照验证中心文本居中)</summary>
|
/// <summary>内部调试:模拟悬停指定扇形(用于快照验证中心文本居中)</summary>
|
||||||
internal void SimulateHover(int index) => Highlight(index);
|
internal void SimulateHover(int index) => Highlight(index);
|
||||||
|
|
||||||
/// <summary>滑动选择命中测试:屏幕坐标(DIP) → 扇形索引,间隙/中心/圆环外返回 null</summary>
|
/// <summary>精确命中测试:物理屏幕像素 → 扇形索引;间隙/中心/圆环外返回 null(单击/双击用)</summary>
|
||||||
public int? HitTestScreenDip(Point screenDip)
|
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));
|
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) =>
|
private static Brush GetSectorBrush(int index) =>
|
||||||
Application.Current?.TryFindResource($"SectorBrush{index % 12}") as Brush ?? Brushes.CornflowerBlue;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user