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: |