From 2081a938bfdfe840f3b2de643b59412afd8d6120 Mon Sep 17 00:00:00 2001 From: cnb Date: Sun, 30 Aug 2026 15:44:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E2=80=94=E2=80=94=E4=B8=80=E9=94=AE=E8=BF=90=E8=A1=8C=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E8=BD=AE=E7=9B=98=E5=B7=A5=E5=85=B7=EF=BC=88WPF=20+?= =?UTF-8?q?=20C#=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 19 ++ CHANGELOG.md | 35 +++ OneClickRun.sln | 36 +++ README.md | 163 +++++++++++ global.json | 6 + requirements.md | 54 ++++ scripts/analyze-hub.ps1 | 48 ++++ scripts/analyze-labels.ps1 | 70 +++++ scripts/analyze-wheel.ps1 | 41 +++ scripts/single-instance-test.ps1 | 17 ++ scripts/smoke.ps1 | 46 ++++ src/OneClickRun/App.xaml | 18 ++ src/OneClickRun/App.xaml.cs | 213 +++++++++++++++ src/OneClickRun/Controls/HotkeyRecorder.cs | 86 ++++++ src/OneClickRun/Controls/WheelItemEditor.xaml | 54 ++++ .../Controls/WheelItemEditor.xaml.cs | 126 +++++++++ src/OneClickRun/Converters/Converters.cs | 86 ++++++ src/OneClickRun/Helpers/AppIcon.cs | 62 +++++ src/OneClickRun/Helpers/DpiUtil.cs | 36 +++ src/OneClickRun/Helpers/GlobalMouseHook.cs | 57 ++++ src/OneClickRun/Helpers/HotkeyFormat.cs | 58 ++++ src/OneClickRun/Helpers/WheelGeometry.cs | 104 +++++++ src/OneClickRun/Helpers/Win32Interop.cs | 129 +++++++++ src/OneClickRun/Models/AppConfig.cs | 9 + src/OneClickRun/Models/Enums.cs | 16 ++ src/OneClickRun/Models/GeneralSettings.cs | 67 +++++ src/OneClickRun/Models/HotkeyBinding.cs | 12 + src/OneClickRun/Models/WheelItem.cs | 20 ++ src/OneClickRun/OneClickRun.csproj | 33 +++ src/OneClickRun/Properties/AssemblyInfo.cs | 3 + src/OneClickRun/Services/AutostartService.cs | 49 ++++ src/OneClickRun/Services/CommandRunner.cs | 145 ++++++++++ src/OneClickRun/Services/ConfigService.cs | 140 ++++++++++ src/OneClickRun/Services/HotkeyService.cs | 80 ++++++ src/OneClickRun/Services/LogService.cs | 40 +++ src/OneClickRun/Services/PowerShellLocator.cs | 64 +++++ src/OneClickRun/Services/ThemeService.cs | 69 +++++ src/OneClickRun/Services/ToastService.cs | 28 ++ .../Services/WheelWindowManager.cs | 204 ++++++++++++++ src/OneClickRun/Themes/Dark.xaml | 46 ++++ src/OneClickRun/Themes/Light.xaml | 46 ++++ src/OneClickRun/Themes/Styles.xaml | 213 +++++++++++++++ src/OneClickRun/ViewModels/MainViewModel.cs | 95 +++++++ src/OneClickRun/Views/AboutView.xaml | 68 +++++ src/OneClickRun/Views/AboutView.xaml.cs | 54 ++++ src/OneClickRun/Views/HotkeyHostWindow.cs | 29 ++ src/OneClickRun/Views/MainWindow.xaml | 47 ++++ src/OneClickRun/Views/MainWindow.xaml.cs | 126 +++++++++ src/OneClickRun/Views/RadialMenuWindow.xaml | 11 + .../Views/RadialMenuWindow.xaml.cs | 256 ++++++++++++++++++ .../Views/SettingsGeneralView.xaml | 107 ++++++++ .../Views/SettingsGeneralView.xaml.cs | 18 ++ src/OneClickRun/Views/ToastWindow.xaml | 17 ++ src/OneClickRun/Views/ToastWindow.xaml.cs | 33 +++ src/OneClickRun/Views/WheelItemsView.xaml | 71 +++++ src/OneClickRun/Views/WheelItemsView.xaml.cs | 54 ++++ src/OneClickRun/app.manifest | 22 ++ src/OneClickRun/assets/samples/hello.ps1 | 11 + tests/OneClickRun.Tests/ConfigServiceTests.cs | 113 ++++++++ tests/OneClickRun.Tests/HotkeyFormatTests.cs | 52 ++++ .../OneClickRun.Tests.csproj | 24 ++ .../PowerShellLocatorTests.cs | 35 +++ tests/OneClickRun.Tests/WheelGeometryTests.cs | 123 +++++++++ 63 files changed, 4214 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 OneClickRun.sln create mode 100644 README.md create mode 100644 global.json create mode 100644 requirements.md create mode 100644 scripts/analyze-hub.ps1 create mode 100644 scripts/analyze-labels.ps1 create mode 100644 scripts/analyze-wheel.ps1 create mode 100644 scripts/single-instance-test.ps1 create mode 100644 scripts/smoke.ps1 create mode 100644 src/OneClickRun/App.xaml create mode 100644 src/OneClickRun/App.xaml.cs create mode 100644 src/OneClickRun/Controls/HotkeyRecorder.cs create mode 100644 src/OneClickRun/Controls/WheelItemEditor.xaml create mode 100644 src/OneClickRun/Controls/WheelItemEditor.xaml.cs create mode 100644 src/OneClickRun/Converters/Converters.cs create mode 100644 src/OneClickRun/Helpers/AppIcon.cs create mode 100644 src/OneClickRun/Helpers/DpiUtil.cs create mode 100644 src/OneClickRun/Helpers/GlobalMouseHook.cs create mode 100644 src/OneClickRun/Helpers/HotkeyFormat.cs create mode 100644 src/OneClickRun/Helpers/WheelGeometry.cs create mode 100644 src/OneClickRun/Helpers/Win32Interop.cs create mode 100644 src/OneClickRun/Models/AppConfig.cs create mode 100644 src/OneClickRun/Models/Enums.cs create mode 100644 src/OneClickRun/Models/GeneralSettings.cs create mode 100644 src/OneClickRun/Models/HotkeyBinding.cs create mode 100644 src/OneClickRun/Models/WheelItem.cs create mode 100644 src/OneClickRun/OneClickRun.csproj create mode 100644 src/OneClickRun/Properties/AssemblyInfo.cs create mode 100644 src/OneClickRun/Services/AutostartService.cs create mode 100644 src/OneClickRun/Services/CommandRunner.cs create mode 100644 src/OneClickRun/Services/ConfigService.cs create mode 100644 src/OneClickRun/Services/HotkeyService.cs create mode 100644 src/OneClickRun/Services/LogService.cs create mode 100644 src/OneClickRun/Services/PowerShellLocator.cs create mode 100644 src/OneClickRun/Services/ThemeService.cs create mode 100644 src/OneClickRun/Services/ToastService.cs create mode 100644 src/OneClickRun/Services/WheelWindowManager.cs create mode 100644 src/OneClickRun/Themes/Dark.xaml create mode 100644 src/OneClickRun/Themes/Light.xaml create mode 100644 src/OneClickRun/Themes/Styles.xaml create mode 100644 src/OneClickRun/ViewModels/MainViewModel.cs create mode 100644 src/OneClickRun/Views/AboutView.xaml create mode 100644 src/OneClickRun/Views/AboutView.xaml.cs create mode 100644 src/OneClickRun/Views/HotkeyHostWindow.cs create mode 100644 src/OneClickRun/Views/MainWindow.xaml create mode 100644 src/OneClickRun/Views/MainWindow.xaml.cs create mode 100644 src/OneClickRun/Views/RadialMenuWindow.xaml create mode 100644 src/OneClickRun/Views/RadialMenuWindow.xaml.cs create mode 100644 src/OneClickRun/Views/SettingsGeneralView.xaml create mode 100644 src/OneClickRun/Views/SettingsGeneralView.xaml.cs create mode 100644 src/OneClickRun/Views/ToastWindow.xaml create mode 100644 src/OneClickRun/Views/ToastWindow.xaml.cs create mode 100644 src/OneClickRun/Views/WheelItemsView.xaml create mode 100644 src/OneClickRun/Views/WheelItemsView.xaml.cs create mode 100644 src/OneClickRun/app.manifest create mode 100644 src/OneClickRun/assets/samples/hello.ps1 create mode 100644 tests/OneClickRun.Tests/ConfigServiceTests.cs create mode 100644 tests/OneClickRun.Tests/HotkeyFormatTests.cs create mode 100644 tests/OneClickRun.Tests/OneClickRun.Tests.csproj create mode 100644 tests/OneClickRun.Tests/PowerShellLocatorTests.cs create mode 100644 tests/OneClickRun.Tests/WheelGeometryTests.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e300d4 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5858bfb --- /dev/null +++ b/CHANGELOG.md @@ -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 验证各扇形文字均为水平正立。 diff --git a/OneClickRun.sln b/OneClickRun.sln new file mode 100644 index 0000000..63e6220 --- /dev/null +++ b/OneClickRun.sln @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bcfd77 --- /dev/null +++ b/README.md @@ -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) diff --git a/global.json b/global.json new file mode 100644 index 0000000..ad4aa5a --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "rollForward": "latestFeature", + "version": "8.0.424" + } +} \ No newline at end of file diff --git a/requirements.md b/requirements.md new file mode 100644 index 0000000..67ec22b --- /dev/null +++ b/requirements.md @@ -0,0 +1,54 @@ +# 一键运行工具开发需求 + +## 概述 + +一键运行工具是采用游戏中常见的快捷轮盘的形式,快速启动软件、打开文件夹、打开链接等快捷指令。 + +## 运行平台与技术栈 + +- 运行平台:Windows平台(Win10,Win11) +- 技术栈:WPF + C# + +## 功能需求 + +- 轮盘:在屏幕上一个圆环区域分割出多个扇形区域,每个区域分配一个操作或指令 +- 轮盘的呼出和隐藏:默认轮盘隐藏,通过指定快捷键呼出轮盘,选择轮盘上的指令后,自动隐藏轮盘 +- 轮盘支持的指令:打开软件、打开指定文件夹、打开指定网站、运行指定的脚本(powershell)等 +- 主界面:提供一个主界面,用于设置软件的各项功能,具体设置参考后续 + +## 快捷指令 + +- 打开软件:指定程序路径 +- 打开文件夹:指定文件夹路径 +- 打开网址:以默认浏览器打开指定网址 +- 运行脚本:指定脚本文件 + +## 系统设置 + +系统的设置项包括: +- 开机启动开关 +- 轮盘全局开关:关闭时,不再呼出轮盘 +- 轮盘显示位置:可选项包括: + - 屏幕中心:呼出轮盘时在屏幕中心显示 + - 鼠标位置:呼出轮盘时在鼠标位置显示 +- 全局快捷键:呼出轮盘的快捷键,全局有效 +- 呼出轮盘操作: + - 点击显示:按一次快捷键即显示轮盘 + - 长按显示:长按快捷键一定时间显示轮盘 + - 按住显示:按住快捷键时显示轮盘,松开即隐藏 +- 轮盘指令选择操作: + - 单击选择:单击轮盘指令选择 + - 双击选择:双击轮盘指令选择 + - 滑动选择:选择鼠标滑动方向所指的指令(仅在按住显示轮盘时有效,以松开快捷键时,鼠标所在轮盘的方位选择指定的指令 +- 日间/黑夜模式切换:日间/黑夜/跟随系统 +- 轮盘指令设置,设置轮盘的各项指令 + +## UI风格 + +- 以现代简约风格为主,支持日间/黑夜模式 +- 轮盘背景设置为透明,轮盘选项(扇形区域)背景设置为亚克力 +- 轮盘扇形之间保留一些间距,注意不论距离中心多远,扇形之间的间距都相等 + +## 其他 + +开发时如需执行PowerShell指令,请使用PowerShell 7.6.5 \ No newline at end of file diff --git a/scripts/analyze-hub.ps1 b/scripts/analyze-hub.ps1 new file mode 100644 index 0000000..8657c22 --- /dev/null +++ b/scripts/analyze-hub.ps1 @@ -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' +} diff --git a/scripts/analyze-labels.ps1 b/scripts/analyze-labels.ps1 new file mode 100644 index 0000000..4ecb917 --- /dev/null +++ b/scripts/analyze-labels.ps1 @@ -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() diff --git a/scripts/analyze-wheel.ps1 b/scripts/analyze-wheel.ps1 new file mode 100644 index 0000000..bb6017e --- /dev/null +++ b/scripts/analyze-wheel.ps1 @@ -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() +} diff --git a/scripts/single-instance-test.ps1 b/scripts/single-instance-test.ps1 new file mode 100644 index 0000000..33c6a1d --- /dev/null +++ b/scripts/single-instance-test.ps1 @@ -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' diff --git a/scripts/smoke.ps1 b/scripts/smoke.ps1 new file mode 100644 index 0000000..f3c6e50 --- /dev/null +++ b/scripts/smoke.ps1 @@ -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' diff --git a/src/OneClickRun/App.xaml b/src/OneClickRun/App.xaml new file mode 100644 index 0000000..30fddba --- /dev/null +++ b/src/OneClickRun/App.xaml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/src/OneClickRun/App.xaml.cs b/src/OneClickRun/App.xaml.cs new file mode 100644 index 0000000..d411654 --- /dev/null +++ b/src/OneClickRun/App.xaml.cs @@ -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(); + 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("启动完成"); + } + + /// 将当前配置应用到运行中的各服务(热键、主题、轮盘开关、开机启动) + 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(); + } +} diff --git a/src/OneClickRun/Controls/HotkeyRecorder.cs b/src/OneClickRun/Controls/HotkeyRecorder.cs new file mode 100644 index 0000000..3a8dd9c --- /dev/null +++ b/src/OneClickRun/Controls/HotkeyRecorder.cs @@ -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; + +/// 快捷键录制控件:点击后按下组合键完成录制 +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"); + } +} diff --git a/src/OneClickRun/Controls/WheelItemEditor.xaml b/src/OneClickRun/Controls/WheelItemEditor.xaml new file mode 100644 index 0000000..296760a --- /dev/null +++ b/src/OneClickRun/Controls/WheelItemEditor.xaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +