1. 终端 UI 的本质
终端不是画布,而是一台状态机打印机。你只能向它写字节流,它按内置规则解释这些字节:可打印字符推进光标并覆盖当前单元格,以 ESC 开头的转义序列则控制光标位置、颜色、清屏等。没有「重绘」概念,没有布局引擎,没有事件对象——一切都得自己实现。
一个 TUI 程序由三层构成:
- 终端控制层:把终端切到 raw 模式,收发 ANSI 序列,处理窗口尺寸变化。
- 渲染层:维护一个字符单元格网格,做差分刷新,只把变化的部分写出去。
- 交互层:解析按键(含转义序列)、鼠标事件,驱动事件循环。
Zig 在这里的优势是:std.posix.termios 直接暴露 termios 结构,std.io 的泛型 writer 让渲染输出可以零成本地指向任意 fd,且没有 GC 导致的停顿——TUI 对延迟敏感,GC 停顿会直接表现为画面卡顿。CLI 程序的基本骨架可先看 /zig-cli-application/。
2. 终端控制
2.1 raw 模式
默认的规范模式(canonical mode)下,终端会缓冲整行、回显输入、把 Ctrl+C 解释成 SIGINT。TUI 需要逐字节即时输入,必须切到 raw 模式:
const std = @import("std");
const posix = std.posix;
const RawGuard = struct {
fd: posix.fd_t,
orig: posix.termios,
pub fn enable(fd: posix.fd_t) !RawGuard {
const orig = try posix.tcgetattr(fd);
var raw = orig;
// 关闭:字符级输入、回显、信号生成、输出处理
raw.lflag.ICANON = false;
raw.lflag.ECHO = false;
raw.lflag.ISIG = false;
raw.lflag.IEXTEN = false;
// 关闭输入处理:CR→NL、流控
raw.iflag.ICRNL = false;
raw.iflag.IXON = false;
raw.iflag.BRKINT = false;
raw.iflag.INPCK = false;
raw.iflag.ISTRIP = false;
// 关闭输出处理
raw.oflag.OPOST = false;
// 每次读返回至少 1 字节,读超时 100ms
raw.cc[@intFromEnum(posix.V.MIN)] = 1;
raw.cc[@intFromEnum(posix.V.TIME)] = 1;
try posix.tcsetattr(fd, .NOW, raw);
return .{ .fd = fd, .orig = orig };
}
pub fn restore(self: RawGuard) void {
posix.tcsetattr(self.fd, .NOW, self.orig) catch {};
}
};
用 defer guard.restore() 保证任何退出路径都恢复终端。若程序崩溃而未恢复,用户终端会停留在 raw 模式——这是 TUI 开发最常见的自伤。加固手段是同时注册 SIGINT/SIGTERM 处理与 atexit。
2.2 ANSI 转义序列
最常用的几组:
| 序列 | 作用 |
|---|---|
ESC[2J | 清屏 |
ESC[H | 光标归位到 (1,1) |
ESC[{row};{col}H | 光标移动到指定行列(1-based) |
ESC[{n}A / B / C / D | 上/下/右/左移动 n 格 |
ESC[?25l / ESC[?25h | 隐藏/显示光标 |
ESC[?1049h / ESC[?1049l | 进入/退出备用屏幕 |
ESC[{n}m | 设置 SGR 属性(颜色、粗体等) |
ESC[0m | 重置所有属性 |
ESC[?1000h / ESC[?1006h | 开启鼠标跟踪(SGR 扩展模式) |
用 Zig 组合这些序列:
const Seq = struct {
pub const clear = "\x1b[2J";
pub const home = "\x1b[H";
pub const hide_cursor = "\x1b[?25l";
pub const show_cursor = "\x1b[?25h";
pub const alt_on = "\x1b[?1049h";
pub const alt_off = "\x1b[?1049l";
pub const reset = "\x1b[0m";
pub fn moveTo(w: anytype, row: u32, col: u32) !void {
try w.print("\x1b[{d};{d}H", .{ row + 1, col + 1 });
}
pub fn fg(w: anytype, r: u8, g: u8, b: u8) !void {
try w.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b });
}
pub fn bg(w: anytype, r: u8, g: u8, b: u8) !void {
try w.print("\x1b[48;2;{d};{d};{d}m", .{ r, g, b });
}
};
38;2;r;g;b 是真彩色(24-bit)语法,需终端支持;退路是 38;5;n 的 256 色索引。
2.3 备用屏幕缓冲区
TUI 应该跑在**备用屏幕(alternate screen)**上,退出后原终端内容完好如初。进入备用屏幕、隐藏光标、清屏三步的顺序不能乱——先隐藏光标再清屏可以避免闪烁,退出时则要逆序还原:
pub fn run(fd: posix.fd_t) !void {
const guard = try RawGuard.enable(fd);
defer guard.restore();
var buf: [4096]u8 = undefined;
var stdout = std.fs.File{ .handle = fd }.writer(&buf);
const w = &stdout.interface;
try w.writeAll(Seq.alt_on ++ Seq.hide_cursor ++ Seq.clear);
defer {
w.writeAll(Seq.reset ++ Seq.show_cursor ++ Seq.alt_off) catch {};
w.flush() catch {};
}
try eventLoop(w);
}
2.4 终端能力协商
不是所有终端都支持真彩色、鼠标、备用屏幕。检测顺序是:先读 TERM/COLORTERM/TERM_PROGRAM 环境变量,再查 terminfo 数据库,最后用 DA1 查询 ESC[c 做运行时探测。实践中最常用的判据只有两条:
pub fn detectCaps(alloc: std.mem.Allocator) struct { truecolor: bool, kitty: bool } {
const colorterm = std.process.getEnvVarOwned(alloc, "COLORTERM") catch "";
defer if (colorterm.len > 0) alloc.free(colorterm);
const term = std.process.getEnvVarOwned(alloc, "TERM") catch "";
defer if (term.len > 0) alloc.free(term);
return .{
.truecolor = std.mem.eql(u8, colorterm, "truecolor") or
std.mem.eql(u8, colorterm, "24bit"),
.kitty = std.mem.startsWith(u8, term, "xterm-kitty"),
};
}
真彩色不可用时降级到 256 色,再不行降到 16 色——用色彩量化把 RGB 映射到最接近的调色板索引。
3. 渲染:从全屏重绘到差分刷新
3.1 单元格网格
渲染的基础数据结构是一个二维单元格数组:
const Cell = struct {
ch: [4]u8 = " ".*, // UTF-8,最长 4 字节
len: u8 = 1, // 实际字节数
fg: u32 = 0xFFFFFF,
bg: u32 = 0x000000,
attr: u8 = 0, // 0x1 粗体, 0x2 斜体, 0x4 下划线
};
const Screen = struct {
front: []Cell, // 上一帧
back: []Cell, // 当前帧
cols: u32,
rows: u32,
pub fn resize(self: *Screen, alloc: std.mem.Allocator, cols: u32, rows: u32) !void {
const n = cols * rows;
self.front = try alloc.realloc(self.front, n);
self.back = try alloc.realloc(self.back, n);
self.cols = cols;
self.rows = rows;
}
};
front 与 back 各是一块 cols * rows 的连续内存,用一维数组模拟二维——缓存局部性远好于 [][]Cell,且 resize 只需一次 realloc。
3.2 差分刷新
全屏重绘在 80×24 上要输出约 2 KB,60 FPS 就是 120 KB/s,SSH 场景下延迟明显。差分刷新只输出变化的单元格:
pub fn flush(self: *Screen, w: anytype) !void {
var cur_row: u32 = std.math.maxInt(u32);
var cur_col: u32 = std.math.maxInt(u32);
var cur_fg: u32 = 0;
var cur_bg: u32 = 0;
for (0..self.rows) |r| {
for (0..self.cols) |c| {
const i = r * self.cols + c;
const b = self.back[i];
const f = self.front[i];
if (cellEqual(b, f)) continue;
// 光标位置连续时省略定位序列
if (r != cur_row or c != cur_col) {
try Seq.moveTo(w, @intCast(r), @intCast(c));
}
if (b.fg != cur_fg or b.bg != cur_bg) {
try Seq.fg(w, @truncate(b.fg >> 16), @truncate(b.fg >> 8), @truncate(b.fg));
try Seq.bg(w, @truncate(b.bg >> 16), @truncate(b.bg >> 8), @truncate(b.bg));
cur_fg = b.fg;
cur_bg = b.bg;
}
try w.writeAll(b.ch[0..b.len]);
cur_col = @intCast(c + 1);
cur_row = @intCast(r);
self.front[i] = b;
}
}
try w.writeAll(Seq.reset);
try w.flush();
}
关键优化点:
- 跳过未变化单元格:静止画面几乎零输出。
- 省略冗余光标定位:光标已在目标位置时不再发
ESC[H。 - 省略冗余颜色切换:只在颜色真正变化时发 SGR 序列。
- 双缓冲交换:
front与back交换而非拷贝,O(1)。
3.3 Unicode 宽度
这是 TUI 最容易被低估的部分。终端里一个「字符」占几列,取决于码点:
| 类别 | 宽度 | 例子 |
|---|---|---|
| ASCII / 拉丁字母 | 1 | a、é |
| CJK 汉字、日文假名 | 2 | 中、あ |
| 全角符号 | 2 | !、: |
| 组合记号(Combining) | 0 | 声调符号、变音符 |
| Emoji | 1~2(不定) | 😀 |
Zig 标准库没有宽度表,需要自己维护区间。一个可用的近似实现:
const zero_width = [_][2]u21{ .{ 0x0300, 0x036F }, .{ 0x1AB0, 0x1AFF } };
const double_width = [_][2]u21{
.{ 0x1100, 0x115F }, .{ 0x2E80, 0xA4CF }, .{ 0xAC00, 0xD7A3 },
.{ 0xF900, 0xFAFF }, .{ 0xFF00, 0xFF60 }, .{ 0xFFE0, 0xFFE6 },
.{ 0x20000, 0x3FFFD },
};
pub fn runeWidth(cp: u21) u8 {
for (zero_width) |r| if (cp >= r[0] and cp <= r[1]) return 0;
for (double_width) |r| if (cp >= r[0] and cp <= r[1]) return 2;
return 1;
}
宽字符占两格,写入时必须同时把下一格标记为「续格」(内容留空,渲染时跳过),否则后续内容会错位:
pub fn putRune(self: *Screen, row: u32, col: u32, cp: u21, fg: u32, bg: u32) u32 {
const w = runeWidth(cp);
const i = row * self.cols + col;
var len = std.unicode.utf8Encode(cp, &self.back[i].ch) catch 1;
self.back[i].len = @intCast(len);
self.back[i].fg = fg;
self.back[i].bg = bg;
if (w == 2 and col + 1 < self.cols) {
self.back[i + 1] = .{ .ch = " ".*, .len = 0, .fg = fg, .bg = bg };
}
return w;
}
.len = 0 是续格标记:渲染时跳过不输出,但参与差分比较。终端文本处理的一般方法(分词、编码转换、正则)见 /zig-text-processing/。
4. 布局
4.1 约束求解
TUI 布局比 GUI 简单得多,一个「固定 + 弹性」的两类约束模型就够用:
const Constraint = union(enum) {
fixed: u32, // 固定 n 列/行
flex: u32, // 权重 n 的弹性空间
percent: u8, // 百分比
min: u32, // 至少 n
};
pub fn solve(avail: u32, cons: []const Constraint) []u32 {
var sizes: [32]u32 = undefined;
var used: u32 = 0;
var total_flex: u32 = 0;
for (cons, 0..) |c, i| {
switch (c) {
.fixed => |n| { sizes[i] = n; used += n; },
.percent => |p| { sizes[i] = avail * p / 100; used += sizes[i]; },
.flex => |w| { total_flex += w; sizes[i] = 0; },
.min => |n| { sizes[i] = n; used += n; },
}
}
// 剩余空间按权重分配
if (total_flex > 0 and avail > used) {
const remain = avail - used;
for (cons, 0..) |c, i| {
if (c == .flex) sizes[i] = remain * c.flex / total_flex;
}
}
return sizes[0..cons.len];
}
两遍扫描:第一遍扣掉固定尺寸,第二遍把剩余按权重分配。这是 Flexbox 的最小可用版本。
4.2 常用组件
| 组件 | 关键点 |
|---|---|
| 边框 | 用 ┌─┐│└┘ 或 ASCII 回退 +-+||-+ |
| 列表 | 维护 selected 与 offset,滚动时只调 offset 不重建 |
| 表格 | 列宽 = max(表头宽, 各单元格宽),注意宽字符 |
| 进度条 | █ 填充 + ░ 空白,用 \r 原地刷新 |
| 输入框 | 需要处理光标位置、左右键、退格、粘贴(括号粘贴模式) |
进度条的原地刷新用回车符而非清屏:\r 回到行首后重写整行,填充块数按 width * pct / 100 计算。相比全屏重绘,这种方式在 SSH 上几乎没有可感知的延迟。
5. 交互:解析输入
5.1 转义序列的歧义
按键读进来是一串字节。问题在于 ESC 本身既是「Escape 键」又是转义序列的开头。用户按 Escape 时只发一个 0x1B,之后不会再跟字节;而按方向键发的是 ESC [ A。区分办法是超时:读到 ESC 后等一小段时间(通常 20~50ms),没等到后续字节就判定为 Escape 键。
const Key = union(enum) {
char: u21,
ctrl: u8, // Ctrl+A = 0x01
up, down, left, right,
home, end, page_up, page_down, delete,
enter, tab, backspace, escape,
f: u8, // F1~F12
unknown: []const u8,
};
pub fn parseKey(buf: []const u8) Key {
if (buf.len == 0) return .escape;
if (buf[0] != 0x1b) {
if (buf[0] < 0x20) return .{ .ctrl = buf[0] };
const cp = std.unicode.utf8Decode(buf) catch return .{ .unknown = buf };
return .{ .char = cp };
}
// 转义序列
if (buf.len >= 3 and buf[1] == '[') {
return switch (buf[2]) {
'A' => .up,
'B' => .down,
'C' => .right,
'D' => .left,
'H' => .home,
'F' => .end,
'3' => .delete, // ESC[3~
'5' => .page_up,
'6' => .page_down,
else => .{ .unknown = buf },
};
}
if (buf.len == 1) return .escape;
return .{ .unknown = buf };
}
F1F4 是 ESC O PESC O S(SS3 形式),F5 以上又是 ESC [ 15 ~ 这类 CSI 形式——各家终端不完全一致,完整实现要查 terminfo。
5.2 鼠标事件
开启 SGR 扩展鼠标模式(ESC[?1006h)后,鼠标事件以 ESC [ < b ; x ; y M(按下/移动)或 ... m(释放)的形式到达,坐标是 1-based。解析要点:
b的低 2 位是按键:0 左键、1 中键、2 右键。b >= 32表示移动事件;b >= 64表示滚轮,b - 64 == 0是上滚,否则下滚。- 终止字节
M是按下、m是释放,两者不可混用。 - 三个数值用
;分隔,解析后坐标要各减 1 转成 0-based。
把这三个数字与终止字节映射成一个 MouseEvent 联合体即可,事件循环里再按 (x, y) 命中测试定位到具体组件。
5.3 事件循环
用 poll 把终端 fd、信号 fd、定时器统一到一个循环里:
pub fn eventLoop(w: anytype) !void {
const stdin = std.fs.File{ .handle = posix.STDIN_FILENO };
var pollfds = [_]posix.pollfd{.{
.fd = posix.STDIN_FILENO,
.events = posix.POLL.IN,
.revents = 0,
}};
var buf: [64]u8 = undefined;
var frame_deadline: i64 = std.time.milliTimestamp() + 16; // ~60 FPS
while (true) {
const now = std.time.milliTimestamp();
const timeout: i32 = @intCast(@max(0, frame_deadline - now));
_ = try posix.poll(&pollfds, timeout);
if (pollfds[0].revents & posix.POLL.IN != 0) {
const n = try stdin.read(&buf);
if (n == 0) break;
const key = parseKey(buf[0..n]);
if (key == .ctrl and key.ctrl == 0x11) break; // Ctrl+Q
try handleKey(key);
}
if (std.time.milliTimestamp() >= frame_deadline) {
try render(w);
frame_deadline = std.time.milliTimestamp() + 16;
}
}
}
只在有输入或有动画时重绘是关键——空闲时 poll 会一直超时,CPU 占用接近 0。这比「无条件 60 FPS 重绘」省电得多。
6. 窗口尺寸变化
用户拖动终端窗口时,内核会向前台进程组发 SIGWINCH。必须处理它,否则渲染会错位:
var winch_flag = std.atomic.Value(bool).init(false);
fn onWinch(_: c_int) callconv(.c) void {
winch_flag.store(true, .release); // 信号处理函数里只允许异步信号安全操作
}
pub fn installWinch() void {
const sa = posix.Sigaction{
.handler = .{ .handler = onWinch },
.mask = posix.empty_sigset,
.flags = 0,
};
posix.sigaction(posix.SIG.WINCH, &sa, null);
}
pub fn querySize(fd: posix.fd_t) !struct { cols: u16, rows: u16 } {
var ws: posix.winsize = undefined;
if (@as(isize, @bitCast(std.os.linux.ioctl(fd, posix.T.IOCGWINSZ, @intFromPtr(&ws)))) < 0)
return error.IoctlFailed;
return .{ .cols = ws.col, .rows = ws.row };
}
真正的 resize 在主循环里做:每轮检查 winch_flag,为真时重新 querySize 并重建网格。SIGWINCH 的默认动作是忽略,所以不注册处理函数不会崩溃,只会渲染错位——这类 bug 在开发机上很难复现,因为很少有人拖窗口。
7. 测试与分发
TUI 的可测试性取决于渲染层与终端层的解耦。把 flush 的输出写进一个 ArrayList 而不是 fd,就能在单元测试里断言输出字节——这正是 flush 签名用 anytype 而非 File.Writer 的原因。测试「差分刷新只输出变化单元格」只需三步:写入一段文本并 flush,记录输出长度;再次 flush 且不改变内容;断言两次长度相等。
分发方面,TUI 程序通常是无依赖的静态二进制,一条 curl | tar 就能装好。真正的坑在终端能力差异:CI 里跑的是哑终端(TERM=dumb),必须能降级到纯文本模式而非输出乱码。
小结
终端 UI 没有框架托底,每个细节都要自己实现。用 Zig 写 TUI 的要点:
- raw 模式必须用
defer保证恢复,并处理崩溃路径。 - 渲染用双缓冲 + 差分刷新,静止画面零输出。
- Unicode 宽度是正确性的核心,宽字符必须占两格并标记续格。
- 布局用「固定 + 弹性」两遍扫描即可覆盖绝大多数界面。
ESC的歧义靠超时消解,别指望单字节判定。SIGWINCH里只设标志,重建网格放到主循环。- 把渲染输出解耦成 writer,单元测试才可写。
终端是 Zig 最能发挥的舞台之一:无运行时、无 GC 停顿、对 fd 与字节流的完全控制。若想看看更成熟的终端交互形态(进度条、表格、颜色降级)的工程细节,可以参考 终端进度条与 ANSI 渲染 ;而 文本界面与 TUI 设计 则从信息密度与键盘优先的角度讨论了交互设计。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。