# 新增功能模块 / 模块重构 > 适用场景:新增模块、重大模块重构、核心架构能力演进。 > 不适用场景:小接口或轻量功能变更,请使用“功能变更”模板。 ## 基本信息 - 模块 ID:Module-20261001-0001 - 模块名称:通用输入采集、焦点与键盘路由系统 - 状态:设计中(v0.9 IME 位置双时机修订;0C 当前可用环境基线通过,下一步进入 0B,阶段 1 尚未开始) - 类型:新增模块 / 架构演进 - 所属系统 / 子系统:StellarX Core / Window / Control / Input / Focus / TextBox - 版本 / 分支:`master`,阶段 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 代理对。 - 本设计稿不代表框架代码已经实现,也不构成运行验证结果。 ## 模块边界 - 职责: - 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: - 本稿只确定职责和接口形状,公共 API 名称在阶段 0 完成后再落地。 - 控件通过焦点能力、键盘处理、文本输入和 Tab stop 收集接口接入。 - 关键数据 / 状态: - `NativeInputEvent`、`KeyEvent`、`TextInputEvent`、`CompositionEvent`、`WindowActivationEvent`。 - `ControlHandle`、`FocusScope`、逻辑焦点句柄和窗口键盘激活状态。 - 每个 Window 的输入队列、drainer 所有者、dispatch/modal 深度。 - 每个 HWND 的 IME 状态、`compositionId`、结果回声和 ANSI 解码状态。 ## 设计说明 ### 1. 总体建设顺序 ```text 阶段 0:输入链路探测 ↓ 阶段 1:输入采集与规范化 ↓ 阶段 2:通用焦点系统 ↓ 阶段 3:键盘定向路由 ↓ 阶段 4:Button / Table / TabControl / Dialog 接入 ↓ 阶段 5:TextBox 编辑内核与自绘 ↓ 阶段 6:IME、Unicode、DPI 完整接入 ``` 阶段 0 是实现门槛,不是可选调研。阶段 0A 的详细规格见: - [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md) #### 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 均通过人工视觉验收。 ### 2. 分层架构 ```text Win32 / EasyX │ ▼ InputCollector 同步应答、异步采集、Unicode/DBCS 解码、IME 状态与去重 │ ▼ NativeInputQueue SPSC 语义的跨线程通道:EasyX 窗口线程生产,宿主线程消费 │ ▼ InputDispatcher 单 drainer、模态重入、逐条出队 ├───────────────┐ ▼ ▼ FocusManager KeyboardRouter └───────┬───────┘ ▼ Button / TextBox / Table / TabControl / Dialog ``` 鼠标 v1 暂不在 WndProc 重新采集。现有 EasyX 鼠标事件进入控件分发前转换成框架 `PointerEvent`,由 PointerDown 在命中焦点叶子时申请焦点。 ### 3. 关键不变量 1. NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。 2. EasyX `EX_KEY` 只排空和观察,不得再次触发业务路由。 3. 同步窗口协议不得进入异步输入队列。 4. 异步路由只能阻止框架默认动作,不能倒过来改变已经结束的 WndProc 返回值。 5. 队列、焦点、Tab 顺序、路由路径和待删除列表长期只保存 `ControlHandle`。 6. 同一 Window 同时只能由最内层消息循环 drain 输入队列。 7. 每次只从队列取一条事件,不预取整个批次到局部容器。 8. WndProc 只在 EasyX 窗口线程采集;焦点、路由和控件操作只在宿主分发线程执行。 9. NativeInputQueue 必须提供跨线程发布/获取的 happens-before,不能使用无锁保护的普通 `std::deque`。 10. `sequence` 提供全局因果编号,但不能代替队列同步。 ### 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: 1. 不修改 `lParam`。 2. 调用旧窗口过程。 3. 返回旧窗口过程结果。 未来自绘组合串时,才在转发前清除 `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*` 选择性声明。 #### 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 消费策略 初始策略: ```text KeyDown/KeyUp:采集 + 转发 WM_CHAR:采集 + 转发 IME 消息:采集 + 转发 真实 WM_UNICHAR:采集 + 消费 ``` 只有阶段 0 证明转发会产生重复行为或原生副作用后,才允许建立窄白名单,例如: - 非组合态 Tab。 - 非组合态且框架明确拥有的方向键。 - 非组合态 Button 的 Enter/Space。 - 框架明确拥有的 Ctrl 快捷键。 硬规则:`imeComposingAtCapture == true` 的键不能进入消费白名单。 ### 6. 输入事件模型 #### 6.1 `KeyEvent` ```cpp 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 }; ``` 规则: ```cpp 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` ```cpp enum class TextSource { ImeResult, UnicodeChar, UnicodeScalar, AnsiCharFallback, Clipboard }; struct TextInputEvent { std::wstring text; TextSource source; uint64_t compositionId; // 非 IME 路径为 0 }; ``` 该事件只表示已经形成的文字,不表示物理按键。 #### 6.3 `CompositionEvent` ```cpp 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` ```cpp struct WindowActivationEvent { bool keyboardFocusActive; HWND otherWindow; }; ``` 窗口失活时可以保留逻辑焦点句柄,但 `keyboardFocusActive == false` 时不路由键盘。窗口重新激活后先验证句柄,失效则执行焦点回退。 #### 6.5 队列外壳 ```cpp struct NativeInputEvent { uint64_t sequence; DWORD messageTime; HWND hwnd; std::variant< KeyEvent, TextInputEvent, CompositionEvent, WindowActivationEvent > payload; }; ``` 键盘、文本和 IME 事件不合并、不静默丢弃。 #### 6.6 跨线程队列模型 当前拓扑是单生产者/单消费者: ```text EasyX HWND/WndProc 线程 └─ InputCollector / push ↓ NativeInputQueue ↓ 宿主 main/runEventLoop 线程 └─ 最内层 drainer / pop one ``` 阶段 1 的正确性基线采用 `mutex + std::deque`: 1. WndProc 每次只在短临界区内移动一条完整事件入队。 2. 宿主每次只在短临界区内弹出一条事件,释放锁后再执行路由、回调和控件 mutation。 3. 禁止在持锁期间调用旧窗口过程、IMM、用户回调、控件方法或文件 I/O。 4. 当前宿主循环持续轮询,不额外增加唤醒;未来改成阻塞等待时再使用 Event/条件变量。 5. 队列不得静默丢输入;若以后改为固定容量 lock-free SPSC,必须先定义文本负载所有权、满队列和分配失败策略。 6. 若改为 lock-free SPSC,生产者写完 slot 后以 release 发布写索引,消费者以 acquire 读取;回收索引反向使用同等内存序。 7. 全局 `sequence` 使用 atomic 分配,但事件可见性由互斥锁或 release/acquire 队列协议保证。 探测器使用的“固定环形缓冲 + 尝试锁 + 允许 `records_lost`”只适用于诊断日志,不能照搬为生产输入队列;生产输入事件比探测记录具有更强的不丢失要求。 ### 7. Unicode 和文字来源 #### 7.1 窗口属性 初始化时记录: ```cpp bool unicodeWindow = IsWindowUnicode(hwnd); ``` 窗口类 A/W 属性决定 `WM_CHAR` 是 UTF-16 还是当前进程代码页数据,但不用于拒绝真实 `WM_UNICHAR`。 #### 7.2 正式提交优先级 ```text 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` 时: 1. 立即产生唯一的 `TextInputEvent`。 2. 记录当前 `compositionId`。 3. 保存预期 UTF-16 或 ANSI 回声序列。 4. 后续紧邻且匹配的 `WM_IME_CHAR/WM_CHAR` 只观察和转发,不再次入队。 5. 遇到不匹配字符、新组合、焦点切换或越过本次结果边界时清空回声状态。 不能使用“字符内容相同就去重”,否则连续输入两个相同汉字时会误删第二个。具体回声顺序以阶段 0 日志决定。 #### 7.4 ANSI / DBCS 回退 ```cpp struct AnsiDecoderState { UINT codePage; std::optional pendingLead; }; ``` 处理规则: 1. 使用当前实际 ANSI 代码页。 2. 使用 `IsDBCSLeadByteEx` 判断 lead byte。 3. lead byte 暂存,不立即形成文本。 4. 下一字节作为 trail。 5. 使用 `MultiByteToWideChar` 严格转换。 6. 转换失败时清除状态并记录诊断。 7. 焦点切换、窗口失活、IME 开始时清除残留 `pendingLead`。 8. 收到 `WM_INPUTLANGCHANGEREQUEST` 时只记录目标布局并转发,不提前改变解码状态。 9. 收到 `WM_INPUTLANGCHANGE` 时,必须先清除 `pendingLead`,再更新活动代码页,禁止跨代码页拼接字节。 10. 窗口挂接和重新获得键盘焦点时,按 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 输入。 ```text WM_IME_STARTCOMPOSITION: compositionId = ++nextCompositionId ``` 如果在 Idle 状态直接收到 `WM_IME_COMPOSITION`,必须惰性分配新的 `compositionId`。该 ID 不得沿用上一次 Commit 或 Cancel 的组合,避免迟到或乱序消息污染已经结束的状态。 #### 8.3 状态流 ```text 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` 由以下情况合成: 1. 活跃组合中收到无 `GCS_*` 的 `WM_IME_COMPOSITION`。 2. 框架调用 `ImmNotifyIME(..., NI_COMPOSITIONSTR, CPS_CANCEL, 0)` 成功。 3. 收到 `WM_IME_ENDCOMPOSITION`,但本次组合没有 Commit。 每个 `compositionId` 最多产生一次 Cancel。主动取消成功后,后续系统消息不能再次发出 Cancel。 #### 8.5 组合期间按键 组合开始后: 1. 取消 Button 等控件残留的键盘按压态。 2. KeyEvent 继续采集并转发旧窗口过程。 3. KeyEvent 不发送给控件命令处理器。 4. 不执行 Tab 导航。 5. 不执行 Dialog 的 Enter/Escape。 6. 不切换 TabControl 页签。 7. 文本只接受 IME 正式提交结果。 判断依据是事件自己的 `imeComposingAtCapture`,不是稍后读取的全局状态。 ### 9. IME 位置与 DPI TextBox 提供: ```cpp struct TextInputAnchor { POINT caretTopLeft; RECT caretLineRect; }; ``` v1 使用: ```text COMPOSITIONFORM: CFS_POINT ptCurrentPos = caretBottom CANDIDATEFORM: CFS_EXCLUDE ptCurrentPos = caretBottom rcArea = 当前文字行矩形 ``` DPI 规则: 1. 记录进程 DPI awareness 和 `GetDpiForWindow(hwnd)`。 2. 验证 EasyX 绘制坐标是否与目标 HWND 坐标空间一致。 3. 一致时直接使用,不手工乘 DPI 比例。 4. 只有实测发现坐标空间不同才做明确转换。 5. 验证 100%、125%、150% 和跨显示器移动。 当前 0C-2 证据允许把第 3 条落实为当前默认:在已验证的 DPI-unaware 96 视图和 Per-Monitor-V2 144 视图中,`caretBottom` 与 `caretLineRect` 直接使用目标 HWND 客户区坐标,不再手工乘 DPI 比例。该默认必须在 0C-3 真实 IME 和尚缺的 100%/125%/跨屏矩阵中继续验证;发现反例时只能增加带 awareness 适用范围的显式换算,不能全局补乘比例。 位置刷新规则: 1. TextBox 获得焦点、caret 移动、选择变化导致 caret 改变、滚动改变可视 caret,或输入法/输入配置发生可观察变化时,立即把最新 anchor 写入当前 IME context。 2. `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION` 到达时再次写入,校准 IME 在组合开始时重置位置的实现差异。 3. 两类写入使用同一份 `TextInputAnchor` 和 HWND 客户区坐标,不建立两套换算公式。 4. 不把“未收到 IME 生命周期消息”等同于“没有候选 UI”;搜狗实测已经否定该假设。 ### 10. 通用焦点系统 #### 10.1 焦点能力 ```cpp enum class FocusPolicy { None, Programmatic, Click, TabAndClick }; ``` | 控件 | 焦点角色 | |---|---| | Label | 不可聚焦 | | Canvas | 透明焦点容器 | | Button | 焦点叶子 | | TextBox | 焦点叶子 | | Table | 焦点容器,暴露内部翻页 Button | | TabControl | 页签焦点组和活动页面容器 | | Dialog | 焦点作用域 | | Window | 根焦点作用域 | #### 10.2 `ControlHandle` ```cpp struct ControlHandle { WindowId owner; uint32_t slot; uint32_t generation; }; ``` - slot 重用必须递增 generation。 - 失效句柄解析为空。 - `collectTabStops()` 返回 `ControlHandle`。 - `Control*` 只允许作为当前函数内立即使用的借用视图,不得跨事件、回调、mutation transaction 或模态调用保存。 #### 10.3 焦点有效条件 ```text 句柄有效 && effectiveVisible && effectiveEnabled && FocusPolicy 允许 && 位于最上层活动 FocusScope && 位于活动 Tab 页面 && 不在 pending removal ``` #### 10.4 焦点切换 ```cpp FocusChangeResult requestFocus( ControlHandle target, FocusReason reason); ``` 顺序: 1. 验证目标。 2. 保存旧句柄。 3. 更新 FocusManager。 4. 向旧目标发送 `FocusLost`。 5. 向新目标发送 `FocusGained`。 6. 使相关区域失效并重绘。 7. 新目标为 TextBox 时更新 IME 锚点。 #### 10.5 焦点失效回退 焦点控件被隐藏、禁用、删除或移入非活动页时: ```text 同一作用域下一个可聚焦项 → 上一个可聚焦项 → 作用域 initialFocus → 无焦点 ``` 控件进入 pending removal 前,必须先让焦点系统中的对应句柄失效。 #### 10.6 Dialog 作用域 Dialog 打开: 1. 压入新 FocusScope。 2. 保存外层焦点句柄。 3. 选择 `initialFocus`,否则选择第一个 Tab stop。 4. 完成作用域建立后才允许模态循环 drain。 Dialog 关闭: 1. 停止内层 drain。 2. 弹出作用域。 3. 验证并恢复原焦点。 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 与页面是兄弟关系: ```text 页签 Button → TabControl → 外层容器 ``` 禁止: ```text 页签 Button → 活动页面 ``` 活动页面控件可以向祖先 TabControl 冒泡,但 TabControl 只有在原始焦点是页签 Button 时才处理 Left/Right。 ### 12. 键盘路由 #### 12.1 总流程 ```text 取出一条事件 → 验证窗口激活状态 → IME 预处理 → Tab 导航 → 锁定原始焦点句柄 → 构造句柄路由路径 → 焦点叶子 → 父容器冒泡 → FocusScope 默认动作 → Window 默认动作 → 提交当前事件产生的 mutation ``` `TextInputEvent` 只发送给实现文本输入接口的当前焦点叶子,不冒泡。 #### 12.2 路由结果 ```cpp struct KeyRouteResult { bool stopBubble; bool preventFrameworkDefault; }; ``` - `stopBubble`:不再交给父容器。 - `preventFrameworkDefault`:冒泡结束后不再执行 Dialog/Window 默认动作。 - 两者均不影响已经完成的原生 WndProc 返回值。 #### 12.3 路由路径与 mutation 事件开始时使用句柄构造路径快照: ```text FocusedLeaf → Parent → ... → FocusScope → Window ``` 每个节点使用前重新解析。处理过程中删除控件时,无效节点被跳过;当前事件仍使用原始路径,下一事件才使用新焦点和新树结构。 ### 13. 按键归属 #### 13.1 Tab - IME 组合中交给 IME,不移动焦点。 - 非组合态由 FocusManager 处理。 - Shift+Tab 反向移动。 - 普通 Button/TextBox 不直接接收 Tab。 #### 13.2 Space Button: ```text 首次 KeyDown → armed + pressed 重复 KeyDown → 忽略 KeyUp 且仍聚焦、仍 armed → 触发一次 click 焦点丢失或控件禁用 → 取消 armed ``` TextBox 不从 KeyEvent 插入空格,只接受 `TextInputEvent(U+0020)`。 #### 13.3 Enter ```text IME → 聚焦 Button → 聚焦控件自定义动作 → Dialog 默认按钮 → Window 默认动作 ``` - 单行 TextBox 不插入换行,默认不阻止 Dialog 默认按钮。 - Button 只在首次 KeyDown 激活,自动重复不能重复点击。 #### 13.4 Escape ```text IME 取消 → Button 清除按压态 → 子控件局部取消 → Dialog 关闭 ``` - Button 清除 pressed 只恢复视觉状态,不阻止 Dialog 默认动作。 - 子控件存在真正局部取消状态时,可以设置 `preventFrameworkDefault=true`。 - 普通文本选区不建议定义为 Escape 可取消状态。 #### 13.5 方向键 ```text 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 内部模型 ```cpp 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 插入。 粘贴建议规范: ```text CRLF / CR / LF → 一个空格 Tab → 一个空格 其他控制字符 → 删除 ``` #### 14.4 自绘顺序 ```text 背景 → 边框 → 选区背景 → 文本 → 光标 → 焦点边框 ``` 必须支持内容裁剪、水平滚动、光标可见、选区颜色、焦点/窗口失活样式和 IME 锚点刷新。 #### 14.5 宽字符绘制 首选: ```text 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` 表示输入调度状态,需改为深度和所有者: ```cpp struct DispatchState { uint32_t dispatchDepth; uint32_t modalDepth; DrainToken activeDrainer; }; ``` 规则: 1. 外层循环持有 drainer。 2. Dialog 进入模态前先压入 FocusScope。 3. 最内层模态循环接管 drainer。 4. 外层停止取下一条事件。 5. 内层退出后归还 drainer。 6. 外层继续剩余队列。 7. 每次只弹一条事件。 8. 禁止一次性预取整个队列。 模态窗口可能在 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 前必须验证: 1. `IsWindowUnicode(hwnd)` 实际结果。 2. EasyX 主循环是否经过当前 WndProc。 3. WndProc 和 EasyX `EX_KEY` 是否重复报告。 4. EasyX 是否调用 `TranslateMessage`。 5. `GCS_RESULTSTR`、`WM_IME_CHAR`、`WM_CHAR` 的真实顺序。 6. 微软拼音和搜狗输入法差异。 7. 消费 `WM_KEYDOWN` 是否影响 EasyX 或 IME 状态。 8. 是否收到 `WM_GETDLGCODE`。 9. ANSI 中文是否拆成 lead/trail `WM_CHAR`。 10. 鼠标、键盘、IME、窗口焦点消息顺序。 11. 模态循环是否仍经过同一 WndProc。 12. 宽字符 GDI 是否画入当前 EasyX 工作图像。(归属阶段 0C) 13. 100%、125%、150% 候选框定位。(归属阶段 0C) 14. 跨显示器后的 DPI 和候选框坐标。(归属阶段 0C) 15. 接管后 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`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)。 - 当前状态:0C-1 自动门槛通过;0C-2 当前 96/144 DPI 自动门槛通过;微软拼音与搜狗三个 anchor 已取得真实视觉证据,搜狗提交后 stale-position 回归通过。当前可用环境的 0C 基线关闭,100%/125%/跨屏作为实机能力缺口继续记录,下一步进入 0B。 #### 阶段 0B:隔离消费实验 使用测试专用规则: ```cpp struct ProbeConsumeRule { UINT message; std::optional virtualKey; ImeStateCondition imeCondition; // Any / IdleOnly / ComposingOnly ProbeAction action; // ObserveAndForward / ObserveAndConsume }; ``` - 每次只启用一条或一小组明确规则。 - 日志携带实验 ID 和规则 ID。 - 同步窗口协议禁止进入规则表。 - 正式构建默认没有任何消费规则。 #### 阶段 1:InputCollector - 输入事件结构和 NativeInputQueue。 - 按已确认双线程模型实现 SPSC 语义的跨线程发布;正确性版本先用短临界区互斥队列。 - `sequence` 使用 atomic 分配,队列同步独立提供 happens-before。 - KeyEvent 完整解析。 - Unicode/ANSI 解码。 - IME 状态机。 - EasyX `EX_KEY` 排空但不业务路由。 #### 阶段 2:FocusManager - ControlHandle。 - 焦点申请与回退。 - Window/Dialog FocusScope。 - PointerDown 申请焦点。 - mutation 生命周期。 #### 阶段 3:KeyboardRouter - Tab 导航。 - 句柄路由路径。 - 冒泡和框架默认动作。 - Button 和 Dialog 键盘行为。 #### 阶段 4:复合控件 - Table 翻页 Button。 - TabControl roving tab stop。 - 活动页面过滤和页签/页面路由隔离。 #### 阶段 5:TextBox - UTF-16 编辑模型。 - 选区、光标、剪贴板。 - Ctrl 快捷键。 - 宽字符测量和自绘。 #### 阶段 6:IME 与 DPI 收口 - `GCS_RESULTSTR` 和回声去重。 - 系统组合窗口、候选框定位。 - 微软拼音、搜狗、DPI 和跨屏测试。 ### 19. 已锁定决策 1. 输入采集先于焦点系统。 2. v1 键盘默认采集并转发,不预设消费。 3. NativeInputQueue 是唯一键盘业务源。 4. EasyX `EX_KEY` 只排空和观察,不进入业务路由。 5. v1 使用系统 IME UI,不自绘组合串。 6. IME 正式提交优先使用 `GCS_RESULTSTR`。 7. TextBox 内部使用 UTF-16。 8. 绘制优先使用宽字符 GDI,不默认降级 CP936。 9. 长期焦点、队列和遍历结果只使用 ControlHandle。 10. 路由结果区分停止冒泡和阻止框架默认动作。 11. TabControl 页签事件绝不传入活动页面。 12. IME 组合期间暂停框架快捷键、焦点导航和 Dialog 默认动作。 13. 模态循环采用最内层单 drainer。 14. TextBox v1 至少保证代理对安全,完整字素簇以后增强。 15. `pendingLead` 在输入法/代码页切换前清除。 16. `compositionId` 在 START 时分配,缺失 START 时惰性分配新 ID。 17. Space KeyUp 与 Enter 首次 KeyDown 的触发差异属于有意交互语义。 18. EasyX WndProc 是生产线程,宿主 `runEventLoop/peekmessage` 是消费线程;NativeInputQueue 必须跨线程同步。 19. 当前 EasyX HWND 为 ANSI,DBCS 解码和 `WM_IME_CHAR/WM_CHAR` 回声路径属于阶段 1 必经路径。 20. 阶段 1 先使用互斥 SPSC 队列;只有定义好变长文本所有权与满队列策略后才允许换成 lock-free ring。 21. 当前微软拼音和搜狗均以 `GCS_RESULTSTR` 为正式提交、`WM_IME_CHAR` 为匹配回声;阶段 1 不得把回声再次入队。 22. `WM_INPUTLANGCHANGEREQUEST` 只表示请求,不能提前更新代码页;只有 `WM_INPUTLANGCHANGE`,或挂接/重新获焦时确认 HKL 快照确已变化,才执行“清 pending lead → 更新代码页”。 23. IME 内部中英文模式或 TSF 输入配置文件切换不保证产生 `WM_INPUTLANGCHANGE`,不得把该消息当作所有输入模式变化的统一通知。 24. 当前环境只有 `zh-Hans-CN`,无法实测跨语言 HKL;阶段 1 在窗口挂接和重新获焦时必须用 HWND 属主线程的 `GetKeyboardLayout` 快照补偿潜在消息缺口。 25. 当前 EasyX 宽字符 GDI 绘制链可用;TextBox 自绘优先保留 UTF-16 并使用 W API,不启用 CP936 显示降级。 26. EasyX 批次必须从主画面进入和退出;工作图像只在批次内部切换,并通过显式合成上屏。 27. 在已验证的 DPI-unaware 96 与 Per-Monitor-V2 144 环境中,EasyX 绘制坐标可直接作为 HWND 客户区坐标;不得额外乘 DPI 比例。 28. IME 位置必须在焦点/caret/anchor 变化时立即预设,并在组合生命周期消息到达时再次校准;不得假定候选 UI 出现前一定会收到 `WM_IME_STARTCOMPOSITION/WM_IME_COMPOSITION`。 ## 实现与影响 - 关键实现点: - 阶段 0A 探测器已经进入框架;0C 独立视觉探测工程已实现;FocusManager、生产 NativeInputQueue 和 TextBox 改造尚未开始。 - 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则。 - 涉及文件 / 类 / 函数: - 预计涉及 `Window`、`Control`、`Canvas`、`Button`、`TextBox`、`Table`、`TabControl`、`Dialog`。 - 实际文件和接口必须在阶段 0 与实现前只读检查后确定。 - 兼容性影响: - 设计目标是保留现有鼠标行为和公共文本 API 的兼容入口。 - 输入链路和 TextBox 内部模型将发生架构级变化,必须分阶段接入。 - 性能影响: - 输入事件会增加规范化和句柄解析开销,但不允许在 WndProc 中执行阻塞 I/O。 - TextBox 宽字符测量、选区和水平滚动需要缓存策略,具体数据待实现阶段评估。 - 风险点: - EasyX 双线程、ANSI 窗口和当前微软拼音/搜狗回声顺序已确认,但其他 IME、窗口 A/W 属性和系统版本仍可能产生不同顺序。 - 模态重入和控件删除若破坏单 drainer/句柄不变量,可能产生难复现的顺序或生命周期问题。 - 该文档是设计基线,不应被误读为已实现或已验证能力。 ## 测试与验证 - 测试范围: - 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。 - 独立线程归属探测、0A 构建、固定消息 smoke、微软拼音/搜狗真实输入链路测试以及当前可用环境的 0C 基线均已完成;0B 消费实验、生产输入队列和焦点系统尚未开始。 - 验证步骤: 1. 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。 2. 按阶段 0C 规格完成宽字符 GDI 与 IME 坐标探测,取得绘制与坐标证据。 3. 使用阶段 0B 的逐消息规则验证消费策略,不修改生产默认路径。 4. 阶段 1 至阶段 6 分别执行逻辑测试、仪器化运行测试和人工输入法/DPI 回归。 - 验证结果: - 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。 - 已知限制 / 遗留问题: - §5.3 的最终消费白名单必须等待阶段 0B 数据。 - §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。 - 宽字符 GDI 与当前微软拼音/搜狗定位门槛已通过;100%/125% 与跨屏 DPI 仍由阶段 0C 规格作为实机能力缺口承接。 ## 落地信息 - 关联功能变更 ID:无 - 关联 BUG / Fix:无 - Commit:`c218c25`(阶段 0A 诊断实现与线程归属复现);`c0eca1d`(探测暴露的原生窗口关闭修复);`43cf4ac`(阶段 0C 视觉与 IME 坐标探测) - PR:无 - 发布版本:未发布 - 相关文档: - [`Module-20261001-0002-阶段0A输入链路被动探测规格.md`](./Module-20261001-0002-阶段0A输入链路被动探测规格.md) - [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.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) - [WM_IME_SETCONTEXT](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-setcontext) - [WM_IME_COMPOSITION](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-composition) - [ImmGetCompositionStringW](https://learn.microsoft.com/en-us/windows/win32/api/imm/nf-imm-immgetcompositionstringw) - [ImmNotifyIME](https://learn.microsoft.com/en-us/windows/win32/api/immdev/nf-immdev-immnotifyime) - [COMPOSITIONFORM](https://learn.microsoft.com/en-us/windows/win32/api/imm/ns-imm-compositionform) - [CANDIDATEFORM](https://learn.microsoft.com/en-us/windows/win32/api/imm/ns-imm-candidateform)