docs: 收口阶段3键盘路由与焦点回归结论

This commit is contained in:
Codex
2026-10-10 00:06:41 +08:00
parent 29df994f09
commit dd88a80b8c
8 changed files with 649 additions and 34 deletions
@@ -7,10 +7,10 @@
- 模块 ID:Module-20261001-0001
- 模块名称:通用输入采集、焦点与键盘路由系统
- 状态:实现中(v0.16;0C/0B 前置基线、阶段 1 输入地基及阶段 2 实现与回归归因均已关闭;可以进入阶段 3,键盘换轨尚未开始)
- 状态:实现中(v0.23;0C/0B 前置基线、阶段 1 输入地基、阶段 2 与阶段 3 六个切片均已实现并完成验收;阶段 4–6 尚未实现)
- 类型:新增模块 / 架构演进
- 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox
- 版本 / 分支:`master`,阶段 0A 实现基线 `c218c25`
- 版本 / 分支:`master`,阶段 3 实现提交 `29df994`;阶段 0A 实现基线 `c218c25`
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + MBCS 工程配置
- 负责人:Codex 协作设计
@@ -37,7 +37,7 @@
- v1 不自绘 IME 拼音组合串和候选列表,继续使用系统 IME UI。
- v1 不重写现有全部鼠标采集链;现有 EasyX 鼠标消息只在框架入口规范化为 `PointerEvent`。
- v1 不承诺完整 Unicode grapheme cluster 编辑,只保证不拆分 UTF-16 代理对。
- 本稿中的阶段 1 与阶段 2 已有实现记录和自动验证证据;阶段 3 至阶段 6 仍是设计目标,不应被误读为已经实现。
- 本稿中的阶段 1、阶段 2 与阶段 3 已有实现记录、自动验证和对应人工证据;阶段 3 已关闭验收。阶段 4–6 仍是设计目标,不应被误读为已经实现。
## 模块边界
@@ -61,8 +61,8 @@
- IMM32:`ImmGetCompositionStringW`、`ImmSetCompositionWindow`、`ImmSetCandidateWindow`、`ImmNotifyIME`。
- 宽字符 GDI:`TextOutW` / `DrawTextW` / `GetTextExtentPoint32W`。
- 对外能力 / API:
- 本稿只确定职责和接口形状,公共 API 名称在阶段 0 完成后再落地。
- 控件通过焦点能力、键盘处理、文本输入和 Tab stop 收集接口接入。
- 阶段 1–3 已落地 `NativeInputEvent`、`FocusManager`、`FocusTargetId`、`RoutedKeyEvent`、`KeyRouteResult` 和 `Control::handleKeyEvent()`;后续阶段不得再把这些完成态写成占位草案。
- 控件通过焦点能力、键盘处理和 Tab stop 收集接口接入;正式文本输入接口仍留阶段 5/6。
- 关键数据 / 状态:
- `NativeInputEvent`、`KeyEvent`、`TextInputEvent`、`CompositionEvent`、`WindowActivationEvent`。
- `ControlHandle`、`FocusScope`、逻辑焦点句柄和窗口键盘激活状态。
@@ -80,9 +80,9 @@
↓
阶段 2:通用焦点系统
↓
阶段 3:键盘定向路由
阶段 3:键盘定向路由与 Button 接入
↓
阶段 4:Button / Table / TabControl / Dialog 接入
阶段 4:Table / TabControl / Dialog 作用域与默认动作接入
↓
阶段 5:TextBox 编辑内核与自绘
↓
@@ -162,18 +162,20 @@ Button / TextBox / Table / TabControl / Dialog
### 3. 关键不变量
以下是阶段 3 换轨后的完成态不变量;阶段 1–2 的兼容过渡例外仅限“旧 EX_KEY 继续业务下发、新队列只观察”,不得被扩展成长期双业务源:
以下是阶段 3 换轨后的完成态不变量。阶段 1–2 的“旧 EX_KEY 继续业务下发、新队列只观察”和阶段 3 切片 1–5 的测试门控均为历史过渡;v0.22 已由切片 6 一次性把生产默认切换为 Routed,Legacy 只保留为事件循环启动前锁定的内部 A/B 基线,任何模式下旧、新业务路径都必须互斥:
1. 阶段 3 启用后,NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。
2. 阶段 3 启用后,EasyX `EX_KEY` 只排空和观察,不得再次触发业务路由。
3. 同步窗口协议不得进入异步输入队列。
4. 异步路由只能阻止框架默认动作,不能倒过来改变已经结束的 WndProc 返回值。
5. 队列、焦点、Tab 顺序、路由路径和待删除列表长期只保存 `ControlHandle`。
5. 队列、焦点、Tab 顺序、路由路径和待删除列表长期只保存 `FocusTargetId` / `ControlHandle`,不保存裸 `Control*`。
6. 同一 Window 同时只能由最内层消息循环 drain 输入队列。
7. 每次只从队列取一条事件,不预取整个批次到局部容器。
8. WndProc 只在 EasyX 窗口线程采集;焦点、路由和控件操作只在宿主分发线程执行。
9. NativeInputQueue 必须提供跨线程发布/获取的 happens-before,不能使用无锁保护的普通 `std::deque`。
10. `sequence` 提供全局因果编号,但不能代替队列同步。
11. 窗口键盘激活态由采集线程发布原子快照;业务路由不能只依赖消费者按顺序处理 `WindowActivationEvent` 得到的布尔值。
12. 模态 Dialog 尚未进入 FocusScope 前,内层循环只排空并观察原生键盘队列,不得把键路由到底层 Window 焦点。
### 4. Win32 语义边界
@@ -224,6 +226,13 @@ v1 使用系统 IME UI:
`WM_GETDLGCODE` 不得无条件返回 `DLGC_WANTALLKEYS`,必须结合当前焦点能力、`wParam` 和可选 `MSG*` 选择性声明。
阶段 3 的首个生产接管项只有 Tab:
1. 切片 1–5 的生产路径继续原样返回旧窗口过程结果,不提前改变同步协议。
2. 切片 6 启用生产 KeyboardRouter 后,先调用旧窗口过程,再仅在当前查询明确对应 Tab 时合并 `DLGC_WANTTAB`。
3. 不因阶段 3 已有路由框架就顺手声明 `DLGC_WANTALLKEYS`、`DLGC_WANTARROWS` 或 `DLGC_WANTCHARS`;方向键、文本与复合控件能力在实际接入阶段分别开放。
4. `WM_GETDLGCODE` 仍不进入 NativeInputQueue,也不经过异步 `KeyRouteResult`。
#### 5.2 异步输入消息
| 消息 | 规范化结果 | 初始处置 |
@@ -353,7 +362,7 @@ struct WindowActivationEvent
};
```
窗口失活时可以保留逻辑焦点句柄,但 `keyboardFocusActive == false` 时不路由键盘。窗口重新激活后先验证句柄,失效则执行焦点回退。
窗口失活时保留逻辑焦点身份,但 `keyboardFocusActive == false` 时暂停键盘、文本和 IME 的业务分发,并单独取消 Button 等控件的键盘瞬时按压态。窗口重新激活后先验证句柄,失效则执行焦点回退。清理按压态和暂停业务路由是两个独立动作,不能用其中一个代替另一个。
#### 6.5 队列外壳
@@ -364,6 +373,9 @@ struct NativeInputEvent
DWORD messageTime;
HWND hwnd;
bool windowActiveAtCapture;
uint64_t windowActivationGeneration;
std::variant<
KeyEvent,
TextInputEvent,
@@ -375,6 +387,19 @@ struct NativeInputEvent
键盘、文本和 IME 事件不合并、不静默丢弃。
`windowActiveAtCapture` 与 `windowActivationGeneration` 属于整个原生事件的采集时快照,而不是只属于 `KeyEvent`。TextBox 后续接入文本与 IME 时必须使用同一门控,避免失活前积压的文字在重新激活后插入。
每个 `NativeInputContext` 维护一个可原子读取的 `{ keyboardFocusActive, generation }`:
1. 挂接 WndProc 前用 HWND 属主线程的 `GetGUIThreadInfo` 初始化;查询失败时按 inactive 处理,不猜测为 active。
2. `WM_SETFOCUS/WM_KILLFOCUS` 在入队 `WindowActivationEvent` 前发布新快照;激活状态真正变化时递增 generation。
3. 其它原生事件入队时复制当时的 active 与 generation。
4. 宿主路由一条业务事件前重新读取最新原子快照;只有“采集时 active、当前仍 active、generation 相同”三者同时成立才允许路由。
5. 失活后再恢复会产生新 generation,旧激活周期的积压事件即使此时窗口再次 active 也必须丢弃业务交付。
6. 门控以宿主线程紧邻路由前的原子读取为线性化点;不得为追求跨线程绝对同时而在 WndProc 与控件回调之间持有共享阻塞锁。
切片 3 使用一个 64 位原子值打包 active 位与 generation,避免消费者读到撕裂的二元状态;generation `0` 保留为未知/无效快照。首次挂接通过窗口属主线程的 `GetGUIThreadInfo` 判断键盘焦点,查询失败按 inactive 初始化。`WM_SETFOCUS/WM_KILLFOCUS` 先发布新状态,再把携带同一快照的 `WindowActivationEvent` 入队;每条其它原生输入也复制采集时快照。
#### 6.6 跨线程队列模型
当前拓扑是单生产者/单消费者:
@@ -739,13 +764,15 @@ Table 本身不是焦点叶子,通过 `collectTabStops()` 暴露上一页和
```text
取出一条事件
→ 验证窗口激活状态
→ IME 预处理
→ Tab 导航
→ 校验 sequence 并累计观察统计
→ 验证采集激活快照与当前激活 generation
→ 验证当前是否处于模态隔离态
→ IME / 系统组合键预处理
→ 锁定原始焦点句柄
→ 构造句柄路由路径
→ 焦点叶子
→ 父容器冒泡
→ 若未阻止框架默认动作,以回调后的新焦点树执行 Tab 导航
→ FocusScope 默认动作
→ Window 默认动作
→ 提交当前事件产生的 mutation
@@ -753,6 +780,8 @@ Table 本身不是焦点叶子,通过 `collectTabStops()` 暴露上一页和
`TextInputEvent` 只发送给实现文本输入接口的当前焦点叶子,不冒泡。
阶段 3 只对 `KeyEvent` 建立生产业务路由;`TextInputEvent` 与 `CompositionEvent` 仍完整排空、观察和计数,等阶段 5/6 的 TextBox/IME 接口接入后再交付。原始 `VK_PROCESSKEY`、`imeComposingAtCapture == true` 的键,以及阶段 3 没有明确拥有的 Alt/System 组合键,一律不进入 Tab、Button、Dialog 默认动作或其它框架业务处理。
#### 12.2 路由结果
```cpp
@@ -767,6 +796,8 @@ struct KeyRouteResult
- `preventFrameworkDefault`:冒泡结束后不再执行 Dialog/Window 默认动作。
- 两者均不影响已经完成的原生 WndProc 返回值。
切片 4 将 Tab 定义为冒泡后的框架默认动作:原始焦点路径可以通过 `preventFrameworkDefault` 抑制换焦,单独 `stopBubble` 不会隐式抑制默认动作。真正执行 Tab 时重新采集当前活动 FocusScope 的完整停靠点树,不复用冒泡前快照,也不缓存上一按键的空结果。
#### 12.3 路由路径与 mutation
事件开始时使用句柄构造路径快照:
@@ -777,14 +808,54 @@ FocusedLeaf → Parent → ... → FocusScope → Window
每个节点使用前重新解析。处理过程中删除控件时,无效节点被跳过;当前事件仍使用原始路径,下一事件才使用新焦点和新树结构。
新路由的整个单事件处理必须位于 Window mutation transaction 和托管重绘分发边界内。路由回调可以删除当前/祖先控件、改变焦点或打开模态 Dialog;回调返回后只能使用 `FocusTargetId` 重新解析,不能继续使用回调前取得的裸指针或快照布尔值。
`managedDispatchActive` 的进入/退出必须保留外层状态,不能让内层模态循环或回调重入把外层分发状态提前清成 false。NativeInputQueue 每次只弹出一条事件;路由回调进入模态循环时,外层 drainer 停在当前调用栈,内层循环可以继续逐条排空,返回后外层再继续,禁止预取整批事件到局部容器。
切片 2 的实现使用固定容量 256 的 `FocusTargetId` 路径数组,避免在业务路由中为常规父链动态分配。路径在任何控件回调前完整构造;检测到父链循环、超过深度上限或原始目标已逻辑失效时,整条事件不做部分投递,并通过内部结果的 `pathComplete=false` 暴露给测试/诊断。逐节点调用由 Control/Canvas/Table/TabControl 的受保护递归协议完成,借用的 `Control*` 不离开当前函数调用。
#### 12.4 阶段 3 测试门控与原子换轨
切片 1–5 曾使用内部流水线模式验证新路由;切片 6 完成后的模式为:
```text
Routed(生产默认)
EX_KEY → 纯排空与统计
NativeInputQueue → KeyboardRouter
LegacyCompatibility(仅内部 A/B 基线)
EX_KEY → Control::handleEvent
NativeInputQueue → 只观察排空
```
硬规则:
1. 两种模式互斥,任何窗口在同一时刻只能有一个键盘业务源。
2. 测试门控只能在事件循环开始前设置,不允许运行中切换造成半批次双投递或漏投递。
3. 切片 1–5 的生产默认曾保持 `LegacyCompatibility`;切片 6 已把默认一次性改为 Routed。
4. 普通 Window 与模态 Dialog 的 EasyX 循环必须在同一变更中换轨,不能存在只换其中一路的中间生产状态;当前实现已满足该约束。
5. 换轨后仍继续请求并排空 `EX_KEY`,避免 EasyX 内部队列累积;但该面不再调用任何控件的 `handleEvent()`。
6. `EX_MOUSE` 永久保持既有 `handleEvent()` 路径,不属于本次兼容性切换。
7. Legacy 只允许测试在事件循环锁定前选择;运行中不能切换,生产 API 不暴露该模式。
#### 12.5 窗口失活与模态隔离
- 收到失活事件时,KeyboardRouter 立即通知当前焦点路径清理 keyboard armed 等瞬时状态,但不清除 FocusManager 的逻辑焦点。
- 失活后路由暂停;失活前已入队但尚未 drain 的业务事件由 activation generation 判为 stale。
- 快速失活再恢复时,旧 generation 的事件不得投递到新激活周期。
- 当前模态 `Dialog` 是栈对象,不在 Window 的焦点树中。阶段 3 的模态循环必须继续排空、观察并统计 NativeInputQueue,但在真实 Dialog FocusScope 接入前抑制业务路由,禁止激活底层 Window 控件。
- 阶段 4 接入 Dialog FocusScope、默认按钮和 Escape 后,才把模态期键盘定向交给 Dialog 子树;阶段 3 不复制临时 Dialog 路由。
### 13. 按键归属
#### 13.1 Tab
- IME 组合中交给 IME,不移动焦点。
- 非组合态由 FocusManager 处理。
- 非组合态、无 Ctrl 的 `VK_TAB` KeyDown 在冒泡后由 FocusManager 处理;KeyUp 与 Ctrl+Tab 不触发普通遍历。
- Shift+Tab 反向移动。
- 普通 Button/TextBox 不直接接收 Tab。
- 无焦点时 Tab 进入首项、Shift+Tab 进入末项;到达边界后在当前最上层 FocusScope 内循环。
- 每次按键重新采集并验证停靠点;冒泡回调对可见、可用、删除和作用域状态的修改对同一按键的默认动作立即生效。
- 普通 Button/TextBox 不把 Tab 作为自身命令处理;默认空路由后由框架 Tab 默认动作接管。
#### 13.2 Space
@@ -794,11 +865,13 @@ Button:
首次 KeyDown → armed + pressed
重复 KeyDown → 忽略
KeyUp 且仍聚焦、仍 armed → 触发一次 click
焦点丢失或控件禁用 → 取消 armed
焦点丢失、控件禁用/隐藏、Escape 或窗口失活 → 取消 armed
```
TextBox 不从 KeyEvent 插入空格,只接受 `TextInputEvent(U+0020)`。
`keyboardArmed` 必须是 Button 独立状态,不能复用现有 `click`:`click` 在 NORMAL 模式承担鼠标瞬时按压,在 TOGGLE 模式承担持久选中状态,复用会让键盘释放破坏 Toggle 状态或让鼠标/键盘互相误触发。绘制按压色时可使用 `click || keyboardArmed`,但阶段 2 的焦点框仍在按钮本体之后绘制并保持可见。
#### 13.3 Enter
```text
@@ -1083,18 +1156,20 @@ struct ProbeConsumeRule
#### 阶段 3:KeyboardRouter
- Tab 导航。
- 句柄路由路径。
- 冒泡和框架默认动作。
- Button 和 Dialog 键盘行为。
- Table 分页 Button 仅在首次 draw 后 materialize;首次 draw 前“无可遍历项”表示焦点树尚未就绪,不得缓存或解释为该 Table 永久没有 Tab stop。
- Button 键盘按压态必须与阶段 2 的最小焦点框叠加绘制,不得用按压态覆盖或抹除键盘焦点反馈。
- 切片 1(已完成):增加 `RoutedKeyEvent` / `KeyRouteResult`、Control 默认空路由接口和内部流水线门控;生产默认仍为旧 EX_KEY。
- 切片 2(已完成):实现 `FocusTargetId` 原始路由路径、逐节点重新解析、叶子到祖先冒泡、`stopBubble` / `preventFrameworkDefault` 独立聚合,以及可嵌套的单事件 mutation/托管分发边界;真实 Dialog 默认动作暂不接入。
- 切片 3(已完成):实现 activation generation、失活业务门控、键盘瞬时状态暂停协议、IME/Alt-System 抑制和模态期底层路由隔离;该暂停协议已由切片 5 的 Button `keyboardArmed` 清理路径复用。
- 切片 4(已完成):实现测试门控下的 Tab / Shift+Tab 冒泡后默认动作;每次按键重新获取焦点树,支持当前活动 FocusScope 内正反向循环、无焦点进入首/末项和 `preventFrameworkDefault` 抑制。Table 分页 Button 仅在首次 draw 后 materialize,draw 前“无可遍历项”不缓存或解释为永久无 Tab stop。
- 切片 5(已完成):实现 Button Enter/Space、独立 `keyboardArmed`、Escape/焦点变化/模式切换/隐藏/失活与模态隔离清理,以及按压视觉与焦点框叠加;自动重复不重复激活,Button 命令阻止框架默认动作但继续冒泡,仍只由测试门控触发。
- 切片 6(实现与自动回归已完成):生产默认改为 Routed;普通 Window 与模态 Dialog 的 EX_KEY 同时变为纯排空,NativeInputQueue 成为唯一键盘业务源;同步加入选择性 `WM_GETDLGCODE`、旧业务/排空诊断计数、第三方迁移说明、生产 `keyboard-routing` UI 场景和受控 A/B。真实 IME / KEY1–6 人工换轨回归仍待执行。
- 切片 1–5 不允许新路由与旧 `EX_KEY -> handleEvent()` 同时向生产控件交付;测试窗口进入 Routed 模式时也必须先关闭自己的旧业务交付。
#### 阶段 4:复合控件
- Table 翻页 Button。
- TabControl roving tab stop。
- 活动页面过滤和页签/页面路由隔离。
- 真实 Dialog FocusScope 生命周期、模态子树路由、默认按钮与 Escape 关闭。
#### 阶段 5:TextBox
@@ -1119,7 +1194,7 @@ struct ProbeConsumeRule
6. IME 正式提交优先使用 `GCS_RESULTSTR`。
7. TextBox 内部使用 UTF-16。
8. 绘制优先使用宽字符 GDI,不默认降级 CP936。
9. 长期焦点、队列和遍历结果只使用 ControlHandle。
9. 长期焦点、队列和遍历结果只使用 `FocusTargetId` / `ControlHandle`,不保存裸控件指针。
10. 路由结果区分停止冒泡和阻止框架默认动作。
11. TabControl 页签事件绝不传入活动页面。
12. IME 组合期间暂停框架快捷键、焦点导航和 Dialog 默认动作。
@@ -1154,19 +1229,37 @@ struct ProbeConsumeRule
41. 阶段 2 只建立 FocusScope 机制;真实 Dialog 作用域接入、Tab 导航和键盘路由不得提前混入。
42. 阶段 3 的 Tab 遍历必须区分 Table 分页 Button“首次 draw 前尚未 materialize”与“永久无停靠项”,不得把 draw 前空快照固化为长期结论。
43. 阶段 3 的 Button 键盘按压视觉必须叠加在最小焦点框之上,焦点框不得因按压态切换而消失。
44. 阶段 3 切片 1–5 的新路由只能通过内部测试门控启用,且测试态的新旧业务交付也必须互斥;切片 6 已按约定一次性切换生产默认。
45. 窗口键盘激活门控使用采集侧原子 `{active, generation}` 与事件采集快照双重校验;只按队列顺序更新消费者布尔值不足以阻止失活前积压键或快速失活/恢复后的跨代投递。
46. 窗口失活时“清理 keyboard armed”和“暂停键盘/文本/IME 业务分发”是两个独立动作;逻辑焦点保留,重新激活后重新验证。
47. 当前栈上模态 Dialog 未进入 Window 焦点树;阶段 3 模态期只排空并抑制业务路由,禁止按键穿透到底层焦点。真实模态子树路由、默认按钮和 Escape 在阶段 4 随 Dialog FocusScope 一起接入。
48. Button 的 keyboard armed 独立于现有 `click`;Space 在 KeyUp 确认,Enter 在首次 KeyDown 确认,焦点丢失、禁用、隐藏、Escape 和窗口失活都会取消 armed。
49. 切片 6 必须在同一提交内同时切换 Window 主循环和 Dialog 模态循环的 EX_KEY 路径;换轨后仍持续排空 EX_KEY,但业务交付计数必须为零,EX_MOUSE 不变。
50. 阶段 3 只对明确的 Tab 查询选择性合并 `DLGC_WANTTAB`;不得无条件返回 `DLGC_WANTALLKEYS`,其它 dialog code 随对应功能接入。
51. `VK_PROCESSKEY`、采集时标准组合态和阶段 3 未明确拥有的 Alt/System 组合键只观察不路由;WndProc 仍按默认策略转发。
52. 新路由必须纳入可嵌套的托管分发与 mutation 边界;回调后重新按身份解析,内层模态循环不得提前清除外层 `managedDispatchActive` 状态。
53. 切片 2 的原始路由路径使用固定容量 256 的身份数组;循环、超深或构造期失效必须在调用任何控件前 fail closed,不允许动态分配失败或畸形父链造成半条路径已投递。
54. Button 的 Enter 与有效 Space 属于叶子命令:返回 `preventFrameworkDefault=true` 防止框架默认动作重复激活,但保持 `stopBubble=false` 允许祖先观察;Escape 只取消本地 armed,不阻止 Dialog/Window 后续默认动作。
55. v0.22 起生产默认固定为 Routed;LegacyCompatibility 只用于事件循环启动前锁定的内部 A/B,不是公开兼容开关,也不得在运行中切换。
56. 阶段 3 只把 `KeyEvent` 交给控件业务路由;`TextInputEvent` 与 `CompositionEvent` 继续完整排空、观察和计数,正式文本交付接口留阶段 5/6。
57. 自动 A/B、生产 UI 与 input-shadow 只能关闭自动换轨门槛;微软拼音、搜狗和 KEY1–6 人工结果必须独立记账,不能由自动测试代替。
58. Window 背景或非模态 Canvas 空白区域收到未被子控件消费的左键按下时,必须清除逻辑焦点并消费该指针事件,禁止继续向更低层兄弟穿透;模态 Dialog 在阶段 4 接入真实 FocusScope 前不借此清除底层逻辑焦点。
59. Table 分页 Button 虽有独立 ControlHandle,但属于 Table 内部绘制单元;其焦点视觉变化必须提升为 Table 重绘边界,不能把内部 Button 当成可独立提交到屏幕的顶层重绘 root。
60. 同父 Canvas 的下层持久 coverage 已写入且命中上层兄弟时,上层完整恢复优先于其局部脏子树提交;必须作废旧快照并重建整个上层兄弟,避免下层像素覆盖上层未变脏区域。
## 实现与影响
- 关键实现点:
- 阶段 0A 探测器和 0C 独立视觉探测已进入仓库;阶段 1 的 NativeInputQueue 与输入规范化已经实现;阶段 2 的 FocusManager、递归焦点协议、PointerDown 聚焦、失效回退和作用域机制已经实现。KeyboardRouter 与 TextBox 编辑内核尚未开始。
- 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则;v0.13 根据 0B 证据锁定默认转发与无生产消费白名单;v0.14 根据阶段 1 实现审查锁定 EX_KEY 兼容过渡、两轴快照、fail-stop、END 回声边界和显式异常计数;v0.15 根据阶段 2 审计与实现锁定 FocusTargetId、瞬时快照、单槽重入、64 轮活性保护和 pending removal 即时失效;v0.16 根据受控 A/B 和正式完整 UI 三轮结果关闭阶段 2 回归归因门槛,并锁定 Table 首次 draw 生命周期与 Button 按压/焦点视觉叠加约束。
- 阶段 0A 探测器和 0C 独立视觉探测已进入仓库;阶段 1 的 NativeInputQueue 与输入规范化、阶段 2 的 FocusManager、递归焦点协议、PointerDown 聚焦、失效回退和作用域机制,以及阶段 3 全部六个切片的路由契约、互斥门控、身份路径、逐节点分发、冒泡、嵌套 mutation 边界、activation generation、输入所有权门控、模态隔离、Tab 默认动作、Button Enter/Space 行为和生产 EX_KEY 原子换轨已经实现。TextBox 编辑内核尚未开始。
- 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则;v0.13 根据 0B 证据锁定默认转发与无生产消费白名单;v0.14 根据阶段 1 实现审查锁定 EX_KEY 兼容过渡、两轴快照、fail-stop、END 回声边界和显式异常计数;v0.15 根据阶段 2 审计与实现锁定 FocusTargetId、瞬时快照、单槽重入、64 轮活性保护和 pending removal 即时失效;v0.16 根据受控 A/B 和正式完整 UI 三轮结果关闭阶段 2 回归归因门槛,并锁定 Table 首次 draw 生命周期与 Button 按压/焦点视觉叠加约束;v0.17 根据阶段 3 只读审计锁定测试门控、activation generation、模态隔离、Button 独立 armed、两套 EX_KEY 循环原子换轨和选择性 `WM_GETDLGCODE`;v0.18 根据切片 2 实现锁定固定容量原始身份路径、逐节点重新解析、独立路由结果位与可嵌套托管 mutation 边界;v0.19 根据切片 3 实现锁定原子激活快照、跨代丢弃、IME/Alt-System 抑制、焦点路径暂停协议和模态底层隔离;v0.20 根据切片 4 实现锁定冒泡后 Tab 默认动作、每键新树、当前 FocusScope 循环和空结果不缓存;v0.21 根据切片 5 实现锁定 Button 独立 armed、Space 释放确认、Enter 首次按下确认、自动重复抑制、取消路径和“阻止默认但继续冒泡”的命令语义;v0.22 根据切片 6 实现与 A/B 证据锁定 Routed 生产默认、EX_KEY 纯排空、旧业务零交付、选择性 `DLGC_WANTTAB` 与第三方迁移边界;v0.23 根据 KEY1/KEY5 人工反馈与自动复验锁定 Window/Canvas 空白左键清焦点、Table 内部焦点视觉重绘提升和同父 Canvas 上层完整恢复优先级。
- 涉及文件 / 类 / 函数:
- 预计涉及 `Window`、`Control`、`Canvas`、`Button`、`TextBox`、`Table`、`TabControl`、`Dialog`。
- 实际文件和接口必须在阶段 0 与实现前只读检查后确定。
- 阶段 3 已涉及 `InputEvents`、`KeyboardRouting`、`NativeInputContext`、`KeyboardRouter`、`FocusManager`、`Window`、`Control`、`Button`、`Dialog`、测试夹具和迁移文档。
- `Canvas`、`Table`、`TabControl` 在阶段 3 提供既有焦点树、逻辑父关系和递归身份分发协议;高级键盘行为仍留阶段 4。`TextBox` 编辑和文本交付仍留阶段 5/6。
- 兼容性影响:
- 设计目标是保留现有鼠标行为和公共文本 API 的兼容入口。
- 阶段 2 向 `Control` 增加焦点协议虚接口,现有二进制派生控件需要重新编译;源码接口没有删除,源码分发用户按正常流程重编译即可。
- 阶段 1 不改变现有 `EX_KEY` 业务交付;阶段 3 停止向 `Control::handleEvent()` 下发 `EX_KEY` 时属于明确的派生控件迁移点,`EX_MOUSE` 永久保留。
- 阶段 1 没有改变旧 `EX_KEY` 业务交付;阶段 3 现已停止向 `Control::handleEvent()` 下发 `EX_KEY`,这是明确的派生控件迁移点,`EX_MOUSE` 永久保留。
- 阶段 3 已新增 routed key 虚接口并再次改变 `Control` vtable;二进制派生控件必须重新编译,在旧 `handleEvent(EX_KEY)` 中处理键盘的源码派生控件必须迁移到新接口。
- 输入链路和 TextBox 内部模型将发生架构级变化,必须分阶段接入。
- 性能影响:
- 输入事件会增加规范化和句柄解析开销,但不允许在 WndProc 中执行阻塞 I/O。
@@ -1174,19 +1267,26 @@ struct ProbeConsumeRule
- 风险点:
- EasyX 双线程、ANSI 窗口和当前微软拼音/搜狗回声顺序已确认,但其他 IME、窗口 A/W 属性和系统版本仍可能产生不同顺序。
- 模态重入和控件删除若破坏单 drainer/句柄不变量,可能产生难复现的顺序或生命周期问题。
- 阶段 1 的自动验证与人工回归必须分开记账;尚未执行的真实 IME/KEY1–6 人工操作不能由单元测试替代。
- 激活门控若只保存布尔而没有 generation,会在失活前积压或快速失活/恢复时产生跨激活周期误投递。
- Window 与 Dialog 两套 EX_KEY 循环若没有同一提交切换,会形成局部双投递或模态期旧路径残留。
- 自动验证与人工回归必须分开记账;阶段 3 的微软拼音、搜狗及 KEY1/KEY5 人工门槛已完成,不能用单元测试倒推其它未覆盖环境同样通过。
## 测试与验证
- 测试范围:
- 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。
- 独立线程归属探测、0A、0C、0B 门槛均已完成;阶段 1 已加入逻辑测试与真实 EasyX `input-shadow` 场景;阶段 2 已加入焦点事务单测和真实 EasyX `focus` 场景,并以 62/62 受控 A/B 和 24/24 正式完整 UI 关闭回归归因门槛。
- 阶段 3 切片 1–5 已在内部测试门控启用的 Routed 模式覆盖路由路径、mutation 重入、activation generation、模态隔离、Tab 与 Button;切片 6 使用保存的换轨前 fixture 与当前 Routed fixture 进行共同场景 A/B,并把生产 input-shadow 改为“新路由完整、EX_KEY 纯排空、旧业务零交付”的断言。
- 切片 2 自动测试必须覆盖叶子到祖先顺序、原始目标稳定、停止冒泡与阻止默认动作相互独立、pending removal 跳过后续节点、嵌套路由不提前提交外层 mutation,以及回调改焦点只影响下一事件。
- 切片 6 的新 input-shadow 已同时证明:WndProc 规范化事件完整到达新路由、EasyX EX_KEY 仍持续被排空、`Control::handleEvent(EX_KEY)` 业务交付为零、队列最终深度为零、序列严格且稳定波次下内存不增长。
- 验证步骤:
1. 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。
2. 按阶段 0C 规格完成宽字符 GDI 与 IME 坐标探测,取得绘制与坐标证据。
3. 使用阶段 0B 的逐消息规则验证消费策略,不修改生产默认路径。
4. 阶段 1 至阶段 6 分别执行逻辑测试、仪器化运行测试和人工输入法/DPI 回归。
5. 阶段 3 切片 6 以前禁止把测试门控结果写成生产换轨完成;切片 6 以换轨前提交为基线执行自动 A/B,结构相同的 UI 场景比较行为与耗时,输入场景按新旧不同契约分别断言。
6. 切片 6 完成后执行完整 UI、KEY1–KEY6、微软拼音和搜狗人工回归;真实候选态的 `VK_PROCESSKEY`/组合键不得触发 Button、Tab 或底层 Dialog/Window 默认动作。
- 验证结果:
- v0.4 设计经过多轮评审,已作为后续探测和实现的基线。
@@ -1211,10 +1311,21 @@ struct ProbeConsumeRule
- 2026-10-07:最终 Debug x64 单元测试 15/15、UI fixture 1 轮、F24 KeyDown A/B 新运行通过;既有 KeyUp 与 baseline mismatch 证据经当前分析器回放保持有效。阶段 1 以默认转发且无消费白名单为起点。
- 2026-10-07:阶段 1 自动回归通过:Debug x64、x64 ASan、Release x64、Debug Win32 均为 27/27,KEY1–6 全部编译/链接成功;完整 UI fixture 曾一轮通过,最终源码的独立 `input-shadow` 再次通过,旧 EX_KEY 与新队列均完整观察 4000/4000 条事件,新队列最终深度 0、顺序严格、内存增长 0、无 fault/规范化异常;PowerShell 5.1/7 AST、工程 XML 和 `git diff --check` 通过。最终完整 UI 的两次追加复跑分别在 `geometry/ownership` 鼠标点击检查点超时,均未进入输入场景,按既有桌面焦点抖动保留失败证据,不冒充最终全套连续通过。真实 IME 与 KEY1–6 交互仍待人工确认。
- 2026-10-08:阶段 2 FocusManager 完成实现审查;Debug x64、x64 ASan、Release x64、Debug Win32 均为 40/40,KEY1–6 全部编译/链接成功。最终源码的独立 `focus` UI 连续 3 轮通过。针对随后出现的 Tooltip 像素等待和 geometry 点击结果超时,以阶段 1 基线 `81341fa` 与阶段 2 工作树执行受控 A/B,62/62 个结构化样本全部通过;当前阶段 2 正式完整 UI 三轮八场景共 24/24 通过,未发现阶段 2 相关失败或耗时劣化。此前超时定性为间歇性桌面 fixture / 输入时序波动,仍保留残余偶发波动可能,不把关闭门槛表述为证明 UI 永不波动。
- 2026-10-08:阶段 3 只读审计确认旧 EX_KEY 业务入口同时存在于 Window 主循环和 Dialog 模态循环;`NativeInputContext` 只有顺序观察统计、没有业务 pop/激活代次门控;Button 没有独立键盘 armed;当前栈上模态 Dialog 不在 Window 焦点树;生产 WndProc 尚未实现选择性 `WM_GETDLGCODE`。v0.17 据此完成实现前契约收口,尚未改动代码或宣称阶段 3 已实现。
- 2026-10-08:阶段 3 切片 1 完成:新增 `KeyboardRouting.h`、Control 默认空 `handleKeyEvent()` 与启动前固定的 `LegacyCompatibility/Routed` 内部门控;Window 主循环和 Dialog 模态循环共用同一旧业务交付判定,Routed 测试态只截断 EX_KEY,EX_MOUSE/窗口消息不受影响。生产默认仍为 LegacyCompatibility,未建立 KeyboardRouter、未提前换轨。Debug x64、x64 ASan、Release x64、Debug Win32 均为 46/46,KEY1–KEY6 全部编译链接成功;完整 UI 追加复跑 1/1 通过,独立 input-shadow 记录旧业务 4000/4000、新队列 4000/4000、最终深度 0、严格有序且零 fault。此前完整 UI/定向 focus 各出现一次 `PrintWindow` 全黑抓帧,目标区域 6480/6480 为黑,而同轮后续失败截图显示焦点框正常;保留为 fixture 截图波动,不把失败样本删除或冒充连续通过。
- 2026-10-09:阶段 3 切片 2 完成:新增内部 KeyboardRouter、固定容量原始身份路径、Control/Canvas/Table/TabControl 递归身份分发协议,以及 NativeInputContext 单条“弹出并观察”入口;显式 Routed 测试模式在释放队列锁后逐条路由 KeyEvent,Legacy 生产默认仍只观测新队列并继续由旧 EX_KEY 交付业务。六个新增单测覆盖顺序冒泡、独立结果位、祖先 pending removal、嵌套 mutation/managed-dispatch、回调改焦点和非 KeyEvent 不交付边界。Debug x64、x64 ASan、Release x64、Debug Win32 均为 52/52,KEY1–KEY6 全部编译链接成功;交互桌面完整 UI 1/1 通过,`input-shadow` 保持旧业务 4000/4000、新队列 4000/4000、最终深度 0、顺序严格且零 fault。首次受限环境运行在场景开始前因 `MoveMouseToScreen` 返回 Access denied,未记作产品/UI 失败;获得交互桌面权限后重新完整执行并通过。
- 2026-10-09:阶段 3 切片 3 完成:NativeInputContext 使用单个 64 位原子值维护 active/generation,所有原生输入携带采集时激活快照;路由前以最新原子快照为线性化点,失活、跨代、`VK_PROCESSKEY`、标准组合生命周期内按键、Alt/System 和模态期底层输入均不进入控件回调。失活与进入模态循环会沿原始焦点身份路径调用键盘瞬时状态暂停协议,但保留逻辑焦点;Button 的真实 `keyboardArmed` 仍留切片 5。三项新增单测覆盖原子代次发布、五类业务门控和暂停路径;Debug x64、x64 ASan、Release x64、Debug Win32 均为 55/55,KEY1–KEY6 全部编译链接成功。生产仍默认 LegacyCompatibility;完整 UI 1/1 通过,证据位于 `TestResults/ui/20261009-013601-150-29780`,`input-shadow` 维持旧业务/新队列各 4000/4000、最终深度 0、顺序严格且零 fault。该 UI 证据只证明生产兼容路径未回归,Routed 门控由单元测试验证,不写成生产换轨完成。
- 2026-10-09:阶段 3 切片 4 完成:FocusManager 新增不缓存的当前作用域 Tab 遍历,KeyboardRouter 在原始路径冒泡完成且未被 `preventFrameworkDefault` 阻止后,对无 Ctrl 的 `VK_TAB` KeyDown 执行正向或 Shift 反向循环;KeyUp、Ctrl+Tab 和切片 3 的所有权/激活门控不会误触发。五项新增单测覆盖排序与双向循环、无焦点入口、停止冒泡与默认阻止独立性、回调后新树、空结果不缓存和活动 FocusScope 边界;Debug x64、x64 ASan、Release x64、Debug Win32 均为 60/60,KEY1–KEY6 全部编译链接成功。首次完整 UI 在场景开始前因桌面权限被拒绝,交互桌面重跑 1/1 通过,证据位于 `TestResults/ui/20261009-140434-611-33188`,`input-shadow` 仍为旧业务/新队列各 4000/4000、最终深度 0、顺序严格且零 fault。生产默认保持 LegacyCompatibility,以上只证明测试门控 Tab 与生产兼容路径,未宣称生产换轨。
- 2026-10-09:阶段 3 切片 5 完成:Button 新增独立 `keyboardArmed`,Space 首次 KeyDown 进入按压态、KeyUp 确认一次,Enter 只在首次 KeyDown 激活,自动重复不重复执行命令;Escape、焦点丢失、模式变化、隐藏、窗口失活和模态隔离会取消 armed。NORMAL/TOGGLE 的既有 `click` 语义保持不变,绘制层只合并按压色且焦点框继续最后绘制;命令阻止框架默认动作但继续冒泡,回调删除由托管 mutation 边界保护。五项新增单测后,Debug x64、x64 ASan、Release x64、Debug Win32 均为 65/65,KEY1–KEY6 全部编译链接成功;完整 UI 1/1 通过,证据位于 `TestResults/ui/20261009-150453-477-26448`,`input-shadow` 仍为旧业务/新队列各 4000/4000、最终深度 0、顺序严格且零 fault。生产默认仍为 LegacyCompatibility,本轮不宣称生产换轨。
- 2026-10-09:阶段 3 切片 6 实现与自动回归完成:生产默认改为 Routed,Window 与 Dialog 两套 EasyX 循环同时把 EX_KEY 降为纯排空;新增排空/旧业务交付诊断计数、窄 `DLGC_WANTTAB` 同步应答、生产 `keyboard-routing` UI 场景和迁移说明。Debug x64、x64 ASan、Release x64、Debug Win32 均为 66/66,KEY1–KEY6 全部编译链接成功。换轨前 fixture(SHA-256 `3F6267B6D5EE85A7A97244C3DAE21DB44CEFBD185F56A38516E5E2F6F6234974`)与当前 fixture 的共同八场景执行 5 对 5 A/B,Legacy 40/40、Routed 40/40;唯一较大耗时差来自 Routed 首次 ownership 启动单点,扩展样本后未形成稳定劣化。当前正式 UI 两轮九个业务场景 18/18,普通/模态关闭均通过;`keyboard-routing` 2/2,`input-shadow` 两轮均为 Routed 4000/4000、EasyX 排空 4000、旧业务交付 0、队列深度归零、序列严格、零 fault、内存增长 0。微软拼音、搜狗和 KEY1–6 的换轨后人工回归仍待执行。
- 2026-10-09:最终补跑暴露 PowerShell 7 宿主的 `PATH` / `Path` 双环境键会使 MSBuild 在创建 `CL.exe` 前抛 `MSB6001`。`test.ps1` 现仅在检测到重复键时通过 `cmd` 构建子进程删除大写 `PATH`、保留同值 `Path`,不修改调用者环境;PowerShell 7 与 Windows PowerShell 5.1 均重跑 Debug x64 66/66 通过。该失败未进入产品编译或用例执行,不记作键盘路由回归。
- 2026-10-09:微软拼音与搜狗人工候选输入复验完成;Button Space/Enter 行为符合锁定语义。KEY1/KEY5 随后暴露 Table 分页焦点框延迟、Window/Canvas 空白不清焦点及同父 Canvas 下层重绘覆盖上层三项问题,v0.23 依决策 #58–#60 修复。四配置单测均为 67/67,KEY1–KEY6 全部编译/链接;扩展 focus UI 3/3 与同一 fixture 的完整 UI 9/9 通过。移除临时诊断字段后,最终 fixture(SHA-256 `F439C0636CD6B979A47C4594BC5AB3728F6342F49602FE43078435011A877793`)在 `TestResults/ui/20261009-223506-755-18932` 再次完成九场景 9/9,普通/模态原生关闭均通过;用户定向复测 KEY1/KEY5 暂未发现问题,阶段 3 验收关闭。
- 已知限制 / 遗留问题:
- §5.3 当前明确不建立生产消费白名单;若后续需要窄消费规则,仍必须补对应消息、虚拟键、IME 和环境的独立 0B 证据。
- §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。
- 宽字符 GDI 与当前微软拼音/搜狗定位门槛已通过;100%/125% 与跨屏 DPI 仍由阶段 0C 规格作为实机能力缺口承接。
- 阶段 3 六个切片、自动回归、微软拼音/搜狗和 KEY1/KEY5 定向人工复测均已完成;阶段 3 验收已关闭。真实 Dialog FocusScope、默认按钮、Escape 与复合控件高级键盘行为仍属阶段 4。
- 阶段 3 只路由 `KeyEvent`;TextBox 的 `TextInputEvent` / `CompositionEvent` 正式交付、编辑内核、caret 和 IME anchor 仍属阶段 5/6。
## 落地信息
@@ -1226,6 +1337,7 @@ struct ProbeConsumeRule
- 相关文档:
- [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md)
- [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)
- [`Module-20261009-0007-阶段3键盘定向路由与Button接入实现.md`](./Module-20261009-0007-阶段3键盘定向路由与Button接入实现.md)
- [TranslateMessage](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-translatemessage)
- [WM_UNICHAR](https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-unichar)
- [WM_GETDLGCODE](https://learn.microsoft.com/en-us/windows/win32/dlgbox/wm-getdlgcode)
@@ -0,0 +1,190 @@
# 新增功能模块 / 模块重构
> 本记录描述通用输入系统阶段 3 的实际实现、生产换轨边界与验证证据。阶段 3 完成 KeyEvent 路由和 Button 接入,但不代表 TextBox 文本编辑、真实 Dialog FocusScope 或复合控件高级键盘行为已经完成。
## 基本信息
- 模块 ID:Module-20261009-0007
- 模块名称:阶段 3 键盘定向路由、Button 接入与 EX_KEY 生产换轨
- 状态:已完成;实现、自动回归、微软拼音/搜狗及 KEY1/KEY5 人工门槛全部关闭
- 类型:新增模块 / 架构演进 / 兼容性迁移
- 所属系统 / 子系统:StellarX Core / Window / Native Input / Focus / Keyboard Routing / Button
- 版本 / 分支:`master`,实现提交 `29df994`
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + MBCS 工程配置
- 负责人:Codex 协作实现
## 背景与目标
- 阶段 1 已建立 WndProc 采集、规范化和跨线程 NativeInputQueue;阶段 2 已建立稳定身份焦点树和 FocusScope 机制。
- 阶段 3 要把 `KeyEvent` 从当前逻辑焦点叶子沿逻辑父链定向冒泡,并接入 Tab、Button Enter/Space、窗口激活与 IME 所有权门控。
- 最终切片必须一次性停止旧 `EX_KEY -> Control::handleEvent()` 生产交付,同时继续排空 EasyX 队列,避免新旧业务源并存或内部队列累积。
## 实现边界
### 已实现
- 公共 `RoutedKeyEvent`、`KeyRouteResult` 和 `Control::handleKeyEvent()` 默认空实现。
- 内部 `KeyboardRouter`:固定容量原始身份路径、逐节点重新解析、叶子到祖先冒泡、独立的停止冒泡与阻止框架默认动作结果位。
- 单事件 mutation transaction 与可嵌套 `managedDispatchActive` 边界;用户回调后不保留裸 `Control*`。
- 采集侧原子 `{ keyboardFocusActive, generation }` 快照;失活、跨激活代次、IME 所有和 Alt/System 输入在业务路由前被抑制。
- 失活和模态入口沿当前焦点路径取消 keyboard armed 等瞬时状态,但保留逻辑焦点。
- Tab / Shift+Tab 冒泡后默认动作:每次按键重新采集活动 FocusScope 的当前焦点树,支持循环、无焦点首/末项与 `preventFrameworkDefault`。
- Button 独立 `keyboardArmed`:Space 首次 KeyDown 按下、KeyUp 确认;Enter 首次 KeyDown 确认;自动重复不重复激活。
- Escape、焦点丢失、模式变化、隐藏、窗口失活和模态隔离取消 armed;按压色与阶段 2 焦点框叠加。
- 生产默认改为 Routed;Window 主循环和 Dialog 模态循环同时继续排空 `EX_KEY`,但不再交给 `handleEvent()`。
- `easyXKeyboardMessagesDrained` 与 `legacyEasyXKeyboardBusinessDeliveries` 诊断计数,用于证明排空持续且旧业务为零。
- `WM_GETDLGCODE` 窄同步分支:先取 EasyX 旧窗口过程结果,只对明确普通 Tab 查询合并 `DLGC_WANTTAB`。
- UI 运行器可指定 fixture、结果根目录和 Legacy/Routed 预期,并输出每场景结构化耗时 `run-summary.json`。
- 生产端到端 `keyboard-routing` 场景覆盖 Space 激活首按钮、Tab 换焦和 Enter 激活第二按钮。
- Window 背景与非模态 Canvas 空白左键清焦点并消费,避免焦点框残留和重叠兄弟穿透。
- Table 内部分页 Button 焦点变化提升为 Table 整体重绘;同父 Canvas 的下层持久 coverage 命中上层时,上层完整恢复优先于局部脏子树提交。
### 明确未实现
- `TextInputEvent` / `CompositionEvent` 向 TextBox 的正式业务交付;当前仍只排空、观察和计数。
- TextBox UTF-16 编辑内核、自绘、caret、选区、剪贴板和 IME anchor。
- Table 方向键/翻页命令、TabControl roving-tab-stop 和方向键切页。
- 真实 Dialog FocusScope、模态 Dialog 子树键盘路由、默认按钮和 Escape 关闭;当前模态循环抑制底层 Window 业务路由。
- `DLGC_WANTALLKEYS`、`DLGC_WANTCHARS`、`DLGC_WANTARROWS`;没有相应功能前不提前声明。
- WndProc 生产消费白名单;阶段 0B 没有给出跨 IME 安全白名单,因此原生按键仍按默认策略采集并转发。
## 关键契约
1. NativeInputQueue 是生产键盘业务源;EasyX `EX_KEY` 仅排空和观察。
2. Routed 与 LegacyCompatibility 互斥。Legacy 仅供测试在事件循环锁定前选择,不能运行中切换,也不是公开兼容开关。
3. 每次只从队列弹出一条事件,释放队列锁后才进入焦点、控件和用户回调;不得预取整批事件。
4. 路由开始时构造原始身份路径,之后逐节点重新解析。早期回调删除的后续节点跳过;回调改变焦点只影响下一事件。
5. `stopBubble` 与 `preventFrameworkDefault` 独立;二者都不能回溯改变已经完成的 WndProc 返回值。
6. 原始 `VK_PROCESSKEY` 是单键 IME 所有权威;`imeComposingAtCapture` 是独立生命周期轴。两者任一命中时,阶段 3 不执行控件、Tab 或默认动作。
7. 激活门控同时比较事件采集快照和当前原子 generation;窗口重新激活不能让旧周期积压键重新生效。
8. Button Space 在 KeyUp 确认,Enter 在首次 KeyDown 确认;命令阻止框架默认动作但继续允许祖先观察。
9. WndProc `WM_GETDLGCODE` 只在 Routed 普通 Tab 查询时增加 `DLGC_WANTTAB`;System/Alt+Tab 不认领。
10. `EX_MOUSE` 永久保留在 `Control::handleEvent()`;本次迁移只切断 `EX_KEY` 业务交付。
## 六个实现切片
### 切片 1:公共契约与互斥门控
- 新增 routed key 公共类型、Control 默认入口和内部 Legacy/Routed 模式。
- Window 与 Dialog 共用旧业务交付判定,测试 Routed 模式从第一天起就先关闭自己的旧 EX_KEY 业务路径,避免双投递。
### 切片 2:身份路由与 mutation
- 固定容量 256 的 `FocusTargetId` 路径;循环、超深或构造期失效时在调用控件前 fail closed。
- Control、Canvas、Table、TabControl 提供递归身份分发;借用指针不逃逸当前调用。
### 切片 3:激活、IME 与模态门控
- NativeInputEvent 携带采集时激活快照。
- `VK_PROCESSKEY`、标准组合生命周期内按键、Alt/System、失活、跨代和模态期底层输入不进入业务回调。
- 失活与模态入口调用焦点路径暂停协议,保留逻辑焦点。
### 切片 4:Tab 默认动作
- 无 Ctrl 的 Tab KeyDown 在冒泡后执行;Shift 反向、边界循环、无焦点进入首/末项。
- 每键重新获取焦点树;Table 分页 Button 在首次 draw 前的空结果不缓存。
### 切片 5:Button 键盘状态机
- `keyboardArmed` 与鼠标/Toggle 的 `click` 分离,只在绘制层合并按压视觉。
- Space/Enter、自动重复、Escape 和全部取消边界均由单测覆盖。
### 切片 6:生产原子换轨
- `Window::routedKeyboardPipeline` 生产默认改为 true;主循环和模态循环同时切断旧 EX_KEY 业务交付。
- 新增窄 `WM_GETDLGCODE` 分支、排空/旧业务计数、生产端到端 UI 场景、迁移说明和换轨前后 A/B。
- `TextInputEvent` 与 `CompositionEvent` 不提前交给控件,避免阶段 3 越界实现 TextBox。
## 实现文件
- `include/StellarX/KeyboardRouting.h`
- `include/StellarX/InputEvents.h`
- `include/StellarX/Control.h` / `src/StellarX/Control.cpp`
- `include/StellarX/FocusManager.h` / `src/StellarX/FocusManager.cpp`
- `include/StellarX/Button.h` / `src/StellarX/Button.cpp`
- `include/StellarX/Window.h` / `src/StellarX/Window.cpp`
- `src/StellarX/KeyboardRouter.cpp`
- `src/StellarX/Dialog.cpp`
- `src/StellarX/SxNativeInput.cpp`
- `src/StellarX/internal/SxKeyboardRouterInternal.h`
- `src/StellarX/internal/SxEventLoopInternal.h`
- `src/StellarX/internal/SxNativeInputInternal.h`
- `Canvas`、`Table`、`TabControl` 对应递归分发实现
- `tests/StellarXTests.cpp`
- `tests/UiFixture.cpp`
- `scripts/run-ui-tests.ps1`
## 自动验证
### 逻辑与构建矩阵
- Debug x64:67/67。
- x64 ASan:67/67,无 sanitizer 报错。
- Release x64:67/67。
- Debug Win32:67/67。
- KEY1–KEY6:全部编译/链接成功。
- 覆盖路由顺序、结果位独立性、删除/改焦点重入、激活代次、IME/Alt/System/模态抑制、Tab 当前树、Button 状态机、Routed 默认/Legacy 锁定和窄 `DLGC_WANTTAB`。
### 换轨前后 UI A/B
- 换轨前 fixture:`TestResults/Stage3CutoverAB/baseline/StellarX.UiFixture.exe`。
- 基线 SHA-256:`3F6267B6D5EE85A7A97244C3DAE21DB44CEFBD185F56A38516E5E2F6F6234974`。
- 共同八场景执行 5 对 5:Legacy 40/40、Routed 40/40。
- 结构化证据:
- `TestResults/Stage3CutoverAB/legacy/20261009-152906-951-18728`
- `TestResults/Stage3CutoverAB/legacy-expanded/20261009-153941-779-34800`
- `TestResults/Stage3CutoverAB/routed-formal/20261009-153745-045-33364`
- `TestResults/Stage3CutoverAB/routed-expanded/20261009-154044-849-12588`
- Routed 首轮 ownership 为 `1330 ms`,其余四次 `531–601 ms`,与 Legacy `517–709 ms` 重叠;其它场景平均差为 `-19 ms` 至 `+43 ms`。未观察到稳定耗时劣化,但仍承认交互桌面存在残余偶发波动。
### Routed 正式 UI
- 连续两轮九个业务场景共 18/18;普通/模态原生关闭检查每轮通过。
- `keyboard-routing` 2/2:首 Button Space 激活一次,Tab 聚焦第二 Button,Enter 激活一次。
- `input-shadow` 两轮一致:
- Routed 4000/4000,Down/Up 各 2000;
- EasyX `EX_KEY` 排空 4000;
- 旧 EX_KEY 业务交付 0;
- pushed/popped 4000/4000、depth 0、maximumDepth 40;
- sequence 严格、零 fault、零规范化异常、内存增长 0。
- 最初受限桌面运行因 `MoveMouseToScreen` 返回 Access denied,未进入产品断言;授权交互桌面后全部重跑,失败样本不计作产品回归。
- PowerShell 7 宿主同时注入 `PATH` / `Path` 时,旧 `test.ps1` 会让 MSBuild 在创建 `CL.exe` 前以 `MSB6001` 失败。运行器已改为只在检测到重复键时通过 `cmd` 子进程删除大写 `PATH`、保留同值 `Path`;不修改调用者环境。修复后 PowerShell 7 与 Windows PowerShell 5.1 的 Debug x64 均为 66/66。
### 人工反馈修复自动复验
- 新增 `CanvasBackgroundPointerDownClearsFocusAndConsumes` 后,四配置单测均为 67/67,KEY1–KEY6 全部编译/链接成功。
- 扩展 `focus` UI 连续 3/3 通过,证据位于 `TestResults/ui/20261009-220625-941-15096`;覆盖 Table 分页焦点框即时出现/消失、Window/Canvas 空白清焦点和 KEY5 同批上下层重绘。
- 同一 fixture 的完整 UI 1/1、九个业务场景 9/9 通过,证据位于 `TestResults/ui/20261009-220940-941-31488`;Tooltip、Dialog、input-shadow 与 keyboard-routing 均通过。
- 移除 UiFixture 中用于定位问题的临时 Button JSON 诊断字段后,用户从项目终端重新执行最终源码完整 UI。证据位于 `TestResults/ui/20261009-223506-755-18932`:fixture SHA-256 为 `F439C0636CD6B979A47C4594BC5AB3728F6342F49602FE43078435011A877793`,与 `run-summary.json` 一致;九个业务场景 9/9 通过,普通/模态原生关闭均通过。
- 自动化过程中曾分别出现一次沙箱 `SendInput Access denied`、一次 Button 点击未命中和一次 ownership 等待超时;失败证据保留。随后定向 3/3 与完整 UI 通过只说明未形成稳定产品回归,不把这些桌面输入波动写成从未发生。
## 兼容性与迁移
- `Control::handleEvent()` 仍保留,但生产循环不再向其交付 `EX_KEY`;第三方源码控件必须把键盘逻辑迁到 `handleKeyEvent()`。
- `EX_MOUSE` 和既有鼠标命中/分发保持不变。
- 新增虚接口再次改变 `Control` vtable;已编译的第三方派生控件必须使用当前头文件和 Core 库重新编译。
- 默认 `handleKeyEvent()` 为空,因此未迁移的第三方控件不会崩溃,但其旧键盘能力不会继续生效。
- 详细迁移示例见 [`../MIGRATION.md`](../MIGRATION.md)。
## 人工门槛与后续
- 微软拼音与搜狗已分别完成真实候选输入复验:候选出现/取消和 Space 交互正常,未报告卡顿、重复提交或输入法按键误触发 Button。
- Button Space/Enter 行为已由人工确认符合锁定语义:Space 按下显示按压态、松开执行;Enter 首次按下立即执行且不显示持续按压态。
- KEY1/KEY5 人工反馈暴露三项问题:Table 分页焦点框延迟、Window/Canvas 空白点击不清焦点、同父 Canvas 下层重绘覆盖 D-2 上层区域;对应实现和自动回归已补齐。
- 模态 Dialog Tab 不可用和非模态作用域顺序属于阶段 4 真实 Dialog FocusScope 范围,本轮不做临时补丁。
- 用户已定向复测 KEY1 与 KEY5,暂未发现问题:Table 分页焦点框即时出现/消失,Window/Canvas 空白点击清焦点,KEY5 D-1 → D-2 未再发生覆盖。
- 阶段 3 验收至此关闭;后续可单独讨论阶段 4 的 Table、TabControl 和真实 Dialog FocusScope 接入,但本记录不提前宣称这些能力已经实现。
## 落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:`BUG-20261009-0001` / `Fix-BUG-20261009-0001`;`BUG-20261009-0002` / `Fix-BUG-20261009-0002`
- Commit:`29df994`(阶段 3 键盘路由、生产换轨与焦点重绘收口)
- PR:无
- 发布版本:未发布
- 相关文档:
- [`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
- [`Module-20261007-0005-阶段1输入采集与规范化实现.md`](./Module-20261007-0005-阶段1输入采集与规范化实现.md)
- [`Module-20261008-0006-阶段2通用焦点管理器实现.md`](./Module-20261008-0006-阶段2通用焦点管理器实现.md)
- [`../MIGRATION.md`](../MIGRATION.md)
- [`../TESTING.md`](../TESTING.md)