Files
StellarX-kaifa/开发记录/模块/Module-20261001-0001-通用输入焦点与键盘路由系统设计.md
T

64 KiB
Raw Blame History

新增功能模块 / 模块重构

适用场景:新增模块、重大模块重构、核心架构能力演进。 不适用场景:小接口或轻量功能变更,请使用“功能变更”模板。

基本信息

  • 模块 ID:Module-20261001-0001
  • 模块名称:通用输入采集、焦点与键盘路由系统
  • 状态:实现中(v0.16;0C/0B 前置基线、阶段 1 输入地基及阶段 2 实现与回归归因均已关闭;可以进入阶段 3,键盘换轨尚未开始)
  • 类型:新增模块 / 架构演进
  • 所属系统 / 子系统: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 代理对。
    • 本稿中的阶段 1 与阶段 2 已有实现记录和自动验证证据;阶段 3 至阶段 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:
    • 本稿只确定职责和接口形状,公共 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. 关键不变量

以下是阶段 3 换轨后的完成态不变量;阶段 1–2 的兼容过渡例外仅限“旧 EX_KEY 继续业务下发、新队列只观察”,不得被扩展成长期双业务源:

  1. 阶段 3 启用后,NativeInputQueue 是键盘、文本和 IME 的唯一业务输入源。
  2. 阶段 3 启用后,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 消费策略

初始策略:

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:

  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 失效判定只适用于诊断日志,不能照搬为生产输入队列;生产输入事件比探测记录具有更强的不丢失要求。诊断路径同样禁止持锁调用旧窗口过程、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 时:

  1. 立即产生唯一的 TextInputEvent。
  2. 记录当前 compositionId。
  3. 保存预期 UTF-16 或 ANSI 回声序列。
  4. 后续紧邻且匹配的 WM_IME_CHAR/WM_CHAR 只观察和转发,不再次入队。
  5. 遇到不匹配字符、新组合、焦点切换或越过本次结果边界时清空回声状态。

不能使用“字符内容相同就去重”,否则连续输入两个相同汉字时会误删第二个。具体回声顺序以阶段 0 日志决定。

7.4 ANSI / DBCS 回退

struct AnsiDecoderState
{
    UINT codePage;
    std::optional<BYTE> 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 输入。

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 由以下情况合成:

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

struct TextInputAnchor
{
    POINT caretTopLeft;
    RECT caretLineRect;
};

v1 使用:

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 焦点能力

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 中执行:

  1. 重新解析并验证目标。
  2. 保存旧 FocusTargetId,临时清空 active。
  3. 向旧目标发送 FocusLost;回调可删除控件、切页、push scope 或再次请求焦点。
  4. 回调后重新拉取目标的完整快照;目标失效时保持无焦点,不复活旧目标。
  5. 提交新 active,再发送 FocusGained。
  6. 回调后再次验证 active;active 失效才进入独立的失效回退路径。
  7. 使旧/新控件所属托管区域失效并重绘。

回调内的 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 打开:

  1. 压入新 FocusScope。
  2. 保存外层焦点句柄。
  3. 选择 initialFocus,否则选择第一个 Tab stop。
  4. 完成作用域建立后才允许模态循环 drain。

Dialog 关闭:

  1. 停止内层 drain。
  2. 弹出作用域。
  3. 验证并恢复原焦点。
  4. 原焦点失效时执行外层回退。

阶段 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 总流程

取出一条事件
→ 验证窗口激活状态
→ 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;
};

规则:

  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。
  • 当前状态: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 的 SendInput KeyDown 未到达目标 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

  • Tab 导航。
  • 句柄路由路径。
  • 冒泡和框架默认动作。
  • Button 和 Dialog 键盘行为。
  • Table 分页 Button 仅在首次 draw 后 materialize;首次 draw 前“无可遍历项”表示焦点树尚未就绪,不得缓存或解释为该 Table 永久没有 Tab stop。
  • Button 键盘按压态必须与阶段 2 的最小焦点框叠加绘制,不得用按压态覆盖或抹除键盘焦点反馈。

阶段 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。
  29. 原始 VK_PROCESSKEY 一律视为 IME 所有:WndProc 层采集并转发,阶段 1 不向 Button、Dialog、快捷键和焦点导航路由;不得依赖 imeComposingAtCapture 单独判断搜狗候选态。
  30. 0B 没有建立跨 IME 生产消费白名单;阶段 1 从默认转发基线开始,后续任何窄消费规则都必须有独立逐键证据。
  31. 阶段 1 只建输入地基、不切换业务轨道:旧 EX_KEY 继续进入 Control::handleEvent(),新队列只观测排空;阶段 3 一次性切换,EX_MOUSE 不受影响。
  32. 单键 IME 所有权只由采集时的原始 VK_PROCESSKEY 表达;imeComposingAtCapture 只记录标准生命周期,两者不得合并为一个持久“组合中”状态。
  33. mutex + deque 在正常资源条件下不得丢输入;分配失败或队列异常必须 fail-stop,不能捕获后假装成功。
  34. WM_IME_ENDCOMPOSITION 不得在转发旧窗口过程之前清空结果回声;END 处理中嵌套或紧随其后的匹配 WM_IME_CHAR/WM_CHAR 仍须抑制重复入队。
  35. 无效文本序列、IMM 查询失败和残留半截解码状态必须以不含原文的计数显式诊断,不得写入替代文本或静默记为成功。
  36. 焦点长期身份统一为 FocusTargetId;现有真实 Control 节点一律使用 subNode == 0,Table 分页和 TabControl 页签不得建立合成身份。
  37. 当前 ControlHandle 在进程内单调分配且不复用,不采用 slot/generation 草案;焦点解析使用独立递归快照协议。
  38. 焦点回调重入使用当前事务回合内的单槽 latest-wins;最终槽位失效只丢弃,不复活旧请求。
  39. 焦点切换最多 64 轮;超限以 FocusError::ReentrancyLimit 明确失败,并保留最后一个仍有效的已提交焦点。
  40. removal 请求入队时立即让对应焦点子树逻辑失效;批量 clear 必须先使整批失效,再执行 active 回退。
  41. 阶段 2 只建立 FocusScope 机制;真实 Dialog 作用域接入、Tab 导航和键盘路由不得提前混入。
  42. 阶段 3 的 Tab 遍历必须区分 Table 分页 Button“首次 draw 前尚未 materialize”与“永久无停靠项”,不得把 draw 前空快照固化为长期结论。
  43. 阶段 3 的 Button 键盘按压视觉必须叠加在最小焦点框之上,焦点框不得因按压态切换而消失。

实现与影响

  • 关键实现点:
    • 阶段 0A 探测器和 0C 独立视觉探测已进入仓库;阶段 1 的 NativeInputQueue 与输入规范化已经实现;阶段 2 的 FocusManager、递归焦点协议、PointerDown 聚焦、失效回退和作用域机制已经实现。KeyboardRouter 与 TextBox 编辑内核尚未开始。
    • 独立 EasyX 探测已经推翻 v0.4 的单线程假设,v0.5 据此补入跨线程队列约束;v0.6 根据微软拼音/搜狗真实日志锁定结果串与回声关系,并补入输入布局 request/change 分层;v0.7 根据专用短测区分 TSF 配置文件切换与跨语言 HKL,并增加重新获焦快照约束;v0.8 根据 0C 自动证据锁定宽字符绘制、批次进入顺序与当前 DPI 坐标规则;v0.9 根据搜狗真实失败与修复复验锁定 IME 位置双时机规则;v0.13 根据 0B 证据锁定默认转发与无生产消费白名单;v0.14 根据阶段 1 实现审查锁定 EX_KEY 兼容过渡、两轴快照、fail-stop、END 回声边界和显式异常计数;v0.15 根据阶段 2 审计与实现锁定 FocusTargetId、瞬时快照、单槽重入、64 轮活性保护和 pending removal 即时失效;v0.16 根据受控 A/B 和正式完整 UI 三轮结果关闭阶段 2 回归归因门槛,并锁定 Table 首次 draw 生命周期与 Button 按压/焦点视觉叠加约束。
  • 涉及文件 / 类 / 函数:
    • 预计涉及 Window、Control、Canvas、Button、TextBox、Table、TabControl、Dialog。
    • 实际文件和接口必须在阶段 0 与实现前只读检查后确定。
  • 兼容性影响:
    • 设计目标是保留现有鼠标行为和公共文本 API 的兼容入口。
    • 阶段 2 向 Control 增加焦点协议虚接口,现有二进制派生控件需要重新编译;源码接口没有删除,源码分发用户按正常流程重编译即可。
    • 阶段 1 不改变现有 EX_KEY 业务交付;阶段 3 停止向 Control::handleEvent() 下发 EX_KEY 时属于明确的派生控件迁移点,EX_MOUSE 永久保留。
    • 输入链路和 TextBox 内部模型将发生架构级变化,必须分阶段接入。
  • 性能影响:
    • 输入事件会增加规范化和句柄解析开销,但不允许在 WndProc 中执行阻塞 I/O。
    • TextBox 宽字符测量、选区和水平滚动需要缓存策略,具体数据待实现阶段评估。
  • 风险点:
    • EasyX 双线程、ANSI 窗口和当前微软拼音/搜狗回声顺序已确认,但其他 IME、窗口 A/W 属性和系统版本仍可能产生不同顺序。
    • 模态重入和控件删除若破坏单 drainer/句柄不变量,可能产生难复现的顺序或生命周期问题。
    • 阶段 1 的自动验证与人工回归必须分开记账;尚未执行的真实 IME/KEY1–6 人工操作不能由单元测试替代。

测试与验证

  • 测试范围:
    • 设计评审覆盖输入采集、同步消息、Unicode、IME、焦点、复合控件、按键归属、TextBox 和模态重入。
    • 独立线程归属探测、0A、0C、0B 门槛均已完成;阶段 1 已加入逻辑测试与真实 EasyX input-shadow 场景;阶段 2 已加入焦点事务单测和真实 EasyX focus 场景,并以 62/62 受控 A/B 和 24/24 正式完整 UI 关闭回归归因门槛。
  • 验证步骤:
  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。
    • 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 全部编译/链接成功。最终源码的独立 focus UI 连续 3 轮通过。针对随后出现的 Tooltip 像素等待和 geometry 点击结果超时,以阶段 1 基线 81341fa 与阶段 2 工作树执行受控 A/B,62/62 个结构化样本全部通过;当前阶段 2 正式完整 UI 三轮八场景共 24/24 通过,未发现阶段 2 相关失败或耗时劣化。此前超时定性为间歇性桌面 fixture / 输入时序波动,仍保留残余偶发波动可能,不把关闭门槛表述为证明 UI 永不波动。
  • 已知限制 / 遗留问题:
    • §5.3 当前明确不建立生产消费白名单;若后续需要窄消费规则,仍必须补对应消息、虚拟键、IME 和环境的独立 0B 证据。
    • §7.3 的微软拼音/搜狗回声状态机已有 0A 证据,其他 IME 仍需保持可诊断回退。
    • 宽字符 GDI 与当前微软拼音/搜狗定位门槛已通过;100%/125% 与跨屏 DPI 仍由阶段 0C 规格作为实机能力缺口承接。

落地信息