Skip to the content.

mini-vim:三个终端绘制后端

一个用 C 实现的 Vim 风格教学编辑器。同一份编辑逻辑可以切换三种绘制后端:

三者仍然通过 PTY 把字节交给终端模拟器,没有 GUI 库或 Vim 插件。 共同使用 termios 控制输入方式、read() 接收字节、 ioctl(TIOCGWINSZ) 查询尺寸、SIGWINCH 通知重绘。

已有的 vim.md 保留为背景说明,程序实现在 mini-vim.c。

编译和运行

cd /home/martins3/data/vn/docs/kernel/tty/vim
make
./mini-vim.out example.txt
./mini-vim.out --backend terminfo example.txt
./mini-vim.out --backend curses example.txt

默认后端是 ansi,也可以显式传 --backend ansi。状态栏显示当前后端。 构建需要 C 编译器、pkg-config、ncursesw 和 tinfo 开发库。 本机默认 cc 来自 Nix,但 pkg-config 找到的是系统库;在这里使用:

make CC=/usr/bin/cc

若只想构建不依赖这两个库的原始实验,可以运行:

make mini-vim-ansi.out
./mini-vim-ansi.out example.txt

在 Kitty、Ghostty、WezTerm 等支持 xterm 风格控制序列的终端中运行。 不需要 root,不创建额外 PTY,直接使用启动它的终端。 terminfo 和 curses 依赖正确的 $TERM 及对应数据库条目。 数据库缺失或 TERM=dumb 时会报错并退出,不会悄悄回退到 ANSI。

可以先在示例里编辑,再用 :w /tmp/my-tty-demo.txt 保存副本,避免覆盖示例。 不传文件名则打开空缓冲区,之后也可以用 :w filename 保存。

实验零:这两个库分别优化了什么?

terminfo 是终端能力数据库及其访问接口,这里由 libtinfo 提供; curses 是更高层的终端界面 API,这里选择支持宽字符的 ncursesw 实现。 它们不是两个互相排斥的技术:ncurses 本身也会使用 terminfo。

责任 ansi terminfo curses / ncursesw
文档、光标位置、模式、滚动与布局 共用 mini-vim.c 相同 相同
控制序列从哪里来 代码中的字符串 setupterm() / tigetstr() 查 $TERM ncurses 内部查终端能力
移动光标 拼 ESC[行;列H tparm(cup, ...) / tputs() wmove()
清行、样式、文字 手写序列并追加字节 查 el / sgr0 / setaf 等能力 wclrtoeol() / wattrset() / waddnstr()
哪些内容真的要发送 应用发送完整可见区域 应用发送完整可见区域 wnoutrefresh() + doupdate() 比较屏幕状态
应用是否维护输入解析与 raw 模式 是 是 是,本实验仅替换绘制后端

运行三个版本,分别按一次 l,再按没有绑定操作的 ?。画面应该基本一样, 但查看输出可以发现差别。下面是本机 TERM=xterm-256color、24×80、 打开 example.txt 时从 PTY 捕获的字节数,不是函数调用次数或 CPU 耗时:

后端 启动及首帧 按一次 l 随后按一次 ?
ansi 1080 1064 1064
terminfo 1173 1148 1148
curses 961 30 0

这里的 terminfo 序列含有额外的属性复位等操作,输出反而稍多。 terminfo 改善的是终端适配,不会自动减少重绘量。 curses 在移动光标时只更新必要的光标和状态栏内容;没有视觉变化时可以完全不输出。 这不意味着 curses 的 CPU 成本为零:公共布局仍然遍历可见区域,库也需要比较屏幕。 具体字节数会随窗口大小、终端条目和 ncurses 版本变化。

可重复运行同一组操作,同时检查三个后端的终端文本画面和保存结果:

make CC=/usr/bin/cc check

测试额外需要支持 jobstart(..., {term=true}) 的 Neovim(本机为 0.12)。 test-backends.lua 使用 Neovim 的终端解析器和真实 PTY,覆盖中文分段输入、 控制字节可视化、粘贴、长行和长命令滚动、保存、极小窗口、Ctrl-C 和异常 $TERM。

对代码复杂度的影响

backend.h 中 struct backend 只抽出绘制需要的操作,draw_screen() 仍负责“这一行应该显示什么”。编辑器不再直接拼光标、颜色和清行序列。

新增两个后端和测试后,总代码当然变多了。优化体现在职责的转移: terminfo 接管终端能力适配,curses 进一步接管屏幕缓存和差量输出, 不是简单地“引入库就能让这个小程序总行数更少”。

本次刻意保留公共 read_key() 和 raw 模式教学路径。 完整 curses 应用还可以使用 get_wch()、keypad() 等接管输入;这里没有做, 因此不能把本实验当成任意历史终端的完整兼容层。 方向键解析和 bracketed paste 仍采用原来的 xterm 风格协议。 curses 光标使用 curs_set(2/1) 切换高可见/普通可见,不能保证 INSERT 模式一定是竖线; terminfo 只有条目提供扩展能力 Ss 时才切换形状,ANSI 后端保留原来的显式形状。

操作

按键 操作
i / a 在光标处 / 当前字符后进入 INSERT
o 在当前行下方插入新行,进入 INSERT
Esc 回到 NORMAL
h j k l / 方向键 左、下、上、右;方向键也可在 INSERT 使用
0 / $ 行首 / 行末;Home、End 也可以使用
gg / G 文件开头 / 最后一行
x / Delete 删除光标处字符,不合并行
dd 删除当前行
INSERT 中 Backspace 删除前一个字符;在行首时合并行
INSERT 中 Enter / Tab 插入换行 / 制表符
:w / :w filename 保存 / 保存到指定路径
:wq 保存成功后退出
:q / :q! 退出 / 放弃未保存修改并退出
:help 在消息栏显示按键提示
Ctrl-C 收到 0x03 字节,取消当前模式并显示说明

支持基本 UTF-8 输入、中文移动和删除;终端的 bracketed paste 会被作为文本插入, 不会把粘贴内容中的 :q! 当成编辑器命令。

实验一:关掉 canonical 和 echo,谁来编辑?

启动后看状态栏(终端足够宽时):

NORMAL [-] | ansi | 1:1 | 24x80 | winch=0 | ICANON=0 ECHO=0 ISIG=0
  1. 按 i,逐个输入 abc。每个按键立即触发重绘,不需要等回车。
  2. 按 Backspace,再输入 d,得到 abd。
  3. 按 Enter,编辑器自己往文档中插入 \n。
  4. 按 Ctrl-C,程序继续运行,并说明自己收到了 0x03。

对应 terminal_start():

此时的退格来自 previous_char() 和 delete_range(),而不是内核 n_tty.c 中的 eraser()。

实验二:亲眼看见 Vim 风格界面的“绘制命令”

下面的序列表格以默认 ANSI 后端为例。切换 terminfo 后,日志中的序列来自数据库。

终端 A(日志路径需要尚不存在;重复实验时换一个名字):

./mini-vim.out --trace /tmp/mini-vim-demo.trace example.txt

终端 B:

tail -f /tmp/mini-vim-demo.trace

回到 A 输入、移动光标、切换模式。B 中会显示类似:

RAW: ICANON=0 ECHO=0 ISIG=0 OPOST=0 VMIN=1 VTIME=0
TX paste on 8 bytes: <ESC>[?2004h
START -> TIOCGWINSZ: rows=24 cols=80 (drawing 24x80)
RX 1 bytes: i
RX 1 bytes: a
RX 1 bytes: \x7f

TX frame 后面是程序实际输出的一整帧。日志用 <ESC> 表示单个 0x1b 字节,用 \xNN 表示非 ASCII 字节;这些记号本身没有发送给终端。 UTF-8 字符会出现多个输入字节。

输出序列 终端执行的操作 代码入口
ESC[?1049h / ESC[?1049l 进入 / 退出备用屏幕,退出后恢复原 shell 画面 backend-ansi.c 中 start() / stop()
ESC[行;列H 移动光标,行列从 1 开始 backend-ansi.c 中 position()
ESC[K 清除光标到行末的旧内容 erase_line()
ESC[36m / ESC[0m 设置青色 / 恢复样式 style()
ESC[7m 反色,绘制状态栏 style()
ESC[?25l / ESC[?25h 绘制期间隐藏光标 / 显示光标 begin() / present()
ESC[2 q / ESC[6 q 方块光标 / 竖线光标,留意数字后的空格 present()
ESC[?2004h / ESC[?2004l 开启 / 关闭 bracketed paste 报告 启动 / 恢复终端

方向键也是协议。按向上键,通常会收到 ESC [ A,程序在 read_key() 里把它识别为 KEY_UP。单独的 Esc 和这些序列共享前缀,因此这里使用 50 ms 的等待区分;这也是小型演示实现的一个取舍。

日志含编辑内容和输入字节,创建权限为 0600。

curses 后端的 --trace 记录公共输入、尺寸和 CURSES frame 事件, 不会把逻辑绘制事件冒充实际 TX 字节,因为输出由 ncurses 内部完成。 要看真实输出,可以换一个未占用的日志路径运行:

strace -o /tmp/mini-vim-curses-syscalls.log -s 4096 -e trace=write,writev \
    ./mini-vim.out --backend curses example.txt

实验三:拖动窗口,观察 SIGWINCH

拖动终端 A 的窗口尺寸,观察:

路径为:

终端模拟器设置 PTY 尺寸:ioctl(master, TIOCSWINSZ, ...)
    → 内核向前台进程组发 SIGWINCH
    → signal_handler() 只设置 resized 标志
    → 主循环调用 update_size() 查询新尺寸
    → draw_screen() 输出新的控制序列和文字
    → 终端模拟器解析并绘制

SIGWINCH 不携带尺寸。多个普通信号可能合并,计数表示应用处理了几次通知, 不是鼠标移动次数。过小的窗口会提示放大;恢复后可以继续编辑。

实验四:从系统调用看同一件事

strace -o /tmp/mini-vim-syscalls.log -s 256 \
    -e trace=read,write,ioctl,poll,rt_sigaction,rt_sigreturn \
    ./mini-vim.out example.txt

编辑并退出后查看日志,可找到:

阅读代码的顺序

先读 main(),再沿下面两条路径看:

输入:terminal_start → read_byte → read_key → handle_key → 修改文档
输出:draw_screen → cursor_to / draw_text → struct backend
      ansi / terminfo → backend_emit → backend_flush → write_all → PTY
      curses → ncurses 虚拟窗口 → wnoutrefresh / doupdate → PTY
      PTY → 终端模拟器

trace_bytes() 只是旁路记录,不参与协议转换。draw_text() 把文件中的 ESC、NUL 等控制字节显示为 ^[、^@,避免文件内容被终端当成绘制命令。

范围与取舍

协议背景可查 Vim terminal 文档、 XTerm 控制序列 和 TIOCGWINSZ/TIOCSWINSZ。 库接口可查 terminfo 低层 API 与 curses 屏幕刷新。

本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。