From 6eebb26dbca60ad021c28d526672e4093470c7bb Mon Sep 17 00:00:00 2001 From: Rezetyan Date: Mon, 27 Jul 2026 07:08:23 +0800 Subject: [PATCH] Add community Windows GUI configurator Self-contained WinForms configurator for MouseMaster properties files, developed in the Rezetyan/mousemaster fork and released there as configurator v2.1.0. Targets the v89 properties format and preserves any properties and comments it does not manage. - Bilingual Simplified Chinese / English UI following the Windows display language, switchable at runtime. - Rebinds 37 actions in six categories plus four mouse/wheel motion values, with exact-chord conflict resolution and reporting. - Normal-mode entry combos are generated with mutual same-key modifier exclusion (^{...}) so a modifier-free binding never shadows a modified one. - Optional focus mode generates per-mode swallow-all fallback aliases for the eight keyboard-mouse modes; Alt-Tab centering is toggled independently. - Lossless round-trip via comment-based mmcfg metadata, rolling .gui-backup before every save, and restore of the build-time embedded neo-mousekeys-ijkl default. - Builds with the stock .NET Framework csc.exe (no NuGet packages); PerMonitorV2 DPI-aware layout that rescales across monitors. - 305-assertion headless self-test (--self-test) plus an end-to-end focus-mode test that injects keys into a real foreground window driven by a real mousemaster.exe. Co-Authored-By: Claude Fable 5 --- .gitignore | 8 + README.md | 9 + configurator/LICENSE | 21 + configurator/README.md | 299 +++++ configurator/README.zh-CN.md | 258 ++++ configurator/app.manifest | 26 + configurator/build.ps1 | 47 + configurator/src/BindingCatalog.cs | 191 +++ configurator/src/ConfigDocument.cs | 357 +++++ configurator/src/ConfiguratorEngine.cs | 1147 +++++++++++++++++ configurator/src/DpiHelper.cs | 120 ++ configurator/src/KeyCaptureDialog.cs | 581 +++++++++ configurator/src/Localization.cs | 497 +++++++ configurator/src/MainForm.cs | 1017 +++++++++++++++ configurator/src/Models.cs | 463 +++++++ configurator/src/Program.cs | 145 +++ configurator/src/SelfTests.cs | 669 ++++++++++ configurator/src/ThemeAndControls.cs | 590 +++++++++ .../tests/FocusModeIntegrationTest.cs | 316 +++++ 19 files changed, 6761 insertions(+) create mode 100644 configurator/LICENSE create mode 100644 configurator/README.md create mode 100644 configurator/README.zh-CN.md create mode 100644 configurator/app.manifest create mode 100644 configurator/build.ps1 create mode 100644 configurator/src/BindingCatalog.cs create mode 100644 configurator/src/ConfigDocument.cs create mode 100644 configurator/src/ConfiguratorEngine.cs create mode 100644 configurator/src/DpiHelper.cs create mode 100644 configurator/src/KeyCaptureDialog.cs create mode 100644 configurator/src/Localization.cs create mode 100644 configurator/src/MainForm.cs create mode 100644 configurator/src/Models.cs create mode 100644 configurator/src/Program.cs create mode 100644 configurator/src/SelfTests.cs create mode 100644 configurator/src/ThemeAndControls.cs create mode 100644 configurator/tests/FocusModeIntegrationTest.cs diff --git a/.gitignore b/.gitignore index 549e00a2..24b76511 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,11 @@ build/ ### VS Code ### .vscode/ + +### Windows configurator ### +/MouseMasterConfigurator.exe +/MouseMasterConfigurator.pdb +/MouseMasterConfigurator.*.log +/mousemaster.properties +/mousemaster.properties.gui-backup +/configurator/build/ diff --git a/README.md b/README.md index f7e7fe1d..afe10963 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,15 @@ mousemaster lets you control your mouse from the keyboard and remap your keys. configuration. If you have ideas for a better configuration that you would like to share, I'd love to hear from you. +### Optional Windows GUI configurator + +A community-maintained WinForms configurator is available in +[`configurator/`](configurator/README.md). It can edit 37 keyboard and mouse actions, +mouse and wheel motion values, focus-mode input swallowing, and Alt-Tab centering while +preserving any properties and comments it does not manage. The UI is available in +Simplified Chinese and English. The configurator builds with the .NET Framework C# +compiler only (no NuGet packages) and is not part of the official MouseMaster release. + ## Usage ### Default Configuration (neo-mousekeys-ijkl) diff --git a/configurator/LICENSE b/configurator/LICENSE new file mode 100644 index 00000000..f247815a --- /dev/null +++ b/configurator/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rezetyan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/configurator/README.md b/configurator/README.md new file mode 100644 index 00000000..fd13cd3e --- /dev/null +++ b/configurator/README.md @@ -0,0 +1,299 @@ +# MouseMaster Windows GUI Configurator + +[中文说明](README.zh-CN.md) + +A community-maintained Windows GUI for editing MouseMaster properties files. It is not an +official component of the upstream MouseMaster project. The configurator targets the +MouseMaster v89 properties format, is written in .NET Framework WinForms, and has no +third-party NuGet dependencies. The UI is bilingual (Simplified Chinese and English), +follows the Windows display language by default, and can be switched at any time from the +drop-down at the bottom of the sidebar; the choice is stored in +`HKCU\Software\MouseMasterConfigurator`. + +This directory contains the complete source code, the build script and the regression +tests. Compiled EXE, PDB, log and test runtime files are never committed to Git. + +## Feature scope + +- 37 actions in six categories can each be rebound. +- Mouse max velocity, mouse acceleration, wheel max velocity and wheel acceleration can + be edited. +- Depending on the action, single keys, modifier chords, alternate bindings and clearing + are supported. +- When the exact same shortcut already exists in the same mode scope, the new binding is + kept, the old binding is cleared, and the change is reported. +- Saving preserves properties and plain comments the GUI does not manage, including + properties introduced by newer MouseMaster versions. +- Comment-based `mmcfg` metadata records the exact GUI state for lossless reloading. +- A single rolling backup `mousemaster.properties.gui-backup` is written before every + save. +- The default configuration embedded at build time can be restored on demand. +- Focus mode generates per-mode swallow-all fallback rules for the eight keyboard-mouse + modes while preserving each mode's existing non-swallowing combos. +- Alt-Tab auto-centering can be toggled independently and its trigger chord edited. + +The four velocity inputs replace only the default branch of the dynamic properties and +preserve the initial velocity and the slow / fast / super-slow branches. Max velocity +accepts `1..100000`, acceleration `0..100000`; setting an acceleration to 0 disables it. +The GUI does not edit initial velocities, per-modifier branches, colors, grid sizes, +hint density or other numeric parameters. + +## Directory layout + +```text +configurator/ + app.manifest + build.ps1 + LICENSE + README.md + README.zh-CN.md + src/ + BindingCatalog.cs + ConfigDocument.cs + ConfiguratorEngine.cs + DpiHelper.cs + KeyCaptureDialog.cs + Localization.cs + MainForm.cs + Models.cs + Program.cs + SelfTests.cs + ThemeAndControls.cs + tests/ + FocusModeIntegrationTest.cs +``` + +Main responsibilities: + +| File | Responsibility | +|---|---| +| `BindingCatalog.cs` | Six categories, 37 actions, four numeric settings, defaults and conflict scopes | +| `Models.cs` | Shortcut model, numeric settings, state model and conflict resolution | +| `ConfigDocument.cs` | Preserving properties editing, metadata, atomic writes and the embedded default | +| `ConfiguratorEngine.cs` | Converts GUI state into actual MouseMaster properties and per-mode focus fallbacks | +| `DpiHelper.cs` | Converts 96-DPI design metrics to the current monitor DPI | +| `Localization.cs` | Chinese/English text dictionary, current language, registry language preference | +| `KeyCaptureDialog.cs` | Windows key capture and v89 key-name conversion | +| `MainForm.cs` | Main window, key and numeric inputs, save, reload, conflict prompts and restore flow | +| `SelfTests.cs` | Built-in regression tests with no test-framework dependency | +| `FocusModeIntegrationTest.cs` | End-to-end v89 test that injects keys into a real foreground window | + +## Requirements + +- Windows 10 or Windows 11. +- .NET Framework 4.x; currently built and verified on .NET Framework 4.8. +- The 64-bit .NET Framework C# compiler: + `C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe`. +- `configuration/neo-mousekeys-ijkl.properties` must exist in the repository; it is + embedded at build time as the restore-default configuration. +- A MouseMaster v89 `mousemaster.exe` at the repository root is only needed to run the + end-to-end test. + +`app.manifest` uses `asInvoker` (the configurator never requests administrator rights) +and declares Per-Monitor V2 DPI awareness. All layout metrics are designed for 96 DPI +and converted by `DpiHelper` to the current monitor DPI; fonts scale automatically +because they are measured in points, so text is not clipped at 125% / 150% scaling. +Moving the window to a monitor with a different DPI rebuilds the layout for the new DPI. + +## Build + +Open PowerShell at the repository root: + +```powershell +Set-Location C:\path\to\mousemaster +.\configurator\build.ps1 +``` + +The build script: + +1. Compiles `configurator/src/*.cs` ordered by file name. +2. References the System, Drawing and WinForms assemblies shipped with .NET Framework. +3. Embeds `configuration/neo-mousekeys-ijkl.properties` as the + `MouseMasterConfigurator.DefaultProperties` resource. +4. Produces at the repository root: + +```text +MouseMasterConfigurator.exe +MouseMasterConfigurator.pdb +``` + +The build never modifies any properties file. Both artifacts are covered by the root +`.gitignore`. + +## Run + +By default the configurator opens `mousemaster.properties` next to the EXE. During +development, copy a configuration first so the tracked samples are not modified: + +```powershell +Copy-Item .\configuration\neo-mousekeys-ijkl.properties .\mousemaster.properties +.\MouseMasterConfigurator.exe +``` + +Open a specific configuration: + +```powershell +.\MouseMasterConfigurator.exe --config "D:\path\to\mousemaster.properties" +``` + +The target file must already exist. The configurator only writes to the given +properties file; it does not start MouseMaster. The development copies +`mousemaster.properties` and its `.gui-backup` at the repository root are ignored by +Git. + +## Built-in self-tests + +The WinForms executable provides a headless self-test entry point: + +```powershell +.\MouseMasterConfigurator.exe --self-test ` + .\configurator\build\self-test.log ` + .\configurator\build\acceptance.properties +``` + +The arguments are: + +1. The required `--self-test` flag. +2. An optional test report path; defaults to the executable directory. +3. An optional acceptance configuration path, written only when every assertion passes. + +Because the main program is a WinForms `winexe`, automation scripts should explicitly +wait for the process to exit and then check the exit code and the log. Current +coverage: + +- Customized Vim movement keys, `Ctrl+M` activation and four-direction wheel output. +- Chord parsing, left/right modifiers, accessibility display text and the F24 + reservation rule. +- Complete conflict reporting and "new binding wins" behavior. +- All 37 catalog actions change the real configuration text. +- Import, range checking, dynamic-branch preservation, metadata round-trip and + initial-velocity protection for the four velocity settings. +- v89 special key names and layout-independent numpad aliases. +- Focus mode's eight per-mode fallback aliases, non-swallowing combo exclusion, and + cleanup plus macro restoration after focus mode is turned off. +- Metadata round-trip, property uniqueness and non-blank checks. +- Atomic restore, byte-identical default content and `.gui-backup` contents. +- Title/description spacing, focus description/toggle spacing, opaque toggle painting + and the layout of the four numeric inputs. +- Instant Chinese/English UI switching, post-switch title and navigation text, and a + text-fit check of every fixed-size label in both languages (guarding against + high-DPI or long-text clipping). + +On success the last log line looks like: + +```text +PASS: 305 assertions +``` + +## v89 focus-mode integration test + +`FocusModeIntegrationTest.cs` starts a real `mousemaster.exe`, creates an input +receiver window and verifies: + +1. `X` in idle mode reaches the active application. +2. `Ctrl+M` enters keyboard-mouse mode. +3. `F2` still reaches the active application and switches back to idle mode as a + configured pass-through exit combo. +4. The `B` after `F2` reaches the application, proving the mode switch happened. +5. After entering again, letters, digits and `Ctrl+S` are all swallowed. +6. After `Q` exits, `B` reaches the active application again. + +`Ctrl+M` here is the activation key of the isolated acceptance configuration produced +by the self-test, not the default activation key of the official +`neo-mousekeys-ijkl` configuration. + +Compile the test: + +```powershell +$compiler = 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe' +& $compiler /nologo /target:exe /platform:anycpu /optimize+ ` + /out:.\configurator\build\FocusModeIntegrationTest.exe ` + /r:System.dll /r:System.Drawing.dll /r:System.Windows.Forms.dll ` + .\configurator\tests\FocusModeIntegrationTest.cs +``` + +The test must use an isolated directory in which the configuration file is named +`mousemaster.properties`. Do not overwrite the production configuration at the +repository root with the automated acceptance configuration: + +```powershell +$runtime = '.\configurator\build\v89-runtime' +New-Item -ItemType Directory -Path $runtime -Force | Out-Null +Copy-Item .\mousemaster.exe "$runtime\mousemaster.exe" -Force +Copy-Item .\configurator\build\acceptance.properties ` + "$runtime\mousemaster.properties" -Force + +.\configurator\build\FocusModeIntegrationTest.exe ` + "$runtime\mousemaster.exe" ` + "$runtime\mousemaster.properties" +``` + +The test briefly brings its receiver window to the foreground and injects keyboard +events; it kills the MouseMaster child process it started when it finishes or fails. +Do not run another MouseMaster instance while it runs. + +Expected output: + +```text +OK: idle-mode input reaches the active application +OK: focus mode preserves a configured pass-through combo +OK: the configured pass-through combo still performs its mode switch +OK: focus mode eats ordinary typing +OK: focus mode eats application shortcuts +OK: input resumes after leaving keyboard-mouse mode +PASS: focus mode preserved configured combos, swallowed unhandled input, and restored input after Q. +``` + +## Save format and compatibility + +The GUI-managed state is written to an `mmcfg` metadata block at the end of the file, +surrounded by comments. MouseMaster ignores this block. Before every save the +configurator removes the old metadata and rebuilds from the latest on-disk content; +unknown properties and plain comments are preserved. + +Cleared MouseMaster aliases use `F24` as an internal placeholder that v89 can parse, +so the capture dialog forbids assigning F24. Numpad keys cannot participate in v89's +`.us-qwerty` cross-layout conversion; the generator automatically migrates aliases +containing `numpad*` or `numlock` to the layout-independent form. + +Focus mode generates a complete swallowing alias per mode for the eight modes, and +excludes from each alias the keys used by that mode's existing `#` non-swallowing +combos and pressed preconditions. The aliases carry no layout suffix, otherwise v89 +would try to convert numpad keys and might reject the whole configuration on an +active layout other than `us-qwerty`. + +Normal-mode entry combos (mode switches, exit / pass-through, navigation macros, +wheel start) are generated with mutual same-key exclusion: a modifier-free binding +(a bare `+key`, which would otherwise match even while modifiers are held) +automatically excludes the modifiers of same-key bindings. For example, with Screen +Hint on `Ctrl+F` and UI Hint on `F`, the generated combos are `_{leftctrl} +f` and +`^{leftctrl} +f`, which can no longer fire together. Bindings that have modifiers are +already protected by the strict `_{...}` semantics (any extra held key breaks the +match) and need no exclusion. + +While focus mode is enabled, the normal-mode properties that send keys to the +operating system (navigate back / forward, arrow-key remapping and the Alt-Tab +listener) are commented out. When focus mode is disabled, the first two are restored, +while Alt-Tab continues to follow its own independent auto-centering toggle. When +Alt-Tab auto-centering is off, the full property chain from the entry point to the +`center-on-active-window-mode` return rules is commented out, avoiding the orphaned +modes that v89 forbids. + +## Pre-release checks + +Run at least the following checks: + +1. Re-run `configurator/build.ps1` and confirm the compiler exit code is 0. +2. Run the full `--self-test` and confirm the report contains no `FAIL`. +3. Start a local v89 with the unmodified acceptance configuration and confirm it + prints `Loaded configuration file mousemaster.properties`. +4. Run the focus-mode integration test and confirm all six `OK` lines. +5. Confirm no MouseMaster, configurator or integration test process is left running. +6. Confirm `git status` shows no EXE, PDB, personal properties, backup, log or test + runtime files. + +## License + +The new configurator source code in `configurator/` is licensed under the +[MIT License](LICENSE). This license only covers the new content in this directory +and does not change the licensing of the other upstream files in the repository. diff --git a/configurator/README.zh-CN.md b/configurator/README.zh-CN.md new file mode 100644 index 00000000..960799f8 --- /dev/null +++ b/configurator/README.zh-CN.md @@ -0,0 +1,258 @@ +# MouseMaster Windows 个性化配置器 + +[English README](README.md) + +这是 `Rezetyan/mousemaster` fork 提供的社区 GUI,不是上游 MouseMaster 的官方组件。 +配置器面向 MouseMaster v89 properties,使用 .NET Framework WinForms 编写,不依赖 +第三方 NuGet 包。界面支持简体中文和 English,默认跟随系统语言,可在侧栏底部随时切换; +选择结果保存在 `HKCU\Software\MouseMasterConfigurator`。 + +本目录包含完整源码、构建脚本和回归测试。编译产生的 EXE、PDB、日志和测试运行时不会 +提交到 Git。 + +## 功能边界 + +- 六个分类中的 37 项操作均可编辑对应按键。 +- 可以编辑鼠标最高速度、鼠标加速度、滚轮最高速度和滚轮加速度。 +- 按操作能力支持单键、组合键、备用键和清空。 +- 同一模式作用域中出现完全相同的快捷键时,新设置保留,旧绑定清除并提示。 +- 保存时保留未由 GUI 管理的属性和普通注释。 +- 使用注释形式的 `mmcfg` 元数据准确回读 GUI 状态。 +- 每次写入前生成单份滚动备份 `mousemaster.properties.gui-backup`。 +- 可以恢复构建时嵌入的默认配置。 +- 专注模式为八个键盘鼠标模式分别生成兜底吞键规则,同时保留该模式已有的非吞键组合。 +- Alt-Tab 自动居中可以独立开关并修改触发组合。 + +四个速度输入只替换动态属性的普通档分支,并保留初始速度及慢速、快速、超慢速分支。 +最高速度范围为 `1..100000`,加速度范围为 `0..100000`;加速度设为 0 可关闭加速。 +GUI 不负责编辑初始速度、各修饰键分支、颜色、网格尺寸、Hint 密度等其他数值参数。 + +## 目录结构 + +```text +configurator/ + app.manifest + build.ps1 + LICENSE + README.md + src/ + BindingCatalog.cs + ConfigDocument.cs + ConfiguratorEngine.cs + DpiHelper.cs + KeyCaptureDialog.cs + Localization.cs + MainForm.cs + Models.cs + Program.cs + SelfTests.cs + ThemeAndControls.cs + tests/ + FocusModeIntegrationTest.cs +``` + +主要职责: + +| 文件 | 职责 | +|---|---| +| `BindingCatalog.cs` | 六个分类、37 项操作、4 项数值设置、默认值和冲突作用域 | +| `Models.cs` | 快捷键模型、数值设置、状态模型与冲突解析 | +| `ConfigDocument.cs` | 保留式 properties 编辑、元数据、原子写入和嵌入默认值 | +| `ConfiguratorEngine.cs` | 把 GUI 状态转换为实际 MouseMaster 属性与逐模式专注兜底规则 | +| `DpiHelper.cs` | 96 DPI 设计尺寸到当前显示器 DPI 的像素换算 | +| `Localization.cs` | 中英文案字典、当前语言、注册表语言偏好 | +| `KeyCaptureDialog.cs` | Windows 按键捕获和 v89 键名转换 | +| `MainForm.cs` | 主界面、按键与数值输入、保存、重新读取、冲突提示和恢复流程 | +| `SelfTests.cs` | 无测试框架依赖的内置回归测试 | +| `FocusModeIntegrationTest.cs` | 向真实前台窗口注入按键的 v89 端到端测试 | + +## 环境要求 + +- Windows 10 或 Windows 11。 +- .NET Framework 4.x;当前在 .NET Framework 4.8 上构建验证。 +- 64 位 .NET Framework C# 编译器: + `C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe`。 +- 仓库中存在 `configuration/neo-mousekeys-ijkl.properties`,构建时将其嵌入为恢复默认值。 +- 仅运行端到端测试时,需要在仓库根目录放置 MouseMaster v89 `mousemaster.exe`。 + +`app.manifest` 使用 `asInvoker`,配置器不会主动请求管理员权限,并声明 Per-Monitor +V2 DPI 感知。所有界面像素尺寸按 96 DPI 设计,经 `DpiHelper` 换算到当前显示器 DPI, +字体以磅为单位随 DPI 自动放大,因此在 125% / 150% 等缩放下文字不会被裁剪;窗口移到 +不同 DPI 的显示器时会按新 DPI 重建界面。 + +## 构建 + +在根目录打开 PowerShell: + +```powershell +Set-Location C:\path\to\mousemaster +.\configurator\build.ps1 +``` + +构建脚本会: + +1. 按文件名排序编译 `configurator/src/*.cs`。 +2. 引用 .NET Framework 自带的 System、Drawing 和 WinForms 程序集。 +3. 把 `configuration/neo-mousekeys-ijkl.properties` 作为 + `MouseMasterConfigurator.DefaultProperties` 嵌入资源。 +4. 在根目录生成: + +```text +MouseMasterConfigurator.exe +MouseMasterConfigurator.pdb +``` + +构建不会修改任何 properties。两个构建产物已由根目录 `.gitignore` 忽略。 + +## 运行 + +默认打开与 EXE 同目录的 `mousemaster.properties`。开发时先复制一份配置,避免直接修改 +仓库跟踪的示例: + +```powershell +Copy-Item .\configuration\neo-mousekeys-ijkl.properties .\mousemaster.properties +.\MouseMasterConfigurator.exe +``` + +打开指定配置: + +```powershell +.\MouseMasterConfigurator.exe --config "D:\path\to\mousemaster.properties" +``` + +目标文件必须已存在。配置器只写入指定 properties,不负责启动 MouseMaster。根目录 +开发副本 `mousemaster.properties` 及其 `.gui-backup` 已被 Git 忽略。 + +## 内置自检 + +WinForms 可执行文件提供无界面的自检入口: + +```powershell +.\MouseMasterConfigurator.exe --self-test ` + .\configurator\build\self-test.log ` + .\configurator\build\acceptance.properties +``` + +参数依次为: + +1. 必需标志 `--self-test`。 +2. 可选测试报告路径;省略时写到可执行文件目录。 +3. 可选验收配置路径;仅在全部断言通过时写出。 + +因为主程序是 WinForms `winexe`,自动化脚本应显式等待进程退出,再检查退出码和日志。 +当前自检覆盖: + +- 个性化 Vim 移动键、`Ctrl+M` 激活和四向滚动输出。 +- 组合键解析、左右修饰键、无障碍显示文本和 F24 保留规则。 +- 完整冲突报告与“新设置优先”行为。 +- 37 项目录操作都会改变真实配置正文。 +- 四项速度设置的导入、范围检查、动态分支保留、元数据回读和初始速度保护。 +- v89 特殊键名和小键盘无布局别名。 +- 专注模式八个逐模式兜底别名、非吞键组合排除、关闭后的清理与宏恢复。 +- 元数据回读、属性唯一性和非空检查。 +- 原子恢复、默认内容一致性及 `.gui-backup` 内容。 +- 标题与说明间距、专注说明与开关间距、开关不透明绘制,以及四个数值框的界面布局。 +- 中英文界面即时切换、切换后标题与导航文案,以及两种语言下所有固定尺寸标签的 + 文字适配检查(防止高 DPI 或长文案裁剪)。 + +成功时日志最后一行形如: + +```text +PASS: 299 assertions +``` + +## v89 专注模式集成测试 + +`FocusModeIntegrationTest.cs` 会启动真实 `mousemaster.exe`,创建一个输入接收窗口,并验证: + +1. 空闲模式中的 `X` 能到达活动应用。 +2. `Ctrl+M` 进入键盘鼠标模式。 +3. `F2` 仍到达活动应用,并作为已配置的透传退出组合切换回空闲模式。 +4. `F2` 后的 `B` 能到达应用,证明模式切换已经执行。 +5. 再次进入后,字母、数字和 `Ctrl+S` 均被吞掉。 +6. `Q` 退出后,`B` 再次到达活动应用。 + +这里的 `Ctrl+M` 是自检生成的隔离验收配置所使用的激活键,不代表官方 +`neo-mousekeys-ijkl` 配置的默认激活键。 + +编译测试: + +```powershell +$compiler = 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe' +& $compiler /nologo /target:exe /platform:anycpu /optimize+ ` + /out:.\configurator\build\FocusModeIntegrationTest.exe ` + /r:System.dll /r:System.Drawing.dll /r:System.Windows.Forms.dll ` + .\configurator\tests\FocusModeIntegrationTest.cs +``` + +测试必须使用隔离目录,其中配置文件名必须是 `mousemaster.properties`。不要把自动化验收 +配置覆盖到根目录正式配置: + +```powershell +$runtime = '.\configurator\build\v89-runtime' +New-Item -ItemType Directory -Path $runtime -Force | Out-Null +Copy-Item .\mousemaster.exe "$runtime\mousemaster.exe" -Force +Copy-Item .\configurator\build\acceptance.properties ` + "$runtime\mousemaster.properties" -Force + +.\configurator\build\FocusModeIntegrationTest.exe ` + "$runtime\mousemaster.exe" ` + "$runtime\mousemaster.properties" +``` + +测试会短暂把自己的接收窗口设为前台并注入键盘事件,完成或失败后都会终止它启动的 +MouseMaster 子进程。运行期间不要同时启动另一份 MouseMaster。 + +成功输出: + +```text +OK: idle-mode input reaches the active application +OK: focus mode preserves a configured pass-through combo +OK: the configured pass-through combo still performs its mode switch +OK: focus mode eats ordinary typing +OK: focus mode eats application shortcuts +OK: input resumes after leaving keyboard-mouse mode +PASS: focus mode preserved configured combos, swallowed unhandled input, and restored input after Q. +``` + +## 保存格式与兼容性 + +GUI 管理的状态写入文件末尾、由注释包围的 `mmcfg` 元数据块。MouseMaster 会忽略该块。 +再次保存前,配置器先移除旧元数据并基于磁盘最新正文重建,未知属性和普通注释会保留。 + +被清空的 MouseMaster 别名使用 `F24` 作为 v89 可解析的内部占位;捕获器因此禁止用户 +分配 F24。小键盘键不能参与 v89 的 `.us-qwerty` 跨布局转换,生成器会自动把包含 +`numpad*` 或 `numlock` 的别名迁移为无布局后缀形式。 + +专注模式为八个模式分别生成一个完整吞键别名,并从每个别名中排除该模式已有 `#` +非吞键组合所使用的按键和已按下前置条件。别名不带布局后缀,否则 v89 会尝试转换 +小键盘键,并可能在非 `us-qwerty` 活动布局上拒绝整个配置。 + +普通模式的入口组合(模式切换、退出、透传、导航宏、滚轮启动)按"同主键互斥"生成: +无修饰键的绑定(裸 `+key` 在按住修饰键时也会匹配)会自动排除同主键绑定上的修饰键, +例如屏幕 Hint 设为 `Ctrl+F`、UI Hint 设为 `F` 时生成 `_{leftctrl} +f` 与 +`^{leftctrl} +f`,二者不再同时命中。带修饰键的绑定本身已被 `_{...}` 严格语义 +保护(多按一个键即不匹配),无需额外排除。 + +启用专注模式时,普通模式中会向操作系统发送按键的应用前进 / 后退、方向键映射和 +Alt-Tab 监听会被注释。关闭专注模式后,前两类属性会恢复,Alt-Tab 监听则继续服从独立 +的自动居中开关。Alt-Tab 自动居中关闭时,从入口到 +`center-on-active-window-mode` 返回规则的完整属性链都会被注释,避免产生 v89 +不允许的孤立模式。 + +## 发布前检查 + +至少执行以下检查: + +1. 重新运行 `configurator/build.ps1`,确认编译退出码为 0。 +2. 运行完整 `--self-test`,确认报告没有 `FAIL`。 +3. 用未经手工修改的验收配置启动本机 v89,确认输出 + `Loaded configuration file mousemaster.properties`。 +4. 运行专注模式集成测试并确认六项 `OK`。 +5. 确认测试结束后没有遗留 MouseMaster、配置器或集成测试进程。 +6. 确认 `git status` 中没有 EXE、PDB、个人 properties、备份、日志或测试运行时。 + +## 许可证 + +`configurator/` 目录中的新配置器源码采用 [MIT License](LICENSE)。该许可只覆盖此目录 +中的新增内容,不改变仓库其他上游文件的授权状态。 diff --git a/configurator/app.manifest b/configurator/app.manifest new file mode 100644 index 00000000..81f0f3ef --- /dev/null +++ b/configurator/app.manifest @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + true/pm + PerMonitorV2,PerMonitor + + + diff --git a/configurator/build.ps1 b/configurator/build.ps1 new file mode 100644 index 00000000..69dfdd3f --- /dev/null +++ b/configurator/build.ps1 @@ -0,0 +1,47 @@ +$ErrorActionPreference = 'Stop' + +$configuratorRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$workspaceRoot = Split-Path -Parent $configuratorRoot +$sourceRoot = Join-Path $configuratorRoot 'src' +$compiler = 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe' +$manifest = Join-Path $configuratorRoot 'app.manifest' +$defaultConfiguration = Join-Path $workspaceRoot 'configuration\neo-mousekeys-ijkl.properties' +$output = Join-Path $workspaceRoot 'MouseMasterConfigurator.exe' + +if (-not (Test-Path -LiteralPath $compiler)) { + throw "C# compiler not found: $compiler" +} + +if (-not (Test-Path -LiteralPath $defaultConfiguration)) { + throw "Default configuration was not found: $defaultConfiguration" +} + +$sources = @(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File | + Sort-Object Name | + Select-Object -ExpandProperty FullName) + +if ($sources.Count -eq 0) { + throw "No C# sources found in $sourceRoot" +} + +$arguments = @( + '/nologo', + '/target:winexe', + '/platform:anycpu', + '/optimize+', + '/debug:pdbonly', + "/out:$output", + "/win32manifest:$manifest", + "/resource:$defaultConfiguration,MouseMasterConfigurator.DefaultProperties", + '/r:System.dll', + '/r:System.Core.dll', + '/r:System.Drawing.dll', + '/r:System.Windows.Forms.dll' +) + $sources + +& $compiler $arguments +if ($LASTEXITCODE -ne 0) { + throw "Build failed with exit code $LASTEXITCODE" +} + +Write-Host "Built $output" diff --git a/configurator/src/BindingCatalog.cs b/configurator/src/BindingCatalog.cs new file mode 100644 index 00000000..ca6bb8c2 --- /dev/null +++ b/configurator/src/BindingCatalog.cs @@ -0,0 +1,191 @@ +using System.Collections.Generic; +using System.Linq; + +namespace MouseMasterConfigurator +{ + /// + /// Declares the six categories, 37 actions and four numeric settings the GUI + /// can edit. Display text is not stored here: titles and descriptions are + /// resolved from L10n using "category.<id>", "action.<id>" and + /// "num.<id>" keys so the UI language can switch at runtime. + /// + internal static class BindingCatalog + { + public static readonly IList Categories = new List + { + new CategoryDefinition("general"), + new CategoryDefinition("movement"), + new CategoryDefinition("mouse"), + new CategoryDefinition("modes"), + new CategoryDefinition("hints"), + new CategoryDefinition("automation") + }; + + public static readonly IList NumericSettings = + new List + { + new NumericSettingDefinition( + "mouse-max-velocity", + "movement", + "px/s", + 1, + 100000, + 100), + new NumericSettingDefinition( + "mouse-acceleration", + "movement", + "px/s²", + 0, + 100000, + 100), + new NumericSettingDefinition( + "wheel-max-velocity", + "mouse", + "px/s", + 1, + 100000, + 100), + new NumericSettingDefinition( + "wheel-acceleration", + "mouse", + "px/s²", + 0, + 100000, + 100) + }; + + public static readonly IList Actions = new List + { + new ActionDefinition( + "activate", "general", + "leftalt+e|leftalt+capslock", null, true, true, false, "idle"), + new ActionDefinition( + "exit", "general", + "q|p", "exit", true, true, true, "normal", "grid", "hint2", "screen"), + new ActionDefinition( + "clickdisable", "general", + ".", "clickthendisable", false, false, true, "normal"), + new ActionDefinition( + "passthroughexit", "general", + "leftctrl+f|leftctrl+l|leftctrl+e|f2|f3|/", null, true, true, true, "normal"), + + new ActionDefinition( + "moveup", "movement", + "i", "up", false, false, true, "normal", "edge", "grid", "window"), + new ActionDefinition( + "movedown", "movement", + "k", "down", false, false, true, "normal", "edge", "grid", "window"), + new ActionDefinition( + "moveleft", "movement", + "j", "left", false, false, true, "normal", "edge", "grid", "window"), + new ActionDefinition( + "moveright", "movement", + "l", "right", false, false, true, "normal", "edge", "grid", "window"), + new ActionDefinition( + "fast", "movement", + "v|b", "fast", false, true, true, "normal", "edge"), + new ActionDefinition( + "slow", "movement", + "leftshift", "slow", false, true, true, "normal-speed"), + new ActionDefinition( + "superslow", "movement", + "capslock", "superslow", false, true, true, "normal-speed"), + new ActionDefinition( + "edge", "movement", + "rightalt", "edge", false, false, true, "normal"), + + new ActionDefinition( + "leftbutton", "mouse", + ";", "leftbutton", true, true, true, "normal", "edge"), + new ActionDefinition( + "middlebutton", "mouse", + "rightshift", "middlebutton", true, true, true, "normal", "edge"), + new ActionDefinition( + "rightbutton", "mouse", + "'", "rightbutton", true, true, true, "normal", "edge"), + new ActionDefinition( + "toggleleft", "mouse", + "n", "toggleleft", true, true, true, "normal", "edge"), + new ActionDefinition( + "wheelup", "mouse", + ",", "wheelup", true, true, true, "normal"), + new ActionDefinition( + "wheeldown", "mouse", + "m", "wheeldown", true, true, true, "normal"), + new ActionDefinition( + "wheelleft", "mouse", + "u", "wheelleft", true, true, true, "normal"), + new ActionDefinition( + "wheelright", "mouse", + "o", "wheelright", true, true, true, "normal"), + + new ActionDefinition( + "gridmode", "modes", + "g", "grid", true, true, true, "normal", "grid"), + new ActionDefinition( + "windowmode", "modes", + "leftshift+g", null, true, false, true, "normal", "window"), + new ActionDefinition( + "hintmode", "modes", + "f", "hint", true, true, true, "normal"), + new ActionDefinition( + "uihintmode", "modes", + "leftalt+f", null, true, false, true, "normal"), + new ActionDefinition( + "hint2modifier", "modes", + "leftshift", "hint2mod", false, false, true, "hint1"), + new ActionDefinition( + "screenselection", "modes", + "c", "screenselection", true, true, true, "normal", "screen"), + new ActionDefinition( + "cancel", "modes", + "esc", null, false, true, true, "grid", "hint1", "hint2", "screen", "ui"), + new ActionDefinition( + "hintback", "modes", + "backspace", null, false, true, true, "hint1", "hint2", "screen", "ui"), + + new ActionDefinition( + "hint1keys", "hints", + "q|w|e|r|t|a|s|d|f|g|z|x|c|v|b|y|u|i|o|p|h|j|k|l|;|n|m|,|.|/", + "hint1key", false, true, true, "hint1-default", "ui"), + new ActionDefinition( + "hint2keys", "hints", + "q|w|e|r|u|i|o|p|a|s|d|f|j|k|l|;|z|x|c|v|m|,|.|/", + "hint2key", false, true, true, "hint2-default"), + new ActionDefinition( + "extendedhint1keys", "hints", + "1|2|3|4|5|6|7|8|9|0|q|w|e|r|t|y|u|i|o|p|a|s|d|f|g|h|j|k|l|;|z|x|c|v|b|n|m|,|.|/", + "extendedhint1key", false, true, true, "hint1-extended"), + new ActionDefinition( + "extendedhint2keys", "hints", + "1|2|3|4|5|6|7|8|9|0|q|w|e|r|t|y|u|i|o|p|a|s|d|f|g|h|j|k|l|;|z|x|c|v|b|n|m|,|.|/", + "extendedhint2key", false, true, true, "hint2-extended"), + new ActionDefinition( + "screenhintkeys", "hints", + "j|k|l|;|a|s|d|f|g", "hintscreenselectionkey", false, true, true, "screen"), + + new ActionDefinition( + "navigateback", "automation", + "h", "navigateback", true, true, true, "normal"), + new ActionDefinition( + "navigateforward", "automation", + "y", "navigateforward", true, true, true, "normal"), + new ActionDefinition( + "arrowmodifier", "automation", + "leftalt", "arrowmod", false, false, true, "normal-arrow"), + new ActionDefinition( + "alttab", "automation", + "leftalt+tab", null, true, false, true, "idle", "normal") + }; + + public static ActionDefinition FindAction(string id) + { + return Actions.First(delegate(ActionDefinition action) { return action.Id == id; }); + } + + public static CategoryDefinition FindCategory(string id) + { + return Categories.First(delegate(CategoryDefinition category) { return category.Id == id; }); + } + } +} diff --git a/configurator/src/ConfigDocument.cs b/configurator/src/ConfigDocument.cs new file mode 100644 index 00000000..1c5ef7d5 --- /dev/null +++ b/configurator/src/ConfigDocument.cs @@ -0,0 +1,357 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace MouseMasterConfigurator +{ + internal sealed class ConfigDocument + { + public const string DisabledPrefix = "# mmcfg-disabled: "; + public const string MetadataBegin = "# --- MouseMaster Configurator state: begin ---"; + public const string MetadataEnd = "# --- MouseMaster Configurator state: end ---"; + + private readonly List lines; + private readonly string newLine; + + private ConfigDocument(IEnumerable sourceLines, string newLine) + { + lines = new List(sourceLines); + this.newLine = newLine; + } + + public static ConfigDocument Parse(string text) + { + if (text == null) + throw new ArgumentNullException("text"); + + string newline = text.Contains("\r\n") ? "\r\n" : "\n"; + string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n'); + var split = new List(normalized.Split('\n')); + if (split.Count > 0 && split[split.Count - 1].Length == 0) + split.RemoveAt(split.Count - 1); + return new ConfigDocument(split, newline); + } + + public string GetText() + { + return string.Join(newLine, lines.ToArray()) + newLine; + } + + public bool HasActiveProperty(string propertyKey) + { + return FindSpan(propertyKey) != null; + } + + public string GetPropertyValue(string propertyKey) + { + PropertySpan span = FindSpan(propertyKey); + if (span == null) + return null; + + var builder = new StringBuilder(); + for (int index = span.Start; index <= span.End; index++) + { + string part = lines[index].Trim(); + if (index == span.Start) + { + int equalsIndex = part.IndexOf('='); + part = equalsIndex >= 0 ? part.Substring(equalsIndex + 1).Trim() : string.Empty; + } + if (part.EndsWith("\\", StringComparison.Ordinal)) + part = part.Substring(0, part.Length - 1).TrimEnd(); + if (builder.Length > 0) + builder.Append(' '); + builder.Append(part); + } + return builder.ToString().Trim(); + } + + public string FindAliasPropertyKey(string aliasName) + { + string baseKey = "key-alias." + aliasName; + foreach (PropertySpan span in EnumerateSpans()) + { + if (string.Equals(span.Key, baseKey, StringComparison.OrdinalIgnoreCase) || + span.Key.StartsWith(baseKey + ".", StringComparison.OrdinalIgnoreCase)) + return span.Key; + } + return null; + } + + public void SetProperty(string propertyKey, string propertyValue) + { + if (string.IsNullOrWhiteSpace(propertyKey)) + throw new ArgumentException("Property key cannot be blank.", "propertyKey"); + if (string.IsNullOrWhiteSpace(propertyValue)) + throw new ArgumentException("Property value cannot be blank.", "propertyValue"); + + PropertySpan span = FindSpan(propertyKey); + string replacement = propertyKey + "=" + propertyValue.Trim(); + if (span != null) + { + lines.RemoveRange(span.Start, span.End - span.Start + 1); + lines.Insert(span.Start, replacement); + return; + } + + int insertionIndex = MetadataStartIndex(); + if (insertionIndex < 0) + insertionIndex = lines.Count; + if (insertionIndex > 0 && lines[insertionIndex - 1].Length != 0) + lines.Insert(insertionIndex++, string.Empty); + lines.Insert(insertionIndex, replacement); + } + + public void RemoveProperty(string propertyKey) + { + PropertySpan span = FindSpan(propertyKey); + if (span != null) + lines.RemoveRange(span.Start, span.End - span.Start + 1); + } + + public void CommentProperty(string propertyKey) + { + PropertySpan span = FindSpan(propertyKey); + if (span == null) + return; + for (int index = span.Start; index <= span.End; index++) + lines[index] = DisabledPrefix + lines[index]; + } + + public void RestoreManagedComments() + { + for (int index = 0; index < lines.Count; index++) + { + if (lines[index].StartsWith(DisabledPrefix, StringComparison.Ordinal)) + lines[index] = lines[index].Substring(DisabledPrefix.Length); + } + } + + public IDictionary ReadMetadata() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + int start = MetadataStartIndex(); + if (start < 0) + return result; + int end = MetadataEndIndex(start); + if (end < 0) + return result; + + for (int index = start + 1; index < end; index++) + { + string line = lines[index].Trim(); + if (!line.StartsWith("# mmcfg.", StringComparison.OrdinalIgnoreCase)) + continue; + string content = line.Substring(2); + int equalsIndex = content.IndexOf('='); + if (equalsIndex <= 0) + continue; + result[content.Substring(0, equalsIndex).Trim()] = + content.Substring(equalsIndex + 1).Trim(); + } + return result; + } + + public void RemoveMetadata() + { + int start = MetadataStartIndex(); + if (start < 0) + return; + int end = MetadataEndIndex(start); + if (end < 0) + end = lines.Count - 1; + lines.RemoveRange(start, end - start + 1); + while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) + lines.RemoveAt(lines.Count - 1); + } + + public void AppendMetadata(IEnumerable metadataLines) + { + RemoveMetadata(); + if (lines.Count > 0 && lines[lines.Count - 1].Length != 0) + lines.Add(string.Empty); + lines.Add(MetadataBegin); + foreach (string line in metadataLines) + lines.Add("# " + line); + lines.Add(MetadataEnd); + } + + public IList DuplicateActivePropertyKeys() + { + return EnumerateSpans() + .GroupBy(delegate(PropertySpan span) { return span.Key; }, StringComparer.OrdinalIgnoreCase) + .Where(delegate(IGrouping group) { return group.Count() > 1; }) + .Select(delegate(IGrouping group) { return group.Key; }) + .ToList(); + } + + public IList InvalidActivePropertyLines() + { + var invalid = new List(); + foreach (PropertySpan span in EnumerateSpans()) + { + string value = GetPropertyValue(span.Key); + if (string.IsNullOrWhiteSpace(span.Key) || string.IsNullOrWhiteSpace(value)) + invalid.Add(lines[span.Start]); + } + return invalid; + } + + public IList> ActivePropertiesStartingWith(string prefix) + { + var result = new List>(); + foreach (PropertySpan span in EnumerateSpans()) + { + if (!span.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + continue; + result.Add(new KeyValuePair( + span.Key, + GetPropertyValue(span.Key))); + } + return result; + } + + private IEnumerable EnumerateSpans() + { + int index = 0; + while (index < lines.Count) + { + string trimmed = lines[index].TrimStart(); + if (trimmed.Length == 0 || trimmed.StartsWith("#", StringComparison.Ordinal)) + { + index++; + continue; + } + + int equalsIndex = trimmed.IndexOf('='); + if (equalsIndex <= 0) + { + index++; + continue; + } + + string key = trimmed.Substring(0, equalsIndex).Trim(); + int end = index; + while (end < lines.Count - 1 && lines[end].TrimEnd().EndsWith("\\", StringComparison.Ordinal)) + end++; + yield return new PropertySpan(key, index, end); + index = end + 1; + } + } + + private PropertySpan FindSpan(string propertyKey) + { + return EnumerateSpans().FirstOrDefault( + delegate(PropertySpan span) + { + return string.Equals(span.Key, propertyKey, StringComparison.OrdinalIgnoreCase); + }); + } + + private int MetadataStartIndex() + { + for (int index = 0; index < lines.Count; index++) + { + if (string.Equals(lines[index].Trim(), MetadataBegin, StringComparison.Ordinal)) + return index; + } + return -1; + } + + private int MetadataEndIndex(int start) + { + for (int index = start + 1; index < lines.Count; index++) + { + if (string.Equals(lines[index].Trim(), MetadataEnd, StringComparison.Ordinal)) + return index; + } + return -1; + } + + private sealed class PropertySpan + { + public PropertySpan(string key, int start, int end) + { + Key = key; + Start = start; + End = end; + } + + public string Key { get; private set; } + public int Start { get; private set; } + public int End { get; private set; } + } + } + + internal static class AtomicFile + { + public static void WriteAllText(string path, string content, bool createBackup) + { + if (path == null) + throw new ArgumentNullException("path"); + + string fullPath = Path.GetFullPath(path); + string directory = Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(directory)) + throw new InvalidOperationException("Configuration directory could not be resolved."); + Directory.CreateDirectory(directory); + + string temporaryPath = Path.Combine( + directory, + Path.GetFileName(fullPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + var encoding = new UTF8Encoding(false); + File.WriteAllText(temporaryPath, content, encoding); + try + { + if (File.Exists(fullPath)) + { + if (createBackup) + File.Copy(fullPath, fullPath + ".gui-backup", true); + try + { + File.Replace(temporaryPath, fullPath, null, true); + temporaryPath = null; + } + catch (PlatformNotSupportedException) + { + File.Copy(temporaryPath, fullPath, true); + } + catch (IOException) + { + File.Copy(temporaryPath, fullPath, true); + } + } + else + { + File.Move(temporaryPath, fullPath); + temporaryPath = null; + } + } + finally + { + if (temporaryPath != null && File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + } + + internal static class EmbeddedDefault + { + private const string ResourceName = "MouseMasterConfigurator.DefaultProperties"; + + public static string ReadText() + { + Assembly assembly = Assembly.GetExecutingAssembly(); + using (Stream stream = assembly.GetManifestResourceStream(ResourceName)) + { + if (stream == null) + throw new InvalidOperationException("Embedded default configuration is missing."); + using (var reader = new StreamReader(stream, new UTF8Encoding(false), true)) + return reader.ReadToEnd(); + } + } + } +} diff --git a/configurator/src/ConfiguratorEngine.cs b/configurator/src/ConfiguratorEngine.cs new file mode 100644 index 00000000..59d3f7d7 --- /dev/null +++ b/configurator/src/ConfiguratorEngine.cs @@ -0,0 +1,1147 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace MouseMasterConfigurator +{ + internal sealed class ConfiguratorEngine + { + private const string LegacyFocusKeyAliasProperty = "key-alias.mmcfgfocuskey"; + private const string FocusKeyAliasPrefix = "key-alias.mmcfgfocus"; + + private static readonly string[] AltTabCenteringProperties = + { + "idle-mode.to.begin-alt-tab-mode", + "normal-mode.to.begin-alt-tab-mode", + "begin-alt-tab-mode.to.end-alt-tab-mode", + "end-alt-tab-mode.to.center-on-active-window-mode", + "end-alt-tab-mode.to.previous-mode-from-history-stack", + "center-on-active-window-mode.grid.area", + "center-on-active-window-mode.grid.synchronization", + "center-on-active-window-mode.to.previous-mode-from-history-stack" + }; + + private static readonly string[] FocusModes = + { + "normal-mode", + "edge-mode", + "grid-mode", + "window-mode", + "hint1-mode", + "hint2-mode", + "screen-selection-mode", + "ui-hint-mode" + }; + + private static readonly string[] FocusKeys = + { + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", + "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", + ",", ".", ";", "'", "/", "-", "=", "[", "]", "\\", "`", + "tab", "enter", "leftshift", "leftctrl", "leftalt", + "rightshift", "rightctrl", "rightalt", "pause", "scrolllock", + "capslock", "esc", "space", "pageup", "pagedown", "end", "home", + "leftarrow", "uparrow", "rightarrow", "downarrow", "printscreen", + "insert", "del", "break", "backspace", "leftwin", "rightwin", "menu", + "numpad0", "numpad1", "numpad2", "numpad3", "numpad4", + "numpad5", "numpad6", "numpad7", "numpad8", "numpad9", + "numpadmultiply", "numpadadd", "numpadsubtract", "numpaddecimal", + "numpaddivide", "numlock", + "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", + "f11", "f12", "f13", "f14", "f15", "f16", "f17", "f18", "f19", + "f20", "f21", "f22", "f23", "f24" + }; + + private static readonly HashSet FocusKeySet = + new HashSet(FocusKeys, StringComparer.OrdinalIgnoreCase); + + /// + /// Actions whose bindings never produce a combo in normal-mode + /// (hint selection key lists and hint-mode internals). Their chords + /// must not contribute modifier exclusions to normal-mode combos. + /// + private static readonly HashSet NonNormalModeActions = + new HashSet(StringComparer.Ordinal) + { + "hint1keys", + "hint2keys", + "extendedhint1keys", + "extendedhint2keys", + "screenhintkeys", + "hint2modifier", + "cancel", + "hintback" + }; + + public ConfiguratorState LoadState(string text) + { + ConfigDocument document = ConfigDocument.Parse(text); + IDictionary metadata = document.ReadMetadata(); + ConfiguratorState state = ConfiguratorState.CreateDefaults(); + ImportMotionSettings(document, metadata, state); + + if (metadata.Count > 0 && metadata.ContainsKey("mmcfg.version")) + { + string focus; + if (metadata.TryGetValue("mmcfg.focus", out focus)) + state.FocusModeEnabled = ParseBoolean(focus); + string altTab; + if (metadata.TryGetValue("mmcfg.alttab", out altTab)) + state.AltTabCenteringEnabled = ParseBoolean(altTab); + + foreach (ActionDefinition action in BindingCatalog.Actions) + { + string serialized; + if (metadata.TryGetValue("mmcfg.binding." + action.Id, out serialized)) + state.SetBindings(action.Id, KeyChord.ParseList(serialized)); + } + return state; + } + + ImportAliases(document, state); + state.FocusModeEnabled = document.HasActiveProperty("normal-mode.noop.mmcfg-focus"); + state.AltTabCenteringEnabled = + document.HasActiveProperty("idle-mode.to.begin-alt-tab-mode"); + return state; + } + + public string Apply(string sourceText, ConfiguratorState state) + { + Validate(state); + + ConfigDocument document = ConfigDocument.Parse(sourceText); + document.RestoreManagedComments(); + document.RemoveMetadata(); + foreach (string mode in FocusModes) + { + document.RemoveProperty(mode + ".noop.mmcfg-focus"); + document.RemoveProperty(FocusAliasProperty(mode)); + document.RemoveProperty(FocusAliasProperty(mode) + ".us-qwerty"); + } + document.RemoveProperty(LegacyFocusKeyAliasProperty); + document.RemoveProperty(LegacyFocusKeyAliasProperty + ".us-qwerty"); + + UpdateAliases(document, state); + UpdateActivationAndExit(document, state); + UpdateMouseButtons(document, state); + UpdateWheel(document, state); + UpdateMotionSettings(document, state); + UpdateModes(document, state); + UpdateAutomation(document, state); + UpdateVirtualKeys(document); + ApplyFeatureToggles(document, state); + AppendMetadata(document, state); + + IList duplicates = document.DuplicateActivePropertyKeys(); + if (duplicates.Count > 0) + throw new InvalidOperationException( + "Configuration contains duplicate properties: " + string.Join(", ", duplicates.ToArray())); + IList invalid = document.InvalidActivePropertyLines(); + if (invalid.Count > 0) + throw new InvalidOperationException( + "Configuration contains blank property values: " + string.Join(", ", invalid.ToArray())); + return document.GetText(); + } + + public void RestoreDefault(string configurationPath) + { + AtomicFile.WriteAllText(configurationPath, EmbeddedDefault.ReadText(), true); + } + + private static bool ParseBoolean(string value) + { + bool result; + return bool.TryParse(value, out result) && result; + } + + private static void Validate(ConfiguratorState state) + { + if (state.GetBindings("activate").Count == 0) + throw new InvalidOperationException(L10n.T("engine.activateRequired")); + + IList window = state.GetBindings("windowmode"); + if (window.Any(delegate(KeyChord chord) { return !chord.HasModifiers; })) + throw new InvalidOperationException(L10n.T("engine.windowModifier")); + + if (state.AltTabCenteringEnabled) + { + IList altTab = state.GetBindings("alttab"); + if (altTab.Count == 0 || altTab.Any(delegate(KeyChord chord) { return !chord.HasModifiers; })) + throw new InvalidOperationException(L10n.T("engine.altTabModifier")); + } + + ValidateRange(state.MouseMaxVelocity, 1, 100000, L10n.T("num.mouse-max-velocity.title")); + ValidateRange(state.MouseAcceleration, 0, 100000, L10n.T("num.mouse-acceleration.title")); + ValidateRange(state.WheelMaxVelocity, 1, 100000, L10n.T("num.wheel-max-velocity.title")); + ValidateRange(state.WheelAcceleration, 0, 100000, L10n.T("num.wheel-acceleration.title")); + } + + private static void ValidateRange(int value, int minimum, int maximum, string title) + { + if (value < minimum || value > maximum) + { + throw new InvalidOperationException( + L10n.F("engine.range", title, minimum, maximum)); + } + } + + private static void ImportMotionSettings( + ConfigDocument document, + IDictionary metadata, + ConfiguratorState state) + { + state.MouseMaxVelocity = ReadMotionValue( + document, + metadata, + "normal-mode.mouse.max-velocity", + "mmcfg.mouse.max-velocity", + state.MouseMaxVelocity); + state.MouseAcceleration = ReadMotionValue( + document, + metadata, + "normal-mode.mouse.acceleration", + "mmcfg.mouse.acceleration", + state.MouseAcceleration); + state.WheelMaxVelocity = ReadMotionValue( + document, + metadata, + "normal-mode.wheel.max-velocity", + "mmcfg.wheel.max-velocity", + state.WheelMaxVelocity); + state.WheelAcceleration = ReadMotionValue( + document, + metadata, + "normal-mode.wheel.acceleration", + "mmcfg.wheel.acceleration", + state.WheelAcceleration); + } + + private static int ReadMotionValue( + ConfigDocument document, + IDictionary metadata, + string propertyKey, + string metadataKey, + int fallback) + { + string propertyValue = document.GetPropertyValue(propertyKey); + int separator = IndexOfTopLevel(propertyValue, '|'); + string defaultBranch = propertyValue == null + ? null + : (separator < 0 ? propertyValue : propertyValue.Substring(0, separator)).Trim(); + int parsed; + if (int.TryParse( + defaultBranch, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out parsed)) + return parsed; + + string metadataValue; + if (metadata.TryGetValue(metadataKey, out metadataValue) && + int.TryParse( + metadataValue, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out parsed)) + return parsed; + return fallback; + } + + private static void ImportAliases(ConfigDocument document, ConfiguratorState state) + { + foreach (ActionDefinition action in BindingCatalog.Actions) + { + if (string.IsNullOrEmpty(action.AliasName)) + continue; + string propertyKey = document.FindAliasPropertyKey(action.AliasName); + if (propertyKey == null) + continue; + string value = document.GetPropertyValue(propertyKey); + if (string.IsNullOrWhiteSpace(value)) + continue; + var chords = value.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries) + .Where(delegate(string key) { return !key.StartsWith("mmcfgunbound", StringComparison.OrdinalIgnoreCase); }) + .Select(delegate(string key) { return new KeyChord(new string[0], key); }) + .ToList(); + state.SetBindings(action.Id, chords); + } + + IList activationModifiers = ReadAliasKeys(document, "enablemod"); + IList activationKeys = ReadAliasKeys(document, "enablekey"); + if (activationKeys.Count > 0) + { + var activation = new List(); + if (activationModifiers.Count == 0) + { + activation.AddRange(activationKeys.Select( + delegate(string key) { return new KeyChord(new string[0], key); })); + } + else + { + foreach (string modifier in activationModifiers) + { + foreach (string key in activationKeys) + activation.Add(new KeyChord(new[] { modifier }, key)); + } + } + state.SetBindings("activate", activation); + } + + IList windowModifiers = ReadAliasKeys(document, "windowmod"); + IList gridKeys = ReadAliasKeys(document, "grid"); + if (windowModifiers.Count > 0 && gridKeys.Count > 0) + state.SetBindings("windowmode", new[] { new KeyChord(windowModifiers, gridKeys[0]) }); + + IList uiModifiers = ReadAliasKeys(document, "uihintmod"); + IList hintKeys = ReadAliasKeys(document, "hint"); + if (uiModifiers.Count > 0 && hintKeys.Count > 0) + state.SetBindings("uihintmode", new[] { new KeyChord(uiModifiers, hintKeys[0]) }); + } + + private static IList ReadAliasKeys(ConfigDocument document, string aliasName) + { + string key = document.FindAliasPropertyKey(aliasName); + if (key == null) + return new List(); + string value = document.GetPropertyValue(key); + if (string.IsNullOrWhiteSpace(value)) + return new List(); + return value.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries) + .Where(delegate(string item) + { + return !item.StartsWith("mmcfgunbound", StringComparison.OrdinalIgnoreCase); + }) + .ToList(); + } + + private void UpdateAliases(ConfigDocument document, ConfiguratorState state) + { + foreach (ActionDefinition action in BindingCatalog.Actions) + { + if (string.IsNullOrEmpty(action.AliasName)) + continue; + SetAlias(document, action.AliasName, MainKeys(state, action.Id)); + } + + List activationModifiers = state.GetBindings("activate") + .SelectMany(delegate(KeyChord chord) { return chord.Modifiers; }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + SetAlias(document, "enablemod", activationModifiers); + SetAlias(document, "enablekey", MainKeys(state, "activate")); + + List windowModifiers = state.GetBindings("windowmode") + .SelectMany(delegate(KeyChord chord) { return chord.Modifiers; }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + SetAlias(document, "windowmod", windowModifiers); + + List uiHintModifiers = state.GetBindings("uihintmode") + .SelectMany(delegate(KeyChord chord) { return chord.Modifiers; }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + SetAlias(document, "uihintmod", uiHintModifiers); + } + + private void SetAlias( + ConfigDocument document, + string aliasName, + IEnumerable keys) + { + List normalized = keys + .Select(KeyChord.NormalizeKey) + .Where(delegate(string key) { return key.Length > 0; }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (normalized.Count == 0) + normalized.Add("f24"); + + string basePropertyKey = "key-alias." + aliasName; + string propertyKey = document.FindAliasPropertyKey(aliasName); + if (normalized.Any(IsNumpadKey)) + { + while (propertyKey != null) + { + document.RemoveProperty(propertyKey); + propertyKey = document.FindAliasPropertyKey(aliasName); + } + propertyKey = basePropertyKey; + } + else if (propertyKey == null) + { + propertyKey = basePropertyKey; + } + document.SetProperty(propertyKey, string.Join(" ", normalized.ToArray())); + } + + private static bool IsNumpadKey(string key) + { + return key.StartsWith("numpad", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "numlock", StringComparison.OrdinalIgnoreCase); + } + + private static IEnumerable MainKeys(ConfiguratorState state, string actionId) + { + return state.GetBindings(actionId) + .Select(delegate(KeyChord chord) { return chord.Key; }) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + private static void UpdateActivationAndExit(ConfigDocument document, ConfiguratorState state) + { + List pool = NormalModeChordPool(state); + string activation = BuildActivation(state.GetBindings("activate"), pool); + SetOrDisable(document, "idle-mode.to.normal-mode", activation); + + var exitBranches = new List(); + AddBranches(exitBranches, BuildPressWithPoolExclusions(state.GetBindings("exit"), pool, true)); + AddBranches(exitBranches, BuildRelease(state.GetBindings("clickdisable"))); + AddBranches(exitBranches, activation); + AddBranches(exitBranches, BuildPressWithPoolExclusions(state.GetBindings("passthroughexit"), pool, false)); + SetOrDisable(document, "normal-mode.to.idle-mode", JoinBranches(exitBranches)); + + string exit = BuildPressWithPoolExclusions(state.GetBindings("exit"), pool, true); + SetOrDisable(document, "grid-mode.to.idle-mode", exit); + SetOrDisable(document, "hint2-mode.to.idle-mode", exit); + SetOrDisable(document, "screen-selection-mode.to.idle-mode", exit); + } + + private static void UpdateMouseButtons(ConfigDocument document, ConfiguratorState state) + { + var leftPress = new List(); + AddBranches(leftPress, BuildMousePress(state.GetBindings("leftbutton"), true)); + AddBranches(leftPress, BuildMousePress(state.GetBindings("clickdisable"), true)); + SetOrDisable(document, "normal-mode.press.left", JoinBranches(leftPress)); + + var leftRelease = new List(); + AddBranches(leftRelease, BuildRelease(state.GetBindings("leftbutton"))); + AddBranches(leftRelease, BuildRelease(state.GetBindings("clickdisable"))); + SetOrDisable(document, "normal-mode.release.left", JoinBranches(leftRelease)); + + SetOrDisable( + document, + "normal-mode.press.middle", + BuildMousePress(state.GetBindings("middlebutton"), false)); + SetOrDisable( + document, + "normal-mode.release.middle", + BuildRelease(state.GetBindings("middlebutton"))); + SetOrDisable( + document, + "normal-mode.press.right", + BuildMousePress(state.GetBindings("rightbutton"), true)); + SetOrDisable( + document, + "normal-mode.release.right", + BuildRelease(state.GetBindings("rightbutton"))); + SetOrDisable( + document, + "normal-mode.toggle.left", + BuildMousePress(state.GetBindings("toggleleft"), true)); + } + + private static void UpdateWheel(ConfigDocument document, ConfiguratorState state) + { + List pool = NormalModeChordPool(state); + UpdateWheelDirection(document, state, pool, "up", "wheelup"); + UpdateWheelDirection(document, state, pool, "down", "wheeldown"); + UpdateWheelDirection(document, state, pool, "left", "wheelleft"); + UpdateWheelDirection(document, state, pool, "right", "wheelright"); + } + + private static void UpdateWheelDirection( + ConfigDocument document, + ConfiguratorState state, + List pool, + string direction, + string actionId) + { + SetOrDisable( + document, + "normal-mode.start-wheel." + direction, + BuildPressWithPoolExclusions(state.GetBindings(actionId), pool, true)); + SetOrDisable( + document, + "normal-mode.stop-wheel." + direction, + BuildRelease(state.GetBindings(actionId))); + } + + private static void UpdateMotionSettings( + ConfigDocument document, + ConfiguratorState state) + { + ReplaceDefaultPropertyBranch( + document, + "normal-mode.mouse.max-velocity", + state.MouseMaxVelocity); + ReplaceDefaultPropertyBranch( + document, + "normal-mode.mouse.acceleration", + state.MouseAcceleration); + ReplaceDefaultPropertyBranch( + document, + "normal-mode.wheel.max-velocity", + state.WheelMaxVelocity); + ReplaceDefaultPropertyBranch( + document, + "normal-mode.wheel.acceleration", + state.WheelAcceleration); + } + + private static void ReplaceDefaultPropertyBranch( + ConfigDocument document, + string propertyKey, + int value) + { + string existing = document.GetPropertyValue(propertyKey); + int separator = IndexOfTopLevel(existing, '|'); + string suffix = separator < 0 + ? string.Empty + : " " + existing.Substring(separator).TrimStart(); + document.SetProperty( + propertyKey, + value.ToString(CultureInfo.InvariantCulture) + suffix); + } + + private static void UpdateModes(ConfigDocument document, ConfiguratorState state) + { + List pool = NormalModeChordPool(state); + string gridEntry = BuildPressWithPoolExclusions( + state.GetBindings("gridmode"), + pool, + true); + SetOrDisable(document, "normal-mode.to.grid-mode", gridEntry); + + var gridExit = new List(); + AddBranches(gridExit, BuildPress(state.GetBindings("gridmode"), true)); + AddBranches(gridExit, BuildPress(state.GetBindings("cancel"), true)); + SetOrDisable(document, "grid-mode.to.normal-mode", JoinBranches(gridExit)); + + string windowEntry = BuildPressWithPoolExclusions( + state.GetBindings("windowmode"), + pool, + true); + SetOrDisable(document, "normal-mode.to.window-mode", windowEntry); + List windowModifiers = state.GetBindings("windowmode") + .SelectMany(delegate(KeyChord chord) { return chord.Modifiers; }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + SetOrDisable( + document, + "normal-mode.noop.eatwindowmod", + windowModifiers.Count == 0 ? null : "+windowmod"); + SetOrDisable( + document, + "window-mode.to.normal-mode", + JoinBranches(windowModifiers.Select(delegate(string key) { return "-" + key; }))); + var windowCenters = state.GetBindings("windowmode") + .Select(delegate(KeyChord chord) { return new KeyChord(new string[0], chord.Key); }) + .ToList(); + SetOrDisable( + document, + "window-mode.move-to-grid-center", + BuildPress(windowCenters, true)); + + string hintEntry = BuildPressWithPoolExclusions( + state.GetBindings("hintmode"), + pool, + true); + SetOrDisable(document, "normal-mode.to.hint1-mode", hintEntry); + SetOrDisable( + document, + "normal-mode.to.ui-hint-mode", + BuildPressWithPoolExclusions(state.GetBindings("uihintmode"), pool, true)); + SetOrDisable( + document, + "normal-mode.to.screen-selection-mode", + BuildPressWithPoolExclusions(state.GetBindings("screenselection"), pool, true)); + + var hint1Exit = new List(); + AddBranches(hint1Exit, BuildPress(state.GetBindings("cancel"), true)); + AddBranches(hint1Exit, BuildPress(state.GetBindings("hintback"), true)); + hint1Exit.Add("^{hint2mod} +extendedhint1key"); + SetOrDisable(document, "hint1-mode.to.normal-mode", JoinBranches(hint1Exit)); + SetOrDisable( + document, + "hint1-mode.hint.undo", + BuildPress(state.GetBindings("hintback"), true)); + + bool hasHint2Modifier = state.GetBindings("hint2modifier").Count > 0; + SetOrDisable( + document, + "hint1-mode.noop.eathintmod", + hasHint2Modifier ? "+hint2mod" : null); + SetOrDisable( + document, + "hint1-mode.to.hint2-mode", + hasHint2Modifier ? "_{hint2mod} +extendedhint1key" : null); + + string hintBackWithPrecondition = BuildPressWithRawPrecondition( + state.GetBindings("hintback"), "_{none | hint2mod}"); + SetOrDisable(document, "hint2-mode.to.hint1-mode", hintBackWithPrecondition); + + var hint2Exit = new List(); + AddBranches(hint2Exit, BuildPress(state.GetBindings("cancel"), true)); + hint2Exit.Add("_{none | hint2mod} +extendedhint1key"); + SetOrDisable(document, "hint2-mode.to.normal-mode", JoinBranches(hint2Exit)); + + var screenExit = new List(); + AddBranches(screenExit, BuildPress(state.GetBindings("screenselection"), true)); + AddBranches(screenExit, BuildPress(state.GetBindings("cancel"), true)); + AddBranches(screenExit, BuildPress(state.GetBindings("hintback"), true)); + screenExit.Add("+hintscreenselectionkey"); + SetOrDisable(document, "screen-selection-mode.to.normal-mode", JoinBranches(screenExit)); + + var uiExit = new List(); + AddBranches(uiExit, BuildPress(state.GetBindings("cancel"), true)); + AddBranches(uiExit, BuildPress(state.GetBindings("hintback"), true)); + uiExit.Add("+hint1key"); + SetOrDisable(document, "ui-hint-mode.to.normal-mode", JoinBranches(uiExit)); + SetOrDisable( + document, + "ui-hint-mode.hint.undo", + BuildPress(state.GetBindings("hintback"), true)); + } + + private static void UpdateAutomation(ConfigDocument document, ConfiguratorState state) + { + List pool = NormalModeChordPool(state); + string navigateBack = BuildPressWithPoolExclusions(state.GetBindings("navigateback"), pool, true); + SetOrDisable( + document, + "normal-mode.macro.navigateback", + string.IsNullOrWhiteSpace(navigateBack) + ? null + : navigateBack + " -> +leftalt +leftarrow -leftarrow -leftalt"); + + string navigateForward = BuildPressWithPoolExclusions(state.GetBindings("navigateforward"), pool, true); + SetOrDisable( + document, + "normal-mode.macro.navigateforward", + string.IsNullOrWhiteSpace(navigateForward) + ? null + : navigateForward + " -> +leftalt +rightarrow -rightarrow -leftalt"); + + string altTab = BuildPressWithPoolExclusions(state.GetBindings("alttab"), pool, false); + SetOrDisable(document, "idle-mode.to.begin-alt-tab-mode", altTab); + SetOrDisable(document, "normal-mode.to.begin-alt-tab-mode", altTab); + + List altTabModifiers = state.GetBindings("alttab") + .SelectMany(delegate(KeyChord chord) { return chord.Modifiers; }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + SetOrDisable( + document, + "begin-alt-tab-mode.to.end-alt-tab-mode", + JoinBranches(altTabModifiers.Select(delegate(string key) { return "-" + key; }))); + } + + private void UpdateVirtualKeys(ConfigDocument document) + { + var preserved = new List(); + string existing = document.GetPropertyValue("virtual-keys"); + if (!string.IsNullOrWhiteSpace(existing)) + { + preserved.AddRange(existing.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries) + .Where(delegate(string key) + { + return !key.StartsWith("mmcfgunbound", StringComparison.OrdinalIgnoreCase); + })); + } + preserved = preserved.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (preserved.Count == 0) + document.RemoveProperty("virtual-keys"); + else + document.SetProperty("virtual-keys", string.Join(" ", preserved.ToArray())); + } + + private static void ApplyFeatureToggles(ConfigDocument document, ConfiguratorState state) + { + if (!state.AltTabCenteringEnabled) + { + foreach (string property in AltTabCenteringProperties) + document.CommentProperty(property); + } + + if (!state.FocusModeEnabled) + return; + + document.CommentProperty("normal-mode.macro.navigateback"); + document.CommentProperty("normal-mode.macro.navigateforward"); + document.CommentProperty("normal-mode.macro"); + document.CommentProperty("normal-mode.to.begin-alt-tab-mode"); + + foreach (string mode in FocusModes) + { + ISet passThroughKeys = NonEatingHandledKeys(document, mode); + string[] fallbackKeys = FocusKeys.Where( + delegate(string key) { return !passThroughKeys.Contains(key); }).ToArray(); + if (fallbackKeys.Length == 0) + continue; + + string aliasName = FocusAliasName(mode); + document.SetProperty( + FocusAliasProperty(mode), + string.Join(" ", fallbackKeys)); + document.SetProperty(mode + ".noop.mmcfg-focus", "+" + aliasName); + } + } + + private static string FocusAliasName(string mode) + { + return "mmcfgfocus" + + mode.Replace("-mode", string.Empty).Replace("-", string.Empty) + + "key"; + } + + private static string FocusAliasProperty(string mode) + { + return "key-alias." + FocusAliasName(mode); + } + + private static ISet NonEatingHandledKeys( + ConfigDocument document, + string mode) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair property in + document.ActivePropertiesStartingWith(mode + ".")) + { + string comboText = BeforeTopLevelArrow(property.Value); + foreach (string branch in SplitTopLevel(comboText, '|')) + { + if (branch.IndexOf('#') < 0) + continue; + CollectNonEatingMoves(document, branch, result); + CollectPressedPreconditions(document, branch, result); + } + } + return result; + } + + private static void CollectNonEatingMoves( + ConfigDocument document, + string combo, + ISet result) + { + for (int index = 0; index < combo.Length; index++) + { + if (combo[index] != '#') + continue; + + index++; + bool negated = index < combo.Length && combo[index] == '!'; + if (negated) + index++; + if (index >= combo.Length) + break; + + if (combo[index] == '{') + { + int close = combo.IndexOf('}', index + 1); + if (close < 0) + break; + string content = combo.Substring(index + 1, close - index - 1).Trim(); + var resolved = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string token in SplitKeyTokens(content)) + AddResolvedFocusKeys(document, token, resolved, new HashSet( + StringComparer.OrdinalIgnoreCase)); + + if (negated) + { + foreach (string key in FocusKeys) + { + if (!resolved.Contains(key)) + result.Add(key); + } + } + else if (content == "*" || content == "+") + { + result.UnionWith(FocusKeys); + } + else if (content != "-") + { + result.UnionWith(resolved); + } + index = close; + continue; + } + + int start = index; + while (index < combo.Length && + !char.IsWhiteSpace(combo[index]) && + combo[index] != '|') + { + index++; + } + string bareToken = StripMoveDuration(combo.Substring(start, index - start)); + var bareResolved = new HashSet(StringComparer.OrdinalIgnoreCase); + AddResolvedFocusKeys( + document, + bareToken, + bareResolved, + new HashSet(StringComparer.OrdinalIgnoreCase)); + if (negated) + { + foreach (string key in FocusKeys) + { + if (!bareResolved.Contains(key)) + result.Add(key); + } + } + else + { + result.UnionWith(bareResolved); + } + index--; + } + } + + private static void CollectPressedPreconditions( + ConfigDocument document, + string combo, + ISet result) + { + int index = 0; + while ((index = combo.IndexOf("_{", index, StringComparison.Ordinal)) >= 0) + { + int close = combo.IndexOf('}', index + 2); + if (close < 0) + return; + string content = combo.Substring(index + 2, close - index - 2); + foreach (string token in SplitKeyTokens(content)) + { + AddResolvedFocusKeys( + document, + token, + result, + new HashSet(StringComparer.OrdinalIgnoreCase)); + } + index = close + 1; + } + } + + private static void AddResolvedFocusKeys( + ConfigDocument document, + string token, + ISet result, + ISet visitedAliases) + { + token = token.Trim().TrimEnd('?'); + while (token.StartsWith("*", StringComparison.Ordinal) || + token.StartsWith("!", StringComparison.Ordinal)) + { + token = token.Substring(1); + } + if (token.Length == 0 || + string.Equals(token, "none", StringComparison.OrdinalIgnoreCase)) + return; + if (FocusKeySet.Contains(token)) + { + result.Add(token); + return; + } + if (!visitedAliases.Add(token)) + return; + + string aliasProperty = document.FindAliasPropertyKey(token); + if (aliasProperty == null) + return; + string aliasValue = document.GetPropertyValue(aliasProperty); + foreach (string aliasToken in SplitKeyTokens(aliasValue)) + AddResolvedFocusKeys(document, aliasToken, result, visitedAliases); + } + + private static IEnumerable SplitKeyTokens(string value) + { + return (value ?? string.Empty).Split( + new[] { ' ', '\t', '|' }, + StringSplitOptions.RemoveEmptyEntries); + } + + private static string StripMoveDuration(string token) + { + for (int index = 1; index < token.Length; index++) + { + if (token[index] == '-' && + index + 1 < token.Length && + char.IsDigit(token[index + 1])) + return token.Substring(0, index); + } + return token; + } + + private static string BeforeTopLevelArrow(string value) + { + int depth = 0; + for (int index = 0; index < value.Length - 1; index++) + { + if (value[index] == '{') + depth++; + else if (value[index] == '}') + depth = Math.Max(0, depth - 1); + else if (depth == 0 && value[index] == '-' && value[index + 1] == '>') + return value.Substring(0, index); + } + return value; + } + + private static IEnumerable SplitTopLevel(string value, char separator) + { + var result = new List(); + int depth = 0; + int start = 0; + for (int index = 0; index < value.Length; index++) + { + if (value[index] == '{') + depth++; + else if (value[index] == '}') + depth = Math.Max(0, depth - 1); + else if (depth == 0 && value[index] == separator) + { + result.Add(value.Substring(start, index - start)); + start = index + 1; + } + } + result.Add(value.Substring(start)); + return result; + } + + private static int IndexOfTopLevel(string value, char separator) + { + if (string.IsNullOrEmpty(value)) + return -1; + int depth = 0; + for (int index = 0; index < value.Length; index++) + { + if (value[index] == '{') + depth++; + else if (value[index] == '}') + depth = Math.Max(0, depth - 1); + else if (depth == 0 && value[index] == separator) + return index; + } + return -1; + } + + private static void AppendMetadata(ConfigDocument document, ConfiguratorState state) + { + var metadata = new List + { + "mmcfg.version=1", + "mmcfg.focus=" + state.FocusModeEnabled.ToString().ToLowerInvariant(), + "mmcfg.alttab=" + state.AltTabCenteringEnabled.ToString().ToLowerInvariant(), + "mmcfg.mouse.max-velocity=" + + state.MouseMaxVelocity.ToString(CultureInfo.InvariantCulture), + "mmcfg.mouse.acceleration=" + + state.MouseAcceleration.ToString(CultureInfo.InvariantCulture), + "mmcfg.wheel.max-velocity=" + + state.WheelMaxVelocity.ToString(CultureInfo.InvariantCulture), + "mmcfg.wheel.acceleration=" + + state.WheelAcceleration.ToString(CultureInfo.InvariantCulture) + }; + foreach (ActionDefinition action in BindingCatalog.Actions) + { + metadata.Add( + "mmcfg.binding." + action.Id + "=" + + KeyChord.SerializeList(state.GetBindings(action.Id))); + } + document.AppendMetadata(metadata); + } + + private static string BuildActivation(IEnumerable chords, List pool) + { + var branches = new List(); + foreach (KeyChord chord in chords.Distinct()) + { + if (!chord.HasModifiers) + { + branches.Add(BuildPressChord(chord, true, PoolExcludedModifiers(chord, pool))); + continue; + } + + string modifierPress; + if (chord.Modifiers.Count == 1) + modifierPress = "+" + chord.Modifiers[0]; + else + modifierPress = "{+" + string.Join(" +", chord.Modifiers.ToArray()) + "}"; + branches.Add(modifierPress + " +" + chord.Key); + branches.Add("_{" + string.Join(" ", chord.Modifiers.ToArray()) + "} +" + chord.Key); + } + return JoinBranches(branches); + } + + private static string BuildPress(IEnumerable chords, bool eat) + { + return JoinBranches(chords.Distinct().Select( + delegate(KeyChord chord) { return BuildPressChord(chord, eat, new string[0]); })); + } + + /// + /// Chords of every action that produces a combo in normal-mode. A + /// modifier-free chord (plain +key) matches even while modifiers are + /// held, so it must explicitly exclude the modifiers of any sibling + /// chord bound to the same main key (^{leftctrl} +f); otherwise both + /// combos fire and the one later in the file wins. + /// + private static List NormalModeChordPool(ConfiguratorState state) + { + var pool = new List(); + foreach (ActionDefinition action in BindingCatalog.Actions) + { + if (NonNormalModeActions.Contains(action.Id)) + continue; + pool.AddRange(state.GetBindings(action.Id)); + } + return pool; + } + + private static List PoolExcludedModifiers( + KeyChord chord, + List pool) + { + return pool + .Where(delegate(KeyChord other) + { + return string.Equals(other.Key, chord.Key, StringComparison.OrdinalIgnoreCase); + }) + .SelectMany(delegate(KeyChord other) { return other.Modifiers; }) + .Where(delegate(string modifier) + { + return !chord.Modifiers.Contains(modifier, StringComparer.OrdinalIgnoreCase); + }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// Builds press branches for chords that share normal-mode with other + /// bindings. Modifier-free chords gain ^{...} exclusions for the + /// modifiers of same-key siblings. Chords with modifiers are already + /// protected by the strict _{...} semantics (an extra held key breaks + /// the pressed precondition), so they are emitted unchanged. + /// + private static string BuildPressWithPoolExclusions( + IEnumerable chords, + List pool, + bool eat) + { + return JoinBranches(chords.Distinct().Select( + delegate(KeyChord chord) + { + return BuildPressChord( + chord, + eat, + chord.HasModifiers + ? (IEnumerable) new string[0] + : PoolExcludedModifiers(chord, pool)); + })); + } + + private static string BuildPressChord( + KeyChord chord, + bool eat, + IEnumerable excludedModifiers) + { + var builder = new StringBuilder(); + string[] excluded = excludedModifiers.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (excluded.Length > 0) + builder.Append("^{").Append(string.Join(" ", excluded)).Append("} "); + if (chord.Modifiers.Count > 0) + builder.Append("_{").Append(string.Join(" ", chord.Modifiers.ToArray())).Append("} "); + builder.Append(eat ? "+" : "#").Append(chord.Key); + return builder.ToString(); + } + + private static string BuildPressWithRawPrecondition( + IEnumerable chords, + string rawPrecondition) + { + return JoinBranches(chords.Distinct().Select( + delegate(KeyChord chord) + { + string chordPrecondition = chord.Modifiers.Count == 0 + ? string.Empty + : " _{" + string.Join(" ", chord.Modifiers.ToArray()) + "}"; + return rawPrecondition + chordPrecondition + " +" + chord.Key; + })); + } + + private static string BuildMousePress(IEnumerable chords, bool allowCtrlClick) + { + return JoinBranches(chords.Distinct().Select( + delegate(KeyChord chord) + { + if (allowCtrlClick && chord.Modifiers.Count == 0) + return "_{none | leftctrl} +" + chord.Key; + return BuildPressChord(chord, true, new string[0]); + })); + } + + private static string BuildRelease(IEnumerable chords) + { + var branches = new List(); + foreach (KeyChord chord in chords.Distinct()) + { + branches.Add("-" + chord.Key); + foreach (string modifier in chord.Modifiers) + branches.Add("_{" + chord.Key + "} -" + modifier); + } + return JoinBranches(branches); + } + + private static void SetOrDisable( + ConfigDocument document, + string propertyKey, + string propertyValue) + { + if (string.IsNullOrWhiteSpace(propertyValue)) + document.CommentProperty(propertyKey); + else + document.SetProperty(propertyKey, propertyValue); + } + + private static void AddBranches(ICollection target, string branches) + { + if (string.IsNullOrWhiteSpace(branches)) + return; + + var current = new StringBuilder(); + int braceDepth = 0; + foreach (char character in branches) + { + if (character == '{') + braceDepth++; + else if (character == '}' && braceDepth > 0) + braceDepth--; + + if (character == '|' && braceDepth == 0) + { + string branch = current.ToString().Trim(); + if (branch.Length > 0) + target.Add(branch); + current.Clear(); + continue; + } + current.Append(character); + } + + string finalBranch = current.ToString().Trim(); + if (finalBranch.Length > 0) + target.Add(finalBranch); + } + + private static string JoinBranches(IEnumerable branches) + { + return string.Join( + " | ", + branches.Where(delegate(string branch) { return !string.IsNullOrWhiteSpace(branch); }) + .Select(delegate(string branch) { return branch.Trim(); }) + .Distinct(StringComparer.Ordinal) + .ToArray()); + } + } +} diff --git a/configurator/src/DpiHelper.cs b/configurator/src/DpiHelper.cs new file mode 100644 index 00000000..559159e4 --- /dev/null +++ b/configurator/src/DpiHelper.cs @@ -0,0 +1,120 @@ +using System; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace MouseMasterConfigurator +{ + /// + /// Scales design-time pixel metrics (authored for 96 DPI) to the DPI of the + /// monitor the window is on. Fonts are specified in points and therefore + /// already scale with DPI; only pixel-based sizes, positions, paddings and + /// margins go through this helper. Without it, fixed-size labels clip their + /// (physically larger) text at 125% / 150% scaling. + /// + internal static class DpiHelper + { + public const int WmDpiChanged = 0x02E0; + + private const uint SwpNoZOrder = 0x0004; + private const uint SwpNoActivate = 0x0010; + + private static float factor = ReadSystemDpiFactor(); + + public static float Factor + { + get { return factor; } + } + + /// Scales a design-time (96 DPI) pixel value. + public static int S(int value) + { + return (int)Math.Round(value * factor, MidpointRounding.AwayFromZero); + } + + public static Point Pt(int x, int y) + { + return new Point(S(x), S(y)); + } + + public static Size Sz(int width, int height) + { + return new Size(S(width), S(height)); + } + + public static Padding Pd(int left, int top, int right, int bottom) + { + return new Padding(S(left), S(top), S(right), S(bottom)); + } + + public static void UpdateForDpi(int dpi) + { + if (dpi < 48 || dpi > 960) + return; + factor = dpi / 96f; + } + + /// Extracts the new DPI from a WM_DPICHANGED wParam. + public static int DpiFromWParam(IntPtr wParam) + { + return (int)(wParam.ToInt64() & 0xFFFF); + } + + /// + /// Applies the window rectangle suggested by WM_DPICHANGED so the window + /// keeps its relative size when moved between monitors. + /// + public static void ApplySuggestedWindowRect(Form form, IntPtr lParam) + { + if (form == null || lParam == IntPtr.Zero) + return; + try + { + NativeRect rect = (NativeRect)Marshal.PtrToStructure(lParam, typeof(NativeRect)); + SetWindowPos( + form.Handle, + IntPtr.Zero, + rect.Left, + rect.Top, + Math.Max(1, rect.Right - rect.Left), + Math.Max(1, rect.Bottom - rect.Top), + SwpNoZOrder | SwpNoActivate); + } + catch + { + } + } + + private static float ReadSystemDpiFactor() + { + try + { + using (Graphics graphics = Graphics.FromHwnd(IntPtr.Zero)) + return graphics.DpiX / 96f; + } + catch + { + return 1f; + } + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SetWindowPos( + IntPtr hWnd, + IntPtr hWndInsertAfter, + int x, + int y, + int cx, + int cy, + uint flags); + + [StructLayout(LayoutKind.Sequential)] + private struct NativeRect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + } +} diff --git a/configurator/src/KeyCaptureDialog.cs b/configurator/src/KeyCaptureDialog.cs new file mode 100644 index 00000000..ceeb92ff --- /dev/null +++ b/configurator/src/KeyCaptureDialog.cs @@ -0,0 +1,581 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace MouseMasterConfigurator +{ + internal sealed class KeyCaptureDialog : Form + { + private readonly ActionDefinition definition; + private readonly List pending; + private ListBox shortcutList; + private RoundedButton captureButton; + private Label captureHint; + private RoundedButton removeButton; + private RoundedButton clearButton; + private RoundedButton cancelButton; + private RoundedButton saveButton; + private bool capturing; + private string pendingModifier; + + public KeyCaptureDialog(ActionDefinition definition, IEnumerable current) + { + this.definition = definition; + pending = current == null + ? new List() + : current.Select(delegate(KeyChord chord) { return chord.Clone(); }).ToList(); + + Text = L10n.T("dialog.title"); + StartPosition = FormStartPosition.CenterParent; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + ShowInTaskbar = false; + KeyPreview = true; + BackColor = AppTheme.PageBackground; + ClientSize = DpiHelper.Sz(570, 500); + AutoScaleMode = AutoScaleMode.None; + Font = AppTheme.Font(9.5f, FontStyle.Regular); + + BuildContents(); + RefreshList(); + } + + public IList Result + { + get { return pending.Select(delegate(KeyChord chord) { return chord.Clone(); }).ToList(); } + } + + private void BuildContents() + { + var title = new Label + { + Text = definition.Title, + Font = AppTheme.Font(16f, FontStyle.Bold), + ForeColor = AppTheme.Text, + AutoSize = false, + Location = DpiHelper.Pt(28, 24), + Size = DpiHelper.Sz(514, 32) + }; + var description = new Label + { + Text = definition.Description, + Font = AppTheme.Font(9f, FontStyle.Regular), + ForeColor = AppTheme.MutedText, + AutoSize = false, + Location = DpiHelper.Pt(29, 59), + Size = DpiHelper.Sz(512, 42) + }; + + captureButton = new RoundedButton + { + Text = L10n.T("dialog.capture"), + Location = DpiHelper.Pt(28, 112), + Size = DpiHelper.Sz(514, 56), + Radius = DpiHelper.S(7), + BorderColor = Color.FromArgb(195, 207, 228), + BorderThickness = 1, + FillColor = Color.White, + HoverColor = AppTheme.AccentSoft, + PressedColor = Color.FromArgb(222, 233, 253), + TextColor = AppTheme.Accent, + Font = AppTheme.Font(11f, FontStyle.Bold) + }; + captureButton.Click += delegate { BeginCapture(); }; + + captureHint = new Label + { + Text = definition.AllowModifiers + ? L10n.T("dialog.hintModifiers") + : L10n.T("dialog.hintSingle"), + Font = AppTheme.Font(8.5f, FontStyle.Regular), + ForeColor = AppTheme.MutedText, + AutoSize = false, + Location = DpiHelper.Pt(29, 177), + Size = DpiHelper.Sz(512, 22), + TextAlign = ContentAlignment.MiddleLeft + }; + + shortcutList = new ListBox + { + Location = DpiHelper.Pt(28, 210), + Size = DpiHelper.Sz(514, 166), + BorderStyle = BorderStyle.FixedSingle, + Font = AppTheme.Font(10f, FontStyle.Regular), + IntegralHeight = false, + DrawMode = DrawMode.OwnerDrawFixed, + ItemHeight = DpiHelper.S(32), + BackColor = Color.White, + ForeColor = AppTheme.Text + }; + shortcutList.DrawItem += DrawShortcutItem; + + removeButton = new RoundedButton + { + Text = L10n.T("dialog.remove"), + Height = DpiHelper.S(38), + FillColor = Color.White, + HoverColor = AppTheme.DangerSoft, + PressedColor = Color.FromArgb(250, 220, 220), + BorderColor = AppTheme.Border, + TextColor = AppTheme.Danger + }; + removeButton.Click += delegate + { + if (shortcutList.SelectedIndex < 0) + return; + pending.RemoveAt(shortcutList.SelectedIndex); + RefreshList(); + }; + + clearButton = new RoundedButton + { + Text = L10n.T("dialog.clear"), + Height = DpiHelper.S(38), + FillColor = Color.White, + HoverColor = AppTheme.DangerSoft, + PressedColor = Color.FromArgb(250, 220, 220), + BorderColor = AppTheme.Border, + TextColor = AppTheme.Danger, + Enabled = definition.CanClear + }; + clearButton.Click += delegate + { + pending.Clear(); + RefreshList(); + }; + + cancelButton = new RoundedButton + { + Text = L10n.T("dialog.cancel"), + DialogResult = DialogResult.Cancel, + Height = DpiHelper.S(38), + FillColor = Color.White, + HoverColor = Color.FromArgb(245, 247, 249), + PressedColor = Color.FromArgb(237, 240, 244), + BorderColor = AppTheme.Border, + TextColor = AppTheme.Text + }; + saveButton = new RoundedButton + { + Text = L10n.T("dialog.save"), + DialogResult = DialogResult.OK, + Height = DpiHelper.S(38), + FillColor = AppTheme.Accent, + HoverColor = AppTheme.AccentHover, + PressedColor = Color.FromArgb(30, 64, 175), + BorderThickness = 0, + TextColor = Color.White, + Font = AppTheme.Font(9.5f, FontStyle.Bold) + }; + + RoundedButton.FitWidthToText(removeButton, 112); + RoundedButton.FitWidthToText(clearButton, 96); + RoundedButton.FitWidthToText(cancelButton, 92); + RoundedButton.FitWidthToText(saveButton, 140); + removeButton.Location = DpiHelper.Pt(28, 388); + clearButton.Location = new Point(removeButton.Right + DpiHelper.S(10), DpiHelper.S(388)); + saveButton.Left = ClientSize.Width - DpiHelper.S(28) - saveButton.Width; + saveButton.Top = DpiHelper.S(447); + cancelButton.Left = saveButton.Left - DpiHelper.S(10) - cancelButton.Width; + cancelButton.Top = DpiHelper.S(447); + + Controls.Add(title); + Controls.Add(description); + Controls.Add(captureButton); + Controls.Add(captureHint); + Controls.Add(shortcutList); + Controls.Add(removeButton); + Controls.Add(clearButton); + Controls.Add(cancelButton); + Controls.Add(saveButton); + + AcceptButton = saveButton; + CancelButton = cancelButton; + } + + protected override void WndProc(ref Message message) + { + if (message.Msg == DpiHelper.WmDpiChanged) + { + int newDpi = DpiHelper.DpiFromWParam(message.WParam); + if (newDpi != (int)Math.Round(DpiHelper.Factor * 96)) + { + DpiHelper.UpdateForDpi(newDpi); + RebuildForDpi(); + } + } + base.WndProc(ref message); + } + + private void RebuildForDpi() + { + capturing = false; + pendingModifier = null; + SuspendLayout(); + Controls.Clear(); + ClientSize = DpiHelper.Sz(570, 500); + BuildContents(); + RefreshList(); + ResumeLayout(true); + } + + protected override bool ProcessCmdKey(ref Message message, Keys keyData) + { + if (!capturing) + return base.ProcessCmdKey(ref message, keyData); + + Keys keyCode = keyData & Keys.KeyCode; + if (KeyCaptureMap.IsReservedPlaceholderKey(keyCode)) + { + captureHint.Text = L10n.T("dialog.hintReserved"); + return true; + } + string keyName = KeyCaptureMap.ToMouseMasterKey(keyCode); + if (string.IsNullOrEmpty(keyName)) + return true; + + if (KeyCaptureMap.IsModifierKeyCode(keyCode)) + { + pendingModifier = KeyCaptureMap.SpecificModifier(keyCode); + captureButton.Text = L10n.F( + "dialog.modifierPending", + KeyNames.FriendlyName(pendingModifier)); + return true; + } + + List modifiers = definition.AllowModifiers + ? KeyCaptureMap.ResolveModifiers( + keyData, + KeyCaptureMap.PressedModifiers(), + pendingModifier) + : new List(); + AcceptChord(new KeyChord(modifiers, keyName)); + return true; + } + + protected override void OnKeyUp(KeyEventArgs e) + { + if (capturing && pendingModifier != null && KeyCaptureMap.IsModifierKeyCode(e.KeyCode)) + { + AcceptChord(new KeyChord(new string[0], pendingModifier)); + e.Handled = true; + e.SuppressKeyPress = true; + } + base.OnKeyUp(e); + } + + private void BeginCapture() + { + capturing = true; + pendingModifier = null; + captureButton.Text = L10n.T("dialog.capturing"); + captureButton.BorderColor = AppTheme.Accent; + captureButton.FillColor = AppTheme.AccentSoft; + captureHint.Text = L10n.T("dialog.hintListening"); + ActiveControl = captureButton; + } + + private void AcceptChord(KeyChord chord) + { + if (!definition.AllowModifiers && chord.HasModifiers) + { + System.Media.SystemSounds.Exclamation.Play(); + captureHint.Text = L10n.T("dialog.hintRejectCombo"); + return; + } + + if (!definition.AllowMultiple) + pending.Clear(); + if (!pending.Contains(chord)) + pending.Add(chord); + capturing = false; + pendingModifier = null; + captureButton.Text = L10n.T(definition.AllowMultiple ? "dialog.captureMore" : "dialog.captureRedo"); + captureButton.BorderColor = Color.FromArgb(195, 207, 228); + captureButton.FillColor = Color.White; + captureHint.Text = L10n.T(definition.AllowMultiple ? "dialog.hintMore" : "dialog.hintDone"); + RefreshList(); + } + + private void RefreshList() + { + int selection = shortcutList.SelectedIndex; + shortcutList.Items.Clear(); + foreach (KeyChord chord in pending) + shortcutList.Items.Add(chord); + if (shortcutList.Items.Count > 0) + shortcutList.SelectedIndex = Math.Min(Math.Max(selection, 0), shortcutList.Items.Count - 1); + removeButton.Enabled = shortcutList.Items.Count > 0; + } + + private static void DrawShortcutItem(object sender, DrawItemEventArgs e) + { + e.DrawBackground(); + if (e.Index < 0) + return; + var list = (ListBox)sender; + var chord = (KeyChord)list.Items[e.Index]; + Color background = (e.State & DrawItemState.Selected) != 0 + ? AppTheme.AccentSoft + : Color.White; + using (var brush = new SolidBrush(background)) + e.Graphics.FillRectangle(brush, e.Bounds); + TextRenderer.DrawText( + e.Graphics, + chord.DisplayText(), + list.Font, + new Rectangle( + e.Bounds.X + DpiHelper.S(12), + e.Bounds.Y, + e.Bounds.Width - DpiHelper.S(24), + e.Bounds.Height), + AppTheme.Text, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix); + } + } + + internal static class KeyCaptureMap + { + private const int VirtualLeftShift = 0xA0; + private const int VirtualRightShift = 0xA1; + private const int VirtualLeftControl = 0xA2; + private const int VirtualRightControl = 0xA3; + private const int VirtualLeftAlt = 0xA4; + private const int VirtualRightAlt = 0xA5; + private const int VirtualLeftWin = 0x5B; + private const int VirtualRightWin = 0x5C; + + [DllImport("user32.dll")] + private static extern short GetAsyncKeyState(int virtualKey); + + public static bool IsModifierKeyCode(Keys keyCode) + { + return keyCode == Keys.ShiftKey || keyCode == Keys.LShiftKey || keyCode == Keys.RShiftKey || + keyCode == Keys.ControlKey || keyCode == Keys.LControlKey || keyCode == Keys.RControlKey || + keyCode == Keys.Menu || keyCode == Keys.LMenu || keyCode == Keys.RMenu || + keyCode == Keys.LWin || keyCode == Keys.RWin; + } + + public static bool IsReservedPlaceholderKey(Keys keyCode) + { + return keyCode == Keys.F24; + } + + public static string SpecificModifier(Keys keyCode) + { + if (keyCode == Keys.LShiftKey) + return "leftshift"; + if (keyCode == Keys.RShiftKey) + return "rightshift"; + if (keyCode == Keys.LControlKey) + return "leftctrl"; + if (keyCode == Keys.RControlKey) + return "rightctrl"; + if (keyCode == Keys.LMenu) + return "leftalt"; + if (keyCode == Keys.RMenu) + return "rightalt"; + if (keyCode == Keys.LWin) + return "leftwin"; + if (keyCode == Keys.RWin) + return "rightwin"; + + if (keyCode == Keys.ControlKey) + { + if (IsDown(VirtualRightControl)) + return "rightctrl"; + return "leftctrl"; + } + if (keyCode == Keys.Menu) + { + if (IsDown(VirtualRightAlt)) + return "rightalt"; + return "leftalt"; + } + if (keyCode == Keys.ShiftKey) + { + if (IsDown(VirtualRightShift)) + return "rightshift"; + return "leftshift"; + } + return "leftshift"; + } + + public static List PressedModifiers() + { + var result = new List(); + AddIfDown(result, VirtualLeftControl, "leftctrl"); + AddIfDown(result, VirtualRightControl, "rightctrl"); + AddIfDown(result, VirtualLeftShift, "leftshift"); + AddIfDown(result, VirtualRightShift, "rightshift"); + AddIfDown(result, VirtualLeftAlt, "leftalt"); + AddIfDown(result, VirtualRightAlt, "rightalt"); + AddIfDown(result, VirtualLeftWin, "leftwin"); + AddIfDown(result, VirtualRightWin, "rightwin"); + return result; + } + + public static List ResolveModifiers( + Keys keyData, + IEnumerable pressedModifiers, + string pendingModifier) + { + var result = pressedModifiers == null + ? new List() + : pressedModifiers + .Select(KeyChord.NormalizeKey) + .Where(KeyChord.IsModifier) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + AddModifier(result, pendingModifier); + AddModifierFromKeyData(result, keyData, Keys.Control, "leftctrl", "rightctrl"); + AddModifierFromKeyData(result, keyData, Keys.Shift, "leftshift", "rightshift"); + AddModifierFromKeyData(result, keyData, Keys.Alt, "leftalt", "rightalt"); + return result; + } + + public static string ToMouseMasterKey(Keys keyCode) + { + if (IsReservedPlaceholderKey(keyCode)) + return null; + if (keyCode >= Keys.A && keyCode <= Keys.Z) + return keyCode.ToString().ToLowerInvariant(); + if (keyCode >= Keys.D0 && keyCode <= Keys.D9) + return ((int)(keyCode - Keys.D0)).ToString(); + if (keyCode >= Keys.F1 && keyCode <= Keys.F24) + return keyCode.ToString().ToLowerInvariant(); + if (keyCode >= Keys.NumPad0 && keyCode <= Keys.NumPad9) + return "numpad" + ((int)(keyCode - Keys.NumPad0)).ToString(); + + switch (keyCode) + { + case Keys.ShiftKey: + case Keys.LShiftKey: + case Keys.RShiftKey: + case Keys.ControlKey: + case Keys.LControlKey: + case Keys.RControlKey: + case Keys.Menu: + case Keys.LMenu: + case Keys.RMenu: + case Keys.LWin: + case Keys.RWin: + return SpecificModifier(keyCode); + case Keys.Capital: + return "capslock"; + case Keys.Escape: + return "esc"; + case Keys.Back: + return "backspace"; + case Keys.Return: + return "enter"; + case Keys.Space: + return "space"; + case Keys.Tab: + return "tab"; + case Keys.Delete: + return "del"; + case Keys.Insert: + return "insert"; + case Keys.Home: + return "home"; + case Keys.End: + return "end"; + case Keys.PageUp: + return "pageup"; + case Keys.PageDown: + return "pagedown"; + case Keys.Up: + return "uparrow"; + case Keys.Down: + return "downarrow"; + case Keys.Left: + return "leftarrow"; + case Keys.Right: + return "rightarrow"; + case Keys.Oemcomma: + return ","; + case Keys.OemPeriod: + return "."; + case Keys.OemSemicolon: + return ";"; + case Keys.OemQuotes: + return "'"; + case Keys.OemQuestion: + return "/"; + case Keys.OemMinus: + return "minus"; + case Keys.Oemplus: + return "="; + case Keys.OemOpenBrackets: + return "["; + case Keys.OemCloseBrackets: + return "]"; + case Keys.OemPipe: + return "backslash"; + case Keys.Oemtilde: + return "`"; + case Keys.Apps: + return "menu"; + case Keys.PrintScreen: + return "printscreen"; + case Keys.Scroll: + return "scrolllock"; + case Keys.Pause: + return "pause"; + case Keys.Add: + return "numpadadd"; + case Keys.Subtract: + return "numpadsubtract"; + case Keys.Multiply: + return "numpadmultiply"; + case Keys.Divide: + return "numpaddivide"; + case Keys.Decimal: + return "numpaddecimal"; + default: + return null; + } + } + + private static void AddIfDown(ICollection target, int virtualKey, string name) + { + if (IsDown(virtualKey)) + target.Add(name); + } + + private static void AddModifier(ICollection target, string modifier) + { + string normalized = KeyChord.NormalizeKey(modifier); + if (!KeyChord.IsModifier(normalized) || + target.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + return; + target.Add(normalized); + } + + private static void AddModifierFromKeyData( + ICollection target, + Keys keyData, + Keys modifierFlag, + string leftName, + string rightName) + { + if ((keyData & modifierFlag) != modifierFlag) + return; + if (target.Contains(leftName, StringComparer.OrdinalIgnoreCase) || + target.Contains(rightName, StringComparer.OrdinalIgnoreCase)) + return; + target.Add(leftName); + } + + private static bool IsDown(int virtualKey) + { + return (GetAsyncKeyState(virtualKey) & 0x8000) != 0; + } + } +} diff --git a/configurator/src/Localization.cs b/configurator/src/Localization.cs new file mode 100644 index 00000000..0b043010 --- /dev/null +++ b/configurator/src/Localization.cs @@ -0,0 +1,497 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.Win32; + +namespace MouseMasterConfigurator +{ + internal enum UiLanguage + { + Chinese, + English + } + + /// + /// Provides every user-visible string in Chinese and English. The active + /// language defaults to the OS UI culture and can be overridden by the user; + /// the override is persisted under HKCU\Software\MouseMasterConfigurator. + /// Catalog definitions (categories, actions, numeric settings) resolve their + /// Title/Description through this class at access time, so switching the + /// language re-renders the whole UI without a restart. + /// + internal static class L10n + { + private const string RegistryKeyPath = @"Software\MouseMasterConfigurator"; + private const string RegistryValueName = "Language"; + + private static UiLanguage language = DetectDefaultLanguage(); + + public static UiLanguage Language + { + get { return language; } + set { language = value; } + } + + /// + /// Self-tests disable persistence so clicking the language switcher from a + /// headless test never touches the user's registry. + /// + public static bool PersistenceEnabled { get; set; } + + static L10n() + { + PersistenceEnabled = true; + } + + public static string T(string key) + { + string value; + Dictionary primary = language == UiLanguage.English ? English : Chinese; + Dictionary fallback = language == UiLanguage.English ? Chinese : English; + if (primary.TryGetValue(key, out value)) + return value; + if (fallback.TryGetValue(key, out value)) + return value; + return key; + } + + public static string F(string key, object arg0) + { + return string.Format(CultureInfo.InvariantCulture, T(key), arg0); + } + + public static string F(string key, object arg0, object arg1) + { + return string.Format(CultureInfo.InvariantCulture, T(key), arg0, arg1); + } + + public static string F(string key, params object[] args) + { + return string.Format(CultureInfo.InvariantCulture, T(key), args); + } + + /// Reads the persisted language; falls back to the OS UI culture. + public static UiLanguage LoadPreference() + { + try + { + using (RegistryKey key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, false)) + { + if (key != null) + { + string value = key.GetValue(RegistryValueName) as string; + if (string.Equals(value, "en-US", StringComparison.OrdinalIgnoreCase)) + return UiLanguage.English; + if (string.Equals(value, "zh-CN", StringComparison.OrdinalIgnoreCase)) + return UiLanguage.Chinese; + } + } + } + catch + { + } + return DetectDefaultLanguage(); + } + + public static void SavePreference() + { + if (!PersistenceEnabled) + return; + try + { + using (RegistryKey key = Registry.CurrentUser.CreateSubKey(RegistryKeyPath)) + { + if (key != null) + { + key.SetValue( + RegistryValueName, + language == UiLanguage.English ? "en-US" : "zh-CN"); + } + } + } + catch + { + } + } + + private static UiLanguage DetectDefaultLanguage() + { + string name = CultureInfo.CurrentUICulture.Name; + return name.StartsWith("zh", StringComparison.OrdinalIgnoreCase) + ? UiLanguage.Chinese + : UiLanguage.English; + } + + private static readonly Dictionary Chinese = + new Dictionary(StringComparer.Ordinal) + { + { "app.title", "MouseMaster 个性化配置" }, + { "sidebar.subtitle", "个性化配置" }, + { "sidebar.file", "当前配置文件" }, + { "search.cue", "搜索操作或说明" }, + + { "focus.title", "专注模式" }, + { "focus.on", "已启用:普通及子模式会吸收全部键盘事件" }, + { "focus.off", "激活后吞掉全部键盘事件,并停用对外键盘宏" }, + + { "category.general.title", "基础" }, + { "category.general.description", "激活、退出与全局行为" }, + { "category.movement.title", "移动" }, + { "category.movement.description", "方向、速度与边缘定位" }, + { "category.mouse.title", "鼠标与滚轮" }, + { "category.mouse.description", "点击、拖拽和四向滚动" }, + { "category.modes.title", "定位模式" }, + { "category.modes.description", "网格、窗口、Hint 与多屏操作" }, + { "category.hints.title", "Hint 键盘" }, + { "category.hints.description", "标签选择键与撤销键" }, + { "category.automation.title", "重映射" }, + { "category.automation.description", "前进后退、方向键和 Alt-Tab" }, + + { "num.mouse-max-velocity.title", "鼠标最高速度" }, + { "num.mouse-max-velocity.description", "调整普通档连续移动的速度上限;慢速、快速和超慢速分支保持不变。" }, + { "num.mouse-acceleration.title", "鼠标加速度" }, + { "num.mouse-acceleration.description", "调整普通档达到最高速度的快慢;设为 0 可关闭加速。" }, + { "num.wheel-max-velocity.title", "滚轮最高速度" }, + { "num.wheel-max-velocity.description", "调整普通档连续滚动的速度上限;速度修饰键分支保持不变。" }, + { "num.wheel-acceleration.title", "滚轮加速度" }, + { "num.wheel-acceleration.description", "调整普通档达到滚轮最高速度的快慢;设为 0 可关闭加速。" }, + + { "action.activate.title", "激活 / 切换普通模式" }, + { "action.activate.description", "从空闲模式进入键盘鼠标模式;在普通模式中再次按下则退出。" }, + { "action.exit.title", "退出到空闲模式" }, + { "action.exit.description", "在普通、网格、二级 Hint 和屏幕选择模式中立即退出。" }, + { "action.clickdisable.title", "左键单击后退出" }, + { "action.clickdisable.description", "按下时按住左键,松开时完成单击并返回空闲模式。" }, + { "action.passthroughexit.title", "退出并保留原快捷键" }, + { "action.passthroughexit.description", "退出普通模式,同时允许当前应用继续收到这些快捷键。" }, + { "action.moveup.title", "向上移动" }, + { "action.moveup.description", "普通模式连续移动;也用于边缘、网格和窗口定位。" }, + { "action.movedown.title", "向下移动" }, + { "action.movedown.description", "普通模式连续移动;也用于边缘、网格和窗口定位。" }, + { "action.moveleft.title", "向左移动" }, + { "action.moveleft.description", "普通模式连续移动;也用于边缘、网格和窗口定位。" }, + { "action.moveright.title", "向右移动" }, + { "action.moveright.description", "普通模式连续移动;也用于边缘、网格和窗口定位。" }, + { "action.fast.title", "快速移动" }, + { "action.fast.description", "按住后提高鼠标和滚轮的最高速度与加速度。" }, + { "action.slow.title", "慢速移动" }, + { "action.slow.description", "按住后降低鼠标和滚轮最高速度。" }, + { "action.superslow.title", "超慢速移动" }, + { "action.superslow.description", "按住后进入精细定位速度。" }, + { "action.edge.title", "屏幕边缘模式" }, + { "action.edge.description", "按住并配合方向键,跳到当前屏幕有效区域边缘。" }, + { "action.leftbutton.title", "鼠标左键" }, + { "action.leftbutton.description", "支持按住和拖拽;不带修饰键时仍兼容 Ctrl+单击。" }, + { "action.middlebutton.title", "鼠标中键" }, + { "action.middlebutton.description", "按下和释放对应鼠标中键。" }, + { "action.rightbutton.title", "鼠标右键" }, + { "action.rightbutton.description", "支持按住和拖拽;不带修饰键时仍兼容 Ctrl+单击。" }, + { "action.toggleleft.title", "锁定 / 释放鼠标左键" }, + { "action.toggleleft.description", "适合不持续按键的长距离拖拽。" }, + { "action.wheelup.title", "向上滚动" }, + { "action.wheelup.description", "按住开始滚动,松开停止;支持组合键和备用键。" }, + { "action.wheeldown.title", "向下滚动" }, + { "action.wheeldown.description", "按住开始滚动,松开停止;支持组合键和备用键。" }, + { "action.wheelleft.title", "向左滚动" }, + { "action.wheelleft.description", "可以设置为 Shift + U 等组合键。" }, + { "action.wheelright.title", "向右滚动" }, + { "action.wheelright.description", "可以设置为 Shift + O 等组合键。" }, + { "action.gridmode.title", "屏幕网格模式" }, + { "action.gridmode.description", "进入网格;在网格中再次使用该键可返回普通模式。" }, + { "action.windowmode.title", "活动窗口定位模式" }, + { "action.windowmode.description", "按住组合中的修饰键进入;主键在窗口模式中用于跳到中心。" }, + { "action.hintmode.title", "屏幕 Hint" }, + { "action.hintmode.description", "在活动屏幕显示位置标签。" }, + { "action.uihintmode.title", "UI Hint" }, + { "action.uihintmode.description", "标记活动窗口中由 Windows UI Automation 暴露的控件。" }, + { "action.hint2modifier.title", "二级 Hint 修饰键" }, + { "action.hint2modifier.description", "一级 Hint 选择完成时按住它,进入局部放大的二级 Hint。" }, + { "action.screenselection.title", "屏幕选择" }, + { "action.screenselection.description", "为每块显示器显示一个标签并移动鼠标。" }, + { "action.cancel.title", "取消 / 返回" }, + { "action.cancel.description", "用于退出网格、Hint、屏幕选择和 UI Hint。" }, + { "action.hintback.title", "Hint 撤销 / 返回" }, + { "action.hintback.description", "撤销标签输入、返回上一级 Hint,或退出屏幕选择。" }, + { "action.hint1keys.title", "一级 / UI Hint 选择键" }, + { "action.hint1keys.description", "常规分辨率下生成一级屏幕 Hint,并用于 UI Hint。" }, + { "action.hint2keys.title", "二级 Hint 选择键" }, + { "action.hint2keys.description", "常规分辨率下生成二级精确定位标签。" }, + { "action.extendedhint1keys.title", "一级 Hint 扩展选择键" }, + { "action.extendedhint1keys.description", "4K、QHD 和超宽屏覆盖配置使用的 40 键集合。" }, + { "action.extendedhint2keys.title", "二级 Hint 扩展选择键" }, + { "action.extendedhint2keys.description", "高分辨率二级 Hint 使用的 40 键集合。" }, + { "action.screenhintkeys.title", "显示器标签选择键" }, + { "action.screenhintkeys.description", "按显示器顺序分配标签;最多提供九个快捷键。" }, + { "action.navigateback.title", "应用后退" }, + { "action.navigateback.description", "普通模式中向当前应用发送 Alt + Left Arrow;专注模式会停用输出。" }, + { "action.navigateforward.title", "应用前进" }, + { "action.navigateforward.description", "普通模式中向当前应用发送 Alt + Right Arrow;专注模式会停用输出。" }, + { "action.arrowmodifier.title", "方向键映射修饰键" }, + { "action.arrowmodifier.description", "按住后,当前移动方向键会输出系统方向键;专注模式中停用。" }, + { "action.alttab.title", "Alt-Tab 后自动居中" }, + { "action.alttab.description", "窗口切换结束后将鼠标移动到新活动窗口中心。" }, + + { "search.title", "搜索结果" }, + { "search.description", "匹配“{0}”的操作" }, + { "section.count.one", "{0} 项设置" }, + { "section.count.many", "{0} 项设置" }, + { "alttab.title", "启用 Alt-Tab 自动居中" }, + { "alttab.description", "关闭后保留其他重映射,但不再监听窗口切换。" }, + { "row.empty", "没有找到匹配的操作。" }, + + { "binding.none", "点击设置快捷键" }, + { "binding.more", " · 共 {0} 项" }, + { "binding.clearTip", "清除该操作的全部快捷键" }, + + { "button.restore", "恢复默认" }, + { "button.reload", "重新读取" }, + { "button.save", "保存并应用" }, + + { "status.loading", "正在读取配置…" }, + { "status.ready", "已连接配置文件,所有修改会在保存后热加载。" }, + { "status.reloaded", "已重新读取配置文件。" }, + { "status.loadFail", "无法读取配置文件:{0}" }, + { "status.dirty", "有尚未保存的修改。" }, + { "status.updated", "已更新“{0}”,尚未保存。" }, + { "status.saved", "已保存并应用;MouseMaster 会自动重新加载配置。" }, + { "status.saveFail", "保存失败:{0}" }, + { "status.restored", "已恢复默认设置;原配置保存在 .gui-backup 文件中。" }, + { "status.restoreFail", "恢复失败:{0}" }, + { "status.conflict", "存在冲突,旧操作中的冲突按键已清除。" }, + + { "conflict.message", "已保留“{0}”的新设置,并清除冲突:{1}" }, + { "conflict.detail", "“{0}”中的 {1}" }, + { "conflict.joinChords", "、" }, + { "conflict.joinDetails", ";" }, + + { "msg.noSet", "无法设置快捷键" }, + { "msg.conflict", "按键冲突已自动处理" }, + { "msg.readFail", "读取配置失败" }, + { "msg.external", "检测到外部修改" }, + { "msg.external.body", "配置文件在本程序之外发生了变化。是否把当前 GUI 设置合并到最新文件?" }, + { "msg.saveFail", "保存配置失败" }, + { "msg.discard", "放弃未保存修改" }, + { "msg.discard.body", "重新读取会丢弃尚未保存的 GUI 修改。是否继续?" }, + { "msg.restore", "恢复默认设置" }, + { "msg.restore.body", "将立即把 mousemaster.properties 恢复为配置器内置的原始默认版本。\n\n当前文件会先保存为 mousemaster.properties.gui-backup。" }, + { "msg.restoreFail", "恢复默认失败" }, + { "msg.unsaved", "未保存修改" }, + { "msg.unsaved.body", "还有尚未保存的修改。确定关闭吗?" }, + + { "error.fatal", "MouseMaster 配置器错误" }, + { "error.unknown", "发生未知错误。" }, + { "error.noConfig", "找不到 mousemaster.properties。" }, + + { "dialog.title", "设置快捷键" }, + { "dialog.capture", "点击后按下快捷键" }, + { "dialog.capturing", "请按下快捷键…" }, + { "dialog.hintModifiers", "支持 Ctrl、Shift、Alt、Win 组合;单独松开修饰键可绑定修饰键本身。" }, + { "dialog.hintSingle", "此操作仅支持单键。" }, + { "dialog.hintListening", "正在监听键盘。Esc、Tab、Enter 也可以直接作为快捷键。" }, + { "dialog.hintReserved", "F24 由配置器保留用于表示已清空的绑定,请选择其他按键。" }, + { "dialog.hintRejectCombo", "此操作仅支持单键,请松开修饰键后重试。" }, + { "dialog.captureMore", "继续添加快捷键" }, + { "dialog.captureRedo", "重新设置快捷键" }, + { "dialog.hintMore", "可以继续添加备用键;保存后会自动处理冲突。" }, + { "dialog.hintDone", "保存后会自动检查并处理冲突。" }, + { "dialog.remove", "删除选中项" }, + { "dialog.clear", "全部清除" }, + { "dialog.cancel", "取消" }, + { "dialog.save", "使用这些快捷键" }, + { "dialog.modifierPending", "{0}(松开以使用)" }, + + { "engine.activateRequired", "至少需要保留一个激活快捷键。" }, + { "engine.windowModifier", "活动窗口定位模式必须包含至少一个修饰键。" }, + { "engine.altTabModifier", "Alt-Tab 自动居中需要一个包含修饰键的快捷键。" }, + { "engine.range", "{0}必须在 {1} 到 {2} 之间。" }, + { "engine.singleOnly", "{0} 仅支持单键,不支持组合修饰键。" }, + { "engine.required", "{0} 是必需操作,不能清空。" } + }; + + private static readonly Dictionary English = + new Dictionary(StringComparer.Ordinal) + { + { "app.title", "MouseMaster Configurator" }, + { "sidebar.subtitle", "Configurator" }, + { "sidebar.file", "Current configuration file" }, + { "search.cue", "Search actions or descriptions" }, + + { "focus.title", "Focus Mode" }, + { "focus.on", "Enabled: normal and sub-modes absorb all keyboard events" }, + { "focus.off", "While active, swallows all keyboard events and disables outbound macros" }, + + { "category.general.title", "General" }, + { "category.general.description", "Activation, exit and global behavior" }, + { "category.movement.title", "Movement" }, + { "category.movement.description", "Direction, speed and edge positioning" }, + { "category.mouse.title", "Mouse & Wheel" }, + { "category.mouse.description", "Clicks, drags and four-way scrolling" }, + { "category.modes.title", "Position Modes" }, + { "category.modes.description", "Grid, window, hint and multi-screen modes" }, + { "category.hints.title", "Hint Keys" }, + { "category.hints.description", "Label selection and undo keys" }, + { "category.automation.title", "Remapping" }, + { "category.automation.description", "Back/forward, arrow keys and Alt-Tab" }, + + { "num.mouse-max-velocity.title", "Mouse Max Speed" }, + { "num.mouse-max-velocity.description", "Normal-tier continuous-move speed limit; slow, fast and super-slow branches are unchanged." }, + { "num.mouse-acceleration.title", "Mouse Acceleration" }, + { "num.mouse-acceleration.description", "How fast the normal tier reaches max speed; set to 0 to disable acceleration." }, + { "num.wheel-max-velocity.title", "Wheel Max Speed" }, + { "num.wheel-max-velocity.description", "Normal-tier continuous-scroll speed limit; speed-modifier branches are unchanged." }, + { "num.wheel-acceleration.title", "Wheel Acceleration" }, + { "num.wheel-acceleration.description", "How fast the wheel reaches max speed; set to 0 to disable acceleration." }, + + { "action.activate.title", "Activate / Toggle Normal Mode" }, + { "action.activate.description", "Enter keyboard-mouse mode from idle; press again in normal mode to exit." }, + { "action.exit.title", "Exit to Idle Mode" }, + { "action.exit.description", "Exit immediately from normal, grid, level-2 hint and screen-selection modes." }, + { "action.clickdisable.title", "Exit After Left Click" }, + { "action.clickdisable.description", "Holds the left button while pressed; clicks and returns to idle on release." }, + { "action.passthroughexit.title", "Exit & Keep Original Shortcut" }, + { "action.passthroughexit.description", "Exit normal mode while letting the current app receive these shortcuts." }, + { "action.moveup.title", "Move Up" }, + { "action.moveup.description", "Continuous movement in normal mode; also used for edge, grid and window positioning." }, + { "action.movedown.title", "Move Down" }, + { "action.movedown.description", "Continuous movement in normal mode; also used for edge, grid and window positioning." }, + { "action.moveleft.title", "Move Left" }, + { "action.moveleft.description", "Continuous movement in normal mode; also used for edge, grid and window positioning." }, + { "action.moveright.title", "Move Right" }, + { "action.moveright.description", "Continuous movement in normal mode; also used for edge, grid and window positioning." }, + { "action.fast.title", "Fast Move" }, + { "action.fast.description", "Hold to raise mouse and wheel maximum speed and acceleration." }, + { "action.slow.title", "Slow Move" }, + { "action.slow.description", "Hold to lower mouse and wheel maximum speed." }, + { "action.superslow.title", "Super-Slow Move" }, + { "action.superslow.description", "Hold for fine positioning speed." }, + { "action.edge.title", "Screen Edge Mode" }, + { "action.edge.description", "Hold with arrow keys to jump to an edge of the current screen's usable area." }, + { "action.leftbutton.title", "Left Mouse Button" }, + { "action.leftbutton.description", "Supports hold and drag; Ctrl+click stays compatible without modifiers." }, + { "action.middlebutton.title", "Middle Mouse Button" }, + { "action.middlebutton.description", "Press and release map to the middle mouse button." }, + { "action.rightbutton.title", "Right Mouse Button" }, + { "action.rightbutton.description", "Supports hold and drag; Ctrl+click stays compatible without modifiers." }, + { "action.toggleleft.title", "Lock / Release Left Button" }, + { "action.toggleleft.description", "For long drags without holding a key down." }, + { "action.wheelup.title", "Scroll Up" }, + { "action.wheelup.description", "Hold to scroll, release to stop; supports combos and alternate keys." }, + { "action.wheeldown.title", "Scroll Down" }, + { "action.wheeldown.description", "Hold to scroll, release to stop; supports combos and alternate keys." }, + { "action.wheelleft.title", "Scroll Left" }, + { "action.wheelleft.description", "Can be set to a combo such as Shift + U." }, + { "action.wheelright.title", "Scroll Right" }, + { "action.wheelright.description", "Can be set to a combo such as Shift + O." }, + { "action.gridmode.title", "Screen Grid Mode" }, + { "action.gridmode.description", "Enter the grid; using the key again in the grid returns to normal mode." }, + { "action.windowmode.title", "Window Position Mode" }, + { "action.windowmode.description", "Hold the combo's modifier to enter; the main key jumps to the window center." }, + { "action.hintmode.title", "Screen Hint" }, + { "action.hintmode.description", "Show position labels on the active screen." }, + { "action.uihintmode.title", "UI Hint" }, + { "action.uihintmode.description", "Label controls exposed by Windows UI Automation in the active window." }, + { "action.hint2modifier.title", "Level-2 Hint Modifier" }, + { "action.hint2modifier.description", "Hold while a level-1 hint completes to enter a zoomed level-2 hint." }, + { "action.screenselection.title", "Screen Selection" }, + { "action.screenselection.description", "Show a label per monitor and move the mouse to it." }, + { "action.cancel.title", "Cancel / Back" }, + { "action.cancel.description", "Exit grid, hint, screen selection and UI hint." }, + { "action.hintback.title", "Hint Undo / Back" }, + { "action.hintback.description", "Undo label input, go back one hint level, or exit screen selection." }, + { "action.hint1keys.title", "Level-1 / UI Hint Keys" }, + { "action.hint1keys.description", "Generate level-1 screen hints at regular resolutions; also used for UI hints." }, + { "action.hint2keys.title", "Level-2 Hint Keys" }, + { "action.hint2keys.description", "Generate level-2 precision labels at regular resolutions." }, + { "action.extendedhint1keys.title", "Extended Level-1 Hint Keys" }, + { "action.extendedhint1keys.description", "40-key set used by 4K, QHD and ultrawide overlay configurations." }, + { "action.extendedhint2keys.title", "Extended Level-2 Hint Keys" }, + { "action.extendedhint2keys.description", "40-key set used for high-resolution level-2 hints." }, + { "action.screenhintkeys.title", "Monitor Label Keys" }, + { "action.screenhintkeys.description", "Assign labels in monitor order; up to nine shortcuts." }, + { "action.navigateback.title", "App Back" }, + { "action.navigateback.description", "Sends Alt + Left Arrow to the current app; focus mode disables output." }, + { "action.navigateforward.title", "App Forward" }, + { "action.navigateforward.description", "Sends Alt + Right Arrow to the current app; focus mode disables output." }, + { "action.arrowmodifier.title", "Arrow-Key Mapping Modifier" }, + { "action.arrowmodifier.description", "Hold to make movement keys emit system arrow keys; disabled in focus mode." }, + { "action.alttab.title", "Center After Alt-Tab" }, + { "action.alttab.description", "Move the mouse to the center of the newly active window after switching." }, + + { "search.title", "Search Results" }, + { "search.description", "Actions matching \"{0}\"" }, + { "section.count.one", "{0} setting" }, + { "section.count.many", "{0} settings" }, + { "alttab.title", "Enable Auto-Center After Alt-Tab" }, + { "alttab.description", "Keeps other remaps but stops tracking window switches." }, + { "row.empty", "No matching actions found." }, + + { "binding.none", "Click to set a shortcut" }, + { "binding.more", " · {0} total" }, + { "binding.clearTip", "Clear all shortcuts for this action" }, + + { "button.restore", "Restore Defaults" }, + { "button.reload", "Reload" }, + { "button.save", "Save & Apply" }, + + { "status.loading", "Reading configuration…" }, + { "status.ready", "Connected to the configuration file; changes hot-reload after saving." }, + { "status.reloaded", "Configuration reloaded." }, + { "status.loadFail", "Failed to read the configuration file: {0}" }, + { "status.dirty", "There are unsaved changes." }, + { "status.updated", "Updated \"{0}\" (not saved yet)." }, + { "status.saved", "Saved and applied; MouseMaster reloads the configuration automatically." }, + { "status.saveFail", "Save failed: {0}" }, + { "status.restored", "Defaults restored; the previous configuration is in the .gui-backup file." }, + { "status.restoreFail", "Restore failed: {0}" }, + { "status.conflict", "Conflicts found; conflicting keys on older actions were cleared." }, + + { "conflict.message", "Kept the new setting for \"{0}\" and cleared conflicts: {1}" }, + { "conflict.detail", "{1} from \"{0}\"" }, + { "conflict.joinChords", ", " }, + { "conflict.joinDetails", "; " }, + + { "msg.noSet", "Cannot Set Shortcut" }, + { "msg.conflict", "Key Conflicts Resolved Automatically" }, + { "msg.readFail", "Failed to Read Configuration" }, + { "msg.external", "External Changes Detected" }, + { "msg.external.body", "The configuration file changed outside this program. Merge the current GUI settings into the latest file?" }, + { "msg.saveFail", "Failed to Save Configuration" }, + { "msg.discard", "Discard Unsaved Changes" }, + { "msg.discard.body", "Reloading discards unsaved GUI changes. Continue?" }, + { "msg.restore", "Restore Default Settings" }, + { "msg.restore.body", "mousemaster.properties will immediately be restored to the configurator's built-in defaults.\n\nThe current file is first saved as mousemaster.properties.gui-backup." }, + { "msg.restoreFail", "Failed to Restore Defaults" }, + { "msg.unsaved", "Unsaved Changes" }, + { "msg.unsaved.body", "There are unsaved changes. Close anyway?" }, + + { "error.fatal", "MouseMaster Configurator Error" }, + { "error.unknown", "An unknown error occurred." }, + { "error.noConfig", "mousemaster.properties was not found." }, + + { "dialog.title", "Set Shortcut" }, + { "dialog.capture", "Click, then press the shortcut" }, + { "dialog.capturing", "Press a shortcut…" }, + { "dialog.hintModifiers", "Ctrl, Shift, Alt and Win combos are supported; release a modifier alone to bind the modifier itself." }, + { "dialog.hintSingle", "This action supports a single key only." }, + { "dialog.hintListening", "Listening… Esc, Tab and Enter can also be used directly as shortcuts." }, + { "dialog.hintReserved", "F24 is reserved by the configurator to mark cleared bindings; choose another key." }, + { "dialog.hintRejectCombo", "Single keys only; release the modifiers and try again." }, + { "dialog.captureMore", "Add Another Shortcut" }, + { "dialog.captureRedo", "Set Shortcut Again" }, + { "dialog.hintMore", "You can keep adding alternates; conflicts are resolved on save." }, + { "dialog.hintDone", "Conflicts are checked and resolved on save." }, + { "dialog.remove", "Remove Selected" }, + { "dialog.clear", "Clear All" }, + { "dialog.cancel", "Cancel" }, + { "dialog.save", "Use These Shortcuts" }, + { "dialog.modifierPending", "{0} (release to use)" }, + + { "engine.activateRequired", "At least one activation shortcut is required." }, + { "engine.windowModifier", "Window position mode requires at least one modifier." }, + { "engine.altTabModifier", "Auto-center after Alt-Tab requires a shortcut with a modifier." }, + { "engine.range", "{0} must be between {1} and {2}." }, + { "engine.singleOnly", "{0} supports single keys only, no modifier combos." }, + { "engine.required", "{0} is required and cannot be cleared." } + }; + } +} diff --git a/configurator/src/MainForm.cs b/configurator/src/MainForm.cs new file mode 100644 index 00000000..d5517780 --- /dev/null +++ b/configurator/src/MainForm.cs @@ -0,0 +1,1017 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Windows.Forms; + +namespace MouseMasterConfigurator +{ + internal sealed class MainForm : Form + { + private readonly string configurationPath; + private readonly ConfiguratorEngine engine; + private readonly List navigationButtons = + new List(); + + private ConfiguratorState state; + private string loadedSourceText; + private bool dirty; + private bool suppressToggleEvents; + private bool suppressLanguageEvents; + private string selectedCategoryId = "general"; + + private TableLayoutPanel mainLayout; + private FlowLayoutPanel rowsPanel; + private Panel bottomBar; + private Label subtitleLabel; + private Label fileCaptionLabel; + private ComboBox languageCombo; + private Label pageTitle; + private Label pageDescription; + private TextBox searchBox; + private Panel conflictPanel; + private Label conflictLabel; + private Label statusLabel; + private RoundedButton restoreButton; + private RoundedButton reloadButton; + private RoundedButton saveButton; + private ToggleSwitch focusToggle; + private Label focusTitle; + private Label focusDescription; + + private string statusMessage; + private Color statusColor = Color.Empty; + private ActionDefinition conflictCurrent; + private IList conflictChanges; + + public MainForm(string configurationPath) + { + this.configurationPath = Path.GetFullPath(configurationPath); + engine = new ConfiguratorEngine(); + + Text = L10n.T("app.title"); + StartPosition = FormStartPosition.CenterScreen; + BackColor = AppTheme.PageBackground; + ClientSize = DpiHelper.Sz(1180, 790); + MinimumSize = DpiHelper.Sz(980, 650); + // Layout metrics are scaled explicitly through DpiHelper; the built-in + // auto-scaler would be a no-op here because the whole tree is created + // at the current DPI. + AutoScaleMode = AutoScaleMode.None; + Font = AppTheme.Font(9.5f, FontStyle.Regular); + + InitializeLayout(); + LoadConfiguration(false); + FormClosing += OnFormClosing; + } + + private void InitializeLayout() + { + SuspendLayout(); + var sidebar = BuildSidebar(); + Controls.Add(sidebar); + + mainLayout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + BackColor = AppTheme.Surface, + ColumnCount = 1, + RowCount = 4, + Margin = new Padding(0), + Padding = new Padding(0) + }; + mainLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); + mainLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, DpiHelper.S(136))); + mainLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 0f)); + mainLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100f)); + mainLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, DpiHelper.S(72))); + + Panel header = BuildHeader(); + conflictPanel = BuildConflictPanel(); + rowsPanel = BuildRowsPanel(); + bottomBar = BuildBottomBar(); + + mainLayout.Controls.Add(header, 0, 0); + mainLayout.Controls.Add(conflictPanel, 0, 1); + mainLayout.Controls.Add(rowsPanel, 0, 2); + mainLayout.Controls.Add(bottomBar, 0, 3); + Controls.Add(mainLayout); + mainLayout.BringToFront(); + + ResumeLayout(true); + } + + private Panel BuildSidebar() + { + var sidebar = new Panel + { + Dock = DockStyle.Left, + Width = DpiHelper.S(240), + BackColor = AppTheme.Sidebar, + Padding = DpiHelper.Pd(18, 20, 18, 16) + }; + sidebar.Paint += delegate(object sender, PaintEventArgs e) + { + using (var pen = new Pen(AppTheme.Border)) + e.Graphics.DrawLine(pen, sidebar.Width - 1, 0, sidebar.Width - 1, sidebar.Height); + }; + + var logo = new LogoControl + { + Location = DpiHelper.Pt(18, 20), + Size = DpiHelper.Sz(38, 38) + }; + var brand = new Label + { + Text = "MouseMaster", + Font = AppTheme.Font(12.5f, FontStyle.Bold), + ForeColor = AppTheme.Text, + AutoSize = false, + Location = DpiHelper.Pt(66, 20), + Size = DpiHelper.Sz(158, 26) + }; + subtitleLabel = new Label + { + Text = L10n.T("sidebar.subtitle"), + Font = AppTheme.Font(8.8f, FontStyle.Regular), + ForeColor = AppTheme.MutedText, + AutoSize = false, + Location = DpiHelper.Pt(66, 47), + Size = DpiHelper.Sz(158, 20) + }; + sidebar.Controls.Add(logo); + sidebar.Controls.Add(brand); + sidebar.Controls.Add(subtitleLabel); + + int top = DpiHelper.S(96); + foreach (CategoryDefinition category in BindingCatalog.Categories) + { + var button = new NavigationButton + { + Text = category.Title, + Tag = category.Id, + Location = new Point(DpiHelper.S(18), top), + Size = DpiHelper.Sz(204, 44), + Selected = category.Id == selectedCategoryId + }; + button.Click += OnCategoryClick; + navigationButtons.Add(button); + sidebar.Controls.Add(button); + top += DpiHelper.S(50); + } + + languageCombo = new ComboBox + { + DropDownStyle = ComboBoxStyle.DropDownList, + Font = AppTheme.Font(9.5f, FontStyle.Regular), + ForeColor = AppTheme.Text, + BackColor = Color.White, + FlatStyle = FlatStyle.Flat, + Anchor = AnchorStyles.Left | AnchorStyles.Bottom, + Location = new Point(DpiHelper.S(18), sidebar.Height - DpiHelper.S(126)), + Width = DpiHelper.S(204) + }; + languageCombo.Items.Add("简体中文"); + languageCombo.Items.Add("English"); + suppressLanguageEvents = true; + languageCombo.SelectedIndex = L10n.Language == UiLanguage.English ? 1 : 0; + suppressLanguageEvents = false; + languageCombo.SelectedIndexChanged += OnLanguageChanged; + + fileCaptionLabel = new Label + { + Text = L10n.T("sidebar.file"), + Font = AppTheme.Font(8.5f, FontStyle.Bold), + ForeColor = AppTheme.MutedText, + AutoSize = false, + Size = DpiHelper.Sz(204, 20), + Anchor = AnchorStyles.Left | AnchorStyles.Bottom, + Location = new Point(DpiHelper.S(18), sidebar.Height - DpiHelper.S(92)) + }; + var filePath = new Label + { + Text = configurationPath, + Font = AppTheme.Font(8.2f, FontStyle.Regular), + ForeColor = AppTheme.MutedText, + AutoEllipsis = true, + AutoSize = false, + Size = DpiHelper.Sz(204, 40), + Anchor = AnchorStyles.Left | AnchorStyles.Bottom, + Location = new Point(DpiHelper.S(18), sidebar.Height - DpiHelper.S(68)) + }; + sidebar.Controls.Add(languageCombo); + sidebar.Controls.Add(fileCaptionLabel); + sidebar.Controls.Add(filePath); + sidebar.Resize += delegate + { + languageCombo.Top = sidebar.Height - DpiHelper.S(126); + fileCaptionLabel.Top = sidebar.Height - DpiHelper.S(92); + filePath.Top = sidebar.Height - DpiHelper.S(68); + }; + var toolTip = new ToolTip(); + toolTip.SetToolTip(filePath, configurationPath); + return sidebar; + } + + private Panel BuildHeader() + { + var header = new Panel + { + Dock = DockStyle.Fill, + BackColor = AppTheme.Surface, + Padding = DpiHelper.Pd(30, 20, 30, 16) + }; + header.Paint += delegate(object sender, PaintEventArgs e) + { + using (var pen = new Pen(AppTheme.Border)) + e.Graphics.DrawLine(pen, 0, header.Height - 1, header.Width, header.Height - 1); + }; + + pageTitle = new Label + { + Name = "pageTitle", + Text = L10n.T("category.general.title"), + Font = AppTheme.Font(18f, FontStyle.Bold), + ForeColor = AppTheme.Text, + AutoSize = false, + Location = DpiHelper.Pt(30, 17), + Size = DpiHelper.Sz(350, 34) + }; + pageDescription = new Label + { + Name = "pageDescription", + Text = L10n.T("category.general.description"), + Font = AppTheme.Font(9f, FontStyle.Regular), + ForeColor = AppTheme.MutedText, + AutoSize = false, + AutoEllipsis = true, + Location = DpiHelper.Pt(31, 56), + Size = DpiHelper.Sz(440, 22) + }; + searchBox = new TextBox + { + BorderStyle = BorderStyle.FixedSingle, + Font = AppTheme.Font(10f, FontStyle.Regular), + ForeColor = AppTheme.Text, + BackColor = Color.White, + Location = DpiHelper.Pt(30, 91), + Size = DpiHelper.Sz(360, 30) + }; + TextBoxCue.Set(searchBox, L10n.T("search.cue")); + searchBox.TextChanged += delegate { RenderRows(); }; + + focusTitle = new Label + { + Name = "focusTitle", + Text = L10n.T("focus.title"), + Font = AppTheme.Font(10.5f, FontStyle.Bold), + ForeColor = AppTheme.Text, + AutoSize = false, + Size = DpiHelper.Sz(210, 24), + TextAlign = ContentAlignment.MiddleRight, + Anchor = AnchorStyles.Top | AnchorStyles.Right + }; + focusDescription = new Label + { + Name = "focusDescription", + Text = L10n.T("focus.off"), + Font = AppTheme.Font(8.3f, FontStyle.Regular), + ForeColor = AppTheme.MutedText, + AutoSize = false, + Size = DpiHelper.Sz(700, 22), + TextAlign = ContentAlignment.MiddleRight, + Anchor = AnchorStyles.Top | AnchorStyles.Right + }; + focusToggle = new ToggleSwitch + { + Anchor = AnchorStyles.Top | AnchorStyles.Right + }; + focusToggle.CheckedChanged += delegate + { + if (suppressToggleEvents || state == null) + return; + state.FocusModeEnabled = focusToggle.Checked; + UpdateFocusAppearance(); + SetDirty(true); + }; + + header.Controls.Add(pageTitle); + header.Controls.Add(pageDescription); + header.Controls.Add(searchBox); + header.Controls.Add(focusTitle); + header.Controls.Add(focusDescription); + header.Controls.Add(focusToggle); + header.Resize += delegate + { + focusToggle.Left = header.Width - DpiHelper.S(30) - focusToggle.Width; + focusToggle.Top = DpiHelper.S(23); + focusTitle.Left = focusToggle.Left - focusTitle.Width - DpiHelper.S(12); + focusTitle.Top = DpiHelper.S(16); + int descriptionWidth = Math.Min( + DpiHelper.S(700), + focusToggle.Left - DpiHelper.S(12) - DpiHelper.S(20)); + focusDescription.Width = Math.Max(DpiHelper.S(200), descriptionWidth); + focusDescription.Left = focusToggle.Left - DpiHelper.S(12) - focusDescription.Width; + focusDescription.Top = DpiHelper.S(51); + searchBox.Width = Math.Min( + DpiHelper.S(420), + Math.Max(DpiHelper.S(280), header.Width - DpiHelper.S(600))); + }; + return header; + } + + private Panel BuildConflictPanel() + { + var panel = new Panel + { + Dock = DockStyle.Fill, + BackColor = AppTheme.WarningSoft, + Padding = DpiHelper.Pd(30, 0, 30, 0), + Visible = false + }; + conflictLabel = new Label + { + Dock = DockStyle.Fill, + Font = AppTheme.Font(9f, FontStyle.Regular), + ForeColor = AppTheme.Warning, + TextAlign = ContentAlignment.MiddleLeft, + AutoEllipsis = true + }; + var close = new RoundedButton + { + Text = "×", + Dock = DockStyle.Right, + Width = DpiHelper.S(32), + Radius = DpiHelper.S(16), + BorderThickness = 0, + FillColor = Color.Transparent, + HoverColor = Color.FromArgb(250, 232, 194), + PressedColor = Color.FromArgb(244, 220, 172), + TextColor = AppTheme.Warning, + Font = AppTheme.Font(13f, FontStyle.Regular) + }; + close.Click += delegate { HideConflictBanner(); }; + panel.Controls.Add(conflictLabel); + panel.Controls.Add(close); + return panel; + } + + private FlowLayoutPanel BuildRowsPanel() + { + var panel = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + BackColor = AppTheme.Surface, + AutoScroll = true, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + Padding = DpiHelper.Pd(30, 14, 30, 22), + Margin = new Padding(0) + }; + panel.SizeChanged += delegate { ResizeRows(); }; + return panel; + } + + private Panel BuildBottomBar() + { + var bottom = new Panel + { + Dock = DockStyle.Fill, + BackColor = AppTheme.Surface, + Padding = DpiHelper.Pd(30, 16, 30, 16) + }; + bottom.Paint += delegate(object sender, PaintEventArgs e) + { + using (var pen = new Pen(AppTheme.Border)) + e.Graphics.DrawLine(pen, 0, 0, bottom.Width, 0); + }; + + statusLabel = new Label + { + Text = L10n.T("status.loading"), + Font = AppTheme.Font(8.8f, FontStyle.Regular), + ForeColor = AppTheme.MutedText, + AutoSize = false, + Location = DpiHelper.Pt(30, 24), + Height = DpiHelper.S(24), + TextAlign = ContentAlignment.MiddleLeft, + AutoEllipsis = true, + Anchor = AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Top + }; + restoreButton = new RoundedButton + { + Text = L10n.T("button.restore"), + Size = DpiHelper.Sz(104, 40), + FillColor = Color.White, + HoverColor = AppTheme.DangerSoft, + PressedColor = Color.FromArgb(250, 220, 220), + BorderColor = AppTheme.Border, + TextColor = AppTheme.Danger, + Anchor = AnchorStyles.Top | AnchorStyles.Right + }; + restoreButton.Click += delegate { RestoreDefault(); }; + reloadButton = new RoundedButton + { + Text = L10n.T("button.reload"), + Size = DpiHelper.Sz(104, 40), + FillColor = Color.White, + HoverColor = Color.FromArgb(245, 247, 249), + PressedColor = Color.FromArgb(237, 240, 244), + BorderColor = AppTheme.Border, + TextColor = AppTheme.Text, + Anchor = AnchorStyles.Top | AnchorStyles.Right + }; + reloadButton.Click += delegate { ReloadRequested(); }; + saveButton = new RoundedButton + { + Text = L10n.T("button.save"), + Size = DpiHelper.Sz(124, 40), + FillColor = AppTheme.Accent, + HoverColor = AppTheme.AccentHover, + PressedColor = Color.FromArgb(30, 64, 175), + BorderThickness = 0, + TextColor = Color.White, + Font = AppTheme.Font(9.5f, FontStyle.Bold), + Anchor = AnchorStyles.Top | AnchorStyles.Right + }; + saveButton.Click += delegate { SaveConfiguration(); }; + + bottom.Controls.Add(statusLabel); + bottom.Controls.Add(restoreButton); + bottom.Controls.Add(reloadButton); + bottom.Controls.Add(saveButton); + bottom.Resize += delegate { LayoutBottomBar(); }; + FitBottomButtons(); + return bottom; + } + + private void FitBottomButtons() + { + RoundedButton.FitWidthToText(restoreButton, 104); + RoundedButton.FitWidthToText(reloadButton, 104); + RoundedButton.FitWidthToText(saveButton, 124); + LayoutBottomBar(); + } + + private void LayoutBottomBar() + { + if (bottomBar == null || saveButton == null) + return; + saveButton.Left = bottomBar.Width - DpiHelper.S(30) - saveButton.Width; + saveButton.Top = DpiHelper.S(16); + reloadButton.Left = saveButton.Left - DpiHelper.S(10) - reloadButton.Width; + reloadButton.Top = DpiHelper.S(16); + restoreButton.Left = reloadButton.Left - DpiHelper.S(10) - restoreButton.Width; + restoreButton.Top = DpiHelper.S(16); + statusLabel.Width = Math.Max(DpiHelper.S(200), restoreButton.Left - DpiHelper.S(48)); + } + + private void OnLanguageChanged(object sender, EventArgs e) + { + if (suppressLanguageEvents || languageCombo == null) + return; + UiLanguage selected = languageCombo.SelectedIndex == 1 + ? UiLanguage.English + : UiLanguage.Chinese; + if (selected == L10n.Language) + return; + L10n.Language = selected; + L10n.SavePreference(); + ApplyLanguage(); + } + + /// Re-applies every user-visible string after a language switch. + private void ApplyLanguage() + { + Text = L10n.T("app.title"); + if (subtitleLabel != null) + subtitleLabel.Text = L10n.T("sidebar.subtitle"); + if (fileCaptionLabel != null) + fileCaptionLabel.Text = L10n.T("sidebar.file"); + foreach (NavigationButton nav in navigationButtons) + nav.Text = BindingCatalog.FindCategory((string)nav.Tag).Title; + if (searchBox != null) + TextBoxCue.Set(searchBox, L10n.T("search.cue")); + if (focusTitle != null) + focusTitle.Text = L10n.T("focus.title"); + UpdateFocusAppearance(); + restoreButton.Text = L10n.T("button.restore"); + reloadButton.Text = L10n.T("button.reload"); + saveButton.Text = L10n.T("button.save"); + FitBottomButtons(); + if (state != null) + { + ShowStatus( + dirty ? L10n.T("status.dirty") : L10n.T("status.ready"), + dirty ? AppTheme.Warning : AppTheme.Success); + } + if (conflictCurrent != null && conflictPanel.Visible) + conflictLabel.Text = BuildConflictText(conflictCurrent, conflictChanges); + RenderRows(); + } + + protected override void WndProc(ref Message message) + { + if (message.Msg == DpiHelper.WmDpiChanged) + { + int newDpi = DpiHelper.DpiFromWParam(message.WParam); + if (newDpi != (int)Math.Round(DpiHelper.Factor * 96)) + { + DpiHelper.UpdateForDpi(newDpi); + DpiHelper.ApplySuggestedWindowRect(this, message.LParam); + RebuildForDpi(); + } + } + base.WndProc(ref message); + } + + /// + /// Recreates the whole control tree at the current DPI factor. The old + /// .NET 4.0-targeted WinForms runtime does not rescale PerMonitorV2 + /// windows on its own, so moving to another monitor triggers a rebuild. + /// + private void RebuildForDpi() + { + SuspendLayout(); + Controls.Clear(); + navigationButtons.Clear(); + MinimumSize = DpiHelper.Sz(980, 650); + InitializeLayout(); + if (state != null) + { + suppressToggleEvents = true; + focusToggle.Checked = state.FocusModeEnabled; + suppressToggleEvents = false; + } + UpdateFocusAppearance(); + ApplyLanguage(); + if (statusMessage != null) + { + ShowStatus( + statusMessage, + statusColor == Color.Empty ? AppTheme.MutedText : statusColor); + } + if (conflictCurrent != null) + { + conflictLabel.Text = BuildConflictText(conflictCurrent, conflictChanges); + conflictPanel.Visible = true; + mainLayout.RowStyles[1].Height = DpiHelper.S(44); + } + saveButton.Enabled = state != null; + ResumeLayout(true); + } + + private void OnCategoryClick(object sender, EventArgs e) + { + var button = sender as NavigationButton; + if (button == null) + return; + selectedCategoryId = (string)button.Tag; + searchBox.Text = string.Empty; + foreach (NavigationButton nav in navigationButtons) + nav.Selected = ReferenceEquals(nav, button); + RenderRows(); + } + + private void RenderRows() + { + if (rowsPanel == null || state == null) + return; + + rowsPanel.SuspendLayout(); + rowsPanel.Controls.Clear(); + string query = searchBox == null ? string.Empty : searchBox.Text.Trim(); + IEnumerable actions; + IEnumerable numericSettings; + string sectionTitle; + string sectionDescription; + + if (query.Length > 0) + { + actions = BindingCatalog.Actions.Where( + delegate(ActionDefinition action) + { + return action.Title.IndexOf(query, StringComparison.CurrentCultureIgnoreCase) >= 0 || + action.Description.IndexOf(query, StringComparison.CurrentCultureIgnoreCase) >= 0; + }); + numericSettings = BindingCatalog.NumericSettings.Where( + delegate(NumericSettingDefinition setting) + { + return setting.Title.IndexOf(query, StringComparison.CurrentCultureIgnoreCase) >= 0 || + setting.Description.IndexOf(query, StringComparison.CurrentCultureIgnoreCase) >= 0; + }); + sectionTitle = L10n.T("search.title"); + sectionDescription = L10n.F("search.description", query); + pageTitle.Text = sectionTitle; + pageDescription.Text = sectionDescription; + } + else + { + CategoryDefinition category = BindingCatalog.FindCategory(selectedCategoryId); + actions = BindingCatalog.Actions.Where( + delegate(ActionDefinition action) { return action.CategoryId == selectedCategoryId; }); + numericSettings = BindingCatalog.NumericSettings.Where( + delegate(NumericSettingDefinition setting) + { + return setting.CategoryId == selectedCategoryId; + }); + sectionTitle = category.Title; + sectionDescription = category.Description; + pageTitle.Text = sectionTitle; + pageDescription.Text = sectionDescription; + } + + var section = new Panel + { + Height = DpiHelper.S(52), + BackColor = AppTheme.Surface, + Margin = new Padding(0) + }; + var sectionLabel = new Label + { + Text = sectionTitle, + Font = AppTheme.Font(11.5f, FontStyle.Bold), + ForeColor = AppTheme.Text, + AutoSize = false, + AutoEllipsis = true, + Location = new Point(0, DpiHelper.S(8)), + Height = DpiHelper.S(25) + }; + int itemCount = actions.Count() + numericSettings.Count(); + var countLabel = new Label + { + Text = L10n.F(itemCount == 1 ? "section.count.one" : "section.count.many", itemCount), + Font = AppTheme.Font(8.5f, FontStyle.Regular), + ForeColor = AppTheme.MutedText, + AutoSize = false, + Location = new Point(0, DpiHelper.S(32)), + Height = DpiHelper.S(18) + }; + section.Controls.Add(sectionLabel); + section.Controls.Add(countLabel); + rowsPanel.Controls.Add(section); + + bool showAltTabToggle = query.Length == 0 && selectedCategoryId == "automation"; + if (showAltTabToggle) + { + var altTabToggle = new ToggleOptionRow( + L10n.T("alttab.title"), + L10n.T("alttab.description")) + { + Checked = state.AltTabCenteringEnabled + }; + altTabToggle.CheckedChanged += delegate + { + if (suppressToggleEvents) + return; + state.AltTabCenteringEnabled = altTabToggle.Checked; + SetDirty(true); + }; + rowsPanel.Controls.Add(altTabToggle); + } + + foreach (NumericSettingDefinition setting in numericSettings) + { + NumericSettingDefinition currentSetting = setting; + var row = new NumericOptionRow( + currentSetting, + state.GetNumericSetting(currentSetting.Id)); + row.ValueChanged += delegate + { + state.SetNumericSetting(currentSetting.Id, row.Value); + SetDirty(true); + ShowStatus( + L10n.F("status.updated", currentSetting.Title), + AppTheme.Warning); + }; + rowsPanel.Controls.Add(row); + } + + foreach (ActionDefinition action in actions) + { + var row = new BindingRow(action); + row.SetBindings(state.GetBindings(action.Id)); + row.EditRequested += OnEditBinding; + row.ClearRequested += OnClearBinding; + rowsPanel.Controls.Add(row); + } + + if (!actions.Any() && !numericSettings.Any()) + { + var empty = new Label + { + Text = L10n.T("row.empty"), + Font = AppTheme.Font(10f, FontStyle.Regular), + ForeColor = AppTheme.MutedText, + TextAlign = ContentAlignment.MiddleCenter, + Height = DpiHelper.S(120), + Margin = new Padding(0) + }; + rowsPanel.Controls.Add(empty); + } + + ResizeRows(); + rowsPanel.ResumeLayout(true); + } + + private void ResizeRows() + { + if (rowsPanel == null) + return; + int width = Math.Max( + DpiHelper.S(520), + rowsPanel.ClientSize.Width - rowsPanel.Padding.Horizontal - DpiHelper.S(20)); + foreach (Control control in rowsPanel.Controls) + control.Width = width; + } + + private void OnEditBinding(object sender, EventArgs e) + { + var row = sender as BindingRow; + if (row == null) + return; + using (var dialog = new KeyCaptureDialog(row.Definition, state.GetBindings(row.Definition.Id))) + { + if (dialog.ShowDialog(this) != DialogResult.OK) + return; + ApplyBindingEdit(row.Definition, dialog.Result); + } + } + + private void OnClearBinding(object sender, EventArgs e) + { + var row = sender as BindingRow; + if (row == null) + return; + ApplyBindingEdit(row.Definition, new KeyChord[0]); + } + + private void ApplyBindingEdit(ActionDefinition definition, IEnumerable requested) + { + try + { + IList conflicts = ConflictResolver.ApplyEdit(state, definition, requested); + SetDirty(true); + RenderRows(); + if (conflicts.Count > 0) + ShowConflicts(definition, conflicts); + else + ShowStatus(L10n.F("status.updated", definition.Title), AppTheme.Warning); + } + catch (Exception exception) + { + MessageBox.Show( + this, + exception.Message, + L10n.T("msg.noSet"), + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + } + + private void ShowConflicts(ActionDefinition current, IList changes) + { + conflictCurrent = current; + conflictChanges = changes; + string compact = BuildConflictText(current, changes); + conflictLabel.Text = compact; + conflictPanel.Visible = true; + mainLayout.RowStyles[1].Height = DpiHelper.S(44); + System.Media.SystemSounds.Exclamation.Play(); + MessageBox.Show( + this, + compact, + L10n.T("msg.conflict"), + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + ShowStatus(L10n.T("status.conflict"), AppTheme.Warning); + } + + private static string BuildConflictText( + ActionDefinition current, + IEnumerable changes) + { + var details = new List(); + foreach (ConflictChange change in changes) + { + string removed = string.Join( + L10n.T("conflict.joinChords"), + change.Removed.Select(delegate(KeyChord chord) { return chord.DisplayText(); }).ToArray()); + details.Add(L10n.F("conflict.detail", change.Action.Title, removed)); + } + return L10n.F( + "conflict.message", + current.Title, + string.Join(L10n.T("conflict.joinDetails"), details.ToArray())); + } + + private void HideConflictBanner() + { + conflictPanel.Visible = false; + conflictCurrent = null; + conflictChanges = null; + mainLayout.RowStyles[1].Height = 0f; + } + + private void LoadConfiguration(bool showSuccess) + { + try + { + if (!File.Exists(configurationPath)) + throw new FileNotFoundException(L10n.T("error.noConfig"), configurationPath); + loadedSourceText = File.ReadAllText(configurationPath); + state = engine.LoadState(loadedSourceText); + dirty = false; + suppressToggleEvents = true; + focusToggle.Checked = state.FocusModeEnabled; + suppressToggleEvents = false; + UpdateFocusAppearance(); + saveButton.Enabled = true; + RenderRows(); + HideConflictBanner(); + ShowStatus( + showSuccess ? L10n.T("status.reloaded") : L10n.T("status.ready"), + AppTheme.Success); + } + catch (Exception exception) + { + saveButton.Enabled = false; + ShowStatus(L10n.F("status.loadFail", exception.Message), AppTheme.Danger); + MessageBox.Show( + this, + exception.Message, + L10n.T("msg.readFail"), + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + } + + private void SaveConfiguration() + { + if (state == null) + return; + try + { + string latest = File.ReadAllText(configurationPath); + if (!string.Equals(latest, loadedSourceText, StringComparison.Ordinal)) + { + DialogResult result = MessageBox.Show( + this, + L10n.T("msg.external.body"), + L10n.T("msg.external"), + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + if (result != DialogResult.Yes) + return; + } + + string output = engine.Apply(latest, state); + AtomicFile.WriteAllText(configurationPath, output, true); + loadedSourceText = output; + state = engine.LoadState(output); + dirty = false; + suppressToggleEvents = true; + focusToggle.Checked = state.FocusModeEnabled; + suppressToggleEvents = false; + UpdateFocusAppearance(); + RenderRows(); + HideConflictBanner(); + ShowStatus(L10n.T("status.saved"), AppTheme.Success); + } + catch (Exception exception) + { + ShowStatus(L10n.F("status.saveFail", exception.Message), AppTheme.Danger); + MessageBox.Show( + this, + exception.Message, + L10n.T("msg.saveFail"), + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + } + + private void ReloadRequested() + { + if (dirty) + { + DialogResult result = MessageBox.Show( + this, + L10n.T("msg.discard.body"), + L10n.T("msg.discard"), + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + if (result != DialogResult.Yes) + return; + } + LoadConfiguration(true); + } + + private void RestoreDefault() + { + DialogResult result = MessageBox.Show( + this, + L10n.T("msg.restore.body"), + L10n.T("msg.restore"), + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + if (result != DialogResult.Yes) + return; + + try + { + engine.RestoreDefault(configurationPath); + LoadConfiguration(false); + ShowStatus(L10n.T("status.restored"), AppTheme.Success); + } + catch (Exception exception) + { + ShowStatus(L10n.F("status.restoreFail", exception.Message), AppTheme.Danger); + MessageBox.Show( + this, + exception.Message, + L10n.T("msg.restoreFail"), + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + } + + private void SetDirty(bool value) + { + dirty = value; + if (value) + ShowStatus(L10n.T("status.dirty"), AppTheme.Warning); + } + + private void ShowStatus(string message, Color color) + { + statusMessage = message; + statusColor = color; + if (statusLabel == null) + return; + statusLabel.Text = message; + statusLabel.ForeColor = color; + } + + private void UpdateFocusAppearance() + { + bool enabled = state != null && state.FocusModeEnabled; + focusTitle.ForeColor = enabled ? AppTheme.Success : AppTheme.Text; + focusDescription.ForeColor = enabled ? AppTheme.Success : AppTheme.MutedText; + focusDescription.Text = L10n.T(enabled ? "focus.on" : "focus.off"); + } + + private void OnFormClosing(object sender, FormClosingEventArgs e) + { + if (!dirty) + return; + DialogResult result = MessageBox.Show( + this, + L10n.T("msg.unsaved.body"), + L10n.T("msg.unsaved"), + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + if (result != DialogResult.Yes) + e.Cancel = true; + } + } + + internal sealed class LogoControl : Control + { + public LogoControl() + { + SetStyle( + ControlStyles.AllPaintingInWmPaint | + ControlStyles.OptimizedDoubleBuffer | + ControlStyles.ResizeRedraw | + ControlStyles.UserPaint, + true); + } + + protected override void OnPaint(PaintEventArgs e) + { + e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + Rectangle bounds = new Rectangle(0, 0, Width - 1, Height - 1); + using (System.Drawing.Drawing2D.GraphicsPath path = + RoundedButton.RoundedRectangle(bounds, DpiHelper.S(7))) + using (var background = new SolidBrush(AppTheme.Accent)) + e.Graphics.FillPath(background, path); + + using (var pen = new Pen(Color.White, 1.8f * DpiHelper.Factor)) + { + int centerX = Width / 2; + int centerY = Height / 2; + e.Graphics.DrawEllipse( + pen, + centerX - DpiHelper.S(6), + centerY - DpiHelper.S(6), + DpiHelper.S(12), + DpiHelper.S(12)); + e.Graphics.DrawLine(pen, centerX, DpiHelper.S(7), centerX, DpiHelper.S(13)); + e.Graphics.DrawLine(pen, centerX, Height - DpiHelper.S(7), centerX, Height - DpiHelper.S(13)); + e.Graphics.DrawLine(pen, DpiHelper.S(7), centerY, DpiHelper.S(13), centerY); + e.Graphics.DrawLine(pen, Width - DpiHelper.S(7), centerY, Width - DpiHelper.S(13), centerY); + } + } + } +} diff --git a/configurator/src/Models.cs b/configurator/src/Models.cs new file mode 100644 index 00000000..611be842 --- /dev/null +++ b/configurator/src/Models.cs @@ -0,0 +1,463 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MouseMasterConfigurator +{ + internal sealed class KeyChord : IEquatable + { + private static readonly string[] ModifierOrder = + { + "leftctrl", "rightctrl", + "leftshift", "rightshift", + "leftalt", "rightalt", + "leftwin", "rightwin" + }; + + private readonly List modifiers; + + public KeyChord(IEnumerable modifiers, string key) + { + if (key == null) + throw new ArgumentNullException("key"); + + Key = NormalizeKey(key); + this.modifiers = modifiers == null + ? new List() + : modifiers.Select(NormalizeKey) + .Where(delegate(string item) { return item.Length > 0 && item != Key; }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(ModifierRank) + .ThenBy(delegate(string item) { return item; }, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public string Key { get; private set; } + + public IList Modifiers + { + get { return modifiers.AsReadOnly(); } + } + + public bool HasModifiers + { + get { return modifiers.Count > 0; } + } + + public string Serialize() + { + if (modifiers.Count == 0) + return Key; + return string.Join("+", modifiers.Concat(new[] { Key }).ToArray()); + } + + public string DisplayText() + { + IEnumerable parts = modifiers.Concat(new[] { Key }).Select(KeyNames.FriendlyName); + return string.Join(" + ", parts.ToArray()); + } + + public override string ToString() + { + return DisplayText(); + } + + public KeyChord Clone() + { + return new KeyChord(modifiers, Key); + } + + public static KeyChord Parse(string serialized) + { + if (string.IsNullOrWhiteSpace(serialized)) + throw new ArgumentException("Shortcut cannot be blank.", "serialized"); + + string[] parts = serialized.Split(new[] { '+' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + throw new ArgumentException("Shortcut cannot be blank.", "serialized"); + if (parts.Length == 1) + return new KeyChord(new string[0], parts[0]); + return new KeyChord(parts.Take(parts.Length - 1), parts[parts.Length - 1]); + } + + public static List ParseList(string serialized) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(serialized) || serialized.Trim() == "(none)") + return result; + + foreach (string item in serialized.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + { + KeyChord chord = Parse(item.Trim()); + if (!result.Contains(chord)) + result.Add(chord); + } + return result; + } + + public static string SerializeList(IEnumerable chords) + { + if (chords == null) + return "(none)"; + string[] values = chords.Distinct().Select(delegate(KeyChord chord) { return chord.Serialize(); }).ToArray(); + return values.Length == 0 ? "(none)" : string.Join("|", values); + } + + public static bool IsModifier(string key) + { + string normalized = NormalizeKey(key); + return ModifierOrder.Contains(normalized, StringComparer.OrdinalIgnoreCase); + } + + public static string NormalizeKey(string key) + { + return (key ?? string.Empty).Trim().ToLowerInvariant(); + } + + private static int ModifierRank(string key) + { + for (int index = 0; index < ModifierOrder.Length; index++) + { + if (string.Equals(ModifierOrder[index], key, StringComparison.OrdinalIgnoreCase)) + return index; + } + return ModifierOrder.Length; + } + + public bool Equals(KeyChord other) + { + if (ReferenceEquals(other, null)) + return false; + return string.Equals(Key, other.Key, StringComparison.OrdinalIgnoreCase) && + modifiers.SequenceEqual(other.modifiers, StringComparer.OrdinalIgnoreCase); + } + + public override bool Equals(object obj) + { + return Equals(obj as KeyChord); + } + + public override int GetHashCode() + { + unchecked + { + int hash = StringComparer.OrdinalIgnoreCase.GetHashCode(Key); + foreach (string modifier in modifiers) + hash = (hash * 397) ^ StringComparer.OrdinalIgnoreCase.GetHashCode(modifier); + return hash; + } + } + } + + internal static class KeyNames + { + private static readonly Dictionary FriendlyNames = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "leftctrl", "Left Ctrl" }, + { "rightctrl", "Right Ctrl" }, + { "leftshift", "Left Shift" }, + { "rightshift", "Right Shift" }, + { "leftalt", "Left Alt" }, + { "rightalt", "Right Alt" }, + { "leftwin", "Left Win" }, + { "rightwin", "Right Win" }, + { "capslock", "Caps Lock" }, + { "backspace", "Backspace" }, + { "enter", "Enter" }, + { "esc", "Esc" }, + { "space", "Space" }, + { "tab", "Tab" }, + { "delete", "Delete" }, + { "insert", "Insert" }, + { "home", "Home" }, + { "end", "End" }, + { "pageup", "Page Up" }, + { "pagedown", "Page Down" }, + { "uparrow", "Up Arrow" }, + { "downarrow", "Down Arrow" }, + { "leftarrow", "Left Arrow" }, + { "rightarrow", "Right Arrow" }, + { ",", "," }, + { ".", "." }, + { ";", ";" }, + { "'", "'" }, + { "/", "/" }, + { "minus", "-" }, + { "equals", "=" }, + { "openbracket", "[" }, + { "closebracket", "]" }, + { "backslash", "\\" }, + { "backtick", "`" } + }; + + public static string FriendlyName(string key) + { + string result; + if (FriendlyNames.TryGetValue(KeyChord.NormalizeKey(key), out result)) + return result; + if (key != null && key.Length == 1) + return key.ToUpperInvariant(); + return string.IsNullOrEmpty(key) ? string.Empty : char.ToUpperInvariant(key[0]) + key.Substring(1); + } + } + + internal sealed class CategoryDefinition + { + public CategoryDefinition(string id) + { + Id = id; + } + + public string Id { get; private set; } + + public string Title + { + get { return L10n.T("category." + Id + ".title"); } + } + + public string Description + { + get { return L10n.T("category." + Id + ".description"); } + } + } + + internal sealed class ActionDefinition + { + public ActionDefinition( + string id, + string categoryId, + string defaultBindings, + string aliasName, + bool allowModifiers, + bool allowMultiple, + bool canClear, + params string[] conflictScopes) + { + Id = id; + CategoryId = categoryId; + DefaultBindings = defaultBindings; + AliasName = aliasName; + AllowModifiers = allowModifiers; + AllowMultiple = allowMultiple; + CanClear = canClear; + ConflictScopes = conflictScopes ?? new string[0]; + } + + public string Id { get; private set; } + public string CategoryId { get; private set; } + public string DefaultBindings { get; private set; } + public string AliasName { get; private set; } + public bool AllowModifiers { get; private set; } + public bool AllowMultiple { get; private set; } + public bool CanClear { get; private set; } + public string[] ConflictScopes { get; private set; } + + public string Title + { + get { return L10n.T("action." + Id + ".title"); } + } + + public string Description + { + get { return L10n.T("action." + Id + ".description"); } + } + } + + internal sealed class NumericSettingDefinition + { + public NumericSettingDefinition( + string id, + string categoryId, + string unit, + int minimum, + int maximum, + int increment) + { + Id = id; + CategoryId = categoryId; + Unit = unit; + Minimum = minimum; + Maximum = maximum; + Increment = increment; + } + + public string Id { get; private set; } + public string CategoryId { get; private set; } + public string Unit { get; private set; } + public int Minimum { get; private set; } + public int Maximum { get; private set; } + public int Increment { get; private set; } + + public string Title + { + get { return L10n.T("num." + Id + ".title"); } + } + + public string Description + { + get { return L10n.T("num." + Id + ".description"); } + } + } + + internal sealed class ConfiguratorState + { + private readonly Dictionary> bindings = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + public bool FocusModeEnabled { get; set; } + public bool AltTabCenteringEnabled { get; set; } + public int MouseMaxVelocity { get; set; } + public int MouseAcceleration { get; set; } + public int WheelMaxVelocity { get; set; } + public int WheelAcceleration { get; set; } + + public IList GetBindings(string actionId) + { + List value; + if (!bindings.TryGetValue(actionId, out value)) + { + value = new List(); + bindings[actionId] = value; + } + return value; + } + + public void SetBindings(string actionId, IEnumerable value) + { + bindings[actionId] = value == null + ? new List() + : value.Select(delegate(KeyChord chord) { return chord.Clone(); }).Distinct().ToList(); + } + + public int GetNumericSetting(string settingId) + { + switch (settingId) + { + case "mouse-max-velocity": + return MouseMaxVelocity; + case "mouse-acceleration": + return MouseAcceleration; + case "wheel-max-velocity": + return WheelMaxVelocity; + case "wheel-acceleration": + return WheelAcceleration; + default: + throw new ArgumentException("Unknown numeric setting: " + settingId, "settingId"); + } + } + + public void SetNumericSetting(string settingId, int value) + { + switch (settingId) + { + case "mouse-max-velocity": + MouseMaxVelocity = value; + break; + case "mouse-acceleration": + MouseAcceleration = value; + break; + case "wheel-max-velocity": + WheelMaxVelocity = value; + break; + case "wheel-acceleration": + WheelAcceleration = value; + break; + default: + throw new ArgumentException("Unknown numeric setting: " + settingId, "settingId"); + } + } + + public ConfiguratorState Clone() + { + var clone = new ConfiguratorState + { + FocusModeEnabled = FocusModeEnabled, + AltTabCenteringEnabled = AltTabCenteringEnabled, + MouseMaxVelocity = MouseMaxVelocity, + MouseAcceleration = MouseAcceleration, + WheelMaxVelocity = WheelMaxVelocity, + WheelAcceleration = WheelAcceleration + }; + foreach (KeyValuePair> pair in bindings) + clone.SetBindings(pair.Key, pair.Value); + return clone; + } + + public static ConfiguratorState CreateDefaults() + { + var state = new ConfiguratorState + { + FocusModeEnabled = false, + AltTabCenteringEnabled = true, + MouseMaxVelocity = 2200, + MouseAcceleration = 3000, + WheelMaxVelocity = 2000, + WheelAcceleration = 500 + }; + foreach (ActionDefinition definition in BindingCatalog.Actions) + state.SetBindings(definition.Id, KeyChord.ParseList(definition.DefaultBindings)); + return state; + } + } + + internal sealed class ConflictChange + { + public ConflictChange(ActionDefinition action, IList removed) + { + Action = action; + Removed = removed; + } + + public ActionDefinition Action { get; private set; } + public IList Removed { get; private set; } + } + + internal static class ConflictResolver + { + public static IList ApplyEdit( + ConfiguratorState state, + ActionDefinition current, + IEnumerable requested) + { + var normalized = requested == null + ? new List() + : requested.Select(delegate(KeyChord chord) { return chord.Clone(); }).Distinct().ToList(); + + if (!current.AllowMultiple && normalized.Count > 1) + normalized = normalized.Take(1).ToList(); + if (!current.AllowModifiers && normalized.Any(delegate(KeyChord chord) { return chord.HasModifiers; })) + throw new InvalidOperationException(L10n.F("engine.singleOnly", current.Title)); + if (!current.CanClear && normalized.Count == 0) + throw new InvalidOperationException(L10n.F("engine.required", current.Title)); + + state.SetBindings(current.Id, normalized); + var changes = new List(); + foreach (ActionDefinition other in BindingCatalog.Actions) + { + if (string.Equals(other.Id, current.Id, StringComparison.OrdinalIgnoreCase)) + continue; + if (!ScopesOverlap(current, other)) + continue; + + List existing = state.GetBindings(other.Id).Select( + delegate(KeyChord chord) { return chord.Clone(); }).ToList(); + List removed = existing.Where( + delegate(KeyChord oldChord) { return normalized.Contains(oldChord); }).ToList(); + if (removed.Count == 0) + continue; + + existing.RemoveAll(delegate(KeyChord oldChord) { return normalized.Contains(oldChord); }); + state.SetBindings(other.Id, existing); + changes.Add(new ConflictChange(other, removed)); + } + return changes; + } + + private static bool ScopesOverlap(ActionDefinition first, ActionDefinition second) + { + return first.ConflictScopes.Intersect( + second.ConflictScopes, StringComparer.OrdinalIgnoreCase).Any(); + } + } +} diff --git a/configurator/src/Program.cs b/configurator/src/Program.cs new file mode 100644 index 00000000..a5c821cd --- /dev/null +++ b/configurator/src/Program.cs @@ -0,0 +1,145 @@ +using System; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Windows.Forms; + +[assembly: System.Reflection.AssemblyTitle("MouseMaster Configurator")] +[assembly: System.Reflection.AssemblyDescription("GUI configurator for MouseMaster")] +[assembly: System.Reflection.AssemblyCompany("Rezetyan")] +[assembly: System.Reflection.AssemblyProduct("MouseMaster Configurator")] +[assembly: System.Reflection.AssemblyVersion("2.1.0.0")] +[assembly: System.Reflection.AssemblyFileVersion("2.1.0.0")] + +namespace MouseMasterConfigurator +{ + internal static class Program + { + [STAThread] + private static void Main(string[] args) + { + if (args.Length > 0 && + string.Equals(args[0], "--self-test", StringComparison.OrdinalIgnoreCase)) + { + string reportPath = args.Length > 1 + ? Path.GetFullPath(args[1]) + : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "MouseMasterConfigurator.self-test.log"); + string report; + string acceptanceConfiguration; + int exitCode = SelfTests.Run(out report, out acceptanceConfiguration); + File.WriteAllText(reportPath, report, new UTF8Encoding(false)); + if (exitCode == 0 && args.Length > 2) + { + string acceptancePath = Path.GetFullPath(args[2]); + string acceptanceDirectory = Path.GetDirectoryName(acceptancePath); + if (!string.IsNullOrEmpty(acceptanceDirectory)) + Directory.CreateDirectory(acceptanceDirectory); + File.WriteAllText( + acceptancePath, + acceptanceConfiguration, + new UTF8Encoding(false)); + } + Environment.ExitCode = exitCode; + return; + } + + // DPI awareness comes from app.manifest (PerMonitorV2); layout metrics + // are scaled explicitly via DpiHelper. + L10n.Language = L10n.LoadPreference(); + + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); + Application.ThreadException += delegate(object sender, ThreadExceptionEventArgs eventArgs) + { + ShowFatalError(eventArgs.Exception); + }; + AppDomain.CurrentDomain.UnhandledException += delegate(object sender, UnhandledExceptionEventArgs eventArgs) + { + ShowFatalError(eventArgs.ExceptionObject as Exception); + }; + + string configurationPath = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, + "mousemaster.properties"); + if (args.Length >= 2 && + string.Equals(args[0], "--config", StringComparison.OrdinalIgnoreCase)) + configurationPath = Path.GetFullPath(args[1]); + + using (var form = new MainForm(configurationPath)) + { + form.Icon = AppIcon.Create(); + Application.Run(form); + } + } + + private static void ShowFatalError(Exception exception) + { + string message = exception == null ? L10n.T("error.unknown") : exception.ToString(); + try + { + string logPath = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, + "MouseMasterConfigurator.error.log"); + File.WriteAllText(logPath, message, new UTF8Encoding(false)); + } + catch + { + } + MessageBox.Show( + message, + L10n.T("error.fatal"), + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + } + + internal static class AppIcon + { + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern bool DestroyIcon(IntPtr handle); + + public static Icon Create() + { + using (var bitmap = new Bitmap(32, 32)) + using (Graphics graphics = Graphics.FromImage(bitmap)) + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.Clear(Color.Transparent); + using (var background = new SolidBrush(AppTheme.Accent)) + graphics.FillRoundedRectangle(background, new Rectangle(1, 1, 30, 30), 7); + using (var pen = new Pen(Color.White, 2f)) + { + graphics.DrawEllipse(pen, 10, 10, 12, 12); + graphics.DrawLine(pen, 16, 5, 16, 10); + graphics.DrawLine(pen, 16, 22, 16, 27); + graphics.DrawLine(pen, 5, 16, 10, 16); + graphics.DrawLine(pen, 22, 16, 27, 16); + } + IntPtr handle = bitmap.GetHicon(); + try + { + using (Icon temporary = Icon.FromHandle(handle)) + return (Icon)temporary.Clone(); + } + finally + { + DestroyIcon(handle); + } + } + } + + private static void FillRoundedRectangle( + this Graphics graphics, + Brush brush, + Rectangle bounds, + int radius) + { + using (GraphicsPath path = RoundedButton.RoundedRectangle(bounds, radius)) + graphics.FillPath(brush, path); + } + } +} diff --git a/configurator/src/SelfTests.cs b/configurator/src/SelfTests.cs new file mode 100644 index 00000000..f2aa26ae --- /dev/null +++ b/configurator/src/SelfTests.cs @@ -0,0 +1,669 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Windows.Forms; + +namespace MouseMasterConfigurator +{ + internal static class SelfTests + { + private static readonly List Messages = new List(); + private static int assertionCount; + + public static int Run(out string report, out string acceptanceConfiguration) + { + Messages.Clear(); + assertionCount = 0; + acceptanceConfiguration = null; + // Never persist language choices made by the headless UI tests. + L10n.PersistenceEnabled = false; + try + { + acceptanceConfiguration = RunAll(); + Messages.Add("PASS: " + assertionCount + " assertions"); + report = string.Join(Environment.NewLine, Messages.ToArray()) + Environment.NewLine; + return 0; + } + catch (Exception exception) + { + Messages.Add("FAIL: " + exception); + report = string.Join(Environment.NewLine, Messages.ToArray()) + Environment.NewLine; + return 1; + } + } + + private static string RunAll() + { + string defaultText = EmbeddedDefault.ReadText(); + Assert(defaultText.Contains("key-alias.up.us-qwerty=i"), "embedded default is the expected IJKL profile"); + Assert(BindingCatalog.Actions.Count >= 36, "catalog exposes all documented operation groups"); + Assert(BindingCatalog.NumericSettings.Count == 4, "catalog exposes four motion controls"); + Assert( + KeyCaptureMap.ResolveModifiers( + Keys.Control | Keys.M, + new string[0], + null).SequenceEqual(new[] { "leftctrl" }, StringComparer.OrdinalIgnoreCase), + "keyData preserves Ctrl when asynchronous modifier state is already clear"); + Assert( + KeyCaptureMap.SpecificModifier(Keys.ControlKey) == "leftctrl" && + KeyCaptureMap.SpecificModifier(Keys.Menu) == "leftalt" && + KeyCaptureMap.SpecificModifier(Keys.ShiftKey) == "leftshift", + "generic modifier fallbacks preserve the modifier family"); + Assert( + KeyChord.Parse("leftctrl+m").ToString() == "Left Ctrl + M", + "key chords expose readable text to accessibility clients"); + Assert( + KeyCaptureMap.ToMouseMasterKey(Keys.Delete) == "del", + "Delete uses the MouseMaster v89 key name"); + Assert( + KeyCaptureMap.ToMouseMasterKey(Keys.Apps) == "menu", + "the application menu key uses the MouseMaster v89 key name"); + Assert( + KeyCaptureMap.ToMouseMasterKey(Keys.Oemplus) == "=", + "the unshifted equals key uses its layout character"); + Assert( + KeyCaptureMap.ToMouseMasterKey(Keys.OemOpenBrackets) == "[", + "the open bracket key uses its layout character"); + Assert( + KeyCaptureMap.ToMouseMasterKey(Keys.OemCloseBrackets) == "]", + "the close bracket key uses its layout character"); + Assert( + KeyCaptureMap.ToMouseMasterKey(Keys.Oemtilde) == "`", + "the backtick key uses its layout character"); + Assert( + KeyCaptureMap.ToMouseMasterKey(Keys.OemMinus) == "minus" && + KeyCaptureMap.ToMouseMasterKey(Keys.OemPipe) == "backslash", + "reserved combo characters keep their MouseMaster static names"); + Assert( + KeyCaptureMap.ToMouseMasterKey(Keys.F24) == null, + "F24 is reserved for cleared bindings"); + VerifyVisualLayout(defaultText); + + ConfiguratorState state = ConfiguratorState.CreateDefaults(); + var conflicts = new List(); + conflicts.AddRange(Edit(state, "activate", "leftctrl+m")); + conflicts.AddRange(Edit(state, "moveup", "k")); + conflicts.AddRange(Edit(state, "movedown", "j")); + conflicts.AddRange(Edit(state, "moveleft", "h")); + conflicts.AddRange(Edit(state, "moveright", "l")); + conflicts.AddRange(Edit(state, "wheelup", "u")); + conflicts.AddRange(Edit(state, "wheeldown", "o")); + conflicts.AddRange(Edit(state, "wheelleft", "leftshift+u")); + conflicts.AddRange(Edit(state, "wheelright", "leftshift+o")); + state.FocusModeEnabled = true; + state.AltTabCenteringEnabled = true; + state.MouseMaxVelocity = 4321; + state.MouseAcceleration = 5432; + state.WheelMaxVelocity = 6543; + state.WheelAcceleration = 765; + + Assert(state.GetBindings("navigateback").Count == 0, "Vim H clears the old Navigate Back binding"); + Assert(conflicts.Any(delegate(ConflictChange change) { return change.Action.Id == "navigateback"; }), + "conflict report includes Navigate Back"); + Assert(conflicts.Any(delegate(ConflictChange change) { return change.Action.Id == "wheelleft"; }), + "U vertical scrolling clears the former U horizontal binding"); + Assert(conflicts.Any(delegate(ConflictChange change) { return change.Action.Id == "wheelright"; }), + "O vertical scrolling clears the former O horizontal binding"); + Assert( + state.GetBindings("wheelleft").Single().Equals(KeyChord.Parse("leftshift+u")), + "Shift+U remains after exact-chord conflict resolution"); + + var engine = new ConfiguratorEngine(); + string motionSource = defaultText + .Replace( + "normal-mode.mouse.max-velocity=2200 |", + "normal-mode.mouse.max-velocity=2345 |") + .Replace( + "normal-mode.mouse.acceleration=3000 |", + "normal-mode.mouse.acceleration=3456 |") + .Replace( + "normal-mode.wheel.max-velocity=2000 |", + "normal-mode.wheel.max-velocity=4567 |") + .Replace( + "normal-mode.wheel.acceleration=500 |", + "normal-mode.wheel.acceleration=678 |"); + ConfiguratorState importedMotion = engine.LoadState(motionSource); + Assert( + importedMotion.MouseMaxVelocity == 2345 && + importedMotion.MouseAcceleration == 3456 && + importedMotion.WheelMaxVelocity == 4567 && + importedMotion.WheelAcceleration == 678, + "motion settings import the default branch from existing properties"); + ConfiguratorState invalidMotion = ConfiguratorState.CreateDefaults(); + invalidMotion.MouseMaxVelocity = 0; + bool invalidMotionRejected = false; + try + { + engine.Apply(defaultText, invalidMotion); + } + catch (InvalidOperationException) + { + invalidMotionRejected = true; + } + Assert(invalidMotionRejected, "motion settings reject values outside the supported range"); + + ConfiguratorState numpadState = ConfiguratorState.CreateDefaults(); + numpadState.SetBindings("navigateback", KeyChord.ParseList("numpad0")); + string numpadOutput = engine.Apply(defaultText, numpadState); + Assert( + numpadOutput.Contains("key-alias.navigateback=numpad0") && + !numpadOutput.Contains("key-alias.navigateback.us-qwerty=numpad0"), + "numpad bindings use a layout-independent alias"); + + ConfiguratorState swappedHints = ConfiguratorState.CreateDefaults(); + swappedHints.FocusModeEnabled = true; + Edit(swappedHints, "hintmode", "leftctrl+f"); + Edit(swappedHints, "uihintmode", "f"); + string swappedOutput = engine.Apply(defaultText, swappedHints); + Assert(swappedOutput.Contains("normal-mode.to.hint1-mode=_{leftctrl} +f"), + "Ctrl+F screen-hint entry keeps its pressed-modifier precondition"); + Assert(swappedOutput.Contains("normal-mode.to.ui-hint-mode=^{leftctrl} +f"), + "plain F UI-hint entry excludes the screen-hint modifier"); + Assert(!swappedOutput.Contains("normal-mode.to.ui-hint-mode=+f"), + "plain F UI-hint entry no longer fires for Ctrl+F"); + string swappedFocusKeys = ConfigDocument.Parse(swappedOutput) + .GetPropertyValue("key-alias.mmcfgfocusnormalkey"); + Assert( + !ContainsKey(swappedFocusKeys, "leftctrl") && ContainsKey(swappedFocusKeys, "f"), + "focus fallback spares the hint modifier but still swallows plain F"); + + ConfiguratorState altTabOffState = ConfiguratorState.CreateDefaults(); + altTabOffState.AltTabCenteringEnabled = false; + string altTabOff = engine.Apply(defaultText, altTabOffState); + string[] altTabModeProperties = + { + "idle-mode.to.begin-alt-tab-mode", + "normal-mode.to.begin-alt-tab-mode", + "begin-alt-tab-mode.to.end-alt-tab-mode", + "end-alt-tab-mode.to.center-on-active-window-mode", + "end-alt-tab-mode.to.previous-mode-from-history-stack", + "center-on-active-window-mode.grid.area", + "center-on-active-window-mode.grid.synchronization", + "center-on-active-window-mode.to.previous-mode-from-history-stack" + }; + Assert( + altTabModeProperties.All(delegate(string property) + { + return altTabOff.Contains(ConfigDocument.DisabledPrefix + property + "="); + }), + "disabling Alt-Tab centering disables the complete referenced mode chain"); + altTabOffState.AltTabCenteringEnabled = true; + string altTabRestored = engine.Apply(altTabOff, altTabOffState); + Assert( + altTabModeProperties.All(delegate(string property) + { + return ConfigDocument.Parse(altTabRestored).HasActiveProperty(property); + }), + "re-enabling Alt-Tab centering restores the complete mode chain"); + + string output = engine.Apply(defaultText, state); + Assert(output.Contains("key-alias.up.us-qwerty=k"), "Vim up mapping is written"); + Assert(output.Contains("key-alias.down.us-qwerty=j"), "Vim down mapping is written"); + Assert(output.Contains("key-alias.left.us-qwerty=h"), "Vim left mapping is written"); + Assert(output.Contains("key-alias.right.us-qwerty=l"), "Vim right mapping is written"); + Assert(output.Contains("key-alias.enablemod.us-qwerty=leftctrl"), "activation modifier is Ctrl only"); + Assert(output.Contains("key-alias.enablekey.us-qwerty=m"), "activation key is M only"); + Assert(output.Contains("idle-mode.to.normal-mode=+leftctrl +m | _{leftctrl} +m"), + "activation combo has no redundant alternative"); + Assert(output.Contains("normal-mode.start-wheel.up=^{leftshift} +u"), + "vertical wheel up yields to the Shift+U horizontal binding"); + Assert(output.Contains("normal-mode.start-wheel.down=^{leftshift} +o"), + "vertical wheel down yields to the Shift+O horizontal binding"); + Assert(output.Contains("normal-mode.start-wheel.left=_{leftshift} +u"), + "horizontal wheel left is Shift+U"); + Assert(output.Contains("normal-mode.stop-wheel.left=-u | _{u} -leftshift"), + "horizontal wheel stops when either key is released"); + Assert(output.Contains("normal-mode.start-wheel.right=_{leftshift} +o"), + "horizontal wheel right is Shift+O"); + Assert(output.Contains("normal-mode.to.hint1-mode=^{leftctrl leftalt} +f"), + "plain F screen hints yield to Ctrl+F pass-through and Alt+F UI hints"); + Assert(output.Contains("normal-mode.to.ui-hint-mode=_{leftalt} +f"), + "modified UI hint entry keeps its pressed-modifier precondition"); + Assert( + output.Contains( + "normal-mode.mouse.max-velocity=4321 | _{slow} ^{superslow} -> 350 | _{fast} -> 4500 | _{superslow} -> 75"), + "mouse maximum speed changes without replacing modifier branches"); + Assert( + output.Contains("normal-mode.mouse.acceleration=5432 | _{fast} -> 5000"), + "mouse acceleration changes without replacing the fast branch"); + Assert( + output.Contains( + "normal-mode.wheel.max-velocity=6543 | _{slow} ^{superslow} -> 200 | _{fast} -> 10000 | _{superslow} -> 50"), + "wheel maximum speed changes without replacing modifier branches"); + Assert( + output.Contains("normal-mode.wheel.acceleration=765 | _{fast} -> 10000"), + "wheel acceleration changes without replacing the fast branch"); + Assert( + output.Contains("normal-mode.mouse.initial-velocity=1000") && + output.Contains("normal-mode.wheel.initial-velocity=1500"), + "motion controls leave initial velocities unchanged"); + Assert( + output.Contains( + "normal-mode.press.left=_{none | leftctrl} +; | _{none | leftctrl} +."), + "Ctrl-click preconditions remain intact when mouse bindings are combined"); + Assert(CountOccurrences(output, ".noop.mmcfg-focus=+mmcfgfocus") == 8, + "focus mode covers all eight keyboard-mouse modes"); + Assert( + CountOccurrences(output, "key-alias.mmcfgfocus") == 8 && + !output.Contains("mmcfgfocusnormalkey.us-qwerty"), + "focus mode defines layout-independent per-mode fallback aliases"); + ConfigDocument focusDocument = ConfigDocument.Parse(output); + string normalFocusKeys = + focusDocument.GetPropertyValue("key-alias.mmcfgfocusnormalkey"); + string edgeFocusKeys = + focusDocument.GetPropertyValue("key-alias.mmcfgfocusedgekey"); + Assert( + ContainsKey(normalFocusKeys, "s") && + !ContainsKey(normalFocusKeys, "f2") && + !ContainsKey(normalFocusKeys, "f3") && + !ContainsKey(normalFocusKeys, "/"), + "normal focus fallback excludes configured non-eating exit keys"); + Assert( + ContainsKey(edgeFocusKeys, "f2"), + "pass-through exclusions are scoped to the mode that handles them"); + Assert(output.Contains("# mmcfg-disabled: normal-mode.macro=_arrowremapping-mode.macro"), + "focus mode disables OS-bound arrow remapping in normal mode"); + Assert(output.Contains("# mmcfg-disabled: normal-mode.to.begin-alt-tab-mode="), + "focus mode disables normal-mode Alt-Tab handling"); + Assert(output.Contains("# mmcfg.binding.activate=leftctrl+m"), + "GUI state metadata is embedded in the properties file"); + Assert( + output.Contains("# mmcfg.mouse.max-velocity=4321") && + output.Contains("# mmcfg.mouse.acceleration=5432") && + output.Contains("# mmcfg.wheel.max-velocity=6543") && + output.Contains("# mmcfg.wheel.acceleration=765"), + "motion settings are embedded in GUI metadata"); + Assert( + output.Contains("key-alias.navigateback.us-qwerty=f24"), + "cleared aliases use the MouseMaster v89-compatible F24 placeholder"); + Assert( + !output.Contains("mmcfgunbound") && !output.Contains("virtual-keys="), + "generated configuration does not use unsupported virtual keys"); + + ConfigDocument document = ConfigDocument.Parse(output); + Assert(document.DuplicateActivePropertyKeys().Count == 0, "generated properties are unique"); + Assert(document.InvalidActivePropertyLines().Count == 0, "generated properties are non-empty"); + VerifyEveryCatalogActionAffectsConfiguration(defaultText); + + ConfiguratorState reloaded = engine.LoadState(output); + Assert(reloaded.FocusModeEnabled, "focus setting reloads from metadata"); + Assert(reloaded.GetBindings("activate").Single().Equals(KeyChord.Parse("leftctrl+m")), + "activation setting reloads from metadata"); + Assert(reloaded.GetBindings("navigateback").Count == 0, + "cleared conflict reloads as unbound"); + Assert( + reloaded.MouseMaxVelocity == 4321 && + reloaded.MouseAcceleration == 5432 && + reloaded.WheelMaxVelocity == 6543 && + reloaded.WheelAcceleration == 765, + "motion settings reload from saved configuration"); + + reloaded.FocusModeEnabled = false; + string focusOff = engine.Apply(output, reloaded); + Assert(!focusOff.Contains(".noop.mmcfg-focus=+mmcfgfocus"), + "focus no-op properties are removed when disabled"); + Assert(!focusOff.Contains("key-alias.mmcfgfocus"), + "focus key aliases are removed when focus mode is disabled"); + Assert(focusOff.Contains("normal-mode.macro=_arrowremapping-mode.macro"), + "normal arrow mapping is restored when focus mode is disabled"); + Assert(focusOff.Contains("normal-mode.to.begin-alt-tab-mode="), + "normal Alt-Tab listener is restored when focus mode is disabled"); + + VerifyAtomicRestore(engine, defaultText); + return output; + } + + private static void VerifyEveryCatalogActionAffectsConfiguration(string defaultText) + { + var engine = new ConfiguratorEngine(); + string baseline = WithoutMetadata( + engine.Apply(defaultText, ConfiguratorState.CreateDefaults())); + + foreach (ActionDefinition action in BindingCatalog.Actions) + { + ConfiguratorState changedState = ConfiguratorState.CreateDefaults(); + string replacement = + action.Id == "activate" || + action.Id == "windowmode" || + action.Id == "uihintmode" || + action.Id == "alttab" + ? "leftctrl+f24" + : "f24"; + changedState.SetBindings(action.Id, KeyChord.ParseList(replacement)); + string changed = WithoutMetadata(engine.Apply(defaultText, changedState)); + Assert( + !string.Equals(changed, baseline, StringComparison.Ordinal), + "catalog action writes behavior: " + action.Id); + } + } + + private static string WithoutMetadata(string text) + { + ConfigDocument document = ConfigDocument.Parse(text); + document.RemoveMetadata(); + return document.GetText(); + } + + private static IList Edit( + ConfiguratorState state, + string actionId, + string serialized) + { + return ConflictResolver.ApplyEdit( + state, + BindingCatalog.FindAction(actionId), + KeyChord.ParseList(serialized)); + } + + private static void VerifyAtomicRestore(ConfiguratorEngine engine, string defaultText) + { + string directory = Path.Combine( + Path.GetTempPath(), + "MouseMasterConfiguratorSelfTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, "mousemaster.properties"); + try + { + File.WriteAllText(path, "sentinel=value" + Environment.NewLine, new UTF8Encoding(false)); + engine.RestoreDefault(path); + string restored = File.ReadAllText(path); + Assert(restored == defaultText, "restore writes the embedded default byte-for-text"); + Assert(File.Exists(path + ".gui-backup"), "restore creates a rolling GUI backup"); + Assert(File.ReadAllText(path + ".gui-backup").Contains("sentinel=value"), + "GUI backup contains the pre-restore configuration"); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + if (File.Exists(path + ".gui-backup")) + File.Delete(path + ".gui-backup"); + if (Directory.Exists(directory)) + Directory.Delete(directory); + } + } + + private static void VerifyVisualLayout(string defaultText) + { + string directory = Path.Combine( + Path.GetTempPath(), + "MouseMasterConfiguratorLayoutTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, "mousemaster.properties"); + UiLanguage originalLanguage = L10n.Language; + try + { + File.WriteAllText(path, defaultText, new UTF8Encoding(false)); + L10n.Language = UiLanguage.Chinese; + using (var form = new MainForm(path)) + { + form.CreateControl(); + PerformLayoutRecursively(form); + VerifyHeaderLayout(form, "zh"); + VerifyMovementPage(form, "zh"); + VerifyMousePage(form, "zh"); + AssertLabelsFit(form, "zh"); + + ComboBox languageCombo = FindDescendant( + form, + delegate(ComboBox combo) { return true; }); + Assert( + languageCombo != null && languageCombo.Items.Count == 2, + "sidebar offers a Chinese/English language switcher"); + languageCombo.SelectedIndex = 1; + PerformLayoutRecursively(form); + Assert( + L10n.Language == UiLanguage.English, + "the language switcher activates English"); + Assert( + form.Text == L10n.T("app.title"), + "language switch updates the window title"); + + NavigationButton generalButton = FindDescendant( + form, + delegate(NavigationButton button) { return (string)button.Tag == "general"; }); + SelectCategory(form, generalButton); + PerformLayoutRecursively(form); + VerifyHeaderLayout(form, "en"); + VerifyMovementPage(form, "en"); + VerifyMousePage(form, "en"); + AssertLabelsFit(form, "en"); + } + } + finally + { + L10n.Language = originalLanguage; + if (File.Exists(path)) + File.Delete(path); + if (Directory.Exists(directory)) + Directory.Delete(directory); + } + } + + private static void VerifyHeaderLayout(MainForm form, string languageTag) + { + Label pageTitle = FindDescendant