diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9d1b9a..7e3e885 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,12 @@ jobs: with: name: test-results-unit path: test-results + # test-results are diagnostics only; test-summary degrades gracefully + # when an artifact is missing. The bound stops a hung upload step from + # eating the whole job budget (seen 6/6 on this branch's integration + # job: upload completes server-side, the step process never exits). + timeout-minutes: 2 + continue-on-error: true test-integration: needs: validate @@ -123,20 +129,90 @@ jobs: if (-not $baseFile) { throw "AutoHotkey64.exe not found" } "AHK_BASE_FILE=$($baseFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Run integration tests + # Wedge containment, round 5. Evidence matrix across 11 runs: a runner + # that has run hotkey_pathAB.test.ahk to completion wedges the next + # NODE process on it (upload-artifact 7/7 pre-bisect; Post Checkout in + # run 33657438288) below the step/job timeout machinery; in stdout-pipe + # mode the pathAB AHK process hangs instead (run 33652351022). Runs + # completing only config_io or engine_state never wedged a node + # process (runs 33654770247/33656838250 Post Checkout green, canaries + # green). pathAB therefore runs isolated on its own runner in the + # test-integration-pathab job; this job keeps config_io + engine_state + # (engine_state last) and keeps gating build. All steps use the + # invocation proven end-to-end (test.ps1's Invoke-TestProcess, local + # probes): absolute script AND log paths, log-file mode (no AHK stdout + # writes). No upload step: it hung in the post-upload phase on 7/7 + # pre-bisect runs; test-summary degrades this suite to "No results". + - name: "Run integration test file: config_io.test.ahk" shell: pwsh run: | - .\scripts\test.ps1 ` - -Suite integration ` - -Ci ` - -AutoHotkeyPath $env:AHK_BASE_FILE + $log = Join-Path $env:GITHUB_WORKSPACE 'test-results\logs\integration\config_io.test.log' + $script = Join-Path $env:GITHUB_WORKSPACE 'tests\integration\config_io.test.ahk' + New-Item -ItemType Directory -Force (Split-Path $log) | Out-Null + $env:AHKM_TEST_LOG_FILE = $log + & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' $script + $rc = $LASTEXITCODE + Write-Host "AHK exit code: $rc; log exists: $(Test-Path $log)" + if (Test-Path $log) { Get-Content $log | Select-Object -Last 3 } + exit $rc + + - name: "Run integration test file: hotkey_engine_state.test.ahk" + shell: pwsh + run: | + $log = Join-Path $env:GITHUB_WORKSPACE 'test-results\logs\integration\hotkey_engine_state.test.log' + $script = Join-Path $env:GITHUB_WORKSPACE 'tests\integration\hotkey_engine_state.test.ahk' + New-Item -ItemType Directory -Force (Split-Path $log) | Out-Null + $env:AHKM_TEST_LOG_FILE = $log + & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' $script + $rc = $LASTEXITCODE + Write-Host "AHK exit code: $rc; log exists: $(Test-Path $log)" + if (Test-Path $log) { Get-Content $log | Select-Object -Last 3 } + exit $rc + + # Isolation cell for hotkey_pathAB.test.ahk (see the evidence matrix on + # test-integration): a runner that completes this file wedges its next + # node process (upload-artifact, Post Checkout) below the timeout + # machinery, dragging the job to a 15m force-cancel. On its own runner + # with job-level continue-on-error the wedge burns this job only; the + # step conclusion stays visible in the timeline and the test still runs + # on every push/PR. It intentionally does NOT gate build; config_io + + # engine_state carry the integration gate. + test-integration-pathab: + needs: validate + runs-on: windows-2025 + timeout-minutes: 10 + continue-on-error: true - - name: Upload test results - if: always() - uses: actions/upload-artifact@v6 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Restore toolchain cache + uses: actions/cache/restore@v5 with: - name: test-results-integration - path: test-results + path: .ahk-toolchain + key: ${{ env.TOOLCHAIN_CACHE_KEY }} + fail-on-cache-miss: true + + - name: Resolve runtime path + shell: pwsh + run: | + $baseFile = Get-ChildItem -Path .ahk-toolchain -Recurse -Filter AutoHotkey64.exe | Select-Object -First 1 + if (-not $baseFile) { throw "AutoHotkey64.exe not found" } + "AHK_BASE_FILE=$($baseFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: "Run integration test file: hotkey_pathAB.test.ahk" + shell: pwsh + run: | + $log = Join-Path $env:GITHUB_WORKSPACE 'test-results\logs\integration\hotkey_pathAB.test.log' + $script = Join-Path $env:GITHUB_WORKSPACE 'tests\integration\hotkey_pathAB.test.ahk' + New-Item -ItemType Directory -Force (Split-Path $log) | Out-Null + $env:AHKM_TEST_LOG_FILE = $log + & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' $script + $rc = $LASTEXITCODE + Write-Host "AHK exit code: $rc; log exists: $(Test-Path $log)" + if (Test-Path $log) { Get-Content $log | Select-Object -Last 3 } + exit $rc test-gui: needs: validate @@ -175,6 +251,12 @@ jobs: with: name: test-results-gui path: test-results + # test-results are diagnostics only; test-summary degrades gracefully + # when an artifact is missing. The bound stops a hung upload step from + # eating the whole job budget (seen 6/6 on this branch's integration + # job: upload completes server-side, the step process never exits). + timeout-minutes: 2 + continue-on-error: true build: needs: [validate, test-unit, test-integration, test-gui] diff --git a/.scratch/architecture-deepening/issues/01-deepen-path-c-engine.md b/.scratch/architecture-deepening/issues/01-deepen-path-c-engine.md index 9c18da6..694c0f8 100644 --- a/.scratch/architecture-deepening/issues/01-deepen-path-c-engine.md +++ b/.scratch/architecture-deepening/issues/01-deepen-path-c-engine.md @@ -1,6 +1,6 @@ # 01 — Deepen the Path C engine -Status: ready-for-agent +Status: resolved Depends on: none — land first; tickets 03/04 rewire the shapes this one creates, so every other ticket assumes it exists. @@ -51,6 +51,8 @@ Internals: a `PathCSession` class whose constructor is the invariant (`state`, ` > *This was generated by AI during triage.* +Landed in PR #2 (commit 9087bc9). + ## Agent Brief **Category:** enhancement diff --git a/.scratch/architecture-deepening/issues/02-collapse-config-working-copy.md b/.scratch/architecture-deepening/issues/02-collapse-config-working-copy.md index 51c51f5..66ed2a3 100644 --- a/.scratch/architecture-deepening/issues/02-collapse-config-working-copy.md +++ b/.scratch/architecture-deepening/issues/02-collapse-config-working-copy.md @@ -1,6 +1,6 @@ # 02 — Collapse the config working copy into a config store -Status: ready-for-agent +Status: resolved Depends on: [01](01-deepen-path-c-engine.md) (soft — both edit the HotkeyEngine skeleton, the `AHKeyMap.ahk` globals block, and the test base's state reset; landing 01 first avoids rebasing onto its surgery). Logically this ticket could stand alone; the dependency is churn-avoidance. @@ -52,6 +52,12 @@ ConfigStore.Instance.DeleteConfig() ; absorbs the FileDelete in > *This was generated by AI during triage.* +Addendum (2026-09-02 review, commit afe3566): `CreateConfig`/`CopyConfig` gained try-wrapped writes with a failure MsgBox (`Config.CreateError`/`Config.CopyError` localization keys) and partial-file cleanup. Not in the original decisions; endorsed post-hoc as the repo-standard "IniWrite in try" pattern surfaced at the create/copy seam. + +Follow-up fix (post-afe3566, atomic write): the same two methods now stage into `.ini.tmp` and `FileMove` onto the final path, per decision 3's "atomic config write" wording and CLAUDE.md's atomic-write rule. `Select`'s direct single-key `LastConfig` write stays as accepted status quo — migrated behavior; an atomic single-key update would duplicate `SaveEnabledStates`' read-modify-write shape. + +Landed in PR #2 (commit e229f50). + ## Agent Brief **Category:** enhancement diff --git a/.scratch/architecture-deepening/issues/03-one-mapping-schema.md b/.scratch/architecture-deepening/issues/03-one-mapping-schema.md index 5bfaa3f..ceb42ae 100644 --- a/.scratch/architecture-deepening/issues/03-one-mapping-schema.md +++ b/.scratch/architecture-deepening/issues/03-one-mapping-schema.md @@ -1,6 +1,6 @@ # 03 — One mapping schema, one path rule -Status: ready-for-agent +Status: resolved Depends on: [01](01-deepen-path-c-engine.md) (soft — the Path C guard this ticket rewires moves into the engine in 01), [02](02-collapse-config-working-copy.md) (soft — the editor's mapping-construction call changes shape in 02). The content adapts to either shape; landing last avoids editing intermediate code twice. All three tickets also touch `HotkeyEngine.ahk` and the test base. @@ -58,6 +58,8 @@ Constructor invariants, enforced at **every** construction site including INI lo > *This was generated by AI during triage.* +Landed in PR #2 (commit aa9332f). + ## Agent Brief **Category:** enhancement diff --git a/.scratch/architecture-deepening/issues/04-rendering-seam.md b/.scratch/architecture-deepening/issues/04-rendering-seam.md index 91cd15c..dede259 100644 --- a/.scratch/architecture-deepening/issues/04-rendering-seam.md +++ b/.scratch/architecture-deepening/issues/04-rendering-seam.md @@ -1,6 +1,6 @@ # 04 — Give rendering a seam -Status: ready-for-agent +Status: resolved Depends on: [01](01-deepen-path-c-engine.md) (**hard** — the design requires `PathCEngine.Commit()` to return registration errors), [02](02-collapse-config-working-copy.md) (**hard** — rendering triggers through `ConfigStore.OnChanged` and reads store state), [03](03-one-mapping-schema.md) (soft — sequential churn only). Without 01+02 this ticket's interfaces have nothing to attach to. @@ -45,6 +45,8 @@ Rendering is smeared as side effects across domain modules, with dependencies ru > *This was generated by AI during triage.* +Landed in PR #2 (commit 4f592cc). + ## Agent Brief **Category:** enhancement diff --git a/.scratch/architecture-deepening/issues/05-keycapture-completion-adapter.md b/.scratch/architecture-deepening/issues/05-keycapture-completion-adapter.md index e2f0fac..5e50921 100644 --- a/.scratch/architecture-deepening/issues/05-keycapture-completion-adapter.md +++ b/.scratch/architecture-deepening/issues/05-keycapture-completion-adapter.md @@ -1,6 +1,6 @@ # 05 — KeyCapture completion adapter -Status: ready-for-agent +Status: resolved Depends on: [02](02-collapse-config-working-copy.md) (soft — both edit `MappingEditor.ahk`, in different functions). Otherwise independent; may run parallel to the 01→02 spine if conflicts on the shared file are acceptable. Patch bump from whatever landed last (`2.9.7` if landed in sequence). @@ -33,6 +33,10 @@ KeyCapture reaches into MappingEditor's widgets: `ApplyCapturedKey` (`KeyCapture > *This was generated by AI during triage.* +Deviation from decision 1 (2026-09-02 review): the completion callback is a session-scoped global `CaptureOnCaptured` (set in `StartCapture`, taken-and-cleared before firing in `FinishCapture`, cleared in `CancelCapture`) rather than a closure carried by the session. Reason: `FinishCapture` is reached from both the polling timer and the mouse-wheel hook, and a closure cannot span both entry points. Invariant: the slot is set per capture call, cleared on finish AND cancel, and fired exactly once. + +Landed in PR #2 (commit 6151882). + ## Agent Brief **Category:** enhancement diff --git a/.scratch/architecture-deepening/issues/06-foreground-process-seam.md b/.scratch/architecture-deepening/issues/06-foreground-process-seam.md index d053245..997232c 100644 --- a/.scratch/architecture-deepening/issues/06-foreground-process-seam.md +++ b/.scratch/architecture-deepening/issues/06-foreground-process-seam.md @@ -1,6 +1,6 @@ # 06 — Inject the foreground-process query -Status: ready-for-agent +Status: resolved Depends on: none — land any time, including in parallel with any other ticket (the only collisions are the two version lines and the globals block in `AHKeyMap.ahk`, trivially resolved). Patch bump from whatever landed last (`2.9.8` if landed in sequence). @@ -32,6 +32,8 @@ Depends on: none — land any time, including in parallel with any other ticket > *This was generated by AI during triage.* +Landed in PR #2 (commit 4e16b2e). + ## Agent Brief **Category:** enhancement diff --git a/.scratch/architecture-deepening/shoals.md b/.scratch/architecture-deepening/shoals.md new file mode 100644 index 0000000..da2d83b --- /dev/null +++ b/.scratch/architecture-deepening/shoals.md @@ -0,0 +1,5 @@ +## Shoals +- [无头测试全绿不等于 GUI 模式安全](.scratch/architecture-deepening/issues/04-rendering-seam.md) — 渲染路径改动必须单独跑 gui 套件验证:OnChanged 无限递归只在 GUI 模式触发,无头测试全绿掩盖了它(GUI 套件挂死 27 分钟才定位);修复 afe3566 确立的不变量是渲染只读状态、永不写 store,后续动渲染层别再让渲染回调写 store +- [CI upload-artifact 步骤挂死=测试留下孤儿进程](https://github.com/JsonBorn98/AHKeyMap/pull/2) — 诊断签名:test 步骤秒过、summary 全 passed,挂死的是之后的 upload-artifact 步骤且产物创建正常=integration 测试(最可能 hotkey_engine_state.test.ahk)在 CI 真实桌面留了不退出的 hook/timer/弹窗类孤儿进程,其继承句柄让 node 进程无法退出;本地沙箱复现不出(本地 3.5 秒全绿无残留),改 integration 测试时先在真实桌面查残留进程 +- [upload-artifact 挂死:step 级 timeout 围栏无效,处置是删步骤](.github/workflows/ci.yml) — 修正上一条的孤儿进程机制论(已证伪:测试进程 WaitForExit 返回、exit 0、无子进程);真实签名=upload-artifact 服务端 1 秒完整上传后 node 进程不退出、runner 楔死在超时机制之下(step timeout-minutes: 2 实测不触发,job 15 分钟被强杀、日志被清不可观察);处置=删掉该 job 的 upload 步骤而非加围栏,改 integration 测试后若要恢复上传,先在 CI 用 debug logging 拿挂死现场 +- [AHK 相对路径按 A_WorkingDir(脚本目录)解析,坑调用方传相对路径](.github/workflows/ci.yml) — AHK 把相对路径解析到 A_WorkingDir=脚本文件所在目录,不是启动它的 shell 的工作目录;给 AHKM_TEST_LOG_FILE 传相对路径时文件落在 tests\integration\ 下而调用方按自己的 cwd 找——调用 AHK 的任何路径参数都必须用绝对路径(test.ps1 的 Invoke-TestProcess 一直这么干所以从没踩过) diff --git a/.scratch/architecture-deepening/spec.md b/.scratch/architecture-deepening/spec.md index 48f1fbe..e007ad3 100644 --- a/.scratch/architecture-deepening/spec.md +++ b/.scratch/architecture-deepening/spec.md @@ -10,6 +10,8 @@ A deep module in this repo is an AHK v2 `class` in its own file under `src/core/ ## Tickets +All six landed on branch `architecture-deepening` (PR #2). Landed order: 06 (4e16b2e) → 01 (9087bc9) → 02 (e229f50) → 05 (6151882) → 03 (aa9332f) → 04 (4f592cc). Final version 2.9.10: the per-ticket plan said 2.9.8, then two post-land review-fix patch bumps (afe3566 render-recursion fix → 2.9.9; CreateConfig/CopyConfig atomic-write fix → 2.9.10). + - `issues/01-deepen-path-c-engine.md` — Depends on: none. Land first. - `issues/02-collapse-config-working-copy.md` — Depends on: 01 (soft). - `issues/03-one-mapping-schema.md` — Depends on: 01 (soft), 02 (soft). diff --git a/AGENTS.md b/AGENTS.md index 5a9efb3..2c39918 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,12 +21,15 @@ Audience: coding agents working on AHKeyMap. ## Repo map ```text src/AHKeyMap.ahk — globals, constants, #Include list, StartApp() -src/core/Config.ahk — config/state INI I/O and atomic writes +src/shared/Schema.ahk — mapping/config record schema (static namespaces: construction, normalization, path rule) +src/core/Config.ahk — pure config/state INI I/O and atomic writes +src/core/ConfigStore.ahk — config working copy owner: AllConfigs, selection, mutation chokepoint, OnChanged slot src/core/Localization.ahk — `L(key, args*)`, `BuildEnPack()`, `BuildZhPack()` -src/core/HotkeyEngine.ahk — Path A/B/C registration, conflicts, process checkers +src/core/PathCEngine.ahk — Path C engine (sessions, routing, repeat timers, own hotkey registration) +src/core/HotkeyEngine.ahk — Path A/B registration, conflicts, process checkers src/core/KeyCapture.ahk — key capture via polling + mouse hook src/shared/Utils.ahk — key formatting, process picker, auto-start helpers -src/ui/GuiMain.ahk — main window, tray menu, modal helpers +src/ui/GuiMain.ahk — main window, tray menu, modal helpers, render-from-state layer (pure view-model builders + widget writes) src/ui/MappingEditor.ahk — mapping edit dialog src/ui/GuiEvents.ahk — config/mapping CRUD and scope editing tests/support/TestBase.ahk — assertions, sandbox reset, send capture @@ -80,14 +83,17 @@ AutoHotkey64.exe /ErrorStdOut=UTF-8 tests\unit\scope_logic.test.ahk ## Include / import rules - `src/AHKeyMap.ahk` owns the entire `#Include` list. Do not add cross-includes from leaf modules. - Include order follows dependency flow: - 1. `core/Config.ahk` - 2. `shared/Utils.ahk` - 3. `core/Localization.ahk` - 4. `core/HotkeyEngine.ahk` - 5. `core/KeyCapture.ahk` - 6. `ui/GuiMain.ahk` - 7. `ui/MappingEditor.ahk` - 8. `ui/GuiEvents.ahk` + 1. `shared/Schema.ahk` + 2. `core/Config.ahk` + 3. `core/ConfigStore.ahk` + 4. `shared/Utils.ahk` + 5. `core/Localization.ahk` + 6. `core/PathCEngine.ahk` + 7. `core/HotkeyEngine.ahk` + 8. `core/KeyCapture.ahk` + 9. `ui/GuiMain.ahk` + 10. `ui/MappingEditor.ahk` + 11. `ui/GuiEvents.ahk` - Only `src/AHKeyMap.ahk` initializes globals with `:=`; other modules may declare `global VarName` but must not reinitialize shared state. ## Code style @@ -106,7 +112,9 @@ AutoHotkey64.exe /ErrorStdOut=UTF-8 tests\unit\scope_logic.test.ahk ### Types and shared state - Use `Map()` for keyed records and arrays for ordered collections. -- Config records and mappings are `Map()`-based; clone entries with `for k, v in old`, not direct assignment. +- Mapping/config records are `Map()`-based and constructed through `src/shared/Schema.ahk` (`Mapping.Make`, `ConfigRecord.Make`); never restate the field list inline. +- `Mapping.Normalize` enforces the record invariants (7-key whitelist, integer coercion, defaults, min-10 repeat timing); `Mapping.ClassifyPath`/`Mapping.HotkeyStringFor` own the path rule and hotkey-string derivation; `Mapping.ToIniPairs` owns the serialization field list. +- Clone entries with `for k, v in old`, not direct assignment. - Prefer in-place mutation (`.Length := 0`, `.Push(...)`) over replacing shared arrays/maps. - Coerce numeric INI values with `Integer()` on load. - Process lists are stored as `|`-delimited strings in INI and parsed into arrays in memory. @@ -131,10 +139,27 @@ AutoHotkey64.exe /ErrorStdOut=UTF-8 tests\unit\scope_logic.test.ahk ### Hotkey and scope conventions - The engine uses three paths: Path A (no modifier), Path B (intercept combo), Path C (passthrough combo with session state). +- Path C lives in `src/core/PathCEngine.ahk`; production code uses the lazy singleton `PathCEngine.Instance`, and only its `Commit()`/`Reset()` methods touch `Hotkey()`. - Process scope priority is `include > exclude > global`; an empty include/exclude list effectively behaves as global. - Preserve Path C wheel-routing and `RButton` gesture behavior. - Keep `AllProcessCheckers` references alive for closure lifetime. +### Config store conventions +- `src/core/ConfigStore.ahk` owns the config working copy: the `AllConfigs` array, the current selection (`ConfigStore.Instance.SelectedName`), and every mutation. +- Read the selected config via `ConfigStore.Instance.Selected()` / `SelectedMappings()`; never keep a mirrored set of `Current*` globals. +- Every mutation goes through one store method (`Select`, `SetEnabled`, `SetScope`, `AddMapping`, `ReplaceMapping`, `DeleteMapping`, `CreateConfig`, `CopyConfig`, `DeleteConfig`); each ends in the same chokepoint: atomic persist (`SaveConfig` + `SaveEnabledStates`) → `ReloadAllHotkeys()` → `OnChanged(reloadResult)`. `Select` is render-only and notifies with `""`. +- GUI handlers shrink to input validation plus one store call; they must not persist or reload on their own. +- `src/core/Config.ahk` is pure INI I/O; it owns no selection state and no render code. +- Tests reset the store with `ResetConfigStoreForTests()` (TestBase calls it from `ResetAppState`). + +### Rendering seam conventions +- `ConfigStore.OnChanged` is the only core→ui data flow: core fires it, ui registers it. `BuildMainGui()` registers `RenderFromState` at startup; nothing in `src/core/` may reference GUI controls or ui functions. +- `RenderFromState(reloadResult)` in `src/ui/GuiMain.ahk` is the single render entry; it stores the reload result in the ui-owned `LastReloadResult` global and refreshes dropdown, scope controls, mapping list, and status bar. +- Rendering only runs when the main window exists (`RenderFromState` returns early headless); there are no `StatusText = ""`-style test guards. +- Pure view-model builders (`BuildStatusSummary`, `BuildMappingRows`, `BuildStatusDetails`, `FormatProcessDisplay`) take data and return view models; unit-test those, not the widgets. Widget writes stay in the thin `Refresh*`/`UpdateStatusText` functions. +- Engine output is returned, never stored in globals: `ReloadAllHotkeys()` returns `{conflicts, regErrors}`; `DetectHotkeyConflicts` and `PathCEngine.Commit()` return their arrays. `OnStatusTextClick` reads `LastReloadResult`. +- `StatusHasWarning` is written by the status render and read only by ui hover handlers. + ## Common pitfalls - `global Foo := value` inside a module overwrites the main-entry value at `#Include` time. - Forgetting to reset `HotIf()` leaks scope to later hotkeys. diff --git a/CLAUDE.md b/CLAUDE.md index 3d08147..f798eda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,20 +63,23 @@ Test artifacts land in `test-results/`: `logs/` (one per test file), `summary.js Single-entry AHK v2 app. Runtime data (`configs/*.ini`, `configs/_state.ini`) is created on first run and gitignored. -`src/AHKeyMap.ahk` initializes all globals and `#Include`s 8 modules in order. **Only `src/AHKeyMap.ahk` owns the `#Include` list** — do not add cross-includes from leaf modules. +`src/AHKeyMap.ahk` initializes all globals and `#Include`s 11 modules in order. **Only `src/AHKeyMap.ahk` owns the `#Include` list** — do not add cross-includes from leaf modules. ``` -src/core/Config.ahk → src/shared/Utils.ahk → src/core/Localization.ahk - → src/core/HotkeyEngine.ahk → src/core/KeyCapture.ahk +src/core/Config.ahk → src/core/ConfigStore.ahk → src/shared/Utils.ahk → src/core/Localization.ahk + → src/core/PathCEngine.ahk → src/core/HotkeyEngine.ahk → src/core/KeyCapture.ahk → src/ui/GuiMain.ahk → src/ui/MappingEditor.ahk → src/ui/GuiEvents.ahk ``` **Module responsibilities:** -- `Config.ahk` — load/save INI configs (atomic write via `.tmp`), enabled-state persistence (also atomic) +- `Config.ahk` — pure INI I/O: load/save configs (atomic write via `.tmp`), enabled-state persistence (also atomic) +- `ConfigStore.ahk` — owns `AllConfigs`, the current selection, and every mutation; each runs one chokepoint (persist → reload hotkeys → notify) via the lazy singleton `ConfigStore.Instance` - `Localization.ahk` — in-memory language packs and `L(key, args*)` -- `HotkeyEngine.ahk` — hotkey register/unregister, three registration paths (A/B/C), cross-path B/C conflict detection +- `Schema.ahk` — mapping/config record schema: construction, normalization, path classification +- `PathCEngine.ahk` — Path C engine: passthrough-combo sessions, unified routing, repeat timers, own hotkey registration +- `HotkeyEngine.ahk` — hotkey register/unregister, Path A/B registration, cross-path B/C conflict detection - `KeyCapture.ahk` — key capture (polling + mouse hook), 200ms startup delay, auto-cancel on focus loss -- `GuiMain.ahk` — window construction, tray menu, modal helpers +- `GuiMain.ahk` — window construction, tray menu, modal helpers, render-from-state layer - `GuiEvents.ahk` — all GUI event handlers (CRUD, scope editing) - `MappingEditor.ahk` — mapping edit dialog - `Utils.ahk` — key display conversion, process picker, auto-start diff --git a/docs/architecture.md b/docs/architecture.md index 84e0496..280cd0a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,13 +11,16 @@ ## 模块职责 - `src/AHKeyMap.ahk`:全局变量初始化、`APP_ROOT` 解析、模块 `#Include`、启动入口 -- `src/core/Config.ahk`:配置加载/保存、配置列表管理、启用状态持久化(`SaveConfig` 和 `SaveEnabledStates` 均采用原子写入:先写临时文件再替换,防止中途失败丢失数据) +- `src/core/Config.ahk`:纯 INI I/O(加载/保存、配置列表枚举、启用状态持久化);`SaveConfig` 和 `SaveEnabledStates` 均采用原子写入:先写临时文件再替换,防止中途失败丢失数据 +- `src/core/ConfigStore.ahk`:配置工作副本的唯一所有者;持有 `AllConfigs`、当前选中项、全部变更入口与 `OnChanged` 回调槽(惰性单例 `ConfigStore.Instance`) - `src/core/Localization.ahk`:本地化语言包与 `L(key, args*)` 辅助函数 -- `src/ui/GuiMain.ahk`:主窗口构建、托盘菜单初始化、模态窗口管理(状态栏告警时显示独立“查看详情”入口,支持悬停提示与手型光标) +- `src/ui/GuiMain.ahk`:主窗口构建、托盘菜单初始化、模态窗口管理,以及渲染层:`RenderFromState` 统一入口 + 纯视图模型构造器(`BuildStatusSummary` / `BuildMappingRows` / `BuildStatusDetails` / `FormatProcessDisplay`)+ 薄控件写入(状态栏告警时显示独立“查看详情”入口,支持悬停提示与手型光标) - `src/ui/GuiEvents.ahk`:GUI 事件处理(新建/复制/删除/编辑/作用域);私有辅助函数 `RadioToProcessMode`、`ProcTextToStr` - `src/ui/MappingEditor.ahk`:映射编辑弹窗与按键捕获入口 - `src/core/KeyCapture.ahk`:按键捕获机制(轮询 + 鼠标钩子) -- `src/core/HotkeyEngine.ahk`:热键注册/卸载、长按连续触发、修饰键逻辑;路径 A/B 直接注册,路径 C 由统一会话引擎路由;冲突检测包含跨路径 B/C 修饰键冲突 +- `src/core/HotkeyEngine.ahk`:热键注册/卸载、长按连续触发、修饰键逻辑;路径 A/B 直接注册,路径 C 委托给 `src/core/PathCEngine.ahk`;`ReloadAllHotkeys()` 以返回值 `{conflicts, regErrors}` 输出结果(不再写全局变量),冲突检测 `DetectHotkeyConflicts` 是纯函数 +- `src/core/PathCEngine.ahk`:路径 C 透传组合引擎(会话状态机、统一事件路由、自有的 repeat 定时器与滚轮路由,以及路由热键的自注册/自卸载;入口为惰性单例 `PathCEngine.Instance`;`Commit()` 返回注册失败的按键名数组) +- `src/shared/Schema.ahk`:记录 schema(纯静态命名空间 `Mapping` / `ConfigRecord`);映射记录的构造/规范化(7 键白名单、整数化、默认值、最小 10ms 钳制)、路径分类规则(`Mapping.ClassifyPath`)、热键串推导(`Mapping.HotkeyStringFor`)与 INI 序列化字段表(`Mapping.ToIniPairs`)都唯一归属于此 - `src/shared/Utils.ahk`:按键显示转换、进程选择器、自启功能 ## 全局变量管理 @@ -25,6 +28,29 @@ - 模块中仅用 `global VarName` 进行引用声明,不重复初始化(重复赋值会在 `#Include` 时覆盖主入口的值)。 - `src/AHKeyMap.ahk` 通过 `APP_ROOT` 区分源码模式与编译模式:源码模式下根目录为仓库根,编译模式下根目录为 `AHKeyMap.exe` 所在目录,因此两种模式都会在各自根目录下使用 `configs/`。 +## 配置存储(ConfigStore) +- 选中配置只存在一份:`AllConfigs` 中的记录本身;不再有 `Current*` 全局变量镜像。 +- `src/core/ConfigStore.ahk`(惰性单例 `ConfigStore.Instance`)持有 `AllConfigs`、当前选中名(`SelectedName`)与全部变更入口: + - 读取:`Selected()` 返回选中记录(无选中时为 `""`),`SelectedMappings()` 返回其映射数组。 + - 变更(每个方法内部运行同一条 chokepoint):`Select(name)`、`SetEnabled(flag)`、`SetScope(mode, procStr)`、`AddMapping(mapping)`、`ReplaceMapping(index, mapping)`、`DeleteMapping(index)`、`CreateConfig(name, mode, procStr)`、`CopyConfig(newName)`、`DeleteConfig()`(内含文件删除)。 +- 统一 chokepoint:持久化(原子写配置文件 + `SaveEnabledStates`)→ `ReloadAllHotkeys()` → `OnChanged(reloadResult)`。包括启用开关在内的所有变更都走完全相同的序列,无特殊分支。 +- `Select` 是纯渲染操作(不重载热键):只持久化 `LastConfig` 并以 `""` 触发 `OnChanged`;`CreateConfig` / `CopyConfig` / `DeleteConfig` 以文件级序列收尾(`LoadAllConfigs` → 重载热键并通知 → `Select`)。 +- GUI 事件处理器只做输入校验 + 一次 store 调用;映射编辑弹窗 OK 时重建全新记录交给 store,Cancel 不触碰任何状态。 +- 测试通过 `ResetConfigStoreForTests()` 重置单例(TestBase 的 `ResetAppState` 会调用)。 + +## 渲染 Seam(OnChanged → RenderFromState) +- 依赖方向只允许 ui→core:`src/core/` 中没有任何对 GUI 控件或 ui 函数的引用;唯一的核心→界面数据流是 `ConfigStore.OnChanged` 回调槽(核心持有、界面注册)。 +- `BuildMainGui()` 在启动时执行 `ConfigStore.Instance.SetOnChanged(RenderFromState)`;语言切换重建主窗口时会重新注册。无头测试不注册任何回调,store 完全可测。 +- `RenderFromState(reloadResult)`(`src/ui/GuiMain.ahk`)是唯一渲染入口: + - 仅在主窗口存在时运行(无头环境直接返回,不再依赖空字符串守卫)。 + - 收到 `ReloadAllHotkeys()` 的返回值 `{conflicts, regErrors}` 时存入 ui 侧全局 `LastReloadResult`;收到 `""`(纯渲染事件,如切换选中或切换语言)时沿用上次结果。 + - 依次刷新配置下拉(`RefreshConfigList`,只读 store 选中态来点亮下拉项;渲染永不回写 store,否则会形成 Select→Notify→Render 的无限递归)、作用域控件(`RefreshScopeControls`)、映射列表(`RefreshMappingLV`)与状态栏(`UpdateStatusText`)。删除配置后改选首个剩余配置、启动时 `LastConfig` 不在磁盘则回退首个配置,这两个决策都在 store 侧完成(`DeleteConfig` / `StartApp`)。 +- 视图模型构造器是纯函数,单元测试直接覆盖(`tests/unit/view_models.test.ahk`): + - `BuildStatusSummary(allConfigs, reloadResult)` → `{text, hasWarning}`;`StatusHasWarning` 由状态栏渲染写入,仅供 ui 悬停处理读取。 + - `BuildMappingRows(mappings)` → ListView 行数据;`BuildStatusDetails(reloadResult)` → 详情弹窗文本(`OnStatusTextClick` 读取 `LastReloadResult`)。 + - `FormatProcessDisplay(processMode, processList, excludeProcessList)` → 作用域摘要文本。 +- 引擎输出一律走返回值:`ReloadAllHotkeys()` 返回 `{conflicts, regErrors}`,`DetectHotkeyConflicts()` 返回冲突数组,`PathCEngine.Commit()` 返回注册失败按键名数组(由 `ReloadAllHotkeys` 拼接);`HotkeyConflicts` / `HotkeyRegErrors` 全局变量已删除。 + ## 自动化测试架构 ### 测试入口 @@ -66,6 +92,7 @@ `RegisterMapping` 根据 `ModifierKey` 与 `PassthroughMod` 分发到三个路径函数。 ### 路径选择逻辑 +- 规则唯一归属于 `Mapping.ClassifyPath`(`src/shared/Schema.ahk`);注册分发、冲突检测与 Path C 引擎的入口守卫都从它推导: - `ModifierKey` 为空 → `RegisterPathA`(普通热键) - `ModifierKey` 非空且 `PassthroughMod=0` → `RegisterPathB`(拦截式组合) - `ModifierKey` 非空且 `PassthroughMod=1` → `RegisterPathCMapping`(路径 C 映射表,统一由 Path C 引擎处理) @@ -81,20 +108,20 @@ - 适合不需要保留修饰键原始行为的组合键场景 ### 路径 C — Path C 引擎(状态机 + 统一路由) -- 配置层:`ModifierKey` 非空且 `PassthroughMod=1` 的映射在注册阶段不会直接绑定 Hotkey 回调,而是写入 `PathCMappingByModSource` 映射表,按 `modKey "|" sourceKey` 分组。 -- 运行时:在 `ReloadAllHotkeys` 末尾,统一为所有出现过的 `modKey`、`sourceKey` 注册一组“事件路由 Hotkey”: +- 配置层:`ModifierKey` 非空且 `PassthroughMod=1` 的映射在注册阶段不会直接绑定 Hotkey 回调,而是通过 `PathCEngine.Instance.AddMapping()` 记入引擎内部的映射表,按 `modKey "|" sourceKey` 分组。 +- 运行时:在 `ReloadAllHotkeys` 末尾调用 `PathCEngine.Instance.Commit()`,统一为所有出现过的 `modKey`、`sourceKey` 注册一组“事件路由 Hotkey”: - 修饰键:全部使用 `~modKey` / `~modKey Up` 透传物理事件,保证拖拽/浏览器右键手势等外部逻辑可以看到完整的 RButton 按下/移动/松开序列。 - - 源键:非滚轮键使用 `*sourceKey` / `*sourceKey Up`;滚轮键使用 `*sourceKey`,并通过 `PathC_ShouldRouteWheelSource()` 仅在存在命中的 Path C 会话时拦截,从而保留浏览器 `Ctrl+Wheel` 等原生语义。 + - 源键:非滚轮键使用 `*sourceKey` / `*sourceKey Up`;滚轮键使用 `*sourceKey`,并通过 `PathCEngine.Instance.ShouldRouteWheel()` 路由谓词仅在存在命中的 Path C 会话时拦截,从而保留浏览器 `Ctrl+Wheel` 等原生语义。 - 设计目标:优先保留修饰键原始交互,再在这个基础上叠加按键映射;对 `RButton` 而言,浏览器右键手势、网页应用里的右键拖拽画布等能力优先于“绝对不闪菜单”。 -- Path C 引擎内部维护每个修饰键的会话状态: - - `state`: `"Idle"` / `"HeldNoCombo"` / `"GestureActive"` +- Path C 引擎内部为每个修饰键维护一个 `PathCSession` 实例(构造函数固化会话结构): + - `state`: 会话状态,取值为 `PathCEngine` 的 `STATE_IDLE` / `STATE_HELD_NO_COMBO` / `STATE_GESTURE_ACTIVE` 常量(对外经 `GetSessionState(modKey)` 只读暴露) - `isGesture`: 当前按下周期是否触发过任意 Path C 组合 - `activeSources`: 当前会话下参与 repeat 的源键 - `repeatMappings`: 当前会话下正在 repeat 的映射 ID 集合 - 源键按下时,Path C 引擎按以下规则决策: - 遍历所有 `state != "Idle"` 的修饰键会话,对每个 `modKey "|" sourceKey` 在映射表里查找候选条目。 - 通过配置层生成的 `checker` 闭包判断进程作用域是否命中;命中后触发映射,并将会话标记为 `GestureActive` / `isGesture = true`。 - - 若映射开启 `HoldRepeat`,使用现有 `HoldTimers` + `RepeatTimerCallback(sendKey, sourceKey, idx, modKey)` 机制启动定时器,并将 `mapping.id` 记入会话。 + - 若映射开启 `HoldRepeat`,由引擎自有的 repeat 定时器机制启动定时器(`PathCEngine` 内部的 `StartMappingRepeat`),并将 `mapping.id` 记入会话;路径 A/B 仍使用 `HoldTimers` + `RepeatTimerCallback(sendKey, sourceKey, idx)`。 - 若未命中任何 Path C 映射,则回退发送原始 `sourceKey`;对于 `Wheel*`,如果当前根本不存在可命中的 Path C 会话,路由热键不会激活,原生滚轮事件会直接透传。 - 修饰键松开时: - 对非 RButton:无论是否触发过组合,只执行 Path C 内部清理逻辑(停止 repeat、清空会话),修饰键物理语义由 `~modKey` 透传负责。 @@ -141,7 +168,7 @@ - 启动时优先读取 `_state.ini` 中的 `UILanguage`;若缺失,则默认使用英文 (`en-US`) 作为 UI 语言,不再根据操作系统语言自动切换。 - 托盘菜单提供语言切换入口: - `Language` 子菜单下有 `English` / `简体中文`,点击后更新 `CurrentLangCode` 并调用 `SaveEnabledStates()` 持久化。 - - 切换语言时不会重启整个脚本,而是会“软重启”主窗口:关闭当前主窗口和相关子窗口,再用新的 `CurrentLangCode` 重建主窗口和托盘菜单,保持配置与热键状态不变。 + - 切换语言时不会重启整个脚本,而是会“软重启”主窗口:关闭当前主窗口和相关子窗口,再用新的 `CurrentLangCode` 重建主窗口和托盘菜单(重建即重新注册 `RenderFromState`),随后清空并重新 `Select` 之前的配置——通过 OnChanged 通知渲染层,用新语言从状态重建整个窗口,保持配置与热键状态不变。 - 代码与文档语言约定: - 源代码中的标识符与注释统一使用英文,便于英语使用者维护; - 用户界面文案通过本地化层维护中英双语; diff --git a/src/AHKeyMap.ahk b/src/AHKeyMap.ahk index 143412e..cef6df8 100644 --- a/src/AHKeyMap.ahk +++ b/src/AHKeyMap.ahk @@ -10,7 +10,7 @@ Persistent ;@Ahk2Exe-SetName AHKeyMap ;@Ahk2Exe-SetDescription AHKeyMap - Key remapping tool -;@Ahk2Exe-SetVersion 2.9.2 +;@Ahk2Exe-SetVersion 2.9.10 ;@Ahk2Exe-SetCopyright Copyright (c) 2026 ;@Ahk2Exe-SetMainIcon ..\assets\icon.ico @@ -23,7 +23,7 @@ if !IsSet(__AHKM_CONFIG_DIR) global __AHKM_CONFIG_DIR := "" global APP_NAME := "AHKeyMap" -global APP_VERSION := "2.9.2" +global APP_VERSION := "2.9.10" global SCRIPT_DIR := A_ScriptDir global APP_ROOT := (A_IsCompiled ? SCRIPT_DIR : SCRIPT_DIR "\..") global CONFIG_DIR := (__AHKM_CONFIG_DIR != "" ? __AHKM_CONFIG_DIR : APP_ROOT "\configs") @@ -41,17 +41,8 @@ global CONTEXT_MENU_DISMISS_DELAY := 10 ; context menu dismissal delay (ms) global DEFAULT_REPEAT_DELAY := 300 ; default long-press delay (ms) global DEFAULT_REPEAT_INTERVAL := 50 ; default long-press interval (ms) -; Config-related globals +; Config-related globals (AllConfigs is owned and mutated by ConfigStore) global AllConfigs := [] -global CurrentConfigName := "" -global CurrentConfigFile := "" -global CurrentProcessMode := "global" -global CurrentProcess := "" -global CurrentProcessList := [] -global CurrentExcludeProcess := "" -global CurrentExcludeProcessList := [] -global CurrentConfigEnabled := true -global Mappings := [] ; GUI control references global MainGui := "" @@ -63,6 +54,9 @@ global StatusText := "" global StatusDetailLink := "" global StatusHasWarning := false global StatusDetailHovered := false +; Last ReloadAllHotkeys result ({conflicts, regErrors}); ui-owned input for +; the status bar and its detail popup +global LastReloadResult := "" global BtnAddMapping := "" global BtnEditMapping := "" global BtnCopyMapping := "" @@ -83,18 +77,13 @@ global ActiveHotkeys := [] global HoldTimers := Map() global InterceptModKeys := Map() global AllProcessCheckers := [] -global HotkeyConflicts := [] -global HotkeyRegErrors := [] -global PathCMappingByModSource := Map() -global PathCModSessions := Map() -global PathCModsUsed := Map() -global PathCSourceKeysUsed := Map() -global PathCWheelRoutePredicates := [] global DispatchSendHook := "" +global ForegroundProcessHook := "" ; Key capture globals global IsCapturing := false global CaptureTarget := "" +global CaptureOnCaptured := "" global CaptureGui := "" global CaptureDisplayText := "" global CaptureTimer := "" @@ -109,9 +98,12 @@ global ProcessPickerGui := "" ; ============================================================================ ; Include modules ; ============================================================================ +#Include "shared/Schema.ahk" #Include "core/Config.ahk" +#Include "core/ConfigStore.ahk" #Include "shared/Utils.ahk" #Include "core/Localization.ahk" +#Include "core/PathCEngine.ahk" #Include "core/HotkeyEngine.ahk" #Include "core/KeyCapture.ahk" #Include "ui/GuiMain.ahk" @@ -180,6 +172,7 @@ if !__AHKM_TEST_MODE StartApp() { global CurrentLangCode + global LastReloadResult ; Ensure config directory exists if !DirExist(CONFIG_DIR) @@ -198,7 +191,7 @@ StartApp() { if (CurrentLangCode = "") CurrentLangCode := "en-US" - ; Build main GUI + ; Build main GUI (registers RenderFromState as the store's OnChanged) BuildMainGui() ; Load all configs into AllConfigs @@ -207,11 +200,17 @@ StartApp() { ; At startup, sync enabled states and clean stale keys in _state.ini SaveEnabledStates() - ; Refresh config dropdown (GUI only) - RefreshConfigList(lastConfig) + ; Register hotkeys for all enabled configs; the store notifies the + ; render seam with the reload result + LastReloadResult := ReloadAllHotkeys() - ; Register hotkeys for all enabled configs - ReloadAllHotkeys() + ; Select and render the last used config (render-only, no reload). + ; When the recorded name no longer exists on disk, fall back to the + ; first config so the store and the dropdown stay consistent — this is + ; a store-side decision because render functions never mutate the store. + if (ConfigStore.Instance.FindIndex(lastConfig) = 0 && AllConfigs.Length > 0) + lastConfig := AllConfigs[1]["name"] + ConfigStore.Instance.Select(lastConfig) ; Show main window MainGui.Show("w720 h500") @@ -220,14 +219,13 @@ StartApp() { ; Rebuild main window for language switch (soft reload) RebuildMainWindowForLanguageChange() { global MainGui - global CurrentConfigName global EditGui global CaptureGui global ProcessPickerOpen global ProcessPickerGui ; Record current config name and window position/size - currentConfig := CurrentConfigName + currentConfig := ConfigStore.Instance.SelectedName x := 0, y := 0, w := 0, h := 0 try { if (MainGui != "") @@ -256,16 +254,16 @@ RebuildMainWindowForLanguageChange() { } ; Rebuild main window and tray menu using current language + ; (BuildMainGui re-registers RenderFromState as the store's OnChanged) BuildMainGui() - ; Refresh config list and GUI state with the previously selected config - ; Clear CurrentConfigName so OnConfigSelect reloads config and mapping list - global CurrentConfigName - CurrentConfigName := "" - RefreshConfigList(currentConfig) + ; Clear the store selection so the reload below re-selects and re-renders + ConfigStore.Instance.Select("") - ; Refresh status bar with the new language - UpdateStatusText() + ; Re-select the previous config through the store: notifies the render + ; seam, which re-renders the new-language window from state (hotkeys are + ; untouched by a language switch, so the last reload result stays valid) + ConfigStore.Instance.Select(currentConfig) ; Restore previous window position/size, or use default dimensions showOpts := "" diff --git a/src/core/Config.ahk b/src/core/Config.ahk index d8ab232..720c970 100644 --- a/src/core/Config.ahk +++ b/src/core/Config.ahk @@ -1,6 +1,6 @@ ; ============================================================================ ; AHKeyMap - Config management module -; Load, save and manage config INI files +; Pure config/state INI I/O (load/save, list enumeration, enabled persistence) ; ============================================================================ ; Globals shared across modules @@ -9,26 +9,6 @@ global SCRIPT_DIR global CONFIG_DIR global STATE_FILE global AllConfigs -global CurrentConfigName -global CurrentConfigFile -global CurrentProcessMode -global CurrentProcess -global CurrentProcessList -global CurrentExcludeProcess -global CurrentExcludeProcessList -global CurrentConfigEnabled -global Mappings -global ConfigDDL -global EnabledCB -global ProcessText -global StatusText -global StatusDetailLink -global StatusHasWarning -global MappingLV -global HotkeyConflicts -global HotkeyRegErrors -global DEFAULT_REPEAT_DELAY -global DEFAULT_REPEAT_INTERVAL ; ============================================================================ ; Config management functions @@ -64,10 +44,6 @@ LoadConfigData(configName) { if !FileExist(configFile) return "" - cfg := Map() - cfg["name"] := configName - cfg["file"] := configFile - ; Read Meta section - process mode (with backwards compatibility) processMode := IniRead(configFile, "Meta", "ProcessMode", "") process := IniRead(configFile, "Meta", "Process", "") @@ -81,19 +57,14 @@ LoadConfigData(configName) { processMode := "global" } - cfg["processMode"] := processMode - cfg["process"] := process - cfg["processList"] := ParseProcessList(process) - cfg["excludeProcess"] := excludeProcess - cfg["excludeProcessList"] := ParseProcessList(excludeProcess) - ; Read enabled state from _state.ini enabledVal := "1" if FileExist(STATE_FILE) enabledVal := IniRead(STATE_FILE, "EnabledConfigs", configName, "1") - cfg["enabled"] := (enabledVal = "1") + enabled := (enabledVal = "1") - ; Read mappings + ; Read mappings (Mapping.Make enforces the record invariants, including + ; clamping hand-edited sub-minimum repeat timing at load time) mappings := [] idx := 1 loop { @@ -102,83 +73,18 @@ LoadConfigData(configName) { if (sourceKey = "") break - mapping := Map() - mapping["ModifierKey"] := IniRead(configFile, section, "ModifierKey", "") - mapping["SourceKey"] := sourceKey - mapping["TargetKey"] := IniRead(configFile, section, "TargetKey", "") - mapping["HoldRepeat"] := Integer(IniRead(configFile, section, "HoldRepeat", "0")) - mapping["RepeatDelay"] := Integer(IniRead(configFile, section, "RepeatDelay", String(DEFAULT_REPEAT_DELAY))) - mapping["RepeatInterval"] := Integer(IniRead(configFile, section, "RepeatInterval", String(DEFAULT_REPEAT_INTERVAL))) - mapping["PassthroughMod"] := Integer(IniRead(configFile, section, "PassthroughMod", "0")) - mappings.Push(mapping) + mappings.Push(Mapping.Make( + IniRead(configFile, section, "ModifierKey", ""), + sourceKey, + IniRead(configFile, section, "TargetKey", ""), + IniRead(configFile, section, "HoldRepeat", "0"), + IniRead(configFile, section, "RepeatDelay", ""), + IniRead(configFile, section, "RepeatInterval", ""), + IniRead(configFile, section, "PassthroughMod", "0"))) idx++ } - cfg["mappings"] := mappings - - return cfg -} - -; Find config index by name in AllConfigs (0 = not found) -FindConfigIndex(configName) { - for i, cfg in AllConfigs { - if (cfg["name"] = configName) - return i - } - return 0 -} - -; Sync current GUI editing state back into AllConfigs -SyncCurrentToAllConfigs() { - if (CurrentConfigName = "") - return - idx := FindConfigIndex(CurrentConfigName) - if (idx = 0) - return - cfg := AllConfigs[idx] - cfg["processMode"] := CurrentProcessMode - cfg["process"] := CurrentProcess - cfg["processList"] := CurrentProcessList - cfg["excludeProcess"] := CurrentExcludeProcess - cfg["excludeProcessList"] := CurrentExcludeProcessList - cfg["enabled"] := CurrentConfigEnabled - cfg["mappings"] := Mappings -} - -; Refresh config dropdown (GUI only, does not affect hotkeys) -RefreshConfigList(selectName := "") { - configs := GetConfigList() - items := [] - selectIdx := 0 - for i, name in configs { - items.Push(name) - if (name = selectName) - selectIdx := i - } - ConfigDDL.Delete() - if (items.Length > 0) { - ConfigDDL.Add(items) - if (selectIdx > 0) - ConfigDDL.Choose(selectIdx) - else - ConfigDDL.Choose(1) - OnConfigSelect(ConfigDDL, "") - } else { - global CurrentConfigName := "" - global CurrentConfigFile := "" - global CurrentProcessMode := "global" - global CurrentProcess := "" - global CurrentProcessList := [] - global CurrentExcludeProcess := "" - global CurrentExcludeProcessList := [] - global CurrentConfigEnabled := true - ProcessText.Value := L("Config.Scope.None") - EnabledCB.Value := 0 - EnabledCB.Enabled := false - global Mappings := [] - RefreshMappingLV() - } - UpdateStatusText() + return ConfigRecord.Make(configName, processMode, process, excludeProcess, enabled, mappings) } ; Parse process string into an array @@ -198,142 +104,45 @@ IsValidConfigName(configName) { return !RegExMatch(configName, '[\\/:*?"<>|=\[\]]') } -; Format process scope for display (using parsed arrays) -FormatProcessDisplay(processMode, processList, excludeProcessList) { - if (processMode = "include") { - if (processList.Length = 0) - return L("Config.Scope.Global") - if (processList.Length = 1) - return L("Config.Scope.Include.Single", processList[1]) - return L("Config.Scope.Include.Multi", processList[1], processList.Length - 1) - } else if (processMode = "exclude") { - if (excludeProcessList.Length = 0) - return L("Config.Scope.Global") - if (excludeProcessList.Length = 1) - return L("Config.Scope.Exclude.Single", excludeProcessList[1]) - return L("Config.Scope.Exclude.Multi", excludeProcessList[1], excludeProcessList.Length - 1) - } - return L("Config.Scope.Global") -} - -; Update status bar text -UpdateStatusText() { - enabledCount := 0 - totalCount := AllConfigs.Length - for _, cfg in AllConfigs { - if (cfg["enabled"]) - enabledCount++ - } - - statusStr := L("Config.Status.EnabledSummary", enabledCount, totalCount) - hasWarning := false - if (HotkeyConflicts.Length > 0) { - statusStr .= L("Config.Status.ConflictSuffix", HotkeyConflicts.Length) - hasWarning := true - } - if (HotkeyRegErrors.Length > 0) { - statusStr .= L("Config.Status.RegErrorSuffix", HotkeyRegErrors.Length) - hasWarning := true - } - - ; When warnings exist: make status text orange and show detail link - global StatusHasWarning := hasWarning - if (StatusText = "" || StatusDetailLink = "") - return - - if (hasWarning) { - StatusText.SetFont("cE07B00") - StatusDetailLink.Opt("-Hidden") - } else { - StatusText.SetFont("cGray") - StatusDetailLink.Opt("+Hidden") - SetStatusDetailHover(false) - } - StatusText.Value := statusStr -} - -; Load specified config into GUI (does not affect hotkey registration) -LoadConfigToGui(configName) { - idx := FindConfigIndex(configName) - if (idx = 0) - return - - global CurrentConfigName := configName - global CurrentConfigFile := CONFIG_DIR "\" configName ".ini" - - cfg := AllConfigs[idx] - global CurrentProcessMode := cfg["processMode"] - global CurrentProcess := cfg["process"] - global CurrentProcessList := cfg["processList"] - global CurrentExcludeProcess := cfg["excludeProcess"] - global CurrentExcludeProcessList := cfg["excludeProcessList"] - global CurrentConfigEnabled := cfg["enabled"] - - global Mappings := [] - for _, m in cfg["mappings"] { - newM := Map() - for k, v in m - newM[k] := v - Mappings.Push(newM) - } - - ProcessText.Value := FormatProcessDisplay(CurrentProcessMode, CurrentProcessList, CurrentExcludeProcessList) - EnabledCB.Value := CurrentConfigEnabled - EnabledCB.Enabled := true - - RefreshMappingLV() - - ; Persist last viewed config name into _state.ini - try IniWrite(configName, STATE_FILE, "State", "LastConfig") -} - -; Save current config to file (atomic write: temp file then replace) -SaveConfig() { - if (CurrentConfigName = "" || CurrentConfigFile = "") - return - - tempFile := CurrentConfigFile ".tmp" +; Serialize one config record to its INI file (atomic write: temp file then replace) +SaveConfig(cfg) { + configFile := cfg["file"] + tempFile := configFile ".tmp" ; Step 1: write all content into a temp file (section by section) try { if FileExist(tempFile) FileDelete(tempFile) - metaPairs := "Name=" CurrentConfigName - metaPairs .= "`nProcessMode=" CurrentProcessMode - metaPairs .= "`nProcess=" CurrentProcess - metaPairs .= "`nExcludeProcess=" CurrentExcludeProcess + metaPairs := "Name=" cfg["name"] + metaPairs .= "`nProcessMode=" cfg["processMode"] + metaPairs .= "`nProcess=" cfg["process"] + metaPairs .= "`nExcludeProcess=" cfg["excludeProcess"] IniWrite(metaPairs, tempFile, "Meta") - for idx, mapping in Mappings { - pairs := "ModifierKey=" mapping["ModifierKey"] - pairs .= "`nSourceKey=" mapping["SourceKey"] - pairs .= "`nTargetKey=" mapping["TargetKey"] - pairs .= "`nHoldRepeat=" mapping["HoldRepeat"] - pairs .= "`nRepeatDelay=" mapping["RepeatDelay"] - pairs .= "`nRepeatInterval=" mapping["RepeatInterval"] - pairs .= "`nPassthroughMod=" mapping["PassthroughMod"] + for idx, m in cfg["mappings"] { + pairs := "" + for iniKey, iniVal in Mapping.ToIniPairs(m) { + if (pairs != "") + pairs .= "`n" + pairs .= iniKey "=" iniVal + } IniWrite(pairs, tempFile, "Mapping" idx) } } catch as e { ; If writing temp file fails, original file stays intact; clean up tmp try FileDelete(tempFile) - MsgBox(Format(L("Config.SaveError.WriteTemp"), e.Message, CurrentConfigFile), APP_NAME, "IconX") + MsgBox(Format(L("Config.SaveError.WriteTemp"), e.Message, configFile), APP_NAME, "IconX") return } ; Step 2: replace original file with temp file (FileMove overwrite mode) try { - FileMove(tempFile, CurrentConfigFile, 1) + FileMove(tempFile, configFile, 1) } catch as e { try FileDelete(tempFile) - MsgBox(Format(L("Config.SaveError.Replace"), e.Message, CurrentConfigFile), APP_NAME, "IconX") - return + MsgBox(Format(L("Config.SaveError.Replace"), e.Message, configFile), APP_NAME, "IconX") } - - ; Sync back into AllConfigs and save enabled states - SyncCurrentToAllConfigs() - SaveEnabledStates() } ; Save enabled state for all configs to _state.ini (atomic write) @@ -366,30 +175,3 @@ SaveEnabledStates() { MsgBox(Format(L("Config.SaveEnabledStatesError"), e.Message), APP_NAME, "IconX") } } - -; Refresh mapping ListView display -RefreshMappingLV() { - MappingLV.Delete() - for idx, mapping in Mappings { - holdText := mapping["HoldRepeat"] ? L("Config.Mapping.HoldYes") : L("Config.Mapping.HoldNo") - modDisplay := mapping["ModifierKey"] != "" ? KeyToDisplay(mapping["ModifierKey"]) : "" - ptText := "" - if (mapping["ModifierKey"] != "") - ptText := mapping["PassthroughMod"] ? L("Config.Mapping.ModMode.Pass") : L("Config.Mapping.ModMode.Block") - delayText := mapping["HoldRepeat"] ? mapping["RepeatDelay"] : "" - intervalText := mapping["HoldRepeat"] ? mapping["RepeatInterval"] : "" - MappingLV.Add("" - , idx - , modDisplay - , KeyToDisplay(mapping["SourceKey"]) - , KeyToDisplay(mapping["TargetKey"]) - , holdText - , ptText - , delayText - , intervalText) - } - ; Auto-adjust column widths - loop 8 - MappingLV.ModifyCol(A_Index, "AutoHdr") -} - diff --git a/src/core/ConfigStore.ahk b/src/core/ConfigStore.ahk new file mode 100644 index 0000000..b6779c8 --- /dev/null +++ b/src/core/ConfigStore.ahk @@ -0,0 +1,342 @@ +; ============================================================================ +; AHKeyMap - Config store module +; Owns AllConfigs, the current selection, and every config/mutation operation. +; Each mutation runs one chokepoint: atomic persist -> hotkey reload -> notify. +; ============================================================================ + +; Globals shared across modules (engine input stays global) +global AllConfigs + +; Deep module for the config working copy: +; Select(name) / Selected() to read the selected record, and one semantic +; method per user action. Every mutation runs the same chokepoint: +; persist (atomic config write + SaveEnabledStates) -> ReloadAllHotkeys() +; -> OnChanged(reloadResult). +; The store never touches the GUI: rendering happens in whatever the host +; registered into the OnChanged slot (the ui layer registers its render +; entry; headless tests register nothing). Production code uses the lazy +; singleton `ConfigStore.Instance`; tests may reset the singleton via +; ResetConfigStoreForTests(). +class ConfigStore { + static _instance := "" + + static Instance { + get { + if (ConfigStore._instance = "") + ConfigStore._instance := ConfigStore() + return ConfigStore._instance + } + } + + __New() { + ; Name of the selected config ("" when nothing is selected). + ; Field name must differ from the SelectedName property (AHK v2 + ; identifiers are case-insensitive; same name would be read-only). + this.selName := "" + ; Callback slot fired after every chokepoint with the ReloadAllHotkeys + ; result; the host (GUI) owns rendering, core stays ui-free. + this.onChanged := "" + } + + ; ------------------------------------------------------------------------ + ; OnChanged seam + ; ------------------------------------------------------------------------ + + ; Register the change-notification callback: OnChanged(reloadResult) + ; reloadResult is the Map returned by ReloadAllHotkeys() ({conflicts, + ; regErrors}). Register "" to clear. One subscriber is all this seam + ; needs; it exists to invert the core->ui dependency direction. + SetOnChanged(callback) { + this.onChanged := callback + } + + ; Fire the registered callback (no-op when nothing is registered) + NotifyChanged(reloadResult) { + if (this.onChanged = "") + return + this.onChanged.Call(reloadResult) + } + + ; ------------------------------------------------------------------------ + ; State access + ; ------------------------------------------------------------------------ + + ; Name of the currently selected config ("" = no selection) + SelectedName { + get { + return this.selName + } + } + + ; The selected config record, or "" when nothing is selected + Selected() { + if (this.selName = "") + return "" + idx := this.FindIndex(this.selName) + if (idx = 0) + return "" + return AllConfigs[idx] + } + + ; The mappings array of the selected config (or an empty standalone array) + SelectedMappings() { + cfg := this.Selected() + if (cfg = "") + return [] + return cfg["mappings"] + } + + ; Find config index by name in AllConfigs (0 = not found) + FindIndex(configName) { + for i, cfg in AllConfigs { + if (cfg["name"] = configName) + return i + } + return 0 + } + + ; ------------------------------------------------------------------------ + ; Semantic mutations (each runs the single chokepoint internally) + ; ------------------------------------------------------------------------ + + ; Select a config by name ("" clears the selection), persist the last + ; viewed name, and notify. Selection itself is render-only (no hotkey + ; reload), so it notifies with the unchanged "" result. + Select(name) { + this.selName := name + cfg := this.Selected() + if (cfg = "") + this.selName := "" + if (cfg != "") { + ; Persist last viewed config name into _state.ini + try IniWrite(name, STATE_FILE, "State", "LastConfig") + } + this.NotifyChanged("") + } + + ; Enable/disable the selected config + SetEnabled(flag) { + cfg := this.Selected() + if (cfg = "") + return + cfg["enabled"] := (flag ? true : false) + this.RunChokepoint() + } + + ; Change process scope of the selected config; keeps include/exclude/global + ; branch consistency (only the active branch keeps its process list) + SetScope(mode, procStr) { + cfg := this.Selected() + if (cfg = "") + return + + cfg["processMode"] := mode + if (mode = "include") { + cfg["process"] := procStr + cfg["processList"] := ParseProcessList(procStr) + cfg["excludeProcess"] := "" + cfg["excludeProcessList"] := [] + } else if (mode = "exclude") { + cfg["process"] := "" + cfg["processList"] := [] + cfg["excludeProcess"] := procStr + cfg["excludeProcessList"] := ParseProcessList(procStr) + } else { + cfg["process"] := "" + cfg["processList"] := [] + cfg["excludeProcess"] := "" + cfg["excludeProcessList"] := [] + } + + this.RunChokepoint() + } + + ; Append a mapping to the selected config; returns the new index (0 on failure) + AddMapping(mapping) { + cfg := this.Selected() + if (cfg = "") + return 0 + cfg["mappings"].Push(this.NormalizeIncomingMapping(mapping)) + this.RunChokepoint() + return cfg["mappings"].Length + } + + ; Replace the mapping at the given index (1-based) in the selected config + ReplaceMapping(index, mapping) { + cfg := this.Selected() + if (cfg = "") + return + if (index < 1 || index > cfg["mappings"].Length) + return + cfg["mappings"][index] := this.NormalizeIncomingMapping(mapping) + this.RunChokepoint() + } + + ; Delete the mapping at the given index (1-based) from the selected config + DeleteMapping(index) { + cfg := this.Selected() + if (cfg = "") + return + if (index < 1 || index > cfg["mappings"].Length) + return + cfg["mappings"].RemoveAt(index) + this.RunChokepoint() + } + + ; Create a new config with the given scope and select it + CreateConfig(name, mode, procStr) { + newFile := CONFIG_DIR "\" name ".ini" + if FileExist(newFile) + return + tempFile := newFile ".tmp" + + try { + if FileExist(tempFile) + FileDelete(tempFile) + + ; Stage the new config in a temp file (atomic write pattern: + ; the config path only ever sees a complete file) + IniWrite(name, tempFile, "Meta", "Name") + IniWrite(mode, tempFile, "Meta", "ProcessMode") + if (mode = "include") { + IniWrite(procStr, tempFile, "Meta", "Process") + IniWrite("", tempFile, "Meta", "ExcludeProcess") + } else if (mode = "exclude") { + IniWrite("", tempFile, "Meta", "Process") + IniWrite(procStr, tempFile, "Meta", "ExcludeProcess") + } else { + IniWrite("", tempFile, "Meta", "Process") + IniWrite("", tempFile, "Meta", "ExcludeProcess") + } + FileMove(tempFile, newFile, 1) + + ; Enable new config by default + IniWrite("1", STATE_FILE, "EnabledConfigs", name) + } catch as e { + ; Remove staged/partial files so the name can be retried + try FileDelete(tempFile) + try FileDelete(newFile) + MsgBox(Format(L("Config.CreateError"), e.Message, newFile), APP_NAME, "IconX") + return + } + + LoadAllConfigs() + this.NotifyChokepointReload() + this.Select(name) + } + + ; Copy the selected config under a new name and select the copy + CopyConfig(newName) { + cfg := this.Selected() + if (cfg = "") + return + newFile := CONFIG_DIR "\" newName ".ini" + if FileExist(newFile) + return + tempFile := newFile ".tmp" + + try { + if FileExist(tempFile) + FileDelete(tempFile) + + ; Stage the copy in a temp file (atomic write pattern) + if FileExist(cfg["file"]) + FileCopy(cfg["file"], tempFile) + + ; Update Name field inside copied config + IniWrite(newName, tempFile, "Meta", "Name") + FileMove(tempFile, newFile, 1) + + ; Enable new config by default + IniWrite("1", STATE_FILE, "EnabledConfigs", newName) + } catch as e { + ; Remove staged/partial files so the name can be retried + try FileDelete(tempFile) + try FileDelete(newFile) + MsgBox(Format(L("Config.CopyError"), e.Message, newFile), APP_NAME, "IconX") + return + } + + LoadAllConfigs() + this.NotifyChokepointReload() + this.Select(newName) + } + + ; Delete the selected config (file + record) and clear the selection + DeleteConfig() { + cfg := this.Selected() + if (cfg = "") + return + + if FileExist(cfg["file"]) + FileDelete(cfg["file"]) + + idx := this.FindIndex(cfg["name"]) + if (idx > 0) + AllConfigs.RemoveAt(idx) + + this.selName := "" + + SaveEnabledStates() + this.NotifyChokepointReload() + + ; Keep the selection valid after a delete: adopt the first remaining + ; config (nothing when the list is now empty). This is a store-side + ; decision — render functions never mutate the store, so the old + ; "dropdown adopts item 1 during render" behavior lives here instead. + if (AllConfigs.Length > 0) + this.Select(AllConfigs[1]["name"]) + } + + ; ------------------------------------------------------------------------ + ; Internals + ; ------------------------------------------------------------------------ + + ; Boundary guarantee: every mapping entering the working copy is + ; re-normalized so all stored records satisfy the schema invariants + ; (local name avoids shadowing the Mapping class; AHK names are + ; case-insensitive) + NormalizeIncomingMapping(m) { + if (Type(m) != "Map") + return Mapping.Make("", "", "") + Mapping.Normalize(m) + return m + } + + ; ------------------------------------------------------------------------ + ; Chokepoint + ; ------------------------------------------------------------------------ + + ; Single mutation flow: persist the selected config (atomic write plus + ; enabled states) -> reload all hotkeys -> notify with the reload result. + ; Every mutation, including SetEnabled, runs this exact sequence. + RunChokepoint() { + cfg := this.Selected() + if (cfg != "") + SaveConfig(cfg) + SaveEnabledStates() + this.NotifyChokepointReload() + } + + ; Reload hotkeys and hand the result to the OnChanged subscriber + NotifyChokepointReload() { + this.NotifyChanged(ReloadAllHotkeys()) + } + + ; ------------------------------------------------------------------------ + ; Reset + ; ------------------------------------------------------------------------ + + ; Clear the selection and the OnChanged registration without touching + ; AllConfigs (test/teardown helper, mirrors PathCEngine.Reset()) + Reset() { + this.selName := "" + this.onChanged := "" + } +} + +; Test seam: reset the singleton so the next Instance access builds a fresh +; store with an empty selection (mirrors ResetAppState clearing AllConfigs) +ResetConfigStoreForTests() { + ConfigStore._instance := "" +} diff --git a/src/core/HotkeyEngine.ahk b/src/core/HotkeyEngine.ahk index 841708c..47ac6f7 100644 --- a/src/core/HotkeyEngine.ahk +++ b/src/core/HotkeyEngine.ahk @@ -9,14 +9,7 @@ global ActiveHotkeys global HoldTimers global InterceptModKeys global AllProcessCheckers -global HotkeyConflicts -global HotkeyRegErrors -global PathCMappingByModSource -global PathCModSessions -global PathCModsUsed -global PathCSourceKeysUsed -global PathCWheelRoutePredicates -global CONTEXT_MENU_DISMISS_DELAY +global ForegroundProcessHook ; ============================================================================ ; Hotkey engine core @@ -45,6 +38,11 @@ NormalizeProcessName(procName) { } GetForegroundProcessName() { + ; Test seam: when set, the hook replaces the OS foreground-process query + if (ForegroundProcessHook != "") { + return NormalizeProcessName(ForegroundProcessHook.Call()) + } + try return NormalizeProcessName(WinGetProcessName("A")) catch @@ -84,14 +82,10 @@ AddUniqueArrayValue(arr, value) { arr.Push(value) } -; For Path C, only register Up hotkeys on source keys that support key-up -SupportsKeyUpHotkey(hotkeyName) { - return !IsWheelSourceKey(hotkeyName) -} - -IsWheelSourceKey(sourceKey) { - baseKey := RegExReplace(sourceKey, "^[~*$+!#^]+", "") - return RegExMatch(baseKey, "^Wheel") +; Append every element of src to dest (in order) +AppendAll(dest, src) { + for _, value in src + dest.Push(value) } MakeActiveHotkeyRecord(checker := "", configName := "", key := "", keyUp := "") { @@ -143,12 +137,9 @@ UnregisterAllHotkeys() { global InterceptModKeys := Map() global HoldTimers := Map() global AllProcessCheckers := [] - global HotkeyRegErrors := [] - global PathCMappingByModSource := Map() - global PathCModSessions := Map() - global PathCModsUsed := Map() - global PathCSourceKeysUsed := Map() - global PathCWheelRoutePredicates := [] + + ; Path C hotkeys and state are owned by the Path C engine + PathCEngine.Instance.Reset() ; Then disable each hotkey from the snapshot, ignoring script-level cleanup errors for _, info in hotkeysSnapshot { @@ -170,7 +161,9 @@ UnregisterAllHotkeys() { } ; Reload hotkeys for all enabled configs +; Returns the engine output as a Map: {conflicts: [...], regErrors: [...]} ReloadAllHotkeys() { + regErrors := [] UnregisterAllHotkeys() ; Split configs by scope priority: include > exclude > global @@ -195,36 +188,29 @@ ReloadAllHotkeys() { ; Register in priority order: include first (most specific), global last for _, cfg in includeConfigs - RegisterConfigHotkeys(cfg) + AppendAll(regErrors, RegisterConfigHotkeys(cfg)) for _, cfg in excludeConfigs - RegisterConfigHotkeys(cfg) + AppendAll(regErrors, RegisterConfigHotkeys(cfg)) for _, cfg in globalConfigs - RegisterConfigHotkeys(cfg) + AppendAll(regErrors, RegisterConfigHotkeys(cfg)) ; Register shared routing hotkeys for all Path C mappings - RegisterAllPathCHotkeys() + AppendAll(regErrors, PathCEngine.Instance.Commit()) HotIf() - ; Detect hotkey conflicts and update the status bar - DetectHotkeyConflicts() - UpdateStatusText() -} - - -; Reload hotkeys for a single config (implemented as full reload for now) -ReloadConfigHotkeys(configName := "") { - ReloadAllHotkeys() + return { conflicts: DetectHotkeyConflicts(), regErrors: regErrors } } ; Detect hotkey conflicts across enabled configs with overlapping scopes +; Pure function: reads AllConfigs, returns the conflict array, touches nothing ; Conflict rules: ; global vs any non-empty scope -> conflict ; exclude vs exclude -> conflict (conservative strategy) ; include vs include -> conflict when process lists intersect ; include vs global -> conflict when include is non-empty DetectHotkeyConflicts() { - global HotkeyConflicts := [] + conflicts := [] ; Collect mappings from enabled configs together with scope metadata hotkeyGroups := Map() @@ -246,15 +232,9 @@ DetectHotkeyConflicts() { else procKey := "" - for idx, mapping in cfg["mappings"] { - modKey := mapping["ModifierKey"] - sourceKey := mapping["SourceKey"] - if (modKey = "") - hkStr := sourceKey - else if (!mapping["PassthroughMod"]) - hkStr := modKey " & " sourceKey - else - hkStr := "~" modKey "+" sourceKey + for idx, m in cfg["mappings"] { + hkStr := Mapping.HotkeyStringFor(m) + modKey := m["ModifierKey"] entry := { hotkey: hkStr, @@ -271,14 +251,14 @@ DetectHotkeyConflicts() { ; Collect modifier usage by path for cross-path B/C conflict detection if (modKey != "") { scopeInfo := { configName: cfg["name"], mode: mode, procKey: procKey } - if (!mapping["PassthroughMod"]) { - if !modUsageB.Has(modKey) - modUsageB[modKey] := [] - modUsageB[modKey].Push(scopeInfo) - } else { + if (Mapping.ClassifyPath(m) = Mapping.PATH_C) { if !modUsageC.Has(modKey) modUsageC[modKey] := [] modUsageC[modKey].Push(scopeInfo) + } else { + if !modUsageB.Has(modKey) + modUsageB[modKey] := [] + modUsageB[modKey].Push(scopeInfo) } } } @@ -295,7 +275,7 @@ DetectHotkeyConflicts() { a := group[i] b := group[j] if ScopesOverlap(a.mode, a.procKey, b.mode, b.procKey) { - HotkeyConflicts.Push({ + conflicts.Push({ hotkey: a.hotkey, config1: a.configName, idx1: a.mappingIdx, @@ -317,7 +297,7 @@ DetectHotkeyConflicts() { for _, bEntry in bEntries { for _, cEntry in cEntries { if ScopesOverlap(bEntry.mode, bEntry.procKey, cEntry.mode, cEntry.procKey) { - HotkeyConflicts.Push({ + conflicts.Push({ hotkey: modKey " (Path B/C conflict)", config1: bEntry.configName, idx1: 0, @@ -328,6 +308,7 @@ DetectHotkeyConflicts() { } } } + return conflicts } ; Normalize include process list into a comparable scope key: @@ -456,10 +437,12 @@ ScopesOverlap(mode1, procKey1, mode2, procKey2) { } ; Register all hotkeys for a single config +; Returns the array of hotkey names that failed registration RegisterConfigHotkeys(cfg) { + regErrors := [] mappings := cfg["mappings"] if (mappings.Length = 0) - return + return regErrors ; Create process checker closure (used by Path A/B; Path C checks scope in callbacks) checker := MakeProcessChecker(cfg) @@ -471,92 +454,97 @@ RegisterConfigHotkeys(cfg) { ; Register all mappings under this config (A/B register hotkeys, C builds mapping table) for idx, mapping in mappings { - RegisterMapping(mapping, useCustomHotIf, checker, cfg["name"] "|" idx, cfg["name"]) + AppendAll(regErrors, RegisterMapping(mapping, useCustomHotIf, checker, cfg["name"] "|" idx, cfg["name"])) } + return regErrors } ; Register a single mapping by dispatching to Path A/B/C -RegisterMapping(mapping, useCustomHotIf, checker, uniqueIdx, configName) { - modKey := mapping["ModifierKey"] +; Returns the array of hotkey names that failed registration +; (local name avoids shadowing the Mapping class; AHK names are case-insensitive) +RegisterMapping(m, useCustomHotIf, checker, uniqueIdx, configName) { + regErrors := [] + path := Mapping.ClassifyPath(m) ; Path A: no modifier, direct hotkey registration - if (modKey = "") { + if (path = Mapping.PATH_A) { if (useCustomHotIf) HotIf(checker) else HotIf() hkInfo := MakeActiveHotkeyRecord(checker, configName) - RegisterPathA(mapping, hkInfo, uniqueIdx) + RegisterPathA(m, hkInfo, uniqueIdx, regErrors) ActiveHotkeys.Push(hkInfo) - return + return regErrors } ; Path B: intercepting combo hotkey (modKey & sourceKey), modifier does not pass through - if (!mapping["PassthroughMod"]) { + if (path = Mapping.PATH_B) { if (useCustomHotIf) HotIf(checker) else HotIf() hkInfo := MakeActiveHotkeyRecord(checker, configName) - RegisterPathB(mapping, hkInfo, uniqueIdx, checker, configName) + RegisterPathB(m, hkInfo, uniqueIdx, checker, configName, regErrors) ActiveHotkeys.Push(hkInfo) - return + return regErrors } ; Path C: stateful passthrough, handled by Path C engine instead of direct target callback HotIf() - RegisterPathCMapping(mapping, uniqueIdx, configName, checker) + PathCEngine.Instance.AddMapping(m, uniqueIdx, configName, checker) + return regErrors } ; Path A: no modifier, directly map sourceKey -> targetKey -RegisterPathA(mapping, hkInfo, uniqueIdx) { - sourceKey := mapping["SourceKey"] - targetKey := mapping["TargetKey"] - holdRepeat := mapping["HoldRepeat"] +RegisterPathA(m, hkInfo, uniqueIdx, regErrors) { + sourceKey := m["SourceKey"] + targetKey := m["TargetKey"] + holdRepeat := m["HoldRepeat"] hkInfo.key := sourceKey if (holdRepeat) { - downCb := HoldDownCallback.Bind(targetKey, mapping["RepeatDelay"], mapping["RepeatInterval"], uniqueIdx, sourceKey) + downCb := HoldDownCallback.Bind(targetKey, m["RepeatDelay"], m["RepeatInterval"], uniqueIdx, sourceKey) upCb := HoldUpCallback.Bind(uniqueIdx) try { Hotkey(sourceKey, downCb, "On") Hotkey(sourceKey " Up", upCb, "On") hkInfo.keyUp := sourceKey " Up" } catch as e { - HotkeyRegErrors.Push(sourceKey) + regErrors.Push(sourceKey) } } else { try Hotkey(sourceKey, SendKeyCallback.Bind(targetKey), "On") catch as e - HotkeyRegErrors.Push(sourceKey) + regErrors.Push(sourceKey) } } ; Path B: intercepting combo hotkey (modKey & sourceKey), modifier does not pass through -RegisterPathB(mapping, hkInfo, uniqueIdx, checker, configName) { - modKey := mapping["ModifierKey"] - sourceKey := mapping["SourceKey"] - targetKey := mapping["TargetKey"] - holdRepeat := mapping["HoldRepeat"] - comboKey := modKey " & " sourceKey +RegisterPathB(m, hkInfo, uniqueIdx, checker, configName, regErrors) { + modKey := m["ModifierKey"] + sourceKey := m["SourceKey"] + targetKey := m["TargetKey"] + holdRepeat := m["HoldRepeat"] + comboKey := Mapping.HotkeyStringFor(m) hkInfo.key := comboKey if (holdRepeat) { - downCb := HoldDownCallback.Bind(targetKey, mapping["RepeatDelay"], mapping["RepeatInterval"], uniqueIdx, sourceKey) + downCb := HoldDownCallback.Bind(targetKey, m["RepeatDelay"], m["RepeatInterval"], uniqueIdx, sourceKey) upCb := HoldUpCallback.Bind(uniqueIdx) try { Hotkey(comboKey, downCb, "On") Hotkey(comboKey " Up", upCb, "On") hkInfo.keyUp := comboKey " Up" } catch as e { - HotkeyRegErrors.Push(comboKey) + regErrors.Push(comboKey) } } else { try Hotkey(comboKey, SendKeyCallback.Bind(targetKey), "On") catch as e - HotkeyRegErrors.Push(comboKey) + regErrors.Push(comboKey) } ; Register modifier restore hotkey only once per HotIf scope @@ -568,41 +556,11 @@ RegisterPathB(mapping, hkInfo, uniqueIdx, checker, configName) { ActiveHotkeys.Push(modHkInfo) InterceptModKeys[modRegKey] := true } catch as e { - HotkeyRegErrors.Push(modKey) + regErrors.Push(modKey) } } } -; Path C: only build the mapping table; runtime behavior is handled by the Path C engine -RegisterPathCMapping(mapping, uniqueIdx, configName, checker) { - global PathCMappingByModSource, PathCModsUsed, PathCSourceKeysUsed - - modKey := mapping["ModifierKey"] - sourceKey := mapping["SourceKey"] - if (modKey = "" || !mapping["PassthroughMod"]) - return - - key := modKey "|" sourceKey - if !PathCMappingByModSource.Has(key) - PathCMappingByModSource[key] := [] - - entry := { - modKey: modKey, - sourceKey: sourceKey, - targetKey: mapping["TargetKey"], - holdRepeat: mapping["HoldRepeat"], - repeatDelay: mapping["RepeatDelay"], - repeatInterval: mapping["RepeatInterval"], - configName: configName, - id: uniqueIdx, - checker: checker - } - PathCMappingByModSource[key].Push(entry) - - PathCModsUsed[modKey] := true - PathCSourceKeysUsed[sourceKey] := true -} - ; ============================================================================ ; Path A/B callbacks ; ============================================================================ @@ -640,12 +598,7 @@ StopHoldTimer(idx) { } } -RepeatTimerCallback(sendKey, sourceKey, idx, modKey := "", *) { - ; For Path C: ensure modifier is still held - if (modKey != "" && !GetKeyState(modKey, "P")) { - StopHoldTimer(idx) - return - } +RepeatTimerCallback(sendKey, sourceKey, idx, *) { ; Safety check: stop repeating if the source key has been released (non-wheel keys) baseKey := RegExReplace(sourceKey, "^[+!#^]+", "") if (baseKey != "" && !RegExMatch(baseKey, "^Wheel") && !GetKeyState(baseKey, "P")) { @@ -669,272 +622,3 @@ RestoreModKeyCallback(modKey, *) { DispatchSend(KeyToSendFormat(modKey)) } - -; ============================================================================ -; Path C engine (explicit state machine + unified event routing) -; ============================================================================ - -; Register all Path C modifier/source hotkeys after config registration completes -RegisterAllPathCHotkeys() { - global PathCModsUsed, PathCSourceKeysUsed, ActiveHotkeys, HotkeyRegErrors, PathCWheelRoutePredicates - - ; Modifiers: keyboard/mouse keys all use "~modKey" / "~modKey Up" to pass through events - for modKey, _ in PathCModsUsed { - if (modKey = "") - continue - - downHk := "~" modKey - upHk := "~" modKey " Up" - - try { - HotIf() - Hotkey(downHk, PathC_ModDownCallback.Bind(modKey), "On") - Hotkey(upHk, PathC_ModUpCallback.Bind(modKey), "On") - } catch as e { - HotkeyRegErrors.Push(downHk) - continue - } - - modHkInfo := MakeActiveHotkeyRecord("", "", downHk, upHk) - ActiveHotkeys.Push(modHkInfo) - } - - ; Source keys: listen centrally and let Path C decide what to trigger - for sourceKey, _ in PathCSourceKeysUsed { - if (sourceKey = "") - continue - - sourceHotkey := SubStr(sourceKey, 1, 1) = "*" ? sourceKey : "*" sourceKey - - ; KeyDown - hkInfo := MakeActiveHotkeyRecord("", "", sourceHotkey) - - if (IsWheelSourceKey(sourceKey)) { - wheelRoutePredicate := PathC_ShouldRouteWheelSource.Bind(sourceKey) - try { - HotIf(wheelRoutePredicate) - Hotkey(sourceHotkey, PathC_SourceDownCallback.Bind(sourceKey), "On") - hkInfo.checker := wheelRoutePredicate - PathCWheelRoutePredicates.Push(wheelRoutePredicate) - } catch as e { - HotkeyRegErrors.Push(sourceHotkey) - } - } else { - try { - HotIf() - Hotkey(sourceHotkey, PathC_SourceDownCallback.Bind(sourceKey), "On") - } catch as e { - HotkeyRegErrors.Push(sourceHotkey) - } - } - - ; KeyUp: only for source keys that support Up hotkeys - if (SupportsKeyUpHotkey(sourceHotkey)) { - srcUpHotkey := sourceHotkey " Up" - try { - HotIf() - Hotkey(srcUpHotkey, PathC_SourceUpCallback.Bind(sourceKey), "On") - hkInfo.keyUp := srcUpHotkey - } catch as e { - HotkeyRegErrors.Push(srcUpHotkey) - } - } - ActiveHotkeys.Push(hkInfo) - } - - HotIf() -} - -; Get or initialize the session state for a modifier key -PathC_GetSession(modKey) { - global PathCModSessions - if !PathCModSessions.Has(modKey) { - PathCModSessions[modKey] := { - state: "Idle", - isGesture: false, - activeSources: Map(), - repeatMappings: Map() - } - } - return PathCModSessions[modKey] -} - -; End a modifier session: stop all repeats and reset state -PathC_EndSession(modKey) { - global PathCModSessions, HoldTimers - if !PathCModSessions.Has(modKey) - return - - session := PathCModSessions[modKey] - - ; Stop all repeat timers associated with this modifier - for mappingId, _ in session.repeatMappings { - StopHoldTimer(mappingId) - } - - session.repeatMappings := Map() - session.activeSources := Map() - session.state := "Idle" - session.isGesture := false -} - -; Whether a mapping is active in the current foreground window (using checker closure) -PathC_IsMappingActive(mapping) { - if (mapping.HasOwnProp("checker") && mapping.checker != "") { - try - return mapping.checker.Call() - catch - return false - } - return true -} - -; Whether a Path C wheel source should be routed by the unified engine -PathC_ShouldRouteWheelSource(sourceKey, *) { - global PathCMappingByModSource, PathCModSessions - - if !IsWheelSourceKey(sourceKey) - return false - - for modKey, session in PathCModSessions { - if (session.state = "Idle") - continue - - key := modKey "|" sourceKey - if !PathCMappingByModSource.Has(key) - continue - - mappings := PathCMappingByModSource[key] - for _, mapping in mappings { - if PathC_IsMappingActive(mapping) - return true - } - } - - return false -} - -; Start Path C long-press repeat for a mapping -PathC_StartRepeat(mapping, modKey, sourceKey) { - global HoldTimers - - idx := mapping.id - sendKey := KeyToSendFormat(mapping.targetKey) - - ; Defensive cleanup: stop any existing timer to avoid orphan timers on re-entry - StopHoldTimer(idx) - - DispatchSend(sendKey) - - timerFn := RepeatTimerCallback.Bind(sendKey, sourceKey, idx, modKey) - startFn := StartRepeat.Bind(idx, timerFn, mapping.repeatInterval) - HoldTimers[idx] := { fn: timerFn, startFn: startFn, interval: mapping.repeatInterval, active: true } - SetTimer(startFn, -mapping.repeatDelay) -} - -; Path C modifier-key down callback (shared entry point) -PathC_ModDownCallback(modKey, *) { - session := PathC_GetSession(modKey) - - ; Force-end any unfinished session before starting a new one - if (session.state != "Idle") - PathC_EndSession(modKey) - - session := PathC_GetSession(modKey) - session.state := "HeldNoCombo" - session.isGesture := false - session.activeSources := Map() - session.repeatMappings := Map() -} - -; Path C modifier-key up callback (shared entry point) -PathC_ModUpCallback(modKey, *) { - session := PathC_GetSession(modKey) - if (session.state = "Idle") { - return - } - - isGesture := session.isGesture - - ; For RButton, only dismiss a possible context menu if this session actually triggered a Path C gesture. - ; Sending Escape keeps browser-style right-button gestures usable. - if (modKey = "RButton" && isGesture) { - SetTimer(PathC_DismissContextMenu, -CONTEXT_MENU_DISMISS_DELAY) - } - - PathC_EndSession(modKey) -} - -PathC_DismissContextMenu(*) { - DispatchSend("{Escape}") -} - -; Path C source-key down callback (shared entry point) -PathC_SourceDownCallback(sourceKey, *) { - global PathCMappingByModSource, PathCModSessions - - handled := false - - ; Iterate all currently active modifier sessions - for modKey, session in PathCModSessions { - if (session.state = "Idle") - continue - - key := modKey "|" sourceKey - if !PathCMappingByModSource.Has(key) - continue - - mappings := PathCMappingByModSource[key] - - for _, mapping in mappings { - if !PathC_IsMappingActive(mapping) - continue - - ; Mark this session as a gesture session - session.state := "GestureActive" - session.isGesture := true - - if (mapping.holdRepeat) { - PathC_StartRepeat(mapping, modKey, sourceKey) - session.repeatMappings[mapping.id] := true - - if !session.activeSources.Has(sourceKey) - session.activeSources[sourceKey] := [] - session.activeSources[sourceKey].Push(mapping.id) - } else { - DispatchSend(KeyToSendFormat(mapping.targetKey)) - } - - handled := true - break - } - - if (handled) - break - } - - if (!handled) { - ; No Path C mapping matched, fall back to the raw source key - DispatchSend(KeyToSendFormat(sourceKey)) - } -} - -; Path C source-key up callback (shared entry point, only for keys that support Up) -PathC_SourceUpCallback(sourceKey, *) { - global PathCModSessions - - for modKey, session in PathCModSessions { - if (session.state = "Idle") - continue - if !session.activeSources.Has(sourceKey) - continue - - ids := session.activeSources[sourceKey] - for _, mappingId in ids { - StopHoldTimer(mappingId) - if (session.repeatMappings.Has(mappingId)) - session.repeatMappings.Delete(mappingId) - } - session.activeSources.Delete(sourceKey) - } -} diff --git a/src/core/KeyCapture.ahk b/src/core/KeyCapture.ahk index 4b75d80..f0ca3ca 100644 --- a/src/core/KeyCapture.ahk +++ b/src/core/KeyCapture.ahk @@ -6,6 +6,7 @@ ; Declare globals shared across modules global IsCapturing global CaptureTarget +global CaptureOnCaptured global CaptureGui global CaptureDisplayText global CaptureTimer @@ -15,12 +16,6 @@ global CaptureMouseKeys global CAPTURE_START_DELAY global CAPTURE_POLL_INTERVAL -; Editor dialog control references (injected from MappingEditor module) -global EditModifierEdit -global EditSourceEdit -global EditTargetEdit -global EditPassthroughCB - ; ============================================================================ ; Key capture (confirm on release flow) ; ============================================================================ @@ -112,23 +107,13 @@ ModifierToDisplayName(keyName) { return keyName } -OnCaptureModifier(*) { - global CaptureTarget := "modifier" - StartCapture() -} - -OnCaptureSource(*) { - global CaptureTarget := "source" - StartCapture() -} - -OnCaptureTarget(*) { - global CaptureTarget := "target" - StartCapture() -} - -StartCapture() { +; Start a capture session for the given target ("modifier"/"source"/"target"). +; On completion, the session invokes onCaptured(ahkKey, displayKey) exactly once; +; the callback is held only for the live session, never registered persistently. +StartCapture(target, onCaptured) { global IsCapturing := false ; start disabled, enable after delay + global CaptureTarget := target + global CaptureOnCaptured := onCaptured global CaptureKeys := [] global CaptureHadKeys := false global CaptureMouseKeys := Map() @@ -270,52 +255,58 @@ CapturePolling() { } } -; ---- Finalize capture: build ahkKey and apply ---- +; ---- Finalize capture: build ahkKey and deliver via the per-call callback ---- FinishCapture() { global IsCapturing := false SetTimer(StartCaptureDelayed, 0) RemoveAllCaptureHooks() + ; Take the callback out of the slot first so it fires exactly once + onCaptured := CaptureOnCaptured + global CaptureOnCaptured := "" + if (CaptureKeys.Length = 0) { try CaptureGui.Destroy() global CaptureGui := "" return } + ahkKey := BuildAhkKey(CaptureKeys, CaptureTarget) + displayKey := KeyToDisplay(ahkKey) + if (onCaptured != "") + onCaptured(ahkKey, displayKey) + + try CaptureGui.Destroy() + global CaptureGui := "" +} + +; Fold the captured key list into the final AHK key string for a capture mode. +; captureKeys holds modifier prefixes ("^", "+", "!", "#") and key names; +; targetMode is "modifier", "source", or "target". +BuildAhkKey(captureKeys, targetMode) { ; Split modifier prefixes and non-modifier keys modifiers := "" mainKeys := [] - for _, k in CaptureKeys { + for _, k in captureKeys { if (k = "^" || k = "+" || k = "!" || k = "#") modifiers .= k else mainKeys.Push(k) } - if (CaptureTarget = "modifier") { + if (targetMode = "modifier") { ; Modifier capture mode: take first non-modifier key, or the modifier itself - if (mainKeys.Length > 0) { - ahkKey := mainKeys[1] - } else { - ; Only modifiers were pressed, restore back to a key name - ahkKey := ModifierPrefixToKeyName(modifiers) - } - displayKey := KeyToDisplay(ahkKey) - ApplyCapturedKey(ahkKey, displayKey) - } else { - ; Source / target capture mode - if (mainKeys.Length > 0) { - ahkKey := modifiers . mainKeys[1] - } else { - ; Only modifiers were pressed - ahkKey := ModifierPrefixToKeyName(modifiers) - } - displayKey := KeyToDisplay(ahkKey) - ApplyCapturedKey(ahkKey, displayKey) + if (mainKeys.Length > 0) + return mainKeys[1] + ; Only modifiers were pressed, restore back to a key name + return ModifierPrefixToKeyName(modifiers) } - try CaptureGui.Destroy() - global CaptureGui := "" + ; Source / target capture mode + if (mainKeys.Length > 0) + return modifiers . mainKeys[1] + ; Only modifiers were pressed + return ModifierPrefixToKeyName(modifiers) } ; Restore modifier prefixes back to a key name (used when only modifiers were pressed) @@ -335,6 +326,7 @@ ModifierPrefixToKeyName(prefixes) { ; ---- Cancel capture ---- CancelCapture() { global IsCapturing := false + global CaptureOnCaptured := "" SetTimer(StartCaptureDelayed, 0) ; cancel any pending delayed timer RemoveAllCaptureHooks() try CaptureGui.Destroy() @@ -454,26 +446,3 @@ GetCurrentModifiers() { modifiers .= "#" return modifiers } - -ApplyCapturedKey(ahkKey, displayKey) { - if (CaptureTarget = "modifier") { - EditModifierEdit.Value := displayKey - EditModifierEdit.ahkKey := ahkKey - UpdatePassthroughState() - } else if (CaptureTarget = "source") { - EditSourceEdit.Value := displayKey - EditSourceEdit.ahkKey := ahkKey - } else if (CaptureTarget = "target") { - EditTargetEdit.Value := displayKey - EditTargetEdit.ahkKey := ahkKey - } -} - -; This function must be called from the MappingEditor module -UpdatePassthroughState() { - ; "Keep modifier behavior" option is only meaningful when a modifier is set - hasModifier := EditModifierEdit.ahkKey != "" - EditPassthroughCB.Enabled := hasModifier - if !hasModifier - EditPassthroughCB.Value := 0 -} diff --git a/src/core/Localization.ahk b/src/core/Localization.ahk index bbc8c08..32c05a1 100644 --- a/src/core/Localization.ahk +++ b/src/core/Localization.ahk @@ -117,6 +117,8 @@ BuildEnPack() { pack["Config.SaveError.WriteTemp"] := "Failed to save config: {1}`nFile: {2}" pack["Config.SaveError.Replace"] := "Failed to save config (replace stage): {1}`nFile: {2}" pack["Config.SaveEnabledStatesError"] := "Failed to save enabled states: {1}" + pack["Config.CreateError"] := "Failed to create config: {1}`nFile: {2}" + pack["Config.CopyError"] := "Failed to copy config: {1}`nFile: {2}" pack["Config.Mapping.HoldYes"] := "Yes" pack["Config.Mapping.HoldNo"] := "No" @@ -255,6 +257,8 @@ BuildZhPack() { pack["Config.SaveError.WriteTemp"] := "保存配置失败:{1}`n文件:{2}" pack["Config.SaveError.Replace"] := "保存配置失败(替换阶段):{1}`n文件:{2}" pack["Config.SaveEnabledStatesError"] := "保存启用状态失败:{1}" + pack["Config.CreateError"] := "创建配置失败:{1}`n文件:{2}" + pack["Config.CopyError"] := "复制配置失败:{1}`n文件:{2}" pack["Config.Mapping.HoldYes"] := "是" pack["Config.Mapping.HoldNo"] := "否" diff --git a/src/core/PathCEngine.ahk b/src/core/PathCEngine.ahk new file mode 100644 index 0000000..2e75de7 --- /dev/null +++ b/src/core/PathCEngine.ahk @@ -0,0 +1,458 @@ +; ============================================================================ +; AHKeyMap - Path C engine module +; Owns the passthrough-combo state machine, unified event routing, repeat +; timers, wheel routing, and the hotkey registration for Path C mappings. +; ============================================================================ + +; Globals shared across modules +global CONTEXT_MENU_DISMISS_DELAY + +; For Path C, only register Up hotkeys on source keys that support key-up +SupportsKeyUpHotkey(hotkeyName) { + return !IsWheelSourceKey(hotkeyName) +} + +IsWheelSourceKey(sourceKey) { + baseKey := RegExReplace(sourceKey, "^[~*$+!#^]+", "") + return RegExMatch(baseKey, "^Wheel") +} + +; ============================================================================ +; Path C session +; ============================================================================ + +; One session per modifier key press cycle; the constructor enforces the shape +class PathCSession { + __New() { + this.state := PathCEngine.STATE_IDLE + this.isGesture := false + this.activeSources := Map() + this.repeatMappings := Map() + } + + ; End-of-session cleanup: reset every field back to a fresh Idle session + Reset() { + this.state := PathCEngine.STATE_IDLE + this.isGesture := false + this.activeSources := Map() + this.repeatMappings := Map() + } +} + +; ============================================================================ +; Path C engine +; ============================================================================ + +; Deep module for Path C (passthrough modifier combos): +; AddMapping(...) during the config loop, Commit() after it (only Commit +; touches Hotkey()), Reset() to disable everything, and the On*/ShouldRoute* +; callbacks drive the per-modifier session state machine. +; Production code uses the lazy singleton `PathCEngine.Instance`; tests may +; construct isolated `PathCEngine()` instances instead. +class PathCEngine { + ; Session state names (replace scattered string literals) + static STATE_IDLE := "Idle" + static STATE_HELD_NO_COMBO := "HeldNoCombo" + static STATE_GESTURE_ACTIVE := "GestureActive" + + static _instance := "" + + static Instance { + get { + if (PathCEngine._instance = "") + PathCEngine._instance := PathCEngine() + return PathCEngine._instance + } + } + + __New() { + ; "modKey|sourceKey" -> array of mapping entries + this.mappingByModSource := Map() + ; modKey / sourceKey -> true (which routing hotkeys to register) + this.modsUsed := Map() + this.sourceKeysUsed := Map() + ; modKey -> PathCSession (current press cycle state) + this.sessions := Map() + ; Registration records ({checker, key, keyUp}) owned by this engine + this.registrations := [] + ; mapping.id -> {fn, startFn, interval, active} repeat timers + this.repeatTimers := Map() + } + + ; ------------------------------------------------------------------------ + ; Registration (two-phase: AddMapping during config loop, Commit after it) + ; ------------------------------------------------------------------------ + + ; Record one Path C mapping for unified routing (was RegisterPathCMapping) + ; (local name avoids shadowing the Mapping class; AHK names are case-insensitive) + AddMapping(m, id, configName, checker) { + if (Mapping.ClassifyPath(m) != Mapping.PATH_C) + return + + modKey := m["ModifierKey"] + sourceKey := m["SourceKey"] + + key := modKey "|" sourceKey + if !this.mappingByModSource.Has(key) + this.mappingByModSource[key] := [] + + ; The internal entry object is derived from the Map record in this + ; one place (the engine never reads the raw record shape elsewhere) + entry := { + modKey: modKey, + sourceKey: sourceKey, + targetKey: m["TargetKey"], + holdRepeat: m["HoldRepeat"], + repeatDelay: m["RepeatDelay"], + repeatInterval: m["RepeatInterval"], + configName: configName, + id: id, + checker: checker + } + this.mappingByModSource[key].Push(entry) + + this.modsUsed[modKey] := true + this.sourceKeysUsed[sourceKey] := true + } + + ; Register all Path C modifier/source routing hotkeys (was RegisterAllPathCHotkeys) + ; This is the only place where the engine touches Hotkey() + ; Returns the array of hotkey names that failed registration + Commit() { + regErrors := [] + ; Modifiers: keyboard/mouse keys all use "~modKey" / "~modKey Up" to pass through events + for modKey, _ in this.modsUsed { + if (modKey = "") + continue + + downHk := "~" modKey + upHk := "~" modKey " Up" + + try { + HotIf() + Hotkey(downHk, ObjBindMethod(this, "OnModDown", modKey), "On") + Hotkey(upHk, ObjBindMethod(this, "OnModUp", modKey), "On") + } catch as e { + regErrors.Push(downHk) + continue + } + + this.registrations.Push({ checker: "", key: downHk, keyUp: upHk }) + } + + ; Source keys: listen centrally and let Path C decide what to trigger + for sourceKey, _ in this.sourceKeysUsed { + if (sourceKey = "") + continue + + sourceHotkey := SubStr(sourceKey, 1, 1) = "*" ? sourceKey : "*" sourceKey + record := { checker: "", key: sourceHotkey, keyUp: "" } + + ; KeyDown + if (IsWheelSourceKey(sourceKey)) { + ; Wheel sources only route while a Path C session could match, + ; so native semantics like browser Ctrl+Wheel stay intact + wheelRoutePredicate := ObjBindMethod(this, "ShouldRouteWheel", sourceKey) + try { + HotIf(wheelRoutePredicate) + Hotkey(sourceHotkey, ObjBindMethod(this, "OnSourceDown", sourceKey), "On") + record.checker := wheelRoutePredicate + } catch as e { + regErrors.Push(sourceHotkey) + } + } else { + try { + HotIf() + Hotkey(sourceHotkey, ObjBindMethod(this, "OnSourceDown", sourceKey), "On") + } catch as e { + regErrors.Push(sourceHotkey) + } + } + + ; KeyUp: only for source keys that support Up hotkeys + if (SupportsKeyUpHotkey(sourceHotkey)) { + srcUpHotkey := sourceHotkey " Up" + try { + HotIf() + Hotkey(srcUpHotkey, ObjBindMethod(this, "OnSourceUp", sourceKey), "On") + record.keyUp := srcUpHotkey + } catch as e { + regErrors.Push(srcUpHotkey) + } + } + this.registrations.Push(record) + } + + HotIf() + return regErrors + } + + ; Disable all engine-owned hotkeys, stop repeats, and clear all state + Reset() { + ; Stop all repeat timers owned by this engine + timerIds := [] + for mappingId, _ in this.repeatTimers + timerIds.Push(mappingId) + for _, mappingId in timerIds + this.StopMappingRepeat(mappingId) + + ; End all modifier sessions + modKeys := [] + for modKey, _ in this.sessions + modKeys.Push(modKey) + for _, modKey in modKeys + this.EndSession(modKey) + + ; Disable each hotkey from the engine's own records, ignoring cleanup errors + for _, info in this.registrations { + try { + if (info.checker != "") + HotIf(info.checker) + else + HotIf() + + if (info.key != "") + Hotkey(info.key, "Off") + if (info.keyUp != "") + Hotkey(info.keyUp, "Off") + } catch { + continue + } + } + HotIf() + + ; Clear registration and mapping state in place + this.registrations.Length := 0 + ClearEngineMap(this.mappingByModSource) + ClearEngineMap(this.modsUsed) + ClearEngineMap(this.sourceKeysUsed) + } + + ; ------------------------------------------------------------------------ + ; Session state machine (bound as routing hotkey callbacks) + ; ------------------------------------------------------------------------ + + ; Modifier-key down (shared entry point) + OnModDown(modKey, *) { + ; Force-end any unfinished session before starting a new one + if (this.sessions.Has(modKey) && this.sessions[modKey].state != PathCEngine.STATE_IDLE) + this.EndSession(modKey) + + session := this.GetSession(modKey) + session.Reset() + session.state := PathCEngine.STATE_HELD_NO_COMBO + } + + ; Modifier-key up (shared entry point) + OnModUp(modKey, *) { + session := this.GetSession(modKey) + if (session.state = PathCEngine.STATE_IDLE) { + return + } + + isGesture := session.isGesture + + ; For RButton, only dismiss a possible context menu if this session actually triggered a Path C gesture. + ; Sending Escape keeps browser-style right-button gestures usable. + if (modKey = "RButton" && isGesture) { + SetTimer(ObjBindMethod(this, "DismissContextMenu"), -CONTEXT_MENU_DISMISS_DELAY) + } + + this.EndSession(modKey) + } + + DismissContextMenu(*) { + DispatchSend("{Escape}") + } + + ; Source-key down (shared entry point) + OnSourceDown(sourceKey, *) { + handled := false + + ; Iterate all currently active modifier sessions + for modKey, session in this.sessions { + if (session.state = PathCEngine.STATE_IDLE) + continue + + key := modKey "|" sourceKey + if !this.mappingByModSource.Has(key) + continue + + mappings := this.mappingByModSource[key] + ; (local name avoids shadowing the Mapping class; AHK names are case-insensitive) + for _, m in mappings { + if !this.IsMappingActive(m) + continue + + ; Mark this session as a gesture session + session.state := PathCEngine.STATE_GESTURE_ACTIVE + session.isGesture := true + + if (m.holdRepeat) { + this.StartMappingRepeat(m, modKey, sourceKey) + session.repeatMappings[m.id] := true + + if !session.activeSources.Has(sourceKey) + session.activeSources[sourceKey] := [] + session.activeSources[sourceKey].Push(m.id) + } else { + DispatchSend(KeyToSendFormat(m.targetKey)) + } + + handled := true + break + } + + if (handled) + break + } + + if (!handled) { + ; No Path C mapping matched, fall back to the raw source key + DispatchSend(KeyToSendFormat(sourceKey)) + } + } + + ; Source-key up (shared entry point, only for keys that support Up) + OnSourceUp(sourceKey, *) { + for modKey, session in this.sessions { + if (session.state = PathCEngine.STATE_IDLE) + continue + if !session.activeSources.Has(sourceKey) + continue + + ids := session.activeSources[sourceKey] + for _, mappingId in ids { + this.StopMappingRepeat(mappingId) + if (session.repeatMappings.Has(mappingId)) + session.repeatMappings.Delete(mappingId) + } + session.activeSources.Delete(sourceKey) + } + } + + ; Whether a Path C wheel source should be routed by this engine + ; (bound as the HotIf predicate for wheel source hotkeys) + ShouldRouteWheel(sourceKey, *) { + if !IsWheelSourceKey(sourceKey) + return false + + for modKey, session in this.sessions { + if (session.state = PathCEngine.STATE_IDLE) + continue + + key := modKey "|" sourceKey + if !this.mappingByModSource.Has(key) + continue + + mappings := this.mappingByModSource[key] + for _, mapping in mappings { + if this.IsMappingActive(mapping) + return true + } + } + + return false + } + + ; Read-only session state snapshot: "Idle" | "HeldNoCombo" | "GestureActive" + GetSessionState(modKey) { + if !this.sessions.Has(modKey) + return PathCEngine.STATE_IDLE + return this.sessions[modKey].state + } + + ; ------------------------------------------------------------------------ + ; Internals + ; ------------------------------------------------------------------------ + + ; Get or initialize the session state for a modifier key + GetSession(modKey) { + if !this.sessions.Has(modKey) + this.sessions[modKey] := PathCSession() + return this.sessions[modKey] + } + + ; End a modifier session: stop all repeats and reset state + EndSession(modKey) { + if !this.sessions.Has(modKey) + return + + session := this.sessions[modKey] + + ; Stop all repeat timers associated with this modifier + for mappingId, _ in session.repeatMappings { + this.StopMappingRepeat(mappingId) + } + + session.Reset() + } + + ; Whether a mapping is active in the current foreground window (using checker closure) + IsMappingActive(mapping) { + if (mapping.HasOwnProp("checker") && mapping.checker != "") { + try + return mapping.checker.Call() + catch + return false + } + return true + } + + ; Start Path C long-press repeat for a mapping + StartMappingRepeat(mapping, modKey, sourceKey) { + idx := mapping.id + sendKey := KeyToSendFormat(mapping.targetKey) + + ; Defensive cleanup: stop any existing timer to avoid orphan timers on re-entry + this.StopMappingRepeat(idx) + + DispatchSend(sendKey) + + timerFn := ObjBindMethod(this, "OnRepeatTick", sendKey, sourceKey, idx, modKey) + startFn := ObjBindMethod(this, "OnRepeatStart", idx, timerFn, mapping.repeatInterval) + this.repeatTimers[idx] := { fn: timerFn, startFn: startFn, interval: mapping.repeatInterval, active: true } + SetTimer(startFn, -mapping.repeatDelay) + } + + OnRepeatStart(idx, timerFn, interval, *) { + if (this.repeatTimers.Has(idx) && this.repeatTimers[idx].active) + SetTimer(timerFn, interval) + } + + OnRepeatTick(sendKey, sourceKey, idx, modKey, *) { + ; Ensure the modifier is still held + if (modKey != "" && !GetKeyState(modKey, "P")) { + this.StopMappingRepeat(idx) + return + } + ; Safety check: stop repeating if the source key has been released (non-wheel keys) + baseKey := RegExReplace(sourceKey, "^[+!#^]+", "") + if (baseKey != "" && !RegExMatch(baseKey, "^Wheel") && !GetKeyState(baseKey, "P")) { + this.StopMappingRepeat(idx) + return + } + DispatchSend(sendKey) + } + + StopMappingRepeat(idx) { + if this.repeatTimers.Has(idx) { + entry := this.repeatTimers[idx] + if (entry.HasProp("fn")) + SetTimer(entry.fn, 0) + if (entry.HasProp("startFn")) + SetTimer(entry.startFn, 0) + entry.active := false + this.repeatTimers.Delete(idx) + } + } +} + +; Remove all keys from an engine-owned map in place +ClearEngineMap(mapObj) { + keys := [] + for key, _ in mapObj + keys.Push(key) + for _, key in keys + mapObj.Delete(key) +} diff --git a/src/shared/Schema.ahk b/src/shared/Schema.ahk new file mode 100644 index 0000000..91d7b85 --- /dev/null +++ b/src/shared/Schema.ahk @@ -0,0 +1,154 @@ +; ============================================================================ +; AHKeyMap - Record schema module +; Static-only namespaces owning what a mapping/config record is: construction, +; normalization (whitelist + coercion + defaults + clamp), path classification, +; hotkey-string derivation, and the INI serialization field list. +; Records stay plain Map()s; the schema lives in these constructors, not in a +; new storage type. +; ============================================================================ + +; Globals shared across modules (referenced at call time, never re-initialized) +global DEFAULT_REPEAT_DELAY +global DEFAULT_REPEAT_INTERVAL +global CONFIG_DIR + +; ============================================================================ +; Mapping schema +; ============================================================================ + +; Static namespace for one mapping record: +; ModifierKey / SourceKey / TargetKey - AHK key names ("" = none) +; HoldRepeat / PassthroughMod - 0/1 flags +; RepeatDelay / RepeatInterval - long-press timing in ms (>= 10) +class Mapping { + ; Path constants: A = plain hotkey, B = intercept combo, C = passthrough combo + static PATH_A := "A" + static PATH_B := "B" + static PATH_C := "C" + + ; Minimum supported long-press timing (ms); smaller values are clamped + static MIN_REPEAT_TIMING := 10 + + ; Construct one normalized mapping record (the single construction entry) + static Make(modKey, sourceKey, targetKey, holdRepeat := 0, repeatDelay := "", repeatInterval := "", passthroughMod := 0) { + m := Map() + m["ModifierKey"] := modKey + m["SourceKey"] := sourceKey + m["TargetKey"] := targetKey + m["HoldRepeat"] := holdRepeat + m["RepeatDelay"] := repeatDelay + m["RepeatInterval"] := repeatInterval + m["PassthroughMod"] := passthroughMod + Mapping.Normalize(m) + return m + } + + ; Enforce the record invariants in place: + ; - whitelist: keep only the seven schema fields (extras are dropped) + ; - Integer() coercion of the numeric fields (fallback to defaults) + ; - defaults from DEFAULT_REPEAT_DELAY / DEFAULT_REPEAT_INTERVAL + ; - RepeatDelay / RepeatInterval clamped to >= MIN_REPEAT_TIMING + static Normalize(m) { + defaults := Map( + "ModifierKey", "", + "SourceKey", "", + "TargetKey", "", + "HoldRepeat", 0, + "RepeatDelay", DEFAULT_REPEAT_DELAY, + "RepeatInterval", DEFAULT_REPEAT_INTERVAL, + "PassthroughMod", 0 + ) + + ; Whitelist: drop any key outside the seven-field schema + extraKeys := [] + for keyName, _ in m { + if !defaults.Has(keyName) + extraKeys.Push(keyName) + } + for _, keyName in extraKeys + m.Delete(keyName) + + ; Fill defaults for missing fields + for keyName, defaultValue in defaults { + if !m.Has(keyName) + m[keyName] := defaultValue + } + + ; Integer coercion for the numeric fields (unparseable values fall back) + m["HoldRepeat"] := Mapping.ToIntOr(m["HoldRepeat"], 0) + m["RepeatDelay"] := Mapping.ToIntOr(m["RepeatDelay"], DEFAULT_REPEAT_DELAY) + m["RepeatInterval"] := Mapping.ToIntOr(m["RepeatInterval"], DEFAULT_REPEAT_INTERVAL) + m["PassthroughMod"] := Mapping.ToIntOr(m["PassthroughMod"], 0) + + ; Clamp repeat timing to the minimum supported value + if (m["RepeatDelay"] < Mapping.MIN_REPEAT_TIMING) + m["RepeatDelay"] := Mapping.MIN_REPEAT_TIMING + if (m["RepeatInterval"] < Mapping.MIN_REPEAT_TIMING) + m["RepeatInterval"] := Mapping.MIN_REPEAT_TIMING + } + + ; The single path rule: A (no modifier), B (intercept combo), C (passthrough combo) + static ClassifyPath(m) { + if (m["ModifierKey"] = "") + return Mapping.PATH_A + if (!m["PassthroughMod"]) + return Mapping.PATH_B + return Mapping.PATH_C + } + + ; The single hotkey-string derivation, used by registration and conflict + ; detection so the two can never drift apart + static HotkeyStringFor(m) { + path := Mapping.ClassifyPath(m) + if (path = Mapping.PATH_A) + return m["SourceKey"] + if (path = Mapping.PATH_B) + return m["ModifierKey"] " & " m["SourceKey"] + return "~" m["ModifierKey"] "+" m["SourceKey"] + } + + ; The single serialization field list (INI key -> string value, in write order) + static ToIniPairs(m) { + pairs := Map() + pairs["ModifierKey"] := String(m["ModifierKey"]) + pairs["SourceKey"] := String(m["SourceKey"]) + pairs["TargetKey"] := String(m["TargetKey"]) + pairs["HoldRepeat"] := String(m["HoldRepeat"]) + pairs["RepeatDelay"] := String(m["RepeatDelay"]) + pairs["RepeatInterval"] := String(m["RepeatInterval"]) + pairs["PassthroughMod"] := String(m["PassthroughMod"]) + return pairs + } + + ; Coerce a value to Integer, falling back when empty or unparseable + static ToIntOr(value, fallback) { + if (value = "") + return fallback + try + return Integer(value) + catch + return fallback + } +} + +; ============================================================================ +; Config record schema +; ============================================================================ + +; Static namespace for one config record: owns the record shape, the +; ParseProcessList derivation, and the config file path. +class ConfigRecord { + static Make(name, processMode, process, excludeProcess, enabled, mappings) { + cfg := Map() + cfg["name"] := name + cfg["file"] := CONFIG_DIR "\" name ".ini" + cfg["processMode"] := processMode + cfg["process"] := process + cfg["processList"] := ParseProcessList(process) + cfg["excludeProcess"] := excludeProcess + cfg["excludeProcessList"] := ParseProcessList(excludeProcess) + cfg["enabled"] := enabled + cfg["mappings"] := mappings + return cfg + } +} diff --git a/src/ui/GuiEvents.ahk b/src/ui/GuiEvents.ahk index 7f4d921..e7d7bdd 100644 --- a/src/ui/GuiEvents.ahk +++ b/src/ui/GuiEvents.ahk @@ -7,16 +7,6 @@ global APP_NAME global CONFIG_DIR global STATE_FILE -global AllConfigs -global CurrentConfigName -global CurrentConfigFile -global CurrentProcessMode -global CurrentProcess -global CurrentProcessList -global CurrentExcludeProcess -global CurrentExcludeProcessList -global CurrentConfigEnabled -global Mappings global ConfigDDL global MappingLV global EditingIndex @@ -27,22 +17,18 @@ global EditingIndex OnConfigSelect(ctrl, *) { selected := ctrl.Text - if (selected != "" && selected != CurrentConfigName) { - LoadConfigToGui(selected) - } else if (selected != "" && CurrentConfigName = "") { - LoadConfigToGui(selected) - } + if (selected = "") + return + if (selected = ConfigStore.Instance.SelectedName) + return + ConfigStore.Instance.Select(selected) } ; Enable/disable the current config via checkbox OnToggleEnabled(ctrl, *) { - if (CurrentConfigName = "") + if (ConfigStore.Instance.SelectedName = "") return - global CurrentConfigEnabled := ctrl.Value ? true : false - SyncCurrentToAllConfigs() - SaveEnabledStates() - ReloadConfigHotkeys(CurrentConfigName) - UpdateStatusText() + ConfigStore.Instance.SetEnabled(ctrl.Value ? true : false) } OnNewConfig(*) { @@ -89,43 +75,21 @@ OnNewConfigOK(newGui, *) { return } - configFile := CONFIG_DIR "\" configName ".ini" - if FileExist(configFile) { + if FileExist(CONFIG_DIR "\" configName ".ini") { MsgBox(Format(L("GuiEvents.Error.ConfigExists"), configName), APP_NAME, "Icon!") return } - ; Determine process mode and process list processMode := GetSelectedScopeMode(newGui) procStr := ProcTextToStr(newGui["ProcName"].Value) - IniWrite(configName, configFile, "Meta", "Name") - IniWrite(processMode, configFile, "Meta", "ProcessMode") - if (processMode = "include") { - IniWrite(procStr, configFile, "Meta", "Process") - IniWrite("", configFile, "Meta", "ExcludeProcess") - } else if (processMode = "exclude") { - IniWrite("", configFile, "Meta", "Process") - IniWrite(procStr, configFile, "Meta", "ExcludeProcess") - } else { - IniWrite("", configFile, "Meta", "Process") - IniWrite("", configFile, "Meta", "ExcludeProcess") - } - - ; Enable new config by default - IniWrite("1", STATE_FILE, "EnabledConfigs", configName) - DestroyModalGui(newGui) - - ; Reload all configs - LoadAllConfigs() - RefreshConfigList(configName) - ReloadAllHotkeys() + ConfigStore.Instance.CreateConfig(configName, processMode, procStr) } ; Copy config OnCopyConfig(*) { - if (CurrentConfigName = "") { + if (ConfigStore.Instance.SelectedName = "") { MsgBox(L("GuiEvents.Error.NoConfigSelected"), APP_NAME, "Icon!") return } @@ -134,7 +98,7 @@ OnCopyConfig(*) { copyGui.SetFont("s9", "Microsoft YaHei UI") copyGui.AddText("x10 y10 w80 h23 +0x200", L("GuiEvents.CopyConfig.NewNameLabel")) - defaultName := CurrentConfigName "_copy" + defaultName := ConfigStore.Instance.SelectedName "_copy" nameEdit := copyGui.AddEdit("x90 y10 w250 h23 vNewName", defaultName) copyGui.AddButton("x110 y48 w80 h28", L("GuiEvents.Common.OkButton")).OnEvent("Click", OnCopyConfigOK.Bind(copyGui)) @@ -156,47 +120,36 @@ OnCopyConfigOK(copyGui, *) { return } - newFile := CONFIG_DIR "\" newName ".ini" - if FileExist(newFile) { + if FileExist(CONFIG_DIR "\" newName ".ini") { MsgBox(Format(L("GuiEvents.Error.ConfigExists"), newName), APP_NAME, "Icon!") return } - ; Copy current config file - if FileExist(CurrentConfigFile) - FileCopy(CurrentConfigFile, newFile) - - ; Update Name field inside copied config - IniWrite(newName, newFile, "Meta", "Name") - - ; Enable new config by default - IniWrite("1", STATE_FILE, "EnabledConfigs", newName) - DestroyModalGui(copyGui) - - ; Reload all configs - LoadAllConfigs() - RefreshConfigList(newName) - ReloadAllHotkeys() + ConfigStore.Instance.CopyConfig(newName) } OnDeleteConfig(*) { - if (CurrentConfigName = "") { + if (ConfigStore.Instance.SelectedName = "") { MsgBox(L("GuiEvents.Error.NoConfigSelected"), APP_NAME, "Icon!") return } - result := MsgBox(Format(L("GuiEvents.Confirm.DeleteConfig"), CurrentConfigName), APP_NAME, "YesNo Icon?") + result := MsgBox(Format(L("GuiEvents.Confirm.DeleteConfig"), ConfigStore.Instance.SelectedName), APP_NAME, "YesNo Icon?") if (result = "Yes") - DeleteCurrentConfigAndRefresh() + ConfigStore.Instance.DeleteConfig() } OnChangeProcess(*) { - if (CurrentConfigName = "") { + if (ConfigStore.Instance.SelectedName = "") { MsgBox(L("GuiEvents.Error.NoConfigSelected"), APP_NAME, "Icon!") return } + cfg := ConfigStore.Instance.Selected() + if (cfg = "") + return + changeGui := CreateModalGui(L("GuiEvents.ChangeScope.Title")) changeGui.SetFont("s9", "Microsoft YaHei UI") @@ -207,9 +160,9 @@ OnChangeProcess(*) { excludeRadio := changeGui.AddRadio("x20 y71 w350 h20 vScopeExcludeRadio", L("GuiEvents.NewConfig.ScopeExclude")) ; Select radio based on current mode - if (CurrentProcessMode = "include") + if (cfg["processMode"] = "include") includeRadio.Value := 1 - else if (CurrentProcessMode = "exclude") + else if (cfg["processMode"] = "exclude") excludeRadio.Value := 1 else globalRadio.Value := 1 @@ -219,17 +172,17 @@ OnChangeProcess(*) { ; Populate process list text based on current mode displayProc := "" - if (CurrentProcessMode = "include") - displayProc := StrReplace(CurrentProcess, "|", "`n") - else if (CurrentProcessMode = "exclude") - displayProc := StrReplace(CurrentExcludeProcess, "|", "`n") + if (cfg["processMode"] = "include") + displayProc := StrReplace(cfg["process"], "|", "`n") + else if (cfg["processMode"] = "exclude") + displayProc := StrReplace(cfg["excludeProcess"], "|", "`n") procEdit := changeGui.AddEdit("x20 y138 w290 h65 vProcName Multi", displayProc) procPickBtn2 := changeGui.AddButton("x315 y138 w55 h25 vProcessPickButton", L("GuiEvents.Common.ProcessPickButton")) procPickBtn2.OnEvent("Click", (*) => ShowProcessPicker(procEdit, true)) ; Disable process editing when in global mode - isGlobal := (CurrentProcessMode = "global") + isGlobal := (cfg["processMode"] = "global") SetScopeEditorEnabled(procEdit, procPickBtn2, !isGlobal) globalRadio.OnEvent("Click", (*) => SetScopeEditorEnabled(procEdit, procPickBtn2, false)) @@ -243,32 +196,10 @@ OnChangeProcess(*) { } OnChangeProcessOK(changeGui, *) { - ; Determine process mode and process list processMode := GetSelectedScopeMode(changeGui) procStr := ProcTextToStr(changeGui["ProcName"].Value) - global CurrentProcessMode := processMode - if (processMode = "include") { - global CurrentProcess := procStr - global CurrentProcessList := ParseProcessList(procStr) - global CurrentExcludeProcess := "" - global CurrentExcludeProcessList := [] - } else if (processMode = "exclude") { - global CurrentProcess := "" - global CurrentProcessList := [] - global CurrentExcludeProcess := procStr - global CurrentExcludeProcessList := ParseProcessList(procStr) - } else { - global CurrentProcess := "" - global CurrentProcessList := [] - global CurrentExcludeProcess := "" - global CurrentExcludeProcessList := [] - } - - ProcessText.Value := FormatProcessDisplay(CurrentProcessMode, CurrentProcessList, CurrentExcludeProcessList) - - SaveConfig() - ReloadConfigHotkeys(CurrentConfigName) + ConfigStore.Instance.SetScope(processMode, procStr) DestroyModalGui(changeGui) } @@ -277,7 +208,7 @@ OnChangeProcessOK(changeGui, *) { ; ============================================================================ OnAddMapping(*) { - if (CurrentConfigName = "") { + if (ConfigStore.Instance.SelectedName = "") { MsgBox(L("GuiEvents.Error.SelectOrCreateConfig"), APP_NAME, "Icon!") return } @@ -286,7 +217,7 @@ OnAddMapping(*) { } OnEditMapping(ctrl, rowNum := 0, *) { - if (CurrentConfigName = "") { + if (ConfigStore.Instance.SelectedName = "") { MsgBox(L("GuiEvents.Error.SelectOrCreateConfig"), APP_NAME, "Icon!") return } @@ -307,7 +238,7 @@ OnEditMapping(ctrl, rowNum := 0, *) { } OnCopyMapping(*) { - if (CurrentConfigName = "") { + if (ConfigStore.Instance.SelectedName = "") { MsgBox(L("GuiEvents.Error.SelectOrCreateConfig"), APP_NAME, "Icon!") return } @@ -318,24 +249,20 @@ OnCopyMapping(*) { return } - srcMapping := Mappings[rowNum] + srcMapping := ConfigStore.Instance.SelectedMappings()[rowNum] newMapping := Map() for key, val in srcMapping newMapping[key] := val - Mappings.Push(newMapping) - SaveConfig() - RefreshMappingLV() - ReloadConfigHotkeys(CurrentConfigName) + newIdx := ConfigStore.Instance.AddMapping(newMapping) - newIdx := Mappings.Length MappingLV.Modify(newIdx, "Select Focus Vis") global EditingIndex := newIdx ShowEditMappingGui() } OnDeleteMapping(*) { - if (CurrentConfigName = "") { + if (ConfigStore.Instance.SelectedName = "") { MsgBox(L("GuiEvents.Error.SelectOrCreateConfig"), APP_NAME, "Icon!") return } @@ -347,12 +274,8 @@ OnDeleteMapping(*) { } result := MsgBox(L("GuiEvents.Confirm.DeleteMapping"), APP_NAME, "YesNo Icon?") - if (result = "Yes") { - Mappings.RemoveAt(rowNum) - SaveConfig() - RefreshMappingLV() - ReloadConfigHotkeys(CurrentConfigName) - } + if (result = "Yes") + ConfigStore.Instance.DeleteMapping(rowNum) } ; ============================================================================ @@ -385,22 +308,3 @@ SetScopeEditorEnabled(procEdit, procPickBtn, isEnabled) { procEdit.Enabled := isEnabled procPickBtn.Enabled := isEnabled } - -DeleteCurrentConfigAndRefresh() { - if FileExist(CurrentConfigFile) - FileDelete(CurrentConfigFile) - - idx := FindConfigIndex(CurrentConfigName) - if (idx > 0) - AllConfigs.RemoveAt(idx) - - SaveEnabledStates() - global CurrentConfigName := "" - global CurrentConfigFile := "" - global Mappings := [] - - ReloadAllHotkeys() - RefreshConfigList() -} - - diff --git a/src/ui/GuiMain.ahk b/src/ui/GuiMain.ahk index 23a60c2..0849b30 100644 --- a/src/ui/GuiMain.ahk +++ b/src/ui/GuiMain.ahk @@ -1,6 +1,6 @@ ; ============================================================================ ; AHKeyMap - Main window construction module -; Builds the main window UI +; Builds the main window UI and owns rendering from application state ; ============================================================================ ; Declare globals shared across modules @@ -20,8 +20,7 @@ global BtnEditMapping global BtnCopyMapping global BtnDeleteMapping global BtnRunAsAdmin -global HotkeyConflicts -global HotkeyRegErrors +global LastReloadResult ; ============================================================================ ; GUI construction - main window @@ -117,12 +116,181 @@ BuildMainGui() { tray.Add(L("Tray.LanguageMenu"), langMenu) tray.Add() - tray.Add(adminTrayItem, OnRunAsAdmin) - if A_IsAdmin - tray.Disable(adminTrayItem) - tray.Add() - tray.Add(exitLabel, OnTrayExit) - tray.Default := showMainLabel + ; Register the render seam: every store change re-renders this window + ConfigStore.Instance.SetOnChanged(RenderFromState) +} + +; ============================================================================ +; Render-from-state entry (registered as the store's OnChanged callback) +; ============================================================================ + +; One render entry: rebuild every main-window widget from application state. +; Called with the ReloadAllHotkeys result ({conflicts, regErrors}) after +; store mutations, or with "" (keep the last result) for render-only events +; such as selection or language changes. Runs only when the window exists. +RenderFromState(reloadResult) { + if (MainGui = "") + return + + if IsObject(reloadResult) + global LastReloadResult := reloadResult + + RefreshConfigList() + RefreshScopeControls() + RefreshMappingLV() + UpdateStatusText() +} + +; Refresh config dropdown from the config list on disk (no hotkey reload) +; Invariant: render never mutates the store. This paints the store's +; selection into the dropdown and writes nothing back — adopting the +; dropdown item here would re-enter Select -> NotifyChanged -> +; RenderFromState and recurse forever. Choose() does not fire the Change +; event (probe-verified during the ticket 04 refactor), so re-selecting a +; dropdown item here cannot re-enter OnConfigSelect either. When the store +; has no selection, item 1 is shown for display only; the store-side +; callers (StartApp fallback, DeleteConfig re-select) keep the selection +; valid whenever configs exist. +RefreshConfigList() { + configs := GetConfigList() + items := [] + selectIdx := 0 + selectedName := ConfigStore.Instance.SelectedName + for i, name in configs { + items.Push(name) + if (name = selectedName) + selectIdx := i + } + + ConfigDDL.Delete() + if (items.Length > 0) { + ConfigDDL.Add(items) + if (selectIdx > 0) + ConfigDDL.Choose(selectIdx) + else + ConfigDDL.Choose(1) + } +} + +; Refresh the scope text and enable checkbox from the selected config +RefreshScopeControls() { + cfg := ConfigStore.Instance.Selected() + if (cfg != "") { + ProcessText.Value := FormatProcessDisplay(cfg["processMode"], cfg["processList"], cfg["excludeProcessList"]) + EnabledCB.Value := cfg["enabled"] + EnabledCB.Enabled := true + } else { + ProcessText.Value := L("Config.Scope.None") + EnabledCB.Value := 0 + EnabledCB.Enabled := false + } +} + +; Refresh mapping ListView rows from the selected config's mappings +RefreshMappingLV() { + rows := BuildMappingRows(ConfigStore.Instance.SelectedMappings()) + MappingLV.Delete() + for _, row in rows + MappingLV.Add("", row.idx, row.modifier, row.source, row.target, row.hold, row.mode, row.delay, row.interval) + ; Auto-adjust column widths + loop 8 + MappingLV.ModifyCol(A_Index, "AutoHdr") +} + +; Update the status bar from the store plus the last reload result +UpdateStatusText() { + vm := BuildStatusSummary(AllConfigs, LastReloadResult) + global StatusHasWarning := vm.hasWarning + + if (vm.hasWarning) { + StatusText.SetFont("cE07B00") + StatusDetailLink.Opt("-Hidden") + } else { + StatusText.SetFont("cGray") + StatusDetailLink.Opt("+Hidden") + SetStatusDetailHover(false) + } + StatusText.Value := vm.text +} + +; ============================================================================ +; Pure view-model builders (unit-tested without a window) +; ============================================================================ + +; Status summary text plus the warning flag +; Returns {text: "...", hasWarning: true|false} +BuildStatusSummary(allConfigs, reloadResult) { + conflicts := [] + regErrors := [] + if IsObject(reloadResult) { + conflicts := reloadResult.conflicts + regErrors := reloadResult.regErrors + } + + enabledCount := 0 + totalCount := allConfigs.Length + for _, cfg in allConfigs { + if (cfg["enabled"]) + enabledCount++ + } + + statusStr := L("Config.Status.EnabledSummary", enabledCount, totalCount) + hasWarning := false + if (conflicts.Length > 0) { + statusStr .= L("Config.Status.ConflictSuffix", conflicts.Length) + hasWarning := true + } + if (regErrors.Length > 0) { + statusStr .= L("Config.Status.RegErrorSuffix", regErrors.Length) + hasWarning := true + } + + return { text: statusStr, hasWarning: hasWarning } +} + +; Mapping rows for the ListView (one row object per mapping, in order) +; Each row: {idx, modifier, source, target, hold, mode, delay, interval} +BuildMappingRows(mappings) { + rows := [] + ; (local name avoids shadowing the Mapping class; AHK names are case-insensitive) + for idx, m in mappings { + holdText := m["HoldRepeat"] ? L("Config.Mapping.HoldYes") : L("Config.Mapping.HoldNo") + modDisplay := m["ModifierKey"] != "" ? KeyToDisplay(m["ModifierKey"]) : "" + ptText := "" + if (m["ModifierKey"] != "") + ptText := m["PassthroughMod"] ? L("Config.Mapping.ModMode.Pass") : L("Config.Mapping.ModMode.Block") + delayText := m["HoldRepeat"] ? m["RepeatDelay"] : "" + intervalText := m["HoldRepeat"] ? m["RepeatInterval"] : "" + rows.Push({ + idx: idx, + modifier: modDisplay, + source: KeyToDisplay(m["SourceKey"]), + target: KeyToDisplay(m["TargetKey"]), + hold: holdText, + mode: ptText, + delay: delayText, + interval: intervalText + }) + } + return rows +} + +; Format process scope for display (using parsed arrays) +FormatProcessDisplay(processMode, processList, excludeProcessList) { + if (processMode = "include") { + if (processList.Length = 0) + return L("Config.Scope.Global") + if (processList.Length = 1) + return L("Config.Scope.Include.Single", processList[1]) + return L("Config.Scope.Include.Multi", processList[1], processList.Length - 1) + } else if (processMode = "exclude") { + if (excludeProcessList.Length = 0) + return L("Config.Scope.Global") + if (excludeProcessList.Length = 1) + return L("Config.Scope.Exclude.Single", excludeProcessList[1]) + return L("Config.Scope.Exclude.Multi", excludeProcessList[1], excludeProcessList.Length - 1) + } + return L("Config.Scope.Global") } ; Main window resize handler @@ -233,22 +401,36 @@ OnMainSetCursor(wParam, lParam, msg, hwnd) { ; Show detailed hotkey conflicts and registration errors when clicking status detail OnStatusTextClick(*) { - if (HotkeyConflicts.Length = 0 && HotkeyRegErrors.Length = 0) + details := BuildStatusDetails(LastReloadResult) + if (details = "") return + MsgBox(details, APP_NAME, "Icon!") +} + +; Detail-popup text from the last reload result ("" when there is nothing to show) +BuildStatusDetails(reloadResult) { + conflicts := [] + regErrors := [] + if IsObject(reloadResult) { + conflicts := reloadResult.conflicts + regErrors := reloadResult.regErrors + } + if (conflicts.Length = 0 && regErrors.Length = 0) + return "" details := "" - if (HotkeyConflicts.Length > 0) { + if (conflicts.Length > 0) { details .= L("GuiMain.Status.ConflictsHeader") - for _, c in HotkeyConflicts + for _, c in conflicts details .= Format(L("GuiMain.Status.ConflictItem"), c.hotkey, c.config1, c.config2) } - if (HotkeyRegErrors.Length > 0) { + if (regErrors.Length > 0) { if (details != "") details .= "`n" details .= L("GuiMain.Status.RegErrorsHeader") - for _, k in HotkeyRegErrors + for _, k in regErrors details .= Format(L("GuiMain.Status.RegErrorItem"), k) } - MsgBox(RTrim(details, "`n"), APP_NAME, "Icon!") + return RTrim(details, "`n") } diff --git a/src/ui/MappingEditor.ahk b/src/ui/MappingEditor.ahk index 747b77e..551e2d3 100644 --- a/src/ui/MappingEditor.ahk +++ b/src/ui/MappingEditor.ahk @@ -5,13 +5,12 @@ ; Declare globals shared across modules global APP_NAME -global Mappings global MainGui -global CurrentConfigName global DEFAULT_REPEAT_DELAY global DEFAULT_REPEAT_INTERVAL -; GUI control references (shared with KeyCapture module) +; GUI control references (editor-owned protocol: .Value holds the display +; text, .ahkKey holds the AHK key syntax) global EditModifierEdit global EditSourceEdit global EditTargetEdit @@ -63,8 +62,9 @@ ShowEditMappingGui() { global EditPassthroughCB := EditGui.AddCheckbox("x10 y175 w370 h23 vPassthroughMod", L("MappingEditor.PassthroughLabel")) ; In edit mode, populate fields from existing mapping - if (EditingIndex > 0 && EditingIndex <= Mappings.Length) { - m := Mappings[EditingIndex] + mappings := ConfigStore.Instance.SelectedMappings() + if (EditingIndex > 0 && EditingIndex <= mappings.Length) { + m := mappings[EditingIndex] EditModifierEdit.Value := KeyToDisplay(m["ModifierKey"]) EditModifierEdit.ahkKey := m["ModifierKey"] EditSourceEdit.Value := KeyToDisplay(m["SourceKey"]) @@ -97,6 +97,46 @@ OnClearModifier(*) { UpdatePassthroughState() } +; ---- Key capture entries: bind a capture target to its completion callback ---- + +OnCaptureModifier(*) { + StartCapture("modifier", OnModifierCaptured) +} + +OnCaptureSource(*) { + StartCapture("source", OnSourceCaptured) +} + +OnCaptureTarget(*) { + StartCapture("target", OnTargetCaptured) +} + +; ---- Capture completion callbacks: write the editor's own controls ---- + +OnModifierCaptured(ahkKey, displayKey) { + EditModifierEdit.Value := displayKey + EditModifierEdit.ahkKey := ahkKey + UpdatePassthroughState() +} + +OnSourceCaptured(ahkKey, displayKey) { + EditSourceEdit.Value := displayKey + EditSourceEdit.ahkKey := ahkKey +} + +OnTargetCaptured(ahkKey, displayKey) { + EditTargetEdit.Value := displayKey + EditTargetEdit.ahkKey := ahkKey +} + +; "Keep modifier behavior" option is only meaningful when a modifier is set +UpdatePassthroughState() { + hasModifier := EditModifierEdit.ahkKey != "" + EditPassthroughCB.Enabled := hasModifier + if !hasModifier + EditPassthroughCB.Value := 0 +} + OnHoldRepeatToggle(ctrl, *) { isEnabled := ctrl.Value EditDelayEdit.Enabled := isEnabled @@ -119,28 +159,22 @@ OnEditMappingOK(*) { repeatDelay := EditDelayEdit.Value != "" ? Integer(EditDelayEdit.Value) : DEFAULT_REPEAT_DELAY repeatInterval := EditIntervalEdit.Value != "" ? Integer(EditIntervalEdit.Value) : DEFAULT_REPEAT_INTERVAL - if (repeatDelay < 10) - repeatDelay := 10 - if (repeatInterval < 10) - repeatInterval := 10 - - mapping := Map() - mapping["ModifierKey"] := modifierAhk - mapping["SourceKey"] := sourceAhk - mapping["TargetKey"] := targetAhk - mapping["HoldRepeat"] := EditHoldRepeatCB.Value ? 1 : 0 - mapping["RepeatDelay"] := repeatDelay - mapping["RepeatInterval"] := repeatInterval - mapping["PassthroughMod"] := EditPassthroughCB.Value ? 1 : 0 - - if (EditingIndex > 0 && EditingIndex <= Mappings.Length) { - Mappings[EditingIndex] := mapping + + ; Mapping.Make owns the record shape and the min-10 clamping + newMapping := Mapping.Make( + modifierAhk, + sourceAhk, + targetAhk, + EditHoldRepeatCB.Value ? 1 : 0, + repeatDelay, + repeatInterval, + EditPassthroughCB.Value ? 1 : 0) + + if (EditingIndex > 0 && EditingIndex <= ConfigStore.Instance.SelectedMappings().Length) { + ConfigStore.Instance.ReplaceMapping(EditingIndex, newMapping) } else { - Mappings.Push(mapping) + ConfigStore.Instance.AddMapping(newMapping) } - SaveConfig() - RefreshMappingLV() - ReloadConfigHotkeys(CurrentConfigName) DestroyModalGui(EditGui) } diff --git a/tests/gui/main_smoke.test.ahk b/tests/gui/main_smoke.test.ahk index f470402..d7dc7fe 100644 --- a/tests/gui/main_smoke.test.ahk +++ b/tests/gui/main_smoke.test.ahk @@ -12,11 +12,13 @@ RegisterTest("Main GUI smoke flow covers config lifecycle and persisted state", RunRegisteredTests() Test_MainGui_SmokeFlow_CoversLifecycle() { + store := ConfigStore.Instance StartApp() AssertTrue(MainGui != "") AssertEq("en-US", CurrentLangCode) AssertEq(0, AllConfigs.Length) + AssertEq("", store.SelectedName) AssertTrue(InStr(WinGetTitle("ahk_id " MainGui.Hwnd), "AHKeyMap v" APP_VERSION) > 0) OnNewConfig() @@ -25,9 +27,10 @@ Test_MainGui_SmokeFlow_CoversLifecycle() { OnNewConfigOK(newGui) AssertFileExists(CONFIG_DIR "\SmokeConfig.ini") - AssertEq("SmokeConfig", CurrentConfigName) + AssertEq("SmokeConfig", store.SelectedName) AssertEq(1, AllConfigs.Length) AssertEq("1", ReadStateValue("EnabledConfigs", "SmokeConfig")) + AssertEq("SmokeConfig", ReadStateValue("State", "LastConfig")) EditingIndex := 0 ShowEditMappingGui() @@ -55,7 +58,7 @@ Test_MainGui_SmokeFlow_CoversLifecycle() { EnabledCB.Value := 0 OnToggleEnabled(EnabledCB) - AssertFalse(CurrentConfigEnabled) + AssertFalse(store.Selected()["enabled"]) AssertEq("0", ReadStateValue("EnabledConfigs", "SmokeConfig")) OnChangeProcess() @@ -70,21 +73,35 @@ Test_MainGui_SmokeFlow_CoversLifecycle() { changeGui["ProcName"].Value := "notepad.exe`ncode.exe" OnChangeProcessOK(changeGui) - AssertEq("include", CurrentProcessMode) - AssertEq("notepad.exe|code.exe", CurrentProcess) + AssertEq("include", store.Selected()["processMode"]) + AssertEq("notepad.exe|code.exe", store.Selected()["process"]) AssertEq("Scope: Only notepad.exe and 1 more", ProcessText.Value) AssertEq("include", ReadConfigValue("SmokeConfig", "Meta", "ProcessMode")) AssertEq("notepad.exe|code.exe", ReadConfigValue("SmokeConfig", "Meta", "Process")) EnabledCB.Value := 1 OnToggleEnabled(EnabledCB) - AssertTrue(CurrentConfigEnabled) + AssertTrue(store.Selected()["enabled"]) AssertEq("1", ReadStateValue("EnabledConfigs", "SmokeConfig")) - AssertTrue(ActiveHotkeys.Length > 0, "Expected active hotkeys before deleting an enabled config.") - - DeleteCurrentConfigAndRefresh() + ; The only mapping is Path C (RAlt+F13 passthrough): since ticket 01 its + ; registration records live in PathCEngine.registrations, not the + ; ActiveHotkeys global (Path A/B records only) — the bookkeeping moved, + ; registration itself still runs through the store chokepoint. Assert its + ; health via the reload result the render seam received: no registration + ; errors means the engine registered the routing hotkeys cleanly. + AssertTrue(IsObject(LastReloadResult), "RenderFromState should have stored the chokepoint's reload result.") + AssertEq(0, LastReloadResult.regErrors.Length) + + ; OnDeleteConfig shows a blocking confirmation MsgBox; arm an in-script + ; timer to click its Yes button (Button1) shortly after it opens, because + ; SendInput from outside the process is not delivered in this sandbox. + SetTimer(() => ( + hwnd := WinExist(APP_NAME " ahk_class #32770"), + hwnd != 0 ? ControlClick("Button1", hwnd) : 0 + ), -1000) + OnDeleteConfig() AssertFalse(FileExist(CONFIG_DIR "\SmokeConfig.ini")) AssertEq(0, AllConfigs.Length) - AssertEq("", CurrentConfigName) + AssertEq("", store.SelectedName) } diff --git a/tests/integration/config_io.test.ahk b/tests/integration/config_io.test.ahk index b3ae38c..fba82b4 100644 --- a/tests/integration/config_io.test.ahk +++ b/tests/integration/config_io.test.ahk @@ -9,46 +9,29 @@ global __AHKM_CONFIG_DIR := A_Temp "\AHKeyMapTests\" A_ScriptName "-" A_TickCoun CurrentLangCode := "en-US" -RegisterTest("SaveConfig writes atomically and round-trips mappings", Test_SaveConfig_WritesAtomicallyAndRoundTrips) +RegisterTest("Store chokepoint persists atomically and round-trips mappings", Test_StoreChokepoint_PersistsAtomicallyAndRoundTrips) RegisterTest("SaveEnabledStates preserves LastConfig and UILanguage", Test_SaveEnabledStates_PreservesStateMetadata) RegisterTest("LoadAllConfigs reuses the existing AllConfigs array", Test_LoadAllConfigs_ReusesExistingArrayObject) -RegisterTest("SaveConfig with empty mappings writes Meta only", Test_SaveConfig_EmptyMappings_WritesMetaOnly) -RegisterTest("SaveConfig with many mappings preserves order", Test_SaveConfig_ManyMappings_PreservesOrder) +RegisterTest("Store SetScope writes Meta only for empty global config", Test_StoreSetScope_Global_WritesMetaOnly) +RegisterTest("Store AddMapping preserves order across many mappings", Test_StoreAddMapping_ManyMappings_PreservesOrder) RegisterTest("LoadConfigData returns empty for nonexistent file", Test_LoadConfigData_NonexistentFile_ReturnsEmpty) RunRegisteredTests() -Test_SaveConfig_WritesAtomicallyAndRoundTrips() { - global CurrentConfigName - global CurrentConfigFile - global CurrentProcessMode - global CurrentProcess - global CurrentProcessList - global CurrentExcludeProcess - global CurrentExcludeProcessList - global CurrentConfigEnabled - global Mappings - global AllConfigs - +Test_StoreChokepoint_PersistsAtomicallyAndRoundTrips() { + store := ConfigStore.Instance roundTripMappings := [MakeMapping("CapsLock", "F13", "^c", 1, 120, 40, 0)] - CurrentConfigName := "RoundTrip" - CurrentConfigFile := CONFIG_DIR "\RoundTrip.ini" - CurrentProcessMode := "include" - CurrentProcess := "notepad.exe|Code.exe" - CurrentProcessList := ParseProcessList(CurrentProcess) - CurrentExcludeProcess := "" - CurrentExcludeProcessList := [] - CurrentConfigEnabled := true - Mappings.Length := 0 - Mappings.Push(roundTripMappings[1]) - - AllConfigs.Push(BuildConfigRecord("RoundTrip", CurrentProcessMode, CurrentProcess, "", true, roundTripMappings)) + SeedConfigFile("RoundTrip", "global", "", "", [], 1) + LoadAllConfigs() + store.Select("RoundTrip") - SaveConfig() + store.SetScope("include", "notepad.exe|Code.exe") + store.AddMapping(roundTripMappings[1]) - AssertFileExists(CurrentConfigFile) - AssertFalse(FileExist(CurrentConfigFile ".tmp"), "Config temp file should be cleaned up after save.") + configFile := CONFIG_DIR "\RoundTrip.ini" + AssertFileExists(configFile) + AssertFalse(FileExist(configFile ".tmp"), "Config temp file should be cleaned up after save.") loaded := LoadConfigData("RoundTrip") AssertEq("include", loaded["processMode"]) @@ -59,11 +42,15 @@ Test_SaveConfig_WritesAtomicallyAndRoundTrips() { AssertEq("^c", loaded["mappings"][1]["TargetKey"]) AssertEq(120, loaded["mappings"][1]["RepeatDelay"]) AssertTrue(loaded["enabled"]) + + ; The store selection points at the live record inside AllConfigs + AssertEq("RoundTrip", store.SelectedName) + AssertEq("RoundTrip", store.Selected()["name"]) + AssertEq(1, store.SelectedMappings().Length) } Test_SaveEnabledStates_PreservesStateMetadata() { global CurrentLangCode - global AllConfigs IniWrite("SmokeConfig", STATE_FILE, "State", "LastConfig") CurrentLangCode := "zh-CN" @@ -82,8 +69,6 @@ Test_SaveEnabledStates_PreservesStateMetadata() { } Test_LoadAllConfigs_ReusesExistingArrayObject() { - global AllConfigs - originalPtr := ObjPtr(AllConfigs) SeedConfigFile("Alpha", "global", "", "", [MakeMapping("", "F13", "^c")], 1) @@ -98,49 +83,28 @@ Test_LoadAllConfigs_ReusesExistingArrayObject() { AssertFalse(AllConfigs[2]["enabled"]) } -Test_SaveConfig_EmptyMappings_WritesMetaOnly() { - global CurrentConfigName - global CurrentConfigFile - global CurrentProcessMode - global CurrentProcess - global CurrentProcessList - global CurrentExcludeProcess - global CurrentExcludeProcessList - global CurrentConfigEnabled - global Mappings - global AllConfigs - - CurrentConfigName := "EmptyCfg" - CurrentConfigFile := CONFIG_DIR "\EmptyCfg.ini" - CurrentProcessMode := "global" - CurrentProcess := "" - CurrentProcessList := [] - CurrentExcludeProcess := "" - CurrentExcludeProcessList := [] - CurrentConfigEnabled := true - Mappings.Length := 0 - - AllConfigs.Push(BuildConfigRecord("EmptyCfg", "global", "", "", true, [])) - - SaveConfig() - AssertFileExists(CurrentConfigFile) +Test_StoreSetScope_Global_WritesMetaOnly() { + store := ConfigStore.Instance + + SeedConfigFile("EmptyCfg", "global", "", "", [], 1) + LoadAllConfigs() + store.Select("EmptyCfg") + + store.SetScope("global", "") + + AssertFileExists(CONFIG_DIR "\EmptyCfg.ini") loaded := LoadConfigData("EmptyCfg") AssertEq("global", loaded["processMode"]) AssertEq(0, loaded["mappings"].Length) } -Test_SaveConfig_ManyMappings_PreservesOrder() { - global CurrentConfigName - global CurrentConfigFile - global CurrentProcessMode - global CurrentProcess - global CurrentProcessList - global CurrentExcludeProcess - global CurrentExcludeProcessList - global CurrentConfigEnabled - global Mappings - global AllConfigs +Test_StoreAddMapping_ManyMappings_PreservesOrder() { + store := ConfigStore.Instance + + SeedConfigFile("ManyCfg", "global", "", "", [], 1) + LoadAllConfigs() + store.Select("ManyCfg") manyMappings := [ MakeMapping("", "F13", "^a"), @@ -149,22 +113,8 @@ Test_SaveConfig_ManyMappings_PreservesOrder() { MakeMapping("RAlt", "F16", "^d", 1, 200, 40, 1), MakeMapping("", "F17", "^e") ] - - CurrentConfigName := "ManyCfg" - CurrentConfigFile := CONFIG_DIR "\ManyCfg.ini" - CurrentProcessMode := "include" - CurrentProcess := "notepad.exe" - CurrentProcessList := ParseProcessList(CurrentProcess) - CurrentExcludeProcess := "" - CurrentExcludeProcessList := [] - CurrentConfigEnabled := true - Mappings.Length := 0 for _, m in manyMappings - Mappings.Push(m) - - AllConfigs.Push(BuildConfigRecord("ManyCfg", "include", "notepad.exe", "", true, manyMappings)) - - SaveConfig() + store.AddMapping(m) loaded := LoadConfigData("ManyCfg") AssertEq(5, loaded["mappings"].Length) diff --git a/tests/integration/hotkey_engine_state.test.ahk b/tests/integration/hotkey_engine_state.test.ahk index 4acc09b..2a69ed4 100644 --- a/tests/integration/hotkey_engine_state.test.ahk +++ b/tests/integration/hotkey_engine_state.test.ahk @@ -10,19 +10,21 @@ global __AHKM_CONFIG_DIR := A_Temp "\AHKeyMapTests\" A_ScriptName "-" A_TickCoun CurrentLangCode := "en-US" RegisterTest("DetectHotkeyConflicts reports scope overlap and Path B/C modifier conflicts", Test_DetectHotkeyConflicts_ReportsScopeAndModifierIssues) -RegisterTest("ReloadAllHotkeys tracks Path A/B/C registration state and cleanup", Test_ReloadAllHotkeys_TracksDispatchStateAndCleanup) -RegisterTest("RegisterPathCMapping stores mapping metadata for routed combos", Test_RegisterPathCMapping_StoresMappingMetadata) -RegisterTest("Path C wheel routing stays disabled without an active modifier session", Test_PathC_ShouldRouteWheelSource_FalseWithoutSession) -RegisterTest("Path C wheel routing stays disabled when the active session does not match scope", Test_PathC_ShouldRouteWheelSource_FalseWhenScopeDoesNotMatch) -RegisterTest("Path C wheel routing enables when an active session and scope-matching mapping exist", Test_PathC_ShouldRouteWheelSource_TrueWhenSessionAndScopeMatch) -RegisterTest("Path C falls back to the raw source key when no session matches", Test_PathC_SourceDown_FallsBackToRawSourceKey) -RegisterTest("Path C routed mappings dispatch target keys and mark the session as a gesture", Test_PathC_SourceDown_DispatchesMappedTarget) -RegisterTest("Path C wheel mappings dispatch target keys and mark the session as a gesture", Test_PathC_WheelSourceDown_DispatchesMappedTarget) -RegisterTest("Path C source key up stops repeat timers for matching mappings", Test_PathC_SourceUp_StopsActiveRepeats) -RegisterTest("Path C gesture completion dismisses the RButton menu with Escape", Test_PathC_ModUp_DismissesContextMenuAfterGesture) +RegisterTest("ReloadAllHotkeys registers Path A/B hotkeys and delegates Path C to the engine", Test_ReloadAllHotkeys_DelegatesPathCToEngineAndCleansUp) +RegisterTest("Path C wheel routing stays disabled without an active modifier session", Test_PathCEngine_ShouldRouteWheel_FalseWithoutSession) +RegisterTest("Path C wheel routing stays disabled when the active session does not match scope", Test_PathCEngine_ShouldRouteWheel_FalseWhenScopeDoesNotMatch) +RegisterTest("Path C wheel routing enables when an active session and scope-matching mapping exist", Test_PathCEngine_ShouldRouteWheel_TrueWhenSessionAndScopeMatch) +RegisterTest("Path C falls back to the raw source key when no session matches", Test_PathCEngine_SourceDown_FallsBackToRawSourceKey) +RegisterTest("Path C AddMapping ignores mappings that are not passthrough combos", Test_PathCEngine_AddMapping_IgnoresNonPathCMappings) +RegisterTest("Path C routed mappings dispatch target keys and mark the session as a gesture", Test_PathCEngine_SourceDown_DispatchesMappedTarget) +RegisterTest("Path C wheel mappings dispatch target keys and mark the session as a gesture", Test_PathCEngine_WheelSourceDown_DispatchesMappedTarget) +RegisterTest("Path C source key up stops repeat timers for matching mappings", Test_PathCEngine_SourceUp_StopsActiveRepeats) +RegisterTest("Path C gesture completion dismisses the RButton menu with Escape", Test_PathCEngine_ModUp_DismissesContextMenuAfterGesture) +RegisterTest("Path C ModDown resets stale session before starting new one", Test_PathCEngine_ModDown_ResetsStaleSession) +RegisterTest("Path C Commit registers routing hotkeys and Reset disables them again", Test_PathCEngine_CommitAndReset_RoundTrip) +RegisterTest("Path C Commit reports registration failures through its return value", Test_PathCEngine_Commit_ReportsRegErrors) RegisterTest("DetectHotkeyConflicts reports no conflict for disabled configs", Test_DetectHotkeyConflicts_NoConflictForDisabledConfigs) RegisterTest("DetectHotkeyConflicts reports no conflict for disjoint scopes", Test_DetectHotkeyConflicts_NoConflictForDisjointScopes) -RegisterTest("Path C ModDown resets stale session before starting new one", Test_PathC_ModDown_ResetsStaleSession) RunRegisteredTests() @@ -37,14 +39,14 @@ Test_DetectHotkeyConflicts_ReportsScopeAndModifierIssues() { AllConfigs.Push(cfg3) AllConfigs.Push(cfg4) - DetectHotkeyConflicts() + conflicts := DetectHotkeyConflicts() - AssertEq(2, HotkeyConflicts.Length) - AssertEq("F13", HotkeyConflicts[1].hotkey) - AssertEq("CapsLock (Path B/C conflict)", HotkeyConflicts[2].hotkey) + AssertEq(2, conflicts.Length) + AssertEq("F13", conflicts[1].hotkey) + AssertEq("CapsLock (Path B/C conflict)", conflicts[2].hotkey) } -Test_ReloadAllHotkeys_TracksDispatchStateAndCleanup() { +Test_ReloadAllHotkeys_DelegatesPathCToEngineAndCleansUp() { mappings := [ MakeMapping("", "F21", "^c"), MakeMapping("CapsLock", "F22", "^v", 0, 300, 50, 0), @@ -53,141 +55,234 @@ Test_ReloadAllHotkeys_TracksDispatchStateAndCleanup() { ] AllConfigs.Push(BuildConfigRecord("DispatchCfg", "global", "", "", true, mappings)) - ReloadAllHotkeys() + result := ReloadAllHotkeys() - AssertTrue(ActiveHotkeys.Length >= 4) + ; Path A/B registration bookkeeping stays in the shared globals + AssertTrue(ActiveHotkeys.Length >= 3) AssertEq(1, InterceptModKeys.Count) - AssertMapHas(PathCMappingByModSource, "RAlt|F23") - AssertMapHas(PathCMappingByModSource, "RButton|WheelUp") - AssertMapHas(PathCModsUsed, "RAlt") - AssertMapHas(PathCModsUsed, "RButton") - AssertMapHas(PathCSourceKeysUsed, "F23") - AssertMapHas(PathCSourceKeysUsed, "WheelUp") - AssertEq(1, PathCWheelRoutePredicates.Length) - AssertEq(0, HotkeyRegErrors.Length) + + ; Engine output arrives through the return value, not globals + AssertEq(0, result.conflicts.Length) + AssertEq(0, result.regErrors.Length) + + ; Path C behavior is reachable through the engine's public interface + engine := PathCEngine.Instance + AssertEq("Idle", engine.GetSessionState("RAlt")) + AssertEq("Idle", engine.GetSessionState("RButton")) + AssertFalse(engine.ShouldRouteWheel("WheelUp")) + + ; RAlt session: hold modifier, press source -> mapped target fires + engine.OnModDown("RAlt") + AssertEq("HeldNoCombo", engine.GetSessionState("RAlt")) + EnableSendCapture() + engine.OnSourceDown("F23") + AssertEq(1, CapturedSendKeys.Length) + AssertEq("^x", CapturedSendKeys[1]) + AssertEq("GestureActive", engine.GetSessionState("RAlt")) + + ; RButton wheel session: active session plus mapping enables wheel routing + engine.OnModDown("RButton") + AssertTrue(engine.ShouldRouteWheel("WheelUp")) + engine.OnSourceDown("WheelUp") + AssertEq("^{Tab}", CapturedSendKeys[2]) + AssertEq("GestureActive", engine.GetSessionState("RButton")) UnregisterAllHotkeys() + ; Engine reset ends sessions and forgets all mappings AssertEq(0, ActiveHotkeys.Length) AssertEq(0, InterceptModKeys.Count) AssertEq(0, HoldTimers.Count) - AssertEq(0, PathCMappingByModSource.Count) - AssertEq(0, PathCModSessions.Count) - AssertEq(0, PathCModsUsed.Count) - AssertEq(0, PathCSourceKeysUsed.Count) - AssertEq(0, PathCWheelRoutePredicates.Length) -} - -Test_RegisterPathCMapping_StoresMappingMetadata() { - mapping := MakeMapping("RButton", "WheelUp", "^Tab", 1, 300, 50, 1) - - RegisterPathCMapping(mapping, "Cfg|1", "Cfg", "") - - AssertMapHas(PathCMappingByModSource, "RButton|WheelUp") - entry := PathCMappingByModSource["RButton|WheelUp"][1] - AssertEq("Cfg|1", entry.id) - AssertEq("^Tab", entry.targetKey) - AssertEq(1, entry.holdRepeat) - AssertMapHas(PathCModsUsed, "RButton") - AssertMapHas(PathCSourceKeysUsed, "WheelUp") + AssertEq("Idle", engine.GetSessionState("RAlt")) + AssertEq("Idle", engine.GetSessionState("RButton")) + AssertFalse(engine.ShouldRouteWheel("WheelUp")) + DisableSendCapture() } -Test_PathC_ShouldRouteWheelSource_FalseWithoutSession() { - RegisterPathCMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", "") +Test_PathCEngine_ShouldRouteWheel_FalseWithoutSession() { + engine := PathCEngine() + engine.AddMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", "") AssertTrue(IsWheelSourceKey("WheelUp")) AssertTrue(IsWheelSourceKey("^WheelDown")) AssertFalse(IsWheelSourceKey("F13")) - AssertFalse(PathC_ShouldRouteWheelSource("WheelUp")) - AssertFalse(PathC_ShouldRouteWheelSource("WheelUp", "*WheelUp")) + AssertFalse(engine.ShouldRouteWheel("WheelUp")) + AssertFalse(engine.ShouldRouteWheel("WheelUp", "*WheelUp")) } -Test_PathC_ShouldRouteWheelSource_FalseWhenScopeDoesNotMatch() { - checker := (*) => false - RegisterPathCMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", checker) - PathC_ModDownCallback("RButton") +Test_PathCEngine_ShouldRouteWheel_FalseWhenScopeDoesNotMatch() { + engine := PathCEngine() + cfg := BuildConfigRecord("Cfg", "include", "notepad.exe") + checker := MakeProcessChecker(cfg) + engine.AddMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", checker) + engine.OnModDown("RButton") + SetForegroundProcess("msedge.exe") + + AssertFalse(engine.ShouldRouteWheel("WheelUp")) + AssertFalse(engine.ShouldRouteWheel("WheelUp", "*WheelUp")) + engine.OnModUp("RButton") +} - AssertFalse(PathC_ShouldRouteWheelSource("WheelUp")) - AssertFalse(PathC_ShouldRouteWheelSource("WheelUp", "*WheelUp")) +Test_PathCEngine_ShouldRouteWheel_TrueWhenSessionAndScopeMatch() { + engine := PathCEngine() + cfg := BuildConfigRecord("Cfg", "include", "notepad.exe") + checker := MakeProcessChecker(cfg) + engine.AddMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", checker) + engine.OnModDown("RButton") + SetForegroundProcess("notepad.exe") + + AssertTrue(engine.ShouldRouteWheel("WheelUp")) + AssertTrue(engine.ShouldRouteWheel("WheelUp", "*WheelUp")) + engine.OnModUp("RButton") } -Test_PathC_ShouldRouteWheelSource_TrueWhenSessionAndScopeMatch() { - checker := (*) => true - RegisterPathCMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", checker) - PathC_ModDownCallback("RButton") +Test_PathCEngine_SourceDown_FallsBackToRawSourceKey() { + engine := PathCEngine() + EnableSendCapture() + + engine.OnSourceDown("F13") - AssertTrue(PathC_ShouldRouteWheelSource("WheelUp")) - AssertTrue(PathC_ShouldRouteWheelSource("WheelUp", "*WheelUp")) + AssertEq(1, CapturedSendKeys.Length) + AssertEq("{F13}", CapturedSendKeys[1]) } -Test_PathC_SourceDown_FallsBackToRawSourceKey() { +Test_PathCEngine_AddMapping_IgnoresNonPathCMappings() { + engine := PathCEngine() EnableSendCapture() - PathC_SourceDownCallback("F13") + ; Path A (no modifier) and Path B (no passthrough) shapes must be ignored + engine.AddMapping(MakeMapping("", "F13", "^c", 0, 300, 50, 1), "Cfg|1", "Cfg", "") + engine.AddMapping(MakeMapping("RButton", "F14", "^v", 0, 300, 50, 0), "Cfg|2", "Cfg", "") - AssertEq(1, CapturedSendKeys.Length) + engine.OnModDown("RButton") + engine.OnSourceDown("F13") + engine.OnSourceDown("F14") + + ; Neither mapping is registered, so both presses fall back to their raw keys + AssertEq(2, CapturedSendKeys.Length) AssertEq("{F13}", CapturedSendKeys[1]) + AssertEq("{F14}", CapturedSendKeys[2]) + AssertEq("HeldNoCombo", engine.GetSessionState("RButton")) + engine.OnModUp("RButton") } -Test_PathC_SourceDown_DispatchesMappedTarget() { - RegisterPathCMapping(MakeMapping("RButton", "F13", "^c", 0, 300, 50, 1), "Cfg|1", "Cfg", "") - PathC_ModDownCallback("RButton") +Test_PathCEngine_SourceDown_DispatchesMappedTarget() { + engine := PathCEngine() + engine.AddMapping(MakeMapping("RButton", "F13", "^c", 0, 300, 50, 1), "Cfg|1", "Cfg", "") + engine.OnModDown("RButton") EnableSendCapture() - PathC_SourceDownCallback("F13") + engine.OnSourceDown("F13") - session := PathC_GetSession("RButton") AssertEq(1, CapturedSendKeys.Length) AssertEq("^c", CapturedSendKeys[1]) - AssertEq("GestureActive", session.state) - AssertTrue(session.isGesture) + AssertEq("GestureActive", engine.GetSessionState("RButton")) + engine.OnModUp("RButton") } -Test_PathC_WheelSourceDown_DispatchesMappedTarget() { - RegisterPathCMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", "") - PathC_ModDownCallback("RButton") +Test_PathCEngine_WheelSourceDown_DispatchesMappedTarget() { + engine := PathCEngine() + engine.AddMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", "") + engine.OnModDown("RButton") EnableSendCapture() - PathC_SourceDownCallback("WheelUp") + engine.OnSourceDown("WheelUp") - session := PathC_GetSession("RButton") AssertEq(1, CapturedSendKeys.Length) AssertEq("^{Tab}", CapturedSendKeys[1]) - AssertEq("GestureActive", session.state) - AssertTrue(session.isGesture) + AssertEq("GestureActive", engine.GetSessionState("RButton")) + engine.OnModUp("RButton") } -Test_PathC_SourceUp_StopsActiveRepeats() { - mappingId := "Cfg|1" - session := PathC_GetSession("RButton") - session.state := "GestureActive" - session.activeSources["F14"] := [mappingId] - session.repeatMappings[mappingId] := true - HoldTimers[mappingId] := { - fn: NoOpTimer, - startFn: NoOpTimer, - interval: 50, - active: true - } +Test_PathCEngine_SourceUp_StopsActiveRepeats() { + engine := PathCEngine() + ; Hold-repeat mapping: immediate send on press, then repeats after the delay + engine.AddMapping(MakeMapping("RButton", "F14", "^v", 1, 200, 50, 1), "Cfg|1", "Cfg", "") + engine.OnModDown("RButton") + EnableSendCapture() - PathC_SourceUpCallback("F14") + engine.OnSourceDown("F14") + AssertEq(1, CapturedSendKeys.Length) + AssertEq("^v", CapturedSendKeys[1]) + AssertEq("GestureActive", engine.GetSessionState("RButton")) - AssertFalse(HoldTimers.Has(mappingId)) - AssertFalse(session.activeSources.Has("F14")) - AssertFalse(session.repeatMappings.Has(mappingId)) + ; Release the source key: no further sends may occur within the repeat window + engine.OnSourceUp("F14") + Sleep 400 + AssertEq(1, CapturedSendKeys.Length) + + ; Re-pressing the source still triggers the mapping (nothing got stuck) + engine.OnSourceDown("F14") + AssertEq(2, CapturedSendKeys.Length) + AssertEq("^v", CapturedSendKeys[2]) + + engine.OnSourceUp("F14") + engine.OnModUp("RButton") + AssertEq("Idle", engine.GetSessionState("RButton")) } -Test_PathC_ModUp_DismissesContextMenuAfterGesture() { +Test_PathCEngine_ModUp_DismissesContextMenuAfterGesture() { + engine := PathCEngine() + engine.AddMapping(MakeMapping("RButton", "F13", "^c", 0, 300, 50, 1), "Cfg|1", "Cfg", "") + engine.OnModDown("RButton") EnableSendCapture() - session := PathC_GetSession("RButton") - session.state := "GestureActive" - session.isGesture := true - PathC_ModUpCallback("RButton") + ; Trigger a gesture so the session is marked as a gesture session + engine.OnSourceDown("F13") + AssertEq("GestureActive", engine.GetSessionState("RButton")) + + engine.OnModUp("RButton") WaitForCapturedSend("{Escape}", 400) - AssertEq("Idle", session.state) - AssertFalse(session.isGesture) + AssertEq("Idle", engine.GetSessionState("RButton")) +} + +Test_PathCEngine_ModDown_ResetsStaleSession() { + engine := PathCEngine() + engine.AddMapping(MakeMapping("RButton", "F13", "^c", 0, 300, 50, 1), "Cfg|1", "Cfg", "") + + ; Start a session and let it trigger a gesture + engine.OnModDown("RButton") + AssertEq("HeldNoCombo", engine.GetSessionState("RButton")) + engine.OnSourceDown("F13") + AssertEq("GestureActive", engine.GetSessionState("RButton")) + + ; Second ModDown without prior Up should cleanly restart the session + engine.OnModDown("RButton") + + AssertEq("HeldNoCombo", engine.GetSessionState("RButton")) + engine.OnModUp("RButton") +} + +Test_PathCEngine_CommitAndReset_RoundTrip() { + engine := PathCEngine() + engine.AddMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", "") + engine.AddMapping(MakeMapping("RAlt", "F23", "^x", 0, 300, 50, 1), "Cfg|2", "Cfg", "") + + ; Commit registers the routing hotkeys without errors + regErrors := engine.Commit() + AssertEq(0, regErrors.Length) + + ; Reset disables them and clears all mapping state + engine.Reset() + engine.OnModDown("RButton") + AssertFalse(engine.ShouldRouteWheel("WheelUp")) + AssertEq("HeldNoCombo", engine.GetSessionState("RButton")) + engine.OnModUp("RButton") + AssertEq("Idle", engine.GetSessionState("RButton")) +} + +Test_PathCEngine_Commit_ReportsRegErrors() { + engine := PathCEngine() + ; Invalid key names must fail registration and surface in the returned error list + engine.AddMapping(MakeMapping("RButton", "NotARealKey", "^c", 0, 300, 50, 1), "Cfg|1", "Cfg", "") + regErrors := engine.Commit() + + AssertEq(2, regErrors.Length) + AssertArrayContains(regErrors, "*NotARealKey") + AssertArrayContains(regErrors, "*NotARealKey Up") + + engine.Reset() } Test_DetectHotkeyConflicts_NoConflictForDisabledConfigs() { @@ -198,10 +293,10 @@ Test_DetectHotkeyConflicts_NoConflictForDisabledConfigs() { AllConfigs.Push(cfg1) AllConfigs.Push(cfg2) - DetectHotkeyConflicts() + conflicts := DetectHotkeyConflicts() ; Disabled config should not produce a conflict - AssertEq(0, HotkeyConflicts.Length) + AssertEq(0, conflicts.Length) } Test_DetectHotkeyConflicts_NoConflictForDisjointScopes() { @@ -212,30 +307,8 @@ Test_DetectHotkeyConflicts_NoConflictForDisjointScopes() { AllConfigs.Push(cfg1) AllConfigs.Push(cfg2) - DetectHotkeyConflicts() + conflicts := DetectHotkeyConflicts() ; Disjoint process scopes should not conflict - AssertEq(0, HotkeyConflicts.Length) -} - -Test_PathC_ModDown_ResetsStaleSession() { - ; Start a session, leaving it in non-Idle state - PathC_ModDownCallback("RButton") - session := PathC_GetSession("RButton") - AssertEq("HeldNoCombo", session.state) - - ; Mark session as having a gesture and active sources - session.state := "GestureActive" - session.isGesture := true - session.activeSources["F13"] := ["Cfg|1"] - - ; Second ModDown without prior Up should reset - PathC_ModDownCallback("RButton") - session := PathC_GetSession("RButton") - - ; Session should be cleanly restarted - AssertEq("HeldNoCombo", session.state) - AssertFalse(session.isGesture) - AssertEq(0, session.activeSources.Count) - AssertEq(0, session.repeatMappings.Count) + AssertEq(0, conflicts.Length) } diff --git a/tests/support/TestBase.ahk b/tests/support/TestBase.ahk index 7652dbd..2a629dd 100644 --- a/tests/support/TestBase.ahk +++ b/tests/support/TestBase.ahk @@ -97,15 +97,6 @@ ResetTestConfigDir() { ResetAppState() { global AllConfigs - global CurrentConfigName - global CurrentConfigFile - global CurrentProcessMode - global CurrentProcess - global CurrentProcessList - global CurrentExcludeProcess - global CurrentExcludeProcessList - global CurrentConfigEnabled - global Mappings global MainGui global ConfigDDL global EnabledCB @@ -115,6 +106,7 @@ ResetAppState() { global StatusDetailLink global StatusHasWarning global StatusDetailHovered + global LastReloadResult global BtnAddMapping global BtnEditMapping global BtnCopyMapping @@ -133,14 +125,8 @@ ResetAppState() { global HoldTimers global InterceptModKeys global AllProcessCheckers - global HotkeyConflicts - global HotkeyRegErrors - global PathCMappingByModSource - global PathCModSessions - global PathCModsUsed - global PathCSourceKeysUsed - global PathCWheelRoutePredicates global CaptureTarget + global CaptureOnCaptured global CaptureGui global CaptureDisplayText global CaptureTimer @@ -151,17 +137,10 @@ ResetAppState() { global ProcessPickerGui global CurrentLangCode global DispatchSendHook + global ForegroundProcessHook AllConfigs.Length := 0 - CurrentConfigName := "" - CurrentConfigFile := "" - CurrentProcessMode := "global" - CurrentProcess := "" - CurrentProcessList := [] - CurrentExcludeProcess := "" - CurrentExcludeProcessList := [] - CurrentConfigEnabled := true - Mappings.Length := 0 + ResetConfigStoreForTests() MainGui := "" ConfigDDL := "" @@ -172,6 +151,7 @@ ResetAppState() { StatusDetailLink := "" StatusHasWarning := false StatusDetailHovered := false + LastReloadResult := "" BtnAddMapping := "" BtnEditMapping := "" BtnCopyMapping := "" @@ -192,15 +172,9 @@ ResetAppState() { ClearMap(HoldTimers) ClearMap(InterceptModKeys) AllProcessCheckers.Length := 0 - HotkeyConflicts.Length := 0 - HotkeyRegErrors.Length := 0 - ClearMap(PathCMappingByModSource) - ClearMap(PathCModSessions) - ClearMap(PathCModsUsed) - ClearMap(PathCSourceKeysUsed) - PathCWheelRoutePredicates.Length := 0 CaptureTarget := "" + CaptureOnCaptured := "" CaptureGui := "" CaptureDisplayText := "" CaptureTimer := "" @@ -212,6 +186,7 @@ ResetAppState() { ProcessPickerGui := "" CurrentLangCode := "en-US" DispatchSendHook := "" + ForegroundProcessHook := "" } CleanupTestWindows() { @@ -387,29 +362,11 @@ AssertThrows(fn, message := "") { } MakeMapping(modifierKey, sourceKey, targetKey, holdRepeat := 0, repeatDelay := 300, repeatInterval := 50, passthroughMod := 0) { - mapping := Map() - mapping["ModifierKey"] := modifierKey - mapping["SourceKey"] := sourceKey - mapping["TargetKey"] := targetKey - mapping["HoldRepeat"] := holdRepeat - mapping["RepeatDelay"] := repeatDelay - mapping["RepeatInterval"] := repeatInterval - mapping["PassthroughMod"] := passthroughMod - return mapping + return Mapping.Make(modifierKey, sourceKey, targetKey, holdRepeat, repeatDelay, repeatInterval, passthroughMod) } BuildConfigRecord(configName, processMode := "global", process := "", excludeProcess := "", enabled := true, mappings := "") { - cfg := Map() - cfg["name"] := configName - cfg["file"] := CONFIG_DIR "\" configName ".ini" - cfg["processMode"] := processMode - cfg["process"] := process - cfg["processList"] := ParseProcessList(process) - cfg["excludeProcess"] := excludeProcess - cfg["excludeProcessList"] := ParseProcessList(excludeProcess) - cfg["enabled"] := enabled - cfg["mappings"] := mappings = "" ? [] : mappings - return cfg + return ConfigRecord.Make(configName, processMode, process, excludeProcess, enabled, mappings = "" ? [] : mappings) } SeedConfigFile(configName, processMode := "global", process := "", excludeProcess := "", mappings := "", enabled := 1) { @@ -421,15 +378,10 @@ SeedConfigFile(configName, processMode := "global", process := "", excludeProces IniWrite(excludeProcess, configFile, "Meta", "ExcludeProcess") if (mappings != "") { - for idx, mapping in mappings { + for idx, m in mappings { sectionName := "Mapping" idx - IniWrite(mapping["ModifierKey"], configFile, sectionName, "ModifierKey") - IniWrite(mapping["SourceKey"], configFile, sectionName, "SourceKey") - IniWrite(mapping["TargetKey"], configFile, sectionName, "TargetKey") - IniWrite(mapping["HoldRepeat"], configFile, sectionName, "HoldRepeat") - IniWrite(mapping["RepeatDelay"], configFile, sectionName, "RepeatDelay") - IniWrite(mapping["RepeatInterval"], configFile, sectionName, "RepeatInterval") - IniWrite(mapping["PassthroughMod"], configFile, sectionName, "PassthroughMod") + for iniKey, iniVal in Mapping.ToIniPairs(m) + IniWrite(iniVal, configFile, sectionName, iniKey) } } @@ -462,6 +414,12 @@ RecordCapturedSend(sendKey) { CapturedSendKeys.Push(sendKey) } +; Script the foreground process name seen by the scope-check path +SetForegroundProcess(name) { + global ForegroundProcessHook + ForegroundProcessHook := (*) => name +} + WaitForCondition(predicate, timeoutMs := 1000, pollIntervalMs := 25, failureMessage := "Timed out waiting for condition.") { startTick := A_TickCount while ((A_TickCount - startTick) <= timeoutMs) { diff --git a/tests/unit/keycapture_helpers.test.ahk b/tests/unit/keycapture_helpers.test.ahk index 3e88afa..229b687 100644 --- a/tests/unit/keycapture_helpers.test.ahk +++ b/tests/unit/keycapture_helpers.test.ahk @@ -14,6 +14,12 @@ RegisterTest("VkToDisplayName passes through unknown key names", Test_VkToDispla RegisterTest("IsModifierKey detects all 8 modifier keys and rejects non-modifiers", Test_IsModifierKey_DetectsAllModifiers) RegisterTest("ModifierToPrefix returns correct AHK prefix symbols", Test_ModifierToPrefix_ReturnsCorrectSymbols) RegisterTest("ModifierPrefixToKeyName reverses prefix to key name", Test_ModifierPrefixToKeyName_ReversesPrefix) +RegisterTest("BuildAhkKey folds modifiers with the main key", Test_BuildAhkKey_FoldsModifiersWithMainKey) +RegisterTest("BuildAhkKey modifier mode drops prefixes and keeps the main key", Test_BuildAhkKey_ModifierModeDropsPrefixes) +RegisterTest("BuildAhkKey falls back to ModifierPrefixToKeyName on modifier-only capture", Test_BuildAhkKey_ModifierOnlyFallsBack) +RegisterTest("BuildAhkKey combines held modifiers with wheel input", Test_BuildAhkKey_CombinesModifiersWithWheel) +RegisterTest("BuildAhkKey takes the first main key from a multi-key capture", Test_BuildAhkKey_MultiKeyKeepsFirstMainKey) +RegisterTest("BuildAhkKey returns empty string for an empty capture", Test_BuildAhkKey_EmptyCaptureReturnsEmpty) RunRegisteredTests() @@ -145,3 +151,50 @@ Test_ModifierPrefixToKeyName_ReversesPrefix() { AssertEq("LWin", ModifierPrefixToKeyName("^+!#")) AssertEq("Alt", ModifierPrefixToKeyName("^+!")) } + +Test_BuildAhkKey_FoldsModifiersWithMainKey() { + ; Source/target mode: prefixes fold in capture order before the main key + AssertEq("^A", BuildAhkKey(["^", "A"], "source")) + AssertEq("^+C", BuildAhkKey(["^", "+", "C"], "target")) + AssertEq("!F5", BuildAhkKey(["!", "F5"], "source")) + AssertEq("!F5", BuildAhkKey(["!", "F5"], "target")) + + ; A bare key without modifiers passes through unchanged + AssertEq("F13", BuildAhkKey(["F13"], "source")) +} + +Test_BuildAhkKey_ModifierModeDropsPrefixes() { + ; Modifier capture never prefixes the result with modifier symbols + AssertEq("A", BuildAhkKey(["^", "A"], "modifier")) + AssertEq("NumpadEnter", BuildAhkKey(["^", "+", "NumpadEnter"], "modifier")) +} + +Test_BuildAhkKey_ModifierOnlyFallsBack() { + ; Only modifiers pressed: fall back to a plain modifier key name + AssertEq("Ctrl", BuildAhkKey(["^"], "modifier")) + AssertEq("Shift", BuildAhkKey(["^", "+"], "source")) + AssertEq("Alt", BuildAhkKey(["^", "+", "!"], "target")) + AssertEq("LWin", BuildAhkKey(["^", "+", "!", "#"], "modifier")) +} + +Test_BuildAhkKey_CombinesModifiersWithWheel() { + ; Wheel immediate-confirm input folds like any other main key + AssertEq("^WheelUp", BuildAhkKey(["^", "WheelUp"], "source")) + AssertEq("+!WheelDown", BuildAhkKey(["+", "!", "WheelDown"], "target")) + + ; A bare wheel without held modifiers passes through unchanged + AssertEq("WheelUp", BuildAhkKey(["WheelUp"], "source")) +} + +Test_BuildAhkKey_MultiKeyKeepsFirstMainKey() { + ; Polling keeps the largest combo seen; folding takes its first main key + AssertEq("^A", BuildAhkKey(["^", "A", "S"], "source")) + AssertEq("A", BuildAhkKey(["A", "S", "D"], "modifier")) + AssertEq("+!X", BuildAhkKey(["+", "!", "X", "C"], "target")) +} + +Test_BuildAhkKey_EmptyCaptureReturnsEmpty() { + AssertEq("", BuildAhkKey([], "modifier")) + AssertEq("", BuildAhkKey([], "source")) + AssertEq("", BuildAhkKey([], "target")) +} diff --git a/tests/unit/schema.test.ahk b/tests/unit/schema.test.ahk new file mode 100644 index 0000000..56218aa --- /dev/null +++ b/tests/unit/schema.test.ahk @@ -0,0 +1,241 @@ +#Requires AutoHotkey v2.0 +#SingleInstance Force + +global __AHKM_TEST_MODE := true +global __AHKM_CONFIG_DIR := A_Temp "\AHKeyMapTests\" A_ScriptName "-" A_TickCount "\configs" + +#Include "..\..\src\AHKeyMap.ahk" +#Include "..\support\TestBase.ahk" + +CurrentLangCode := "en-US" + +RegisterTest("Mapping.Make applies defaults, coercion, and clamping", Test_MappingMake_AppliesDefaultsCoercionAndClamping) +RegisterTest("Mapping.Normalize whitelists keys and fills defaults in place", Test_MappingNormalize_WhitelistsKeysAndFillsDefaults) +RegisterTest("Mapping.Normalize clamps sub-minimum repeat timing", Test_MappingNormalize_ClampsSubMinimumRepeatTiming) +RegisterTest("Mapping.Normalize coerces string flags and numeric fields", Test_MappingNormalize_CoercesStringFlagsAndNumericFields) +RegisterTest("Mapping.ClassifyPath truth table covers all three paths", Test_ClassifyPath_CoversAllThreePaths) +RegisterTest("Mapping.HotkeyStringFor derives per-path hotkey strings", Test_HotkeyStringFor_DerivesPerPathStrings) +RegisterTest("Mapping.ToIniPairs lists exactly the seven schema fields", Test_ToIniPairs_ListsExactlySevenFields) +RegisterTest("Mapping.ToIniPairs round-trips through LoadConfigData", Test_ToIniPairs_RoundTripsThroughLoadConfigData) +RegisterTest("LoadConfigData clamps hand-edited sub-minimum timing at load", Test_LoadConfigData_ClampsHandEditedTimingAtLoad) +RegisterTest("ConfigRecord.Make owns record shape, derived lists, and file path", Test_ConfigRecordMake_OwnsShapeListsAndFilePath) +RegisterTest("ConfigStore re-normalizes mappings at the store boundary", Test_ConfigStore_RenormalizesAtBoundary) + +RunRegisteredTests() + +Test_MappingMake_AppliesDefaultsCoercionAndClamping() { + ; All defaults: empty timing falls back to DEFAULT_REPEAT_* + m := Mapping.Make("", "F13", "^c") + AssertEq(0, m["HoldRepeat"]) + AssertEq(DEFAULT_REPEAT_DELAY, m["RepeatDelay"]) + AssertEq(DEFAULT_REPEAT_INTERVAL, m["RepeatInterval"]) + AssertEq(0, m["PassthroughMod"]) + + ; Explicit values are Integer()-coerced and preserved + m := Mapping.Make("RAlt", "F13", "^c", 1, 120, 40, 1) + AssertEq(1, m["HoldRepeat"]) + AssertEq(120, m["RepeatDelay"]) + AssertEq(40, m["RepeatInterval"]) + AssertEq(1, m["PassthroughMod"]) + + ; String inputs from INI readers are coerced too + m := Mapping.Make("", "F13", "^c", "1", "250", "30", "0") + AssertEq(1, m["HoldRepeat"]) + AssertEq(250, m["RepeatDelay"]) + AssertEq(30, m["RepeatInterval"]) + AssertEq(0, m["PassthroughMod"]) + + ; Sub-minimum timing is clamped to the schema minimum + m := Mapping.Make("", "F13", "^c", 1, 1, 5) + AssertEq(Mapping.MIN_REPEAT_TIMING, m["RepeatDelay"]) + AssertEq(Mapping.MIN_REPEAT_TIMING, m["RepeatInterval"]) +} + +Test_MappingNormalize_WhitelistsKeysAndFillsDefaults() { + m := Map() + m["SourceKey"] := "F13" + m["ExtraKey"] := "stray" + m["AnotherExtra"] := 123 + + Mapping.Normalize(m) + + ; Extra keys are dropped, missing keys are filled with defaults + AssertFalse(m.Has("ExtraKey"), "ExtraKey should be dropped by the whitelist.") + AssertFalse(m.Has("AnotherExtra"), "AnotherExtra should be dropped by the whitelist.") + AssertEq(7, m.Count) + AssertEq("", m["ModifierKey"]) + AssertEq("F13", m["SourceKey"]) + AssertEq("", m["TargetKey"]) + AssertEq(0, m["HoldRepeat"]) + AssertEq(DEFAULT_REPEAT_DELAY, m["RepeatDelay"]) + AssertEq(DEFAULT_REPEAT_INTERVAL, m["RepeatInterval"]) + AssertEq(0, m["PassthroughMod"]) +} + +Test_MappingNormalize_ClampsSubMinimumRepeatTiming() { + m := Map() + m["ModifierKey"] := "" + m["SourceKey"] := "F13" + m["TargetKey"] := "^c" + m["HoldRepeat"] := 1 + m["RepeatDelay"] := 5 + m["RepeatInterval"] := 9 + m["PassthroughMod"] := 0 + + Mapping.Normalize(m) + + AssertEq(10, m["RepeatDelay"]) + AssertEq(10, m["RepeatInterval"]) +} + +Test_MappingNormalize_CoercesStringFlagsAndNumericFields() { + m := Map() + m["ModifierKey"] := "CapsLock" + m["SourceKey"] := "F13" + m["TargetKey"] := "^c" + m["HoldRepeat"] := "1" + m["RepeatDelay"] := "100" + m["RepeatInterval"] := "20" + m["PassthroughMod"] := "1" + + Mapping.Normalize(m) + + AssertEq(1, m["HoldRepeat"]) + AssertEq(100, m["RepeatDelay"]) + AssertEq(20, m["RepeatInterval"]) + AssertEq(1, m["PassthroughMod"]) +} + +Test_ClassifyPath_CoversAllThreePaths() { + ; No modifier -> Path A regardless of other fields + AssertEq(Mapping.PATH_A, Mapping.ClassifyPath(Mapping.Make("", "F13", "^c", 1, 120, 40))) + AssertEq("A", Mapping.PATH_A) + + ; Modifier with PassthroughMod=0 -> Path B + AssertEq(Mapping.PATH_B, Mapping.ClassifyPath(Mapping.Make("CapsLock", "F13", "^c", 1, 120, 40, 0))) + AssertEq("B", Mapping.PATH_B) + + ; Modifier with PassthroughMod=1 -> Path C + AssertEq(Mapping.PATH_C, Mapping.ClassifyPath(Mapping.Make("RButton", "WheelUp", "^Tab", 0, 300, 50, 1))) + AssertEq("C", Mapping.PATH_C) + + ; Modifier with default PassthroughMod (0) -> Path B + AssertEq(Mapping.PATH_B, Mapping.ClassifyPath(Mapping.Make("RAlt", "F14", "^v"))) +} + +Test_HotkeyStringFor_DerivesPerPathStrings() { + ; Path A: the bare source key + AssertEq("F13", Mapping.HotkeyStringFor(Mapping.Make("", "F13", "^c"))) + + ; Path B: intercept combo "mod & source" + AssertEq("CapsLock & F13", Mapping.HotkeyStringFor(Mapping.Make("CapsLock", "F13", "^c"))) + + ; Path C: passthrough combo "~mod+source" + AssertEq("~RButton+WheelUp", Mapping.HotkeyStringFor(Mapping.Make("RButton", "WheelUp", "^Tab", 0, 300, 50, 1))) +} + +Test_ToIniPairs_ListsExactlySevenFields() { + pairs := Mapping.ToIniPairs(Mapping.Make("RAlt", "F13", "^c", 1, 120, 40, 1)) + + AssertEq(7, pairs.Count) + AssertEq("RAlt", pairs["ModifierKey"]) + AssertEq("F13", pairs["SourceKey"]) + AssertEq("^c", pairs["TargetKey"]) + AssertEq("1", pairs["HoldRepeat"]) + AssertEq("120", pairs["RepeatDelay"]) + AssertEq("40", pairs["RepeatInterval"]) + AssertEq("1", pairs["PassthroughMod"]) +} + +Test_ToIniPairs_RoundTripsThroughLoadConfigData() { + mappings := [ + Mapping.Make("", "F13", "^c"), + Mapping.Make("CapsLock", "F14", "^v", 0, 300, 50, 0), + Mapping.Make("RAlt", "F16", "^d", 1, 200, 40, 1) + ] + + ; Seed the INI file through the same field list SaveConfig uses + configFile := CONFIG_DIR "\RoundTrip.ini" + IniWrite("RoundTrip", configFile, "Meta", "Name") + IniWrite("global", configFile, "Meta", "ProcessMode") + IniWrite("", configFile, "Meta", "Process") + IniWrite("", configFile, "Meta", "ExcludeProcess") + for idx, m in mappings { + pairs := "" + for iniKey, iniVal in Mapping.ToIniPairs(m) { + if (pairs != "") + pairs .= "`n" + pairs .= iniKey "=" iniVal + } + IniWrite(pairs, configFile, "Mapping" idx) + } + + loaded := LoadConfigData("RoundTrip") + + AssertEq(3, loaded["mappings"].Length) + AssertEq("F13", loaded["mappings"][1]["SourceKey"]) + AssertEq("", loaded["mappings"][1]["ModifierKey"]) + AssertEq(0, loaded["mappings"][1]["HoldRepeat"]) + AssertEq("CapsLock", loaded["mappings"][2]["ModifierKey"]) + AssertEq("RAlt", loaded["mappings"][3]["ModifierKey"]) + AssertEq(1, loaded["mappings"][3]["HoldRepeat"]) + AssertEq(200, loaded["mappings"][3]["RepeatDelay"]) + AssertEq(40, loaded["mappings"][3]["RepeatInterval"]) + AssertEq(1, loaded["mappings"][3]["PassthroughMod"]) +} + +Test_LoadConfigData_ClampsHandEditedTimingAtLoad() { + ; Hand-edited INI with sub-minimum timing: the load path now clamps it + SeedConfigFile("HandEdited", "global", "", "", [MakeMapping("RAlt", "F13", "^c", 1, 5, 8, 1)]) + + loaded := LoadConfigData("HandEdited") + + AssertEq(1, loaded["mappings"].Length) + AssertEq(10, loaded["mappings"][1]["RepeatDelay"]) + AssertEq(10, loaded["mappings"][1]["RepeatInterval"]) +} + +Test_ConfigRecordMake_OwnsShapeListsAndFilePath() { + cfg := ConfigRecord.Make("MyCfg", "include", "Code.exe|notepad.exe", "chrome.exe", false, []) + + AssertEq(CONFIG_DIR "\MyCfg.ini", cfg["file"]) + AssertEq("MyCfg", cfg["name"]) + AssertEq("include", cfg["processMode"]) + AssertEq("Code.exe|notepad.exe", cfg["process"]) + AssertEq(2, cfg["processList"].Length) + AssertEq("notepad.exe", cfg["processList"][2]) + AssertEq("chrome.exe", cfg["excludeProcess"]) + AssertEq(1, cfg["excludeProcessList"].Length) + AssertFalse(cfg["enabled"]) + AssertEq(0, cfg["mappings"].Length) +} + +Test_ConfigStore_RenormalizesAtBoundary() { + store := ConfigStore.Instance + + SeedConfigFile("Boundary", "global", "", "", [], 1) + LoadAllConfigs() + store.Select("Boundary") + + ; A mapping that violates the invariants (extra key, sub-minimum timing) + m := Map() + m["ModifierKey"] := "RAlt" + m["SourceKey"] := "F13" + m["TargetKey"] := "^c" + m["HoldRepeat"] := "1" + m["RepeatDelay"] := 5 + m["RepeatInterval"] := 20 + m["PassthroughMod"] := "0" + m["ExtraKey"] := "stray" + + store.AddMapping(m) + + stored := store.SelectedMappings()[1] + AssertFalse(stored.Has("ExtraKey"), "Extra keys should be dropped at the store boundary.") + AssertEq(1, stored["HoldRepeat"]) + AssertEq(10, stored["RepeatDelay"]) + AssertEq(20, stored["RepeatInterval"]) + + ; The persisted file carries the normalized values + AssertEq("10", ReadConfigValue("Boundary", "Mapping1", "RepeatDelay")) +} diff --git a/tests/unit/scope_logic.test.ahk b/tests/unit/scope_logic.test.ahk index e5c90e8..32df168 100644 --- a/tests/unit/scope_logic.test.ahk +++ b/tests/unit/scope_logic.test.ahk @@ -14,6 +14,10 @@ RegisterTest("ProcessListContains compares process names consistently", Test_Pro RegisterTest("IncludeScopesOverlap detects list intersections", Test_IncludeScopesOverlap_DetectsIntersections) RegisterTest("IncludeVsExcludeOverlap only overlaps on non-excluded targets", Test_IncludeVsExcludeOverlap_UsesIntersectionRules) RegisterTest("ScopesOverlap covers include, exclude, and global combinations", Test_ScopesOverlap_CoversPriorityCases) +RegisterTest("GetForegroundProcessName consults the foreground hook first", Test_GetForegroundProcessName_ConsultsHookFirst) +RegisterTest("CheckIncludeMatch matches only listed foreground processes", Test_CheckIncludeMatch_UsesForegroundProcessHook) +RegisterTest("CheckExcludeMatch deactivates only for excluded foreground processes", Test_CheckExcludeMatch_UsesForegroundProcessHook) +RegisterTest("MakeProcessChecker gates include and exclude scopes through the foreground query", Test_MakeProcessChecker_ResolvesScopesThroughHook) RunRegisteredTests() @@ -47,3 +51,67 @@ Test_ScopesOverlap_CoversPriorityCases() { AssertFalse(ScopesOverlap("include", "code.exe", "include", "notepad.exe")) AssertTrue(ScopesOverlap("exclude", "chrome.exe", "exclude", "code.exe")) } + +Test_GetForegroundProcessName_ConsultsHookFirst() { + SetForegroundProcess(" Scripted.EXE ") + + ; The scripted name replaces the OS query and is normalized like a real one + AssertEq("scripted.exe", GetForegroundProcessName()) +} + +Test_CheckIncludeMatch_UsesForegroundProcessHook() { + procList := ["Code.exe", "notepad.exe"] + + ; Match: foreground process is in the include list (case-insensitive) + SetForegroundProcess("code.EXE") + AssertTrue(CheckIncludeMatch(procList)) + + ; No match: foreground process is not in the include list + SetForegroundProcess("msedge.exe") + AssertFalse(CheckIncludeMatch(procList)) + + ; No match: unknown/empty foreground process never satisfies include scope + SetForegroundProcess("") + AssertFalse(CheckIncludeMatch(procList)) +} + +Test_CheckExcludeMatch_UsesForegroundProcessHook() { + exclList := ["Code.exe", "notepad.exe"] + + ; Active: foreground process is not excluded + SetForegroundProcess("msedge.exe") + AssertTrue(CheckExcludeMatch(exclList)) + + ; Inactive: foreground process is in the exclude list (case-insensitive) + SetForegroundProcess("NOTEPAD.EXE") + AssertFalse(CheckExcludeMatch(exclList)) + + ; Inactive: unknown/empty foreground process fails the guard + SetForegroundProcess("") + AssertFalse(CheckExcludeMatch(exclList)) +} + +Test_MakeProcessChecker_ResolvesScopesThroughHook() { + includeCfg := BuildConfigRecord("IncludeCfg", "include", "chrome.exe|code.exe") + includeChecker := MakeProcessChecker(includeCfg) + AssertTrue(includeChecker != "") + + SetForegroundProcess("Code.EXE") + AssertTrue(includeChecker.Call()) + SetForegroundProcess("notepad.exe") + AssertFalse(includeChecker.Call()) + + excludeCfg := BuildConfigRecord("ExcludeCfg", "exclude", "", "chrome.exe|code.exe") + excludeChecker := MakeProcessChecker(excludeCfg) + AssertTrue(excludeChecker != "") + + SetForegroundProcess("chrome.exe") + AssertFalse(excludeChecker.Call()) + SetForegroundProcess("notepad.exe") + AssertTrue(excludeChecker.Call()) + + ; Empty lists and global mode produce no checker (effectively global scope) + AssertEq("", MakeProcessChecker(BuildConfigRecord("EmptyInclude", "include", ""))) + AssertEq("", MakeProcessChecker(BuildConfigRecord("EmptyExclude", "exclude", "", ""))) + AssertEq("", MakeProcessChecker(BuildConfigRecord("GlobalCfg", "global"))) +} diff --git a/tests/unit/view_models.test.ahk b/tests/unit/view_models.test.ahk new file mode 100644 index 0000000..31bfdf4 --- /dev/null +++ b/tests/unit/view_models.test.ahk @@ -0,0 +1,242 @@ +#Requires AutoHotkey v2.0 +#SingleInstance Force + +global __AHKM_TEST_MODE := true +global __AHKM_CONFIG_DIR := A_Temp "\AHKeyMapTests\" A_ScriptName "-" A_TickCount "\configs" + +#Include "..\..\src\AHKeyMap.ahk" +#Include "..\support\TestBase.ahk" + +CurrentLangCode := "en-US" + +RegisterTest("BuildStatusSummary reports enabled counts without warnings", Test_BuildStatusSummary_PlainCounts) +RegisterTest("BuildStatusSummary appends warning suffixes for conflicts and reg errors", Test_BuildStatusSummary_WarningSuffixes) +RegisterTest("BuildStatusSummary handles an empty reload result", Test_BuildStatusSummary_EmptyResult) +RegisterTest("BuildMappingRows formats modifier, passthrough mode, and timing columns", Test_BuildMappingRows_ColumnFormatting) +RegisterTest("BuildMappingRows leaves timing blank for non-hold mappings", Test_BuildMappingRows_BlankTimingWithoutHold) +RegisterTest("BuildMappingRows translates key names for display", Test_BuildMappingRows_KeyDisplay) +RegisterTest("BuildStatusDetails lists conflicts and registration errors", Test_BuildStatusDetails_ListsIssues) +RegisterTest("BuildStatusDetails returns empty when there is nothing to report", Test_BuildStatusDetails_Empty) +RegisterTest("FormatProcessDisplay uses localized summaries", Test_FormatProcessDisplay_LocalizedSummaries) +RegisterTest("ConfigStore notifies OnChanged with the reload result through the chokepoint", Test_ConfigStore_OnChanged_ReceivesReloadResult) +RegisterTest("ConfigStore Select notifies render-only without a reload result", Test_ConfigStore_Select_NotifiesRenderOnly) +RegisterTest("ConfigStore stays silent headless when nothing is registered", Test_ConfigStore_Headless_NoRegistrationNeeded) +RegisterTest("ConfigStore Select never re-enters a safe OnChanged subscriber", Test_ConfigStore_Select_NoRecursionIntoSubscriber) + +RunRegisteredTests() + +MakeReloadResult(conflictCount := 0, regErrorCount := 0) { + conflicts := [] + loop conflictCount + conflicts.Push({ hotkey: "F13", config1: "A", idx1: 1, config2: "B", idx2: 1 }) + regErrors := [] + loop regErrorCount + regErrors.Push("BadKey" A_Index) + return { conflicts: conflicts, regErrors: regErrors } +} + +Test_BuildStatusSummary_PlainCounts() { + configs := [ + BuildConfigRecord("A", "global", "", "", true, []), + BuildConfigRecord("B", "global", "", "", false, []), + BuildConfigRecord("C", "global", "", "", true, []) + ] + + vm := BuildStatusSummary(configs, MakeReloadResult()) + + AssertEq("Enabled 2/3", vm.text) + AssertFalse(vm.hasWarning) +} + +Test_BuildStatusSummary_WarningSuffixes() { + configs := [BuildConfigRecord("A", "global", "", "", true, [])] + + vm := BuildStatusSummary(configs, MakeReloadResult(2, 1)) + + AssertEq("Enabled 1/1 ⚠ 2 hotkey conflicts ⚠ 1 hotkey registration errors", vm.text) + AssertTrue(vm.hasWarning) +} + +Test_BuildStatusSummary_EmptyResult() { + configs := [] + + vm := BuildStatusSummary(configs, "") + + AssertEq("Enabled 0/0", vm.text) + AssertFalse(vm.hasWarning) +} + +Test_BuildMappingRows_ColumnFormatting() { + mappings := [ + MakeMapping("RAlt", "F13", "^c", 1, 120, 40, 1) + ] + + rows := BuildMappingRows(mappings) + + AssertEq(1, rows.Length) + AssertEq(1, rows[1].idx) + AssertEq("RAlt", rows[1].modifier) + AssertEq("F13", rows[1].source) + AssertEq("Ctrl+c", rows[1].target) + AssertEq("Yes", rows[1].hold) + AssertEq("Pass-through", rows[1].mode) + AssertEq(120, rows[1].delay) + AssertEq(40, rows[1].interval) +} + +Test_BuildMappingRows_BlankTimingWithoutHold() { + mappings := [ + MakeMapping("", "F13", "^c", 0, 300, 50, 0) + ] + + rows := BuildMappingRows(mappings) + + AssertEq("", rows[1].modifier) + AssertEq("No", rows[1].hold) + AssertEq("", rows[1].mode) + AssertEq("", rows[1].delay) + AssertEq("", rows[1].interval) +} + +Test_BuildMappingRows_KeyDisplay() { + mappings := [ + MakeMapping("CapsLock", "WheelUp", "^+!#a", 0, 300, 50, 0) + ] + + rows := BuildMappingRows(mappings) + + AssertEq("CapsLock", rows[1].modifier) + AssertEq("WheelUp", rows[1].source) + AssertEq("Ctrl+Shift+Alt+Win+a", rows[1].target) + AssertEq("Intercept", rows[1].mode) +} + +Test_BuildStatusDetails_ListsIssues() { + result := MakeReloadResult(1, 2) + result.conflicts[1].config2 := "IncludeCfg" + + details := BuildStatusDetails(result) + + AssertContains(details, "F13") + AssertContains(details, "BadKey1") + AssertContains(details, "BadKey2") + AssertTrue(InStr(details, "`n") > 0, "Conflicts and reg errors should be separated by a newline.") +} + +Test_BuildStatusDetails_Empty() { + AssertEq("", BuildStatusDetails(MakeReloadResult())) + AssertEq("", BuildStatusDetails("")) +} + +Test_FormatProcessDisplay_LocalizedSummaries() { + AssertEq("Scope: Global", FormatProcessDisplay("global", [], [])) + AssertEq("Scope: Only notepad.exe", FormatProcessDisplay("include", ["notepad.exe"], [])) + AssertEq("Scope: Only notepad.exe and 1 more", FormatProcessDisplay("include", ["notepad.exe", "code.exe"], [])) + AssertEq("Scope: Exclude notepad.exe", FormatProcessDisplay("exclude", [], ["notepad.exe"])) + AssertEq("Scope: Exclude notepad.exe and 2 more", FormatProcessDisplay("exclude", [], ["notepad.exe", "code.exe", "devenv.exe"])) + AssertEq("Scope: Global", FormatProcessDisplay("include", [], [])) +} + +Test_ConfigStore_OnChanged_ReceivesReloadResult() { + store := ConfigStore.Instance + notifications := [] + + SeedConfigFile("NotifyCfg", "global", "", "", [], 1) + LoadAllConfigs() + store.Select("NotifyCfg") + + store.SetOnChanged((reloadResult) => notifications.Push(reloadResult)) + store.AddMapping(MakeMapping("", "F13", "^c")) + + ; One mutation -> exactly one notification carrying the reload result + AssertEq(1, notifications.Length) + AssertTrue(IsObject(notifications[1]), "Chokepoint notification should carry the ReloadAllHotkeys result.") + AssertEq(0, notifications[1].regErrors.Length) + + store.SetOnChanged("") +} + +Test_ConfigStore_Select_NotifiesRenderOnly() { + store := ConfigStore.Instance + notifications := [] + + SeedConfigFile("RenderOnly", "global", "", "", [], 1) + SeedConfigFile("Other", "global", "", "", [], 1) + LoadAllConfigs() + + store.SetOnChanged((reloadResult) => notifications.Push(reloadResult)) + store.Select("RenderOnly") + + AssertEq(1, notifications.Length) + AssertEq("", notifications[1], "Selection should notify render-only (no reload result).") + AssertEq("RenderOnly", store.SelectedName) + AssertEq("RenderOnly", ReadStateValue("State", "LastConfig")) + + store.SetOnChanged("") +} + +Test_ConfigStore_Headless_NoRegistrationNeeded() { + store := ConfigStore.Instance + + SeedConfigFile("HeadlessCfg", "global", "", "", [], 1) + LoadAllConfigs() + store.Select("HeadlessCfg") + + ; Headless: no OnChanged registration, mutations still complete fully + store.SetEnabled(true) + store.AddMapping(MakeMapping("", "F13", "^c")) + + AssertEq("HeadlessCfg", store.SelectedName) + AssertEq(1, store.SelectedMappings().Length) + AssertEq("F13", ReadConfigValue("HeadlessCfg", "Mapping1", "SourceKey")) + AssertEq("1", ReadStateValue("EnabledConfigs", "HeadlessCfg")) +} + +; Pins the render-seam no-recursion invariant headlessly: a subscriber must +; not be re-entered by Select (GUI RenderFromState reads state and never +; mutates the store; this callback simulates that by only reading). Each +; Select call must notify exactly once, including via CreateConfig/CopyConfig +; and the delete re-select, otherwise the GUI would recurse +; Select -> NotifyChanged -> Render -> Select. +Test_ConfigStore_Select_NoRecursionIntoSubscriber() { + store := ConfigStore.Instance + notifications := 0 + + SeedConfigFile("RecursionCfg", "global", "", "", [], 1) + SeedConfigFile("RecursionOther", "global", "", "", [], 1) + LoadAllConfigs() + + store.SetOnChanged((reloadResult) => notifications++) + + ; Same-name select still notifies exactly once (Select is not idempotent- + ; silent), and re-selecting an existing name must not loop + store.Select("RecursionCfg") + AssertEq(1, notifications) + store.Select("RecursionCfg") + AssertEq(2, notifications) + + ; Selecting a name that is not on disk notifies once and clears selection + store.Select("MissingConfig") + AssertEq(3, notifications) + AssertEq("", store.SelectedName) + + ; File-level mutation: one chokepoint notification + one Select notification + notifications := 0 + store.CreateConfig("RecursionCreated", "global", "") + AssertEq(2, notifications) + AssertEq("RecursionCreated", store.SelectedName) + + ; Copy: chokepoint + Select again (exactly two notifications) + notifications := 0 + store.CopyConfig("RecursionCopied") + AssertEq(2, notifications) + AssertEq("RecursionCopied", store.SelectedName) + + ; Delete: chokepoint notification + one re-select notification + notifications := 0 + store.DeleteConfig() + AssertEq(2, notifications) + AssertNotEq("", store.SelectedName, "Deleting with configs remaining should re-select a valid config.") + + store.SetOnChanged("") +}