feat: 初始提交——一键运行快捷轮盘工具(WPF + C#)
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
# ================= 构建产物 =================
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
publish/
|
||||
artifacts/
|
||||
|
||||
# ================= IDE / 工具 =================
|
||||
.vs/
|
||||
.idea/
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
.dsh-vision-router/
|
||||
TestResults/
|
||||
|
||||
# ================= 系统文件 =================
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
@@ -0,0 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
本项目的所有重要变更都会记录在此文件中。
|
||||
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
||||
版本号遵循 [语义化版本 SemVer](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- 首次实现“一键运行”完整功能(v1.0.0 基线):
|
||||
- 游戏式快捷轮盘:透明背景 + 亚克力风格扇形,支持 1–12 条指令;
|
||||
扇形边界采用平行偏移几何,任意半径处相邻扇形间隙宽度恒定(默认 10px),并有单元测试与像素级验证。
|
||||
- 全局快捷键呼出轮盘(默认 Ctrl+Alt+Space,可在设置中录制/清空),
|
||||
支持点击显示 / 长按显示(阈值可调)/ 按住显示三种呼出方式。
|
||||
- 支持单击选择 / 双击选择 / 滑动选择三种选择方式(滑动选择仅在按住显示下可用,松开时按鼠标方位执行)。
|
||||
- 四类指令:打开软件(含参数)、打开文件夹、打开网址(自动补全协议)、
|
||||
运行 PowerShell 脚本(使用 PowerShell 7.6.5 的 pwsh.exe,支持脚本参数)。
|
||||
- 主设置界面:轮盘指令管理(新建/编辑/删除/排序)、系统设置(开机启动、轮盘开关、
|
||||
显示位置、快捷键、呼出/选择方式、长按阈值、日间/黑夜/跟随系统主题),改动即时生效并自动保存。
|
||||
- 托盘常驻(双击/右键菜单)、单实例(二次启动唤起设置窗口)、PerMonitorV2 多显示器 DPI 感知。
|
||||
- 配置持久化到 %APPDATA%\OneClickRun\config.json(原子写入,损坏自动备份重建),
|
||||
按天滚动日志,指令执行失败非阻塞 Toast 提示。
|
||||
- xUnit 测试项目 26 项测试(几何等间距、热键格式往返、配置读写与损坏兜底、pwsh 参数引号)。
|
||||
- 内部调试参数 `--snapshot` 用于导出轮盘渲染快照;scripts/ 提供冒烟、单实例与像素验证脚本。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复轮盘显示时鼠标划过扇形后,中心文本(“一键运行”)因悬停提示文字宽度变化而偏移的问题:
|
||||
枢纽文本改用固定尺寸居中容器,提示行固定单行(超长省略),悬停结束后恢复默认提示,
|
||||
标题在任意状态下始终保持居中(像素级验证:标题包围盒中心恒等于画布中心)。
|
||||
- 修复扇形区域文字沿半径方向旋转、不便阅读的问题:扇形文字与图标改为按屏幕方向水平正向排布
|
||||
(图标在上、名称在下),并根据扇形数量自适应文字宽度避免相邻扇形重叠;
|
||||
经笔画走向分析与 OCR 验证各扇形文字均为水平正立。
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BEA48898-514A-483D-A8A7-9A02C38E5F38}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OneClickRun", "src\OneClickRun\OneClickRun.csproj", "{05A181F3-F512-4839-9E92-1D8543E9B9DA}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F67AA50D-099A-4419-B84A-FD4963DB9748}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OneClickRun.Tests", "tests\OneClickRun.Tests\OneClickRun.Tests.csproj", "{D9C2ED0A-3A56-40AF-85C8-5034A2A40F3D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{05A181F3-F512-4839-9E92-1D8543E9B9DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{05A181F3-F512-4839-9E92-1D8543E9B9DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{05A181F3-F512-4839-9E92-1D8543E9B9DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{05A181F3-F512-4839-9E92-1D8543E9B9DA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D9C2ED0A-3A56-40AF-85C8-5034A2A40F3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D9C2ED0A-3A56-40AF-85C8-5034A2A40F3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D9C2ED0A-3A56-40AF-85C8-5034A2A40F3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D9C2ED0A-3A56-40AF-85C8-5034A2A40F3D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{05A181F3-F512-4839-9E92-1D8543E9B9DA} = {BEA48898-514A-483D-A8A7-9A02C38E5F38}
|
||||
{D9C2ED0A-3A56-40AF-85C8-5034A2A40F3D} = {F67AA50D-099A-4419-B84A-FD4963DB9748}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,163 @@
|
||||
# 一键运行(OneClickRun)
|
||||
|
||||
以游戏式**快捷轮盘**快速启动软件、打开文件夹、访问网址与运行 PowerShell 脚本的 Windows 桌面工具。
|
||||
程序默认驻留系统托盘,按全局快捷键呼出轮盘,选择指令后自动隐藏。
|
||||
|
||||
- 运行平台:Windows 10 / 11
|
||||
- 技术栈:WPF + C#(.NET 8,`net8.0-windows`)
|
||||
- 界面语言:简体中文
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **快捷轮盘**:透明背景、亚克力风格扇形圆环,支持 1–12 条指令,扇形间留有均匀间距。
|
||||
- **等间距几何**:每个扇形边界为平行偏移线,**任意半径处相邻扇形间隙宽度恒定**(默认 10 像素),
|
||||
由单元测试与像素级验证双重保证。
|
||||
- **呼出与隐藏**:默认隐藏,全局快捷键呼出;执行指令 / Esc / 点击轮盘外区域 / 再按快捷键 均可隐藏。
|
||||
- **三种呼出方式**:
|
||||
- 点击显示:按一次快捷键显示,再按一次隐藏;
|
||||
- 长按显示:按住超过阈值(300–1500ms 可调)后显示;
|
||||
- 按住显示:按住时显示、松开即隐藏。
|
||||
- **三种选择方式**:
|
||||
- 单击选择 / 双击选择;
|
||||
- 滑动选择(仅“按住显示”下可用):按住快捷键移动鼠标,松开时执行鼠标所在方位的指令。
|
||||
- **四类指令**:
|
||||
- 打开软件(.exe / .lnk / .bat / .cmd,支持启动参数);
|
||||
- 打开文件夹(资源管理器);
|
||||
- 打开网址(默认浏览器,无协议前缀自动补全 `https://`);
|
||||
- 运行脚本(使用 **PowerShell 7.6.5** 的 `pwsh.exe` 执行,支持脚本参数)。
|
||||
- **系统设置**:开机启动、轮盘全局开关、显示位置(屏幕中心 / 鼠标位置)、全局快捷键录制、
|
||||
呼出方式、选择方式、长按阈值、主题(日间 / 黑夜 / 跟随系统),全部即时生效并自动保存。
|
||||
- **托盘与单实例**:关闭设置窗口后驻留托盘;双击托盘恢复设置;二次启动自动唤起已有实例。
|
||||
- **容错**:配置文件损坏自动备份重建;指令执行失败非阻塞提示并记录日志;多显示器 DPI 感知(PerMonitorV2)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
.
|
||||
├── OneClickRun.sln
|
||||
├── global.json # 固定 .NET SDK 8.0
|
||||
├── src/OneClickRun/
|
||||
│ ├── OneClickRun.csproj # net8.0-windows; WPF + WinForms(托盘); 无第三方运行时依赖
|
||||
│ ├── app.manifest # PerMonitorV2 DPI 感知
|
||||
│ ├── App.xaml / App.xaml.cs # 入口:单实例、托盘、服务装配、快照调试模式
|
||||
│ ├── Models/ # AppConfig / GeneralSettings / HotkeyBinding / WheelItem / 枚举
|
||||
│ ├── Services/
|
||||
│ │ ├── ConfigService.cs # %APPDATA%\OneClickRun\config.json 读写与损坏兜底
|
||||
│ │ ├── HotkeyService.cs # RegisterHotKey 全局快捷键
|
||||
│ │ ├── WheelWindowManager.cs # 三种呼出/选择模式状态机(30ms 按键轮询)
|
||||
│ │ ├── CommandRunner.cs # 四类指令执行
|
||||
│ │ ├── PowerShellLocator.cs # 定位 pwsh.exe 并构造参数
|
||||
│ │ ├── AutostartService.cs # HKCU Run 开机启动
|
||||
│ │ ├── ThemeService.cs # 日间/黑夜/跟随系统
|
||||
│ │ ├── ToastService.cs # 非阻塞右下角提示
|
||||
│ │ └── LogService.cs # 按天滚动文件日志
|
||||
│ ├── Helpers/ # Win32 P/Invoke、轮盘几何、热键格式、DPI、全局鼠标钩子、应用图标
|
||||
│ ├── Views/ # 主设置窗口、轮盘窗口、三个设置页、托盘宿主窗口、Toast
|
||||
│ ├── Controls/ # 快捷键录制控件、指令编辑对话框
|
||||
│ ├── Converters/ # 枚举显示/等值/徽标颜色等转换器
|
||||
│ ├── Themes/ # Light.xaml / Dark.xaml / Styles.xaml(现代简约控件样式)
|
||||
│ └── assets/samples/hello.ps1 # 内置示例脚本
|
||||
├── tests/OneClickRun.Tests/ # xUnit:几何等间距、热键格式、配置读写、参数引号
|
||||
├── scripts/ # 冒烟/单实例/像素验证脚本(PowerShell 7.6.5)
|
||||
└── artifacts/ # 轮盘渲染快照(wheel-light.png / wheel-dark.png)
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Windows 10 / 11(x64)
|
||||
- [.NET 8 Desktop Runtime](https://dotnet.microsoft.com/zh-cn/download/dotnet/8.0)(开发/构建需 .NET SDK 8.0.4xx)
|
||||
- **PowerShell 7(建议 7.6.5)**:仅“运行脚本”类指令需要;未安装时该指令会给出安装提示。
|
||||
程序按 `PATH` → `%ProgramFiles%\PowerShell\7\pwsh.exe` → `%LocalAppData%\Programs\PowerShell\7\pwsh.exe` 顺序查找。
|
||||
- 无需管理员权限;开机启动写入当前用户注册表(HKCU Run)。
|
||||
|
||||
## 安装与运行
|
||||
|
||||
```bash
|
||||
# 构建(Debug)
|
||||
dotnet build OneClickRun.sln -c Debug
|
||||
|
||||
# 运行
|
||||
src/OneClickRun/bin/Debug/net8.0-windows/OneClickRun.exe
|
||||
|
||||
# 单元测试(26 项:几何等间距 / 热键格式 / 配置读写 / 参数引号)
|
||||
dotnet test OneClickRun.sln -c Release
|
||||
|
||||
# 发布(框架依赖,体积小)
|
||||
dotnet publish src/OneClickRun/OneClickRun.csproj -c Release -r win-x64 --self-contained false -o publish/
|
||||
|
||||
# 发布(自包含,目标机器无需安装 .NET 运行时)
|
||||
dotnet publish src/OneClickRun/OneClickRun.csproj -c Release -r win-x64 --self-contained true -o publish/
|
||||
```
|
||||
|
||||
首次运行会生成默认配置(4 条示例指令:打开文档 / 记事本 / 必应 / 示例脚本)并打开设置窗口;
|
||||
此后启动直接驻留托盘。
|
||||
|
||||
## 使用说明
|
||||
|
||||
- 按全局快捷键(默认 **Ctrl+Alt+Space**)呼出轮盘;点击扇形执行对应指令,轮盘自动隐藏。
|
||||
- Esc、点击轮盘外区域或(点击显示模式下)再按一次快捷键可隐藏轮盘。
|
||||
- 托盘图标:双击打开设置;右键菜单含“打开设置 / 显示轮盘 / 开机启动 / 退出”。
|
||||
- 关闭设置窗口 = 最小化到托盘,应用继续在后台运行;仅托盘“退出”真正退出。
|
||||
- 全部设置在“系统设置”页即时生效并自动保存到 `%APPDATA%\OneClickRun\config.json`。
|
||||
- 轮盘指令在“轮盘指令”页维护:新建、编辑、删除、上移/下移,按列表顺序在轮盘上顺时针排布。
|
||||
- 关于页可查看/打开配置与日志目录,并显示 PowerShell 探测状态。
|
||||
|
||||
### 内部调试参数
|
||||
|
||||
- `OneClickRun.exe --snapshot <输出.png> [--dark] [--hover N]`:显示轮盘并导出 PNG 快照后退出(不保存配置),`--hover N` 可模拟悬停第 N 个扇形用于验证中心文本居中,
|
||||
用于渲染验证,例如 `artifacts/wheel-light.png`。
|
||||
|
||||
## 配置与日志
|
||||
|
||||
- 配置:`%APPDATA%\OneClickRun\config.json`(UTF-8 JSON,原子写入;损坏时备份为
|
||||
`config.broken-*.json` 并重建默认配置)。
|
||||
- 日志:`%APPDATA%\OneClickRun\logs\app-YYYYMMDD.log`(按天滚动,记录热键注册、指令执行、异常等)。
|
||||
- 配置结构示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"general": {
|
||||
"autoStart": false,
|
||||
"wheelEnabled": true,
|
||||
"wheelPosition": "ScreenCenter",
|
||||
"hotkey": { "modifiers": ["Ctrl", "Alt"], "key": "Space" },
|
||||
"triggerMode": "Click",
|
||||
"longPressMs": 400,
|
||||
"selectionMode": "SingleClick",
|
||||
"theme": "System"
|
||||
},
|
||||
"items": [
|
||||
{ "id": "…", "name": "记事本", "type": "App", "path": "notepad.exe", "args": "" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 验证方式
|
||||
|
||||
- `dotnet test OneClickRun.sln -c Release`:26 项测试全绿;其中
|
||||
`AdjacentSectorGapIsConstantAtEveryRadius` 用数值验证**任意半径处相邻扇形间隙恒等于 10px**(误差 < 1e-9)。
|
||||
- `pwsh -NoProfile -File scripts/smoke.ps1`:启动冒烟(进程存活、默认配置生成、日志输出)。
|
||||
- `pwsh -NoProfile -File scripts/analyze-wheel.ps1`:对轮盘快照做像素级间隙测量
|
||||
(三个半径处间隙宽度一致,不随半径增大)。
|
||||
- 手动验收清单:三种呼出方式、三种选择方式、四类指令、主题切换、开机启动、多显示器。
|
||||
|
||||
## 安全与凭据说明
|
||||
|
||||
- 本工具不收集、不传输任何数据;配置与日志仅保存在本机 `%APPDATA%\OneClickRun`。
|
||||
- 开机启动仅写入当前用户注册表(HKCU Run),不使用管理员权限。
|
||||
- 脚本类指令以当前用户身份执行,请勿添加来源不明的脚本。
|
||||
|
||||
## 已知取舍
|
||||
|
||||
- WPF 透明窗口无法承载系统级 Acrylic 模糊(与逐像素透明互斥),扇形采用**亚克力风格**:
|
||||
80% 透明度渐变叠层 + 高光描边,Win10 / Win11 表现一致。
|
||||
- 混合 DPI 多显示器下,鼠标位置模式的坐标换算以鼠标所在显示器为准,极端组合(主屏 100%、
|
||||
副屏 200% 且轮盘跨越屏幕边界)时以屏幕边界夹取处理。
|
||||
- 全局快捷键若被其他程序占用,设置页会给出“注册失败”提示,请更换组合。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [.NET 8 下载](https://dotnet.microsoft.com/zh-cn/download/dotnet/8.0)
|
||||
- [PowerShell 7.6 安装](https://learn.microsoft.com/zh-cn/powershell/scripting/install/installing-powershell-on-windows)
|
||||
- [RegisterHotKey(Win32)](https://learn.microsoft.com/zh-cn/windows/win32/api/winuser/nf-winuser-registerhotkey)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"rollForward": "latestFeature",
|
||||
"version": "8.0.424"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# 一键运行工具开发需求
|
||||
|
||||
## 概述
|
||||
|
||||
一键运行工具是采用游戏中常见的快捷轮盘的形式,快速启动软件、打开文件夹、打开链接等快捷指令。
|
||||
|
||||
## 运行平台与技术栈
|
||||
|
||||
- 运行平台:Windows平台(Win10,Win11)
|
||||
- 技术栈:WPF + C#
|
||||
|
||||
## 功能需求
|
||||
|
||||
- 轮盘:在屏幕上一个圆环区域分割出多个扇形区域,每个区域分配一个操作或指令
|
||||
- 轮盘的呼出和隐藏:默认轮盘隐藏,通过指定快捷键呼出轮盘,选择轮盘上的指令后,自动隐藏轮盘
|
||||
- 轮盘支持的指令:打开软件、打开指定文件夹、打开指定网站、运行指定的脚本(powershell)等
|
||||
- 主界面:提供一个主界面,用于设置软件的各项功能,具体设置参考后续
|
||||
|
||||
## 快捷指令
|
||||
|
||||
- 打开软件:指定程序路径
|
||||
- 打开文件夹:指定文件夹路径
|
||||
- 打开网址:以默认浏览器打开指定网址
|
||||
- 运行脚本:指定脚本文件
|
||||
|
||||
## 系统设置
|
||||
|
||||
系统的设置项包括:
|
||||
- 开机启动开关
|
||||
- 轮盘全局开关:关闭时,不再呼出轮盘
|
||||
- 轮盘显示位置:可选项包括:
|
||||
- 屏幕中心:呼出轮盘时在屏幕中心显示
|
||||
- 鼠标位置:呼出轮盘时在鼠标位置显示
|
||||
- 全局快捷键:呼出轮盘的快捷键,全局有效
|
||||
- 呼出轮盘操作:
|
||||
- 点击显示:按一次快捷键即显示轮盘
|
||||
- 长按显示:长按快捷键一定时间显示轮盘
|
||||
- 按住显示:按住快捷键时显示轮盘,松开即隐藏
|
||||
- 轮盘指令选择操作:
|
||||
- 单击选择:单击轮盘指令选择
|
||||
- 双击选择:双击轮盘指令选择
|
||||
- 滑动选择:选择鼠标滑动方向所指的指令(仅在按住显示轮盘时有效,以松开快捷键时,鼠标所在轮盘的方位选择指定的指令
|
||||
- 日间/黑夜模式切换:日间/黑夜/跟随系统
|
||||
- 轮盘指令设置,设置轮盘的各项指令
|
||||
|
||||
## UI风格
|
||||
|
||||
- 以现代简约风格为主,支持日间/黑夜模式
|
||||
- 轮盘背景设置为透明,轮盘选项(扇形区域)背景设置为亚克力
|
||||
- 轮盘扇形之间保留一些间距,注意不论距离中心多远,扇形之间的间距都相等
|
||||
|
||||
## 其他
|
||||
|
||||
开发时如需执行PowerShell指令,请使用PowerShell 7.6.5
|
||||
@@ -0,0 +1,48 @@
|
||||
# 验证轮盘中心文本居中:标题像素包围盒应关于画布中心 (268,268) 对称,且状态间完全一致
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
function Measure-Hub($path, [string]$mode) {
|
||||
$bmp = [System.Drawing.Bitmap]::FromFile((Resolve-Path $path))
|
||||
$cx = 268; $cy = 268; $hubR = 54
|
||||
$darkN = 0; $darkX = 0.0; $darkY = 0.0
|
||||
$darkMinX = 9999; $darkMaxX = -1; $darkMinY = 9999; $darkMaxY = -1
|
||||
$grayN = 0; $grayX = 0.0; $grayY = 0.0
|
||||
for ($y = $cy - $hubR; $y -le $cy + $hubR; $y++) {
|
||||
for ($x = $cx - $hubR; $x -le $cx + $hubR; $x++) {
|
||||
$dx = $x - $cx; $dy = $y - $cy
|
||||
if ($dx * $dx + $dy * $dy -gt $hubR * $hubR) { continue }
|
||||
$px = $bmp.GetPixel($x, $y)
|
||||
if ($px.A -lt 200) { continue }
|
||||
$sum = $px.R + $px.G + $px.B
|
||||
if ($mode -eq 'light') {
|
||||
if ($sum -lt 330) {
|
||||
$darkN++; $darkX += $x; $darkY += $y
|
||||
if ($x -lt $darkMinX) { $darkMinX = $x }; if ($x -gt $darkMaxX) { $darkMaxX = $x }
|
||||
if ($y -lt $darkMinY) { $darkMinY = $y }; if ($y -gt $darkMaxY) { $darkMaxY = $y }
|
||||
}
|
||||
elseif ($sum -ge 300 -and $sum -le 420) { $grayN++; $grayX += $x; $grayY += $y }
|
||||
} else {
|
||||
if ($sum -gt 560) {
|
||||
$darkN++; $darkX += $x; $darkY += $y
|
||||
if ($x -lt $darkMinX) { $darkMinX = $x }; if ($x -gt $darkMaxX) { $darkMaxX = $x }
|
||||
if ($y -lt $darkMinY) { $darkMinY = $y }; if ($y -gt $darkMaxY) { $darkMaxY = $y }
|
||||
}
|
||||
elseif ($sum -ge 430 -and $sum -le 560) { $grayN++; $grayX += $x; $grayY += $y }
|
||||
}
|
||||
}
|
||||
}
|
||||
$bmp.Dispose()
|
||||
$bboxCX = if ($darkN -gt 0) { ($darkMinX + $darkMaxX) / 2.0 } else { -1 }
|
||||
Write-Output (" TitlePixelCount={0} BboxX=[{1},{2}] BboxCenterX={3} (期望268) BboxY=[{4},{5}]" -f $darkN, $darkMinX, $darkMaxX, [Math]::Round($bboxCX, 2), $darkMinY, $darkMaxY)
|
||||
Write-Output (" HintPixelCount={0} HintCenterX={1} (供参考)" -f $grayN, $(if ($grayN -gt 0) { [Math]::Round($grayX / $grayN, 2) } else { -1 }))
|
||||
}
|
||||
|
||||
foreach ($file in 'wheel-light.png', 'wheel-light-hover.png') {
|
||||
Write-Output "=== $file ==="
|
||||
Measure-Hub "artifacts\$file" 'light'
|
||||
}
|
||||
foreach ($file in 'wheel-dark.png', 'wheel-dark-hover.png') {
|
||||
Write-Output "=== $file ==="
|
||||
Measure-Hub "artifacts\$file" 'dark'
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# 验证扇形文字走向:水平文字 → 白色水平笔画段占优;旧径向布局 → 对角笔画段占优
|
||||
# 并校验图标位置:新布局图标在文字上方(旧布局图标朝外侧/下方)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$bmp = [System.Drawing.Bitmap]::FromFile((Resolve-Path 'artifacts\wheel-light.png'))
|
||||
|
||||
# 3 项配置:扇形中点 -30°/90°/210°,中半径 145
|
||||
$regions = @(
|
||||
@{ Name = '扇形0(右上, 旧布局=对角60°)'; X = 328; Y = 168; W = 132; H = 57 },
|
||||
@{ Name = '扇形1(下方, 旧布局=倒置水平)'; X = 204; Y = 385; W = 128; H = 55 },
|
||||
@{ Name = '扇形2(左侧, 旧布局=对角-60°)'; X = 77; Y = 168; W = 132; H = 57 }
|
||||
)
|
||||
|
||||
function Is-White($px) { return $px.A -gt 200 -and ($px.R + $px.G + $px.B) -gt 700 }
|
||||
|
||||
foreach ($r in $regions) {
|
||||
$x0 = $r.X; $y0 = $r.Y; $w = $r.W; $h = $r.H
|
||||
$white = New-Object 'bool[,]' ($h + 2), ($w + 2)
|
||||
$topN = 0; $midN = 0; $botN = 0
|
||||
for ($y = 0; $y -lt $h; $y++) {
|
||||
for ($x = 0; $x -lt $w; $x++) {
|
||||
$isWhite = Is-White ($bmp.GetPixel($x0 + $x, $y0 + $y))
|
||||
$white[$y, $x] = $isWhite
|
||||
if ($isWhite) {
|
||||
if ($y -lt [int]($h / 3)) { $topN++ }
|
||||
elseif ($y -lt [int](2 * $h / 3)) { $midN++ }
|
||||
else { $botN++ }
|
||||
}
|
||||
}
|
||||
}
|
||||
$hRuns = 0; $vRuns = 0; $d1Runs = 0; $d2Runs = 0
|
||||
for ($y = 0; $y -lt $h; $y++) {
|
||||
$len = 0
|
||||
for ($x = 0; $x -le $w; $x++) {
|
||||
if ($x -lt $w -and $white[$y, $x]) { $len++ }
|
||||
else { if ($len -ge 4) { $hRuns++ }; $len = 0 }
|
||||
}
|
||||
}
|
||||
for ($x = 0; $x -lt $w; $x++) {
|
||||
$len = 0
|
||||
for ($y = 0; $y -le $h; $y++) {
|
||||
if ($y -lt $h -and $white[$y, $x]) { $len++ }
|
||||
else { if ($len -ge 4) { $vRuns++ }; $len = 0 }
|
||||
}
|
||||
}
|
||||
for ($s = 0; $s -lt ($w + $h - 1); $s++) {
|
||||
$len = 0
|
||||
for ($y = 0; $y -lt $h; $y++) {
|
||||
$x = $s - $y
|
||||
if ($x -ge 0 -and $x -lt $w) {
|
||||
if ($white[$y, $x]) { $len++ } else { if ($len -ge 4) { $d1Runs++ }; $len = 0 }
|
||||
}
|
||||
}
|
||||
if ($len -ge 4) { $d1Runs++ }
|
||||
}
|
||||
for ($s = -($h - 1); $s -lt $w; $s++) {
|
||||
$len = 0
|
||||
for ($x = 0; $x -lt $w; $x++) {
|
||||
$y = $x - $s
|
||||
if ($y -ge 0 -and $y -lt $h) {
|
||||
if ($white[$y, $x]) { $len++ } else { if ($len -ge 4) { $d2Runs++ }; $len = 0 }
|
||||
}
|
||||
}
|
||||
if ($len -ge 4) { $d2Runs++ }
|
||||
}
|
||||
Write-Output ("{0}" -f $r.Name)
|
||||
Write-Output (" 笔画段: 水平={0} 垂直={1} 对角45={2} 对角-45={3} (水平文字期望: 水平占优, 对角极少)" -f $hRuns, $vRuns, $d1Runs, $d2Runs)
|
||||
Write-Output (" 白色像素分布 上/中/下 = {0}/{1}/{2} (新布局期望: 上段为图标密集, 下段为文字)" -f $topN, $midN, $botN)
|
||||
}
|
||||
$bmp.Dispose()
|
||||
@@ -0,0 +1,41 @@
|
||||
# 像素级验证轮盘扇形间隙宽度(任意半径处应恒定 ≈ 10px)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
foreach ($name in 'wheel-light', 'wheel-dark') {
|
||||
$path = "artifacts\$name.png"
|
||||
$bmp = [System.Drawing.Bitmap]::FromFile((Resolve-Path $path))
|
||||
$cx = [int]($bmp.Width / 2); $cy = [int]($bmp.Height / 2)
|
||||
Write-Output "=== $name ($($bmp.Width)x$($bmp.Height)) ==="
|
||||
|
||||
foreach ($radius in 100, 145, 200) {
|
||||
$steps = 7200
|
||||
$transparentRuns = New-Object System.Collections.Generic.List[double]
|
||||
$opaqueRuns = New-Object System.Collections.Generic.List[double]
|
||||
$runSteps = 0; $prevTransparent = $false
|
||||
for ($i = 0; $i -le $steps; $i++) {
|
||||
$ang = ($i / $steps) * 2 * [Math]::PI
|
||||
$x = [int][Math]::Round($cx + $radius * [Math]::Cos($ang))
|
||||
$y = [int][Math]::Round($cy + $radius * [Math]::Sin($ang))
|
||||
$px = $bmp.GetPixel($x, $y)
|
||||
$transparent = $px.A -lt 30
|
||||
if ($i -gt 0 -and $transparent -ne $prevTransparent) {
|
||||
$pxArc = $runSteps / $steps * 2 * [Math]::PI * $radius
|
||||
if ($prevTransparent) { $transparentRuns.Add($pxArc) } else { $opaqueRuns.Add($pxArc) }
|
||||
$runSteps = 0
|
||||
}
|
||||
$runSteps++; $prevTransparent = $transparent
|
||||
}
|
||||
$gaps = @($transparentRuns | Where-Object { $_ -gt 1 -and $_ -lt 40 })
|
||||
$sectors = @($opaqueRuns | Where-Object { $_ -gt 40 })
|
||||
$gapAvg = -1; $gapMin = -1; $gapMax = -1
|
||||
if ($gaps.Count -gt 0) {
|
||||
$gapAvg = ($gaps | Measure-Object -Average).Average
|
||||
$gapMin = ($gaps | Measure-Object -Minimum).Minimum
|
||||
$gapMax = ($gaps | Measure-Object -Maximum).Maximum
|
||||
}
|
||||
$line = '半径 {0}: 扇形段数={1}, 间隙段数={2}, 间隙宽度 均值={3:F2}px 最小={4:F2}px 最大={5:F2}px (期望≈10px 且处处相等)' -f $radius, $sectors.Count, $gaps.Count, $gapAvg, $gapMin, $gapMax
|
||||
Write-Output $line
|
||||
}
|
||||
$bmp.Dispose()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# 单实例验证:第二个实例应在 2 秒内自动退出
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$exe = 'E:\002_Demo\One_click_run_wpf\src\OneClickRun\bin\Debug\net8.0-windows\OneClickRun.exe'
|
||||
Get-Process -Name 'OneClickRun' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -eq $exe } |
|
||||
ForEach-Object { Stop-Process -Id $_.Id -Force }
|
||||
|
||||
$first = Start-Process -FilePath $exe -PassThru
|
||||
Start-Sleep -Seconds 3
|
||||
$second = Start-Process -FilePath $exe -PassThru
|
||||
Start-Sleep -Seconds 2
|
||||
$second.Refresh()
|
||||
Write-Output "FirstAlive: $(-not $first.HasExited)"
|
||||
Write-Output "SecondExited: $($second.HasExited)"
|
||||
if (-not $second.HasExited) { Stop-Process -Id $second.Id -Force }
|
||||
Stop-Process -Id $first.Id -Force
|
||||
Write-Output 'Done'
|
||||
@@ -0,0 +1,46 @@
|
||||
# 一键运行 - 启动冒烟验证脚本(PowerShell 7.6.5)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$exe = 'E:\002_Demo\One_click_run_wpf\src\OneClickRun\bin\Debug\net8.0-windows\OneClickRun.exe'
|
||||
if (-not (Test-Path $exe)) { throw "未找到构建产物: $exe" }
|
||||
|
||||
# 清理上次冒烟残留(仅本路径实例)
|
||||
Get-Process -Name 'OneClickRun' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -eq $exe } |
|
||||
ForEach-Object { Stop-Process -Id $_.Id -Force }
|
||||
|
||||
$logFile = Join-Path $env:APPDATA 'OneClickRun\logs\app-{0}.log' -f (Get-Date -Format 'yyyyMMdd')
|
||||
$logBefore = 0
|
||||
if (Test-Path $logFile) { $logBefore = (Get-Item $logFile).Length }
|
||||
|
||||
$p = Start-Process -FilePath $exe -PassThru
|
||||
Start-Sleep -Seconds 6
|
||||
$p.Refresh()
|
||||
$alive = -not $p.HasExited
|
||||
Write-Output "ProcessAlive: $alive"
|
||||
|
||||
$cfg = Join-Path $env:APPDATA 'OneClickRun\config.json'
|
||||
Write-Output "ConfigExists: $(Test-Path $cfg)"
|
||||
if (Test-Path $cfg) {
|
||||
$json = Get-Content $cfg -Raw | ConvertFrom-Json
|
||||
Write-Output "ConfigItems: $($json.items.Count)"
|
||||
Write-Output "HotkeyText: $($json.general.hotkey.modifiers -join '+')+$($json.general.hotkey.key)"
|
||||
Write-Output "TriggerMode: $($json.general.triggerMode)"
|
||||
Write-Output "SelectionMode: $($json.general.selectionMode)"
|
||||
Write-Output "Theme: $($json.general.theme)"
|
||||
}
|
||||
|
||||
Write-Output '--- 本次启动新增日志 ---'
|
||||
if (Test-Path $logFile) {
|
||||
$stream = [System.IO.File]::Open($logFile, 'Open', 'Read', 'ReadWrite')
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
$reader.BaseStream.Seek($logBefore, 'Begin') | Out-Null
|
||||
$delta = $reader.ReadToEnd()
|
||||
$reader.Close(); $stream.Close()
|
||||
Write-Output $delta
|
||||
}
|
||||
|
||||
if ($alive) {
|
||||
Stop-Process -Id $p.Id -Force
|
||||
Write-Output 'ProcessStopped: true'
|
||||
}
|
||||
Write-Output 'SmokeDone'
|
||||
@@ -0,0 +1,18 @@
|
||||
<Application x:Class="OneClickRun.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:OneClickRun.Converters"
|
||||
ShutdownMode="OnExplicitShutdown">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Themes/Light.xaml"/>
|
||||
<ResourceDictionary Source="Themes/Styles.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<converters:EnumToDisplayConverter x:Key="EnumDisplay"/>
|
||||
<converters:EnumEqualsConverter x:Key="EnumEquals"/>
|
||||
<converters:EnumToBadgeBrushConverter x:Key="EnumBadge"/>
|
||||
<converters:CountToVisibilityConverter x:Key="CountToVisibility"/>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using WinForms = System.Windows.Forms;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Services;
|
||||
using OneClickRun.Views;
|
||||
|
||||
namespace OneClickRun;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private const string SingleInstanceMutexName = @"GlobalOneClickRun_SingleInstance";
|
||||
private const string ShowSettingsMessageName = "OneClickRun.ShowSettings";
|
||||
|
||||
private Mutex? _mutex;
|
||||
private WinForms.NotifyIcon? _tray;
|
||||
private HotkeyHostWindow? _hotkeyHost;
|
||||
private MainWindow? _main;
|
||||
private bool _exitRequested;
|
||||
private string[] _startupArgs = Array.Empty<string>();
|
||||
private bool _snapshotMode;
|
||||
|
||||
public ConfigService Config { get; } = new();
|
||||
public HotkeyService Hotkeys { get; } = new();
|
||||
public WheelWindowManager? Wheel { get; private set; }
|
||||
public ThemeService Themes { get; } = new();
|
||||
public AutostartService Autostart { get; } = new();
|
||||
public CommandRunner Runner { get; } = new();
|
||||
|
||||
public bool IsExitRequested => _exitRequested;
|
||||
public static uint ShowSettingsMessage { get; private set; }
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
_startupArgs = e.Args;
|
||||
Logger.Init();
|
||||
Logger.Info($"一键运行启动 v{Assembly.GetExecutingAssembly().GetName().Version}");
|
||||
|
||||
_mutex = new Mutex(true, SingleInstanceMutexName, out bool createdNew);
|
||||
if (!createdNew)
|
||||
{
|
||||
Logger.Info("检测到已有实例运行,唤起其设置窗口后退出");
|
||||
try
|
||||
{
|
||||
var msg = Win32Interop.RegisterWindowMessage(ShowSettingsMessageName);
|
||||
Win32Interop.PostMessage(Win32Interop.HWND_BROADCAST, msg, IntPtr.Zero, IntPtr.Zero);
|
||||
}
|
||||
catch { /* 忽略 */ }
|
||||
Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
DispatcherUnhandledException += (_, args) =>
|
||||
{
|
||||
Logger.Error("未处理异常", args.Exception);
|
||||
ToastService.Show("发生错误:" + args.Exception.Message);
|
||||
args.Handled = true;
|
||||
};
|
||||
|
||||
ShowSettingsMessage = Win32Interop.RegisterWindowMessage(ShowSettingsMessageName);
|
||||
Config.Load();
|
||||
Themes.Apply(Config.Current.General.Theme);
|
||||
Wheel = new WheelWindowManager(Runner, () => Config.Current);
|
||||
|
||||
// 不可见宿主窗口:为全局热键与广播消息提供稳定 HWND
|
||||
_hotkeyHost = new HotkeyHostWindow();
|
||||
_hotkeyHost.Show();
|
||||
Hotkeys.Attach(_hotkeyHost);
|
||||
Hotkeys.HotkeyPressed += () => Wheel?.OnHotkeyPressed();
|
||||
Hotkeys.RegistrationFailed += () =>
|
||||
{
|
||||
ToastService.Show("全局快捷键注册失败,可能与其他程序冲突,请在设置中更换快捷键");
|
||||
_main?.NotifyHotkeyStatus();
|
||||
};
|
||||
|
||||
_main = new MainWindow();
|
||||
_main.HookShowSettingsMessage(ShowSettingsMessage);
|
||||
ApplyAll();
|
||||
BuildTray();
|
||||
|
||||
// 内部调试:--snapshot <路径> [--dark] [--hover N] 导出轮盘 PNG 后退出,不保存配置
|
||||
var snapshotPath = GetArgValue("--snapshot");
|
||||
if (snapshotPath != null)
|
||||
{
|
||||
RunSnapshotMode(snapshotPath, HasArg("--dark"), GetArgInt("--hover"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Config.WasFirstRun)
|
||||
{
|
||||
Logger.Info("首次运行,显示设置窗口");
|
||||
ShowMainWindow();
|
||||
}
|
||||
Logger.Info("启动完成");
|
||||
}
|
||||
|
||||
/// <summary>将当前配置应用到运行中的各服务(热键、主题、轮盘开关、开机启动)</summary>
|
||||
public void ApplyAll()
|
||||
{
|
||||
Hotkeys.Update(Config.Current.General.Hotkey);
|
||||
Themes.Apply(Config.Current.General.Theme);
|
||||
if (Wheel != null) Wheel.Enabled = Config.Current.General.WheelEnabled;
|
||||
Autostart.SetEnabled(Config.Current.General.AutoStart);
|
||||
}
|
||||
|
||||
public void ShowMainWindow() => _main?.ShowSettingsAndActivate();
|
||||
|
||||
public void RequestExit()
|
||||
{
|
||||
_exitRequested = true;
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
private bool HasArg(string name) => _startupArgs.Contains(name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private int? GetArgInt(string name)
|
||||
{
|
||||
var value = GetArgValue(name);
|
||||
return value != null && int.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private string? GetArgValue(string name)
|
||||
{
|
||||
for (var i = 0; i < _startupArgs.Length - 1; i++)
|
||||
if (string.Equals(_startupArgs[i], name, StringComparison.OrdinalIgnoreCase))
|
||||
return _startupArgs[i + 1];
|
||||
return null;
|
||||
}
|
||||
|
||||
private void RunSnapshotMode(string path, bool dark, int? hoverIndex)
|
||||
{
|
||||
_snapshotMode = true;
|
||||
if (dark) Themes.Apply(Models.ThemeMode.Dark);
|
||||
Wheel!.Show();
|
||||
Dispatcher.BeginInvoke(async () =>
|
||||
{
|
||||
await Task.Delay(600);
|
||||
if (hoverIndex is int index)
|
||||
Wheel.Window.SimulateHover(index);
|
||||
await Task.Delay(600);
|
||||
try { SnapshotWheel(path); }
|
||||
catch (Exception ex) { Logger.Error("导出轮盘快照失败", ex); }
|
||||
Shutdown();
|
||||
}, DispatcherPriority.ApplicationIdle);
|
||||
}
|
||||
|
||||
private void SnapshotWheel(string path)
|
||||
{
|
||||
var window = Wheel!.Window;
|
||||
var canvas = window.Root;
|
||||
canvas.Measure(new Size(window.Width, window.Height));
|
||||
canvas.Arrange(new Rect(0, 0, window.Width, window.Height));
|
||||
canvas.UpdateLayout();
|
||||
var bitmap = new RenderTargetBitmap(
|
||||
(int)Math.Ceiling(window.Width), (int)Math.Ceiling(window.Height), 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(canvas);
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
var full = Path.GetFullPath(path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full) ?? ".");
|
||||
using var stream = File.Create(full);
|
||||
encoder.Save(stream);
|
||||
Logger.Info($"轮盘快照已保存: {full}");
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
Logger.Info("退出");
|
||||
if (!_snapshotMode) { try { Config.Save(); } catch { } }
|
||||
try { Hotkeys.Dispose(); } catch { }
|
||||
try { Wheel?.Dispose(); } catch { }
|
||||
try { _tray?.Dispose(); } catch { }
|
||||
try { _mutex?.ReleaseMutex(); } catch { }
|
||||
base.OnExit(e);
|
||||
}
|
||||
|
||||
private void BuildTray()
|
||||
{
|
||||
_tray = new WinForms.NotifyIcon
|
||||
{
|
||||
Icon = AppIcon.GetTrayIcon(),
|
||||
Text = "一键运行",
|
||||
Visible = true,
|
||||
};
|
||||
|
||||
var menu = new WinForms.ContextMenuStrip();
|
||||
menu.Items.Add("打开设置", null, (_, _) => ShowMainWindow());
|
||||
menu.Items.Add("显示轮盘", null, (_, _) => Wheel?.Show());
|
||||
|
||||
var autoStartItem = new WinForms.ToolStripMenuItem("开机启动")
|
||||
{
|
||||
Checked = Config.Current.General.AutoStart,
|
||||
};
|
||||
autoStartItem.Click += (_, _) =>
|
||||
{
|
||||
autoStartItem.Checked = !autoStartItem.Checked;
|
||||
Config.Current.General.AutoStart = autoStartItem.Checked;
|
||||
Config.Save();
|
||||
Autostart.SetEnabled(autoStartItem.Checked);
|
||||
};
|
||||
menu.Items.Add(autoStartItem);
|
||||
menu.Items.Add(new WinForms.ToolStripSeparator());
|
||||
menu.Items.Add("退出", null, (_, _) => RequestExit());
|
||||
|
||||
_tray.ContextMenuStrip = menu;
|
||||
_tray.DoubleClick += (_, _) => ShowMainWindow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using OneClickRun.Helpers;
|
||||
|
||||
namespace OneClickRun.Controls;
|
||||
|
||||
/// <summary>快捷键录制控件:点击后按下组合键完成录制</summary>
|
||||
public class HotkeyRecorder : UserControl
|
||||
{
|
||||
public static readonly DependencyProperty HotkeyTextProperty = DependencyProperty.Register(
|
||||
nameof(HotkeyText), typeof(string), typeof(HotkeyRecorder),
|
||||
new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnVisualChanged));
|
||||
|
||||
public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register(
|
||||
nameof(HasConflict), typeof(bool), typeof(HotkeyRecorder),
|
||||
new PropertyMetadata(false, OnVisualChanged));
|
||||
|
||||
private readonly Border _border;
|
||||
private readonly TextBlock _text;
|
||||
|
||||
public HotkeyRecorder()
|
||||
{
|
||||
_text = new TextBlock
|
||||
{
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
};
|
||||
_border = new Border
|
||||
{
|
||||
Child = _text,
|
||||
CornerRadius = new CornerRadius(8),
|
||||
BorderThickness = new Thickness(1),
|
||||
Padding = new Thickness(12, 0, 12, 0),
|
||||
Height = 34,
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
_border.SetResourceReference(Border.BackgroundProperty, "InputBackgroundBrush");
|
||||
_border.SetResourceReference(Border.BorderBrushProperty, "BorderBrush");
|
||||
_text.SetResourceReference(TextBlock.ForegroundProperty, "TextPrimaryBrush");
|
||||
|
||||
Content = _border;
|
||||
Focusable = true;
|
||||
PreviewKeyDown += OnPreviewKeyDown;
|
||||
UpdateVisuals();
|
||||
}
|
||||
|
||||
public string HotkeyText
|
||||
{
|
||||
get => (string)GetValue(HotkeyTextProperty);
|
||||
set => SetValue(HotkeyTextProperty, value);
|
||||
}
|
||||
|
||||
public bool HasConflict
|
||||
{
|
||||
get => (bool)GetValue(HasConflictProperty);
|
||||
set => SetValue(HasConflictProperty, value);
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
var key = e.Key == Key.System ? e.SystemKey : e.Key;
|
||||
// 忽略纯修饰键,等待主键
|
||||
if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftShift or Key.RightShift
|
||||
or Key.LeftAlt or Key.RightAlt or Key.LWin or Key.RWin)
|
||||
return;
|
||||
HotkeyText = HotkeyFormat.Format(Keyboard.Modifiers, key);
|
||||
Keyboard.ClearFocus();
|
||||
}
|
||||
|
||||
private static void OnVisualChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) =>
|
||||
((HotkeyRecorder)d).UpdateVisuals();
|
||||
|
||||
private void UpdateVisuals()
|
||||
{
|
||||
_text.Text = string.IsNullOrWhiteSpace(HotkeyText) ? "点击录制快捷键" : HotkeyText;
|
||||
_text.FontSize = string.IsNullOrWhiteSpace(HotkeyText) ? 12 : 13;
|
||||
if (HasConflict)
|
||||
_border.SetResourceReference(Border.BorderBrushProperty, "DangerBrush");
|
||||
else
|
||||
_border.SetResourceReference(Border.BorderBrushProperty, "BorderBrush");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<Window x:Class="OneClickRun.Controls.WheelItemEditor"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="编辑指令"
|
||||
Width="540" SizeToContent="Height" ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Background="{DynamicResource WindowBackgroundBrush}">
|
||||
<Grid Margin="22">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="90"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="名称" VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}" Margin="0,0,0,12"/>
|
||||
<TextBox x:Name="NameBox" Grid.Row="0" Grid.Column="1" Margin="0,0,0,12" MaxLength="30"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="类型" VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}" Margin="0,0,0,12"/>
|
||||
<ComboBox x:Name="TypeBox" Grid.Row="1" Grid.Column="1" Margin="0,0,0,12" Height="32">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource EnumDisplay}}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="路径 / 网址" VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}" Margin="0,0,0,12"/>
|
||||
<Grid Grid.Row="2" Grid.Column="1" Margin="0,0,0,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="PathBox"/>
|
||||
<Button x:Name="BrowseButton" Grid.Column="1" Content="浏览…" Style="{StaticResource SecondaryButton}" Margin="10,0,0,0" Click="Browse_Click"/>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="参数(可选)" VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}" Margin="0,0,0,12"/>
|
||||
<TextBox x:Name="ArgsBox" Grid.Row="3" Grid.Column="1" Margin="0,0,0,12"/>
|
||||
|
||||
<TextBlock x:Name="HintText" Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="2" Style="{StaticResource BodyText}" Margin="0,0,0,16"/>
|
||||
|
||||
<StackPanel Grid.Row="5" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="取消" Style="{StaticResource SecondaryButton}" MinWidth="80" IsCancel="True"/>
|
||||
<Button Content="保存" Style="{StaticResource PrimaryButton}" MinWidth="80" Margin="10,0,0,0" IsDefault="True" Click="Save_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Microsoft.Win32;
|
||||
using OneClickRun.Models;
|
||||
using OneClickRun.Services;
|
||||
|
||||
namespace OneClickRun.Controls;
|
||||
|
||||
/// <summary>轮盘指令编辑对话框</summary>
|
||||
public partial class WheelItemEditor : Window
|
||||
{
|
||||
public WheelItem? Result { get; private set; }
|
||||
|
||||
public WheelItemEditor(WheelItem? existing = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
TypeBox.ItemsSource = Enum.GetValues<WheelItemType>();
|
||||
TypeBox.SelectionChanged += (_, _) => UpdateHint();
|
||||
if (existing != null)
|
||||
{
|
||||
NameBox.Text = existing.Name;
|
||||
TypeBox.SelectedItem = existing.Type;
|
||||
PathBox.Text = existing.Path;
|
||||
ArgsBox.Text = existing.Args;
|
||||
}
|
||||
else
|
||||
{
|
||||
TypeBox.SelectedIndex = 0;
|
||||
}
|
||||
UpdateHint();
|
||||
}
|
||||
|
||||
private void Browse_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
switch ((WheelItemType)(TypeBox.SelectedItem ?? WheelItemType.App))
|
||||
{
|
||||
case WheelItemType.App:
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "选择程序",
|
||||
Filter = "程序 (*.exe;*.lnk;*.bat;*.cmd)|*.exe;*.lnk;*.bat;*.cmd|所有文件 (*.*)|*.*",
|
||||
};
|
||||
if (dialog.ShowDialog(this) == true) SetPath(dialog.FileName);
|
||||
break;
|
||||
}
|
||||
case WheelItemType.Folder:
|
||||
{
|
||||
var dialog = new OpenFolderDialog { Title = "选择文件夹" };
|
||||
if (dialog.ShowDialog(this) == true) SetPath(dialog.FolderName);
|
||||
break;
|
||||
}
|
||||
case WheelItemType.Script:
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "选择 PowerShell 脚本",
|
||||
Filter = "PowerShell 脚本 (*.ps1)|*.ps1|所有文件 (*.*)|*.*",
|
||||
};
|
||||
if (dialog.ShowDialog(this) == true) SetPath(dialog.FileName);
|
||||
break;
|
||||
}
|
||||
case WheelItemType.Url:
|
||||
// 网址无浏览,直接输入
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetPath(string path)
|
||||
{
|
||||
PathBox.Text = path;
|
||||
if (string.IsNullOrWhiteSpace(NameBox.Text) && path.IndexOfAny(Path.GetInvalidPathChars()) < 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
if (!string.IsNullOrWhiteSpace(name)) NameBox.Text = name;
|
||||
}
|
||||
catch { /* 忽略命名推断失败 */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void Save_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var name = NameBox.Text.Trim();
|
||||
var path = PathBox.Text.Trim();
|
||||
var type = (WheelItemType)(TypeBox.SelectedItem ?? WheelItemType.App);
|
||||
|
||||
if (name.Length == 0)
|
||||
{
|
||||
MessageBox.Show(this, "请输入指令名称。", "一键运行", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
if (path.Length == 0)
|
||||
{
|
||||
MessageBox.Show(this, type == WheelItemType.Url ? "请输入网址。" : "请选择路径。", "一键运行",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
if (type == WheelItemType.Url)
|
||||
path = CommandRunner.NormalizeUrl(path);
|
||||
|
||||
Result = new WheelItem
|
||||
{
|
||||
Name = name,
|
||||
Type = type,
|
||||
Path = path,
|
||||
Args = ArgsBox.Text.Trim(),
|
||||
};
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void UpdateHint()
|
||||
{
|
||||
var type = (WheelItemType)(TypeBox.SelectedItem ?? WheelItemType.App);
|
||||
HintText.Text = type switch
|
||||
{
|
||||
WheelItemType.App => "提示:支持 .exe、快捷方式(.lnk)、批处理(.bat/.cmd)等,可用“参数”追加启动参数。",
|
||||
WheelItemType.Folder => "提示:选择后将在资源管理器中打开该文件夹。",
|
||||
WheelItemType.Url => "提示:网址将用默认浏览器打开,无协议前缀时自动补全 https://。",
|
||||
WheelItemType.Script => "提示:脚本由 PowerShell 7(pwsh.exe,建议 7.6.5)执行,可在“参数”中追加脚本参数。",
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Converters;
|
||||
|
||||
/// <summary>枚举 → 中文显示名</summary>
|
||||
public class EnumToDisplayConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value switch
|
||||
{
|
||||
WheelItemType.App => "打开软件",
|
||||
WheelItemType.Folder => "打开文件夹",
|
||||
WheelItemType.Url => "打开网址",
|
||||
WheelItemType.Script => "运行脚本",
|
||||
TriggerMode.Click => "点击显示",
|
||||
TriggerMode.Hold => "按住显示",
|
||||
TriggerMode.LongPress => "长按显示",
|
||||
SelectionMode.SingleClick => "单击选择",
|
||||
SelectionMode.DoubleClick => "双击选择",
|
||||
SelectionMode.Swipe => "滑动选择",
|
||||
ThemeMode.System => "跟随系统",
|
||||
ThemeMode.Light => "日间模式",
|
||||
ThemeMode.Dark => "黑夜模式",
|
||||
WheelPosition.ScreenCenter => "屏幕中心",
|
||||
WheelPosition.Mouse => "鼠标位置",
|
||||
_ => value?.ToString() ?? string.Empty,
|
||||
};
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
Binding.DoNothing;
|
||||
}
|
||||
|
||||
/// <summary>枚举值 == 参数 时返回 true(用于单选按钮与控件可用性绑定)</summary>
|
||||
public class EnumEqualsConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
string.Equals(value?.ToString(), parameter?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is true && targetType.IsEnum && parameter is string name &&
|
||||
Enum.TryParse(targetType, name, true, out var result))
|
||||
return result;
|
||||
return Binding.DoNothing;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>指令类型 → 徽标颜色</summary>
|
||||
public class EnumToBadgeBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var color = value switch
|
||||
{
|
||||
WheelItemType.App => "#4F6BFF",
|
||||
WheelItemType.Folder => "#2FAE9C",
|
||||
WheelItemType.Url => "#E8932F",
|
||||
WheelItemType.Script => "#9A5BE8",
|
||||
_ => "#6B7280",
|
||||
};
|
||||
var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color));
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
Binding.DoNothing;
|
||||
}
|
||||
|
||||
/// <summary>空集合时显示占位文本</summary>
|
||||
public class CountToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var count = value is int i ? i : 0;
|
||||
var visible = count == 0;
|
||||
if (parameter is string p && p == "Invert") visible = !visible;
|
||||
return visible ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
Binding.DoNothing;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Drawing = System.Drawing;
|
||||
using Drawing2D = System.Drawing.Drawing2D;
|
||||
using SysIcon = System.Drawing.Icon;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>运行时绘制应用图标(托盘 + 窗口)</summary>
|
||||
public static class AppIcon
|
||||
{
|
||||
private static SysIcon? _trayIcon;
|
||||
|
||||
public static SysIcon GetTrayIcon() => _trayIcon ??= Create();
|
||||
|
||||
public static ImageSource GetWindowIcon()
|
||||
{
|
||||
var icon = GetTrayIcon();
|
||||
return Imaging.CreateBitmapSourceFromHIcon(icon.Handle, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
|
||||
}
|
||||
|
||||
private static SysIcon Create()
|
||||
{
|
||||
const int size = 32;
|
||||
using var bitmap = new Drawing.Bitmap(size, size);
|
||||
using (var g = Drawing.Graphics.FromImage(bitmap))
|
||||
{
|
||||
g.SmoothingMode = Drawing2D.SmoothingMode.AntiAlias;
|
||||
var rect = new Drawing.Rectangle(1, 1, size - 2, size - 2);
|
||||
using var background = new Drawing2D.LinearGradientBrush(
|
||||
rect, Drawing.Color.FromArgb(79, 107, 255), Drawing.Color.FromArgb(122, 92, 255), 45f);
|
||||
g.FillEllipse(background, rect);
|
||||
using var pen = new Drawing.Pen(Drawing.Color.White, 3f)
|
||||
{
|
||||
StartCap = Drawing2D.LineCap.Round,
|
||||
EndCap = Drawing2D.LineCap.Round,
|
||||
};
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var start = i * 45f + 8f;
|
||||
var sweep = 45f - 16f;
|
||||
g.DrawArc(pen, 7, 7, size - 14, size - 14, start, sweep);
|
||||
}
|
||||
using var dot = new Drawing.SolidBrush(Drawing.Color.White);
|
||||
g.FillEllipse(dot, size / 2f - 3f, size / 2f - 3f, 6, 6);
|
||||
}
|
||||
var hicon = bitmap.GetHicon();
|
||||
try
|
||||
{
|
||||
return (SysIcon)SysIcon.FromHandle(hicon).Clone();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DestroyIcon(hicon);
|
||||
}
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
private static extern bool DestroyIcon(IntPtr hIcon);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Windows;
|
||||
using OneClickRun.Services;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>多显示器 DPI 换算辅助(PerMonitorV2 下物理像素 ↔ WPF 设备无关像素)</summary>
|
||||
public static class DpiUtil
|
||||
{
|
||||
public static double GetScale(Point screenPx)
|
||||
{
|
||||
try
|
||||
{
|
||||
var monitor = Win32Interop.MonitorFromPoint(
|
||||
new Win32Interop.POINT((int)Math.Round(screenPx.X), (int)Math.Round(screenPx.Y)),
|
||||
Win32Interop.MONITOR_DEFAULTTONEAREST);
|
||||
if (monitor != IntPtr.Zero &&
|
||||
Win32Interop.GetDpiForMonitor(monitor, Win32Interop.MDT_EFFECTIVE_DPI, out uint dpiX, out _) == 0 &&
|
||||
dpiX > 0)
|
||||
return dpiX / 96.0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"获取显示器 DPI 失败: {ex.Message}");
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
public static Point PxToDip(Point px)
|
||||
{
|
||||
var scale = GetScale(px);
|
||||
return new Point(px.X / scale, px.Y / scale);
|
||||
}
|
||||
|
||||
public static Point GetCursorPosDip() =>
|
||||
Win32Interop.TryGetCursorPos(out var px) ? PxToDip(px) : default;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using OneClickRun.Services;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// 全局低级鼠标钩子(WH_MOUSE_LL),仅在轮盘显示期间启用,
|
||||
/// 用于检测"点击轮盘外区域"以隐藏轮盘。不吞掉任何鼠标事件。
|
||||
/// </summary>
|
||||
public sealed class GlobalMouseHook : IDisposable
|
||||
{
|
||||
private static Win32Interop.LowLevelMouseProc? _callback;
|
||||
private static IntPtr _hook;
|
||||
private static GlobalMouseHook? _active;
|
||||
|
||||
public event Action<System.Windows.Point>? LeftButtonDown;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_hook != IntPtr.Zero) return;
|
||||
_active = this;
|
||||
_callback = HookCallback;
|
||||
_hook = Win32Interop.SetWindowsHookEx(
|
||||
Win32Interop.WH_MOUSE_LL, _callback, Win32Interop.GetModuleHandle(null), 0);
|
||||
if (_hook == IntPtr.Zero)
|
||||
Logger.Warn("全局鼠标钩子安装失败,点击轮盘外区域将无法自动隐藏");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_hook == IntPtr.Zero) return;
|
||||
Win32Interop.UnhookWindowsHookEx(_hook);
|
||||
_hook = IntPtr.Zero;
|
||||
_callback = null;
|
||||
_active = null;
|
||||
}
|
||||
|
||||
private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (nCode >= 0 && wParam.ToInt64() == Win32Interop.WM_LBUTTONDOWN)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = Marshal.PtrToStructure<Win32Interop.MSLLHOOKSTRUCT>(lParam);
|
||||
_active?.LeftButtonDown?.Invoke(new System.Windows.Point(data.pt.X, data.pt.Y));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 钩子回调内不允许抛异常
|
||||
}
|
||||
}
|
||||
return Win32Interop.CallNextHookEx(_hook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Windows.Input;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>快捷键与文本(如 "Ctrl+Alt+Space")之间的转换</summary>
|
||||
public static class HotkeyFormat
|
||||
{
|
||||
public static string Format(HotkeyBinding? binding)
|
||||
{
|
||||
if (binding == null || !binding.IsValid) return string.Empty;
|
||||
var parts = new List<string>();
|
||||
foreach (var m in new[] { "Ctrl", "Alt", "Shift", "Win" })
|
||||
if (binding.Modifiers.Contains(m)) parts.Add(m);
|
||||
parts.Add(binding.Key);
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
|
||||
public static string Format(ModifierKeys mods, Key key)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (mods.HasFlag(ModifierKeys.Control)) parts.Add("Ctrl");
|
||||
if (mods.HasFlag(ModifierKeys.Alt)) parts.Add("Alt");
|
||||
if (mods.HasFlag(ModifierKeys.Shift)) parts.Add("Shift");
|
||||
if (mods.HasFlag(ModifierKeys.Windows)) parts.Add("Win");
|
||||
parts.Add(key.ToString());
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
|
||||
/// <summary>解析快捷键文本;空文本返回空绑定;非法文本返回 null</summary>
|
||||
public static HotkeyBinding? TryParse(string? text)
|
||||
{
|
||||
text = text?.Trim();
|
||||
if (string.IsNullOrEmpty(text)) return new HotkeyBinding();
|
||||
var tokens = text.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length == 0) return null;
|
||||
var keyToken = tokens[^1];
|
||||
if (!Enum.TryParse<Key>(keyToken, true, out var key) || key == Key.None) return null;
|
||||
var mods = new List<string>();
|
||||
foreach (var t in tokens[..^1])
|
||||
{
|
||||
switch (t.ToLowerInvariant())
|
||||
{
|
||||
case "ctrl": case "control": mods.Add("Ctrl"); break;
|
||||
case "alt": mods.Add("Alt"); break;
|
||||
case "shift": mods.Add("Shift"); break;
|
||||
case "win": case "windows": mods.Add("Win"); break;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
return new HotkeyBinding { Modifiers = mods.Distinct().ToList(), Key = key.ToString() };
|
||||
}
|
||||
|
||||
public static Key ParseKey(string keyName) =>
|
||||
Enum.TryParse<Key>(keyName, true, out var key) ? key : Key.None;
|
||||
|
||||
public static int VirtualKeyOf(string keyName) => KeyInterop.VirtualKeyFromKey(ParseKey(keyName));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// 轮盘扇形几何。N 个扇形等角分布;为保证"不论距离中心多远,扇形之间的间距都相等",
|
||||
/// 每个扇形的两条径向边沿垂直方向向内偏移 gap/2:边界射线 θ 在半径 ρ 处的偏移角为
|
||||
/// ε(ρ) = asin(gap / (2ρ)),因此相邻扇形的相邻边是两条相距恒为 gap 的平行线。
|
||||
/// </summary>
|
||||
public static class WheelGeometry
|
||||
{
|
||||
public const double TwoPi = Math.PI * 2;
|
||||
/// <summary>起始角度:12 点钟方向(屏幕坐标 y 向下,角度沿顺时针为正)</summary>
|
||||
public const double StartAngle = -Math.PI / 2;
|
||||
|
||||
public static Point PointOnCircle(Point center, double radius, double angleRad) =>
|
||||
new(center.X + radius * Math.Cos(angleRad), center.Y + radius * Math.Sin(angleRad));
|
||||
|
||||
/// <summary>半径 radius 处,间隙 gap 对应的边界偏移角</summary>
|
||||
public static double EdgeOffset(double radius, double gap)
|
||||
{
|
||||
if (radius <= 0 || gap <= 0) return 0;
|
||||
var v = Math.Clamp(gap / (2 * radius), -1.0, 1.0);
|
||||
return Math.Asin(v);
|
||||
}
|
||||
|
||||
public static double SectorStartAngle(int index, int count) => StartAngle + index * TwoPi / count;
|
||||
|
||||
/// <summary>扇形四个角点:内左、外左、外右、内右(逆时针定义,屏幕坐标)</summary>
|
||||
public static (Point innerLeft, Point outerLeft, Point outerRight, Point innerRight) SectorCorners(
|
||||
Point center, double innerR, double outerR, double gap, int index, int count)
|
||||
{
|
||||
var a = SectorStartAngle(index, count);
|
||||
var b = a + TwoPi / count;
|
||||
var eo = EdgeOffset(outerR, gap);
|
||||
var ei = EdgeOffset(innerR, gap);
|
||||
return (
|
||||
PointOnCircle(center, innerR, a + ei),
|
||||
PointOnCircle(center, outerR, a + eo),
|
||||
PointOnCircle(center, outerR, b - eo),
|
||||
PointOnCircle(center, innerR, b - ei));
|
||||
}
|
||||
|
||||
/// <summary>生成单个扇形的 Path 几何(count==1 时为完整圆环)</summary>
|
||||
public static Geometry CreateSectorGeometry(Point center, double innerR, double outerR, double gap, int index, int count)
|
||||
{
|
||||
if (count <= 1)
|
||||
{
|
||||
var outer = new EllipseGeometry(center, outerR, outerR);
|
||||
var inner = new EllipseGeometry(center, innerR, innerR);
|
||||
var combined = new CombinedGeometry(GeometryCombineMode.Exclude, outer, inner);
|
||||
combined.Freeze();
|
||||
return combined;
|
||||
}
|
||||
var c = SectorCorners(center, innerR, outerR, gap, index, count);
|
||||
var geometry = new StreamGeometry();
|
||||
using (var ctx = geometry.Open())
|
||||
{
|
||||
ctx.BeginFigure(c.innerLeft, isFilled: true, isClosed: true);
|
||||
ctx.LineTo(c.outerLeft, true, false);
|
||||
ctx.ArcTo(c.outerRight, new Size(outerR, outerR), 0, false, SweepDirection.Clockwise, true, false);
|
||||
ctx.LineTo(c.innerRight, true, false);
|
||||
ctx.ArcTo(c.innerLeft, new Size(innerR, innerR), 0, false, SweepDirection.Counterclockwise, true, false);
|
||||
}
|
||||
geometry.Freeze();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/// <summary>扇形文本/图标放置点(半径中点、角度平分线)</summary>
|
||||
public static Point SectorMidPoint(Point center, double innerR, double outerR, int index, int count)
|
||||
{
|
||||
var mid = SectorStartAngle(index, count) + TwoPi / count / 2;
|
||||
return PointOnCircle(center, (innerR + outerR) / 2, mid);
|
||||
}
|
||||
|
||||
/// <summary>扇形中轴角度(度,屏幕坐标,用于文本旋转)</summary>
|
||||
public static double SectorMidAngleDeg(int index, int count) =>
|
||||
(SectorStartAngle(index, count) + TwoPi / count / 2) * 180 / Math.PI;
|
||||
|
||||
/// <summary>
|
||||
/// 命中测试:返回点所在扇形索引;中心区域、圆环外或间隙处返回 null。
|
||||
/// </summary>
|
||||
public static int? HitTest(Point point, Point center, double innerR, double outerR, double gap, int count)
|
||||
{
|
||||
var dx = point.X - center.X;
|
||||
var dy = point.Y - center.Y;
|
||||
var rho = Math.Sqrt(dx * dx + dy * dy);
|
||||
if (rho < innerR || rho > outerR) 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);
|
||||
if (index >= count) index = count - 1;
|
||||
|
||||
var eps = EdgeOffset(rho, gap);
|
||||
var fromLeft = rel - index * span;
|
||||
var fromRight = (index + 1) * span - rel;
|
||||
if (fromLeft < eps || fromRight < eps) return null; // 落在间隙
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>Win32 P/Invoke 互操作集合</summary>
|
||||
public static class Win32Interop
|
||||
{
|
||||
public const int WM_HOTKEY = 0x0312;
|
||||
public const int WM_LBUTTONDOWN = 0x0201;
|
||||
public const int WH_MOUSE_LL = 14;
|
||||
|
||||
public const uint MOD_ALT = 0x0001;
|
||||
public const uint MOD_CONTROL = 0x0002;
|
||||
public const uint MOD_SHIFT = 0x0004;
|
||||
public const uint MOD_WIN = 0x0008;
|
||||
public const uint MOD_NOREPEAT = 0x4000;
|
||||
|
||||
public const int VK_ESCAPE = 0x1B;
|
||||
public const int VK_SHIFT = 0x10;
|
||||
public const int VK_CONTROL = 0x11;
|
||||
public const int VK_MENU = 0x12;
|
||||
public const int VK_LWIN = 0x5B;
|
||||
public const int VK_RWIN = 0x5C;
|
||||
|
||||
public const int MONITOR_DEFAULTTONEAREST = 2;
|
||||
public const int MDT_EFFECTIVE_DPI = 0;
|
||||
|
||||
public static readonly IntPtr HWND_BROADCAST = new(0xffff);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
public POINT(int x, int y) { X = x; Y = y; }
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MSLLHOOKSTRUCT
|
||||
{
|
||||
public POINT pt;
|
||||
public uint mouseData;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
public delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern short GetAsyncKeyState(int vKey);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool GetCursorPos(out POINT lpPoint);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern IntPtr GetModuleHandle(string? lpModuleName);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
|
||||
|
||||
[DllImport("Shcore.dll")]
|
||||
public static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern uint RegisterWindowMessage(string lpString);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
public static bool IsKeyDown(int vk) => (GetAsyncKeyState(vk) & 0x8000) != 0;
|
||||
|
||||
public static uint ModifiersToWin32(IEnumerable<string> modifiers)
|
||||
{
|
||||
uint flags = 0;
|
||||
foreach (var m in modifiers)
|
||||
{
|
||||
switch (m)
|
||||
{
|
||||
case "Ctrl": flags |= MOD_CONTROL; break;
|
||||
case "Alt": flags |= MOD_ALT; break;
|
||||
case "Shift": flags |= MOD_SHIFT; break;
|
||||
case "Win": flags |= MOD_WIN; break;
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
public static bool AreComboModifiersDown(IEnumerable<string> modifiers)
|
||||
{
|
||||
foreach (var m in modifiers)
|
||||
{
|
||||
switch (m)
|
||||
{
|
||||
case "Ctrl": if (!IsKeyDown(VK_CONTROL)) return false; break;
|
||||
case "Alt": if (!IsKeyDown(VK_MENU)) return false; break;
|
||||
case "Shift": if (!IsKeyDown(VK_SHIFT)) return false; break;
|
||||
case "Win": if (!IsKeyDown(VK_LWIN) && !IsKeyDown(VK_RWIN)) return false; break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryGetCursorPos(out System.Windows.Point point)
|
||||
{
|
||||
if (GetCursorPos(out POINT p))
|
||||
{
|
||||
point = new System.Windows.Point(p.X, p.Y);
|
||||
return true;
|
||||
}
|
||||
point = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OneClickRun.Models;
|
||||
|
||||
/// <summary>应用配置根对象(持久化到 config.json)</summary>
|
||||
public class AppConfig
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
public GeneralSettings General { get; set; } = new();
|
||||
public List<WheelItem> Items { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace OneClickRun.Models;
|
||||
|
||||
/// <summary>轮盘指令类型</summary>
|
||||
public enum WheelItemType { App, Folder, Url, Script }
|
||||
|
||||
/// <summary>主题模式</summary>
|
||||
public enum ThemeMode { System, Light, Dark }
|
||||
|
||||
/// <summary>轮盘显示位置</summary>
|
||||
public enum WheelPosition { ScreenCenter, Mouse }
|
||||
|
||||
/// <summary>呼出轮盘方式</summary>
|
||||
public enum TriggerMode { Click, Hold, LongPress }
|
||||
|
||||
/// <summary>指令选择方式</summary>
|
||||
public enum SelectionMode { SingleClick, DoubleClick, Swipe }
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using OneClickRun.Helpers;
|
||||
|
||||
namespace OneClickRun.Models;
|
||||
|
||||
/// <summary>系统设置(实现 INotifyPropertyChanged 以支持界面即时生效)</summary>
|
||||
public class GeneralSettings : INotifyPropertyChanged
|
||||
{
|
||||
private bool _autoStart;
|
||||
private bool _wheelEnabled = true;
|
||||
private WheelPosition _wheelPosition = WheelPosition.ScreenCenter;
|
||||
private HotkeyBinding _hotkey = new() { Modifiers = { "Ctrl", "Alt" }, Key = "Space" };
|
||||
private TriggerMode _triggerMode = TriggerMode.Click;
|
||||
private int _longPressMs = 400;
|
||||
private SelectionMode _selectionMode = SelectionMode.SingleClick;
|
||||
private ThemeMode _theme = ThemeMode.System;
|
||||
|
||||
/// <summary>开机启动</summary>
|
||||
public bool AutoStart { get => _autoStart; set => Set(ref _autoStart, value); }
|
||||
|
||||
/// <summary>轮盘全局开关</summary>
|
||||
public bool WheelEnabled { get => _wheelEnabled; set => Set(ref _wheelEnabled, value); }
|
||||
|
||||
/// <summary>轮盘显示位置</summary>
|
||||
public WheelPosition WheelPosition { get => _wheelPosition; set => Set(ref _wheelPosition, value); }
|
||||
|
||||
/// <summary>呼出轮盘的全局快捷键</summary>
|
||||
public HotkeyBinding Hotkey { get => _hotkey; set => Set(ref _hotkey, value ?? new HotkeyBinding()); }
|
||||
|
||||
/// <summary>呼出方式:点击 / 长按 / 按住</summary>
|
||||
public TriggerMode TriggerMode
|
||||
{
|
||||
get => _triggerMode;
|
||||
set
|
||||
{
|
||||
if (Set(ref _triggerMode, value) && value != TriggerMode.Hold && SelectionMode == SelectionMode.Swipe)
|
||||
SelectionMode = SelectionMode.SingleClick; // 滑动选择仅按住显示下有效
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>长按呼出阈值(毫秒)</summary>
|
||||
public int LongPressMs { get => _longPressMs; set => Set(ref _longPressMs, Math.Clamp(value, 300, 1500)); }
|
||||
|
||||
/// <summary>选择方式:单击 / 双击 / 滑动</summary>
|
||||
public SelectionMode SelectionMode { get => _selectionMode; set => Set(ref _selectionMode, value); }
|
||||
|
||||
/// <summary>主题:日间 / 黑夜 / 跟随系统</summary>
|
||||
public ThemeMode Theme { get => _theme; set => Set(ref _theme, value); }
|
||||
|
||||
/// <summary>快捷键文本(供录制控件绑定)</summary>
|
||||
public string HotkeyText
|
||||
{
|
||||
get => HotkeyFormat.Format(Hotkey);
|
||||
set => Hotkey = HotkeyFormat.TryParse(value) ?? new HotkeyBinding();
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OneClickRun.Models;
|
||||
|
||||
/// <summary>全局快捷键:修饰键 + 主键(主键使用 WPF Key 名称,如 "Space"、"F8")</summary>
|
||||
public class HotkeyBinding
|
||||
{
|
||||
public List<string> Modifiers { get; set; } = new();
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public bool IsValid => !string.IsNullOrWhiteSpace(Key);
|
||||
|
||||
public HotkeyBinding Clone() => new() { Modifiers = new List<string>(Modifiers), Key = Key };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace OneClickRun.Models;
|
||||
|
||||
/// <summary>一条轮盘指令</summary>
|
||||
public class WheelItem
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public WheelItemType Type { get; set; } = WheelItemType.App;
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public string Args { get; set; } = string.Empty;
|
||||
|
||||
public WheelItem Clone() => new()
|
||||
{
|
||||
Id = Id,
|
||||
Name = Name,
|
||||
Type = Type,
|
||||
Path = Path,
|
||||
Args = Args,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OneClickRun</RootNamespace>
|
||||
<AssemblyName>OneClickRun</AssemblyName>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<Version>1.0.0</Version>
|
||||
<Product>一键运行</Product>
|
||||
<AssemblyTitle>一键运行</AssemblyTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="System" />
|
||||
<Using Include="System.Collections.Generic" />
|
||||
<Using Include="System.IO" />
|
||||
<Using Include="System.Linq" />
|
||||
<Using Include="System.Threading" />
|
||||
<Using Include="System.Threading.Tasks" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="assets\samples\hello.ps1">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Windows;
|
||||
|
||||
[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)]
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>开机启动(HKCU Run 键,无需管理员)</summary>
|
||||
public class AutostartService
|
||||
{
|
||||
private const string RunKeyPath = @"SoftwareMicrosoftWindowsCurrentVersionRun";
|
||||
private const string ValueName = "OneClickRun";
|
||||
|
||||
public bool IsEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath);
|
||||
return key?.GetValue(ValueName) is string value && value.Length > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("读取开机启动状态失败", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetEnabled(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath);
|
||||
if (enabled)
|
||||
{
|
||||
var exe = Environment.ProcessPath;
|
||||
if (string.IsNullOrEmpty(exe)) return false;
|
||||
key.SetValue(ValueName, $"\"{exe}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
key.DeleteValue(ValueName, throwOnMissingValue: false);
|
||||
}
|
||||
Logger.Info($"开机启动已{(enabled ? "开启" : "关闭")}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("设置开机启动失败", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
public readonly record struct CommandResult(bool Success, string Message)
|
||||
{
|
||||
public static CommandResult Ok() => new(true, string.Empty);
|
||||
public static CommandResult Fail(string message) => new(false, message);
|
||||
}
|
||||
|
||||
/// <summary>执行四类指令:打开软件 / 文件夹 / 网址 / PowerShell 脚本</summary>
|
||||
public class CommandRunner
|
||||
{
|
||||
public async Task<CommandResult> ExecuteAsync(WheelItem item)
|
||||
{
|
||||
try
|
||||
{
|
||||
return item.Type switch
|
||||
{
|
||||
WheelItemType.App => await RunAppAsync(item),
|
||||
WheelItemType.Folder => RunFolder(item.Path),
|
||||
WheelItemType.Url => RunUrl(item.Path),
|
||||
WheelItemType.Script => await RunScriptAsync(item),
|
||||
_ => CommandResult.Fail($"未知指令类型: {item.Type}"),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"执行指令失败 [{item.Name}] ({item.Type}: {item.Path})", ex);
|
||||
return CommandResult.Fail($"执行「{item.Name}」失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<CommandResult> RunAppAsync(WheelItem item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Path) || !File.Exists(item.Path))
|
||||
return Task.FromResult(CommandResult.Fail($"程序不存在:{item.Path}"));
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = item.Path,
|
||||
Arguments = item.Args ?? string.Empty,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
Logger.Info($"启动软件: {item.Path}");
|
||||
return Task.FromResult(CommandResult.Ok());
|
||||
}
|
||||
catch (Win32Exception ex)
|
||||
{
|
||||
// 常见于用户在 UAC 提示中取消、或系统找不到关联程序
|
||||
Logger.Warn($"启动软件被取消或失败: {item.Path} ({ex.Message})");
|
||||
return Task.FromResult(CommandResult.Fail($"启动「{item.Name}」失败:{ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static CommandResult RunFolder(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
||||
return CommandResult.Fail($"文件夹不存在:{path}");
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{path}\"",
|
||||
UseShellExecute = false,
|
||||
});
|
||||
Logger.Info($"打开文件夹: {path}");
|
||||
return CommandResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"打开文件夹失败: {path}", ex);
|
||||
return CommandResult.Fail($"打开文件夹失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static CommandResult RunUrl(string rawUrl)
|
||||
{
|
||||
var url = NormalizeUrl(rawUrl);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return CommandResult.Fail("网址为空");
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||
Logger.Info($"打开网址: {url}");
|
||||
return CommandResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"打开网址失败: {url}", ex);
|
||||
return CommandResult.Fail($"打开网址失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CommandResult> RunScriptAsync(WheelItem item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Path) || !File.Exists(item.Path))
|
||||
return CommandResult.Fail($"脚本不存在:{item.Path}");
|
||||
|
||||
var pwsh = PowerShellLocator.Find();
|
||||
if (pwsh == null)
|
||||
return CommandResult.Fail("未找到 PowerShell 7(pwsh.exe)。请安装 PowerShell 7.6.5 后重试。");
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = pwsh,
|
||||
Arguments = PowerShellLocator.BuildScriptArguments(item.Path, item.Args),
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(Path.GetFullPath(item.Path)) ?? string.Empty,
|
||||
CreateNoWindow = false,
|
||||
};
|
||||
Logger.Info($"运行脚本: {pwsh} -File \"{item.Path}\"");
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return CommandResult.Fail("脚本进程启动失败");
|
||||
await process.WaitForExitAsync();
|
||||
var code = process.ExitCode;
|
||||
if (code == 0) return CommandResult.Ok();
|
||||
Logger.Warn($"脚本退出码 {code}: {item.Path}");
|
||||
return CommandResult.Fail($"脚本「{item.Name}」执行完毕,退出码 {code}(非 0),请检查脚本输出。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"运行脚本失败: {item.Path}", ex);
|
||||
return CommandResult.Fail($"运行脚本失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static string NormalizeUrl(string rawUrl)
|
||||
{
|
||||
var url = (rawUrl ?? string.Empty).Trim();
|
||||
if (url.Length == 0) return url;
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
return uri.ToString();
|
||||
if (url.Contains("://")) return url; // 其他协议原样保留
|
||||
return "https://" + url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>配置读写:%APPDATA%\OneClickRun\config.json,原子写入,损坏自动备份重建</summary>
|
||||
public class ConfigService
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private readonly object _lock = new();
|
||||
|
||||
public string ConfigDirectory { get; }
|
||||
public string ConfigPath => Path.Combine(ConfigDirectory, "config.json");
|
||||
public AppConfig Current { get; private set; } = new();
|
||||
public bool WasFirstRun { get; private set; }
|
||||
|
||||
public ConfigService(string? directory = null)
|
||||
{
|
||||
ConfigDirectory = directory ??
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OneClickRun");
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try { Directory.CreateDirectory(ConfigDirectory); } catch { }
|
||||
if (!File.Exists(ConfigPath))
|
||||
{
|
||||
Current = CreateDefault();
|
||||
WasFirstRun = true;
|
||||
Save();
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var cfg = JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(ConfigPath), Options);
|
||||
Current = Repair(cfg);
|
||||
if (cfg == null) BackupBroken("null");
|
||||
WasFirstRun = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("配置文件损坏,已备份并重建默认配置", ex);
|
||||
BackupBroken(ex.GetType().Name);
|
||||
Current = CreateDefault();
|
||||
WasFirstRun = true;
|
||||
Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ConfigDirectory);
|
||||
var tmp = ConfigPath + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(Current, Options));
|
||||
File.Move(tmp, ConfigPath, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("保存配置失败", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BackupBroken(string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(ConfigPath))
|
||||
File.Copy(ConfigPath,
|
||||
Path.Combine(ConfigDirectory, $"config.broken-{DateTime.Now:yyyyMMdd-HHmmss}-{reason}.json"),
|
||||
overwrite: true);
|
||||
}
|
||||
catch { /* 备份失败忽略 */ }
|
||||
}
|
||||
|
||||
private static AppConfig Repair(AppConfig? cfg)
|
||||
{
|
||||
cfg ??= new AppConfig();
|
||||
cfg.Version = 1;
|
||||
cfg.General ??= new GeneralSettings();
|
||||
cfg.General.Hotkey ??= new HotkeyBinding();
|
||||
cfg.General.LongPressMs = Math.Clamp(cfg.General.LongPressMs, 300, 1500);
|
||||
cfg.Items ??= new List<WheelItem>();
|
||||
foreach (var item in cfg.Items)
|
||||
{
|
||||
item.Name ??= string.Empty;
|
||||
item.Path ??= string.Empty;
|
||||
item.Args ??= string.Empty;
|
||||
if (string.IsNullOrEmpty(item.Id)) item.Id = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/// <summary>默认配置:4 条示例指令(文档文件夹 / 记事本 / 必应 / 示例脚本)</summary>
|
||||
public static AppConfig CreateDefault()
|
||||
{
|
||||
return new AppConfig
|
||||
{
|
||||
General = new GeneralSettings(),
|
||||
Items = new List<WheelItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "打开文档",
|
||||
Type = WheelItemType.Folder,
|
||||
Path = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "记事本",
|
||||
Type = WheelItemType.App,
|
||||
Path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "notepad.exe"),
|
||||
},
|
||||
new() { Name = "必应", Type = WheelItemType.Url, Path = "https://www.bing.com" },
|
||||
new()
|
||||
{
|
||||
Name = "示例脚本",
|
||||
Type = WheelItemType.Script,
|
||||
Path = Path.Combine(AppContext.BaseDirectory, "assets", "samples", "hello.ps1"),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>全局快捷键服务:RegisterHotKey + WM_HOTKEY 消息挂接</summary>
|
||||
public class HotkeyService : IDisposable
|
||||
{
|
||||
private const int HotkeyId = 0x0C0D;
|
||||
|
||||
private HwndSource? _source;
|
||||
private bool _registered;
|
||||
|
||||
public HotkeyBinding? Current { get; private set; }
|
||||
public bool IsRegistered => _registered;
|
||||
|
||||
/// <summary>快捷键按下(MOD_NOREPEAT,一次按压仅触发一次)</summary>
|
||||
public event Action? HotkeyPressed;
|
||||
|
||||
/// <summary>注册失败(可能与其他程序冲突)</summary>
|
||||
public event Action? RegistrationFailed;
|
||||
|
||||
public void Attach(Window host)
|
||||
{
|
||||
if (_source != null) return;
|
||||
var hwnd = new WindowInteropHelper(host).Handle; // 强制创建句柄
|
||||
_source = HwndSource.FromHwnd(hwnd);
|
||||
_source?.AddHook(WndProc);
|
||||
}
|
||||
|
||||
public void Update(HotkeyBinding? binding)
|
||||
{
|
||||
Unregister();
|
||||
Current = binding?.IsValid == true ? binding : null;
|
||||
if (Current == null || _source == null) return;
|
||||
|
||||
var vk = (uint)HotkeyFormat.VirtualKeyOf(Current.Key);
|
||||
if (vk == 0) return;
|
||||
var mods = Win32Interop.ModifiersToWin32(Current.Modifiers) | Win32Interop.MOD_NOREPEAT;
|
||||
_registered = Win32Interop.RegisterHotKey(_source.Handle, HotkeyId, mods, vk);
|
||||
if (_registered)
|
||||
Logger.Info($"全局快捷键注册成功: {HotkeyFormat.Format(Current)}");
|
||||
else
|
||||
{
|
||||
Logger.Warn($"全局快捷键注册失败: {HotkeyFormat.Format(Current)}");
|
||||
RegistrationFailed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void Unregister()
|
||||
{
|
||||
if (_registered && _source != null)
|
||||
{
|
||||
Win32Interop.UnregisterHotKey(_source.Handle, HotkeyId);
|
||||
_registered = false;
|
||||
}
|
||||
}
|
||||
|
||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg == Win32Interop.WM_HOTKEY && wParam.ToInt64() == HotkeyId)
|
||||
{
|
||||
HotkeyPressed?.Invoke();
|
||||
handled = true;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>判断快捷键组合(主键 + 所有修饰键)当前是否处于按下状态</summary>
|
||||
public static bool IsComboDown(HotkeyBinding? binding)
|
||||
{
|
||||
if (binding == null || !binding.IsValid) return false;
|
||||
if (!Win32Interop.IsKeyDown(HotkeyFormat.VirtualKeyOf(binding.Key))) return false;
|
||||
return Win32Interop.AreComboModifiersDown(binding.Modifiers);
|
||||
}
|
||||
|
||||
public void Dispose() => Unregister();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>轻量文件日志(按天滚动,位于 %APPDATA%\OneClickRun\logs)</summary>
|
||||
public static class Logger
|
||||
{
|
||||
private static readonly object Lock = new();
|
||||
private static readonly string Dir =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OneClickRun", "logs");
|
||||
|
||||
public static string LogDirectory => Dir;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
try { Directory.CreateDirectory(Dir); } catch { /* 日志目录创建失败不影响主流程 */ }
|
||||
}
|
||||
|
||||
public static void Info(string message) => Write("INFO", message);
|
||||
public static void Warn(string message) => Write("WARN", message);
|
||||
public static void Error(string message, Exception? ex = null) =>
|
||||
Write("ERROR", ex == null ? message : message + " | " + ex);
|
||||
|
||||
private static void Write(string level, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
Directory.CreateDirectory(Dir);
|
||||
var file = Path.Combine(Dir, $"app-{DateTime.Now:yyyyMMdd}.log");
|
||||
File.AppendAllText(file,
|
||||
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}",
|
||||
Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
catch { /* 日志失败不抛出 */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>定位 PowerShell 7(pwsh.exe)并构造脚本启动参数</summary>
|
||||
public static class PowerShellLocator
|
||||
{
|
||||
/// <summary>依次从 PATH、标准安装目录查找 pwsh.exe</summary>
|
||||
public static string? Find()
|
||||
{
|
||||
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var dir in pathVar.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(dir.Trim(), "pwsh.exe");
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
}
|
||||
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
string[] candidates =
|
||||
{
|
||||
Path.Combine(programFiles, "PowerShell", "7", "pwsh.exe"),
|
||||
Path.Combine(localAppData, "Programs", "PowerShell", "7", "pwsh.exe"),
|
||||
};
|
||||
foreach (var c in candidates)
|
||||
if (File.Exists(c)) return c;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? GetVersion(string? pwshPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pwshPath) || !File.Exists(pwshPath)) return null;
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = pwshPath,
|
||||
Arguments = "-NoProfile -Command \"$PSVersionTable.PSVersion.ToString()\"",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
if (process == null) return null;
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
return string.IsNullOrWhiteSpace(output) ? null : output.Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"获取 PowerShell 版本失败: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>构造 pwsh 启动参数(脚本路径加引号,兼容含空格路径)</summary>
|
||||
public static string BuildScriptArguments(string scriptPath, string? extraArgs)
|
||||
{
|
||||
var quoted = "\"" + scriptPath + "\"";
|
||||
return string.IsNullOrWhiteSpace(extraArgs)
|
||||
? $"-NoProfile -ExecutionPolicy Bypass -File {quoted}"
|
||||
: $"-NoProfile -ExecutionPolicy Bypass -File {quoted} {extraArgs.Trim()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Windows;
|
||||
using Microsoft.Win32;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>日间/黑夜/跟随系统主题切换(切换合并的主题资源字典)</summary>
|
||||
public class ThemeService
|
||||
{
|
||||
private const string LightUri = "Themes/Light.xaml";
|
||||
private const string DarkUri = "Themes/Dark.xaml";
|
||||
|
||||
private ThemeMode _mode = ThemeMode.System;
|
||||
private bool _systemEventsHooked;
|
||||
|
||||
public ThemeMode Mode => _mode;
|
||||
|
||||
public void Apply(ThemeMode mode)
|
||||
{
|
||||
_mode = mode;
|
||||
var dark = mode switch
|
||||
{
|
||||
ThemeMode.Light => false,
|
||||
ThemeMode.Dark => true,
|
||||
_ => SystemUsesDark(),
|
||||
};
|
||||
|
||||
var app = Application.Current;
|
||||
var dictionaries = app.Resources.MergedDictionaries;
|
||||
for (var i = dictionaries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var source = dictionaries[i].Source;
|
||||
if (source != null &&
|
||||
(source.OriginalString == LightUri || source.OriginalString == DarkUri))
|
||||
dictionaries.RemoveAt(i);
|
||||
}
|
||||
dictionaries.Add(new ResourceDictionary { Source = new Uri(dark ? DarkUri : LightUri, UriKind.Relative) });
|
||||
Logger.Info($"主题已应用: {mode} → {(dark ? "黑夜" : "日间")}");
|
||||
|
||||
HookSystemEvents();
|
||||
}
|
||||
|
||||
private void HookSystemEvents()
|
||||
{
|
||||
if (_systemEventsHooked) return;
|
||||
_systemEventsHooked = true;
|
||||
SystemEvents.UserPreferenceChanged += (_, _) =>
|
||||
{
|
||||
if (_mode != ThemeMode.System) return;
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
dispatcher?.BeginInvoke(() => Apply(ThemeMode.System));
|
||||
};
|
||||
}
|
||||
|
||||
public static bool SystemUsesDark()
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = Registry.GetValue(
|
||||
@"HKEY_CURRENT_USERSoftwareMicrosoftWindowsCurrentVersionThemesPersonalize",
|
||||
"AppsUseLightTheme", 1);
|
||||
return value is int i && i == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Windows;
|
||||
using OneClickRun.Views;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>非阻塞的右下角提示(线程安全,自动切回 UI 线程)</summary>
|
||||
public static class ToastService
|
||||
{
|
||||
private static ToastWindow? _window;
|
||||
|
||||
public static void Show(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message)) return;
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher == null) return;
|
||||
if (!dispatcher.CheckAccess())
|
||||
{
|
||||
dispatcher.BeginInvoke(() => Show(message));
|
||||
return;
|
||||
}
|
||||
if (_window == null)
|
||||
{
|
||||
_window = new ToastWindow();
|
||||
_window.Show();
|
||||
}
|
||||
_window.ShowMessage(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using WinForms = System.Windows.Forms;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Models;
|
||||
using OneClickRun.Views;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 轮盘窗口管理器:三种呼出模式(点击/长按/按住)与三种选择模式(单击/双击/滑动)的状态机。
|
||||
/// 通过 30ms 轮询 GetAsyncKeyState 判断快捷键松开;长按达到阈值后才显示轮盘。
|
||||
/// </summary>
|
||||
public class WheelWindowManager : IDisposable
|
||||
{
|
||||
private readonly CommandRunner _runner;
|
||||
private readonly Func<AppConfig> _config;
|
||||
private readonly RadialMenuWindow _window = new();
|
||||
private readonly DispatcherTimer _timer;
|
||||
private readonly GlobalMouseHook _mouseHook = new();
|
||||
|
||||
private bool _shown;
|
||||
private bool _engaged;
|
||||
private bool _longPressFired;
|
||||
private DateTime _engagedAt;
|
||||
|
||||
/// <summary>轮盘全局开关(关闭时快捷键不再呼出)</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>内部调试:快照导出时访问轮盘窗口</summary>
|
||||
internal RadialMenuWindow Window => _window;
|
||||
|
||||
public WheelWindowManager(CommandRunner runner, Func<AppConfig> config)
|
||||
{
|
||||
_runner = runner;
|
||||
_config = config;
|
||||
_window.SectorPicked += OnSectorPicked;
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Normal) { Interval = TimeSpan.FromMilliseconds(30) };
|
||||
_timer.Tick += OnTick;
|
||||
_mouseHook.LeftButtonDown += OnGlobalLeftButtonDown;
|
||||
}
|
||||
|
||||
/// <summary>全局快捷键按下(WM_HOTKEY,MOD_NOREPEAT 保证单次触发)</summary>
|
||||
public void OnHotkeyPressed()
|
||||
{
|
||||
var cfg = _config();
|
||||
if (!Enabled || !cfg.General.WheelEnabled) return;
|
||||
|
||||
switch (cfg.General.TriggerMode)
|
||||
{
|
||||
case TriggerMode.Click:
|
||||
if (_shown) Hide();
|
||||
else Show();
|
||||
break;
|
||||
case TriggerMode.Hold:
|
||||
_engaged = true;
|
||||
_engagedAt = DateTime.UtcNow;
|
||||
_longPressFired = false;
|
||||
Show();
|
||||
break;
|
||||
case TriggerMode.LongPress:
|
||||
_engaged = true;
|
||||
_engagedAt = DateTime.UtcNow;
|
||||
_longPressFired = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTick(object? sender, EventArgs e)
|
||||
{
|
||||
var cfg = _config();
|
||||
if (_engaged)
|
||||
{
|
||||
if (!HotkeyService.IsComboDown(cfg.General.Hotkey))
|
||||
OnHotkeyReleased();
|
||||
else if (!_shown && !_longPressFired && cfg.General.TriggerMode == TriggerMode.LongPress &&
|
||||
(DateTime.UtcNow - _engagedAt).TotalMilliseconds >= cfg.General.LongPressMs)
|
||||
{
|
||||
_longPressFired = true;
|
||||
Show();
|
||||
}
|
||||
}
|
||||
if (_shown && Win32Interop.IsKeyDown(Win32Interop.VK_ESCAPE))
|
||||
Hide();
|
||||
if (!_engaged && !_shown)
|
||||
_timer.Stop();
|
||||
}
|
||||
|
||||
private void OnHotkeyReleased()
|
||||
{
|
||||
_engaged = false;
|
||||
var cfg = _config();
|
||||
if (!_shown) return; // 长按未到阈值,忽略本次按压
|
||||
|
||||
if (cfg.General.TriggerMode == TriggerMode.Hold)
|
||||
{
|
||||
if (cfg.General.SelectionMode == SelectionMode.Swipe)
|
||||
{
|
||||
var index = _window.HitTestScreenDip(DpiUtil.GetCursorPosDip());
|
||||
if (index is int i && i >= 0 && i < cfg.Items.Count)
|
||||
{
|
||||
ExecuteAndHide(cfg.Items[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Hide(); // 按住显示:松开即隐藏
|
||||
}
|
||||
// 长按显示:松开不影响已显示的轮盘,等待选择 / Esc / 点击外部
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
if (_shown) return;
|
||||
var cfg = _config();
|
||||
var center = cfg.General.WheelPosition == WheelPosition.Mouse
|
||||
? DpiUtil.GetCursorPosDip()
|
||||
: GetPrimaryScreenCenterDip();
|
||||
|
||||
// 滑动选择仅在"按住显示"下有效,其余情况回退为单击
|
||||
var selectionMode = cfg.General.SelectionMode;
|
||||
if (selectionMode == SelectionMode.Swipe && cfg.General.TriggerMode != TriggerMode.Hold)
|
||||
selectionMode = SelectionMode.SingleClick;
|
||||
|
||||
_window.SelectionMode = selectionMode;
|
||||
_window.ShowWheel(cfg.Items, HubHint(cfg), center);
|
||||
ClampToVirtualScreen();
|
||||
_window.Show();
|
||||
_shown = true;
|
||||
_mouseHook.Start();
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
if (!_shown) return;
|
||||
_shown = false;
|
||||
_window.Hide();
|
||||
_mouseHook.Stop();
|
||||
if (!_engaged) _timer.Stop();
|
||||
}
|
||||
|
||||
private void OnSectorPicked(int index)
|
||||
{
|
||||
var items = _config().Items;
|
||||
if (index < 0 || index >= items.Count) return;
|
||||
ExecuteAndHide(items[index]);
|
||||
}
|
||||
|
||||
private async void ExecuteAndHide(WheelItem item)
|
||||
{
|
||||
Hide();
|
||||
var result = await _runner.ExecuteAsync(item);
|
||||
if (!result.Success)
|
||||
ToastService.Show(result.Message);
|
||||
}
|
||||
|
||||
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)
|
||||
Hide();
|
||||
}
|
||||
|
||||
private static Point GetPrimaryScreenCenterDip()
|
||||
{
|
||||
var screen = WinForms.Screen.PrimaryScreen;
|
||||
if (screen == null)
|
||||
{
|
||||
var wa = SystemParameters.WorkArea;
|
||||
return new Point(wa.Left + wa.Width / 2.0, wa.Top + wa.Height / 2.0);
|
||||
}
|
||||
var bounds = screen.Bounds;
|
||||
var px = new Point(bounds.X + bounds.Width / 2.0, bounds.Y + bounds.Height / 2.0);
|
||||
return DpiUtil.PxToDip(px);
|
||||
}
|
||||
|
||||
private void ClampToVirtualScreen()
|
||||
{
|
||||
var left = SystemParameters.VirtualScreenLeft;
|
||||
var top = SystemParameters.VirtualScreenTop;
|
||||
var width = SystemParameters.VirtualScreenWidth;
|
||||
var height = SystemParameters.VirtualScreenHeight;
|
||||
_window.Left = Math.Clamp(_window.Left, left + 4, left + width - _window.Width - 4);
|
||||
_window.Top = Math.Clamp(_window.Top, top + 4, top + height - _window.Height - 4);
|
||||
}
|
||||
|
||||
private static string HubHint(AppConfig cfg)
|
||||
{
|
||||
var (trigger, selection) = (cfg.General.TriggerMode, cfg.General.SelectionMode);
|
||||
if (selection == SelectionMode.Swipe) return "松开执行";
|
||||
var select = selection == SelectionMode.DoubleClick ? "双击选择" : "单击选择";
|
||||
// 保持单行短文本,确保中心提示行高度恒定、标题始终居中
|
||||
return trigger == TriggerMode.Hold ? "按住 · " + select : select + " · Esc";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Stop();
|
||||
_mouseHook.Dispose();
|
||||
_window.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ===== 黑夜主题 ===== -->
|
||||
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#171A21"/>
|
||||
<SolidColorBrush x:Key="PanelBackgroundBrush" Color="#1E222B"/>
|
||||
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#232834"/>
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#F2F4F8"/>
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#9BA3B0"/>
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#5B7CFF"/>
|
||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#7490FF"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#343A46"/>
|
||||
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#262C38"/>
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#F2555A"/>
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#43B472"/>
|
||||
<SolidColorBrush x:Key="NavSelectedBackgroundBrush" Color="#2C3550"/>
|
||||
<SolidColorBrush x:Key="NavHoverBackgroundBrush" Color="#282E3A"/>
|
||||
|
||||
<!-- 轮盘(亚克力风格:高透明度叠层) -->
|
||||
<SolidColorBrush x:Key="HubBackgroundBrush" Color="#F01E222B"/>
|
||||
<SolidColorBrush x:Key="HubStrokeBrush" Color="#40606978"/>
|
||||
<SolidColorBrush x:Key="HubTextBrush" Color="#F2F4F8"/>
|
||||
<SolidColorBrush x:Key="HubHintBrush" Color="#9BA3B0"/>
|
||||
<SolidColorBrush x:Key="SectorBorderBrush" Color="#66FFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorHoverStrokeBrush" Color="#FFFFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorTextBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorGlyphBrush" Color="#FFFFFF"/>
|
||||
|
||||
<SolidColorBrush x:Key="SectorBrush0" Color="#CC3E66D6"/>
|
||||
<SolidColorBrush x:Key="SectorBrush1" Color="#CC5544D6"/>
|
||||
<SolidColorBrush x:Key="SectorBrush2" Color="#CCD6456F"/>
|
||||
<SolidColorBrush x:Key="SectorBrush3" Color="#CC238E7E"/>
|
||||
<SolidColorBrush x:Key="SectorBrush4" Color="#CCD67A1F"/>
|
||||
<SolidColorBrush x:Key="SectorBrush5" Color="#CC3C97C7"/>
|
||||
<SolidColorBrush x:Key="SectorBrush6" Color="#CC669F2E"/>
|
||||
<SolidColorBrush x:Key="SectorBrush7" Color="#CC9943C7"/>
|
||||
<SolidColorBrush x:Key="SectorBrush8" Color="#CCC73B3B"/>
|
||||
<SolidColorBrush x:Key="SectorBrush9" Color="#CC386CC7"/>
|
||||
<SolidColorBrush x:Key="SectorBrush10" Color="#CC3FC7A5"/>
|
||||
<SolidColorBrush x:Key="SectorBrush11" Color="#CC6F5AD6"/>
|
||||
|
||||
<!-- Toast -->
|
||||
<SolidColorBrush x:Key="ToastBackgroundBrush" Color="#F0232834"/>
|
||||
<SolidColorBrush x:Key="ToastBorderBrush" Color="#3A414E"/>
|
||||
<SolidColorBrush x:Key="ToastTextBrush" Color="#F2F4F8"/>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,46 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ===== 日间主题 ===== -->
|
||||
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#F4F6FB"/>
|
||||
<SolidColorBrush x:Key="PanelBackgroundBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#1F2430"/>
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#6B7280"/>
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#4F6BFF"/>
|
||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#3D57E0"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#E5E7EB"/>
|
||||
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D"/>
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#2E9E5B"/>
|
||||
<SolidColorBrush x:Key="NavSelectedBackgroundBrush" Color="#EAF0FF"/>
|
||||
<SolidColorBrush x:Key="NavHoverBackgroundBrush" Color="#F1F4F9"/>
|
||||
|
||||
<!-- 轮盘(亚克力风格:高透明度叠层) -->
|
||||
<SolidColorBrush x:Key="HubBackgroundBrush" Color="#F2FFFFFF"/>
|
||||
<SolidColorBrush x:Key="HubStrokeBrush" Color="#40A0A6B8"/>
|
||||
<SolidColorBrush x:Key="HubTextBrush" Color="#1F2430"/>
|
||||
<SolidColorBrush x:Key="HubHintBrush" Color="#6B7280"/>
|
||||
<SolidColorBrush x:Key="SectorBorderBrush" Color="#80FFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorHoverStrokeBrush" Color="#FFFFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorTextBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorGlyphBrush" Color="#FFFFFF"/>
|
||||
|
||||
<SolidColorBrush x:Key="SectorBrush0" Color="#CC5B8DEF"/>
|
||||
<SolidColorBrush x:Key="SectorBrush1" Color="#CC6A5BE8"/>
|
||||
<SolidColorBrush x:Key="SectorBrush2" Color="#CCE85A8C"/>
|
||||
<SolidColorBrush x:Key="SectorBrush3" Color="#CC2FAE9C"/>
|
||||
<SolidColorBrush x:Key="SectorBrush4" Color="#CCE8932F"/>
|
||||
<SolidColorBrush x:Key="SectorBrush5" Color="#CC4FB4E8"/>
|
||||
<SolidColorBrush x:Key="SectorBrush6" Color="#CC7FBE3F"/>
|
||||
<SolidColorBrush x:Key="SectorBrush7" Color="#CCB75AE8"/>
|
||||
<SolidColorBrush x:Key="SectorBrush8" Color="#CCE85A5A"/>
|
||||
<SolidColorBrush x:Key="SectorBrush9" Color="#CC4F8DE8"/>
|
||||
<SolidColorBrush x:Key="SectorBrush10" Color="#CC5AE8C8"/>
|
||||
<SolidColorBrush x:Key="SectorBrush11" Color="#CC8F8BE8"/>
|
||||
|
||||
<!-- Toast -->
|
||||
<SolidColorBrush x:Key="ToastBackgroundBrush" Color="#F2FFFFFF"/>
|
||||
<SolidColorBrush x:Key="ToastBorderBrush" Color="#E5E7EB"/>
|
||||
<SolidColorBrush x:Key="ToastTextBrush" Color="#1F2430"/>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,213 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<Style TargetType="{x:Type Control}">
|
||||
<Setter Property="FontFamily" Value="Microsoft YaHei UI"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
<Style TargetType="{x:Type TextBlock}">
|
||||
<Setter Property="FontFamily" Value="Microsoft YaHei UI"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
|
||||
<!-- 卡片 -->
|
||||
<Style x:Key="Card" TargetType="{x:Type Border}">
|
||||
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="12"/>
|
||||
<Setter Property="Padding" Value="18"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SectionTitle" TargetType="{x:Type TextBlock}">
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Margin" Value="0,0,0,8"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="BodyText" TargetType="{x:Type TextBlock}">
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
</Style>
|
||||
|
||||
<!-- 侧边导航 -->
|
||||
<Style x:Key="NavButton" TargetType="{x:Type ListBoxItem}">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type ListBoxItem}">
|
||||
<Border x:Name="Bd" Background="Transparent" CornerRadius="8" Padding="14,11" Margin="2">
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
TextElement.Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource NavHoverBackgroundBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource NavSelectedBackgroundBrush}"/>
|
||||
<Setter Property="TextElement.FontWeight" Value="SemiBold"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 按钮 -->
|
||||
<Style x:Key="PrimaryButton" TargetType="{x:Type Button}">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="18,8"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type Button}">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="8"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{TemplateBinding Foreground}"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentHoverBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.45"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SecondaryButton" TargetType="{x:Type Button}">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="16,8"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type Button}">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{TemplateBinding Foreground}"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource NavHoverBackgroundBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.45"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="IconButton" TargetType="{x:Type Button}" BasedOn="{StaticResource SecondaryButton}">
|
||||
<Setter Property="Padding" Value="10,5"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="MinWidth" Value="0"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="DangerButton" TargetType="{x:Type Button}" BasedOn="{StaticResource SecondaryButton}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- 复选框(现代开关样式) -->
|
||||
<Style TargetType="{x:Type CheckBox}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type CheckBox}">
|
||||
<StackPanel Orientation="Horizontal" Background="Transparent">
|
||||
<Border x:Name="Box" Width="18" Height="18" CornerRadius="4"
|
||||
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1.5"
|
||||
Background="{DynamicResource InputBackgroundBrush}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<Path x:Name="Check" Data="M 3.5 9 L 7.5 13 L 14.5 4.5"
|
||||
Stroke="White" StrokeThickness="2"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round"
|
||||
Visibility="Collapsed"/>
|
||||
</Border>
|
||||
<ContentPresenter VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Box" Property="Background" Value="{DynamicResource AccentBrush}"/>
|
||||
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
|
||||
<Setter TargetName="Check" Property="Visibility" Value="Visible"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 单选框 -->
|
||||
<Style TargetType="{x:Type RadioButton}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Margin" Value="0,0,22,0"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type RadioButton}">
|
||||
<StackPanel Orientation="Horizontal" Background="Transparent">
|
||||
<Grid Width="18" Height="18" VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<Ellipse x:Name="Outer" Stroke="{DynamicResource BorderBrush}" StrokeThickness="1.5"
|
||||
Fill="{DynamicResource InputBackgroundBrush}"/>
|
||||
<Ellipse x:Name="Dot" Width="8" Height="8" Fill="{DynamicResource AccentBrush}"
|
||||
Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
<ContentPresenter VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Outer" Property="Stroke" Value="{DynamicResource AccentBrush}"/>
|
||||
<Setter TargetName="Dot" Property="Visibility" Value="Visible"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 输入控件 -->
|
||||
<Style TargetType="{x:Type TextBox}">
|
||||
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="8,6"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="{x:Type ComboBox}">
|
||||
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="Padding" Value="6,5"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="{x:Type Slider}">
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.ViewModels;
|
||||
|
||||
/// <summary>主设置窗口视图模型:包装 AppConfig,改动经 Changed 事件通知主窗口保存与应用</summary>
|
||||
public class MainViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private readonly AppConfig _config;
|
||||
private string _hotkeyStatus = string.Empty;
|
||||
private string _saveStatus = string.Empty;
|
||||
|
||||
public GeneralSettings General => _config.General;
|
||||
public ObservableCollection<WheelItem> Items { get; }
|
||||
|
||||
public MainViewModel(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
Items = new ObservableCollection<WheelItem>(config.Items);
|
||||
General.PropertyChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(General));
|
||||
Changed?.Invoke();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>快捷键注册状态提示</summary>
|
||||
public string HotkeyStatus
|
||||
{
|
||||
get => _hotkeyStatus;
|
||||
set
|
||||
{
|
||||
if (_hotkeyStatus == value) return;
|
||||
_hotkeyStatus = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>自动保存状态提示</summary>
|
||||
public string SaveStatus
|
||||
{
|
||||
get => _saveStatus;
|
||||
set
|
||||
{
|
||||
if (_saveStatus == value) return;
|
||||
_saveStatus = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>任何设置/指令变化(用于防抖保存)</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public void AddItem(WheelItem item)
|
||||
{
|
||||
_config.Items.Add(item);
|
||||
Items.Add(item);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void ReplaceItem(WheelItem oldItem, WheelItem newItem)
|
||||
{
|
||||
var index = _config.Items.IndexOf(oldItem);
|
||||
if (index < 0) return;
|
||||
_config.Items[index] = newItem;
|
||||
Items[index] = newItem;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void RemoveItem(WheelItem item)
|
||||
{
|
||||
if (_config.Items.Remove(item))
|
||||
{
|
||||
Items.Remove(item);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public void MoveItem(WheelItem item, int delta)
|
||||
{
|
||||
var index = _config.Items.IndexOf(item);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= _config.Items.Count) return;
|
||||
(_config.Items[index], _config.Items[target]) = (_config.Items[target], _config.Items[index]);
|
||||
Items.Move(index, target);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<UserControl x:Class="OneClickRun.Views.AboutView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="关于" Style="{StaticResource SectionTitle}" Margin="0,0,0,14"/>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<TextBlock Text="一键运行" FontSize="22" FontWeight="Bold"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="VersionText" Style="{StaticResource BodyText}" Margin="0,6,0,0"/>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,10,0,0"
|
||||
Text="以游戏式快捷轮盘快速启动软件、打开文件夹、访问网址与运行 PowerShell 脚本。默认隐藏于托盘,通过全局快捷键呼出。"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="运行环境" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="配置文件目录" Foreground="{DynamicResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="ConfigPathText" Style="{StaticResource BodyText}" Margin="0,2,0,0"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="打开" Style="{StaticResource SecondaryButton}"
|
||||
Margin="12,0,0,0" Click="OpenConfig_Click"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="日志目录" Foreground="{DynamicResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="LogPathText" Style="{StaticResource BodyText}" Margin="0,2,0,0"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="打开" Style="{StaticResource SecondaryButton}"
|
||||
Margin="12,0,0,0" Click="OpenLog_Click"/>
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Text="PowerShell 状态" Foreground="{DynamicResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="PwshStatusText" Style="{StaticResource BodyText}" Margin="0,2,0,0"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="使用提示" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,0,0,6"
|
||||
Text="· 按全局快捷键(默认 Ctrl+Alt+Space)呼出轮盘,选中指令后自动隐藏;Esc 或点击轮盘外区域也可隐藏。"/>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,0,0,6"
|
||||
Text="· 关闭本窗口后应用仍在托盘运行;托盘图标双击可重新打开设置,右键可显示轮盘或退出。"/>
|
||||
<TextBlock Style="{StaticResource BodyText}"
|
||||
Text="· 脚本指令使用 PowerShell 7(pwsh.exe,建议 7.6.5)执行;未安装时脚本指令会给出安装提示。"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using OneClickRun.Services;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
public partial class AboutView : UserControl
|
||||
{
|
||||
public AboutView()
|
||||
{
|
||||
InitializeComponent();
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
VersionText.Text = $"版本 {version?.ToString(3) ?? "1.0.0"} · Windows 10 / 11 · WPF (.NET 8)";
|
||||
|
||||
var app = (App)Application.Current;
|
||||
ConfigPathText.Text = app.Config.ConfigPath;
|
||||
LogPathText.Text = Logger.LogDirectory;
|
||||
|
||||
Loaded += async (_, _) =>
|
||||
{
|
||||
var path = await System.Threading.Tasks.Task.Run(PowerShellLocator.Find);
|
||||
var versionText = await System.Threading.Tasks.Task.Run(() => PowerShellLocator.GetVersion(path));
|
||||
PwshStatusText.Text = path == null
|
||||
? "未找到 PowerShell 7(pwsh.exe),脚本指令将无法执行。请安装 PowerShell 7.6.5。"
|
||||
: $"已找到:{path}" + (versionText != null ? $"(版本 {versionText})" : string.Empty);
|
||||
};
|
||||
}
|
||||
|
||||
private void OpenConfig_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
OpenFolder(((App)Application.Current).Config.ConfigDirectory);
|
||||
}
|
||||
|
||||
private void OpenLog_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
OpenFolder(Logger.LogDirectory);
|
||||
}
|
||||
|
||||
private static void OpenFolder(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = false });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show("打开目录失败:" + ex.Message, "一键运行", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 不可见的宿主窗口:为全局热键(RegisterHotKey)与单实例广播消息提供稳定 HWND。
|
||||
/// 主设置窗口隐藏到托盘后句柄依然有效。
|
||||
/// </summary>
|
||||
internal sealed class HotkeyHostWindow : Window
|
||||
{
|
||||
public HotkeyHostWindow()
|
||||
{
|
||||
WindowStyle = WindowStyle.None;
|
||||
ResizeMode = ResizeMode.NoResize;
|
||||
ShowInTaskbar = false;
|
||||
ShowActivated = false;
|
||||
AllowsTransparency = true;
|
||||
Background = Brushes.Transparent;
|
||||
Opacity = 0;
|
||||
Width = 1;
|
||||
Height = 1;
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
Left = -100;
|
||||
Top = -100;
|
||||
Focusable = false;
|
||||
IsHitTestVisible = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<Window x:Class="OneClickRun.Views.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="clr-namespace:OneClickRun.Views"
|
||||
Title="一键运行"
|
||||
Width="960" Height="660" MinWidth="860" MinHeight="580"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource WindowBackgroundBrush}">
|
||||
<Grid Margin="14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Background="{DynamicResource PanelBackgroundBrush}" CornerRadius="12"
|
||||
Margin="0,0,14,0" Padding="6">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Margin="4,10,4,18">
|
||||
<TextBlock Text="一键运行" FontSize="20" FontWeight="Bold"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="快捷轮盘启动器" FontSize="12"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" HorizontalAlignment="Center"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
<StackPanel DockPanel.Dock="Bottom" Margin="4,0,4,8">
|
||||
<Button x:Name="ShowWheelButton" Content="预览轮盘" Style="{StaticResource SecondaryButton}"
|
||||
Margin="0,0,0,8" Click="ShowWheelButton_Click"/>
|
||||
<TextBlock Text="{Binding SaveStatus}" FontSize="11"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<ListBox x:Name="NavList" BorderThickness="0" Background="Transparent"
|
||||
SelectionChanged="NavList_SelectionChanged"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBoxItem Style="{StaticResource NavButton}" Content="轮盘指令" Tag="items" IsSelected="True"/>
|
||||
<ListBoxItem Style="{StaticResource NavButton}" Content="系统设置" Tag="general"/>
|
||||
<ListBoxItem Style="{StaticResource NavButton}" Content="关于" Tag="about"/>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Column="1">
|
||||
<views:WheelItemsView x:Name="ItemsView"/>
|
||||
<views:SettingsGeneralView x:Name="GeneralView" Visibility="Collapsed"/>
|
||||
<views:AboutView x:Name="AboutView" Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Threading;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Services;
|
||||
using OneClickRun.ViewModels;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly MainViewModel _vm;
|
||||
private readonly DispatcherTimer _saveTimer;
|
||||
private bool _ready;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
var app = (App)Application.Current;
|
||||
_vm = new MainViewModel(app.Config.Current);
|
||||
DataContext = _vm;
|
||||
ItemsView.DataContext = _vm;
|
||||
GeneralView.DataContext = _vm;
|
||||
AboutView.DataContext = _vm;
|
||||
Icon = AppIcon.GetWindowIcon();
|
||||
|
||||
_vm.Changed += OnConfigChanged;
|
||||
_saveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||
_saveTimer.Tick += (_, _) =>
|
||||
{
|
||||
_saveTimer.Stop();
|
||||
SaveAndApply();
|
||||
};
|
||||
NotifyHotkeyStatus();
|
||||
_ready = true;
|
||||
}
|
||||
|
||||
private void OnConfigChanged()
|
||||
{
|
||||
if (!_ready) return;
|
||||
_saveTimer.Stop();
|
||||
_saveTimer.Start();
|
||||
}
|
||||
|
||||
private void SaveAndApply()
|
||||
{
|
||||
var app = (App)Application.Current;
|
||||
app.Config.Save();
|
||||
app.ApplyAll();
|
||||
_vm.SaveStatus = $"已保存 {DateTime.Now:HH:mm:ss}";
|
||||
NotifyHotkeyStatus();
|
||||
}
|
||||
|
||||
public void NotifyHotkeyStatus()
|
||||
{
|
||||
var app = (App)Application.Current;
|
||||
if (!app.Hotkeys.IsRegistered)
|
||||
{
|
||||
var binding = app.Config.Current.General.Hotkey;
|
||||
_vm.HotkeyStatus = binding.IsValid
|
||||
? "快捷键注册失败,可能与其他程序冲突"
|
||||
: "未设置快捷键(可在左侧系统设置中录制)";
|
||||
}
|
||||
else
|
||||
{
|
||||
_vm.HotkeyStatus = "快捷键已注册";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>挂接单实例广播消息:收到后唤起本窗口</summary>
|
||||
public void HookShowSettingsMessage(uint message)
|
||||
{
|
||||
var helper = new WindowInteropHelper(this);
|
||||
helper.EnsureHandle(); // 窗口尚未显示时强制创建 HWND
|
||||
var hwnd = helper.Handle;
|
||||
HwndSource.FromHwnd(hwnd)?.AddHook((IntPtr h, int msg, IntPtr w, IntPtr l, ref bool handled) =>
|
||||
{
|
||||
if (msg == (int)message)
|
||||
{
|
||||
Dispatcher.BeginInvoke(ShowSettingsAndActivate);
|
||||
handled = true;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
});
|
||||
}
|
||||
|
||||
public void ShowSettingsAndActivate()
|
||||
{
|
||||
Show();
|
||||
WindowState = WindowState.Normal;
|
||||
Activate();
|
||||
}
|
||||
|
||||
private void ShowWheelButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
((App)Application.Current).Wheel?.Show();
|
||||
}
|
||||
|
||||
private void NavList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
// XAML 中 IsSelected="True" 会在 InitializeComponent 阶段触发本事件,
|
||||
// 此时子视图字段尚未赋值,需先判空。
|
||||
if (ItemsView == null || GeneralView == null || AboutView == null) return;
|
||||
var tag = (NavList.SelectedItem as ListBoxItem)?.Tag as string;
|
||||
ItemsView.Visibility = tag == "items" ? Visibility.Visible : Visibility.Collapsed;
|
||||
GeneralView.Visibility = tag == "general" ? Visibility.Visible : Visibility.Collapsed;
|
||||
AboutView.Visibility = tag == "about" ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
protected override void OnClosing(CancelEventArgs e)
|
||||
{
|
||||
var app = (App)Application.Current;
|
||||
if (!app.IsExitRequested)
|
||||
{
|
||||
// 关闭 = 隐藏到托盘
|
||||
e.Cancel = true;
|
||||
SaveAndApply();
|
||||
Hide();
|
||||
return;
|
||||
}
|
||||
SaveAndApply();
|
||||
base.OnClosing(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Window x:Class="OneClickRun.Views.RadialMenuWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="轮盘"
|
||||
AllowsTransparency="True" Background="Transparent"
|
||||
WindowStyle="None" ResizeMode="NoResize" ShowInTaskbar="False"
|
||||
Topmost="True" ShowActivated="False" Focusable="False"
|
||||
WindowStartupLocation="Manual"
|
||||
Width="536" Height="536">
|
||||
<Canvas x:Name="Root"/>
|
||||
</Window>
|
||||
@@ -0,0 +1,256 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using Path = System.Windows.Shapes.Path;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Models;
|
||||
using SelectionMode = OneClickRun.Models.SelectionMode;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 快捷轮盘窗口:透明背景 + 亚克力风格扇形(等间隙几何)。
|
||||
/// 窗口尺寸 = 2 × (外半径 + 阴影边距),画布中心 CanvasCenter 始终位于窗口中心。
|
||||
/// </summary>
|
||||
public partial class RadialMenuWindow : Window
|
||||
{
|
||||
public const double InnerRadius = 70;
|
||||
public const double OuterRadius = 220;
|
||||
public const double GapWidth = 10;
|
||||
public const double FramePadding = 48;
|
||||
public static readonly Point CanvasCenter = new(OuterRadius + FramePadding, OuterRadius + FramePadding);
|
||||
|
||||
private IReadOnlyList<WheelItem> _items = Array.Empty<WheelItem>();
|
||||
private readonly List<Path> _sectors = new();
|
||||
private SelectionMode _selectionMode = SelectionMode.SingleClick;
|
||||
private int _hovered = -1;
|
||||
private TextBlock? _hubHint;
|
||||
private string _hubDefaultHint = string.Empty;
|
||||
|
||||
public RadialMenuWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>单击/双击选择时触发(滑动选择由 WheelWindowManager 在松开时命中测试)</summary>
|
||||
public event Action<int>? SectorPicked;
|
||||
|
||||
public SelectionMode SelectionMode
|
||||
{
|
||||
get => _selectionMode;
|
||||
set => _selectionMode = value;
|
||||
}
|
||||
|
||||
public void ShowWheel(IReadOnlyList<WheelItem> items, string hubHint, Point screenCenterDip)
|
||||
{
|
||||
_items = items;
|
||||
_hovered = -1;
|
||||
Root.Children.Clear();
|
||||
_sectors.Clear();
|
||||
|
||||
var count = Math.Max(1, items.Count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var sector = new Path
|
||||
{
|
||||
Data = WheelGeometry.CreateSectorGeometry(CanvasCenter, InnerRadius, OuterRadius, GapWidth, i, count),
|
||||
Fill = GetSectorBrush(i),
|
||||
Stroke = (Brush)TryFindResource("SectorBorderBrush") ?? Brushes.Transparent,
|
||||
StrokeThickness = 1,
|
||||
Tag = i,
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
sector.MouseEnter += Sector_MouseEnter;
|
||||
sector.MouseLeave += Sector_MouseLeave;
|
||||
sector.MouseLeftButtonDown += Sector_MouseLeftButtonDown;
|
||||
sector.MouseLeftButtonUp += Sector_MouseLeftButtonUp;
|
||||
Root.Children.Add(sector);
|
||||
_sectors.Add(sector);
|
||||
AddLabel(items[i], i, count);
|
||||
}
|
||||
AddHub(hubHint);
|
||||
|
||||
Left = screenCenterDip.X - (OuterRadius + FramePadding);
|
||||
Top = screenCenterDip.Y - (OuterRadius + FramePadding);
|
||||
}
|
||||
|
||||
/// <summary>内部调试:模拟悬停指定扇形(用于快照验证中心文本居中)</summary>
|
||||
internal void SimulateHover(int index) => Highlight(index);
|
||||
|
||||
/// <summary>滑动选择命中测试:屏幕坐标(DIP) → 扇形索引,间隙/中心/圆环外返回 null</summary>
|
||||
public int? HitTestScreenDip(Point screenDip)
|
||||
{
|
||||
var client = PointFromScreen(screenDip);
|
||||
return WheelGeometry.HitTest(client, CanvasCenter, InnerRadius, OuterRadius, GapWidth, Math.Max(1, _items.Count));
|
||||
}
|
||||
|
||||
private static Brush GetSectorBrush(int index) =>
|
||||
Application.Current?.TryFindResource($"SectorBrush{index % 12}") as Brush ?? Brushes.CornflowerBlue;
|
||||
|
||||
private static string GlyphFor(WheelItemType type) => type switch
|
||||
{
|
||||
WheelItemType.App => "\uE768",
|
||||
WheelItemType.Folder => "\uE8B7",
|
||||
WheelItemType.Url => "\uE774",
|
||||
WheelItemType.Script => "\uE756",
|
||||
_ => "\uE768",
|
||||
};
|
||||
|
||||
private void AddLabel(WheelItem item, int index, int count)
|
||||
{
|
||||
var mid = WheelGeometry.SectorMidPoint(CanvasCenter, InnerRadius, OuterRadius, index, count);
|
||||
var panel = new StackPanel { IsHitTestVisible = false, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
|
||||
var glyph = new TextBlock
|
||||
{
|
||||
Text = GlyphFor(item.Type),
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 20,
|
||||
Foreground = (Brush)TryFindResource("SectorGlyphBrush") ?? Brushes.White,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 0, 2),
|
||||
};
|
||||
var name = new TextBlock
|
||||
{
|
||||
Text = TrimName(item.Name),
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = (Brush)TryFindResource("SectorTextBrush") ?? Brushes.White,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxWidth = LabelMaxWidth(count),
|
||||
};
|
||||
panel.Children.Add(glyph);
|
||||
panel.Children.Add(name);
|
||||
panel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||
var w = panel.DesiredSize.Width;
|
||||
var h = panel.DesiredSize.Height;
|
||||
|
||||
// 按屏幕方向(水平正向)显示:图标在上、名称在下,不做旋转,便于阅读
|
||||
Canvas.SetLeft(panel, mid.X - w / 2);
|
||||
Canvas.SetTop(panel, mid.Y - h / 2);
|
||||
Root.Children.Add(panel);
|
||||
}
|
||||
|
||||
/// <summary>标签最大宽度:扇形较多时收窄,避免相邻扇形文字重叠</summary>
|
||||
private static double LabelMaxWidth(int count)
|
||||
{
|
||||
var midRadius = (InnerRadius + OuterRadius) / 2;
|
||||
var chord = 2 * midRadius * Math.Sin(Math.PI / count); // 相邻扇形中点间距
|
||||
return Math.Max(48, Math.Min(OuterRadius - InnerRadius - GapWidth - 8, chord - 8));
|
||||
}
|
||||
|
||||
private void AddHub(string hubHint)
|
||||
{
|
||||
_hubDefaultHint = hubHint;
|
||||
var hubDiameter = 2 * (InnerRadius - 16);
|
||||
var hub = new Ellipse
|
||||
{
|
||||
Width = hubDiameter,
|
||||
Height = hubDiameter,
|
||||
Fill = (Brush)TryFindResource("HubBackgroundBrush") ?? Brushes.White,
|
||||
Stroke = (Brush)TryFindResource("HubStrokeBrush") ?? Brushes.Gray,
|
||||
StrokeThickness = 1,
|
||||
};
|
||||
Canvas.SetLeft(hub, CanvasCenter.X - hubDiameter / 2);
|
||||
Canvas.SetTop(hub, CanvasCenter.Y - hubDiameter / 2);
|
||||
Root.Children.Add(hub);
|
||||
|
||||
// 固定尺寸的居中容器:悬停时提示文本长度变化,容器位置不变,内容始终整体居中
|
||||
var container = new Grid
|
||||
{
|
||||
Width = hubDiameter,
|
||||
Height = hubDiameter,
|
||||
IsHitTestVisible = false,
|
||||
};
|
||||
var panel = new StackPanel
|
||||
{
|
||||
IsHitTestVisible = false,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
MaxWidth = hubDiameter - 20,
|
||||
};
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "一键运行",
|
||||
FontSize = 16,
|
||||
FontWeight = FontWeights.Bold,
|
||||
Foreground = (Brush)TryFindResource("HubTextBrush") ?? Brushes.Black,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
});
|
||||
_hubHint = new TextBlock
|
||||
{
|
||||
Text = hubHint,
|
||||
FontSize = 11,
|
||||
Foreground = (Brush)TryFindResource("HubHintBrush") ?? Brushes.Gray,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
Margin = new Thickness(0, 2, 0, 0),
|
||||
};
|
||||
panel.Children.Add(_hubHint);
|
||||
container.Children.Add(panel);
|
||||
Canvas.SetLeft(container, CanvasCenter.X - hubDiameter / 2);
|
||||
Canvas.SetTop(container, CanvasCenter.Y - hubDiameter / 2);
|
||||
Root.Children.Add(container);
|
||||
}
|
||||
|
||||
private void Sector_MouseEnter(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is Path path && path.Tag is int index)
|
||||
Highlight(index);
|
||||
}
|
||||
|
||||
private void Sector_MouseLeave(object sender, MouseEventArgs e) => Unhighlight();
|
||||
|
||||
private void Sector_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not Path path || path.Tag is not int index) return;
|
||||
Highlight(index);
|
||||
if (_selectionMode == SelectionMode.DoubleClick && e.ClickCount == 2)
|
||||
Pick(index);
|
||||
}
|
||||
|
||||
private void Sector_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not Path path || path.Tag is not int index) return;
|
||||
if (_selectionMode == SelectionMode.SingleClick)
|
||||
Pick(index);
|
||||
}
|
||||
|
||||
private void Pick(int index) => SectorPicked?.Invoke(index);
|
||||
|
||||
private void Highlight(int index)
|
||||
{
|
||||
if (_hovered >= 0 && _hovered < _sectors.Count)
|
||||
{
|
||||
_sectors[_hovered].Stroke = (Brush)TryFindResource("SectorBorderBrush") ?? Brushes.Transparent;
|
||||
_sectors[_hovered].StrokeThickness = 1;
|
||||
}
|
||||
_hovered = index;
|
||||
if (index < 0 || index >= _sectors.Count) return;
|
||||
_sectors[index].Stroke = (Brush)TryFindResource("SectorHoverStrokeBrush") ?? Brushes.White;
|
||||
_sectors[index].StrokeThickness = 2.5;
|
||||
if (_hubHint != null && index < _items.Count)
|
||||
_hubHint.Text = TrimName(_items[index].Name);
|
||||
}
|
||||
|
||||
private void Unhighlight()
|
||||
{
|
||||
if (_hovered >= 0 && _hovered < _sectors.Count)
|
||||
{
|
||||
_sectors[_hovered].Stroke = (Brush)TryFindResource("SectorBorderBrush") ?? Brushes.Transparent;
|
||||
_sectors[_hovered].StrokeThickness = 1;
|
||||
}
|
||||
_hovered = -1;
|
||||
if (_hubHint != null)
|
||||
_hubHint.Text = _hubDefaultHint;
|
||||
}
|
||||
|
||||
private static string TrimName(string name) =>
|
||||
string.IsNullOrWhiteSpace(name) ? "未命名" : name.Length <= 10 ? name : name[..10] + "…";
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<UserControl x:Class="OneClickRun.Views.SettingsGeneralView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:OneClickRun.Controls">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="系统设置" Style="{StaticResource SectionTitle}" Margin="0,0,0,2"/>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,0,0,14"
|
||||
Text="所有修改即时生效并自动保存。"/>
|
||||
|
||||
<TextBlock Text="常规" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<CheckBox Content="开机启动(登录 Windows 后自动在后台运行)"
|
||||
IsChecked="{Binding General.AutoStart}" Margin="0,0,0,10"/>
|
||||
<CheckBox Content="启用轮盘(关闭后全局快捷键不再呼出轮盘)"
|
||||
IsChecked="{Binding General.WheelEnabled}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="轮盘显示位置" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton GroupName="position" Content="屏幕中心"
|
||||
IsChecked="{Binding General.WheelPosition, Converter={StaticResource EnumEquals}, ConverterParameter=ScreenCenter}"/>
|
||||
<RadioButton GroupName="position" Content="鼠标位置"
|
||||
IsChecked="{Binding General.WheelPosition, Converter={StaticResource EnumEquals}, ConverterParameter=Mouse}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="全局快捷键" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<controls:HotkeyRecorder Width="200"
|
||||
HotkeyText="{Binding General.HotkeyText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<Button Grid.Column="1" Content="清空" Style="{StaticResource SecondaryButton}"
|
||||
Margin="10,0,0,0" Click="ClearHotkey_Click"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding HotkeyStatus}" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" Margin="14,0,0,0"
|
||||
TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,10,0,0"
|
||||
Text="点击输入框后按下组合键即可录制,例如 Ctrl+Alt+Space;清空表示不注册快捷键。"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="呼出轮盘方式" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton GroupName="trigger" Content="点击显示"
|
||||
IsChecked="{Binding General.TriggerMode, Converter={StaticResource EnumEquals}, ConverterParameter=Click}"/>
|
||||
<RadioButton GroupName="trigger" Content="长按显示"
|
||||
IsChecked="{Binding General.TriggerMode, Converter={StaticResource EnumEquals}, ConverterParameter=LongPress}"/>
|
||||
<RadioButton GroupName="trigger" Content="按住显示"
|
||||
IsChecked="{Binding General.TriggerMode, Converter={StaticResource EnumEquals}, ConverterParameter=Hold}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
|
||||
<TextBlock Text="长按阈值" VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Slider Minimum="300" Maximum="1500" Width="220" Margin="12,0,10,0"
|
||||
Value="{Binding General.LongPressMs}"
|
||||
IsEnabled="{Binding General.TriggerMode, Converter={StaticResource EnumEquals}, ConverterParameter=LongPress}"/>
|
||||
<TextBlock Text="{Binding General.LongPressMs, StringFormat={}{0} 毫秒}"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,10,0,0"
|
||||
Text="点击显示:按一次快捷键显示,再按一次隐藏;长按显示:按住超过阈值后显示;按住显示:按住时显示、松开即隐藏(可与滑动选择配合)。"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="指令选择方式" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton GroupName="selection" Content="单击选择"
|
||||
IsChecked="{Binding General.SelectionMode, Converter={StaticResource EnumEquals}, ConverterParameter=SingleClick}"/>
|
||||
<RadioButton GroupName="selection" Content="双击选择"
|
||||
IsChecked="{Binding General.SelectionMode, Converter={StaticResource EnumEquals}, ConverterParameter=DoubleClick}"/>
|
||||
<RadioButton GroupName="selection" Content="滑动选择"
|
||||
IsChecked="{Binding General.SelectionMode, Converter={StaticResource EnumEquals}, ConverterParameter=Swipe}"
|
||||
IsEnabled="{Binding General.TriggerMode, Converter={StaticResource EnumEquals}, ConverterParameter=Hold}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,10,0,0"
|
||||
Text="滑动选择仅在“按住显示”时可用:按住快捷键移动鼠标,松开时执行鼠标所在方位的指令。"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="外观主题" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton GroupName="theme" Content="日间模式"
|
||||
IsChecked="{Binding General.Theme, Converter={StaticResource EnumEquals}, ConverterParameter=Light}"/>
|
||||
<RadioButton GroupName="theme" Content="黑夜模式"
|
||||
IsChecked="{Binding General.Theme, Converter={StaticResource EnumEquals}, ConverterParameter=Dark}"/>
|
||||
<RadioButton GroupName="theme" Content="跟随系统"
|
||||
IsChecked="{Binding General.Theme, Converter={StaticResource EnumEquals}, ConverterParameter=System}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using OneClickRun.ViewModels;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
public partial class SettingsGeneralView : UserControl
|
||||
{
|
||||
public SettingsGeneralView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void ClearHotkey_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
((MainViewModel)DataContext).General.HotkeyText = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Window x:Class="OneClickRun.Views.ToastWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="提示"
|
||||
AllowsTransparency="True" Background="Transparent"
|
||||
WindowStyle="None" ResizeMode="NoResize" ShowInTaskbar="False"
|
||||
Topmost="True" ShowActivated="False" Focusable="False"
|
||||
WindowStartupLocation="Manual" SizeToContent="WidthAndHeight"
|
||||
IsHitTestVisible="False">
|
||||
<Border x:Name="Root" CornerRadius="10" MaxWidth="440"
|
||||
Background="{DynamicResource ToastBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource ToastBorderBrush}" BorderThickness="1"
|
||||
Padding="18,12">
|
||||
<TextBlock x:Name="MessageText" TextWrapping="Wrap" FontSize="13"
|
||||
Foreground="{DynamicResource ToastTextBrush}"/>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Animation;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
public partial class ToastWindow : Window
|
||||
{
|
||||
public ToastWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Opacity = 0;
|
||||
}
|
||||
|
||||
public void ShowMessage(string message)
|
||||
{
|
||||
MessageText.Text = message;
|
||||
UpdateLayout();
|
||||
var workArea = SystemParameters.WorkArea;
|
||||
Left = workArea.Right - ActualWidth - 20;
|
||||
Top = workArea.Bottom - ActualHeight - 20;
|
||||
|
||||
BeginAnimation(OpacityProperty, null);
|
||||
Opacity = 0;
|
||||
var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(160));
|
||||
var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(420))
|
||||
{
|
||||
BeginTime = TimeSpan.FromMilliseconds(3600),
|
||||
};
|
||||
fadeOut.Completed += (_, _) => { };
|
||||
BeginAnimation(OpacityProperty, fadeIn);
|
||||
BeginAnimation(OpacityProperty, fadeOut);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<UserControl x:Class="OneClickRun.Views.WheelItemsView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Grid.Row="0" Margin="0,0,0,6">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="轮盘指令" Style="{StaticResource SectionTitle}" Margin="0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Items.Count, StringFormat=共 {0} 个}"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="+ 新建指令" Style="{StaticResource PrimaryButton}" Click="Add_Click"/>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="1" Style="{StaticResource BodyText}" Margin="0,0,0,12"
|
||||
Text="指令按列表顺序在轮盘上顺时针排布,1–12 个指令效果最佳。"/>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Items}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,2,10" Padding="16,12">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" Margin="0,0,12,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
|
||||
<Border Background="{Binding Type, Converter={StaticResource EnumBadge}}"
|
||||
CornerRadius="10" Padding="8,2" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Type, Converter={StaticResource EnumDisplay}}"
|
||||
FontSize="11" Foreground="White"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Path}" Style="{StaticResource BodyText}" Margin="0,6,0,0"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding Args, StringFormat=参数:{0}}" Style="{StaticResource BodyText}"
|
||||
Margin="0,3,0,0"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Content="↑" Style="{StaticResource IconButton}" ToolTip="上移" Click="MoveUp_Click"/>
|
||||
<Button Content="↓" Style="{StaticResource IconButton}" ToolTip="下移" Margin="6,0,0,0" Click="MoveDown_Click"/>
|
||||
<Button Content="编辑" Style="{StaticResource SecondaryButton}" Margin="6,0,0,0" Click="Edit_Click"/>
|
||||
<Button Content="删除" Style="{StaticResource DangerButton}" Margin="6,0,0,0" Click="Delete_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<TextBlock Text="暂无指令,点击右上角“新建指令”添加"
|
||||
Visibility="{Binding Items.Count, Converter={StaticResource CountToVisibility}}"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using OneClickRun.Controls;
|
||||
using OneClickRun.Models;
|
||||
using OneClickRun.ViewModels;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
public partial class WheelItemsView : UserControl
|
||||
{
|
||||
public WheelItemsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private MainViewModel Vm => (MainViewModel)DataContext;
|
||||
|
||||
private void Add_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var editor = new WheelItemEditor { Owner = Window.GetWindow(this) };
|
||||
if (editor.ShowDialog() == true && editor.Result != null)
|
||||
Vm.AddItem(editor.Result);
|
||||
}
|
||||
|
||||
private void Edit_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is not WheelItem item) return;
|
||||
var editor = new WheelItemEditor(item.Clone()) { Owner = Window.GetWindow(this) };
|
||||
if (editor.ShowDialog() == true && editor.Result != null)
|
||||
Vm.ReplaceItem(item, editor.Result);
|
||||
}
|
||||
|
||||
private void Delete_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is not WheelItem item) return;
|
||||
var answer = MessageBox.Show(Window.GetWindow(this),
|
||||
$"确定删除指令「{item.Name}」吗?", "一键运行",
|
||||
MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
if (answer == MessageBoxResult.Yes)
|
||||
Vm.RemoveItem(item);
|
||||
}
|
||||
|
||||
private void MoveUp_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is WheelItem item)
|
||||
Vm.MoveItem(item, -1);
|
||||
}
|
||||
|
||||
private void MoveDown_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is WheelItem item)
|
||||
Vm.MoveItem(item, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="OneClickRun.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / Windows 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,11 @@
|
||||
# 一键运行示例脚本(由 PowerShell 7.6.5 执行)
|
||||
# 你可以把任意 .ps1 脚本添加为轮盘指令
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ========================================"
|
||||
Write-Host " 你好,一键运行!"
|
||||
Write-Host " 当前时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||
Write-Host " 当前目录: $PWD"
|
||||
Write-Host " ========================================"
|
||||
Write-Host ""
|
||||
Read-Host -Prompt "按回车键退出"
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using OneClickRun.Models;
|
||||
using OneClickRun.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace OneClickRun.Tests;
|
||||
|
||||
public class ConfigServiceTests
|
||||
{
|
||||
private static string NewTempDir() =>
|
||||
Path.Combine(Path.GetTempPath(), "OneClickRunTests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoadRoundTrip()
|
||||
{
|
||||
var dir = NewTempDir();
|
||||
try
|
||||
{
|
||||
var service = new ConfigService(dir);
|
||||
service.Load(); // 首次运行生成默认配置
|
||||
Assert.True(service.WasFirstRun);
|
||||
Assert.Equal(4, service.Current.Items.Count);
|
||||
|
||||
service.Current.General.Theme = ThemeMode.Dark;
|
||||
service.Current.General.TriggerMode = TriggerMode.Hold;
|
||||
service.Current.General.SelectionMode = SelectionMode.Swipe;
|
||||
service.Current.General.Hotkey = new HotkeyBinding { Modifiers = { "Ctrl", "Shift" }, Key = "F9" };
|
||||
service.Current.Items[0].Name = "改名后的指令";
|
||||
service.Save();
|
||||
|
||||
var reloaded = new ConfigService(dir);
|
||||
reloaded.Load();
|
||||
Assert.False(reloaded.WasFirstRun);
|
||||
Assert.Equal(ThemeMode.Dark, reloaded.Current.General.Theme);
|
||||
Assert.Equal(TriggerMode.Hold, reloaded.Current.General.TriggerMode);
|
||||
Assert.Equal(SelectionMode.Swipe, reloaded.Current.General.SelectionMode);
|
||||
Assert.Equal("Ctrl+Shift+F9", reloaded.Current.General.HotkeyText);
|
||||
Assert.Equal("改名后的指令", reloaded.Current.Items[0].Name);
|
||||
Assert.Equal(service.Current.Items.Count, reloaded.Current.Items.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorruptConfigIsBackedUpAndRebuilt()
|
||||
{
|
||||
var dir = NewTempDir();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "config.json");
|
||||
File.WriteAllText(path, "{ 这不是合法的 JSON");
|
||||
|
||||
var service = new ConfigService(dir);
|
||||
service.Load();
|
||||
|
||||
Assert.True(File.Exists(path));
|
||||
Assert.Contains("\"General\"", File.ReadAllText(path));
|
||||
Assert.Single(Directory.GetFiles(dir, "config.broken-*.json"));
|
||||
Assert.True(service.WasFirstRun);
|
||||
Assert.NotNull(service.Current.General);
|
||||
Assert.NotNull(service.Current.Items);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingFieldsAreRepaired()
|
||||
{
|
||||
var dir = NewTempDir();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
// General 缺失、Items 缺失、item 内字段缺失
|
||||
var json = JsonSerializer.Serialize(new { version = 1 });
|
||||
File.WriteAllText(Path.Combine(dir, "config.json"), json);
|
||||
|
||||
var service = new ConfigService(dir);
|
||||
service.Load();
|
||||
|
||||
Assert.False(service.WasFirstRun);
|
||||
Assert.NotNull(service.Current.General);
|
||||
Assert.NotNull(service.Current.General.Hotkey);
|
||||
Assert.Empty(service.Current.Items);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumValuesAreSerializedAsStrings()
|
||||
{
|
||||
var service = new ConfigService(NewTempDir());
|
||||
service.Load();
|
||||
service.Current.General.Theme = ThemeMode.System;
|
||||
service.Current.Items.Add(new WheelItem { Type = WheelItemType.Script, Name = "测试", Path = "a.ps1" });
|
||||
service.Save();
|
||||
|
||||
var json = File.ReadAllText(service.ConfigPath);
|
||||
Assert.Contains("\"System\"", json);
|
||||
Assert.Contains("\"Script\"", json);
|
||||
try { Directory.Delete(service.ConfigDirectory, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace OneClickRun.Tests;
|
||||
|
||||
public class HotkeyFormatTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Ctrl+Alt+Space")]
|
||||
[InlineData("Ctrl+Shift+F8")]
|
||||
[InlineData("Win+D")]
|
||||
[InlineData("F8")]
|
||||
[InlineData("Alt+F4")]
|
||||
public void FormatAndParseRoundTrip(string text)
|
||||
{
|
||||
var binding = HotkeyFormat.TryParse(text);
|
||||
Assert.NotNull(binding);
|
||||
Assert.Equal(text, HotkeyFormat.Format(binding));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModifierOrderIsCanonical()
|
||||
{
|
||||
var binding = new HotkeyBinding { Modifiers = { "Alt", "Ctrl", "Shift" }, Key = "Space" };
|
||||
Assert.Equal("Ctrl+Alt+Shift+Space", HotkeyFormat.Format(binding));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRejectsInvalidInput()
|
||||
{
|
||||
Assert.Null(HotkeyFormat.TryParse("Ctrl")); // 缺少主键
|
||||
Assert.Null(HotkeyFormat.TryParse("Ctrl+Xxx")); // 非法键名
|
||||
Assert.Null(HotkeyFormat.TryParse("Bla+Space")); // 非法修饰键
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyInputReturnsInvalidBinding()
|
||||
{
|
||||
var binding = HotkeyFormat.TryParse(" ");
|
||||
Assert.NotNull(binding);
|
||||
Assert.False(binding.IsValid);
|
||||
Assert.Equal(string.Empty, HotkeyFormat.Format(binding));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualKeyMapping()
|
||||
{
|
||||
Assert.Equal(0x20, HotkeyFormat.VirtualKeyOf("Space")); // VK_SPACE
|
||||
Assert.Equal(0x74, HotkeyFormat.VirtualKeyOf("F5")); // VK_F5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseWPF>true</UseWPF>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\OneClickRun\OneClickRun.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
using OneClickRun.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace OneClickRun.Tests;
|
||||
|
||||
public class PowerShellLocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildScriptArgumentsQuotesPathsWithSpaces()
|
||||
{
|
||||
Assert.Equal(
|
||||
"-NoProfile -ExecutionPolicy Bypass -File \"C:\\my scripts\\hello.ps1\"",
|
||||
PowerShellLocator.BuildScriptArguments(@"C:\my scripts\hello.ps1", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildScriptArgumentsAppendsExtraArgs()
|
||||
{
|
||||
Assert.Equal(
|
||||
"-NoProfile -ExecutionPolicy Bypass -File \"D:\\a.ps1\" -Name demo",
|
||||
PowerShellLocator.BuildScriptArguments(@"D:\a.ps1", "-Name demo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindLocatesPwshOnThisMachineOrReturnsNull()
|
||||
{
|
||||
// 开发机已安装 PowerShell 7.6.5;其他环境返回 null 也不算失败
|
||||
var path = PowerShellLocator.Find();
|
||||
if (path != null)
|
||||
{
|
||||
Assert.EndsWith("pwsh.exe", path, System.StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(System.IO.File.Exists(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Windows;
|
||||
using OneClickRun.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace OneClickRun.Tests;
|
||||
|
||||
public class WheelGeometryTests
|
||||
{
|
||||
private const double InnerR = 70;
|
||||
private const double OuterR = 220;
|
||||
private const double Gap = 10;
|
||||
private static readonly Point Center = new(300, 300);
|
||||
|
||||
/// <summary>
|
||||
/// 需求核心:“不论距离中心多远,扇形之间的间距都相等”。
|
||||
/// 相邻扇形在任意半径处,右边界点与左边界点间的距离应恒等于 Gap。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AdjacentSectorGapIsConstantAtEveryRadius()
|
||||
{
|
||||
var radii = new[] { InnerR, 80, 100, 145, 200, OuterR };
|
||||
for (var count = 2; count <= 12; count++)
|
||||
{
|
||||
for (var k = 0; k < count; k++)
|
||||
{
|
||||
var rightEdgeAngle = WheelGeometry.SectorStartAngle(k, count) + WheelGeometry.TwoPi / count;
|
||||
var leftEdgeAngle = WheelGeometry.SectorStartAngle((k + 1) % count, count);
|
||||
// 共享同一条原始径向射线(允许相差 2π 整周期)
|
||||
Assert.Equal(0.0, (rightEdgeAngle - leftEdgeAngle) % WheelGeometry.TwoPi, 12);
|
||||
|
||||
foreach (var rho in radii)
|
||||
{
|
||||
// 扇形 k 的右边界点:角度 - ε(ρ);扇形 k+1 的左边界点:角度 + ε(ρ)
|
||||
var right = WheelGeometry.PointOnCircle(Center, rho, rightEdgeAngle - WheelGeometry.EdgeOffset(rho, Gap));
|
||||
var left = WheelGeometry.PointOnCircle(Center, rho, leftEdgeAngle + WheelGeometry.EdgeOffset(rho, Gap));
|
||||
var distance = (right - left).Length;
|
||||
Assert.True(Math.Abs(distance - Gap) < 1e-9,
|
||||
$"count={count}, sector={k}, rho={rho}: gap={distance}, expected={Gap}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>两个相邻边界点是同一条偏移直线上的点(平行边 ⇒ 处处等距)</summary>
|
||||
[Fact]
|
||||
public void AdjacentEdgePointsLieOnParallelOffsetLines()
|
||||
{
|
||||
const int count = 6;
|
||||
const int sector = 2;
|
||||
var edgeAngle = WheelGeometry.SectorStartAngle(sector, count) + WheelGeometry.TwoPi / count;
|
||||
// 同一偏移线在不同半径上的点应共线:取三点验证叉积为 0
|
||||
var p1 = WheelGeometry.PointOnCircle(Center, 80, edgeAngle + WheelGeometry.EdgeOffset(80, Gap));
|
||||
var p2 = WheelGeometry.PointOnCircle(Center, 145, edgeAngle + WheelGeometry.EdgeOffset(145, Gap));
|
||||
var p3 = WheelGeometry.PointOnCircle(Center, 200, edgeAngle + WheelGeometry.EdgeOffset(200, Gap));
|
||||
var cross = (p2.X - p1.X) * (p3.Y - p1.Y) - (p2.Y - p1.Y) * (p3.X - p1.X);
|
||||
Assert.True(Math.Abs(cross) < 1e-9, $"边界点不共线,叉积={cross}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SectorCornersHaveExactRadii()
|
||||
{
|
||||
for (var count = 1; count <= 12; count++)
|
||||
{
|
||||
for (var k = 0; k < count; k++)
|
||||
{
|
||||
var (p1, p2, p3, p4) = WheelGeometry.SectorCorners(Center, InnerR, OuterR, Gap, k, count);
|
||||
Assert.Equal(InnerR, (p1 - Center).Length, 9);
|
||||
Assert.Equal(OuterR, (p2 - Center).Length, 9);
|
||||
Assert.Equal(OuterR, (p3 - Center).Length, 9);
|
||||
Assert.Equal(InnerR, (p4 - Center).Length, 9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(4)]
|
||||
[InlineData(6)]
|
||||
[InlineData(12)]
|
||||
public void HitTestMapsSectorMidPointsToExpectedSector(int count)
|
||||
{
|
||||
for (var k = 0; k < count; k++)
|
||||
{
|
||||
var mid = WheelGeometry.SectorMidPoint(Center, InnerR, OuterR, k, count);
|
||||
var hit = WheelGeometry.HitTest(mid, Center, InnerR, OuterR, Gap, count);
|
||||
Assert.Equal(k, hit);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitTestReturnsNullForCenterOutsideAndGap()
|
||||
{
|
||||
// 中心区域
|
||||
Assert.Null(WheelGeometry.HitTest(Center, Center, InnerR, OuterR, Gap, 4));
|
||||
// 圆环外
|
||||
Assert.Null(WheelGeometry.HitTest(new Point(Center.X + OuterR + 5, Center.Y), Center, InnerR, OuterR, Gap, 4));
|
||||
// 间隙正中间:原始径向边界角度处
|
||||
var midRadius = (InnerR + OuterR) / 2;
|
||||
var edge = WheelGeometry.SectorStartAngle(1, 4);
|
||||
var inGap = WheelGeometry.PointOnCircle(Center, midRadius, edge);
|
||||
Assert.Null(WheelGeometry.HitTest(inGap, Center, InnerR, OuterR, Gap, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleSectorIsFullRing()
|
||||
{
|
||||
var angles = new[] { -Math.PI / 2, 0.0, Math.PI / 2, Math.PI, Math.PI * 1.5 };
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var point = WheelGeometry.PointOnCircle(Center, (InnerR + OuterR) / 2, angle);
|
||||
Assert.Equal(0, WheelGeometry.HitTest(point, Center, InnerR, OuterR, Gap, 1));
|
||||
}
|
||||
Assert.NotNull(WheelGeometry.CreateSectorGeometry(Center, InnerR, OuterR, Gap, 0, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SectorGeometryIsFrozen()
|
||||
{
|
||||
var geometry = WheelGeometry.CreateSectorGeometry(Center, InnerR, OuterR, Gap, 0, 4);
|
||||
Assert.True(geometry.IsFrozen);
|
||||
Assert.False(geometry.IsEmpty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user