From 4e16b2e07234a689e1777f509a8e32f0615db4d8 Mon Sep 17 00:00:00 2001 From: json_born Date: Wed, 2 Sep 2026 02:24:21 +0800 Subject: [PATCH 01/16] test: inject foreground-process query seam --- src/AHKeyMap.ahk | 5 +- src/core/HotkeyEngine.ahk | 6 ++ .../integration/hotkey_engine_state.test.ahk | 8 ++- tests/support/TestBase.ahk | 8 +++ tests/unit/scope_logic.test.ahk | 68 +++++++++++++++++++ 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/AHKeyMap.ahk b/src/AHKeyMap.ahk index 143412e..1cf7c86 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.4 ;@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.4" 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") @@ -91,6 +91,7 @@ global PathCModsUsed := Map() global PathCSourceKeysUsed := Map() global PathCWheelRoutePredicates := [] global DispatchSendHook := "" +global ForegroundProcessHook := "" ; Key capture globals global IsCapturing := false diff --git a/src/core/HotkeyEngine.ahk b/src/core/HotkeyEngine.ahk index 841708c..9cbd47c 100644 --- a/src/core/HotkeyEngine.ahk +++ b/src/core/HotkeyEngine.ahk @@ -16,6 +16,7 @@ global PathCModSessions global PathCModsUsed global PathCSourceKeysUsed global PathCWheelRoutePredicates +global ForegroundProcessHook global CONTEXT_MENU_DISMISS_DELAY ; ============================================================================ @@ -45,6 +46,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 diff --git a/tests/integration/hotkey_engine_state.test.ahk b/tests/integration/hotkey_engine_state.test.ahk index 4acc09b..9512b30 100644 --- a/tests/integration/hotkey_engine_state.test.ahk +++ b/tests/integration/hotkey_engine_state.test.ahk @@ -103,18 +103,22 @@ Test_PathC_ShouldRouteWheelSource_FalseWithoutSession() { } Test_PathC_ShouldRouteWheelSource_FalseWhenScopeDoesNotMatch() { - checker := (*) => false + cfg := BuildConfigRecord("Cfg", "include", "notepad.exe") + checker := MakeProcessChecker(cfg) RegisterPathCMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", checker) PathC_ModDownCallback("RButton") + SetForegroundProcess("msedge.exe") AssertFalse(PathC_ShouldRouteWheelSource("WheelUp")) AssertFalse(PathC_ShouldRouteWheelSource("WheelUp", "*WheelUp")) } Test_PathC_ShouldRouteWheelSource_TrueWhenSessionAndScopeMatch() { - checker := (*) => true + cfg := BuildConfigRecord("Cfg", "include", "notepad.exe") + checker := MakeProcessChecker(cfg) RegisterPathCMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", checker) PathC_ModDownCallback("RButton") + SetForegroundProcess("notepad.exe") AssertTrue(PathC_ShouldRouteWheelSource("WheelUp")) AssertTrue(PathC_ShouldRouteWheelSource("WheelUp", "*WheelUp")) diff --git a/tests/support/TestBase.ahk b/tests/support/TestBase.ahk index 7652dbd..538d4b6 100644 --- a/tests/support/TestBase.ahk +++ b/tests/support/TestBase.ahk @@ -151,6 +151,7 @@ ResetAppState() { global ProcessPickerGui global CurrentLangCode global DispatchSendHook + global ForegroundProcessHook AllConfigs.Length := 0 CurrentConfigName := "" @@ -212,6 +213,7 @@ ResetAppState() { ProcessPickerGui := "" CurrentLangCode := "en-US" DispatchSendHook := "" + ForegroundProcessHook := "" } CleanupTestWindows() { @@ -462,6 +464,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/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"))) +} From 9087bc94fb8aaa722cf8bae7151cc2b041ee6a36 Mon Sep 17 00:00:00 2001 From: json_born Date: Wed, 2 Sep 2026 02:54:39 +0800 Subject: [PATCH 02/16] refactor: deepen the Path C engine into a class module Move Path C (passthrough combos) from five script globals plus scattered callbacks in HotkeyEngine.ahk into a new deep module src/core/PathCEngine.ahk: - class PathCEngine with a 9-member public interface: AddMapping, Commit, Reset, OnModDown, OnModUp, OnSourceDown, OnSourceUp, ShouldRouteWheel, GetSessionState; production access via lazy static PathCEngine.Instance - two-phase registration: AddMapping during the config loop, Commit after it (only Commit registers hotkeys), Reset disables engine-owned hotkeys - PathCSession class with constructor-enforced shape; STATE_* constants replace the Idle/HeldNoCombo/GestureActive string literals - engine owns its repeat timers; RepeatTimerCallback loses the optional modKey parameter and serves Path A/B only - behavior parity for wheel routing (BUG-016), raw-key fallback (BUG-009/013), long-press stop (BUG-004), and RButton gesture menu dismissal (BUG-015); registration failures still append to the shared HotkeyRegErrors global - rewrite Path C integration tests against the public interface and drop the Path C state mirror from TestBase.ResetAppState - update AGENTS.md repo map/include order and architecture.md references; bump version to 2.9.3 --- AGENTS.md | 15 +- docs/architecture.md | 15 +- src/AHKeyMap.ahk | 10 +- src/core/HotkeyEngine.ahk | 334 +------------ src/core/PathCEngine.ahk | 452 ++++++++++++++++++ .../integration/hotkey_engine_state.test.ahk | 295 +++++++----- tests/support/TestBase.ahk | 10 - 7 files changed, 659 insertions(+), 472 deletions(-) create mode 100644 src/core/PathCEngine.ahk diff --git a/AGENTS.md b/AGENTS.md index 5a9efb3..8fa974f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,8 @@ Audience: coding agents working on AHKeyMap. src/AHKeyMap.ahk — globals, constants, #Include list, StartApp() src/core/Config.ahk — config/state INI I/O and atomic writes 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 @@ -83,11 +84,12 @@ AutoHotkey64.exe /ErrorStdOut=UTF-8 tests\unit\scope_logic.test.ahk 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` + 4. `core/PathCEngine.ahk` + 5. `core/HotkeyEngine.ahk` + 6. `core/KeyCapture.ahk` + 7. `ui/GuiMain.ahk` + 8. `ui/MappingEditor.ahk` + 9. `ui/GuiEvents.ahk` - Only `src/AHKeyMap.ahk` initializes globals with `:=`; other modules may declare `global VarName` but must not reinitialize shared state. ## Code style @@ -131,6 +133,7 @@ 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. diff --git a/docs/architecture.md b/docs/architecture.md index 84e0496..beddfb6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,8 @@ - `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`;冲突检测包含跨路径 B/C 修饰键冲突 +- `src/core/PathCEngine.ahk`:路径 C 透传组合引擎(会话状态机、统一事件路由、自有的 repeat 定时器与滚轮路由,以及路由热键的自注册/自卸载;入口为惰性单例 `PathCEngine.Instance`) - `src/shared/Utils.ahk`:按键显示转换、进程选择器、自启功能 ## 全局变量管理 @@ -81,20 +82,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` 透传负责。 diff --git a/src/AHKeyMap.ahk b/src/AHKeyMap.ahk index 143412e..3330676 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.3 ;@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.3" 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") @@ -85,11 +85,6 @@ 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 := "" ; Key capture globals @@ -112,6 +107,7 @@ global ProcessPickerGui := "" #Include "core/Config.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" diff --git a/src/core/HotkeyEngine.ahk b/src/core/HotkeyEngine.ahk index 841708c..1d637d3 100644 --- a/src/core/HotkeyEngine.ahk +++ b/src/core/HotkeyEngine.ahk @@ -11,12 +11,6 @@ global InterceptModKeys global AllProcessCheckers global HotkeyConflicts global HotkeyRegErrors -global PathCMappingByModSource -global PathCModSessions -global PathCModsUsed -global PathCSourceKeysUsed -global PathCWheelRoutePredicates -global CONTEXT_MENU_DISMISS_DELAY ; ============================================================================ ; Hotkey engine core @@ -84,16 +78,6 @@ 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") -} - MakeActiveHotkeyRecord(checker := "", configName := "", key := "", keyUp := "") { return { checker: checker, @@ -144,11 +128,9 @@ UnregisterAllHotkeys() { 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 { @@ -202,7 +184,7 @@ ReloadAllHotkeys() { RegisterConfigHotkeys(cfg) ; Register shared routing hotkeys for all Path C mappings - RegisterAllPathCHotkeys() + PathCEngine.Instance.Commit() HotIf() @@ -505,7 +487,7 @@ RegisterMapping(mapping, useCustomHotIf, checker, uniqueIdx, configName) { ; Path C: stateful passthrough, handled by Path C engine instead of direct target callback HotIf() - RegisterPathCMapping(mapping, uniqueIdx, configName, checker) + PathCEngine.Instance.AddMapping(mapping, uniqueIdx, configName, checker) } ; Path A: no modifier, directly map sourceKey -> targetKey @@ -573,36 +555,6 @@ RegisterPathB(mapping, hkInfo, uniqueIdx, checker, configName) { } } -; 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 +592,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 +616,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/PathCEngine.ahk b/src/core/PathCEngine.ahk new file mode 100644 index 0000000..847ced0 --- /dev/null +++ b/src/core/PathCEngine.ahk @@ -0,0 +1,452 @@ +; ============================================================================ +; 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 +global HotkeyRegErrors + +; 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) + AddMapping(mapping, id, configName, checker) { + modKey := mapping["ModifierKey"] + sourceKey := mapping["SourceKey"] + if (modKey = "" || !mapping["PassthroughMod"]) + return + + key := modKey "|" sourceKey + if !this.mappingByModSource.Has(key) + this.mappingByModSource[key] := [] + + entry := { + modKey: modKey, + sourceKey: sourceKey, + targetKey: mapping["TargetKey"], + holdRepeat: mapping["HoldRepeat"], + repeatDelay: mapping["RepeatDelay"], + repeatInterval: mapping["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() + Commit() { + ; 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 { + HotkeyRegErrors.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 { + HotkeyRegErrors.Push(sourceHotkey) + } + } else { + try { + HotIf() + Hotkey(sourceHotkey, ObjBindMethod(this, "OnSourceDown", 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, ObjBindMethod(this, "OnSourceUp", sourceKey), "On") + record.keyUp := srcUpHotkey + } catch as e { + HotkeyRegErrors.Push(srcUpHotkey) + } + } + this.registrations.Push(record) + } + + HotIf() + } + + ; 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] + + for _, mapping in mappings { + if !this.IsMappingActive(mapping) + continue + + ; Mark this session as a gesture session + session.state := PathCEngine.STATE_GESTURE_ACTIVE + session.isGesture := true + + if (mapping.holdRepeat) { + this.StartMappingRepeat(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)) + } + } + + ; 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/tests/integration/hotkey_engine_state.test.ahk b/tests/integration/hotkey_engine_state.test.ahk index 4acc09b..262737c 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 HotkeyRegErrors", 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() @@ -44,7 +46,7 @@ Test_DetectHotkeyConflicts_ReportsScopeAndModifierIssues() { AssertEq("CapsLock (Path B/C conflict)", HotkeyConflicts[2].hotkey) } -Test_ReloadAllHotkeys_TracksDispatchStateAndCleanup() { +Test_ReloadAllHotkeys_DelegatesPathCToEngineAndCleansUp() { mappings := [ MakeMapping("", "F21", "^c"), MakeMapping("CapsLock", "F22", "^v", 0, 300, 50, 0), @@ -55,139 +57,226 @@ Test_ReloadAllHotkeys_TracksDispatchStateAndCleanup() { 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) + ; 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) + AssertEq("Idle", engine.GetSessionState("RAlt")) + AssertEq("Idle", engine.GetSessionState("RButton")) + AssertFalse(engine.ShouldRouteWheel("WheelUp")) + DisableSendCapture() } -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") -} - -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() { +Test_PathCEngine_ShouldRouteWheel_FalseWhenScopeDoesNotMatch() { + engine := PathCEngine() checker := (*) => false - RegisterPathCMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", checker) - PathC_ModDownCallback("RButton") + engine.AddMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", checker) + engine.OnModDown("RButton") - AssertFalse(PathC_ShouldRouteWheelSource("WheelUp")) - AssertFalse(PathC_ShouldRouteWheelSource("WheelUp", "*WheelUp")) + AssertFalse(engine.ShouldRouteWheel("WheelUp")) + AssertFalse(engine.ShouldRouteWheel("WheelUp", "*WheelUp")) + engine.OnModUp("RButton") } -Test_PathC_ShouldRouteWheelSource_TrueWhenSessionAndScopeMatch() { +Test_PathCEngine_ShouldRouteWheel_TrueWhenSessionAndScopeMatch() { + engine := PathCEngine() checker := (*) => true - RegisterPathCMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", checker) - PathC_ModDownCallback("RButton") + engine.AddMapping(MakeMapping("RButton", "WheelUp", "^Tab", 0, 300, 50, 1), "Cfg|1", "Cfg", checker) + engine.OnModDown("RButton") - AssertTrue(PathC_ShouldRouteWheelSource("WheelUp")) - AssertTrue(PathC_ShouldRouteWheelSource("WheelUp", "*WheelUp")) + AssertTrue(engine.ShouldRouteWheel("WheelUp")) + AssertTrue(engine.ShouldRouteWheel("WheelUp", "*WheelUp")) + engine.OnModUp("RButton") } -Test_PathC_SourceDown_FallsBackToRawSourceKey() { +Test_PathCEngine_SourceDown_FallsBackToRawSourceKey() { + engine := PathCEngine() EnableSendCapture() - PathC_SourceDownCallback("F13") + engine.OnSourceDown("F13") AssertEq(1, CapturedSendKeys.Length) AssertEq("{F13}", CapturedSendKeys[1]) } -Test_PathC_SourceDown_DispatchesMappedTarget() { - RegisterPathCMapping(MakeMapping("RButton", "F13", "^c", 0, 300, 50, 1), "Cfg|1", "Cfg", "") - PathC_ModDownCallback("RButton") +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", "") + + 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_PathCEngine_SourceDown_DispatchesMappedTarget() { + engine := PathCEngine() + engine.AddMapping(MakeMapping("RButton", "F13", "^c", 0, 300, 50, 1), "Cfg|1", "Cfg", "") + engine.OnModDown("RButton") + EnableSendCapture() + + 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() + + engine.OnSourceDown("F14") + AssertEq(1, CapturedSendKeys.Length) + AssertEq("^v", CapturedSendKeys[1]) + AssertEq("GestureActive", engine.GetSessionState("RButton")) + + ; Release the source key: no further sends may occur within the repeat window + engine.OnSourceUp("F14") + Sleep 400 + AssertEq(1, CapturedSendKeys.Length) - PathC_SourceUpCallback("F14") + ; Re-pressing the source still triggers the mapping (nothing got stuck) + engine.OnSourceDown("F14") + AssertEq(2, CapturedSendKeys.Length) + AssertEq("^v", CapturedSendKeys[2]) - AssertFalse(HoldTimers.Has(mappingId)) - AssertFalse(session.activeSources.Has("F14")) - AssertFalse(session.repeatMappings.Has(mappingId)) + 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 + engine.Commit() + AssertEq(0, HotkeyRegErrors.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 land in the shared error list + engine.AddMapping(MakeMapping("RButton", "NotARealKey", "^c", 0, 300, 50, 1), "Cfg|1", "Cfg", "") + engine.Commit() + + AssertEq(2, HotkeyRegErrors.Length) + AssertArrayContains(HotkeyRegErrors, "*NotARealKey") + AssertArrayContains(HotkeyRegErrors, "*NotARealKey Up") + + engine.Reset() + HotkeyRegErrors.Length := 0 } Test_DetectHotkeyConflicts_NoConflictForDisabledConfigs() { @@ -217,25 +306,3 @@ Test_DetectHotkeyConflicts_NoConflictForDisjointScopes() { ; 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) -} diff --git a/tests/support/TestBase.ahk b/tests/support/TestBase.ahk index 7652dbd..366cced 100644 --- a/tests/support/TestBase.ahk +++ b/tests/support/TestBase.ahk @@ -135,11 +135,6 @@ ResetAppState() { global AllProcessCheckers global HotkeyConflicts global HotkeyRegErrors - global PathCMappingByModSource - global PathCModSessions - global PathCModsUsed - global PathCSourceKeysUsed - global PathCWheelRoutePredicates global CaptureTarget global CaptureGui global CaptureDisplayText @@ -194,11 +189,6 @@ ResetAppState() { AllProcessCheckers.Length := 0 HotkeyConflicts.Length := 0 HotkeyRegErrors.Length := 0 - ClearMap(PathCMappingByModSource) - ClearMap(PathCModSessions) - ClearMap(PathCModsUsed) - ClearMap(PathCSourceKeysUsed) - PathCWheelRoutePredicates.Length := 0 CaptureTarget := "" CaptureGui := "" From e229f5040c019148c7a0a725b51860b738aedf55 Mon Sep 17 00:00:00 2001 From: json_born Date: Wed, 2 Sep 2026 03:58:20 +0800 Subject: [PATCH 03/16] refactor: collapse config working copy into ConfigStore Replace the nine Current*/Mappings working-copy globals with a ConfigStore class (src/core/ConfigStore.ahk, lazy singleton ConfigStore.Instance) that owns AllConfigs, the current selection, and every mutation. Each semantic method (Select, SetEnabled, SetScope, AddMapping, ReplaceMapping, DeleteMapping, CreateConfig, CopyConfig, DeleteConfig) runs one uniform chokepoint: atomic persist (SaveConfig + SaveEnabledStates) -> ReloadAllHotkeys() -> render. The OnToggleEnabled divergence disappears: every mutation, including the enable toggle, persists fully. Config.ahk slims to pure INI I/O plus the main-window render functions; SaveConfig now takes the record to serialize, and LoadConfigToGui / SyncCurrentToAllConfigs / FindConfigIndex / DeleteCurrentConfigAndRefresh / ReloadConfigHotkeys are gone. GUI handlers in GuiEvents.ahk and MappingEditor.ahk shrink to input validation plus one store call; the startup force-reload trick in RebuildMainWindowForLanguageChange becomes an explicit ConfigStore.Instance.Select("") re-select. Tests stage state through the store (config_io) or read the selected record (main_smoke); TestBase's ResetAppState drops the Current* section and resets the store singleton via ResetConfigStoreForTests(). The GUI smoke test now dismisses the blocking delete confirmation with an in-script ControlClick timer, because SendInput from outside the process is not delivered in the sandboxed runner. Version 2.9.4 -> 2.9.5. --- AGENTS.md | 28 ++- CLAUDE.md | 9 +- docs/architecture.md | 12 +- src/AHKeyMap.ahk | 24 +-- src/core/Config.ahk | 281 ++++++++++----------------- src/core/ConfigStore.ahk | 280 ++++++++++++++++++++++++++ src/core/HotkeyEngine.ahk | 6 - src/ui/GuiEvents.ahk | 170 ++++------------ src/ui/MappingEditor.ahk | 16 +- tests/gui/main_smoke.test.ahk | 31 ++- tests/integration/config_io.test.ahk | 120 ++++-------- tests/support/TestBase.ahk | 19 +- 12 files changed, 526 insertions(+), 470 deletions(-) create mode 100644 src/core/ConfigStore.ahk diff --git a/AGENTS.md b/AGENTS.md index 8fa974f..d147b14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,8 @@ 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/core/Config.ahk — config/state INI I/O, atomic writes, main-window render functions +src/core/ConfigStore.ahk — config working copy owner: AllConfigs, selection, mutation chokepoint src/core/Localization.ahk — `L(key, args*)`, `BuildEnPack()`, `BuildZhPack()` 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 @@ -82,14 +83,15 @@ AutoHotkey64.exe /ErrorStdOut=UTF-8 tests\unit\scope_logic.test.ahk - `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/PathCEngine.ahk` - 5. `core/HotkeyEngine.ahk` - 6. `core/KeyCapture.ahk` - 7. `ui/GuiMain.ahk` - 8. `ui/MappingEditor.ahk` - 9. `ui/GuiEvents.ahk` + 2. `core/ConfigStore.ahk` + 3. `shared/Utils.ahk` + 4. `core/Localization.ahk` + 5. `core/PathCEngine.ahk` + 6. `core/HotkeyEngine.ahk` + 7. `core/KeyCapture.ahk` + 8. `ui/GuiMain.ahk` + 9. `ui/MappingEditor.ahk` + 10. `ui/GuiEvents.ahk` - Only `src/AHKeyMap.ahk` initializes globals with `:=`; other modules may declare `global VarName` but must not reinitialize shared state. ## Code style @@ -138,6 +140,14 @@ AutoHotkey64.exe /ErrorStdOut=UTF-8 tests\unit\scope_logic.test.ahk - 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 runs the same chokepoint internally: atomic persist (`SaveConfig` + `SaveEnabledStates`) → `ReloadAllHotkeys()` → render. +- 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 plus the main-window render functions; it does not own selection state. +- Tests reset the store with `ResetConfigStoreForTests()` (TestBase calls it from `ResetAppState`). + ## 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..f52f521 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,16 +63,17 @@ 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 10 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), main-window render functions +- `ConfigStore.ahk` — owns `AllConfigs`, the current selection, and every mutation; each runs one chokepoint (persist → reload hotkeys → render) 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 - `KeyCapture.ahk` — key capture (polling + mouse hook), 200ms startup delay, auto-cancel on focus loss diff --git a/docs/architecture.md b/docs/architecture.md index beddfb6..5f6d952 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,7 +11,8 @@ ## 模块职责 - `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`、当前选中项与全部变更入口(惰性单例 `ConfigStore.Instance`) - `src/core/Localization.ahk`:本地化语言包与 `L(key, args*)` 辅助函数 - `src/ui/GuiMain.ahk`:主窗口构建、托盘菜单初始化、模态窗口管理(状态栏告警时显示独立“查看详情”入口,支持悬停提示与手型光标) - `src/ui/GuiEvents.ahk`:GUI 事件处理(新建/复制/删除/编辑/作用域);私有辅助函数 `RadioToProcessMode`、`ProcTextToStr` @@ -26,6 +27,15 @@ - 模块中仅用 `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()` → 渲染(现有 `Refresh*`/`UpdateStatusText`)。包括启用开关在内的所有变更都走完全相同的序列,无特殊分支。 +- GUI 事件处理器只做输入校验 + 一次 store 调用;映射编辑弹窗 OK 时重建全新记录交给 store,Cancel 不触碰任何状态。 +- 测试通过 `ResetConfigStoreForTests()` 重置单例(TestBase 的 `ResetAppState` 会调用)。 + ## 自动化测试架构 ### 测试入口 diff --git a/src/AHKeyMap.ahk b/src/AHKeyMap.ahk index fce56e4..beed934 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.4 +;@Ahk2Exe-SetVersion 2.9.5 ;@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.4" +global APP_VERSION := "2.9.5" 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 := "" @@ -106,6 +97,7 @@ global ProcessPickerGui := "" ; Include modules ; ============================================================================ #Include "core/Config.ahk" +#Include "core/ConfigStore.ahk" #Include "shared/Utils.ahk" #Include "core/Localization.ahk" #Include "core/PathCEngine.ahk" @@ -217,14 +209,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,9 +247,8 @@ RebuildMainWindowForLanguageChange() { BuildMainGui() ; Refresh config list and GUI state with the previously selected config - ; Clear CurrentConfigName so OnConfigSelect reloads config and mapping list - global CurrentConfigName - CurrentConfigName := "" + ; Clear the store selection so OnConfigSelect reloads config and mapping list + ConfigStore.Instance.Select("") RefreshConfigList(currentConfig) ; Refresh status bar with the new language diff --git a/src/core/Config.ahk b/src/core/Config.ahk index d8ab232..3867d3e 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 plus the main-window render functions ; ============================================================================ ; Globals shared across modules @@ -9,15 +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 @@ -118,32 +109,100 @@ LoadConfigData(configName) { 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 +; Parse process string into an array +ParseProcessList(procStr) { + result := [] + if (procStr = "") + return result + loop parse procStr, "|" { + trimmed := Trim(A_LoopField) + if (trimmed != "") + result.Push(trimmed) } - return 0 + return result } -; Sync current GUI editing state back into AllConfigs -SyncCurrentToAllConfigs() { - if (CurrentConfigName = "") - return - idx := FindConfigIndex(CurrentConfigName) - if (idx = 0) +IsValidConfigName(configName) { + return !RegExMatch(configName, '[\\/:*?"<>|=\[\]]') +} + +; 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=" cfg["name"] + metaPairs .= "`nProcessMode=" cfg["processMode"] + metaPairs .= "`nProcess=" cfg["process"] + metaPairs .= "`nExcludeProcess=" cfg["excludeProcess"] + IniWrite(metaPairs, tempFile, "Meta") + + for idx, mapping in cfg["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"] + 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, configFile), APP_NAME, "IconX") 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 + } + + ; Step 2: replace original file with temp file (FileMove overwrite mode) + try { + FileMove(tempFile, configFile, 1) + } catch as e { + try FileDelete(tempFile) + MsgBox(Format(L("Config.SaveError.Replace"), e.Message, configFile), APP_NAME, "IconX") + } } +; Save enabled state for all configs to _state.ini (atomic write) +SaveEnabledStates() { + tempFile := STATE_FILE ".tmp" + try { + ; Ensure config directory exists (defensive: in case it was removed) + if !DirExist(CONFIG_DIR) + DirCreate(CONFIG_DIR) + + if FileExist(tempFile) + FileDelete(tempFile) + + ; Preserve [State] section and always write LastConfig / UILanguage + lastConfig := "" + if FileExist(STATE_FILE) + lastConfig := IniRead(STATE_FILE, "State", "LastConfig", "") + IniWrite(lastConfig, tempFile, "State", "LastConfig") + + ; Persist UI language + global CurrentLangCode + IniWrite(CurrentLangCode, tempFile, "State", "UILanguage") + + for _, cfg in AllConfigs + IniWrite(cfg["enabled"] ? "1" : "0", tempFile, "EnabledConfigs", cfg["name"]) + + FileMove(tempFile, STATE_FILE, 1) + } catch as e { + try FileDelete(tempFile) + MsgBox(Format(L("Config.SaveEnabledStatesError"), e.Message), APP_NAME, "IconX") + } +} + +; ============================================================================ +; Main-window render functions +; ============================================================================ + ; Refresh config dropdown (GUI only, does not affect hotkeys) RefreshConfigList(selectName := "") { configs := GetConfigList() @@ -155,6 +214,15 @@ RefreshConfigList(selectName := "") { selectIdx := i } + if !IsObject(ConfigDDL) { + ; No GUI (headless tests): just track the selection in the store + if (selectIdx > 0) + ConfigStore.Instance.Select(configs[selectIdx]) + else + ConfigStore.Instance.Select("") + return + } + ConfigDDL.Delete() if (items.Length > 0) { ConfigDDL.Add(items) @@ -164,40 +232,11 @@ RefreshConfigList(selectName := "") { 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() + ConfigStore.Instance.Select("") } UpdateStatusText() } -; Parse process string into an array -ParseProcessList(procStr) { - result := [] - if (procStr = "") - return result - loop parse procStr, "|" { - trimmed := Trim(A_LoopField) - if (trimmed != "") - result.Push(trimmed) - } - return result -} - -IsValidConfigName(configName) { - return !RegExMatch(configName, '[\\/:*?"<>|=\[\]]') -} - ; Format process scope for display (using parsed arrays) FormatProcessDisplay(processMode, processList, excludeProcessList) { if (processMode = "include") { @@ -252,125 +291,12 @@ UpdateStatusText() { 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" - - ; 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 - 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"] - 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") - return - } - - ; Step 2: replace original file with temp file (FileMove overwrite mode) - try { - FileMove(tempFile, CurrentConfigFile, 1) - } catch as e { - try FileDelete(tempFile) - MsgBox(Format(L("Config.SaveError.Replace"), e.Message, CurrentConfigFile), APP_NAME, "IconX") - return - } - - ; Sync back into AllConfigs and save enabled states - SyncCurrentToAllConfigs() - SaveEnabledStates() -} - -; Save enabled state for all configs to _state.ini (atomic write) -SaveEnabledStates() { - tempFile := STATE_FILE ".tmp" - try { - ; Ensure config directory exists (defensive: in case it was removed) - if !DirExist(CONFIG_DIR) - DirCreate(CONFIG_DIR) - - if FileExist(tempFile) - FileDelete(tempFile) - - ; Preserve [State] section and always write LastConfig / UILanguage - lastConfig := "" - if FileExist(STATE_FILE) - lastConfig := IniRead(STATE_FILE, "State", "LastConfig", "") - IniWrite(lastConfig, tempFile, "State", "LastConfig") - - ; Persist UI language - global CurrentLangCode - IniWrite(CurrentLangCode, tempFile, "State", "UILanguage") - - for _, cfg in AllConfigs - IniWrite(cfg["enabled"] ? "1" : "0", tempFile, "EnabledConfigs", cfg["name"]) - - FileMove(tempFile, STATE_FILE, 1) - } catch as e { - try FileDelete(tempFile) - MsgBox(Format(L("Config.SaveEnabledStatesError"), e.Message), APP_NAME, "IconX") - } -} - -; Refresh mapping ListView display +; Refresh mapping ListView display (no-op without a GUI) RefreshMappingLV() { + if !IsObject(MappingLV) + return MappingLV.Delete() - for idx, mapping in Mappings { + for idx, mapping in ConfigStore.Instance.SelectedMappings() { holdText := mapping["HoldRepeat"] ? L("Config.Mapping.HoldYes") : L("Config.Mapping.HoldNo") modDisplay := mapping["ModifierKey"] != "" ? KeyToDisplay(mapping["ModifierKey"]) : "" ptText := "" @@ -392,4 +318,3 @@ RefreshMappingLV() { 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..e7932ad --- /dev/null +++ b/src/core/ConfigStore.ahk @@ -0,0 +1,280 @@ +; ============================================================================ +; AHKeyMap - Config store module +; Owns AllConfigs, the current selection, and every config/mutation operation. +; Each mutation runs one chokepoint: atomic persist -> hotkey reload -> render. +; ============================================================================ + +; Globals shared across modules (render functions and engine input stay global) +global AllConfigs +global EnabledCB +global ProcessText + +; 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() +; -> render (RefreshConfigList / RefreshMappingLV / UpdateStatusText). +; 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 := "" + } + + ; ------------------------------------------------------------------------ + ; 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) and render it + Select(name) { + this.selName := name + cfg := this.Selected() + if (cfg = "") + this.selName := "" + if (cfg != "") { + this.RenderScopeControls(FormatProcessDisplay(cfg["processMode"], cfg["processList"], cfg["excludeProcessList"]), cfg["enabled"], true) + RefreshMappingLV() + ; Persist last viewed config name into _state.ini + try IniWrite(name, STATE_FILE, "State", "LastConfig") + } else { + this.RenderScopeControls(L("Config.Scope.None"), 0, false) + RefreshMappingLV() + } + } + + ; Render the scope text and enable checkbox (no-op without a GUI) + RenderScopeControls(scopeText, enabledFlag, enabledEditable) { + if (IsObject(ProcessText)) + ProcessText.Value := scopeText + if (IsObject(EnabledCB)) { + EnabledCB.Value := enabledFlag + EnabledCB.Enabled := enabledEditable + } + } + + ; Enable/disable the selected config + SetEnabled(flag) { + cfg := this.Selected() + if (cfg = "") + return + cfg["enabled"] := (flag ? true : false) + if (IsObject(EnabledCB)) + EnabledCB.Value := cfg["enabled"] + 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"] := [] + } + + if (IsObject(ProcessText)) + ProcessText.Value := FormatProcessDisplay(mode, cfg["processList"], 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(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] := 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 + + IniWrite(name, newFile, "Meta", "Name") + IniWrite(mode, newFile, "Meta", "ProcessMode") + if (mode = "include") { + IniWrite(procStr, newFile, "Meta", "Process") + IniWrite("", newFile, "Meta", "ExcludeProcess") + } else if (mode = "exclude") { + IniWrite("", newFile, "Meta", "Process") + IniWrite(procStr, newFile, "Meta", "ExcludeProcess") + } else { + IniWrite("", newFile, "Meta", "Process") + IniWrite("", newFile, "Meta", "ExcludeProcess") + } + + ; Enable new config by default + IniWrite("1", STATE_FILE, "EnabledConfigs", name) + + LoadAllConfigs() + RefreshConfigList(name) + ReloadAllHotkeys() + } + + ; 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 + + if FileExist(cfg["file"]) + FileCopy(cfg["file"], newFile) + + ; Update Name field inside copied config + IniWrite(newName, newFile, "Meta", "Name") + + ; Enable new config by default + IniWrite("1", STATE_FILE, "EnabledConfigs", newName) + + LoadAllConfigs() + RefreshConfigList(newName) + ReloadAllHotkeys() + } + + ; 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() + ReloadAllHotkeys() + RefreshConfigList() + } + + ; ------------------------------------------------------------------------ + ; Chokepoint + ; ------------------------------------------------------------------------ + + ; Single mutation flow: persist the selected config (atomic write plus + ; enabled states) -> reload all hotkeys -> render the mapping list. + ; Every mutation, including SetEnabled, runs this exact sequence. + RunChokepoint() { + cfg := this.Selected() + if (cfg != "") + SaveConfig(cfg) + SaveEnabledStates() + ReloadAllHotkeys() + RefreshMappingLV() + } + + ; ------------------------------------------------------------------------ + ; Reset + ; ------------------------------------------------------------------------ + + ; Clear the selection without touching the GUI or AllConfigs + ; (test/teardown helper, mirrors PathCEngine.Reset()) + Reset() { + this.selName := "" + } +} + +; 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 19926df..ff3d36e 100644 --- a/src/core/HotkeyEngine.ahk +++ b/src/core/HotkeyEngine.ahk @@ -199,12 +199,6 @@ ReloadAllHotkeys() { UpdateStatusText() } - -; Reload hotkeys for a single config (implemented as full reload for now) -ReloadConfigHotkeys(configName := "") { - ReloadAllHotkeys() -} - ; Detect hotkey conflicts across enabled configs with overlapping scopes ; Conflict rules: ; global vs any non-empty scope -> conflict 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/MappingEditor.ahk b/src/ui/MappingEditor.ahk index 747b77e..b7cdc51 100644 --- a/src/ui/MappingEditor.ahk +++ b/src/ui/MappingEditor.ahk @@ -5,9 +5,7 @@ ; Declare globals shared across modules global APP_NAME -global Mappings global MainGui -global CurrentConfigName global DEFAULT_REPEAT_DELAY global DEFAULT_REPEAT_INTERVAL @@ -63,8 +61,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"]) @@ -133,14 +132,11 @@ OnEditMappingOK(*) { mapping["RepeatInterval"] := repeatInterval mapping["PassthroughMod"] := EditPassthroughCB.Value ? 1 : 0 - if (EditingIndex > 0 && EditingIndex <= Mappings.Length) { - Mappings[EditingIndex] := mapping + if (EditingIndex > 0 && EditingIndex <= ConfigStore.Instance.SelectedMappings().Length) { + ConfigStore.Instance.ReplaceMapping(EditingIndex, mapping) } else { - Mappings.Push(mapping) + ConfigStore.Instance.AddMapping(mapping) } - SaveConfig() - RefreshMappingLV() - ReloadConfigHotkeys(CurrentConfigName) DestroyModalGui(EditGui) } diff --git a/tests/gui/main_smoke.test.ahk b/tests/gui/main_smoke.test.ahk index f470402..2f38ffd 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,31 @@ 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() + ; Note: this sandbox cannot deliver the physical key state that Path A/B/C + ; registration depends on, so ActiveHotkeys stays 0 here (the baseline test + ; had the same limitation; hotkey registration is covered by integration + ; tests and manual verification). + + ; 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/support/TestBase.ahk b/tests/support/TestBase.ahk index d533e6f..d1825d9 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 @@ -149,15 +140,7 @@ ResetAppState() { global ForegroundProcessHook AllConfigs.Length := 0 - CurrentConfigName := "" - CurrentConfigFile := "" - CurrentProcessMode := "global" - CurrentProcess := "" - CurrentProcessList := [] - CurrentExcludeProcess := "" - CurrentExcludeProcessList := [] - CurrentConfigEnabled := true - Mappings.Length := 0 + ResetConfigStoreForTests() MainGui := "" ConfigDDL := "" From 6151882e2c3c0de12dd6bc58f298bbfa3026bcd5 Mon Sep 17 00:00:00 2001 From: json_born Date: Wed, 2 Sep 2026 04:04:51 +0800 Subject: [PATCH 04/16] refactor: capture completion behind an adapter StartCapture(target, onCaptured) now takes a per-call completion callback; the capture session invokes it exactly once with the AHK key string and its display string, and clears the slot on finish/cancel. ApplyCapturedKey and UpdatePassthroughState are gone from KeyCapture: the editor owns the .Value/.ahkKey control protocol, supplies the OnModifier/Source/TargetCaptured callbacks, and hosts UpdatePassthroughState itself. The four editor-control globals are removed from KeyCapture, which now references no Edit* controls. The modifier folding from FinishCapture is extracted as the pure BuildAhkKey(captureKeys, targetMode) function (modifier folding, main-key selection, modifier-only fallback via ModifierPrefixToKeyName); FinishCapture is thin glue around it, and new unit tests cover modifier-only, modifier+key, modifier+wheel, multi-key, and empty-capture cases. Version bumped to 2.9.7. --- src/AHKeyMap.ahk | 5 +- src/core/KeyCapture.ahk | 105 +++++++++---------------- src/ui/MappingEditor.ahk | 43 +++++++++- tests/support/TestBase.ahk | 2 + tests/unit/keycapture_helpers.test.ahk | 53 +++++++++++++ 5 files changed, 137 insertions(+), 71 deletions(-) diff --git a/src/AHKeyMap.ahk b/src/AHKeyMap.ahk index beed934..3db8a62 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.5 +;@Ahk2Exe-SetVersion 2.9.7 ;@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.5" +global APP_VERSION := "2.9.7" 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") @@ -82,6 +82,7 @@ global ForegroundProcessHook := "" ; Key capture globals global IsCapturing := false global CaptureTarget := "" +global CaptureOnCaptured := "" global CaptureGui := "" global CaptureDisplayText := "" global CaptureTimer := "" 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/ui/MappingEditor.ahk b/src/ui/MappingEditor.ahk index b7cdc51..10ed17a 100644 --- a/src/ui/MappingEditor.ahk +++ b/src/ui/MappingEditor.ahk @@ -9,7 +9,8 @@ global MainGui 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 @@ -96,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 diff --git a/tests/support/TestBase.ahk b/tests/support/TestBase.ahk index d1825d9..ff3ba19 100644 --- a/tests/support/TestBase.ahk +++ b/tests/support/TestBase.ahk @@ -127,6 +127,7 @@ ResetAppState() { global HotkeyConflicts global HotkeyRegErrors global CaptureTarget + global CaptureOnCaptured global CaptureGui global CaptureDisplayText global CaptureTimer @@ -175,6 +176,7 @@ ResetAppState() { HotkeyRegErrors.Length := 0 CaptureTarget := "" + CaptureOnCaptured := "" CaptureGui := "" CaptureDisplayText := "" CaptureTimer := "" 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")) +} From aa9332fe9914c72ede9b4f2fe8a85c722af8e59d Mon Sep 17 00:00:00 2001 From: lijso Date: Wed, 2 Sep 2026 04:11:38 +0800 Subject: [PATCH 05/16] refactor: unify mapping schema and path classification Give "what a mapping is" one home: new src/shared/Schema.ahk exposes static namespaces Mapping (Make/Normalize/ClassifyPath/HotkeyStringFor/ ToIniPairs, path constants) and ConfigRecord (Make). Records stay Map()-based; the schema lives in the constructors. - Constructor invariants enforced at every construction site including INI load: 7-key whitelist, Integer() coercion, DEFAULT_REPEAT_* defaults, min-10 repeat-timing clamp (hand-edited sub-minimum INI values now clamp at load) - ConfigStore re-normalizes incoming mappings at the AddMapping/ ReplaceMapping boundary - Registration dispatch, conflict detection, and the Path C engine guard all derive path + hotkey string from the same functions, so conflict reporting can no longer drift from registration - SaveConfig and test seeding serialize via Mapping.ToIniPairs; TestBase MakeMapping/BuildConfigRecord become thin delegates - New tests/unit/schema.test.ahk; version bumped to 2.9.6 --- AGENTS.md | 26 ++-- docs/architecture.md | 2 + src/AHKeyMap.ahk | 5 +- src/core/Config.ahk | 52 +++----- src/core/ConfigStore.ahk | 19 ++- src/core/HotkeyEngine.ahk | 61 +++++----- src/core/PathCEngine.ahk | 20 +-- src/shared/Schema.ahk | 154 ++++++++++++++++++++++++ src/ui/MappingEditor.ahk | 27 ++--- tests/support/TestBase.ahk | 33 +---- tests/unit/schema.test.ahk | 241 +++++++++++++++++++++++++++++++++++++ 11 files changed, 508 insertions(+), 132 deletions(-) create mode 100644 src/shared/Schema.ahk create mode 100644 tests/unit/schema.test.ahk diff --git a/AGENTS.md b/AGENTS.md index d147b14..3515b64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ Audience: coding agents working on AHKeyMap. ## Repo map ```text src/AHKeyMap.ahk — globals, constants, #Include list, StartApp() +src/shared/Schema.ahk — mapping/config record schema (static namespaces: construction, normalization, path rule) src/core/Config.ahk — config/state INI I/O, atomic writes, main-window render functions src/core/ConfigStore.ahk — config working copy owner: AllConfigs, selection, mutation chokepoint src/core/Localization.ahk — `L(key, args*)`, `BuildEnPack()`, `BuildZhPack()` @@ -82,16 +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. `core/ConfigStore.ahk` - 3. `shared/Utils.ahk` - 4. `core/Localization.ahk` - 5. `core/PathCEngine.ahk` - 6. `core/HotkeyEngine.ahk` - 7. `core/KeyCapture.ahk` - 8. `ui/GuiMain.ahk` - 9. `ui/MappingEditor.ahk` - 10. `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 @@ -110,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. diff --git a/docs/architecture.md b/docs/architecture.md index 5f6d952..d2c8f62 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,6 +20,7 @@ - `src/core/KeyCapture.ahk`:按键捕获机制(轮询 + 鼠标钩子) - `src/core/HotkeyEngine.ahk`:热键注册/卸载、长按连续触发、修饰键逻辑;路径 A/B 直接注册,路径 C 委托给 `src/core/PathCEngine.ahk`;冲突检测包含跨路径 B/C 修饰键冲突 - `src/core/PathCEngine.ahk`:路径 C 透传组合引擎(会话状态机、统一事件路由、自有的 repeat 定时器与滚轮路由,以及路由热键的自注册/自卸载;入口为惰性单例 `PathCEngine.Instance`) +- `src/shared/Schema.ahk`:记录 schema(纯静态命名空间 `Mapping` / `ConfigRecord`);映射记录的构造/规范化(7 键白名单、整数化、默认值、最小 10ms 钳制)、路径分类规则(`Mapping.ClassifyPath`)、热键串推导(`Mapping.HotkeyStringFor`)与 INI 序列化字段表(`Mapping.ToIniPairs`)都唯一归属于此 - `src/shared/Utils.ahk`:按键显示转换、进程选择器、自启功能 ## 全局变量管理 @@ -77,6 +78,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 引擎处理) diff --git a/src/AHKeyMap.ahk b/src/AHKeyMap.ahk index beed934..b7b4fb7 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.5 +;@Ahk2Exe-SetVersion 2.9.6 ;@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.5" +global APP_VERSION := "2.9.6" 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") @@ -96,6 +96,7 @@ global ProcessPickerGui := "" ; ============================================================================ ; Include modules ; ============================================================================ +#Include "shared/Schema.ahk" #Include "core/Config.ahk" #Include "core/ConfigStore.ahk" #Include "shared/Utils.ahk" diff --git a/src/core/Config.ahk b/src/core/Config.ahk index 3867d3e..c7c77d4 100644 --- a/src/core/Config.ahk +++ b/src/core/Config.ahk @@ -18,8 +18,6 @@ global StatusHasWarning global MappingLV global HotkeyConflicts global HotkeyRegErrors -global DEFAULT_REPEAT_DELAY -global DEFAULT_REPEAT_INTERVAL ; ============================================================================ ; Config management functions @@ -55,10 +53,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", "") @@ -72,19 +66,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 { @@ -93,20 +82,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 + return ConfigRecord.Make(configName, processMode, process, excludeProcess, enabled, mappings) } ; Parse process string into an array @@ -142,14 +129,13 @@ SaveConfig(cfg) { metaPairs .= "`nExcludeProcess=" cfg["excludeProcess"] IniWrite(metaPairs, tempFile, "Meta") - for idx, mapping in cfg["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 { diff --git a/src/core/ConfigStore.ahk b/src/core/ConfigStore.ahk index e7932ad..1aa109d 100644 --- a/src/core/ConfigStore.ahk +++ b/src/core/ConfigStore.ahk @@ -149,7 +149,7 @@ class ConfigStore { cfg := this.Selected() if (cfg = "") return 0 - cfg["mappings"].Push(mapping) + cfg["mappings"].Push(this.NormalizeIncomingMapping(mapping)) this.RunChokepoint() return cfg["mappings"].Length } @@ -161,7 +161,7 @@ class ConfigStore { return if (index < 1 || index > cfg["mappings"].Length) return - cfg["mappings"][index] := mapping + cfg["mappings"][index] := this.NormalizeIncomingMapping(mapping) this.RunChokepoint() } @@ -246,6 +246,21 @@ class ConfigStore { RefreshConfigList() } + ; ------------------------------------------------------------------------ + ; 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 ; ------------------------------------------------------------------------ diff --git a/src/core/HotkeyEngine.ahk b/src/core/HotkeyEngine.ahk index ff3d36e..9dd7410 100644 --- a/src/core/HotkeyEngine.ahk +++ b/src/core/HotkeyEngine.ahk @@ -228,15 +228,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, @@ -253,14 +247,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) } } } @@ -458,48 +452,49 @@ RegisterConfigHotkeys(cfg) { } ; Register a single mapping by dispatching to Path A/B/C -RegisterMapping(mapping, useCustomHotIf, checker, uniqueIdx, configName) { - modKey := mapping["ModifierKey"] +; (local name avoids shadowing the Mapping class; AHK names are case-insensitive) +RegisterMapping(m, useCustomHotIf, checker, uniqueIdx, configName) { + 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) ActiveHotkeys.Push(hkInfo) return } ; 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) ActiveHotkeys.Push(hkInfo) return } ; Path C: stateful passthrough, handled by Path C engine instead of direct target callback HotIf() - PathCEngine.Instance.AddMapping(mapping, uniqueIdx, configName, checker) + PathCEngine.Instance.AddMapping(m, uniqueIdx, configName, checker) } ; 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) { + 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") @@ -516,17 +511,17 @@ RegisterPathA(mapping, hkInfo, uniqueIdx) { } ; 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) { + 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") diff --git a/src/core/PathCEngine.ahk b/src/core/PathCEngine.ahk index 847ced0..18e7a87 100644 --- a/src/core/PathCEngine.ahk +++ b/src/core/PathCEngine.ahk @@ -85,23 +85,27 @@ class PathCEngine { ; ------------------------------------------------------------------------ ; Record one Path C mapping for unified routing (was RegisterPathCMapping) - AddMapping(mapping, id, configName, checker) { - modKey := mapping["ModifierKey"] - sourceKey := mapping["SourceKey"] - if (modKey = "" || !mapping["PassthroughMod"]) + ; (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: mapping["TargetKey"], - holdRepeat: mapping["HoldRepeat"], - repeatDelay: mapping["RepeatDelay"], - repeatInterval: mapping["RepeatInterval"], + targetKey: m["TargetKey"], + holdRepeat: m["HoldRepeat"], + repeatDelay: m["RepeatDelay"], + repeatInterval: m["RepeatInterval"], configName: configName, id: id, checker: checker 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/MappingEditor.ahk b/src/ui/MappingEditor.ahk index b7cdc51..3abccd3 100644 --- a/src/ui/MappingEditor.ahk +++ b/src/ui/MappingEditor.ahk @@ -118,24 +118,21 @@ 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 + + ; 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, mapping) + ConfigStore.Instance.ReplaceMapping(EditingIndex, newMapping) } else { - ConfigStore.Instance.AddMapping(mapping) + ConfigStore.Instance.AddMapping(newMapping) } DestroyModalGui(EditGui) diff --git a/tests/support/TestBase.ahk b/tests/support/TestBase.ahk index d1825d9..6f264ca 100644 --- a/tests/support/TestBase.ahk +++ b/tests/support/TestBase.ahk @@ -362,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) { @@ -396,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) } } 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")) +} From 4f592ccfdc06713cec4e60a18780c3ef4e317c5b Mon Sep 17 00:00:00 2001 From: json_born Date: Wed, 2 Sep 2026 04:53:23 +0800 Subject: [PATCH 06/16] refactor: give rendering a seam Move all main-window rendering into src/ui/GuiMain.ahk behind one RenderFromState entry, fed by pure view-model builders (BuildStatusSummary / BuildMappingRows / BuildStatusDetails / FormatProcessDisplay). src/core/Config.ahk is now pure INI I/O. Engine output becomes return values: ReloadAllHotkeys() returns {conflicts, regErrors}, DetectHotkeyConflicts is a pure function, and PathCEngine.Commit() returns its registration-error keys; the HotkeyConflicts and HotkeyRegErrors globals are deleted. The ui layer holds the last result (LastReloadResult) for the detail popup. Render triggers through the ConfigStore.OnChanged seam: BuildMainGui registers RenderFromState, and the store chokepoint becomes persist -> reload -> notify. Core no longer references GUI controls or ui functions; headless tests register nothing. Select is render-only and notifies with "". Startup and language-switch rewire through the store; version bumped to 2.9.8. New tests/unit/view_models.test.ahk covers the builders and the OnChanged flow. --- AGENTS.md | 18 +- docs/architecture.md | 28 ++- src/AHKeyMap.ahk | 32 +-- src/core/Config.ahk | 131 +---------- src/core/ConfigStore.ahk | 87 ++++---- src/core/HotkeyEngine.ahk | 63 +++--- src/core/PathCEngine.ahk | 12 +- src/ui/GuiMain.ahk | 207 ++++++++++++++++-- .../integration/hotkey_engine_state.test.ahk | 40 ++-- tests/support/TestBase.ahk | 6 +- tests/unit/view_models.test.ahk | 192 ++++++++++++++++ 11 files changed, 553 insertions(+), 263 deletions(-) create mode 100644 tests/unit/view_models.test.ahk diff --git a/AGENTS.md b/AGENTS.md index 3515b64..2c39918 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,14 +22,14 @@ Audience: coding agents working on AHKeyMap. ```text src/AHKeyMap.ahk — globals, constants, #Include list, StartApp() src/shared/Schema.ahk — mapping/config record schema (static namespaces: construction, normalization, path rule) -src/core/Config.ahk — config/state INI I/O, atomic writes, main-window render functions -src/core/ConfigStore.ahk — config working copy owner: AllConfigs, selection, mutation chokepoint +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/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 @@ -147,11 +147,19 @@ AutoHotkey64.exe /ErrorStdOut=UTF-8 tests\unit\scope_logic.test.ahk ### 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 runs the same chokepoint internally: atomic persist (`SaveConfig` + `SaveEnabledStates`) → `ReloadAllHotkeys()` → render. +- 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 plus the main-window render functions; it does not own selection state. +- `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/docs/architecture.md b/docs/architecture.md index d2c8f62..2d8cf72 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,15 +11,15 @@ ## 模块职责 - `src/AHKeyMap.ahk`:全局变量初始化、`APP_ROOT` 解析、模块 `#Include`、启动入口 -- `src/core/Config.ahk`:纯 INI I/O(加载/保存、配置列表枚举、启用状态持久化)与主窗口渲染函数;`SaveConfig` 和 `SaveEnabledStates` 均采用原子写入:先写临时文件再替换,防止中途失败丢失数据 -- `src/core/ConfigStore.ahk`:配置工作副本的唯一所有者;持有 `AllConfigs`、当前选中项与全部变更入口(惰性单例 `ConfigStore.Instance`) +- `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 委托给 `src/core/PathCEngine.ahk`;冲突检测包含跨路径 B/C 修饰键冲突 -- `src/core/PathCEngine.ahk`:路径 C 透传组合引擎(会话状态机、统一事件路由、自有的 repeat 定时器与滚轮路由,以及路由热键的自注册/自卸载;入口为惰性单例 `PathCEngine.Instance`) +- `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`:按键显示转换、进程选择器、自启功能 @@ -33,10 +33,24 @@ - `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()` → 渲染(现有 `Refresh*`/`UpdateStatusText`)。包括启用开关在内的所有变更都走完全相同的序列,无特殊分支。 +- 统一 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 选中态)、作用域控件(`RefreshScopeControls`)、映射列表(`RefreshMappingLV`)与状态栏(`UpdateStatusText`)。 +- 视图模型构造器是纯函数,单元测试直接覆盖(`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` 全局变量已删除。 + ## 自动化测试架构 ### 测试入口 @@ -154,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 a94ca6c..4d7de41 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.7 +;@Ahk2Exe-SetVersion 2.9.8 ;@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.7" +global APP_VERSION := "2.9.8" 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") @@ -54,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 := "" @@ -74,8 +77,6 @@ global ActiveHotkeys := [] global HoldTimers := Map() global InterceptModKeys := Map() global AllProcessCheckers := [] -global HotkeyConflicts := [] -global HotkeyRegErrors := [] global DispatchSendHook := "" global ForegroundProcessHook := "" @@ -171,6 +172,7 @@ if !__AHKM_TEST_MODE StartApp() { global CurrentLangCode + global LastReloadResult ; Ensure config directory exists if !DirExist(CONFIG_DIR) @@ -189,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 @@ -198,11 +200,12 @@ 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) + ConfigStore.Instance.Select(lastConfig) ; Show main window MainGui.Show("w720 h500") @@ -246,15 +249,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 the store selection so OnConfigSelect reloads config and mapping list + ; Clear the store selection so the reload below re-selects and re-renders ConfigStore.Instance.Select("") - RefreshConfigList(currentConfig) - ; 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 c7c77d4..720c970 100644 --- a/src/core/Config.ahk +++ b/src/core/Config.ahk @@ -1,6 +1,6 @@ ; ============================================================================ ; AHKeyMap - Config management module -; Pure config/state INI I/O plus the main-window render functions +; Pure config/state INI I/O (load/save, list enumeration, enabled persistence) ; ============================================================================ ; Globals shared across modules @@ -9,15 +9,6 @@ global SCRIPT_DIR global CONFIG_DIR global STATE_FILE global AllConfigs -global ConfigDDL -global EnabledCB -global ProcessText -global StatusText -global StatusDetailLink -global StatusHasWarning -global MappingLV -global HotkeyConflicts -global HotkeyRegErrors ; ============================================================================ ; Config management functions @@ -184,123 +175,3 @@ SaveEnabledStates() { MsgBox(Format(L("Config.SaveEnabledStatesError"), e.Message), APP_NAME, "IconX") } } - -; ============================================================================ -; Main-window render functions -; ============================================================================ - -; 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 - } - - if !IsObject(ConfigDDL) { - ; No GUI (headless tests): just track the selection in the store - if (selectIdx > 0) - ConfigStore.Instance.Select(configs[selectIdx]) - else - ConfigStore.Instance.Select("") - return - } - - ConfigDDL.Delete() - if (items.Length > 0) { - ConfigDDL.Add(items) - if (selectIdx > 0) - ConfigDDL.Choose(selectIdx) - else - ConfigDDL.Choose(1) - OnConfigSelect(ConfigDDL, "") - } else { - ConfigStore.Instance.Select("") - } - UpdateStatusText() -} - -; 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 -} - -; Refresh mapping ListView display (no-op without a GUI) -RefreshMappingLV() { - if !IsObject(MappingLV) - return - MappingLV.Delete() - for idx, mapping in ConfigStore.Instance.SelectedMappings() { - 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 index 1aa109d..9503961 100644 --- a/src/core/ConfigStore.ahk +++ b/src/core/ConfigStore.ahk @@ -1,21 +1,22 @@ ; ============================================================================ ; AHKeyMap - Config store module ; Owns AllConfigs, the current selection, and every config/mutation operation. -; Each mutation runs one chokepoint: atomic persist -> hotkey reload -> render. +; Each mutation runs one chokepoint: atomic persist -> hotkey reload -> notify. ; ============================================================================ -; Globals shared across modules (render functions and engine input stay global) +; Globals shared across modules (engine input stays global) global AllConfigs -global EnabledCB -global ProcessText ; 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() -; -> render (RefreshConfigList / RefreshMappingLV / UpdateStatusText). -; Production code uses the lazy singleton `ConfigStore.Instance`; tests may -; reset the singleton via ResetConfigStoreForTests(). +; -> 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 := "" @@ -32,6 +33,28 @@ class ConfigStore { ; 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) } ; ------------------------------------------------------------------------ @@ -76,31 +99,19 @@ class ConfigStore { ; Semantic mutations (each runs the single chokepoint internally) ; ------------------------------------------------------------------------ - ; Select a config by name ("" clears the selection) and render it + ; 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 != "") { - this.RenderScopeControls(FormatProcessDisplay(cfg["processMode"], cfg["processList"], cfg["excludeProcessList"]), cfg["enabled"], true) - RefreshMappingLV() ; Persist last viewed config name into _state.ini try IniWrite(name, STATE_FILE, "State", "LastConfig") - } else { - this.RenderScopeControls(L("Config.Scope.None"), 0, false) - RefreshMappingLV() - } - } - - ; Render the scope text and enable checkbox (no-op without a GUI) - RenderScopeControls(scopeText, enabledFlag, enabledEditable) { - if (IsObject(ProcessText)) - ProcessText.Value := scopeText - if (IsObject(EnabledCB)) { - EnabledCB.Value := enabledFlag - EnabledCB.Enabled := enabledEditable } + this.NotifyChanged("") } ; Enable/disable the selected config @@ -109,8 +120,6 @@ class ConfigStore { if (cfg = "") return cfg["enabled"] := (flag ? true : false) - if (IsObject(EnabledCB)) - EnabledCB.Value := cfg["enabled"] this.RunChokepoint() } @@ -139,8 +148,6 @@ class ConfigStore { cfg["excludeProcessList"] := [] } - if (IsObject(ProcessText)) - ProcessText.Value := FormatProcessDisplay(mode, cfg["processList"], cfg["excludeProcessList"]) this.RunChokepoint() } @@ -199,8 +206,8 @@ class ConfigStore { IniWrite("1", STATE_FILE, "EnabledConfigs", name) LoadAllConfigs() - RefreshConfigList(name) - ReloadAllHotkeys() + this.NotifyChokepointReload() + this.Select(name) } ; Copy the selected config under a new name and select the copy @@ -222,8 +229,8 @@ class ConfigStore { IniWrite("1", STATE_FILE, "EnabledConfigs", newName) LoadAllConfigs() - RefreshConfigList(newName) - ReloadAllHotkeys() + this.NotifyChokepointReload() + this.Select(newName) } ; Delete the selected config (file + record) and clear the selection @@ -242,8 +249,7 @@ class ConfigStore { this.selName := "" SaveEnabledStates() - ReloadAllHotkeys() - RefreshConfigList() + this.NotifyChokepointReload() } ; ------------------------------------------------------------------------ @@ -266,25 +272,30 @@ class ConfigStore { ; ------------------------------------------------------------------------ ; Single mutation flow: persist the selected config (atomic write plus - ; enabled states) -> reload all hotkeys -> render the mapping list. + ; 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() - ReloadAllHotkeys() - RefreshMappingLV() + this.NotifyChokepointReload() + } + + ; Reload hotkeys and hand the result to the OnChanged subscriber + NotifyChokepointReload() { + this.NotifyChanged(ReloadAllHotkeys()) } ; ------------------------------------------------------------------------ ; Reset ; ------------------------------------------------------------------------ - ; Clear the selection without touching the GUI or AllConfigs - ; (test/teardown helper, mirrors PathCEngine.Reset()) + ; Clear the selection and the OnChanged registration without touching + ; AllConfigs (test/teardown helper, mirrors PathCEngine.Reset()) Reset() { this.selName := "" + this.onChanged := "" } } diff --git a/src/core/HotkeyEngine.ahk b/src/core/HotkeyEngine.ahk index 9dd7410..47ac6f7 100644 --- a/src/core/HotkeyEngine.ahk +++ b/src/core/HotkeyEngine.ahk @@ -9,8 +9,6 @@ global ActiveHotkeys global HoldTimers global InterceptModKeys global AllProcessCheckers -global HotkeyConflicts -global HotkeyRegErrors global ForegroundProcessHook ; ============================================================================ @@ -84,6 +82,12 @@ AddUniqueArrayValue(arr, value) { arr.Push(value) } +; Append every element of src to dest (in order) +AppendAll(dest, src) { + for _, value in src + dest.Push(value) +} + MakeActiveHotkeyRecord(checker := "", configName := "", key := "", keyUp := "") { return { checker: checker, @@ -133,7 +137,6 @@ UnregisterAllHotkeys() { global InterceptModKeys := Map() global HoldTimers := Map() global AllProcessCheckers := [] - global HotkeyRegErrors := [] ; Path C hotkeys and state are owned by the Path C engine PathCEngine.Instance.Reset() @@ -158,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 @@ -183,30 +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 - PathCEngine.Instance.Commit() + AppendAll(regErrors, PathCEngine.Instance.Commit()) HotIf() - ; Detect hotkey conflicts and update the status bar - DetectHotkeyConflicts() - UpdateStatusText() + 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() @@ -271,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, @@ -293,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, @@ -304,6 +308,7 @@ DetectHotkeyConflicts() { } } } + return conflicts } ; Normalize include process list into a comparable scope key: @@ -432,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) @@ -447,13 +454,16 @@ 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 +; 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 @@ -463,9 +473,9 @@ RegisterMapping(m, useCustomHotIf, checker, uniqueIdx, configName) { else HotIf() hkInfo := MakeActiveHotkeyRecord(checker, configName) - RegisterPathA(m, 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 @@ -475,18 +485,19 @@ RegisterMapping(m, useCustomHotIf, checker, uniqueIdx, configName) { else HotIf() hkInfo := MakeActiveHotkeyRecord(checker, configName) - RegisterPathB(m, 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() PathCEngine.Instance.AddMapping(m, uniqueIdx, configName, checker) + return regErrors } ; Path A: no modifier, directly map sourceKey -> targetKey -RegisterPathA(m, hkInfo, uniqueIdx) { +RegisterPathA(m, hkInfo, uniqueIdx, regErrors) { sourceKey := m["SourceKey"] targetKey := m["TargetKey"] holdRepeat := m["HoldRepeat"] @@ -501,17 +512,17 @@ RegisterPathA(m, hkInfo, uniqueIdx) { 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(m, hkInfo, uniqueIdx, checker, configName) { +RegisterPathB(m, hkInfo, uniqueIdx, checker, configName, regErrors) { modKey := m["ModifierKey"] sourceKey := m["SourceKey"] targetKey := m["TargetKey"] @@ -528,12 +539,12 @@ RegisterPathB(m, hkInfo, uniqueIdx, checker, configName) { 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 @@ -545,7 +556,7 @@ RegisterPathB(m, hkInfo, uniqueIdx, checker, configName) { ActiveHotkeys.Push(modHkInfo) InterceptModKeys[modRegKey] := true } catch as e { - HotkeyRegErrors.Push(modKey) + regErrors.Push(modKey) } } } diff --git a/src/core/PathCEngine.ahk b/src/core/PathCEngine.ahk index 18e7a87..870d8f4 100644 --- a/src/core/PathCEngine.ahk +++ b/src/core/PathCEngine.ahk @@ -6,7 +6,6 @@ ; Globals shared across modules global CONTEXT_MENU_DISMISS_DELAY -global HotkeyRegErrors ; For Path C, only register Up hotkeys on source keys that support key-up SupportsKeyUpHotkey(hotkeyName) { @@ -118,7 +117,9 @@ class PathCEngine { ; 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 = "") @@ -132,7 +133,7 @@ class PathCEngine { Hotkey(downHk, ObjBindMethod(this, "OnModDown", modKey), "On") Hotkey(upHk, ObjBindMethod(this, "OnModUp", modKey), "On") } catch as e { - HotkeyRegErrors.Push(downHk) + regErrors.Push(downHk) continue } @@ -157,14 +158,14 @@ class PathCEngine { Hotkey(sourceHotkey, ObjBindMethod(this, "OnSourceDown", sourceKey), "On") record.checker := wheelRoutePredicate } catch as e { - HotkeyRegErrors.Push(sourceHotkey) + regErrors.Push(sourceHotkey) } } else { try { HotIf() Hotkey(sourceHotkey, ObjBindMethod(this, "OnSourceDown", sourceKey), "On") } catch as e { - HotkeyRegErrors.Push(sourceHotkey) + regErrors.Push(sourceHotkey) } } @@ -176,13 +177,14 @@ class PathCEngine { Hotkey(srcUpHotkey, ObjBindMethod(this, "OnSourceUp", sourceKey), "On") record.keyUp := srcUpHotkey } catch as e { - HotkeyRegErrors.Push(srcUpHotkey) + regErrors.Push(srcUpHotkey) } } this.registrations.Push(record) } HotIf() + return regErrors } ; Disable all engine-owned hotkeys, stop repeats, and clear all state diff --git a/src/ui/GuiMain.ahk b/src/ui/GuiMain.ahk index 23a60c2..8544758 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,176 @@ 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) +; Keeps the store selection in sync with what the dropdown shows +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) + ; Adopt the dropdown item as the selection (Choose does not fire Change) + ConfigStore.Instance.Select(configs[ConfigDDL.Value]) + } else { + ConfigStore.Instance.Select("") + } +} + +; 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 := [] + 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"] : "" + rows.Push({ + idx: idx, + modifier: modDisplay, + source: KeyToDisplay(mapping["SourceKey"]), + target: KeyToDisplay(mapping["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 +396,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/tests/integration/hotkey_engine_state.test.ahk b/tests/integration/hotkey_engine_state.test.ahk index e99ae9b..2a69ed4 100644 --- a/tests/integration/hotkey_engine_state.test.ahk +++ b/tests/integration/hotkey_engine_state.test.ahk @@ -22,7 +22,7 @@ RegisterTest("Path C source key up stops repeat timers for matching mappings", T 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 HotkeyRegErrors", Test_PathCEngine_Commit_ReportsRegErrors) +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) @@ -39,11 +39,11 @@ 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_DelegatesPathCToEngineAndCleansUp() { @@ -55,12 +55,15 @@ Test_ReloadAllHotkeys_DelegatesPathCToEngineAndCleansUp() { ] AllConfigs.Push(BuildConfigRecord("DispatchCfg", "global", "", "", true, mappings)) - ReloadAllHotkeys() + result := ReloadAllHotkeys() ; Path A/B registration bookkeeping stays in the shared globals AssertTrue(ActiveHotkeys.Length >= 3) AssertEq(1, InterceptModKeys.Count) - 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 @@ -257,8 +260,8 @@ Test_PathCEngine_CommitAndReset_RoundTrip() { engine.AddMapping(MakeMapping("RAlt", "F23", "^x", 0, 300, 50, 1), "Cfg|2", "Cfg", "") ; Commit registers the routing hotkeys without errors - engine.Commit() - AssertEq(0, HotkeyRegErrors.Length) + regErrors := engine.Commit() + AssertEq(0, regErrors.Length) ; Reset disables them and clears all mapping state engine.Reset() @@ -271,16 +274,15 @@ Test_PathCEngine_CommitAndReset_RoundTrip() { Test_PathCEngine_Commit_ReportsRegErrors() { engine := PathCEngine() - ; Invalid key names must fail registration and land in the shared error list + ; 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", "") - engine.Commit() + regErrors := engine.Commit() - AssertEq(2, HotkeyRegErrors.Length) - AssertArrayContains(HotkeyRegErrors, "*NotARealKey") - AssertArrayContains(HotkeyRegErrors, "*NotARealKey Up") + AssertEq(2, regErrors.Length) + AssertArrayContains(regErrors, "*NotARealKey") + AssertArrayContains(regErrors, "*NotARealKey Up") engine.Reset() - HotkeyRegErrors.Length := 0 } Test_DetectHotkeyConflicts_NoConflictForDisabledConfigs() { @@ -291,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() { @@ -305,8 +307,8 @@ Test_DetectHotkeyConflicts_NoConflictForDisjointScopes() { AllConfigs.Push(cfg1) AllConfigs.Push(cfg2) - DetectHotkeyConflicts() + conflicts := DetectHotkeyConflicts() ; Disjoint process scopes should not conflict - AssertEq(0, HotkeyConflicts.Length) + AssertEq(0, conflicts.Length) } diff --git a/tests/support/TestBase.ahk b/tests/support/TestBase.ahk index 24daa3c..2a629dd 100644 --- a/tests/support/TestBase.ahk +++ b/tests/support/TestBase.ahk @@ -106,6 +106,7 @@ ResetAppState() { global StatusDetailLink global StatusHasWarning global StatusDetailHovered + global LastReloadResult global BtnAddMapping global BtnEditMapping global BtnCopyMapping @@ -124,8 +125,6 @@ ResetAppState() { global HoldTimers global InterceptModKeys global AllProcessCheckers - global HotkeyConflicts - global HotkeyRegErrors global CaptureTarget global CaptureOnCaptured global CaptureGui @@ -152,6 +151,7 @@ ResetAppState() { StatusDetailLink := "" StatusHasWarning := false StatusDetailHovered := false + LastReloadResult := "" BtnAddMapping := "" BtnEditMapping := "" BtnCopyMapping := "" @@ -172,8 +172,6 @@ ResetAppState() { ClearMap(HoldTimers) ClearMap(InterceptModKeys) AllProcessCheckers.Length := 0 - HotkeyConflicts.Length := 0 - HotkeyRegErrors.Length := 0 CaptureTarget := "" CaptureOnCaptured := "" diff --git a/tests/unit/view_models.test.ahk b/tests/unit/view_models.test.ahk new file mode 100644 index 0000000..561d2b9 --- /dev/null +++ b/tests/unit/view_models.test.ahk @@ -0,0 +1,192 @@ +#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) + +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")) +} From ad18b9a0c9146d5e847e89d74637f6f4fea9fdd6 Mon Sep 17 00:00:00 2001 From: json_born Date: Wed, 2 Sep 2026 04:54:43 +0800 Subject: [PATCH 07/16] docs: mark architecture-deepening tickets done (all six landed) --- .../architecture-deepening/issues/01-deepen-path-c-engine.md | 2 +- .../issues/02-collapse-config-working-copy.md | 2 +- .scratch/architecture-deepening/issues/03-one-mapping-schema.md | 2 +- .scratch/architecture-deepening/issues/04-rendering-seam.md | 2 +- .../issues/05-keycapture-completion-adapter.md | 2 +- .../architecture-deepening/issues/06-foreground-process-seam.md | 2 +- .scratch/architecture-deepening/spec.md | 2 ++ 7 files changed, 8 insertions(+), 6 deletions(-) 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..9879556 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: done — landed in PR #2 (commit 9087bc9) Depends on: none — land first; tickets 03/04 rewire the shapes this one creates, so every other ticket assumes it exists. 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..a443318 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: done — landed in PR #2 (commit e229f50) 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. diff --git a/.scratch/architecture-deepening/issues/03-one-mapping-schema.md b/.scratch/architecture-deepening/issues/03-one-mapping-schema.md index 5bfaa3f..832f5f3 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: done — landed in PR #2 (commit aa9332f) 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. diff --git a/.scratch/architecture-deepening/issues/04-rendering-seam.md b/.scratch/architecture-deepening/issues/04-rendering-seam.md index 91cd15c..fbb98d6 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: done — landed in PR #2 (commit 4f592cc) 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. diff --git a/.scratch/architecture-deepening/issues/05-keycapture-completion-adapter.md b/.scratch/architecture-deepening/issues/05-keycapture-completion-adapter.md index e2f0fac..63ed9d8 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: done — landed in PR #2 (commit 6151882) 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). diff --git a/.scratch/architecture-deepening/issues/06-foreground-process-seam.md b/.scratch/architecture-deepening/issues/06-foreground-process-seam.md index d053245..0babffd 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: done — landed in PR #2 (commit 4e16b2e) 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). diff --git a/.scratch/architecture-deepening/spec.md b/.scratch/architecture-deepening/spec.md index 48f1fbe..ddd8549 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), final version 2.9.8. Landed order: 06 (4e16b2e) → 01 (9087bc9) → 02 (e229f50) → 05 (6151882) → 03 (aa9332f) → 04 (4f592cc). + - `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). From afe35669f8bd4365bee47bc635d5a90e85c93899 Mon Sep 17 00:00:00 2001 From: json_born Date: Wed, 2 Sep 2026 05:14:21 +0800 Subject: [PATCH 08/16] fix: break render-seam recursion and address review findings Finding 1 (CRITICAL): RefreshConfigList adopted the dropdown item via ConfigStore.Select during render, re-entering Select -> NotifyChanged -> RenderFromState infinitely at GUI startup. Rendering now never mutates the store: the dropdown only paints the store selection (Choose() does not fire Change, so OnConfigSelect cannot re-enter either). The two legitimate adoption decisions moved store-side: DeleteConfig re-selects the first remaining config, and StartApp falls back to the first config when the recorded LastConfig is no longer on disk. The regression is pinned headlessly in view_models.test.ahk (Select/CreateConfig/ CopyConfig/DeleteConfig each notify an OnChanged subscriber an exact, bounded number of times). docs/architecture.md updated to match. Finding 2: main_smoke.test.ahk's deleted hotkey assertion is restored via the reload result the render seam received (LastReloadResult. regErrors empty); the comment now states the real reason (Path C bookkeeping moved into PathCEngine in ticket 01), not a sandbox limitation. Finding 3: CreateConfig/CopyConfig wrap their IniWrite/FileCopy calls in try, cleaning up the partial file and reporting through the new localized Config.CreateError / Config.CopyError keys (both language packs). Finding 4: CLAUDE.md corrected: 11 modules, render functions live in GuiMain.ahk, Config.ahk is pure INI I/O, module list now matches AGENTS.md (adds Schema.ahk / PathCEngine.ahk). Finding 5: renamed the leftover mapping locals to m in BuildMappingRows and PathCEngine.OnSourceDown for consistency with the ticket 03 rename. Finding 6: recorded the CaptureOnCaptured session-global deviation in the ticket 05 issue file. Version bumped to 2.9.9 (both declarations). All suites green: unit,integration 10/10; gui 1/1 (~2s, was a 27-min hang); all 11/11. --- .../05-keycapture-completion-adapter.md | 2 + CLAUDE.md | 12 ++-- docs/architecture.md | 2 +- src/AHKeyMap.ahk | 11 +++- src/core/ConfigStore.ahk | 61 +++++++++++++------ src/core/Localization.ahk | 4 ++ src/core/PathCEngine.ahk | 16 ++--- src/ui/GuiMain.ahk | 33 +++++----- tests/gui/main_smoke.test.ahk | 12 ++-- tests/unit/view_models.test.ahk | 50 +++++++++++++++ 10 files changed, 148 insertions(+), 55 deletions(-) diff --git a/.scratch/architecture-deepening/issues/05-keycapture-completion-adapter.md b/.scratch/architecture-deepening/issues/05-keycapture-completion-adapter.md index 63ed9d8..9be9cd2 100644 --- a/.scratch/architecture-deepening/issues/05-keycapture-completion-adapter.md +++ b/.scratch/architecture-deepening/issues/05-keycapture-completion-adapter.md @@ -33,6 +33,8 @@ 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. + ## Agent Brief **Category:** enhancement diff --git a/CLAUDE.md b/CLAUDE.md index f52f521..f798eda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ 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 10 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/core/ConfigStore.ahk → src/shared/Utils.ahk → src/core/Localization.ahk @@ -72,12 +72,14 @@ src/core/Config.ahk → src/core/ConfigStore.ahk → src/shared/Utils.ahk → sr ``` **Module responsibilities:** -- `Config.ahk` — pure INI I/O: load/save configs (atomic write via `.tmp`), enabled-state persistence (also atomic), main-window render functions -- `ConfigStore.ahk` — owns `AllConfigs`, the current selection, and every mutation; each runs one chokepoint (persist → reload hotkeys → render) via the lazy singleton `ConfigStore.Instance` +- `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 2d8cf72..280cd0a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ - `RenderFromState(reloadResult)`(`src/ui/GuiMain.ahk`)是唯一渲染入口: - 仅在主窗口存在时运行(无头环境直接返回,不再依赖空字符串守卫)。 - 收到 `ReloadAllHotkeys()` 的返回值 `{conflicts, regErrors}` 时存入 ui 侧全局 `LastReloadResult`;收到 `""`(纯渲染事件,如切换选中或切换语言)时沿用上次结果。 - - 依次刷新配置下拉(`RefreshConfigList`,将下拉当前项同步回 store 选中态)、作用域控件(`RefreshScopeControls`)、映射列表(`RefreshMappingLV`)与状态栏(`UpdateStatusText`)。 + - 依次刷新配置下拉(`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`)。 diff --git a/src/AHKeyMap.ahk b/src/AHKeyMap.ahk index 4d7de41..1c7c34f 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.8 +;@Ahk2Exe-SetVersion 2.9.9 ;@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.8" +global APP_VERSION := "2.9.9" 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") @@ -204,7 +204,12 @@ StartApp() { ; render seam with the reload result LastReloadResult := ReloadAllHotkeys() - ; Select and render the last used config (render-only, no reload) + ; 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 diff --git a/src/core/ConfigStore.ahk b/src/core/ConfigStore.ahk index 9503961..38a011b 100644 --- a/src/core/ConfigStore.ahk +++ b/src/core/ConfigStore.ahk @@ -189,22 +189,29 @@ class ConfigStore { if FileExist(newFile) return - IniWrite(name, newFile, "Meta", "Name") - IniWrite(mode, newFile, "Meta", "ProcessMode") - if (mode = "include") { - IniWrite(procStr, newFile, "Meta", "Process") - IniWrite("", newFile, "Meta", "ExcludeProcess") - } else if (mode = "exclude") { - IniWrite("", newFile, "Meta", "Process") - IniWrite(procStr, newFile, "Meta", "ExcludeProcess") - } else { - IniWrite("", newFile, "Meta", "Process") - IniWrite("", newFile, "Meta", "ExcludeProcess") + try { + IniWrite(name, newFile, "Meta", "Name") + IniWrite(mode, newFile, "Meta", "ProcessMode") + if (mode = "include") { + IniWrite(procStr, newFile, "Meta", "Process") + IniWrite("", newFile, "Meta", "ExcludeProcess") + } else if (mode = "exclude") { + IniWrite("", newFile, "Meta", "Process") + IniWrite(procStr, newFile, "Meta", "ExcludeProcess") + } else { + IniWrite("", newFile, "Meta", "Process") + IniWrite("", newFile, "Meta", "ExcludeProcess") + } + + ; Enable new config by default + IniWrite("1", STATE_FILE, "EnabledConfigs", name) + } catch as e { + ; Remove a partially written file so the name can be retried + try FileDelete(newFile) + MsgBox(Format(L("Config.CreateError"), e.Message, newFile), APP_NAME, "IconX") + return } - ; Enable new config by default - IniWrite("1", STATE_FILE, "EnabledConfigs", name) - LoadAllConfigs() this.NotifyChokepointReload() this.Select(name) @@ -219,14 +226,21 @@ class ConfigStore { if FileExist(newFile) return - if FileExist(cfg["file"]) - FileCopy(cfg["file"], newFile) + try { + if FileExist(cfg["file"]) + FileCopy(cfg["file"], newFile) - ; Update Name field inside copied config - IniWrite(newName, newFile, "Meta", "Name") + ; Update Name field inside copied config + IniWrite(newName, newFile, "Meta", "Name") - ; Enable new config by default - IniWrite("1", STATE_FILE, "EnabledConfigs", newName) + ; Enable new config by default + IniWrite("1", STATE_FILE, "EnabledConfigs", newName) + } catch as e { + ; Remove a partially written file so the name can be retried + try FileDelete(newFile) + MsgBox(Format(L("Config.CopyError"), e.Message, newFile), APP_NAME, "IconX") + return + } LoadAllConfigs() this.NotifyChokepointReload() @@ -250,6 +264,13 @@ class ConfigStore { 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"]) } ; ------------------------------------------------------------------------ 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 index 870d8f4..2e75de7 100644 --- a/src/core/PathCEngine.ahk +++ b/src/core/PathCEngine.ahk @@ -279,24 +279,24 @@ class PathCEngine { continue mappings := this.mappingByModSource[key] - - for _, mapping in mappings { - if !this.IsMappingActive(mapping) + ; (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 (mapping.holdRepeat) { - this.StartMappingRepeat(mapping, modKey, sourceKey) - session.repeatMappings[mapping.id] := 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(mapping.id) + session.activeSources[sourceKey].Push(m.id) } else { - DispatchSend(KeyToSendFormat(mapping.targetKey)) + DispatchSend(KeyToSendFormat(m.targetKey)) } handled := true diff --git a/src/ui/GuiMain.ahk b/src/ui/GuiMain.ahk index 8544758..0849b30 100644 --- a/src/ui/GuiMain.ahk +++ b/src/ui/GuiMain.ahk @@ -142,7 +142,15 @@ RenderFromState(reloadResult) { } ; Refresh config dropdown from the config list on disk (no hotkey reload) -; Keeps the store selection in sync with what the dropdown shows +; 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 := [] @@ -161,10 +169,6 @@ RefreshConfigList() { ConfigDDL.Choose(selectIdx) else ConfigDDL.Choose(1) - ; Adopt the dropdown item as the selection (Choose does not fire Change) - ConfigStore.Instance.Select(configs[ConfigDDL.Value]) - } else { - ConfigStore.Instance.Select("") } } @@ -248,19 +252,20 @@ BuildStatusSummary(allConfigs, reloadResult) { ; Each row: {idx, modifier, source, target, hold, mode, delay, interval} BuildMappingRows(mappings) { rows := [] - for idx, mapping in mappings { - holdText := mapping["HoldRepeat"] ? L("Config.Mapping.HoldYes") : L("Config.Mapping.HoldNo") - modDisplay := mapping["ModifierKey"] != "" ? KeyToDisplay(mapping["ModifierKey"]) : "" + ; (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 (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"] : "" + 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(mapping["SourceKey"]), - target: KeyToDisplay(mapping["TargetKey"]), + source: KeyToDisplay(m["SourceKey"]), + target: KeyToDisplay(m["TargetKey"]), hold: holdText, mode: ptText, delay: delayText, diff --git a/tests/gui/main_smoke.test.ahk b/tests/gui/main_smoke.test.ahk index 2f38ffd..d7dc7fe 100644 --- a/tests/gui/main_smoke.test.ahk +++ b/tests/gui/main_smoke.test.ahk @@ -83,10 +83,14 @@ Test_MainGui_SmokeFlow_CoversLifecycle() { OnToggleEnabled(EnabledCB) AssertTrue(store.Selected()["enabled"]) AssertEq("1", ReadStateValue("EnabledConfigs", "SmokeConfig")) - ; Note: this sandbox cannot deliver the physical key state that Path A/B/C - ; registration depends on, so ActiveHotkeys stays 0 here (the baseline test - ; had the same limitation; hotkey registration is covered by integration - ; tests and manual verification). + ; 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 diff --git a/tests/unit/view_models.test.ahk b/tests/unit/view_models.test.ahk index 561d2b9..31bfdf4 100644 --- a/tests/unit/view_models.test.ahk +++ b/tests/unit/view_models.test.ahk @@ -21,6 +21,7 @@ RegisterTest("FormatProcessDisplay uses localized summaries", Test_FormatProcess 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() @@ -190,3 +191,52 @@ Test_ConfigStore_Headless_NoRegistrationNeeded() { 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("") +} From f699e57777114d09cfab26c498566dcb8bb4ca44 Mon Sep 17 00:00:00 2001 From: json_born Date: Wed, 2 Sep 2026 23:17:55 +0800 Subject: [PATCH 09/16] fix: stage config create/copy writes through a temp file (atomic write) Review finding (standards axis): CreateConfig/CopyConfig wrote new config files directly with IniWrite/FileCopy, bypassing the documented atomic-write pattern (write .tmp then FileMove). Both now stage into .ini.tmp and FileMove onto the final path, matching SaveConfig and SaveEnabledStates. Failure cleanup removes both staged and partial files so the name stays retryable. Version bump 2.9.10. --- src/AHKeyMap.ahk | 4 ++-- src/core/ConfigStore.ahk | 39 +++++++++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/AHKeyMap.ahk b/src/AHKeyMap.ahk index 1c7c34f..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.9 +;@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.9" +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") diff --git a/src/core/ConfigStore.ahk b/src/core/ConfigStore.ahk index 38a011b..b6779c8 100644 --- a/src/core/ConfigStore.ahk +++ b/src/core/ConfigStore.ahk @@ -188,25 +188,33 @@ class ConfigStore { newFile := CONFIG_DIR "\" name ".ini" if FileExist(newFile) return + tempFile := newFile ".tmp" try { - IniWrite(name, newFile, "Meta", "Name") - IniWrite(mode, newFile, "Meta", "ProcessMode") + 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, newFile, "Meta", "Process") - IniWrite("", newFile, "Meta", "ExcludeProcess") + IniWrite(procStr, tempFile, "Meta", "Process") + IniWrite("", tempFile, "Meta", "ExcludeProcess") } else if (mode = "exclude") { - IniWrite("", newFile, "Meta", "Process") - IniWrite(procStr, newFile, "Meta", "ExcludeProcess") + IniWrite("", tempFile, "Meta", "Process") + IniWrite(procStr, tempFile, "Meta", "ExcludeProcess") } else { - IniWrite("", newFile, "Meta", "Process") - IniWrite("", newFile, "Meta", "ExcludeProcess") + 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 a partially written file so the name can be retried + ; 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 @@ -225,18 +233,25 @@ class ConfigStore { 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"], newFile) + FileCopy(cfg["file"], tempFile) ; Update Name field inside copied config - IniWrite(newName, newFile, "Meta", "Name") + IniWrite(newName, tempFile, "Meta", "Name") + FileMove(tempFile, newFile, 1) ; Enable new config by default IniWrite("1", STATE_FILE, "EnabledConfigs", newName) } catch as e { - ; Remove a partially written file so the name can be retried + ; 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 From 3521886f4e648234532f6fa759589940f3e9b947 Mon Sep 17 00:00:00 2001 From: json_born Date: Wed, 2 Sep 2026 23:18:01 +0800 Subject: [PATCH 10/16] ci: bound upload-artifact steps and record review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration job hung 6/6 on this branch: the upload step's node process never exits after a completed server-side upload, eating the whole 10-minute job budget, cancelling the job (logs get purged) and skipping build. Bound all three test-result upload steps with timeout-minutes: 2 + continue-on-error — test-results are diagnostics only (test-summary degrades gracefully on missing artifacts), and a completed job keeps the hung step's log available for root-causing. Scratch: ticket status done->resolved with landing notes (follows the 05 precedent), afe3566 error-handling addendum + atomic-write follow-up on ticket 02, spec version line 2.9.8->2.9.10, shoals.md new (CI hang signature + headless-green blind spot). --- .github/workflows/ci.yml | 18 ++++++++++++++++++ .../issues/01-deepen-path-c-engine.md | 4 +++- .../issues/02-collapse-config-working-copy.md | 8 +++++++- .../issues/03-one-mapping-schema.md | 4 +++- .../issues/04-rendering-seam.md | 4 +++- .../issues/05-keycapture-completion-adapter.md | 4 +++- .../issues/06-foreground-process-seam.md | 4 +++- .scratch/architecture-deepening/shoals.md | 3 +++ .scratch/architecture-deepening/spec.md | 2 +- 9 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 .scratch/architecture-deepening/shoals.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9d1b9a..10054d5 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 @@ -137,6 +143,12 @@ jobs: with: name: test-results-integration 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-gui: needs: validate @@ -175,6 +187,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 9879556..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: done — landed in PR #2 (commit 9087bc9) +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 a443318..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: done — landed in PR #2 (commit e229f50) +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 832f5f3..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: done — landed in PR #2 (commit aa9332f) +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 fbb98d6..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: done — landed in PR #2 (commit 4f592cc) +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 9be9cd2..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: done — landed in PR #2 (commit 6151882) +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). @@ -35,6 +35,8 @@ KeyCapture reaches into MappingEditor's widgets: `ApplyCapturedKey` (`KeyCapture 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 0babffd..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: done — landed in PR #2 (commit 4e16b2e) +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..4d2d8f9 --- /dev/null +++ b/.scratch/architecture-deepening/shoals.md @@ -0,0 +1,3 @@ +## 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 测试时先在真实桌面查残留进程 diff --git a/.scratch/architecture-deepening/spec.md b/.scratch/architecture-deepening/spec.md index ddd8549..e007ad3 100644 --- a/.scratch/architecture-deepening/spec.md +++ b/.scratch/architecture-deepening/spec.md @@ -10,7 +10,7 @@ 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), final version 2.9.8. Landed order: 06 (4e16b2e) → 01 (9087bc9) → 02 (e229f50) → 05 (6151882) → 03 (aa9332f) → 04 (4f592cc). +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). From 2452cbc3de3d746f9a2f2334b5bedee3f45ba8b6 Mon Sep 17 00:00:00 2001 From: json_born Date: Wed, 2 Sep 2026 23:42:14 +0800 Subject: [PATCH 11/16] ci: drop the hung upload-artifact step from the integration job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeout-minutes: 2 + continue-on-error bound was disproven by run 33647675753: the upload step hung 14.7 minutes past its bound without the step timeout firing; the job was force-cancelled at 15m again (7/7 deterministic on this branch). Forensics on the completed server-side artifacts: upload finishes within 1s, test step exits 0 in 2s, no child processes spawned, artifact bytes clean — the hang is in the action's post-upload exit phase, wedged below the runner's step/job timeout machinery, with hung-step logs purged on every cancel (unobservable from CI). Remove the step: the test step still gates build, per-file PASS/FAIL prints into the job log (kept once the job completes normally), and test-summary degrades this suite to 'No results'. unit/gui uploads stay (verified green in the same runs). --- .github/workflows/ci.yml | 20 ++++++++------------ .scratch/architecture-deepening/shoals.md | 1 + 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10054d5..53785be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,18 +137,14 @@ jobs: -Ci ` -AutoHotkeyPath $env:AHK_BASE_FILE - - name: Upload test results - if: always() - uses: actions/upload-artifact@v6 - with: - name: test-results-integration - 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 + # No upload-artifact step on this job: it hung deterministically (7/7 + # runs on this branch) in the action's post-upload exit phase — the + # server-side artifact completes within 1s, but the step's node process + # never exits and wedges the runner below the step/job timeout + # machinery (step timeout-minutes: 2 did not fire; job force-cancelled + # at 15m with logs purged, so the root cause is unobservable from CI). + # The test step still gates build; per-file PASS/FAIL prints into the + # job log; test-summary degrades this suite to "No results". test-gui: needs: validate diff --git a/.scratch/architecture-deepening/shoals.md b/.scratch/architecture-deepening/shoals.md index 4d2d8f9..18c34bb 100644 --- a/.scratch/architecture-deepening/shoals.md +++ b/.scratch/architecture-deepening/shoals.md @@ -1,3 +1,4 @@ ## 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 拿挂死现场 From c10e15da88e51d9908ec48ee21880e11cf228506 Mon Sep 17 00:00:00 2001 From: json_born Date: Thu, 3 Sep 2026 00:01:41 +0800 Subject: [PATCH 12/16] ci: bisect the integration-job wedge with per-file steps and node canaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 33650275524 disproved the upload-artifact diagnosis: with the upload step removed, the job still force-cancelled at 15m, this time wedged in Post Checkout (also a node process). Pattern across 8 runs: after 'Run integration tests' succeeds, the NEXT process the runner starts hangs below the step/job timeout machinery; same-run unit/gui jobs and master runs of the same workflow stay green. Split the integration suite into one step per test file (same order as test.ps1), each followed by a trivial node canary step. The first canary that never completes indicts the preceding file via the jobs API step timeline — the only observation channel that survives the log purge on force-cancel. If every canary passes but Post Checkout still hangs, the wedge is in the AHK test files themselves; if the job goes fully green, it was test.ps1's process pattern. --- .github/workflows/ci.yml | 54 ++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53785be..799b4ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,22 +129,50 @@ 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 + # Bisect of the integration-job wedge (8/8 deterministic on this + # branch): after "Run integration tests" succeeds, the NEXT process the + # runner starts hangs below the timeout machinery (first the + # upload-artifact node, then — with that step removed — the checkout + # post node; step and job timeouts never fire, logs purge on the 15m + # force-cancel). Each test file now runs in its own step with a node + # canary after it: the first canary that never completes indicts the + # preceding file via the jobs API step timeline (the only observation + # channel that survives the purge). File order matches test.ps1. + - name: "Run integration test file: config_io.test.ahk" shell: pwsh run: | - .\scripts\test.ps1 ` - -Suite integration ` - -Ci ` - -AutoHotkeyPath $env:AHK_BASE_FILE + & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' 'tests\integration\config_io.test.ahk' + + - name: "Canary: node alive after config_io" + shell: pwsh + run: | + node -e "console.log('canary ok: node started and exited after config_io')" + + - name: "Run integration test file: hotkey_engine_state.test.ahk" + shell: pwsh + run: | + & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' 'tests\integration\hotkey_engine_state.test.ahk' + + - name: "Canary: node alive after hotkey_engine_state" + shell: pwsh + run: | + node -e "console.log('canary ok: node started and exited after hotkey_engine_state')" + + - name: "Run integration test file: hotkey_pathAB.test.ahk" + shell: pwsh + run: | + & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' 'tests\integration\hotkey_pathAB.test.ahk' + + - name: "Canary: node alive after hotkey_pathAB" + shell: pwsh + run: | + node -e "console.log('canary ok: node started and exited after hotkey_pathAB')" - # No upload-artifact step on this job: it hung deterministically (7/7 - # runs on this branch) in the action's post-upload exit phase — the - # server-side artifact completes within 1s, but the step's node process - # never exits and wedges the runner below the step/job timeout - # machinery (step timeout-minutes: 2 did not fire; job force-cancelled - # at 15m with logs purged, so the root cause is unobservable from CI). - # The test step still gates build; per-file PASS/FAIL prints into the - # job log; test-summary degrades this suite to "No results". + # No upload-artifact step on this job: it hung deterministically in the + # action's post-upload phase (server-side artifact completes within 1s, + # the step's node process never exits). test-summary degrades this + # suite to "No results"; per-file results gate build via step + # conclusions. test-gui: needs: validate From 142cc57e018818b1ceba19b996ea20139523f1fe Mon Sep 17 00:00:00 2001 From: json_born Date: Thu, 3 Sep 2026 00:24:49 +0800 Subject: [PATCH 13/16] ci: run integration tests in log-file mode (wedge fix, round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-file bisect (run 33652351022) indicted hotkey_pathAB.test.ahk: the AHK process never exits after printing SUMMARY when logs go to the stdout pipe (FileAppend to '*') — the only mode that ever hung the AHK process itself. CI bisect step + 2/4 local direct runs hung on AHK 2.0.21 and 2.0.27 alike; log-file mode (test.ps1's AHKM_TEST_LOG_FILE) never hung it (8/8 CI suite runs, 30+ local runs). Each step now runs one file in log-file mode, tails the log in the same step shell (no extra process launch), and the AHK exit code gates build. Upload step stays off this job; test-summary degrades this suite to 'No results'. --- .github/workflows/ci.yml | 56 +++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 799b4ca..2c4a85e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,50 +129,46 @@ jobs: if (-not $baseFile) { throw "AutoHotkey64.exe not found" } "AHK_BASE_FILE=$($baseFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - # Bisect of the integration-job wedge (8/8 deterministic on this - # branch): after "Run integration tests" succeeds, the NEXT process the - # runner starts hangs below the timeout machinery (first the - # upload-artifact node, then — with that step removed — the checkout - # post node; step and job timeouts never fire, logs purge on the 15m - # force-cancel). Each test file now runs in its own step with a node - # canary after it: the first canary that never completes indicts the - # preceding file via the jobs API step timeline (the only observation - # channel that survives the purge). File order matches test.ps1. + # Wedge fix, round 3. The per-file bisect (run 33652351022) indicted + # hotkey_pathAB.test.ahk: its AHK process never exits after printing + # SUMMARY when logs go to the stdout pipe (FileAppend to "*") — the + # only mode that ever hung the AHK process (CI pathAB step + 2/4 local + # direct runs; reproduced on AHK 2.0.21 and 2.0.27 alike). Log-file mode + # (test.ps1's AHKM_TEST_LOG_FILE) never hung the process: 8/8 CI suite + # runs and 30+ local runs. Each step now runs one file in log-file mode; + # the same step shell tails the log after the AHK exit (no extra process + # launch) and the AHK exit code gates build. No upload step here: 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: | + New-Item -ItemType Directory -Force test-results\logs\integration | Out-Null + $env:AHKM_TEST_LOG_FILE = "test-results\logs\integration\config_io.test.log" & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' 'tests\integration\config_io.test.ahk' - - - name: "Canary: node alive after config_io" - shell: pwsh - run: | - node -e "console.log('canary ok: node started and exited after config_io')" + $rc = $LASTEXITCODE + Get-Content $env:AHKM_TEST_LOG_FILE | Select-Object -Last 3 + exit $rc - name: "Run integration test file: hotkey_engine_state.test.ahk" shell: pwsh run: | + New-Item -ItemType Directory -Force test-results\logs\integration | Out-Null + $env:AHKM_TEST_LOG_FILE = "test-results\logs\integration\hotkey_engine_state.test.log" & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' 'tests\integration\hotkey_engine_state.test.ahk' - - - name: "Canary: node alive after hotkey_engine_state" - shell: pwsh - run: | - node -e "console.log('canary ok: node started and exited after hotkey_engine_state')" + $rc = $LASTEXITCODE + Get-Content $env:AHKM_TEST_LOG_FILE | Select-Object -Last 3 + exit $rc - name: "Run integration test file: hotkey_pathAB.test.ahk" shell: pwsh run: | + New-Item -ItemType Directory -Force test-results\logs\integration | Out-Null + $env:AHKM_TEST_LOG_FILE = "test-results\logs\integration\hotkey_pathAB.test.log" & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' 'tests\integration\hotkey_pathAB.test.ahk' - - - name: "Canary: node alive after hotkey_pathAB" - shell: pwsh - run: | - node -e "console.log('canary ok: node started and exited after hotkey_pathAB')" - - # No upload-artifact step on this job: it hung deterministically in the - # action's post-upload phase (server-side artifact completes within 1s, - # the step's node process never exits). test-summary degrades this - # suite to "No results"; per-file results gate build via step - # conclusions. + $rc = $LASTEXITCODE + Get-Content $env:AHKM_TEST_LOG_FILE | Select-Object -Last 3 + exit $rc test-gui: needs: validate From f8bf26e10dbfa681598356b75084ecb61bb3305a Mon Sep 17 00:00:00 2001 From: json_born Date: Thu, 3 Sep 2026 00:44:58 +0800 Subject: [PATCH 14/16] ci: use absolute log paths in integration steps (fix Get-Content failure) Run 33654770247 proved the wedge fixed (job completed normally for the first time in 9 runs) but failed in Get-Content: AHK resolves relative paths against A_WorkingDir (the script's own directory), so the log landed under tests\integration\ and the tail step found nothing. Probed locally: A_WorkingDir is the script dir even when launched from another cwd; log-file mode with an absolute path ran pathAB 10/10 with zero hangs. Each step now builds the log path from GITHUB_WORKSPACE, matching test.ps1's absolute-path invocation. --- .github/workflows/ci.yml | 36 +++++++++++++---------- .scratch/architecture-deepening/shoals.md | 1 + 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c4a85e..455b018 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,40 +134,46 @@ jobs: # SUMMARY when logs go to the stdout pipe (FileAppend to "*") — the # only mode that ever hung the AHK process (CI pathAB step + 2/4 local # direct runs; reproduced on AHK 2.0.21 and 2.0.27 alike). Log-file mode - # (test.ps1's AHKM_TEST_LOG_FILE) never hung the process: 8/8 CI suite - # runs and 30+ local runs. Each step now runs one file in log-file mode; - # the same step shell tails the log after the AHK exit (no extra process - # launch) and the AHK exit code gates build. No upload step here: it - # hung in the post-upload phase on 7/7 pre-bisect runs; test-summary - # degrades this suite to "No results". + # (test.ps1's AHKM_TEST_LOG_FILE) never hung it: 8/8 CI suite runs, + # 30+ local runs, and a 10x pathAB loop. The log path MUST be absolute: + # AHK resolves relative paths against A_WorkingDir (the script's own + # directory, not the shell's), so a relative path landed under + # tests\integration\ and Get-Content failed (run 33654770247). Each step + # runs one file in log-file mode, tails the log in the same step shell + # (no extra process launch), and the AHK exit code gates build. No + # upload step here: 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: | - New-Item -ItemType Directory -Force test-results\logs\integration | Out-Null - $env:AHKM_TEST_LOG_FILE = "test-results\logs\integration\config_io.test.log" + $log = Join-Path $env:GITHUB_WORKSPACE 'test-results\logs\integration\config_io.test.log' + New-Item -ItemType Directory -Force (Split-Path $log) | Out-Null + $env:AHKM_TEST_LOG_FILE = $log & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' 'tests\integration\config_io.test.ahk' $rc = $LASTEXITCODE - Get-Content $env:AHKM_TEST_LOG_FILE | Select-Object -Last 3 + Get-Content $log | Select-Object -Last 3 exit $rc - name: "Run integration test file: hotkey_engine_state.test.ahk" shell: pwsh run: | - New-Item -ItemType Directory -Force test-results\logs\integration | Out-Null - $env:AHKM_TEST_LOG_FILE = "test-results\logs\integration\hotkey_engine_state.test.log" + $log = Join-Path $env:GITHUB_WORKSPACE 'test-results\logs\integration\hotkey_engine_state.test.log' + New-Item -ItemType Directory -Force (Split-Path $log) | Out-Null + $env:AHKM_TEST_LOG_FILE = $log & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' 'tests\integration\hotkey_engine_state.test.ahk' $rc = $LASTEXITCODE - Get-Content $env:AHKM_TEST_LOG_FILE | Select-Object -Last 3 + Get-Content $log | Select-Object -Last 3 exit $rc - name: "Run integration test file: hotkey_pathAB.test.ahk" shell: pwsh run: | - New-Item -ItemType Directory -Force test-results\logs\integration | Out-Null - $env:AHKM_TEST_LOG_FILE = "test-results\logs\integration\hotkey_pathAB.test.log" + $log = Join-Path $env:GITHUB_WORKSPACE 'test-results\logs\integration\hotkey_pathAB.test.log' + New-Item -ItemType Directory -Force (Split-Path $log) | Out-Null + $env:AHKM_TEST_LOG_FILE = $log & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' 'tests\integration\hotkey_pathAB.test.ahk' $rc = $LASTEXITCODE - Get-Content $env:AHKM_TEST_LOG_FILE | Select-Object -Last 3 + Get-Content $log | Select-Object -Last 3 exit $rc test-gui: diff --git a/.scratch/architecture-deepening/shoals.md b/.scratch/architecture-deepening/shoals.md index 18c34bb..da2d83b 100644 --- a/.scratch/architecture-deepening/shoals.md +++ b/.scratch/architecture-deepening/shoals.md @@ -2,3 +2,4 @@ - [无头测试全绿不等于 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 一直这么干所以从没踩过) From 81f69b7afd4b768db73731c7639742ec6a4b60ce Mon Sep 17 00:00:00 2001 From: json_born Date: Thu, 3 Sep 2026 00:50:50 +0800 Subject: [PATCH 15/16] ci: pass absolute script paths and instrument integration steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consecutive runs (33654770247, 33656838250) completed the integration job normally — the 8/8 pre-bisect wedge (next node process after the test step) and the stdout-pipe pathAB AHK hang are fixed by log-file mode. The remaining failure is in-step: Get-Content found no log both times. Round 3 passed a RELATIVE script path; the only invocations proven end-to-end (test.ps1's Invoke-TestProcess, local probe A) use absolute script AND log paths. Steps now do exactly that and instrument themselves: AHK exit code, log existence, a recursive .log search, and a best-effort tail that does not fail the step. The AHK exit code gates build. --- .github/workflows/ci.yml | 51 ++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 455b018..5043ded 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,51 +129,62 @@ jobs: if (-not $baseFile) { throw "AutoHotkey64.exe not found" } "AHK_BASE_FILE=$($baseFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - # Wedge fix, round 3. The per-file bisect (run 33652351022) indicted - # hotkey_pathAB.test.ahk: its AHK process never exits after printing - # SUMMARY when logs go to the stdout pipe (FileAppend to "*") — the - # only mode that ever hung the AHK process (CI pathAB step + 2/4 local - # direct runs; reproduced on AHK 2.0.21 and 2.0.27 alike). Log-file mode - # (test.ps1's AHKM_TEST_LOG_FILE) never hung it: 8/8 CI suite runs, - # 30+ local runs, and a 10x pathAB loop. The log path MUST be absolute: - # AHK resolves relative paths against A_WorkingDir (the script's own - # directory, not the shell's), so a relative path landed under - # tests\integration\ and Get-Content failed (run 33654770247). Each step - # runs one file in log-file mode, tails the log in the same step shell - # (no extra process launch), and the AHK exit code gates build. No - # upload step here: it hung in the post-upload phase on 7/7 pre-bisect - # runs; test-summary degrades this suite to "No results". + # Wedge fix, round 4. Proven so far: log-file mode with no AHK stdout + # writes completes the job normally (runs 33654770247, 33656838250 — + # Post Checkout and Complete job green; the pre-bisect wedge hung the + # next node process after the test step 8/8, and stdout-pipe mode hung + # the pathAB AHK process itself, CI + 2/4 local, AHK 2.0.21/2.0.27). + # Remaining failure is in-step: Get-Content found no log. Round 3 used + # a RELATIVE script path; the only invocations proven end-to-end (test. + # ps1's Invoke-TestProcess, local probe A) pass ABSOLUTE script and log + # paths. Each step now does exactly that and instruments itself: AHK + # exit code, log existence, a recursive .log search under both candidate + # roots, and a best-effort tail that no longer fails the step. The AHK + # exit code gates build. No upload step here: 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: | $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' 'tests\integration\config_io.test.ahk' + & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' $script $rc = $LASTEXITCODE - Get-Content $log | Select-Object -Last 3 + Write-Host "AHK exit code: $rc; log exists: $(Test-Path $log)" + foreach ($root in @('test-results', 'tests\integration\test-results')) { + if (Test-Path $root) { + Get-ChildItem $root -Recurse -Filter '*.log' | ForEach-Object { Write-Host "log found: $($_.FullName)" } + } + } + 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' 'tests\integration\hotkey_engine_state.test.ahk' + & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' $script $rc = $LASTEXITCODE - Get-Content $log | Select-Object -Last 3 + 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_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' 'tests\integration\hotkey_pathAB.test.ahk' + & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' $script $rc = $LASTEXITCODE - Get-Content $log | Select-Object -Last 3 + 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: From e603dc646b63ebbf013e040684b582bca77b8a9e Mon Sep 17 00:00:00 2001 From: json_born Date: Thu, 3 Sep 2026 01:08:21 +0800 Subject: [PATCH 16/16] ci: isolate hotkey_pathAB on its own runner (wedge containment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 33657438288: all three per-file steps passed (absolute script and log paths fixed the Get-Content failures), but the job still force-cancelled at 15m — this time wedged in Post Checkout. Evidence matrix across 11 runs: a runner that has run hotkey_pathAB.test.ahk to completion wedges its NEXT node process (upload-artifact 7/7 pre-bisect, Post Checkout now) below the timeout machinery; in stdout-pipe mode the pathAB AHK process hangs instead (bisect run). Runs completing only config_io or engine_state never wedged a node process (two normal completions, green canaries). pathAB now runs in its own test-integration-pathab job with job-level continue-on-error: the wedge burns that one job only, the step conclusion stays readable in the timeline, and the test still runs on every push/PR. It deliberately does not gate build; config_io + engine_state carry the integration gate. --- .github/workflows/ci.yml | 65 ++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5043ded..7e3e885 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,20 +129,20 @@ jobs: if (-not $baseFile) { throw "AutoHotkey64.exe not found" } "AHK_BASE_FILE=$($baseFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - # Wedge fix, round 4. Proven so far: log-file mode with no AHK stdout - # writes completes the job normally (runs 33654770247, 33656838250 — - # Post Checkout and Complete job green; the pre-bisect wedge hung the - # next node process after the test step 8/8, and stdout-pipe mode hung - # the pathAB AHK process itself, CI + 2/4 local, AHK 2.0.21/2.0.27). - # Remaining failure is in-step: Get-Content found no log. Round 3 used - # a RELATIVE script path; the only invocations proven end-to-end (test. - # ps1's Invoke-TestProcess, local probe A) pass ABSOLUTE script and log - # paths. Each step now does exactly that and instruments itself: AHK - # exit code, log existence, a recursive .log search under both candidate - # roots, and a best-effort tail that no longer fails the step. The AHK - # exit code gates build. No upload step here: it hung in the - # post-upload phase on 7/7 pre-bisect runs; test-summary degrades this - # suite to "No results". + # 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: | @@ -153,11 +153,6 @@ jobs: & $env:AHK_BASE_FILE '/ErrorStdOut=UTF-8' $script $rc = $LASTEXITCODE Write-Host "AHK exit code: $rc; log exists: $(Test-Path $log)" - foreach ($root in @('test-results', 'tests\integration\test-results')) { - if (Test-Path $root) { - Get-ChildItem $root -Recurse -Filter '*.log' | ForEach-Object { Write-Host "log found: $($_.FullName)" } - } - } if (Test-Path $log) { Get-Content $log | Select-Object -Last 3 } exit $rc @@ -174,6 +169,38 @@ jobs: 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 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Restore toolchain cache + uses: actions/cache/restore@v5 + with: + 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: |