Zig 终端 TUI 开发:终端控制、布局与交互

终端 UI 没有 DOM、没有绘制 API,只有一串字节流。本文用 Zig 从 termios raw 模式与 ANSI 转义序列讲起,实现差分刷新、Unicode 宽度计算、约束式布局、按键与鼠标解析,并处理 SIGWINCH 与终端能力协商。

1. 终端 UI 的本质

终端不是画布,而是一台状态机打印机。你只能向它写字节流,它按内置规则解释这些字节:可打印字符推进光标并覆盖当前单元格,以 ESC 开头的转义序列则控制光标位置、颜色、清屏等。没有「重绘」概念,没有布局引擎,没有事件对象——一切都得自己实现。

一个 TUI 程序由三层构成:

  1. 终端控制层:把终端切到 raw 模式,收发 ANSI 序列,处理窗口尺寸变化。
  2. 渲染层:维护一个字符单元格网格,做差分刷新,只把变化的部分写出去。
  3. 交互层:解析按键(含转义序列)、鼠标事件,驱动事件循环。

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 / 拉丁字母1a、é
CJK 汉字、日文假名2中、あ
全角符号2!、:
组合记号(Combining)0声调符号、变音符
Emoji1~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 的要点:

  1. raw 模式必须用 defer 保证恢复,并处理崩溃路径。
  2. 渲染用双缓冲 + 差分刷新,静止画面零输出。
  3. Unicode 宽度是正确性的核心,宽字符必须占两格并标记续格。
  4. 布局用「固定 + 弹性」两遍扫描即可覆盖绝大多数界面。
  5. ESC 的歧义靠超时消解,别指望单字节判定。
  6. SIGWINCH 里只设标志,重建网格放到主循环。
  7. 把渲染输出解耦成 writer,单元测试才可写。

终端是 Zig 最能发挥的舞台之一:无运行时、无 GC 停顿、对 fd 与字节流的完全控制。若想看看更成熟的终端交互形态(进度条、表格、颜色降级)的工程细节,可以参考 终端进度条与 ANSI 渲染 ;而 文本界面与 TUI 设计 则从信息密度与键盘优先的角度讨论了交互设计。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「系统编程」更多文章

  1. Zig 打包与分发:容器镜像、系统包与 Homebrew
  2. Zig GPU 计算:Vulkan Compute 与着色器绑定
  3. Zig WASI 与组件模型:沙箱运行时与宿主嵌入