输入法的奇妙冒险: zsh 斗士
develop ime
书接上回。我们实现了一个 python 输入法。虽然很酷,但用处不大。 考虑到图形化用户界面下的输入法在第一篇文章中已经提过了。 我们来想一想在命令行场景下输入法会用在哪些地方:
- 在编辑器中打字
- 文件管理,比如把一个文件重命名为“学号+姓名+工程伦理大作业.pdf”
我们先解决第二个需求:
和之前一样,一共分为两个部分:
- 用 C 语言实现一个 zsh 模块。
- 基于此模块实现一个 zsh 插件,定义切换输入法状态的快捷键
模块
如果因为性能等因素,要自己写 zsh 模块来调用,也是比较方便的。Zsh 的源码中 Src/Modules 是模块目录,里边有一个实例模块 example(example.c 和 example.mdd 文件)。可以参考代码编写自己的模块,难度并不是很大。
“难度并不是很大”,信你个糟老头子……
和用 C 语言开发 python 模块不同,网上的教程近乎乏善可陈。甚至有些问题笔者得求助 zsh 邮件列表上的开发人员才得以解决。
熟悉构建系统的朋友都知道,构建目标的类型通常有:
- 可执行文件
- 动态链接库
- 静态链接库
- 模块
模块实际上是一种特殊的动态链接库。
一般脚本语言会为模块开发提供一个特殊的头文件:
- python:
Python.h
- nodejs:
node_api.h
- lua:
lauxlib.h
- zsh:
zsh.h
这些头文件暴露了一些 API 。模块不同于一般动态链接库的地方在于这些 API 的函数是没有被链接上其他动态链接库的“悬空”状态。即不可以直接被 dlopen()
。
构建模块需要支持构建二进制模块的适用于该脚本语言的构建系统。该构建系统通常会调用另一个支持构建二进制文件的 C/C++ 构建系统。譬如:
- python:
- setuptools: 使用 distutils, 纯 python 实现,非常简陋甚至笔者都不知道一些功能是否可行(类似构建单独的二进制可执行文件), python 3.11 及以前内置 distutils 。后来移除是为了鼓励更多更好的构建系统
- enscons: 使用 SCons, 纯 python 实现,性能不行。谷歌的 Chrome 最早用的 Makefile, 后来为了跨平台用过 SCons ,但后来性能太慢逼得 Evan Martin 牺牲自己的双休日写出了 ninja 。
- scikit-build-core: 使用 CMake 的 python 构建系统之一。 scikit-build 的下一代。其他的参见其他构建项目。性能可以(使用 ninja 做后端)。
- meson-python: 使用 meson 。 meson 官网的测试基准称性能比 CMake + ninja 快一点。
- nodejs:
- node-gyp: 使用 gyp, nodejs 内置。比较尬的是 gyp 是不仅依赖 ninja 或 make 而且还是使用 python 写的。你能想象一个 nodejs 的构建系统是依赖 python 的嘛?
- cmake.js: 使用 CMake 。
- lua:
- luarocks 内置
- luarocks make
- luarocks cmake
- luarocks-build-xmake: 使用 xmake 。一个 lua 和 C++ 实现的 C/C++ 构建系统
- zsh
- zsh 官方的一个基于 autotools 的构建系统。里面还掺了一大堆 awk 。
是的! zsh 目前只有 autotools 。关于 autotools 的性能语法有多懒笔者就不解释了啊。
先上代码: zsh-rime
真正的 C 代码在 rime.c 。 我们实现一个 rime 的内置命令(就像 cd 一样):
% rime
rime init [arguments...]
rime createSession [session_id]
rime destroySession $session_id
rime getCurrentSchema $session_id [schema_id]
rime getSchemaList [schema_list]
rime selectSchema $session_id $schema_id
rime processKey $session_id $keycode $mask
rime getContext $session_id [context_composition] [context_menu] [context_menu_candidates_text] [context_menu_candidates_comment]
rime getCommit $session_id [commit]
rime commitComposition $session_id
rime clearComposition $session_id
难点有 2 个:
- zsh 采用内存池算法,即 zsh 会提前 malloc 一个很大的内存空间,然后开发者开发 zsh 模块的时候使用 zalloc 和 zsfree 来进行内存管理,从而省去多次 malloc 一块小内存和一次 malloc 一块大内存的时间开销。 除此外所有调用 malloc 的函数都得用对应的版本,比如 ztrdup, ztrcmp 等。 别的语言虽然也有内存池算法,但相关 API 都被封装了,你甚至都不会用到显式的类似 zalloc 的函数。而 zsh 提供的 API 过于低级,随便一个错误操作就是 double free 或 invalid pointer 的 bug 等着你抓狂……
- zsh 不使用 Unicode 编码,有一 metaify 的操作对非 ASCII 编码的 char 进行转义。对于汉字输入法而言不转义的 ztrdup 是致命的。有一个 ztrdup_metafy 执行此操作。因为 zsh 的其他模块根本没有用到转义的功能。所以笔者遇到错误的字符编码 bug 时一头雾水没有任何可参考的代码。感谢 zsh 的邮件列表有人告诉了笔者这个知识。
- zsh 的内建命令的返回值
$?
只能是正整数。指望返回一个字符串是不可能的。通常是这样解决:命令 foo 通过foo bar
调用时创建一个变量$bar
储存非正整数的返回值。如果是通过foo
调用,那么一般是创建一个变量$REPLY
,例如:
% read
123
% echo $REPLY
123
其余就和开发一般脚本语言的模块的方法完全一致。
插件
zsh 的插件开发有一个标准:
除此之外我们需要:
- 当 zsh 模块不存在时自动构建
- 获取用户配置
- 为内置命令 rime 提供一个补全
- 在内置命令 rime 的基础上进一步封装一些函数,例如将按键的 ANSI escape code 转变为 rime 的键码和掩码
- 将用户的按键输入传递给 rime
自动构建
检测模块是否存在,不存在运行: ./configure && make
配置
zstyle -s context_name option_name option_value
然后利用 $option_value
配置该插件
补全
参考 zsh-completions 的文档。
ANSI escape code
termcap 和 terminfo 提供了所有按键和对应 ANSI escape code 的映射。 不同计算机上使用的映射不太一样。有的是 termcap ,有的是 terminfo 。
例如在 zsh 中,可以
zmodload zsh/termcap
print -l ${(kv)termcap} | cat -v
zmodload zsh/terminfo
print -l ${(kv)terminfo} | cat -v
一些按键的名字对应如下:
按键 | termcap | terminfo |
---|---|---|
Up | ku | kcuu1 |
Down | kd | kcud1 |
Left | kl | kcub1 |
Right | kr | kcuf1 |
Delete | kD | kdch1 |
Insert | kI | kich1 |
PageUp | kP | kpp |
PageDown | kN | knp |
Home | kh | khome |
End | @7 | kend |
F1 | k1 | kf1 |
… | … | … |
F10 | k; | kf10 |
F11 | F1 | kf11 |
… | … | … |
F20 | FA | kf20 |
… | … | … |
F63 | Fr | kf63 |
例如,如果想要知道向上键对应的 ANSI escape code ,可以:
% echo ${terminfo[kcuu1]} | cat -v
^[OA
% echo ${termcap[ku]} | cat -v
^[OA
可能会是 ^[[A
,取决于计算机。比如我的台式机就是。
这里的 ^[
就是 \x1b
ESC 。计算机所有的不可打印字符都通过该字符对应的 ASCII
码逻辑或 0x40 再取余来表示。所以 \x00-\x1f
就是 ^@-^_
。 \x7f
就是 ^?
。
不知道的请自觉翻阅 man 7 ascii
。
另一个方法就是使用现成的软件啦:
$ showkey -a
Press any keys - Ctrl-D will terminate this program
^[[A 27 0033 0x1b
91 0133 0x5b
65 0101 0x41
检测按键
一个最简单的例子:
rime-ime() {
self-insert() {
LBUFFER+="$KEYS"
zle -M ' hello'
}
zle -N self-insert
zle -A rime-ime save-rime-ime
zle -A accept-line rime-ime
bindkey -N rime main
zle recursive-edit -K rime
bindkey -D rime
zle -M ''
integer stat=$?
zle -A .self-insert self-insert
zle -A save-rime-ime rime-ime
zle -D save-rime-ime
unfunction self-insert
(( stat )) && zle send-break
return $stat
}
bindkey "^^" rime-ime
按下快捷键 Control + 6, 之后无论按下什么键,都会在光标下方显示 hello 。
我们需要做的,仅仅是模仿上一篇文章把 hello 替换成输入法的菜单,用输入法选中的汉字修改 $LBUFFER
。
集成
可以在 powerlevel10k 的提示符中显示 rime 的输入方案。
其他 shell
除了 zsh, 其他软件有可能实现这种命令行输入法吗?
shell:
- bash: 默认使用 readline, 一个类似 rl_custom_function 的项目是可行的
- ble.sh: 一个用 bash 实现的更好的行编辑器,不能用 C 语言实现内置命令,可能需要在后台有一个 daemon ?
- tclsh: 支持用 C 语言实现内置命令
- fish: 支持用 C 语言实现内置命令
终端分屏器:
- tmux: 类似 ble.sh
- zellij: tmux 的替代品,插件是 wasm 格式,也许可行
终端模拟器:
- fbterm: 一个利用 framebuffer 显示像素配合 fcitx5 输入法输入中文的终端模拟器
不管怎样,笔者选择 zsh 来验证命令行输入法可行性,考虑到此前从未有人提出过命令行输入法的概念,也算是笔者的贡献(以下省略 500 字自吹自擂)。