88 KiB
新增功能模块 / 模块重构
适用场景:新增模块、重大模块重构、核心架构能力演进。 不适用场景:小接口或轻量功能变更,请使用“功能变更”模板。
基本信息
- 模块 ID:Module-20261001-0001
- 模块名称:通用输入采集、焦点与键盘路由系统
- 状态:实现中(v0.23;0C/0B 前置基线、阶段 1 输入地基、阶段 2 与阶段 3 六个切片均已实现并完成验收;阶段 4–6 尚未实现)
- 类型:新增模块 / 架构演进
- 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox
- 版本 / 分支:
master,阶段 3 实现提交29df994;阶段 0A 实现基线c218c25 - 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + MBCS 工程配置
- 负责人:Codex 协作设计
背景与目标
- 背景:
- 当前框架已经具备 Window、Control、Canvas、Button、TextBox、Table、TabControl、Dialog 等基础能力,但尚未形成统一的输入采集、逻辑焦点和键盘定向路由体系。
- 当前 TextBox 仍依赖 EasyX 输入能力,焦点也不能继续作为 TextBox 独有状态;Button、Table 分页按钮、TabControl 页签按钮和 Dialog 都需要进入同一套键盘交互模型。
- 阶段 0 已确认 EasyX HWND/WndProc 与宿主
peekmessage分属两个线程,当前窗口为 ANSI;微软拼音与搜狗的提交/回声顺序已经取得真实输入法证据;宽字符 GDI 绘制链与当前 96/144 DPI 坐标基线也已取得自动证据。
- 当前痛点:
- 缺少“哪个控件接收键盘”的统一规则。
- 键盘物理按键、字符输入和 IME 提交结果没有明确分层。
- Table、TabControl 等复合控件的内部 Button 缺少统一的 Tab 顺序和事件边界。
- Dialog 默认按钮、Escape 关闭、TextBox 编辑和 IME 候选操作可能争用同一按键。
- 控件删除、隐藏、禁用、切页和模态重入可能使裸指针焦点失效。
- 工程仍为 MBCS,现有文本链路不能直接承担完整 Unicode 编辑内核。
- 目标:
- 建立“输入采集与规范化 → 通用焦点 → 键盘定向路由 → 控件接入”的稳定分层。
- 让 Button、TextBox、Table 内部分页 Button、TabControl 页签 Button 等交互控件使用同一焦点系统。
- 建立不依赖控件裸指针的焦点和路由生命周期。
- 为 TextBox 自绘、UTF-16 编辑内核、剪贴板和 IME 提交提供统一输入基础。
- 在框架接管输入前,通过阶段 0 探测 EasyX 和 Win32 的真实消息链路。
- 非目标:
- v1 不自绘 IME 拼音组合串和候选列表,继续使用系统 IME UI。
- v1 不重写现有全部鼠标采集链;现有 EasyX 鼠标消息只在框架入口规范化为
PointerEvent。 - v1 不承诺完整 Unicode grapheme cluster 编辑,只保证不拆分 UTF-16 代理对。
- 本稿中的阶段 1、阶段 2 与阶段 3 已有实现记录、自动验证和对应人工证据;阶段 3 已关闭验收。阶段 4–6 仍是设计目标,不应被误读为已经实现。
模块边界
- 职责:
- WndProc 同步窗口协议的就地应答。
- 键盘、字符、IME 和窗口激活消息的异步采集与规范化。
- 每个 Window 的 NativeInputQueue、单一 drainer 和嵌套模态循环输入所有权。
- 通用逻辑焦点、焦点作用域、Tab 顺序和焦点回退。
- 焦点目标键盘路由、祖先冒泡和 Dialog/Window 默认动作。
- Button、TextBox、Table、TabControl、Dialog 的统一键盘行为。
- UTF-16 文本输入、ANSI/DBCS 回退和 IME 正式提交去重。
- 不负责什么:
- 不替代操作系统键盘布局、IMM32 或系统候选窗口。
- 不让绘制控件变成原生 HWND 子控件。
- 不在路由层回溯修改已经结束的 WndProc 返回值。
- 不把非活动 Tab 页面作为可访问焦点树的一部分。
- 不在 v1 中把所有指针输入迁移到原生 WndProc 采集。
- 外部依赖:
- Win32 窗口消息和旧窗口过程。
- EasyX 消息循环和工作图像。
- IMM32:
ImmGetCompositionStringW、ImmSetCompositionWindow、ImmSetCandidateWindow、ImmNotifyIME。 - 宽字符 GDI:
TextOutW/DrawTextW/GetTextExtentPoint32W。
- 对外能力 / API:
- 阶段 1–3 已落地
NativeInputEvent、FocusManager、FocusTargetId、RoutedKeyEvent、KeyRouteResult和Control::handleKeyEvent();后续阶段不得再把这些完成态写成占位草案。 - 控件通过焦点能力、键盘处理和 Tab stop 收集接口接入;正式文本输入接口仍留阶段 5/6。
- 阶段 1–3 已落地
- 关键数据 / 状态:
NativeInputEvent、KeyEvent、TextInputEvent、CompositionEvent、WindowActivationEvent。ControlHandle、FocusScope、逻辑焦点句柄和窗口键盘激活状态。- 每个 Window 的输入队列、drainer 所有者、dispatch/modal 深度。
- 每个 HWND 的 IME 状态、
compositionId、结果回声和 ANSI 解码状态。
设计说明
1. 总体建设顺序
阶段 0:输入链路探测
↓
阶段 1:输入采集与规范化
↓
阶段 2:通用焦点系统
↓
阶段 3:键盘定向路由与 Button 接入
↓
阶段 4:Table / TabControl / Dialog 作用域与默认动作接入
↓
阶段 5:TextBox 编辑内核与自绘
↓
阶段 6:IME、Unicode、DPI 完整接入
阶段 0 是实现门槛,不是可选调研。阶段 0A 的详细规格见:
1.1 阶段 0 已确认事实(更新至 2026-10-06)
独立 EasyX.ThreadProbe 不链接 StellarX,只执行裸 initgraph、WndProc 子类化、固定键投递和 peekmessage。实测:
- main 在
initgraph前后保持同一线程。 - EasyX HWND 属于另一个线程。
- 裸 WndProc 和固定键 WndProc 都在 HWND 属主线程执行。
peekmessage仍由 main/宿主线程调用。- 固定键同时经过 WndProc 和 EasyX
EX_KEY两个观察面。 IsWindowUnicode(hwnd) == false,当前 EasyX 窗口类为 ANSI;活动中文布局代码页实测为 936。
因此,双线程不是 UiFixture 或 StellarX 钩子制造的假象,而是当前 EasyX initgraph 的固有运行模型。阶段 1 不得再使用“WndProc 与宿主循环同线程”的假设。
微软拼音与搜狗真实人工日志进一步确认:
- 两者均以
WM_IME_COMPOSITION + GCS_RESULTSTR提供正式提交结果。 - 当前 ANSI/CP936 窗口中,随后出现与结果完整匹配的
WM_IME_CHAR回声;两种输入法均未形成可匹配的WM_CHAR回声。 - 微软拼音 3 次结果均为 2 个 scalar;搜狗 3 次结果分别为 7、2、6 个 scalar,证明去重不能依赖固定长度或文本内容。
- 两次“中英文/输入法切换”会话均未观察到
WM_INPUTLANGCHANGE。IME 内部模式、TSF 输入配置文件与 HKL/代码页不得混为同一状态。 - 原探测器遗漏了
WM_INPUTLANGCHANGEREQUEST默认观察,v0.6 已补入 request/change 分层:request 只观察和转发,change 才更新活动代码页。 - v0.7 补观测后的 Win+Space 专用短测仍未出现 request/change;当前系统仅安装
zh-Hans-CN语言标签,实际切换发生在同一中文 HKL/CP936 下的 TSF 输入配置文件之间,因此该结果确认“TSF 配置文件切换不保证产生 HKL 消息”,但不代表跨语言 HKL 切换也不会产生。
2026-10-05 的独立 EasyX.VisualProbe 进一步确认:
- EasyX 25.9.10 的
TextOutW、DrawTextW、GetTextExtentPoint32W可直接写入主画面与SetWorkingImage工作图像,覆盖批次内外、BMP 中文、代理对、NFD 组合字符和混排;无需因当前 MBCS 工程默认降级 CP936 绘制。 - 主画面批次内绘制在 Flush 前不出现在客户区,Flush/End 后出现;工作图像不会自行上屏,必须显式
putimage合成。 - 批次必须在主画面进入/退出;
SetWorkingImage(&working)后直接调用BeginBatchDraw()会触发0xC0000005。安全顺序是“主画面 Begin → 批次内切工作图像 → 回主画面 → Flush/End”。 - 当前应用继承模式为 DPI-unaware,窗口 DPI 为 96;同一台 150% 显示器上的独立 Per-Monitor-V2 进程窗口 DPI 为 144。两种模式下 EasyX、原生 GDI 与 HWND 客户区使用相同数值坐标,未观察到 EasyX 额外隐式缩放,客户区/屏幕往返误差为 0。
- 上述坐标结论只覆盖当前显示器的 96 虚拟化视图和 144 物理视图;100%/125%、跨屏及真实候选框视觉位置仍不得宣称完成。
- 微软拼音三个合成 anchor 的真实截图未观察到相对 caret 的整体偏移;两轮人工操作实际均为微软拼音,脚本中的预期名称不能代替输入法身份事实。
- 搜狗真实运行证明候选 UI 可能已经出现,但 EasyX HWND 尚未收到探测器原先依赖的
WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION。因此位置设置不能只挂在 IME 生命周期消息上;必须在焦点/caret/anchor 变化时预设,并在生命周期消息到达时再校准。 - 修复后的搜狗运行中,F1/F2/F3 的 anchor 预设全部成功,F3 生命周期二次校准成功;F3 提交后回到 F1 的截图与初始 F1 裁剪字节一致。当前 96 DPI 虚拟化视图下,微软拼音与搜狗 0C-3 均通过人工视觉验收。
2026-10-06 的 0B 首轮 Composition 人工运行进一步确认:
- 操作者按 Escape 时,组合态 WndProc 观察到的是
WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1,组合结束后的 KeyUp 才是VK_ESCAPE(27);因此原生采集规则必须允许用 message、VK 与 scan code 三元组精确描述,不能把 WndProc 层的VK_PROCESSKEY伪装成已恢复的逻辑 Escape。 ImmGetVirtualKey只允许应用在自身消息循环调用TranslateMessage前恢复原 VK;当前钩子已经位于 EasyX WndProc,不能依赖该 API 补救时机。- 0B 夹具必须自己引用 0C 已验证的客户区 caret/line rect,并在焦点/IME context 与组合生命周期双时机设置组合窗和候选窗位置;否则“候选框异常”只证明夹具缺少定位,不能证明消费动作破坏 IME。
- 宿主安全点复制整批记录会与 WndProc 争用状态锁。0B 修正为双固定缓冲、锁内常数时间交换、锁外写盘;诊断采集不再用
try-lock丢弃 WndProc 观察。 - 固定 IME 定位 setter 可能同步重入 WndProc。任何探测或生产输入实现都不得持有状态/输入队列锁调用 IMM32 setter;0B 使用“锁内提交父 enter 和不可变请求 → 锁外 IMM32 → 锁内校验会话并提交
ime_position结果”的三阶段协议,并以线程局部标志阻止递归定位。
2. 分层架构
Win32 / EasyX
│
▼
InputCollector
同步应答、异步采集、Unicode/DBCS 解码、IME 状态与去重
│
▼
NativeInputQueue
SPSC 语义的跨线程通道:EasyX 窗口线程生产,宿主线程消费
│
▼
InputDispatcher
单 drainer、模态重入、逐条出队
├───────────────┐
▼ ▼
FocusManager KeyboardRouter
└───────┬───────┘
▼
Button / TextBox / Table / TabControl / Dialog
鼠标 v1 暂不在 WndProc 重新采集。现有 EasyX 鼠标事件进入控件分发前转换成框架 PointerEvent,由 PointerDown 在命中焦点叶子时申请焦点。
3. 关键不变量
以下是阶段 3 换轨后的完成态不变量。阶段 1–2 的“旧 EX_KEY 继续业务下发、新队列只观察”和阶段 3 切片 1–5 的测试门控均为历史过渡;v0.22 已由切片 6 一次性把生产默认切换为 Routed,Legacy 只保留为事件循环启动前锁定的内部 A/B 基线,任何模式下旧、新业务路径都必须互斥:
- 阶段 3 启用后,NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。
- 阶段 3 启用后,EasyX
EX_KEY只排空和观察,不得再次触发业务路由。 - 同步窗口协议不得进入异步输入队列。
- 异步路由只能阻止框架默认动作,不能倒过来改变已经结束的 WndProc 返回值。
- 队列、焦点、Tab 顺序、路由路径和待删除列表长期只保存
FocusTargetId/ControlHandle,不保存裸Control*。 - 同一 Window 同时只能由最内层消息循环 drain 输入队列。
- 每次只从队列取一条事件,不预取整个批次到局部容器。
- WndProc 只在 EasyX 窗口线程采集;焦点、路由和控件操作只在宿主分发线程执行。
- NativeInputQueue 必须提供跨线程发布/获取的 happens-before,不能使用无锁保护的普通
std::deque。 sequence提供全局因果编号,但不能代替队列同步。- 窗口键盘激活态由采集线程发布原子快照;业务路由不能只依赖消费者按顺序处理
WindowActivationEvent得到的布尔值。 - 模态 Dialog 尚未进入 FocusScope 前,内层循环只排空并观察原生键盘队列,不得把键路由到底层 Window 焦点。
4. Win32 语义边界
4.1 TranslateMessage 与 WndProc
标准 Win32 循环通常先执行 TranslateMessage,再通过 DispatchMessage 进入 WndProc。WndProc 对当前 WM_KEYDOWN 返回 0 不能回溯撤销已经产生的字符消息。
真正需要探测的是 EasyX 是否调用 TranslateMessage、调用顺序是否标准、普通循环和模态循环是否一致。因此 v1 默认采用“采集 + 转发”,只允许对实测确认后的窄白名单消费。
4.2 WM_UNICHAR
设计不假设 WM_UNICHAR 只会到达某一种窗口:
UNICODE_NOCHAR是同步能力查询,支持真实WM_UNICHAR时立即返回TRUE。- 真实
WM_UNICHAR使用 UTF-32,收到后统一校验并转换为 UTF-16。 - 真实字符若由框架处理,则返回 0,防止 DefWindowProc 再产生
WM_CHAR。 - 阶段 0 记录其实际来源,不把未被微软文档承诺的系统投递规律写成框架契约。
4.3 WM_IME_SETCONTEXT
IME UI 由“是否转发、转发时保留哪些 ISC_SHOWUI* 标志、被调用窗口过程的返回值”共同决定。
v1 使用系统 IME UI:
- 不修改
lParam。 - 调用旧窗口过程。
- 返回旧窗口过程结果。
未来自绘组合串时,才在转发前清除 ISC_SHOWUICOMPOSITIONWINDOW 等由框架接管的标志。
5. WndProc 消息分类
5.1 同步窗口协议
以下消息必须就地处理,不进入 NativeInputQueue:
| 消息 | v1 行为 |
|---|---|
WM_GETDLGCODE |
根据当前框架焦点能力,选择性合并并返回 DLGC_WANTTAB/WANTARROWS/WANTCHARS 等 |
WM_UNICHAR + UNICODE_NOCHAR |
支持真实 WM_UNICHAR 时立即返回 TRUE |
WM_IME_SETCONTEXT |
原样转发旧窗口过程并返回其结果 |
WM_SETCURSOR |
框架拥有命中区域光标时就地设置,否则转发 |
WM_MOUSEACTIVATE |
根据激活策略同步返回 |
WM_GETMINMAXINFO |
保留现有同步处理 |
WM_SIZING |
保留现有同步处理 |
WM_ERASEBKGND |
保留现有同步处理 |
WM_NCHITTEST 等 |
返回值直接影响当前系统行为的消息保持同步 |
WM_GETDLGCODE 不得无条件返回 DLGC_WANTALLKEYS,必须结合当前焦点能力、wParam 和可选 MSG* 选择性声明。
阶段 3 的首个生产接管项只有 Tab:
- 切片 1–5 的生产路径继续原样返回旧窗口过程结果,不提前改变同步协议。
- 切片 6 启用生产 KeyboardRouter 后,先调用旧窗口过程,再仅在当前查询明确对应 Tab 时合并
DLGC_WANTTAB。 - 不因阶段 3 已有路由框架就顺手声明
DLGC_WANTALLKEYS、DLGC_WANTARROWS或DLGC_WANTCHARS;方向键、文本与复合控件能力在实际接入阶段分别开放。 WM_GETDLGCODE仍不进入 NativeInputQueue,也不经过异步KeyRouteResult。
5.2 异步输入消息
| 消息 | 规范化结果 | 初始处置 |
|---|---|---|
WM_KEYDOWN/UP |
KeyEvent |
采集后转发 |
WM_SYSKEYDOWN/UP |
KeyEvent |
采集后转发,保留系统组合键 |
WM_CHAR |
TextInputEvent 候选来源 |
解码、去重后转发 |
真实 WM_UNICHAR |
UTF-32 转 UTF-16 | 采集并返回 0 |
WM_DEADCHAR |
只记录状态 | 不产生文本,继续转发 |
WM_SYSCHAR/SYSDEADCHAR |
通常不进入控件 | 继续转发 |
WM_IME_STARTCOMPOSITION |
CompositionEvent::Start |
采集后转发 |
WM_IME_COMPOSITION |
Update / Commit / Cancel | 采集后转发 |
WM_IME_ENDCOMPOSITION |
CompositionEvent::End |
采集后转发 |
WM_IME_CHAR |
IME 回声或兼容来源 | 观察、去重、转发 |
WM_SETFOCUS/KILLFOCUS |
WindowActivationEvent |
采集后转发 |
WM_INPUTLANGCHANGEREQUEST |
输入布局切换请求 | 观察请求方向与目标 HKL,原样转发,不提前更新代码页 |
WM_INPUTLANGCHANGE |
更新输入代码页状态 | 同步更新状态并转发 |
5.3 消费策略
初始策略:
KeyDown/KeyUp:采集 + 转发
WM_CHAR:采集 + 转发
IME 消息:采集 + 转发
真实 WM_UNICHAR:采集 + 消费
只有阶段 0 证明转发会产生重复行为或原生副作用后,才允许建立窄白名单,例如:
- 非组合态 Tab。
- 非组合态且框架明确拥有的方向键。
- 非组合态 Button 的 Enter/Space。
- 框架明确拥有的 Ctrl 快捷键。
硬规则:imeComposingAtCapture == true 或原始 virtualKey == VK_PROCESSKEY 的键不能进入消费白名单,也不得进入 Button、Dialog、快捷键或焦点导航等业务路由。后者不依赖标准 Composition 生命周期,因为搜狗实测已证明候选 UI 可见时目标 HWND 仍可能保持 imeState=idle。
6. 输入事件模型
6.1 KeyEvent
struct KeyEvent
{
KeyAction action; // Down / Up
UINT virtualKey; // 消息原始 VK
UINT physicalVirtualKey; // 区分左右修饰键后的 VK
UINT scanCode;
uint16_t repeatCount;
bool extended;
bool wasDown;
bool altContext;
bool systemKey;
bool imeComposingAtCapture;
KeyModifiers modifiers; // left/right Ctrl/Shift/Alt
};
规则:
bool isAutoRepeat = action == KeyAction::Down && wasDown;
modifiers.alt()是业务逻辑依据。altContext只保留 Win32 lParam bit 29 原始值。- 左右 Ctrl/Alt 使用 extended 位,Shift 使用 scan code 区分。
- Button 判断首次按下使用
wasDown,不使用repeatCount > 1代替。
6.2 TextInputEvent
enum class TextSource
{
ImeResult,
UnicodeChar,
UnicodeScalar,
AnsiCharFallback,
Clipboard
};
struct TextInputEvent
{
std::wstring text;
TextSource source;
uint64_t compositionId; // 非 IME 路径为 0
};
该事件只表示已经形成的文字,不表示物理按键。
6.3 CompositionEvent
enum class CompositionStage
{
Start,
Update,
Commit,
Cancel,
End
};
struct CompositionEvent
{
CompositionStage stage;
uint64_t compositionId;
std::wstring compositionText;
uint32_t caretOffset;
};
Commit 只描述 IME 状态变化。真正插入 TextBox 的内容仍然只通过一条 TextInputEvent 发送。
6.4 WindowActivationEvent
struct WindowActivationEvent
{
bool keyboardFocusActive;
HWND otherWindow;
};
窗口失活时保留逻辑焦点身份,但 keyboardFocusActive == false 时暂停键盘、文本和 IME 的业务分发,并单独取消 Button 等控件的键盘瞬时按压态。窗口重新激活后先验证句柄,失效则执行焦点回退。清理按压态和暂停业务路由是两个独立动作,不能用其中一个代替另一个。
6.5 队列外壳
struct NativeInputEvent
{
uint64_t sequence;
DWORD messageTime;
HWND hwnd;
bool windowActiveAtCapture;
uint64_t windowActivationGeneration;
std::variant<
KeyEvent,
TextInputEvent,
CompositionEvent,
WindowActivationEvent
> payload;
};
键盘、文本和 IME 事件不合并、不静默丢弃。
windowActiveAtCapture 与 windowActivationGeneration 属于整个原生事件的采集时快照,而不是只属于 KeyEvent。TextBox 后续接入文本与 IME 时必须使用同一门控,避免失活前积压的文字在重新激活后插入。
每个 NativeInputContext 维护一个可原子读取的 { keyboardFocusActive, generation }:
- 挂接 WndProc 前用 HWND 属主线程的
GetGUIThreadInfo初始化;查询失败时按 inactive 处理,不猜测为 active。 WM_SETFOCUS/WM_KILLFOCUS在入队WindowActivationEvent前发布新快照;激活状态真正变化时递增 generation。- 其它原生事件入队时复制当时的 active 与 generation。
- 宿主路由一条业务事件前重新读取最新原子快照;只有“采集时 active、当前仍 active、generation 相同”三者同时成立才允许路由。
- 失活后再恢复会产生新 generation,旧激活周期的积压事件即使此时窗口再次 active 也必须丢弃业务交付。
- 门控以宿主线程紧邻路由前的原子读取为线性化点;不得为追求跨线程绝对同时而在 WndProc 与控件回调之间持有共享阻塞锁。
切片 3 使用一个 64 位原子值打包 active 位与 generation,避免消费者读到撕裂的二元状态;generation 0 保留为未知/无效快照。首次挂接通过窗口属主线程的 GetGUIThreadInfo 判断键盘焦点,查询失败按 inactive 初始化。WM_SETFOCUS/WM_KILLFOCUS 先发布新状态,再把携带同一快照的 WindowActivationEvent 入队;每条其它原生输入也复制采集时快照。
6.6 跨线程队列模型
当前拓扑是单生产者/单消费者:
EasyX HWND/WndProc 线程
└─ InputCollector / push
↓
NativeInputQueue
↓
宿主 main/runEventLoop 线程
└─ 最内层 drainer / pop one
阶段 1 的正确性基线采用 mutex + std::deque:
- WndProc 每次只在短临界区内移动一条完整事件入队。
- 宿主每次只在短临界区内弹出一条事件,释放锁后再执行路由、回调和控件 mutation。
- 禁止在持锁期间调用旧窗口过程、IMM、用户回调、控件方法或文件 I/O。
- 当前宿主循环持续轮询,不额外增加唤醒;未来改成阻塞等待时再使用 Event/条件变量。
- 队列不得静默丢输入;若以后改为固定容量 lock-free SPSC,必须先定义文本负载所有权、满队列和分配失败策略。
- 若改为 lock-free SPSC,生产者写完 slot 后以 release 发布写索引,消费者以 acquire 读取;回收索引反向使用同等内存序。
- 全局
sequence使用 atomic 分配,但事件可见性由互斥锁或 release/acquire 队列协议保证。
探测器使用的固定双缓冲和 records_lost 失效判定只适用于诊断日志,不能照搬为生产输入队列;生产输入事件比探测记录具有更强的不丢失要求。诊断路径同样禁止持锁调用旧窗口过程、IMM32 setter、用户回调或文件 I/O。
7. Unicode 和文字来源
7.1 窗口属性
初始化时记录:
bool unicodeWindow = IsWindowUnicode(hwnd);
窗口类 A/W 属性决定 WM_CHAR 是 UTF-16 还是当前进程代码页数据,但不用于拒绝真实 WM_UNICHAR。
7.2 正式提交优先级
1. ImmGetCompositionStringW(GCS_RESULTSTR)
2. Unicode HWND 的 WM_CHAR
3. 真实 WM_UNICHAR
4. ANSI HWND 的状态化 WM_CHAR / WM_IME_CHAR 回退
ImmGetCompositionStringW 返回的是字节数,即使使用 W 版本也必须换算为 UTF-16 code unit 数量。
7.3 IME 结果去重
收到 GCS_RESULTSTR 时:
- 立即产生唯一的
TextInputEvent。 - 记录当前
compositionId。 - 保存预期 UTF-16 或 ANSI 回声序列。
- 后续紧邻且匹配的
WM_IME_CHAR/WM_CHAR只观察和转发,不再次入队。 - 遇到不匹配字符、新组合、焦点切换或越过本次结果边界时清空回声状态。
不能使用“字符内容相同就去重”,否则连续输入两个相同汉字时会误删第二个。具体回声顺序以阶段 0 日志决定。
7.4 ANSI / DBCS 回退
struct AnsiDecoderState
{
UINT codePage;
std::optional<BYTE> pendingLead;
};
处理规则:
- 使用当前实际 ANSI 代码页。
- 使用
IsDBCSLeadByteEx判断 lead byte。 - lead byte 暂存,不立即形成文本。
- 下一字节作为 trail。
- 使用
MultiByteToWideChar严格转换。 - 转换失败时清除状态并记录诊断。
- 焦点切换、窗口失活、IME 开始时清除残留
pendingLead。 - 收到
WM_INPUTLANGCHANGEREQUEST时只记录目标布局并转发,不提前改变解码状态。 - 收到
WM_INPUTLANGCHANGE时,必须先清除pendingLead,再更新活动代码页,禁止跨代码页拼接字节。 - 窗口挂接和重新获得键盘焦点时,按 HWND 属主线程调用
GetKeyboardLayout做防御性快照;若快照与当前 HKL/代码页不同,同样先清pendingLead再更新,并记录状态来源。该快照只用于修复消息遗漏或失焦期间变化,不把 TSF 配置文件误建模为代码页变化。
WM_IME_CHAR 在 ANSI 窗口可能携带一个双字节字符;转发给 DefWindowProc 后又可能拆成两个 WM_CHAR,因此必须纳入结果去重状态机。
8. IME 状态机
8.1 v1 策略
- 保留系统组合串和候选框 UI。
- TextBox 自绘已提交文本、选区和光标。
- 框架设置系统 IME UI 的位置。
WM_IME_SETCONTEXT原样转发。- v1 不清除
ISC_SHOWUICOMPOSITIONWINDOW。
8.2 compositionId
每个 Window 维护独立的单调递增 uint64_t nextCompositionId,0 保留给非 IME 输入。
WM_IME_STARTCOMPOSITION:
compositionId = ++nextCompositionId
如果在 Idle 状态直接收到 WM_IME_COMPOSITION,必须惰性分配新的 compositionId。该 ID 不得沿用上一次 Commit 或 Cancel 的组合,避免迟到或乱序消息污染已经结束的状态。
8.3 状态流
Idle
└─ WM_IME_STARTCOMPOSITION → 分配 id → Composing
Composing
├─ GCS_COMPSTR → Update
├─ GCS_RESULTSTR → Commit + TextInputEvent
├─ 无任何 GCS_* → Cancel
└─ WM_IME_ENDCOMPOSITION → End → Idle
8.4 Cancel 来源
CompositionStage::Cancel 由以下情况合成:
- 活跃组合中收到无
GCS_*的WM_IME_COMPOSITION。 - 框架调用
ImmNotifyIME(..., NI_COMPOSITIONSTR, CPS_CANCEL, 0)成功。 - 收到
WM_IME_ENDCOMPOSITION,但本次组合没有 Commit。
每个 compositionId 最多产生一次 Cancel。主动取消成功后,后续系统消息不能再次发出 Cancel。
8.5 组合期间按键
组合开始后:
- 取消 Button 等控件残留的键盘按压态。
- KeyEvent 继续采集并转发旧窗口过程。
- KeyEvent 不发送给控件命令处理器。
- 不执行 Tab 导航。
- 不执行 Dialog 的 Enter/Escape。
- 不切换 TabControl 页签。
- 文本只接受 IME 正式提交结果。
判断依据是事件自己的 imeComposingAtCapture,不是稍后读取的全局状态。
9. IME 位置与 DPI
TextBox 提供:
struct TextInputAnchor
{
POINT caretTopLeft;
RECT caretLineRect;
};
v1 使用:
COMPOSITIONFORM:
CFS_POINT
ptCurrentPos = caretBottom
CANDIDATEFORM:
CFS_EXCLUDE
ptCurrentPos = caretBottom
rcArea = 当前文字行矩形
DPI 规则:
- 记录进程 DPI awareness 和
GetDpiForWindow(hwnd)。 - 验证 EasyX 绘制坐标是否与目标 HWND 坐标空间一致。
- 一致时直接使用,不手工乘 DPI 比例。
- 只有实测发现坐标空间不同才做明确转换。
- 验证 100%、125%、150% 和跨显示器移动。
当前 0C-2 证据允许把第 3 条落实为当前默认:在已验证的 DPI-unaware 96 视图和 Per-Monitor-V2 144 视图中,caretBottom 与 caretLineRect 直接使用目标 HWND 客户区坐标,不再手工乘 DPI 比例。该默认必须在 0C-3 真实 IME 和尚缺的 100%/125%/跨屏矩阵中继续验证;发现反例时只能增加带 awareness 适用范围的显式换算,不能全局补乘比例。
位置刷新规则:
- TextBox 获得焦点、caret 移动、选择变化导致 caret 改变、滚动改变可视 caret,或输入法/输入配置发生可观察变化时,立即把最新 anchor 写入当前 IME context。
WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION到达时再次写入,校准 IME 在组合开始时重置位置的实现差异。- 两类写入使用同一份
TextInputAnchor和 HWND 客户区坐标,不建立两套换算公式。 - 不把“未收到 IME 生命周期消息”等同于“没有候选 UI”;搜狗实测已经否定该假设。
10. 通用焦点系统
10.1 焦点能力
enum class FocusPolicy : std::uint8_t
{
None = 0,
Programmatic = 1u << 0,
Pointer = 1u << 1,
Tab = 1u << 2
};
struct FocusTargetId
{
ControlHandle owner;
std::uint32_t subNode = 0;
};
FocusTargetId 是 FocusManager 唯一长期身份。阶段 2 审计确认 Table 分页按钮与 TabControl 页签按钮都是真实 Button,所以现有焦点节点全部使用 {buttonHandle, 0};非零 subNode 只预留给未来确实没有独立 Control 的轻量节点,不得为现有 Button 制造第二套身份。
| 控件 | 焦点角色 |
|---|---|
| Label | 不可聚焦 |
| Canvas | 透明焦点容器 |
| Button | 焦点叶子 |
| TextBox | 焦点叶子 |
| Table | 焦点容器,暴露内部翻页 Button |
| TabControl | 页签焦点组和活动页面容器 |
| Dialog | 焦点作用域 |
| Window | 根焦点作用域 |
10.2 身份与瞬时快照协议
当前 ControlHandle 是进程内单调递增且不复用的 uint64_t,0 表示无效;不存在 slot/generation 复用模型。焦点树长期只保存 FocusTargetId,解析时临时取得 FocusTargetSnapshot:
struct FocusTargetSnapshot
{
FocusTargetId id;
FocusTargetId logicalParent;
FocusScopeId scope;
FocusPolicy policy;
bool visible;
bool enabled;
bool focusable;
bool tabStop;
std::int32_t tabIndex;
};
Window::findControl()只查直接子级,不能承担焦点解析;FocusManager 使用独立递归协议遍历 Canvas、Table 和 TabControl。enabled由控件协议动态给出,不假定基类存在统一 enabled 字段;例如 Button 从ButtonMode::DISABLED推导。- 快照布尔值只代表采集时刻。任何 FocusLost/FocusGained 用户回调结束后必须重新解析并重新拉取全部字段。
Control*只允许作为当前函数内立即使用的借用视图,不得跨事件、回调、mutation transaction 或模态调用保存。
10.3 焦点有效条件
句柄有效
&& effectiveVisible
&& effectiveEnabled
&& FocusPolicy 允许
&& 位于最上层活动 FocusScope
&& 位于活动 Tab 页面
&& 不在 pending removal
10.4 焦点切换
FocusChangeResult requestFocus(
FocusTargetId target,
FocusReason reason);
切换在宿主线程的 mutation transaction 中执行:
- 重新解析并验证目标。
- 保存旧
FocusTargetId,临时清空 active。 - 向旧目标发送
FocusLost;回调可删除控件、切页、push scope 或再次请求焦点。 - 回调后重新拉取目标的完整快照;目标失效时保持无焦点,不复活旧目标。
- 提交新 active,再发送
FocusGained。 - 回调后再次验证 active;active 失效才进入独立的失效回退路径。
- 使旧/新控件所属托管区域失效并重绘。
回调内的 requestFocus() 不递归切换,只覆盖当前回合的单槽 pending。回合末只验证最终槽位;最终请求失效就丢弃,不恢复被覆盖请求。一次外层调用最多执行 64 轮,超限返回 FocusError::ReentrancyLimit、清空 pending,并保留最后一个已提交且仍有效的 active。
pushScope() 若发生在 FocusLost 回调中,恢复点取该回调对应的旧目标;若发生在 FocusGained 回调中,恢复点取新目标。不能因为切换中间态 activeTarget 暂时为空而丢失外层恢复身份。
10.5 焦点失效回退
焦点控件被隐藏、禁用、删除或移入非活动页时:
同一作用域下一个可聚焦项
→ 上一个可聚焦项
→ 作用域 initialFocus
→ 无焦点
控件进入 pending removal 前,必须先让焦点系统中的对应句柄失效。
pending 请求失效与 active 焦点失效是两个入口:pending 失效只丢弃;active 失效才执行上述回退。clearControls() 必须先批量标记整批子树失效,再选择后备,禁止焦点落到同批即将删除的兄弟控件。
10.6 Dialog 作用域
Dialog 打开:
- 压入新 FocusScope。
- 保存外层焦点句柄。
- 选择
initialFocus,否则选择第一个 Tab stop。 - 完成作用域建立后才允许模态循环 drain。
Dialog 关闭:
- 停止内层 drain。
- 弹出作用域。
- 验证并恢复原焦点。
- 原焦点失效时执行外层回退。
阶段 2 只实现并测试 pushScope/popScope 机制,使用测试作用域验证重入与恢复;真实 Dialog 的打开/关闭接入仍留阶段 4,避免在焦点地基阶段提前改变模态行为。
11. Tab 顺序与复合控件
11.1 Tab 顺序
顺序由显式 tabIndex 和控件树稳定顺序决定,过滤:
- 隐藏控件。
- 禁用控件。
- pending removal 控件。
- 非活动页面控件。
- FocusPolicy 不包含 Tab 的控件。
Tab 只在当前最上层 FocusScope 内循环。
11.2 Table
Table 本身不是焦点叶子,通过 collectTabStops() 暴露上一页和下一页 Button。
- 第一页时上一页 Button 禁用并退出 Tab 顺序。
- 最后一页时下一页 Button 禁用并退出 Tab 顺序。
- 翻页后当前焦点 Button 若被禁用,立即执行焦点回退。
- 翻页 Button 只向 Table 和外层祖先冒泡,不能把按键转发到表格内容。
11.3 TabControl
页签使用 roving-tab-stop:
- 只有当前选中页签位于外部 Tab 顺序。
- Tab 进入 TabControl 时先到当前页签。
- Left/Right 在页签组内移动并激活页签。
- Tab 从页签进入活动页面第一个焦点项。
- Shift+Tab 从页面第一个焦点项回到活动页签。
- 非活动页面完全退出焦点树。
页签 Button 与页面是兄弟关系:
页签 Button → TabControl → 外层容器
禁止:
页签 Button → 活动页面
活动页面控件可以向祖先 TabControl 冒泡,但 TabControl 只有在原始焦点是页签 Button 时才处理 Left/Right。
12. 键盘路由
12.1 总流程
取出一条事件
→ 校验 sequence 并累计观察统计
→ 验证采集激活快照与当前激活 generation
→ 验证当前是否处于模态隔离态
→ IME / 系统组合键预处理
→ 锁定原始焦点句柄
→ 构造句柄路由路径
→ 焦点叶子
→ 父容器冒泡
→ 若未阻止框架默认动作,以回调后的新焦点树执行 Tab 导航
→ FocusScope 默认动作
→ Window 默认动作
→ 提交当前事件产生的 mutation
TextInputEvent 只发送给实现文本输入接口的当前焦点叶子,不冒泡。
阶段 3 只对 KeyEvent 建立生产业务路由;TextInputEvent 与 CompositionEvent 仍完整排空、观察和计数,等阶段 5/6 的 TextBox/IME 接口接入后再交付。原始 VK_PROCESSKEY、imeComposingAtCapture == true 的键,以及阶段 3 没有明确拥有的 Alt/System 组合键,一律不进入 Tab、Button、Dialog 默认动作或其它框架业务处理。
12.2 路由结果
struct KeyRouteResult
{
bool stopBubble;
bool preventFrameworkDefault;
};
stopBubble:不再交给父容器。preventFrameworkDefault:冒泡结束后不再执行 Dialog/Window 默认动作。- 两者均不影响已经完成的原生 WndProc 返回值。
切片 4 将 Tab 定义为冒泡后的框架默认动作:原始焦点路径可以通过 preventFrameworkDefault 抑制换焦,单独 stopBubble 不会隐式抑制默认动作。真正执行 Tab 时重新采集当前活动 FocusScope 的完整停靠点树,不复用冒泡前快照,也不缓存上一按键的空结果。
12.3 路由路径与 mutation
事件开始时使用句柄构造路径快照:
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 完成后的模式为:
Routed(生产默认)
EX_KEY → 纯排空与统计
NativeInputQueue → KeyboardRouter
LegacyCompatibility(仅内部 A/B 基线)
EX_KEY → Control::handleEvent
NativeInputQueue → 只观察排空
硬规则:
- 两种模式互斥,任何窗口在同一时刻只能有一个键盘业务源。
- 测试门控只能在事件循环开始前设置,不允许运行中切换造成半批次双投递或漏投递。
- 切片 1–5 的生产默认曾保持
LegacyCompatibility;切片 6 已把默认一次性改为 Routed。 - 普通 Window 与模态 Dialog 的 EasyX 循环必须在同一变更中换轨,不能存在只换其中一路的中间生产状态;当前实现已满足该约束。
- 换轨后仍继续请求并排空
EX_KEY,避免 EasyX 内部队列累积;但该面不再调用任何控件的handleEvent()。 EX_MOUSE永久保持既有handleEvent()路径,不属于本次兼容性切换。- 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,不移动焦点。
- 非组合态、无 Ctrl 的
VK_TABKeyDown 在冒泡后由 FocusManager 处理;KeyUp 与 Ctrl+Tab 不触发普通遍历。 - Shift+Tab 反向移动。
- 无焦点时 Tab 进入首项、Shift+Tab 进入末项;到达边界后在当前最上层 FocusScope 内循环。
- 每次按键重新采集并验证停靠点;冒泡回调对可见、可用、删除和作用域状态的修改对同一按键的默认动作立即生效。
- 普通 Button/TextBox 不把 Tab 作为自身命令处理;默认空路由后由框架 Tab 默认动作接管。
13.2 Space
Button:
首次 KeyDown → armed + pressed
重复 KeyDown → 忽略
KeyUp 且仍聚焦、仍 armed → 触发一次 click
焦点丢失、控件禁用/隐藏、Escape 或窗口失活 → 取消 armed
TextBox 不从 KeyEvent 插入空格,只接受 TextInputEvent(U+0020)。
keyboardArmed 必须是 Button 独立状态,不能复用现有 click:click 在 NORMAL 模式承担鼠标瞬时按压,在 TOGGLE 模式承担持久选中状态,复用会让键盘释放破坏 Toggle 状态或让鼠标/键盘互相误触发。绘制按压色时可使用 click || keyboardArmed,但阶段 2 的焦点框仍在按钮本体之后绘制并保持可见。
13.3 Enter
IME
→ 聚焦 Button
→ 聚焦控件自定义动作
→ Dialog 默认按钮
→ Window 默认动作
- 单行 TextBox 不插入换行,默认不阻止 Dialog 默认按钮。
- Button 只在首次 KeyDown 激活,自动重复不能重复点击。
13.4 Escape
IME 取消
→ Button 清除按压态
→ 子控件局部取消
→ Dialog 关闭
- Button 清除 pressed 只恢复视觉状态,不阻止 Dialog 默认动作。
- 子控件存在真正局部取消状态时,可以设置
preventFrameworkDefault=true。 - 普通文本选区不建议定义为 Escape 可取消状态。
13.5 方向键
TextBox 光标/选区
→ 原始焦点为页签 Button 时由 TabControl 切页
→ 其他容器自定义导航
13.6 Ctrl 快捷键与控制字符
TextBox v1 处理 Ctrl+A/C/X/V。这些命令走 KeyEvent;相应 WM_CHAR 0x01–0x1A 即使形成 TextInputEvent 也不得插入文本。
13.7 Button 触发时机注释
Button 的 Space 在 KeyUp 确认、Enter 在首次 KeyDown 确认是有意设计:
- Space 对应按压释放语义,允许按住后因焦点移走或状态变化而放弃。
- Enter 对应默认按钮即时确认语义。
禁止为了表面一致把两者统一到同一个触发时机。
14. TextBox 编辑内核与自绘
14.1 内部模型
class TextEditModel
{
std::wstring text;
size_t caret;
size_t selectionAnchor;
size_t selectionActive;
size_t horizontalScroll;
};
最小 Unicode 保证:
- 光标不能落在代理项中间。
- Backspace/Delete 不能删除半个代理对。
- UTF-32 输入拒绝大于
U+10FFFF和代理区值。 - v1 暂不承诺完整 grapheme cluster 删除语义。
14.2 KeyEvent 与 TextInputEvent 分工
KeyEvent 处理:
- Left/Right、Home/End。
- Shift 选区。
- Ctrl+A/C/X/V。
- Backspace/Delete。
TextInputEvent 处理:
- 普通字符。
- 空格。
- IME 正式结果。
- 剪贴板文字。
14.3 单行控制字符
单行 TextBox 默认拒绝 U+0000–U+001F 和 U+007F。Backspace、Tab、Enter、Escape 走 KeyEvent,不通过 TextInputEvent 插入。
粘贴建议规范:
CRLF / CR / LF → 一个空格
Tab → 一个空格
其他控制字符 → 删除
14.4 自绘顺序
背景
→ 边框
→ 选区背景
→ 文本
→ 光标
→ 焦点边框
必须支持内容裁剪、水平滚动、光标可见、选区颜色、焦点/窗口失活样式和 IME 锚点刷新。
14.5 宽字符绘制
首选:
TextOutW / DrawTextW
GetTextExtentPoint32W
直接操作 EasyX 当前工作图像对应的 HDC。MBCS 工程配置不等于必须把内部文本降级成 CP936。
阶段 0 必须验证:
- W API 是否实际画入 EasyX 当前 working image。
SetWorkingImage后 HDC 是否指向正确图像。- 中文、代理对和字体回退。
- 宽字符测量与实际绘制是否一致。
14.6 CP936 显示降级
只有宽字符 GDI 实测不可用时才使用 WideCharToMultiByte(CP936, WC_NO_BEST_FIT_CHARS, ...)。
- 无法表示的字符显示
?。 - 检查
usedDefaultChar。 - 内部
std::wstring原文不变。 - 维护 UTF-16 索引到绘制字节偏移的映射。
- 这是显示降级,不是文本存储降级。
旧 std::string API 可保留兼容,同时增加宽字符接口;旧接口在明确的 CP936 边界转换,不能污染内部模型。
15. 模态循环与单 drainer
不能使用简单的 bool managedDispatchActive 表示输入调度状态,需改为深度和所有者:
struct DispatchState
{
uint32_t dispatchDepth;
uint32_t modalDepth;
DrainToken activeDrainer;
};
规则:
- 外层循环持有 drainer。
- Dialog 进入模态前先压入 FocusScope。
- 最内层模态循环接管 drainer。
- 外层停止取下一条事件。
- 内层退出后归还 drainer。
- 外层继续剩余队列。
- 每次只弹一条事件。
- 禁止一次性预取整个队列。
模态窗口可能在 Button 回调中打开,因此 pending removal 中的句柄立即失效,但对象内存直到最外层 dispatchDepth == 0 才回收。任何借用裸指针都不得跨越 showModal()。
16. 控件行为矩阵
| 控件 | 鼠标焦点 | Tab 焦点 | Enter | Space | Escape | 方向键 |
|---|---|---|---|---|---|---|
| Button | 是 | 是 | 首次 KeyDown 激活 | KeyUp 激活 | 清 pressed,默认动作可继续 | 默认不处理 |
| TextBox | 是 | 是 | 单行不插入 | TextInput 插入 | 局部状态决定 | 光标/选区 |
| Table | 否 | 否 | 内部 Button | 内部 Button | 冒泡 | 可扩展 |
| Table 翻页 Button | 是 | 是 | 翻页 | 翻页 | 清 pressed | 不处理 |
| Tab 页签 Button | 是 | roving | 激活页签 | 激活页签 | 冒泡 | 切换页签 |
| Canvas | 否 | 否 | 冒泡 | 冒泡 | 冒泡 | 冒泡 |
| Dialog | 作用域 | 作用域 | 默认按钮 | 无 | 关闭 | 无 |
| Label | 否 | 否 | 无 | 无 | 无 | 无 |
17. 阶段 0 门槛
在实现 FocusManager 前必须验证:
IsWindowUnicode(hwnd)实际结果。- EasyX 主循环是否经过当前 WndProc。
- WndProc 和 EasyX
EX_KEY是否重复报告。 - EasyX 是否调用
TranslateMessage。 GCS_RESULTSTR、WM_IME_CHAR、WM_CHAR的真实顺序。- 微软拼音和搜狗输入法差异。
- 消费
WM_KEYDOWN是否影响 EasyX 或 IME 状态。 - 是否收到
WM_GETDLGCODE。 - ANSI 中文是否拆成 lead/trail
WM_CHAR。 - 鼠标、键盘、IME、窗口焦点消息顺序。
- 模态循环是否仍经过同一 WndProc。
- 宽字符 GDI 是否画入当前 EasyX 工作图像。(归属阶段 0C)
- 100%、125%、150% 候选框定位。(归属阶段 0C)
- 跨显示器后的 DPI 和候选框坐标。(归属阶段 0C)
- 接管后 EasyX 原有鼠标、缩放和退出行为是否正常。
其中第 5、6、9 项在当前微软拼音/搜狗 + ANSI EasyX + CP936 环境下已有结论:正式提交来自 GCS_RESULTSTR,WM_IME_CHAR 是完整匹配回声,未观察到匹配的 WM_CHAR lead/trail 回声。第 12 项的自动绘制门槛已通过;第 13 项已取得当前 150% 显示器上的 DPI-unaware 96 与 Per-Monitor-V2 144 坐标基线,并完成微软拼音/搜狗真实候选框视觉验收;100%/125% 仍受实机条件限制。第 14 项仍待跨屏环境。第 7 项仍属于 0B 消费实验。
阶段 0A 仅被动观察;阶段 0C 采集宽字符绘制与 IME 坐标证据;阶段 0B 才允许在测试开关下按消息类型、虚拟键和 IME 状态执行隔离消费实验。三者是能力分类,编号不代表执行顺序,建议顺序为 0A → 0C → 0B。
18. 分阶段落地
阶段 0A:被动探测
- 只记录消息和来源。
- 不修改参数、旧窗口过程调用、返回值和业务分发。
- 不为探测新增额外 EasyX
peekmessage。 - 输出版本化、隐私受控的 JSONL 日志。
阶段 0C:宽字符绘制与 IME 坐标探测
- 验证
TextOutW/DrawTextW/GetTextExtentPoint32W在主画面和 EasyX 工作图像上的行为。 - 记录
SetWorkingImage切换前后的真实目标 HDC。 - 用固定非敏感测试串覆盖 ASCII、中文、代理对和组合字符。
- 分离记录绘制结果、测量结果、像素证据和人工视觉结论。
- 验证 DPI awareness、
GetDpiForWindow、客户区/屏幕坐标和 TextBox caret anchor。 - 覆盖 100%、125%、150% 和跨屏不同 DPI。
- 采集
COMPOSITIONFORM/CANDIDATEFORM定位证据。 - 详细规格见
Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md。 - 当前状态:0C-1 自动门槛通过;0C-2 当前 96/144 DPI 自动门槛通过;微软拼音与搜狗三个 anchor 已取得真实视觉证据,搜狗提交后 stale-position 回归通过。当前可用环境的 0C 基线已关闭,100%/125%/跨屏作为实机能力缺口继续记录;后续 0B 阶段 1 前置基线也已关闭。
阶段 0B:隔离消费实验
使用测试专用规则;详细规格见 Module-20261001-0004-阶段0B原生键盘消息隔离消费实验规格.md:
struct ProbeConsumeRule
{
UINT message;
std::optional<UINT> virtualKey;
std::optional<std::uint8_t> scanCode;
ImeStateCondition imeCondition; // Any / IdleOnly / ComposingOnly
ProbeAction action; // ObserveAndForward / ObserveAndConsume
};
- 每次只启用一条或一小组明确规则。
- 日志携带实验 ID 和规则 ID。
- 同步窗口协议禁止进入规则表。
- 正式构建默认没有任何消费规则。
- 动作枚举严格保持
ObserveAndForward / ObserveAndConsume两值;“转发但抑制下游业务路由”属于阶段 1,不是 0B 的第三种 WndProc 动作。 - 实验结论使用
SafeToConsume / MustForward / Inconclusive,与动作枚举分离。 - 自动 A/B 必须单独回答:WndProc 消费目标
WM_KEYDOWN后,宿主peekmessage(EX_KEY)是否仍返回对应虚拟键。 - 配置错误、规则模糊、IME 状态不可靠、出现记录丢失或运行环境与 0C baseline 不一致时一律 fail-open,实际动作退回观察并转发。
- 当前 F24 实测:
WM_KEYDOWN/UP的 Forward 对照均产生一次对应EX_KEY,Consume 后对应EX_KEY均消失;错误 baseline 请求 Consume 时正确退回 Forward。该结论只证明 F24 的两观察面关系,不自动外推为生产消费白名单。 - 自动首键由 F8 改为 F24:当前中文 IME 环境下 F8 的
SendInputKeyDown 未到达目标 WndProc,截断层级尚未确认,不能形成有效 A/B 对照。 - 0B 自动证据现已强制要求严格序列、零丢失、真实 fixture PID 退出、
WM_CLOSE与session_end完整;宿主 flush 和 WndProc 关闭收口使用独立文件写锁串行。 - Composition 人工规则按真实原生观察匹配
WM_KEYDOWN + VK_PROCESSKEY(229) + scanCode 1,操作者动作仍为 Escape;规则不尝试在 WndProc 中恢复逻辑键。 - 0B 人工夹具使用固定客户区 anchor,并记录
ImmGetContext与两个 setter 的分项结果;位置链未成功时请求 Consume 必须 fail-open。 - 0B 日志缓冲采用双固定缓冲,安全点只在状态锁内交换 buffer;文件序列化在锁外完成,避免把探测器自身锁竞争误报成输入链丢失。
- 0B 固定 IME 定位采用三阶段拆锁协议,定位结果写独立
ime_position记录并以parentSequence关联父 enter;setter 同步重入的消息允许正常采集,但不得递归发起定位。 - 修复后微软拼音 Forward 人工运行保持响应,Escape 原生规则唯一命中并转发,定位结果 18/18 成功且无孤立父序号;截图确认组合串/候选 UI 位于固定锚点附近。该结论只关闭诊断死锁和 Forward 对照,不代替 Consume 与搜狗矩阵。
- 微软拼音 Consume 重跑中 Escape 原生规则唯一命中并返回 0,旧窗口过程未被调用,对应 EasyX
EX_KEY=absent;定位 7/7 成功、零丢失且没有产生提交结果。与 Forward 的EX_KEY=present_once对照表明当前受控键在 WndProc 消费后不会再进入 EasyX 键面,但生产白名单仍需搜狗矩阵和最终 0B 评审。 - 搜狗候选态实测没有向目标 HWND 发送 START/COMPOSITION/END 生命周期;Escape KeyDown 仍为
VK_PROCESSKEY + scanCode 1,但 tracked IME state 保持 Idle。最终 Consume 请求按ime_condition_not_met运行时 fail-open 为 Forward,EasyX 键可见一次,结论为MustForward而不是真正 Consume。 - 阶段 1 采用默认 capture-and-forward,不建立生产消费白名单;所有
VK_PROCESSKEY和已确认组合态键只供输入法/文本规范化,不进入控件业务路由。微软拼音单点 Consume 成功只作为“WndProc return 0 会阻断 EasyX 键面”的机制证据。
阶段 1:InputCollector
- 已实现
NativeInputEvent / KeyEvent / TextInputEvent / CompositionEvent / WindowActivationEvent和每 Window 的NativeInputContext。 - 按已确认双线程模型使用
mutex + deque实现跨线程 SPSC 语义;sequence由 atomic 分配,队列互斥独立提供 happens-before。 - 已实现 Key lParam 完整解析、左右修饰键快照、Unicode/ANSI/CP936 状态解码、IME 生命周期、
compositionId与结果回声去重。 - WndProc 捕获时同时快照原始 VK 和
imeComposingAtCapture;二者是正交轴,只有原始VK_PROCESSKEY表示当前单键归 IME。 - 阶段 1 采用行为保持过渡:新队列只在宿主安全点观测排空,旧
EX_KEY -> Control::handleEvent()暂时保留;阶段 3 才把EX_KEY改为纯排空,并让 NativeInputQueue 成为唯一键盘业务源。 Control::handleEvent()的EX_MOUSE永久保留;仅EX_KEY旧入口进入弃用期,派生控件不得新增对该路径的依赖。- 队列分配失败或内部异常由无分配 POD 故障快照发布,WndProc 不抛异常;宿主安全点显式 fail-stop 并以
-2退出,不允许静默丢输入后继续运行。 - 非法 UTF-16/ANSI 序列、IMM 查询失败和被边界清理的半截解码状态使用无原文累计计数,并在宿主安全点按变化输出警告。
- 阶段 1 不实现 FocusManager、KeyboardRouter、生产消费白名单、Button/TextBox 键盘行为或 TextBox 自绘。
阶段 2:FocusManager
- 已实现
FocusTargetId、瞬时快照协议和递归焦点树解析。 - 已实现每 Window
FocusManager、焦点申请、失效回退、单槽重入与 64 轮活性保护。 - 已实现 FocusScope 机制与恢复身份;真实 Dialog 接入留阶段 4。
- Button/TextBox PointerDown 申请焦点并绘制焦点框。
- Table 暴露真实分页 Button;TabControl 暴露真实页签 Button 和活动页面树。
- pending removal 入队时逻辑失效,并与 mutation transaction 同步收口。
- 阶段 1 基线与阶段 2 工作树受控 A/B 为 62/62,通过后当前阶段 2 正式完整 UI 三轮 24/24;未发现阶段 2 相关失败或耗时劣化,阶段 2 实现与回归归因门槛均已关闭。
- 不实现 Tab 键导航、按键路由、Table 键盘翻页、页签 roving-tab-stop 或 TextBox 编辑内核。
阶段 3:KeyboardRouter
- 切片 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-routingUI 场景和受控 A/B。真实 IME / KEY1–6 人工换轨回归仍待执行。 - 切片 1–5 不允许新路由与旧
EX_KEY -> handleEvent()同时向生产控件交付;测试窗口进入 Routed 模式时也必须先关闭自己的旧业务交付。
阶段 4:复合控件
- Table 翻页 Button。
- TabControl roving tab stop。
- 活动页面过滤和页签/页面路由隔离。
- 真实 Dialog FocusScope 生命周期、模态子树路由、默认按钮与 Escape 关闭。
阶段 5:TextBox
- UTF-16 编辑模型。
- 选区、光标、剪贴板。
- Ctrl 快捷键。
- 宽字符测量和自绘。
阶段 6:IME 与 DPI 收口
GCS_RESULTSTR和回声去重。- 系统组合窗口、候选框定位。
- 微软拼音、搜狗、DPI 和跨屏测试。
19. 已锁定决策
- 输入采集先于焦点系统。
- v1 键盘默认采集并转发,不预设消费。
- NativeInputQueue 是唯一键盘业务源。
- EasyX
EX_KEY只排空和观察,不进入业务路由。 - v1 使用系统 IME UI,不自绘组合串。
- IME 正式提交优先使用
GCS_RESULTSTR。 - TextBox 内部使用 UTF-16。
- 绘制优先使用宽字符 GDI,不默认降级 CP936。
- 长期焦点、队列和遍历结果只使用
FocusTargetId/ControlHandle,不保存裸控件指针。 - 路由结果区分停止冒泡和阻止框架默认动作。
- TabControl 页签事件绝不传入活动页面。
- IME 组合期间暂停框架快捷键、焦点导航和 Dialog 默认动作。
- 模态循环采用最内层单 drainer。
- TextBox v1 至少保证代理对安全,完整字素簇以后增强。
pendingLead在输入法/代码页切换前清除。compositionId在 START 时分配,缺失 START 时惰性分配新 ID。- Space KeyUp 与 Enter 首次 KeyDown 的触发差异属于有意交互语义。
- EasyX WndProc 是生产线程,宿主
runEventLoop/peekmessage是消费线程;NativeInputQueue 必须跨线程同步。 - 当前 EasyX HWND 为 ANSI,DBCS 解码和
WM_IME_CHAR/WM_CHAR回声路径属于阶段 1 必经路径。 - 阶段 1 先使用互斥 SPSC 队列;只有定义好变长文本所有权与满队列策略后才允许换成 lock-free ring。
- 当前微软拼音和搜狗均以
GCS_RESULTSTR为正式提交、WM_IME_CHAR为匹配回声;阶段 1 不得把回声再次入队。 WM_INPUTLANGCHANGEREQUEST只表示请求,不能提前更新代码页;只有WM_INPUTLANGCHANGE,或挂接/重新获焦时确认 HKL 快照确已变化,才执行“清 pending lead → 更新代码页”。- IME 内部中英文模式或 TSF 输入配置文件切换不保证产生
WM_INPUTLANGCHANGE,不得把该消息当作所有输入模式变化的统一通知。 - 当前环境只有
zh-Hans-CN,无法实测跨语言 HKL;阶段 1 在窗口挂接和重新获焦时必须用 HWND 属主线程的GetKeyboardLayout快照补偿潜在消息缺口。 - 当前 EasyX 宽字符 GDI 绘制链可用;TextBox 自绘优先保留 UTF-16 并使用 W API,不启用 CP936 显示降级。
- EasyX 批次必须从主画面进入和退出;工作图像只在批次内部切换,并通过显式合成上屏。
- 在已验证的 DPI-unaware 96 与 Per-Monitor-V2 144 环境中,EasyX 绘制坐标可直接作为 HWND 客户区坐标;不得额外乘 DPI 比例。
- IME 位置必须在焦点/caret/anchor 变化时立即预设,并在组合生命周期消息到达时再次校准;不得假定候选 UI 出现前一定会收到
WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION。 - 原始
VK_PROCESSKEY一律视为 IME 所有:WndProc 层采集并转发,阶段 1 不向 Button、Dialog、快捷键和焦点导航路由;不得依赖imeComposingAtCapture单独判断搜狗候选态。 - 0B 没有建立跨 IME 生产消费白名单;阶段 1 从默认转发基线开始,后续任何窄消费规则都必须有独立逐键证据。
- 阶段 1 只建输入地基、不切换业务轨道:旧
EX_KEY继续进入Control::handleEvent(),新队列只观测排空;阶段 3 一次性切换,EX_MOUSE不受影响。 - 单键 IME 所有权只由采集时的原始
VK_PROCESSKEY表达;imeComposingAtCapture只记录标准生命周期,两者不得合并为一个持久“组合中”状态。 mutex + deque在正常资源条件下不得丢输入;分配失败或队列异常必须 fail-stop,不能捕获后假装成功。WM_IME_ENDCOMPOSITION不得在转发旧窗口过程之前清空结果回声;END 处理中嵌套或紧随其后的匹配WM_IME_CHAR/WM_CHAR仍须抑制重复入队。- 无效文本序列、IMM 查询失败和残留半截解码状态必须以不含原文的计数显式诊断,不得写入替代文本或静默记为成功。
- 焦点长期身份统一为
FocusTargetId;现有真实 Control 节点一律使用subNode == 0,Table 分页和 TabControl 页签不得建立合成身份。 - 当前
ControlHandle在进程内单调分配且不复用,不采用 slot/generation 草案;焦点解析使用独立递归快照协议。 - 焦点回调重入使用当前事务回合内的单槽 latest-wins;最终槽位失效只丢弃,不复活旧请求。
- 焦点切换最多 64 轮;超限以
FocusError::ReentrancyLimit明确失败,并保留最后一个仍有效的已提交焦点。 - removal 请求入队时立即让对应焦点子树逻辑失效;批量 clear 必须先使整批失效,再执行 active 回退。
- 阶段 2 只建立 FocusScope 机制;真实 Dialog 作用域接入、Tab 导航和键盘路由不得提前混入。
- 阶段 3 的 Tab 遍历必须区分 Table 分页 Button“首次 draw 前尚未 materialize”与“永久无停靠项”,不得把 draw 前空快照固化为长期结论。
- 阶段 3 的 Button 键盘按压视觉必须叠加在最小焦点框之上,焦点框不得因按压态切换而消失。
- 阶段 3 切片 1–5 的新路由只能通过内部测试门控启用,且测试态的新旧业务交付也必须互斥;切片 6 已按约定一次性切换生产默认。
- 窗口键盘激活门控使用采集侧原子
{active, generation}与事件采集快照双重校验;只按队列顺序更新消费者布尔值不足以阻止失活前积压键或快速失活/恢复后的跨代投递。 - 窗口失活时“清理 keyboard armed”和“暂停键盘/文本/IME 业务分发”是两个独立动作;逻辑焦点保留,重新激活后重新验证。
- 当前栈上模态 Dialog 未进入 Window 焦点树;阶段 3 模态期只排空并抑制业务路由,禁止按键穿透到底层焦点。真实模态子树路由、默认按钮和 Escape 在阶段 4 随 Dialog FocusScope 一起接入。
- Button 的 keyboard armed 独立于现有
click;Space 在 KeyUp 确认,Enter 在首次 KeyDown 确认,焦点丢失、禁用、隐藏、Escape 和窗口失活都会取消 armed。 - 切片 6 必须在同一提交内同时切换 Window 主循环和 Dialog 模态循环的 EX_KEY 路径;换轨后仍持续排空 EX_KEY,但业务交付计数必须为零,EX_MOUSE 不变。
- 阶段 3 只对明确的 Tab 查询选择性合并
DLGC_WANTTAB;不得无条件返回DLGC_WANTALLKEYS,其它 dialog code 随对应功能接入。 VK_PROCESSKEY、采集时标准组合态和阶段 3 未明确拥有的 Alt/System 组合键只观察不路由;WndProc 仍按默认策略转发。- 新路由必须纳入可嵌套的托管分发与 mutation 边界;回调后重新按身份解析,内层模态循环不得提前清除外层
managedDispatchActive状态。 - 切片 2 的原始路由路径使用固定容量 256 的身份数组;循环、超深或构造期失效必须在调用任何控件前 fail closed,不允许动态分配失败或畸形父链造成半条路径已投递。
- Button 的 Enter 与有效 Space 属于叶子命令:返回
preventFrameworkDefault=true防止框架默认动作重复激活,但保持stopBubble=false允许祖先观察;Escape 只取消本地 armed,不阻止 Dialog/Window 后续默认动作。 - v0.22 起生产默认固定为 Routed;LegacyCompatibility 只用于事件循环启动前锁定的内部 A/B,不是公开兼容开关,也不得在运行中切换。
- 阶段 3 只把
KeyEvent交给控件业务路由;TextInputEvent与CompositionEvent继续完整排空、观察和计数,正式文本交付接口留阶段 5/6。 - 自动 A/B、生产 UI 与 input-shadow 只能关闭自动换轨门槛;微软拼音、搜狗和 KEY1–6 人工结果必须独立记账,不能由自动测试代替。
- Window 背景或非模态 Canvas 空白区域收到未被子控件消费的左键按下时,必须清除逻辑焦点并消费该指针事件,禁止继续向更低层兄弟穿透;模态 Dialog 在阶段 4 接入真实 FocusScope 前不借此清除底层逻辑焦点。
- Table 分页 Button 虽有独立 ControlHandle,但属于 Table 内部绘制单元;其焦点视觉变化必须提升为 Table 重绘边界,不能把内部 Button 当成可独立提交到屏幕的顶层重绘 root。
- 同父 Canvas 的下层持久 coverage 已写入且命中上层兄弟时,上层完整恢复优先于其局部脏子树提交;必须作废旧快照并重建整个上层兄弟,避免下层像素覆盖上层未变脏区域。
实现与影响
- 关键实现点:
- 阶段 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 上层完整恢复优先级。
- 涉及文件 / 类 / 函数:
- 阶段 3 已涉及
InputEvents、KeyboardRouting、NativeInputContext、KeyboardRouter、FocusManager、Window、Control、Button、Dialog、测试夹具和迁移文档。 Canvas、Table、TabControl在阶段 3 提供既有焦点树、逻辑父关系和递归身份分发协议;高级键盘行为仍留阶段 4。TextBox编辑和文本交付仍留阶段 5/6。
- 阶段 3 已涉及
- 兼容性影响:
- 设计目标是保留现有鼠标行为和公共文本 API 的兼容入口。
- 阶段 2 向
Control增加焦点协议虚接口,现有二进制派生控件需要重新编译;源码接口没有删除,源码分发用户按正常流程重编译即可。 - 阶段 1 没有改变旧
EX_KEY业务交付;阶段 3 现已停止向Control::handleEvent()下发EX_KEY,这是明确的派生控件迁移点,EX_MOUSE永久保留。 - 阶段 3 已新增 routed key 虚接口并再次改变
Controlvtable;二进制派生控件必须重新编译,在旧handleEvent(EX_KEY)中处理键盘的源码派生控件必须迁移到新接口。 - 输入链路和 TextBox 内部模型将发生架构级变化,必须分阶段接入。
- 性能影响:
- 输入事件会增加规范化和句柄解析开销,但不允许在 WndProc 中执行阻塞 I/O。
- TextBox 宽字符测量、选区和水平滚动需要缓存策略,具体数据待实现阶段评估。
- 风险点:
- EasyX 双线程、ANSI 窗口和当前微软拼音/搜狗回声顺序已确认,但其他 IME、窗口 A/W 属性和系统版本仍可能产生不同顺序。
- 模态重入和控件删除若破坏单 drainer/句柄不变量,可能产生难复现的顺序或生命周期问题。
- 激活门控若只保存布尔而没有 generation,会在失活前积压或快速失活/恢复时产生跨激活周期误投递。
- Window 与 Dialog 两套 EX_KEY 循环若没有同一提交切换,会形成局部双投递或模态期旧路径残留。
- 自动验证与人工回归必须分开记账;阶段 3 的微软拼音、搜狗及 KEY1/KEY5 人工门槛已完成,不能用单元测试倒推其它未覆盖环境同样通过。
测试与验证
- 测试范围:
- 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。
- 独立线程归属探测、0A、0C、0B 门槛均已完成;阶段 1 已加入逻辑测试与真实 EasyX
input-shadow场景;阶段 2 已加入焦点事务单测和真实 EasyXfocus场景,并以 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)业务交付为零、队列最终深度为零、序列严格且稳定波次下内存不增长。
- 验证步骤:
- 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。
- 按阶段 0C 规格完成宽字符 GDI 与 IME 坐标探测,取得绘制与坐标证据。
- 使用阶段 0B 的逐消息规则验证消费策略,不修改生产默认路径。
- 阶段 1 至阶段 6 分别执行逻辑测试、仪器化运行测试和人工输入法/DPI 回归。
- 阶段 3 切片 6 以前禁止把测试门控结果写成生产换轨完成;切片 6 以换轨前提交为基线执行自动 A/B,结构相同的 UI 场景比较行为与耗时,输入场景按新旧不同契约分别断言。
- 切片 6 完成后执行完整 UI、KEY1–KEY6、微软拼音和搜狗人工回归;真实候选态的
VK_PROCESSKEY/组合键不得触发 Button、Tab 或底层 Dialog/Window 默认动作。
- 验证结果:
- v0.4 设计经过多轮评审,已作为后续探测和实现的基线。
- 2026-10-03:独立线程探针确认 EasyX WndProc/宿主循环双线程,当前 EasyX HWND 为 ANSI、中文代码页为 CP936。
- 2026-10-04:微软拼音和搜狗真实结果均为
GCS_RESULTSTR -> WM_IME_CHAR matched -> WM_CHAR expired;日志隐私检查通过,丢失均按wndproc_lock_contention显式记账。 - 2026-10-04:两次布局/模式切换未形成
WM_INPUTLANGCHANGE,并据此发现旧探测器未观察WM_INPUTLANGCHANGEREQUEST;v0.6 已把请求与完成消息拆开。 - 2026-10-04:request 补观测的探测开启构建与 1 轮 UI 回归通过,8 份日志均可解析、sequence 严格递增且无文字原值泄露;共 1 条
wndproc_lock_contention被显式记账。 - 2026-10-04:专用 Win+Space 短测产生 1243 条可解析记录,request/change 仍为 0;日志观察到 Win 键、焦点往返及
VK_PROCESSKEY -> IME composition,系统语言列表仅有zh-Hans-CN,故本次结论限定为同 HKL 的 TSF 配置文件切换,跨语言 HKL 分支仍缺少实机样本。 - 2026-10-04:最终源码的 Debug x64 单元测试 15/15 通过。
- 2026-10-05:提交前重新执行独立线程探针、探测开启 UI 回归和探测关闭单元测试;再次确认 EasyX 双线程与 ANSI/CP936,8 份自动日志均严格递增、零文字原值泄露、零丢失,VSTest 15/15 通过。
- 2026-10-05:0C-1 在主画面/工作图像、批次内外的 40 个结构化绘制用例全部通过;30 张证据图带 SHA-256,W API、表面隔离、GDI 状态恢复、Flush/End 和后续 EasyX 绘制兼容性均有自动证据。
- 2026-10-05:0C-2 在 DPI-unaware 96 和独立 Per-Monitor-V2 144 两种视图中均未观察到 EasyX/GDI/HWND 坐标缩放差异;当前组合基线 ID 与完整哈希记录在 0003 规格。
- 2026-10-05:0C-3 三 anchor 探针与优雅退出/失败门槛烟测通过,但真实微软拼音/搜狗定位尚未执行,不计入阶段门槛通过。
- 2026-10-05:微软拼音三个 anchor 自动 IMM 调用和人工位置观察通过;第二轮虽然脚本参数为
Sogou,操作者确认实际未切换输入法,因此没有把它误记为搜狗证据。 - 2026-10-05 23:51:搜狗首次真实运行按预期门槛失败,F1/F2 未发生 IMM 定位调用,F3 为 2/2;截图确认候选框先位于窗口外,之后沿用 F3 旧位置。
- 2026-10-06:v0.9 将 IME 位置刷新改为“焦点/caret/anchor 变化立即预设 + IME 生命周期再次校准”;探测事件新增触发来源与分项计数,并完成三平台构建及真实搜狗回归。
- 2026-10-06:搜狗修复后运行自动与人工状态均为
pass;三个 anchor 均按请求定位,F3 提交后回到 F1 的截图与初始 F1 裁剪字节一致。当前可用环境 0C-3 关闭,随后进入 0B。 - 2026-10-06:0B F24
WM_KEYDOWN/UP自动 A/B 均通过;Forward 对应EX_KEY=present_once,Consume 对应EX_KEY=absent。错误 baseline 的请求 Consume 正确 fail-open 为 Forward。真实 IME 人工矩阵未完成,因此阶段 1 仍不启动。 - 2026-10-06:0B Composition 首轮人工运行无效:夹具未设置 IME 位置,Escape 规则错误匹配
VK_ESCAPE而实际 KeyDown 为VK_PROCESSKEY + scanCode 1,且出现 3 条锁竞争丢失。已实现固定 IMM32 锚点、原生三元组规则与双缓冲采集修正;微软拼音/搜狗 A/B 需重新执行。 - 2026-10-07:微软拼音 Forward/Consume 有效复验均通过;Forward 对应 EasyX 键出现一次,Consume 后 EasyX 键消失,位置链分别为 18/18 与 7/7,均零丢失、无孤立定位记录。
- 2026-10-07:搜狗候选态没有向目标 HWND 暴露 START/COMPOSITION/END 生命周期;最终 Consume 请求以
ime_condition_not_met正确 fail-open 为 Forward,规则零命中、零消费、EasyX 键出现一次,结构 verdict 为MustForward。 - 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 全部编译/链接成功。最终源码的独立
focusUI 连续 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_TABKeyDown 执行正向或 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-routingUI 场景和迁移说明。Debug x64、x64 ASan、Release x64、Debug Win32 均为 66/66,KEY1–KEY6 全部编译链接成功。换轨前 fixture(SHA-2563F6267B6D5EE85A7A97244C3DAE21DB44CEFBD185F56A38516E5E2F6F6234974)与当前 fixture 的共同八场景执行 5 对 5 A/B,Legacy 40/40、Routed 40/40;唯一较大耗时差来自 Routed 首次 ownership 启动单点,扩展样本后未形成稳定劣化。当前正式 UI 两轮九个业务场景 18/18,普通/模态关闭均通过;keyboard-routing2/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。
落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:
BUG-20261006-0001/Fix-BUG-20261006-0001;BUG-20261006-0002/Fix-BUG-20261006-0002 - Commit:
c218c25(阶段 0A 诊断实现与线程归属复现);c0eca1d(探测暴露的原生窗口关闭修复);43cf4ac(阶段 0C 视觉与 IME 坐标探测) - PR:无
- 发布版本:未发布
- 相关文档: