> InputMode 协议让 emoji mode 和 Google Pinyin ctypes 绑定几乎零成本接入。

本篇是 《TUX IM 开发日志:从零到 0.1》 系列第 7/8 篇。
第 7 篇 · 从 Emoji 到 Google 拼音:InputMode 协议的胜利
第 7 篇 · 从 Emoji 到 Google 拼音:InputMode 协议的胜利
上一篇讲了 ASR,那是个相对独立的子系统。这篇讲一个更体现"扩展性"的故事——两种截然不同的输入模式怎么在零修改 engine.py 的前提下加进来。
Emoji 模式::cat → 🐱
commit cf03200 加了 EmojiMode。规则:用户输入冒号开头的 keyword(如 :cat、:smile),候选框弹出对应的 emoji,选 1 上屏 🐱。
看着简单,但有几个 tricky 的设计决策:
决策 1:emoji 是"模式"还是"触发器"

用户已经在拼音模式了。按 : 触发 emoji——这算切了模式?还是仍然在拼音模式,只是特殊规则?
我的选择:emoji 是一种独立的 input mode,通过 buffer 模式自动激活(buffer 以 : 开头时进入 emoji 逻辑)。
决策 2:emoji 的状态怎么判断
第一版我用 if not self.buffer 判断 emoji 是否激活,大错特错。
旧代码(有 bug)
def feed_key(self, keyval, state):
if not self.buffer:
# buffer 空('' 是 falsy)→ 启动 emoji 模式
if char == ':':
self.buffer = ':'
return KeyResult(handled=True)问题:self.buffer = ''(空字符串)是 falsy,导致每次按键都被当作"启动 emoji"。更糟的是,用户输入第二个 :,buffer 变 ::——这本来应该是退出 emoji 模式的语义,但代码进入"buffer 非空"分支,逻辑乱了。
修复(cf03200):加显式标志位 **_emoji_active**。
新代码
def feed_key(self, keyval, state):
if not self._emoji_active and char == ':':
self._emoji_active = True
self.buffer = ':'
return KeyResult(handled=True)
if self._emoji_active:
# emoji 模式下的处理
...> 这是 InputMode 协议最微妙的坑:不要用 falsy 空 buffer 判断状态。空 buffer 是合法状态(用户还没输入),不是"未激活"。永远用显式标志位。
决策 3:select 后要不要清 buffer?
用户输入 :cat,选 1(🐱)——然后呢?buffer 是空的,下次用户按 : 应该能重新进入 emoji 模式。
修复:select() 返回的 KeyResult 必须设 clear=True,让 engine 调 reset(),并且 reset 要把 **_emoji_active** 一起清掉。
emoji 词典
内置 ~280 个常用 emoji(无外部依赖)。双 trie 结构:
EmojiMode 数据结构
class EmojiMode:
def __init__(self):
self._keyword_to_emoji = {} # 'cat' -> '🐱'
self._emoji_to_keyword = {} # '🐱' -> 'cat'
# 也用 Trie 支持 prefix 查
self._trie = Trie()Google Pinyin:ctypes 绑 C 库
commit cfe6cdf + f738a1d 加了 Google Pinyin 整句输入——但 Google Pinyin 的核心是 C 库 libgooglepinyin。
为什么要 ctypes
- Google Pinyin 是 Apache 2.0 的 C 库,包含 65k+ 词词典、N-gram 语言模型、Viterbi beam search。
- 自己写整句解码器不现实——那是 Google 多年研究的成果。
- ctypes 是 Python 标准库,不需要额外编译包装。
绑定的 Python 接口
GooglePinyinDecoder 简化
class GooglePinyinDecoder:
def __init__(self):
self._lib = ctypes.CDLL("libgooglepinyin.so.0")
# 设置所有 C 函数的 restype / argtypes
self._lib.im_init.restype = ctypes.c_void_p
self._lib.im_search.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
# ...
def search(self, pinyin: str) -> None:
self._lib.im_search(self._handle, pinyin.encode('utf-8'))
def candidates(self) -> list[str]:
n = self._lib.im_get_candidate_num(self._handle)
return [self._lib.im_get_candidate(self._handle, i).decode('utf-8')
for i in range(n)]
def choose(self, index: int) -> None:
self._lib.im_choose(self._handle, index)几个踩出来的坑
- im_search() 永远会重置锁定状态(即使 fixed_len > 0)——这是 Google Pinyin 的设计,必须每次 choose 后再调 search。
- cand_text() 返回值会变——必须先保存候选文本,再调 choose(),否则拿到的会是新的剩余句子的第一个候选。
- im_get_spl_start_pos() 边界是相对剩余 pinyin 字符串,不是绝对 buffer 位置。
> 这些坑全靠f738a1d的 commit message 总结:"Verified behaviors (empirically determined)"——意思是我们跑实验发现的,不是看 Google 文档。Google Pinyin 的官方文档几乎为零。
为什么 InputMode 协议让这一切几乎零成本
看 google_pinyin_mode.py 的 class 声明:
GooglePinyinMode 协议实现
class GooglePinyinMode:
name = "pinyin" # 跟普通 PinyinMode 同名(注册 key 不同)
buffer: str
cursor: int
def feed_key(self, keyval, state) -> KeyResult | None:
# 字母 / tone digit / 标点
...
def candidates(self, limit=9) -> list[Candidate]:
# 从 GooglePinyinDecoder 拿候选,包装成 Candidate
...
def select(self, index) -> KeyResult:
# 调 decoder.choose(index),返回 commit + clear
...
def commit(self) -> str | None:
# focus-out fallback,锁定 top candidate
...
def page(self, direction) -> KeyResult:
# 翻页(候选超过 9 个时)
...
def full_sentence(self) -> str | None:
# Google Pinyin 特有:返回整句解码结果
return self._decoder.full_sentence()engine.py 一行没改。所有"按数字键选候选"、"翻页"、"焦点切换时 commit"的行为,引擎都按统一协议处理。
> 这就是 ae1084b 那个挪位 bug 逼出来的 InputMode 协议的胜利时刻——3 个 commit 之后(cddcb40 加测试,cf03200 加 emoji,f738a1d 加 Google Pinyin),每次新增 mode 都没动过 engine.py 核心逻辑。
未来还能加什么 mode
按协议实现 6 个方法,就能加新 mode。可能的扩展:
- 手写输入(用 ML 模型识别笔迹)——把候选生成换成模型输出。
- 双拼(小鹤、微软双拼方案)——修改
segment()即可。 - 日文/韩文输入法——同样的协议,不同的词典。
- 代码补全(用 LSP)——直接接 IDE 的自动补全 API。
只要遵守协议,engine.py 一行不改。
本篇小结
- 显式状态标志位,不要用 falsy 空 buffer 判断。
- ctypes 绑 C 库是务实的扩展手段,但要小心"返回值会变"这种 C API 怪事。
- InputMode 协议是架构的胜利——3 个 mode(emoji + Google Pinyin + ASR)零成本加入。
- 未来扩展的边界是 6 个方法实现,不是改 engine.py。
> 下一篇:发布 0.1.0:mypy strict、CI、deb 打包。从代码完工到能 sudo apt install ./tux-im.deb 之间,那些看不见的工程化工作。
评论