> TUX IM 是怎么从 5 个文件、132 行 Python 起步的。
本篇是 《TUX IM 开发日志:从零到 0.1》 系列第 1/8 篇。
第 1 篇 · 从一片空仓库到第一个"滴"字——为什么我要重写一个输入法
引子:为什么是输入法
2026 年春天,我在 Linux 桌面上打字时又一次被输入法卡住了。
事情很小:我按 Shift 想切英文,结果候选框不动,焦点丢了,拼音字母跑进了编辑器。我想骂人,但骂完还得继续用——因为没有别的选择。
fcitx5 是 Linux 桌面输入法的事实标准。它能用,但"能用"和"顺心"之间隔着一道很深的天堑。词库大、配置多、社区分裂——我每次想调点东西,都要在五六个 GUI 面板和七八个配置文件之间反复横跳,最后说服自己"凑合用吧"。
凑合用了五年。这一次我决定:不凑合了,自己写一个。
> 这不是"又一个输入法"的故事。这是一个程序员如何用最朴素的工具——一个 IBus 引擎、一份 RIME 词典、一堆 Python 代码——从零搭出一个能跑、能用、能发布的输入法引擎的故事。
整个过程 46 个 commit,从 aa4b094 到 1dfb63a,跨度约三个月,最终产出 v0.1.0-12 正式 deb 包。
输入法在 Linux 桌面是什么
在 Linux 上,输入法不是应用程序,而是一个独立的进程,通过 DBus 跟桌面环境通信。这套机制叫做 IBus(Intelligent Input Bus),是 GNOME 默认采用的输入法框架。KDE 也能用,只是 KDE 自己的 Plasma 桌面默认推 fcitx5。
IBus 架构里有两个角色:
- ibus-daemon:常驻进程,负责把按键事件分发给当前激活的输入法引擎。
- ibus-engine-xxx:具体的输入法引擎,接收按键、产生候选词、把候选词"上屏"(commit)到焦点应用。
听起来很轻量对吧?实际上 IBus 引擎崩溃 = 整个 daemon 死掉——你打的字会直接卡在键盘缓冲区,桌面所有需要输入法的窗口全部失声,必须重启 ibus-daemon 才能恢复。这条规则我们后面会反复遇到,它是整个项目里最重要的约束。

为什么选 IBus,不选 fcitx5
这是一个一开始就决定好的事。fcitx5 在国内社区很流行,但它的 C++ 代码库和插件机制对我来说太重了。我想要的只是一个干净的、能让我用 Python 写业务逻辑的引擎。
IBus 提供了官方的 Python 绑定(ibus-1.0),通过 GObject Introspection 暴露 API。这意味着:
- 引擎主循环用 GLib 跑,Python 写业务逻辑,胶水代码极少。
- 不需要管线程、异步、事件循环——GLib 帮你做了。
- 桌面环境兼容性好(GNOME、KDE、Sway 都能用)。
代价是 IBus 文档稀少,Python 绑定有几个 API 跟 C 不一致(IBus.Text.append 不存在这种事情后续会单独写一篇),但这些坑都能踩过去。
第一个 commit:能跑就行
项目起点是 aa4b094 Initial commit: TUX IM IBus engine。我打开 git log 把这个 commit 的内容拉出来看了一眼——132 行 Python,5 个文件:
tux_im/__init__.pytux_im/__main__.pytux_im/main.pytux_im/engine.pytux_im/shortcut.py
引擎、shortcut 解析器、主入口、模块引导——四件套。当时只能切中英文、显示 preedit,没有任何候选词功能。换句话说,它什么都不能干。但它能跑——这是我给自己定的第一个里程碑:先有一个能跑起来的骨架。

> 第一个里程碑:在 IBus 里注册一个引擎、能切换中英文、能显示 preedit 框——仅此而已。版本号都没有。
工具链:开发环境是怎么搭的
项目根目录的 AGENTS.md(给后续所有 AI 助手看的开发指南)里记录了完整的依赖:
系统依赖
sudo apt install ibus ibus-dev libibus-1.0-dev
python3-gi python3-ibus-1.0
python3-pip python3-venv
portaudio19-dev关键点:必须用系统 Python,不能用 venv 里的 Python。原因是 python3-gi(GObject Introspection 的 Python 绑定)只能装在系统 Python 上,venv 里装不上 gi。这个坑我们后面会单独拎出来讲——它在项目中期差点让我重装系统。
开发流程简单粗暴:
日常开发循环
ruff check . # lint
mypy --strict . # 类型检查
pytest tests/ # 测试
TUX_IM_DEBUG=1 ibus-engine-tux-im --ibus # 跑起来看效果三个静态检查 + 一个手动验证。顺序很重要:ruff → mypy → pytest,前一步没过就不要进下一步。这条规则到项目后期救了无数次命。
第一个候选词:commit 1475a52
从"什么都不能干"到"能拼音输入",中间隔了 9 个 commit。最关键的是 1475a52 Add pinyin 连打 and hide lookup table on commit。
连打是拼音输入的核心体验:用户输入 nihao,引擎要切成 ni hao,然后给出"你好"、"泥猴"等候选词;用户按数字键选第一个,引擎再把 hao 的候选词展开给用户选。
这个机制说起来简单,做起来是一整套流水线:
- 接收按键,追加到 buffer(
ni3hao3)。 - 拼音切分(
ni3+hao3)。 - 从词典里查每个音节的所有候选字。
- 组合成候选词列表,按词频排序。
- 送进 IBus lookup table 显示。

第一步的代码在 tux_im/engine.py 的 do_process_key_event,第二步在 tux_im/input/pinyin.py。它们之间的契约是一个简单的 KeyResult 数据类:
KeyResult 定义
@dataclass
class KeyResult:
handled: bool = False # 是否被引擎消费
commit: str | None = None # 上屏文本
clear: bool = False # 是否清空 buffer
这套契约贯穿整个项目——所有 InputMode 实现(拼音、五笔、混打、英文、emoji、谷歌拼音)都遵循同一套接口。这是我当时没有意识到、但事后证明最重要的一个设计决定:引擎层和输入层完全解耦。这套设计让我们后面加 ASR、加 emoji、加 Google 拼音时,几乎不需要动 engine.py。

第一道墙:IBus 崩溃 = 桌面崩溃
故事不能光讲顺利的。第二次让我想骂人的事情发生在 1475a52 之后:有一次我在测试连打,输入 nihao,候选词弹出来的那一刻,整个 IBus daemon 死了。
所有打开的终端、浏览器、IDE 全部失声。我 ibus restart 之后恢复。
日志里报的是 AttributeError,但 AttributeError 不应该让 daemon 死掉。后来翻 IBus 文档才发现:引擎进程异常退出时,ibus-daemon 默认会自杀以避免脏状态。换成代码描述:
错误做法(早期代码)
def do_process_key_event(self, keyval, state):
result = self.mode.feed_key(keyval, state) # 假设这里抛异常
self._update_lookup_table(...) # 这行永远到不了
return result.handled正确做法是把整个函数体包进 try/except——任何异常都不能逃出引擎进程边界。这条规则后来写进了 AGENTS.md,红色标注:
> NEVER let do_process_key_event raise — all exceptions must be caught at the boundary.

到 cddcb40 feat: crash isolation for all IBus lifecycle hooks 这个 commit,我花了 2 周时间把所有生命周期 hook(do_focus_in、do_focus_out、do_property_activate、do_process_key_event、do_reset)全部包了 try/except。这是一篇单独的故事——下一篇会展开讲。
第一个里程碑完成
到 1475a52 之后,我可以:
在 IBus 里注册并激活 TUX IM用拼音连打输入中文用数字键选候选词用 Shift 切英文焦点切换时自动提交残留 buffer崩溃不杀死 daemon(这是后面才补的)看起来很简陋对吧?但这就是 0.0.1。一个能跑、能拼音输入的输入法引擎。
本篇小结
- 动机:Linux 桌面输入法体验不满意,又不打算用 fcitx5。
- 技术选型:IBus + Python(ibus-1.0 绑定) + GLib 主循环。
- 第一个里程碑:能注册的引擎 + 拼音连打(commit
1475a52)。 - 最重要的设计决定:
InputMode协议 +KeyResult契约,让引擎层和输入层解耦。 - 第一个大教训:IBus 引擎崩溃 = 桌面崩溃,任何异常都不能逃出引擎进程。
> 下一篇:IBus 引擎的第一声惨叫——我是怎么把 daemon 写崩的。讲讲 cddcb40 之前那些差点让我放弃的崩溃、daemon 死循环、和按下 Shift 突然没反应的诡异 bug。
Code: https://github.com/tux-dot-fan/tux-im
Debian Package: https://github.com/tux-dot-fan/tux-im/releases/tag/v0.1.0-12
评论