Skip to content

fix: respond to TSF conversion mode compartment changes#1889

Open
VimWei wants to merge 15 commits into
rime:masterfrom
VimWei:im-control
Open

fix: respond to TSF conversion mode compartment changes#1889
VimWei wants to merge 15 commits into
rime:masterfrom
VimWei:im-control

Conversation

@VimWei

@VimWei VimWei commented Jul 2, 2026

Copy link
Copy Markdown
  • Read TF_CONVERSIONMODE_NATIVE flag from the compartment on GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION change
  • Compare with current _status.ascii_mode and toggle via _HandleLangBarMenuSelect when they differ
  • Use the same IPC path (TrayCommand → SetOption) as the Shift key for consistent behavior
  • Clear pending composition on mode switch to avoid stale preedit

Closes #1371

- Read TF_CONVERSIONMODE_NATIVE flag from the compartment on
GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION change
- Compare with current _status.ascii_mode and toggle via
_HandleLangBarMenuSelect when they differ
- Use the same IPC path (TrayCommand → SetOption) as the Shift key for
consistent behavior
- Clear pending composition on mode switch to avoid stale preedit

Closes rime#1371
- Replace reading compartment value and comparing with
  _status.ascii_mode by a blind toggle, matching the OPENCLOSE
  handler's Shift-key path. The OnChange notification may deliver
  stale values, making the read-compare approach unreliable.
- Remove _IsKeyboardOpen() guard; call _SetKeyboardOpen(true) to
  ensure the keyboard is active when processing external changes.
- Add _updatingLanguageBar re-entrancy guard in _UpdateLanguageBar
  to prevent _SetCompartmentDWORD from re-triggering the handler.
- Add _SetKeyboardOpen/_EnableLanguageBar calls to match the
  OPENCLOSE handler's else branch.
@VimWei

VimWei commented Jul 2, 2026

Copy link
Copy Markdown
Author

fix: replace read-compare with blind toggle for conversion compartment

  • Replace reading compartment value and comparing with
    _status.ascii_mode by a blind toggle, matching the OPENCLOSE
    handler's Shift-key path. The OnChange notification may deliver
    stale values, making the read-compare approach unreliable.
  • Remove _IsKeyboardOpen() guard; call _SetKeyboardOpen(true) to
    ensure the keyboard is active when processing external changes.
  • Add _updatingLanguageBar re-entrancy guard in _UpdateLanguageBar
    to prevent _SetCompartmentDWORD from re-triggering the handler.
  • Add _SetKeyboardOpen/_EnableLanguageBar calls to match the
    OPENCLOSE handler's else branch.

@VimWei

VimWei commented Jul 2, 2026

Copy link
Copy Markdown
Author

修复分析:WeaselTSF 响应 TSF 转换模式 Compartment 外部变更

问题

通过 TSF compartment 程序化切换中英文模式(如 ITfCompartment::SetValue(GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION))对小狼毫无效。外部工具写入 compartment 后,小狼毫不切换模式,且 compartment 值被恢复原状。

行为变化

操作 修复前 修复后
通过 TSF compartment 设置转换模式 无效果(外部变更被撤销) 正常切换中英文
键盘关闭时设置转换模式 不处理 自动打开键盘并切换
Shift 按键切换 正常 正常(不变)
托盘菜单切换 正常 正常(不变)

架构背景

WeaselTSF 是 TSF Text Service(实现 ITfTextInputProcessorEx),不接收 WM_IME_CONTROL。Windows TSF 框架负责在 IMM32 API 与 TSF compartments 之间桥接:

ImmSetConversionStatus(IME_CMODE_NATIVE)
    → TSF framework 翻译
        → ITfCompartment::SetValue(GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION)
            → _HandleCompartment()  ← 修复位置

根因

根因有两个层面:源代码逻辑缺陷DLL 部署位置。两者必须同时解决,缺一不可。

1. 源代码:CONVERSION handler 撤销外部变更

_HandleCompartmentGUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION 的原始实现:

// 原始代码 (commit 93eec2d)
} else if (IsEqualGUID(guidCompartment,
                       GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION)) {
  BOOL isOpen = _IsKeyboardOpen();
  if (isOpen) {
    weasel::ResponseParser parser(NULL, NULL, &_status, NULL,
                                  &_cand->style());
    bool ok = m_client.GetResponseData(std::ref(parser));  // 查询 RIME 后端当前状态
    _UpdateLanguageBar(_status);                           // 写回 compartment
  }
}

问题链条

  1. 外部工具(如 im-control)写入 compartment,清除 TF_CONVERSIONMODE_NATIVE 位(请求切英文)
  2. TSF 触发 ITfCompartmentEventSink::OnChange_HandleCompartment
  3. handler 调用 m_client.GetResponseData(parser) —— 从 RIME 后端获取当前状态(仍是中文,因为 RIME 还没收到切换指令)
  4. handler 调用 _UpdateLanguageBar(_status) —— 把当前状态(中文)写回 compartment
  5. compartment 被恢复为 TF_CONVERSIONMODE_NATIVE 置位 —— 外部变更被静默撤销

原始代码从不读取 compartment 值来判断外部请求的目标模式,也不调用 _HandleLangBarMenuSelect 通知 RIME 引擎切换。它只是把 RIME 后端的当前状态同步回 compartment,方向与需求完全相反。

2. DLL 部署位置

WeaselTSF 的 CLSID {A3F4CDED-B1E9-41EE-9CA6-7B4D0DE6CB0A} 注册路径为:

架构 注册路径
64-bit C:\Windows\system32\weasel.dll
32-bit C:\Windows\SysWOW64\weasel.dll

TSF 框架从上述系统路径加载 DLL。如果将修复后的 DLL 部署到其他路径(如 C:\Program Files\Rime\weasel-0.17.4\),系统不会加载它,进程内仍是旧 DLL。

修复方案

源代码修改

修改文件: WeaselTSF/Compartment.cppWeaselTSF/LanguageBar.cppWeaselTSF/WeaselTSF.h

将 CONVERSION handler 从"查询后端 → 写回 compartment"改为"toggle → 通知 RIME → 同步 UI",与 GUID_COMPARTMENT_KEYBOARD_OPENCLOSE 的 else 分支(Shift 按键切换路径)保持一致:

// 修复后
} else if (IsEqualGUID(guidCompartment,
                       GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION)) {
  if (_updatingLanguageBar)
    return S_OK;
  _status.ascii_mode = !_status.ascii_mode;
  _SetKeyboardOpen(true);
  if (_pLangBarButton && _pLangBarButton->IsLangBarDisabled())
    _EnableLanguageBar(true);
  _HandleLangBarMenuSelect(_status.ascii_mode
                                 ? ID_WEASELTRAY_ENABLE_ASCII
                                 : ID_WEASELTRAY_DISABLE_ASCII);
  if (_pEditSessionContext)
    m_client.ClearComposition();
  _UpdateLanguageBar(_status);
}

与原始代码的关键差异:

项目 原始代码 修复后
响应方式 m_client.GetResponseData() 查询 RIME 后端旧状态,_UpdateLanguageBar() 写回 compartment(撤销外部变更) _status.ascii_mode = !_status.ascii_mode 直接翻转,_HandleLangBarMenuSelect() 通知 RIME 引擎切换
键盘状态守卫 _IsKeyboardOpen() —— 键盘关闭时完全不处理 移除守卫,改为 _SetKeyboardOpen(true) 主动打开键盘
重入保护 无 —— _UpdateLanguageBar 写 compartment 再次触发 handler _updatingLanguageBar 守卫,阻断 _UpdateLanguageBar 引起的自触发

_HandleLangBarMenuSelectTrayCommand IPC → RIME 后端的 SetOption("ascii_mode"),与 Shift 按键切换走同一路径,确保行为和配置(如 global_ascii_mode)一致。

重入守卫

_UpdateLanguageBar 内部调用 _SetCompartmentDWORD 写入 CONVERSION compartment,这会再次触发 OnChange_HandleCompartment。如果不加守卫,handler 会再次 toggle,导致双重翻转或无限递归。

// LanguageBar.cpp
void WeaselTSF::_UpdateLanguageBar(weasel::Status stat) {
  // ...
  _updatingLanguageBar = true;
  _SetCompartmentDWORD(flags, GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION);
  _updatingLanguageBar = false;
  // ...
}
// WeaselTSF.h
BOOL _updatingLanguageBar = false;

为什么用 blind toggle 而非读取 compartment 值

曾尝试读取 compartment 值来推导目标模式(_GetCompartmentDWORD → 检查 TF_CONVERSIONMODE_NATIVE 位 → 与 _status.ascii_mode 比对)。但 TSF 的 OnChange 通知时机可能导致 GetValue 返回旧值。blind toggle 避免了这一时序问题,且与 OPENCLOSE handler 的 else 分支(Shift 按键路径)完全一致。

外部工具(如 im-control)只在 newMode != oldMode 时才调用 SetValue,所以每次 OnChange 通知都代表一次真实的模式切换请求,blind toggle 语义正确。

验证

测试环境

  • 前台进程:Windows Terminal (x64)
  • 外部工具:im-control —— 通过 SetWindowsHookEx 注入 hook DLL 到前台进程,在目标线程内调用 ITfCompartment::SetValue

测试结果

# 中文状态下切英文
im-control -g              → open native
im-control -c alphanumeric → (成功)
im-control -g              → open alphanumeric

# 英文状态下切中文
im-control -g              → open alphanumeric
im-control -c native       → (成功)
im-control -g              → open native

调试日志验证

_HandleCompartment_UpdateLanguageBar 中加入 OutputDebugStringW 日志,确认完整链路:

[05:38:09.417] CONVERSION fired, compartment=0xF49BE320 (NATIVE=0), ascii_mode(before)=0, _updatingLanguageBar=0
[05:38:09.417] CONVERSION toggled ascii_mode: 0 -> 1
[05:38:09.417] CONVERSION called HandleLangBarMenuSelect(40013)    # ID_WEASELTRAY_ENABLE_ASCII
[05:38:09.417] CONVERSION calling _UpdateLanguageBar
[05:38:09.417] CONVERSION done, ascii_mode(after)=1
[05:38:09.433] CONVERSION fired, compartment=0xF49BE320 (NATIVE=0), ascii_mode(before)=1, _updatingLanguageBar=1
[05:38:09.433] CONVERSION skipped (re-entrant from _UpdateLanguageBar)   # 重入守卫生效

链路完整:hook 写入 compartment → sink 触发 → toggle ascii_mode → 通知 RIME 引擎 → _UpdateLanguageBar 写回 compartment → 重入守卫跳过自触发。

相关链接

@fxliang

fxliang commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

不用管_IsKeyboardOpen?

@brglng

brglng commented Jul 3, 2026

Copy link
Copy Markdown

按照我之前测试结果,实际上微软拼音/微软日文输入法也不响应 GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION,而是响应 GUID_COMPARTMENT_KEYBOARD_OPENCLOSE,这个 compartment 可以用于切换中文输入法的中/英状态和日文输入法的假名/英文状态。然而它们都能正确响应 WM_IME_CONTROLIME_CMODE_NATIVE/IME_CMODE_ALPHANUMERIC 状态位。因此我当时认为 WM_IME_CONTROL 可能需要输入法在窗口过程中单独处理。不过我不确定我当时的测试是否有问题,我会尽快重新测试一下。

@fxliang

fxliang commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

因为目前有两种可能的状态,一个是开关键盘的状态,一个是切换ascii的状态。

你的修改上没有考虑输入法是不是开启,感觉有洞

@lotem

lotem commented Jul 3, 2026

Copy link
Copy Markdown
Member

期待~

@VimWei

VimWei commented Jul 7, 2026

Copy link
Copy Markdown
Author

因为目前有两种可能的状态,一个是开关键盘的状态,一个是切换ascii的状态。

你的修改上没有考虑输入法是不是开启,感觉有洞

感谢回复。我想确认一下你说的”输入法是不是开启“具体指哪种情况:
我理解有 3 个层级的状态:

  1. 当前 IME 身份 — 当前 IME 是 RIME,还是系统默认英文输入法、或其他输入法?
  2. RIME 的启用/禁用 — RIME 当前是启用状态(图标正常),还是禁用状态(图标打叉,按键不拦截)?
  3. RIME 的中文/英文模式 — RIME 启用时,当前是中文还是英文输出?
    PR 里修改的是第 3 层(CONVERSION compartment),但你说的「输入法是不是开启」让我比较困惑——是指第 1 层(当前 IME 是不是 RIME)还是第 2 层(RIME 启用还是禁用),还是别的意思?
    能再具体说明一下在什么场景下会出现你提到的「洞」吗?

@fxliang

fxliang commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

原来旧的weasel的响应 打开/关闭输入法 消息是执行的打开keyboard和关闭keyboard,对应影响的是_IsKeyboardOpen 的状态;后来改了默认切换ascii_mode,可选toggleime

- Realign _HandleLangBarMenuSelect ternary to one-line condition with
  colon continuation aligned under the question mark, per Chromium
  clang-format style; resolves CI lint violations at lines 275-276
@VimWei

VimWei commented Jul 8, 2026

Copy link
Copy Markdown
Author

原来旧的weasel的响应 打开/关闭输入法 消息是执行的打开keyboard和关闭keyboard,对应影响的是_IsKeyboardOpen 的状态;后来改了默认切换ascii_mode,可选toggleime

感谢澄清历史背景,现在理解了 OPENCLOSE 语义的演变。

基于你的说明,我梳理了当前 PR 的设计,确认在 _isToOpenClose=false(默认 Shift 切换模式)下是正确的:

当前PR的设计:CONVERSION handler 中 _SetKeyboardOpen(true) 的调用,在 im-control 的典型工作流里是 no-op——因为外部工具总是先写 OPENCLOSE=1 启用 RIME,再写 CONVERSION 切换中英。到 CONVERSION handler 执行时 _IsKeyboardOpen() 已为 true,SetValue(TRUE) 不改变值、不触发 OnChange,OPENCLOSE handler 不会被调用。经实测,gvim 和 terminal vim 下进入/离开 insert mode 均工作正常:进入时切英文、离开时恢复英文、全程不会误禁用 RIME。

一个可选的优化方向:从 CONVERSION handler 中移除 _SetKeyboardOpen(true),让两个 compartment 各司其职——OPENCLOSE 管启用/禁用,CONVERSION 管中英切换。这样在 _isToOpenClose=false 模式下不会依赖已被重新定义语义的 OPENCLOSE compartment。但这需要外部调用方(如 im-control)保证先 -k open 再 -c,否则单独写 CONVERSION 不会启用 RIME。

考虑到当前实现已经正确工作且对调用方更宽容(即使单独写 CONVERSION 也会自动启用 RIME),我倾向保持现状。您认为呢?

PS:上述提到的正确工作,其环境包括:

@lotem lotem requested a review from fxliang July 8, 2026 14:16
VimWei added 5 commits July 8, 2026 22:28
…ndows 11 compatibility

blind toggle (_status.ascii_mode = !_status.ascii_mode) relies on the
_updatingLanguageBar boolean guard catching synchronous OnChange
re-entrancy from _UpdateLanguageBar. On Windows 11, TSF may deliver
OnChange asynchronously or inject extra compartment writes via
TextInputHost.exe, causing the guard to miss and producing an odd
number of toggles (wrong mode).

Replace with reading the actual compartment value and comparing
against _status.ascii_mode. This is naturally idempotent: re-entrant
or delayed OnChange reads the value written by _UpdateLanguageBar,
finds it matches current state, and skips. The _updatingLanguageBar
guard remains as a first layer.

Also update analysis doc with Windows 11 root cause and fix rationale.
Restore conversion-compartment-fix-analysis.md to its original state
(documenting the blind-toggle approach for Windows 10). Create new
windows11-compatibility-fix.md for the value-driven fix that addresses
Windows 11 TSF async callback behavior.
Update windows11-compatibility-fix.md with the finding that Windows 11
TSF only triggers OnChange for writes from activated clients. im-control
was using TF_CLIENTID_NULL, so OnChange was never fired.
…teLanguageBar

Log compartment value, desiredAsciiMode, _status.ascii_mode,
_updatingLanguageBar state, and decision (skip/process) in
_HandleCompartment. Log flags and _updatingLanguageBar state in
_UpdateLanguageBar. Use DebugView to diagnose Windows 11 behavior.
…ingW

Add _DbgInit/_DbgLog helpers that append to
C:\Users\Public\weasel-compartment-debug.log (always-on for diagnosis).
Thread-safe with CRITICAL_SECTION, with timestamps.
Log OPENCLOSE/CONVERSION OnChange entry, decision branches, and
_UpdateLanguageBar guard transitions.
@VimWei

VimWei commented Jul 9, 2026

Copy link
Copy Markdown
Author

发现这个PR在 windows 10 下没有问题,但 windows 11 下有问题,修复中...

@fxliang

fxliang commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION 和 GUID_COMPARTMENT_KEYBOARD_OPENCLOSE 是不一样的消息,作用要区分。在GUID_COMPARTMENT_KEYBOARD_OPENCLOSE的情况下GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION的消息不应该再作任何响应,否则就是对旧功能的break,毕竟已知是有人要这种open/close状态切换而不是ascii切换(这也是传统小狼毫的基本状态)。需要明确的是,输入功能是主功能,外部消息控制是辅助,不能因为要引入辅助功能导致主功能失效是基本要求。

另外调试可以不用那么复杂,debugview++来实时看就行,调用weaselutility.h中DEBUG宏来打印就可以在debugview++中查看到调试信息,如DEBUG << "SOME DBG INFO";

VimWei added 6 commits July 9, 2026 09:37
The CONVERSION handler called _SetKeyboardOpen(true) which writes
OPENCLOSE=1 unconditionally. On Windows 11 this triggers the OPENCLOSE
OnChange sink (else branch for _isToOpenClose=false), which blind-
toggles ascii_mode, reversing the mode that CONVERSION just set.

Debug logs confirmed the cascade:
  1. CONVERSION sets ascii 0->1 (correct)
  2. _SetKeyboardOpen(true) writes OPENCLOSE
  3. OPENCLOSE handler toggles ascii 1->0 (wrong)
  4. Final: ascii=0 (Chinese instead of English)

Per Weasel maintainer guidance: OPENCLOSE and CONVERSION are separate
compartments with separate responsibilities. OPENCLOSE manages
enable/disable, CONVERSION manages Chinese/English. The CONVERSION
handler should not touch OPENCLOSE.

Callers (e.g. im-control/vim plugin) always send both -k open and
-c together, so OPENCLOSE is handled by its own handler. When only
CONVERSION is written (keyboard already open), OPENCLOSE handler
doesn't fire and there is no cascade.
Record the ThreadMgr pointer and thread ID when WeaselTSF is activated,
to compare with im-control's TF_GetThreadMgr pointer and determine if
they share the same ThreadMgr instance in Windows Terminal.
The C:\Users\Public path may not be writable from all processes
(e.g. UWP-packaged apps like Windows Terminal). Use GetTempPathA
to write to the per-user temp directory. Also log PID in session
header to distinguish multiple processes.
Remove _DbgInit/_DbgLog infrastructure, all debug log calls in
Compartment.cpp, LanguageBar.cpp, and WeaselTSF.cpp. The production
code is clean and contains only the functional fixes:
- CONVERSION handler is value-driven (reads compartment, compares
  with state, only acts if different)
- CONVERSION handler no longer calls _SetKeyboardOpen(true)
Add three-layer root cause analysis (TF_GetThreadMgr singleton,
TfClientId, OPENCLOSE cascade), document the compartment decoupling
principle, and add deployment note about restarting applications
after DLL replacement.
Merge conversion-compartment-fix-analysis.md (blind toggle, Win10)
and windows11-compatibility-fix.md (value-driven, Win11) into one
coherent document: compartment-external-control-fix.md.

The new document covers all four root causes, the complete fix
across Weasel/im-control/vim-plugin, design principles, DLL
deployment instructions (preserved from original), and verification
results for both Windows 10 and 11.
@VimWei

VimWei commented Jul 9, 2026

Copy link
Copy Markdown
Author

GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION 和 GUID_COMPARTMENT_KEYBOARD_OPENCLOSE 是不一样的消息,作用要区分。在GUID_COMPARTMENT_KEYBOARD_OPENCLOSE的情况下GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION的消息不应该再作任何响应,否则就是对旧功能的break,毕竟已知是有人要这种open/close状态切换而不是ascii切换(这也是传统小狼毫的基本状态)。需要明确的是,输入功能是主功能,外部消息控制是辅助,不能因为要引入辅助功能导致主功能失效是基本要求。

感谢指导!我做了以下调整:

  1. "两个 compartment 作用要区分" — 已移除 CONVERSION handler 中的 _SetKeyboardOpen(true),CONVERSION 不再触碰 OPENCLOSE,各司其职
  2. "OPENCLOSE 情况下 CONVERSION 不应再作响应" — _updatingLanguageBar 守卫已阻止 _UpdateLanguageBar 引起的 CONVERSION 自触发;移除 _SetKeyboardOpen(true) 后 CONVERSION 也不会反向触发 OPENCLOSE
  3. "主功能不失效" — 用户输入路径(Shift、Ctrl+Space、打字)完全不受影响,只改了外部 compartment 写入的处理方式

经过 windows 10 和 11 测试,目前均可以正常工作了。

@VimWei

VimWei commented Jul 9, 2026

Copy link
Copy Markdown
Author

更新总结之前的所有修订:

WeaselTSF Compartment 外部控制修复分析

问题

通过 TSF compartment 程序化切换中英文模式(如 ITfCompartment::SetValue(GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION))对 Weasel(小狼毫)无效。外部工具写入 compartment 后,Weasel 不切换模式,且 compartment 值被恢复原状。

此问题在 Windows 10 和 Windows 11 上表现不同:

  • Windows 10:compartment 写入能触发 OnChange,但 CONVERSION handler 逻辑缺陷导致外部变更被撤销
  • Windows 11:compartment 写入根本不触发 OnChange,且 CONVERSION handler 存在级联反转问题

架构背景

WeaselTSF 是 TSF Text Service(实现 ITfTextInputProcessorEx),不接收 WM_IME_CONTROL。Windows TSF 框架负责在 IMM32 API 与 TSF compartments 之间桥接:

ImmSetConversionStatus(IME_CMODE_NATIVE)
    → TSF framework 翻译
        → ITfCompartment::SetValue(GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION)
            → _HandleCompartment()  ← 修复位置

两个 compartment 各司其职:

  • GUID_COMPARTMENT_KEYBOARD_OPENCLOSE:管理 IME 启用/禁用(对应 Ctrl+Space、-k open/close
  • GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION:管理中/英文模式(对应 Shift 切换、-c native/alphanumeric

根因

经 Windows 10 和 Windows 11 实测和日志分析,问题有四个独立的层面

1. CONVERSION handler 撤销外部变更(Windows 10)

_HandleCompartmentGUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION 的原始实现:

// 原始代码 (commit 93eec2d)
} else if (IsEqualGUID(guidCompartment,
                       GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION)) {
  BOOL isOpen = _IsKeyboardOpen();
  if (isOpen) {
    weasel::ResponseParser parser(NULL, NULL, &_status, NULL,
                                  &_cand->style());
    bool ok = m_client.GetResponseData(std::ref(parser));  // 查询 RIME 后端当前状态
    _UpdateLanguageBar(_status);                           // 写回 compartment
  }
}

问题链条

  1. 外部工具(如 im-control)写入 compartment,清除 TF_CONVERSIONMODE_NATIVE 位(请求切英文)
  2. TSF 触发 ITfCompartmentEventSink::OnChange_HandleCompartment
  3. handler 调用 m_client.GetResponseData(parser) —— 从 RIME 后端获取当前状态(仍是中文,因为 RIME 还没收到切换指令)
  4. handler 调用 _UpdateLanguageBar(_status) —— 把当前状态(中文)写回 compartment
  5. compartment 被恢复为 TF_CONVERSIONMODE_NATIVE 置位 —— 外部变更被静默撤销

原始代码从不读取 compartment 值来判断外部请求的目标模式,也不调用 _HandleLangBarMenuSelect 通知 RIME 引擎切换。它只是把 RIME 后端的当前状态同步回 compartment,方向与需求完全相反。

2. CoCreateInstance(CLSID_TF_ThreadMgr) 在 Win11 返回新实例

im-control 的 hook 用 CoCreateInstance(CLSID_TF_ThreadMgr) 获取 ThreadMgr。Windows 10 返回 per-thread 单例(与 WeaselTSF 注册 sink 的实例相同),Windows 11 返回新实例——compartment 写入到了不同实例,WeaselTSF 的 sink 收不到 OnChange。

日志证据:im-control SetValue 返回 S_OK,但 Weasel 日志中 _HandleCompartment 完全没有被调用。

3. TF_CLIENTID_NULL 的 SetValue 在 Win11 不触发 OnChange

im-control 的 hook 用 SetValue(0, ...)TF_CLIENTID_NULL)。WeaselTSF 自身用 SetValue(_tfClientId, ...)(有效非零 ID)。

Windows 10:不区分 TfClientId,所有 SetValue 都触发 OnChange
Windows 11:仅为已激活客户端(非零 TfClientId)的写入触发 OnChange

4. CONVERSION handler 的 _SetKeyboardOpen(true) 引发级联反转

CONVERSION handler 调用 _SetKeyboardOpen(true) 写 OPENCLOSE=1,触发 OPENCLOSE handler 的 else 分支(_isToOpenClose=false,为 Shift 键设计的 blind toggle),将 CONVERSION 刚设置好的 ascii_mode 反转。

日志证据

CONVERSION: processing -> switching mode (ascii 0 -> 1)     ← 正确
OPENCLOSE OnChange: isOpen=1 -> toggle ascii_mode 1 -> 0    ← 级联反转
CONVERSION done: ascii_mode=0                                ← 结果中文(错)

移除 _SetKeyboardOpen(true) 后:

CONVERSION: processing -> switching mode (ascii 0 -> 1)     ← 正确
CONVERSION done: ascii_mode=1                                ← 结果英文(对)

修复方案

1. Weasel:CONVERSION handler 改为值驱动

修改文件: WeaselTSF/Compartment.cpp

将原始的"查询后端 → 写回 compartment"改为"读取 compartment 值 → 比对状态 → 通知 RIME → 同步 UI":

} else if (IsEqualGUID(guidCompartment,
                       GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION)) {
  if (_updatingLanguageBar)
    return S_OK;
  DWORD convMode = 0;
  _GetCompartmentDWORD(convMode,
                       GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION);
  bool desiredAsciiMode = !(convMode & TF_CONVERSIONMODE_NATIVE);
  if (desiredAsciiMode != _status.ascii_mode) {
    _status.ascii_mode = desiredAsciiMode;
    // 注意:不调用 _SetKeyboardOpen(true),避免触发 OPENCLOSE 级联
    if (_pLangBarButton && _pLangBarButton->IsLangBarDisabled())
      _EnableLanguageBar(true);
    _HandleLangBarMenuSelect(_status.ascii_mode
                                 ? ID_WEASELTRAY_ENABLE_ASCII
                                 : ID_WEASELTRAY_DISABLE_ASCII);
    if (_pEditSessionContext)
      m_client.ClearComposition();
    _UpdateLanguageBar(_status);
  }
}

天然幂等性:即使 _updatingLanguageBar 守卫失效(异步回调 / 系统注入),重新读取 compartment 值会得到与当前 _status.ascii_mode 一致的结果,desiredAsciiMode == _status.ascii_mode → 跳过。不会产生额外翻转。

_HandleLangBarMenuSelectTrayCommand IPC → RIME 后端的 SetOption("ascii_mode"),与 Shift 按键切换走同一路径,确保行为和配置(如 global_ascii_mode)一致。

2. Weasel:从 CONVERSION handler 移除 _SetKeyboardOpen(true)

修改文件: WeaselTSF/Compartment.cpp

CONVERSION handler 不再调用 _SetKeyboardOpen(true)。两个 compartment 完全解耦:

  • OPENCLOSE:管理 IME 启用/禁用(由 OPENCLOSE handler 处理,为 Ctrl+Space 和 Shift 设计)
  • CONVERSION:管理中/英文模式(由 CONVERSION handler 处理,为外部工具和系统切换设计)

3. Weasel:重入守卫

修改文件: WeaselTSF/LanguageBar.cppWeaselTSF/WeaselTSF.h

_UpdateLanguageBar 内部调用 _SetCompartmentDWORD 写入 CONVERSION compartment,这会再次触发 OnChange_HandleCompartment。如果不加守卫,handler 会再次处理,导致双重翻转或无限递归。

// LanguageBar.cpp
void WeaselTSF::_UpdateLanguageBar(weasel::Status stat) {
  // ...
  _updatingLanguageBar = true;
  _SetCompartmentDWORD(flags, GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION);
  _updatingLanguageBar = false;
  // ...
}
// WeaselTSF.h
BOOL _updatingLanguageBar = false;

4. im-control:使用 TF_GetThreadMgr 获取 per-thread 单例

修改文件: injector/hook.cpp

用 msctf.dll 导出的 TF_GetThreadMgr 替代 CoCreateInstance(CLSID_TF_ThreadMgr),确保获取与 WeaselTSF 相同的 ThreadMgr 实例:

typedef HRESULT(WINAPI* PFN_TF_GetThreadMgr)(ITfThreadMgr**);
static ITfThreadMgr* GetThreadMgrSingleton() {
    HMODULE hMsctf = GetModuleHandleW(L"msctf.dll");
    // ... GetProcAddress("TF_GetThreadMgr") ...
    ITfThreadMgr* pThreadMgr = nullptr;
    pfn(&pThreadMgr);
    return pThreadMgr;
}

5. im-control:使用有效 TfClientId 调用 SetValue

修改文件: injector/hook.cpp

调用 ITfThreadMgr::Activate 获取有效 TfClientId,用于所有 SetValue 调用,完成后 Deactivate

TfClientId clientId = TF_CLIENTID_NULL;
pThreadMgr->Activate(&clientId);
// ... SetValue(clientId, ...) 替代 SetValue(0, ...) ...
pThreadMgr->Deactivate();

6. im-control:OPENCLOSE 跳过未变化的写入

修改文件: injector/hook.cpp

写入前先 GetValue 比对,仅在值变化时 SetValue,避免不必要地触发 OPENCLOSE handler。

7. vim 插件:去掉 -k open,只写 CONVERSION

修改文件: autoload/im_select.vim(vim-im-select)

im_control_set_mode()[im-control, '-k', 'open', '-c', 'native'] 改为 [im-control, '-c', 'native'],两个 compartment 完全解耦。

设计原则

遵循 fxliang 的指导:

GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION 和 GUID_COMPARTMENT_KEYBOARD_OPENCLOSE 是不一样的消息,作用要区分。在 GUID_COMPARTMENT_KEYBOARD_OPENCLOSE 的情况下 GUID_COMPARTMENT_KEYBOARD_INPUTMODE_CONVERSION 的消息不应该再作任何响应,否则就是对旧功能的 break。需要明确的是,输入功能是主功能,外部消息控制是辅助,不能因为要引入辅助功能导致主功能失效是基本要求。

我们的修改完全符合此原则:

  • CONVERSION handler 不再触碰 OPENCLOSE compartment
  • OPENCLOSE handler 未改动(Shift/Ctrl+Space 主功能不受影响)
  • 外部工具只写 CONVERSION 切换中英文,不写 OPENCLOSE

方案对比

项目 原始代码 (93eec2d) blind toggle (f14f2a7) 值驱动(最终)
响应方式 查询后端→写回(撤销外部变更) blind toggle 读取值→比对→切换
回调次数依赖 N/A 严格依赖偶数次 OnChange 幂等,不依赖回调次数
compartment 耦合 CONVERSION 写 OPENCLOSE(级联反转) 完全解耦
重入守卫 唯一防线,异步回调下失效 第一防线;值比对是第二防线
Windows 10 失效 正常 正常
Windows 11 失效 失效 正常

DLL 部署位置

WeaselTSF 的 CLSID {A3F4CDED-B1E9-41EE-9CA6-7B4D0DE6CB0A} 注册路径为:

架构 注册路径
64-bit C:\Windows\system32\weasel.dll
32-bit C:\Windows\SysWOW64\weasel.dll

TSF 框架从上述系统路径加载 DLL。如果将修复后的 DLL 部署到其他路径(如 C:\Program Files\Rime\weasel-0.17.4\),系统不会加载它,进程内仍是旧 DLL。

更新方法

管理员身份打开 PowerShell,执行:

Rename-Item "C:\Windows\system32\weasel.dll" "weasel.dll.bak" -Force
Copy-Item "C:\path\to\weaselx64.dll" "C:\Windows\system32\weasel.dll" -Force
Rename-Item "C:\Windows\SysWOW64\weasel.dll" "weasel.dll.bak" -Force
Copy-Item "C:\path\to\weasel.dll" "C:\Windows\SysWOW64\weasel.dll" -Force

重要:部署后需重启所有使用 RIME 的应用

weasel.dll 由 TSF 框架在进程启动时加载。部署新 DLL 后,已运行的进程仍使用内存中的旧 DLL。必须重启 Windows Terminal、Total Commander、gvim 等应用(或重启 Windows)才能加载新版本。

编译

Weasel

在装有 Visual Studio 2022 + Boost 的机器上编译:

cd C:\Apps\git-kb\repos\VimWei\weasel
git pull
build.bat weasel release

产出 output\weasel.dll(Win32)和 output\weaselx64.dll(x64)。

im-control

cd C:\Apps\git-kb\repos\VimWei\im-control
git pull
cmake -S . -B build -G "Visual Studio 17 2022"
cmake --build build --config RelWithDebInfo
cmake --install build --prefix bin --config RelWithDebInfo
Copy-Item bin\* "C:\Apps\VimReader\lib\utils\im-control\" -Force

vim 插件

vim-im-select 插件为纯脚本,无需编译,pull 后 reload vim 配置即可。

验证

测试环境

  • 操作系统:Windows 10 和 Windows 11
  • 前台进程:gvim 9.2.0735、Windows Terminal、Total Commander
  • 外部工具:im-control —— 通过 SetWindowsHookEx 注入 hook DLL 到前台进程,在目标线程内调用 ITfCompartment::SetValue

测试结果

Windows 10 和 Windows 11 下均通过:

  • gvim:进入/离开 insert/command mode 切换英文,Shift 切中文后 ESC 回 normal mode 自动恢复英文
  • Windows Terminal / Total Commander:AppIME 进入窗口自动切英文,8 秒空闲后自动切回英文
  • 用户 Shift 键切换中英文、Ctrl+Space 启停 IME 均正常

相关链接

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

小狼毫未能正确处理WM_IME_CONTROL相关消息

4 participants