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