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

48 KiB
Raw Blame History

新增功能模块 / 模块重构

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

基本信息

  • 模块 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. 总体建设顺序

阶段 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 均通过人工视觉验收。

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. 关键不变量

  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 消费策略

初始策略:

KeyDown/KeyUp:采集 + 转发
WM_CHAR:采集 + 转发
IME 消息:采集 + 转发
真实 WM_UNICHAR:采集 + 消费

只有阶段 0 证明转发会产生重复行为或原生副作用后,才允许建立窄白名单,例如:

  • 非组合态 Tab。
  • 非组合态且框架明确拥有的方向键。
  • 非组合态 Button 的 Enter/Space。
  • 框架明确拥有的 Ctrl 快捷键。

硬规则:imeComposingAtCapture == true 的键不能进入消费白名单。

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”只适用于诊断日志,不能照搬为生产输入队列;生产输入事件比探测记录具有更强的不丢失要求。

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
{
    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);

顺序:

  1. 验证目标。
  2. 保存旧句柄。
  3. 更新 FocusManager。
  4. 向旧目标发送 FocusLost。
  5. 向新目标发送 FocusGained。
  6. 使相关区域失效并重绘。
  7. 新目标为 TextBox 时更新 IME 锚点。

10.5 焦点失效回退

焦点控件被隐藏、禁用、删除或移入非活动页时:

同一作用域下一个可聚焦项
→ 上一个可聚焦项
→ 作用域 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 与页面是兄弟关系:

页签 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。

阶段 0B:隔离消费实验

使用测试专用规则:

struct ProbeConsumeRule
{
    UINT message;
    std::optional<UINT> 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 规格作为实机能力缺口承接。

落地信息