55 KiB
新增功能模块 / 模块重构
适用场景:新增模块、重大模块重构、核心架构能力演进。 不适用场景:小接口或轻量功能变更,请使用“功能变更”模板。
基本信息
- 模块 ID:Module-20261001-0001
- 模块名称:通用输入采集、焦点与键盘路由系统
- 状态:设计中(v0.13;0C 当前可用环境基线通过,0B 阶段 1 前置基线已关闭;微软拼音 Forward/Consume 有效,搜狗 Consume 请求按
ime_condition_not_metfail-open 并形成MustForward;阶段 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. 总体建设顺序
阶段 0:输入链路探测
↓
阶段 1:输入采集与规范化
↓
阶段 2:通用焦点系统
↓
阶段 3:键盘定向路由
↓
阶段 4:Button / 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. 关键不变量
- NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。
- EasyX
EX_KEY只排空和观察,不得再次触发业务路由。 - 同步窗口协议不得进入异步输入队列。
- 异步路由只能阻止框架默认动作,不能倒过来改变已经结束的 WndProc 返回值。
- 队列、焦点、Tab 顺序、路由路径和待删除列表长期只保存
ControlHandle。 - 同一 Window 同时只能由最内层消息循环 drain 输入队列。
- 每次只从队列取一条事件,不预取整个批次到局部容器。
- WndProc 只在 EasyX 窗口线程采集;焦点、路由和控件操作只在宿主分发线程执行。
- NativeInputQueue 必须提供跨线程发布/获取的 happens-before,不能使用无锁保护的普通
std::deque。 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:
- 不修改
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* 选择性声明。
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 时不路由键盘。窗口重新激活后先验证句柄,失效则执行焦点回退。
6.5 队列外壳
struct NativeInputEvent
{
uint64_t sequence;
DWORD messageTime;
HWND hwnd;
std::variant<
KeyEvent,
TextInputEvent,
CompositionEvent,
WindowActivationEvent
> payload;
};
键盘、文本和 IME 事件不合并、不静默丢弃。
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
{
None,
Programmatic,
Click,
TabAndClick
};
| 控件 | 焦点角色 |
|---|---|
| Label | 不可聚焦 |
| Canvas | 透明焦点容器 |
| Button | 焦点叶子 |
| TextBox | 焦点叶子 |
| Table | 焦点容器,暴露内部翻页 Button |
| TabControl | 页签焦点组和活动页面容器 |
| Dialog | 焦点作用域 |
| Window | 根焦点作用域 |
10.2 ControlHandle
struct ControlHandle
{
WindowId owner;
uint32_t slot;
uint32_t generation;
};
- slot 重用必须递增 generation。
- 失效句柄解析为空。
collectTabStops()返回ControlHandle。Control*只允许作为当前函数内立即使用的借用视图,不得跨事件、回调、mutation transaction 或模态调用保存。
10.3 焦点有效条件
句柄有效
&& effectiveVisible
&& effectiveEnabled
&& FocusPolicy 允许
&& 位于最上层活动 FocusScope
&& 位于活动 Tab 页面
&& 不在 pending removal
10.4 焦点切换
FocusChangeResult requestFocus(
ControlHandle target,
FocusReason reason);
顺序:
- 验证目标。
- 保存旧句柄。
- 更新 FocusManager。
- 向旧目标发送
FocusLost。 - 向新目标发送
FocusGained。 - 使相关区域失效并重绘。
- 新目标为 TextBox 时更新 IME 锚点。
10.5 焦点失效回退
焦点控件被隐藏、禁用、删除或移入非活动页时:
同一作用域下一个可聚焦项
→ 上一个可聚焦项
→ 作用域 initialFocus
→ 无焦点
控件进入 pending removal 前,必须先让焦点系统中的对应句柄失效。
10.6 Dialog 作用域
Dialog 打开:
- 压入新 FocusScope。
- 保存外层焦点句柄。
- 选择
initialFocus,否则选择第一个 Tab stop。 - 完成作用域建立后才允许模态循环 drain。
Dialog 关闭:
- 停止内层 drain。
- 弹出作用域。
- 验证并恢复原焦点。
- 原焦点失效时执行外层回退。
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 总流程
取出一条事件
→ 验证窗口激活状态
→ IME 预处理
→ Tab 导航
→ 锁定原始焦点句柄
→ 构造句柄路由路径
→ 焦点叶子
→ 父容器冒泡
→ FocusScope 默认动作
→ Window 默认动作
→ 提交当前事件产生的 mutation
TextInputEvent 只发送给实现文本输入接口的当前焦点叶子,不冒泡。
12.2 路由结果
struct KeyRouteResult
{
bool stopBubble;
bool preventFrameworkDefault;
};
stopBubble:不再交给父容器。preventFrameworkDefault:冒泡结束后不再执行 Dialog/Window 默认动作。- 两者均不影响已经完成的原生 WndProc 返回值。
12.3 路由路径与 mutation
事件开始时使用句柄构造路径快照:
FocusedLeaf → Parent → ... → FocusScope → Window
每个节点使用前重新解析。处理过程中删除控件时,无效节点被跳过;当前事件仍使用原始路径,下一事件才使用新焦点和新树结构。
13. 按键归属
13.1 Tab
- IME 组合中交给 IME,不移动焦点。
- 非组合态由 FocusManager 处理。
- Shift+Tab 反向移动。
- 普通 Button/TextBox 不直接接收 Tab。
13.2 Space
Button:
首次 KeyDown → armed + pressed
重复 KeyDown → 忽略
KeyUp 且仍聚焦、仍 armed → 触发一次 click
焦点丢失或控件禁用 → 取消 armed
TextBox 不从 KeyEvent 插入空格,只接受 TextInputEvent(U+0020)。
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
- 输入事件结构和 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. 已锁定决策
- 输入采集先于焦点系统。
- v1 键盘默认采集并转发,不预设消费。
- NativeInputQueue 是唯一键盘业务源。
- EasyX
EX_KEY只排空和观察,不进入业务路由。 - v1 使用系统 IME UI,不自绘组合串。
- IME 正式提交优先使用
GCS_RESULTSTR。 - TextBox 内部使用 UTF-16。
- 绘制优先使用宽字符 GDI,不默认降级 CP936。
- 长期焦点、队列和遍历结果只使用 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 从默认转发基线开始,后续任何窄消费规则都必须有独立逐键证据。
实现与影响
- 关键实现点:
- 阶段 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 位置双时机规则;v0.13 根据 0B 微软拼音 Consume 与搜狗运行时 fail-open 证据锁定默认转发、
VK_PROCESSKEY路由抑制和无生产消费白名单基线。
- 涉及文件 / 类 / 函数:
- 预计涉及
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 的 F24 KeyDown/KeyUp 自动 A/B、baseline fail-open、微软拼音 Forward/Consume 和搜狗运行时 fail-open 门槛均已完成;生产输入队列和焦点系统尚未开始。
- 验证步骤:
- 先按关联规格实现阶段 0A 被动探测并取得消息链路证据。
- 按阶段 0C 规格完成宽字符 GDI 与 IME 坐标探测,取得绘制与坐标证据。
- 使用阶段 0B 的逐消息规则验证消费策略,不修改生产默认路径。
- 阶段 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。 - 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 以默认转发且无消费白名单为起点。
- 已知限制 / 遗留问题:
- §5.3 当前明确不建立生产消费白名单;若后续需要窄消费规则,仍必须补对应消息、虚拟键、IME 和环境的独立 0B 证据。
- §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。
- 宽字符 GDI 与当前微软拼音/搜狗定位门槛已通过;100%/125% 与跨屏 DPI 仍由阶段 0C 规格作为实机能力缺口承接。
落地信息
- 关联功能变更 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:无
- 发布版本:未发布
- 相关文档: