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

Python
    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**。

新代码

Python
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 数据结构

Python
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 简化

Python
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 协议实现

Python
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 之间,那些看不见的工程化工作。

最后修改: 2026年7月1日

作者

评论

发表评论

您的邮箱地址不会被公开。