§ 1.2 · Section

字符界面 TUI

Text-based User Interface

上一节的命令行是"一问一答",说完就散。但你一定见过另一种东西:在同一个黑窗口里,居然出现了会实时跳动的彩色条形图、能上下选择的文件列表、能分屏的编辑器。它们不是图形界面,一个像素都没有——TUI 是纯粹用字符搭出来的界面。它是命令行和图形界面之间那个被严重低估的中间物种,而且这几年正在强势复兴。

生活场景
🧩 用麻将牌拼一张画

想象你手上只有一堆麻将牌,每张牌上印着一个字或一个符号,你要在桌面上铺出 80 列 × 24 行的方阵。
你不能画曲线、不能画渐变,但你可以选择:每个格子放哪张牌牌是红的还是绿的哪一格要翻过来高亮
于是你用 ─│┌┐└┘ 这些牌拼出边框,用一整排 拼出进度条,用反色的一行表示"光标现在停在这里"。远远一看——这不就是一个界面吗?

终端里怎么"画"界面:字符网格

要理解 TUI,先要接受一件事:终端窗口本质上是一个二维字符网格。它不像浏览器那样有一个自由的像素坐标系,它只有"第几行第几列"。一个典型终端是 80 列 × 24 行,也就是 1920 个格子,每个格子只能装一个字符,外加几个属性:前景色、背景色、粗体、下划线、反色。

普通命令行程序对这个网格的用法非常保守:只在最后一行往下追加,写过的地方永不回头。像一条只会往前流的河,历史内容滚上去就不管了。

而 TUI 程序做的是完全不同的一件事:它把整个网格接管下来当成画布,可以随时跳到第 5 行第 12 列去改一个字符,可以整屏重绘,可以每秒刷新十次。这就相当于从"写日记"变成了"画画"——同一张纸,可以擦了重画。

下面是一个 TUI 界面在字符层面的真实样子,你看到的"框"其实只是一些制表符字符:

┌─ Processes ──────────────────────────────┐
│  PID  USER    CPU%   MEM%  COMMAND       │
│ 1024  root     3.2    1.1  systemd       │
│ 2048  alice   68.5   12.4  node          │  ← 反色高亮 = 当前选中行
│ 3072  bob      0.7    0.3  bash          │
├──────────────────────────────────────────┤
│ CPU [||||||||||||||||......]  72%        │  ← 用 | 和 . 拼出的进度条
│ MEM [||||||||............]    41%        │
└──────────────────────────────────────────┘
 F1 Help  F5 Tree  F9 Kill  F10 Quit          ← 底部功能键提示条

ANSI 转义序列:控制光标的那套暗号

问题来了:字符是通过一根管道流进终端的,程序怎么告诉终端"接下来这个字符请写到第 5 行第 12 列,用红色"?管道里只能流字符,没有额外的控制通道。

答案是一套非常聪明也非常古老的约定:ANSI 转义序列。约定的核心是选一个几乎不会出现在正常文本里的特殊字符 ESC(ASCII 27)当作"暗号开头"。终端一看到 ESC[ 这个组合,就知道:后面跟着的不是要显示的内容,而是给我下的命令

序列含义类比
ESC[2J清空整个屏幕擦黑板
ESC[H光标回到左上角(1,1)笔尖回到纸的开头
ESC[12;40H光标跳到第 12 行第 40 列直接把笔尖挪到指定格子
ESC[31m之后的字符用红色前景换一支红笔
ESC[7m反色显示(黑白互换)荧光笔涂高亮
ESC[0m重置所有属性换回默认的黑笔
ESC[?25l隐藏光标把碍眼的笔尖收起来

有了这套暗号,"画界面"就变成了一件机械活:清屏 → 跳到某格 → 设颜色 → 输出字符 → 再跳下一格。用最原始的 shell 就能验证:

# 清屏,然后在第 5 行第 20 列打印一行红色反色文字
printf '\033[2J\033[5;20H\033[31;7m Hello TUI \033[0m\n'

# 用一个死循环把秒数刷在同一个位置上(原地更新,不换行)
while true; do
  printf '\033[3;1H当前时间:%s' "$(date +%T)"
  sleep 1
done

注意第二个例子里的关键点:它每次都跳回同一个位置覆盖写。屏幕上看起来是"时间在原地跳动",实际上只是不停地重写那一小块格子。所有 TUI 动画的原理都是这个:反复擦掉重画,快到让你以为它在动。

Analogy · 电子屏与广场大屏

老式火车站的翻页式时刻表,每一格只能翻出固定的几个数字,整块牌子由无数小格拼成——这就是 TUI:格子固定、内容有限、但组合起来足够表达信息。
而广场上的LED 全彩大屏,每个像素都能独立发任意颜色,什么图都能显示——这是 GUI。
翻页牌显然"落后",但它极省电、极耐用、远看极清楚、坏一格不影响全局。TUI 之于 GUI,就是这个关系:不是能力更强,而是在特定约束下更优。

ncurses:不用再手写暗号的那层封装

直接手写转义序列写一个 htop,理论上可行,实际上是噩梦。原因有三个:不同品牌的终端支持的序列不完全一样(VT100、xterm、Windows 控制台各有差异);每帧全屏重绘会导致明显的闪烁;还要自己处理窗口大小变化、键盘特殊键、鼠标事件等一堆杂事。

于是 1980 年代出现了 curses,后来演化成今天几乎所有 Unix 系统都自带的 ncurses(new curses)。它是 TUI 世界的"图形库",解决了四个核心问题:

一段最小的 ncurses 程序长这样,你会发现它的心智模型已经很接近图形界面编程了:

#include <ncurses.h>

int main(void) {
    initscr();              // 接管屏幕,进入 TUI 模式
    noecho();               // 按键不回显
    curs_set(0);            // 隐藏光标
    keypad(stdscr, TRUE);   // 允许识别方向键等特殊键

    mvprintw(2, 4, "请用 ↑↓ 选择,q 退出");
    box(stdscr, 0, 0);      // 给整屏画一个边框
    refresh();              // 差分刷新:只发送变化的部分

    int ch;
    while ((ch = getch()) != 'q') {   // 事件循环:等按键
        mvprintw(4, 4, "你按了键码:%d   ", ch);
        refresh();
    }

    endwin();               // 归还屏幕,恢复原样
    return 0;
}

请留意 while ((ch = getch()) != 'q') 这一行——这是一个事件循环:程序不再从上往下跑完就退出,而是停在那里等你动作,你按一下它响应一下。这个思路和后面 § 1.5 要讲的 GUI 事件驱动本质上是同一件事。也就是说,TUI 虽然长得像命令行,但它的编程范式已经站到 GUI 那一边了。

那些你一定见过的 TUI 代表作

工具做什么为什么它是 TUI 而不是 CLI
htop实时进程与资源监视彩色条形图每秒刷新、可上下选中进程、按 F9 直接杀掉
vim / neovim文本编辑器整屏是可编辑画布,有状态栏、分屏、语法高亮
lazygitGit 图形化操作多面板并排显示分支/提交/暂存区,空格键暂存单个文件块
Midnight Commander (mc)双栏文件管理器左右两栏对拷、F 键功能条,是 DOS 时代 Norton Commander 的精神续作
tmux终端复用器把一个终端切成多个面板与窗口,还能断线后原样恢复会话
nmtui网络配置在没有桌面的服务器上,用对话框式界面配网卡和 WiFi
ranger / yazi文件浏览三栏预览式浏览目录,键盘直达,比图形文件管理器还快

这里面 tmux 值得多说一句,因为它是"TUI 里再跑 TUI"的典型:它自己是一个字符界面程序,同时又把屏幕切成好几块,每块里再跑别的程序(可能又是一个 TUI)。它扮演的角色,其实就是终端世界里的窗口管理器——和桌面上负责给窗口排版、切换焦点的那个组件一模一样。

为什么 SSH 场景下 TUI 无可替代

这是 TUI 存在的最硬的理由,也是它永远不会消失的原因。设想一个真实场景:凌晨三点,你收到告警,要连到新加坡机房的一台服务器上排查内存暴涨。你手上只有笔记本和一个不太稳的网络。

所以 TUI 的生态位可以一句话概括:当你只有一根字符通道,却需要一个界面的时候,TUI 是唯一的答案。

Analogy · 用摩斯电码开会

假如你和同事之间只有一根电报线,一秒钟只能传几个字符。你还想开一场"有议程、有投票、能实时看到结果"的会议,怎么办?
你不能传视频(带宽不够),只发一封封邮件又太慢太散(那是 CLI)。于是你们约定一套精简符号,在有限通道上模拟出一个"会议界面"——这就是 TUI 的设计处境:在极窄的通道上榨出最大的交互密度。

现代 TUI 复兴:它其实正在变年轻

你可能以为 TUI 是上世纪的遗产,只剩老工具在苟活。恰恰相反,2020 年之后 TUI 出现了明显的第二春,原因有三个:云原生时代大家天天泡在终端里;ncurses 的 C 语言 API 太痛苦而新语言给出了现代方案;开发者工具的用户本来就是键盘重度用户,TUI 的操作效率反而更高。

框架 / 工具语言特点
ratatuiRust当红框架(tui-rs 的继任者),提供布局约束、区块、图表、表格等组件,声明式描述每一帧
TextualPython最激进的一个:用类 CSS 写终端界面样式,支持鼠标、动画、异步,甚至能一键跑成网页
Bubble TeaGo采用 Elm 架构(Model-Update-View),状态管理清晰,Charm 系工具链的基石
blessed / inkNode.jsink 让你用 React 组件和 JSX 写终端界面,npm、Jest 的输出界面就出自这一系

最能说明趋势的是 Textualink:一个把 CSS 搬进了终端,一个把 React 搬进了终端。这意味着Web 前端二十年积累的界面开发范式,正在被反向移植回字符网格。写 TUI 不再是和转义序列搏斗,而是像写网页一样声明"我要一个居中的、带边框的、占 30% 宽度的面板"。

看一眼 Textual 的写法,你几乎会以为在看前端代码:

from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, Static

class DemoApp(App):
    CSS = """
    Static { border: round green; padding: 1 2; width: 60%; }
    Button { margin: 1; }
    """
    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("这是一个终端里的面板,样式由 CSS 控制")
        yield Button("点我", variant="success")
        yield Footer()

    def on_button_pressed(self) -> None:      # 事件回调
        self.query_one(Static).update("按钮被按下了!")

DemoApp().run()

顺带说一句,现在你用的很多命令行工具其实已经悄悄是 TUI 了:npm install 那个会动的进度条、docker pull 里多层并行更新的下载条、AI 编程助手在终端里的对话框——它们都在用光标定位原地重写,都是 TUI 技术。TUI 早已渗透到你每天的工作流里,只是你没给它起名字。

Recap · 收束

TUI 的原理是三件事叠起来:把终端当成固定的字符网格当画布用 ANSI 转义序列控制光标位置与颜色靠差分刷新做到不闪烁的实时更新ncurses 把这些脏活封装成了窗口和事件循环,让 htopvimlazygitmctmux 这一整代工具成为可能。它真正不可替代的战场是 SSH 远程运维:带宽只要几 KB、服务器不需要桌面、断线还能续。而 ratatui / Textual / Bubble Tea / ink 的出现说明它没在衰退,而是把现代前端范式吃了进来。记住定位:TUI 的编程范式已经是 GUI 的(事件驱动 + 控件 + 布局),只是它的像素是字符。

☰ 主页
学海无涯 · 界面篇 · § 1.2