§ 1.2 · Section

字符界面 TUI

Text-based User Interface

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

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

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

先把这一节的黑话全翻译一遍

这节的术语密度比上一节高,而且大多是缩写。先一次性换成大白话,后面就不会卡。

TUI 卡在哪个位置:CLI 和 GUI 之间的那一格

很多人对 TUI 的疑惑其实是"它到底算命令行还是算图形界面"。答案是都不算,它是独立的第三种。用一张表把三者钉在各自的格子上:

维度CLI 命令行TUI 字符界面GUI 图形界面
屏幕怎么用只往下追加,写过不回头整屏当画布,随时擦了重画自由像素画布,任意坐标
最小显示单位一个字符一个字符一个像素
交互节奏你说一句它答一句你按一下它立刻反应你按一下它立刻反应
输入方式打字 + 回车单键、方向键、快捷键(有的支持鼠标)鼠标 / 触摸为主
编程范式从上到下跑完退出事件循环,等你动作事件循环,等你动作
远程一次刷新的流量几十字节几 KB几百 KB 到几 MB
会不会看菜单没菜单,全靠记有功能键提示条,算半个菜单菜单全摊在眼前

看第 3 行和第 5 行你就会发现一件有意思的事:TUI 在"长相"上像 CLI,在"骨架"上像 GUI。它显示的东西是字符,所以能塞进 SSH 这种窄通道;但它的程序结构是"停在那儿等你按键",跟图形界面一模一样。

换成大白话打个比方。CLI 好比窗口办事的柜台:你递一张单子,办事员盖章还你,一次一来一回,办完各走。GUI 好比装修好的自助大厅:所有业务的指引牌、取号机、显示屏都摆在那儿,你随便逛随便点。而 TUI 是那种老式的电话客服:全程只有声音(只有字符),但你能"按 1 查余额、按 2 转人工",界面一直在,随时响应——它的通道极窄,交互却是活的。

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

要理解 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          ← 底部功能键提示条

1920 个格子到底是多是少:把数字换算成能摸到的量

上面提到"80 列 × 24 行 = 1920 个格子",这个数字听着没感觉。换算一下你才知道 TUI 是在多窄的地方施展本事。

所以 TUI 的设计哲学可以一句话概括:格子极少,所以每一格都必须有信息量。这也解释了为什么 htop 那种界面看着"密"——不是设计师不懂留白,是他真的没有格子可以浪费。好比在一间只有六平米的小屋里装修,你不会摆装饰品,每一寸都得是收纳。

字符画怎么"画"出边框:那些不是图形的图形

看回上面那个 htop 样子的例子,那些漂亮的框线是怎么来的?答案很朴素:它们就是字符,跟"啊"和"A"是同一类东西,只不过长得像线。

这批字符分三代,一代比一代好看:

代际用到的字符画出来的效果什么时候用
第一代 · 纯 ASCII+ - | =+-----+ 这种,粗糙但绝对到处都能显示不确定对方终端支不支持时的保险选择
第二代 · 制表符─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼连贯的单线框,还有双线版 ═ ║ ╔ ╗今天的主流。这套字符专门为"画框"而设计
第三代 · 方块与半格█ ▓ ▒ ░ ▀ ▄ ▌ ▐实心块、不同浓度的灰、上下半格、左右半格画进度条、柱状图、甚至用半格字符做出"半像素"精度的图表

第三代那个"半格"技巧特别值得说,因为它是 TUI 里最巧的一手。 这个字符只占格子的上半部分, 只占下半部分。于是聪明的做法出现了:把上半格设一个前景色,下半格设背景色,一个格子就变成了两个独立可上色的点。垂直分辨率凭空翻倍。btop 那种看着像真正折线图的曲线,就是这么榨出来的。

这一手就像宿舍里搭上下铺:房间面积没变,睡的人翻了一倍。技术上什么都没多,只是把原来"一格一个用途"的假设打破了。

纯 ASCII 版(最保守)        制表符版(主流)           方块图表版(最花)
+---------------+          ┌───────────────┐          ▁▃▅▇█▇▅▃▁▁▃▅▇
| CPU  [####..] |          │ CPU  ████░░░░ │          ▂▄▆█▆▄▂▁▂▄▆██
| MEM  [##....] |          │ MEM  ███░░░░░ │          CPU 72%  MEM 41%
+---------------+          └───────────────┘          用半格字符逼近折线

还有一个必须提醒的坑:汉字占两格。一个英文字母占一格,一个汉字在终端里占两格宽。所以你用汉字做界面,算宽度时得按两倍算,否则框线会错位——这就是很多中文 TUI 界面框线歪掉的原因。好比装修贴瓷砖,你按小砖的尺寸排好了图,结果送来的是大砖,整面墙的缝就全对不上了。

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 没有鼠标(大多数情况下),这不是缺陷,而是逼出了一套完全不同的操作哲学。理解它,你才不会觉得 vim 是反人类。

核心矛盾是这样:键盘只有一百来个键,但一个软件要提供几百个功能。怎么办?三条路。

模态这件事新手最反感,但它的逻辑其实很硬:既然键不够用,那就让同一批键在不同场合复用。打个比方,这好比医院里同一间诊室,上午是内科门诊、下午是抽血室——房间没变,挂的牌子变了,进去要办的事就完全不同。你要是不看牌子就闯进去,当然会懵。vim 屏幕左下角那个 -- INSERT --,就是那块牌子。

vim 的三个主要模式,用一句人话各自说清:

模式键盘此时是什么生活对应
普通模式(Normal)一整套遥控器:每个键是一条编辑命令你手里拿着装修图纸和红笔,在标注"这儿拆、那儿改"
插入模式(Insert)就是普通打字机,按什么出什么你放下红笔,亲自上手砌墙
命令模式(Command,以 : 开头)临时开一个小命令行,输入整句指令你走到项目部办公室,填一张变更申请单

为什么专业用户愿意付这个学习成本?因为收益是可组合性d 是删,w 是一个词,3 是三次——于是 d3w 就是"删掉后面三个词"。你没有单独学过 d3w 这个功能,它是三个零件拼出来的。这和上一节命令行的管道是同一个思路:给你一堆小零件,让你自己组装,而不是给你一个装好的按钮。图形界面里"删掉后面三个词"这个功能,如果开发者没做,你就永远做不到。

TUI 与 GUI 的效率之争:谁快其实取决于问谁

这个争论吵了三十年,之所以吵不清,是因为双方在比不同的东西。把它拆成三个不同的问题,答案立刻清楚。

你到底在问什么谁赢为什么
第一次上手要多久GUI 大胜菜单把所有能干的事摊在眼前,你逛一圈就会了。TUI 屏幕上只有个提示条,剩下的全靠查文档
用熟以后单次操作多快TUI 大胜手不离键盘。鼠标操作要经过"眼睛找目标 → 手移过去 → 校准 → 点击",全套约 1 到 2 秒;一个快捷键约 0.2 秒
批量重复一千次TUI / CLI 完胜键盘操作可以录成宏、写成脚本;鼠标点击没法存成文件,只能一次次点
空间性任务(调色、拖时间轴)GUI 完胜这类任务需要连续量的边看边调,字符网格根本表达不了"往左 3 个像素"
窄带宽远程TUI 完胜几 KB 对几 MB,差三个数量级

关于那个"鼠标 1 到 2 秒、快捷键 0.2 秒"的差距,值得换算一下才有感觉。假设你每天做 300 次这种小操作,每次省 1 秒,一天省 5 分钟,一年省下大约 30 个小时——差不多是四个完整工作日。这就是为什么重度用户愿意花两周去啃 vim 的快捷键:学习成本是一次性的,效率收益是每天复利的。

但请不要把这条推到荒谬的地步。打个比方:专业厨师用中式菜刀能同时切片、拍蒜、剁馅,比一柜子专用工具都快;但你只是周末做一次饭,买一把菜刀再练三个月刀工,完全不值。工具的效率上限,只对天天用它的人有意义。判断标准很简单:这件事你一年做几次?做三次就用图形界面,做三千次就该学快捷键。

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

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

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

五个代表作巡礼:它们各自解决了什么问题

上面那张表只说了"是什么",这里说"为什么它值得存在"。挑五个最有代表性的,各配一个生活场景。

看完这五个,你会发现一个共同点:它们全都出现在"人在这头、机器在那头,中间只有一根细管子"的场景里。这不是巧合,这就是 TUI 的生态位。

Analogy · 老式火车站的"人工播报 + 翻页牌"

如果只让你记一个画面来理解 TUI,就记这个:三十年前的火车站候车厅。

那时候没有大彩屏。信息全靠两样东西传递:墙上那块翻页式的列车时刻牌,和头顶那个喇叭。时刻牌由无数个小格子拼成,每个格子只能翻出十个数字里的一个,工作人员在后面用手一格格拨。就这么点手段,它却把"车次、始发、终到、几点、晚点多久、哪个站台、开始检票了"这一整套动态信息,同时呈现给整个候车厅上千人看。

把三者对上:CLI 是那个喇叭——播一次就散,你没听见就没了,也不能回头看;GUI 是今天车站里那块全彩大屏,图文视频广告都能放;TUI 就是那块翻页牌:格子固定、字符有限、颜色寡淡,但它是"一直在那儿、随时能看、并且会更新"的——这三条加起来,就是一个"界面"的最低定义,而它用最少的资源满足了。

更妙的是翻页牌的另外几个优点,恰好也是 TUI 的优点:极省电(几 KB 流量)、极耐用(几十年的老工具还在用)、远看极清楚(信息密度高)、坏一格不影响整块牌(一个字符错了不会让程序崩)。所以别把 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 早已渗透到你每天的工作流里,只是你没给它起名字。

自己动手做一个 TUI:三条难度不同的路

看到这儿你可能想试试。Python 生态里正好有三条路,难度从"折磨"到"愉快"依次排开,正好对应了 TUI 开发四十年的进化史。

路子它替你干了什么难度装修类比
curses(标准库)只封装了最基础的"挪光标、写字符、读按键"。布局、边框、组件全靠你自己算坐标给你砖、水泥和一把瓦刀,墙自己砌,尺寸自己量
rich把"漂亮地输出内容"这件事包圆了:彩色表格、进度条、语法高亮、树形结构,一行调用就有给你成品的门窗和柜子,直接往墙上装,但整体格局还得你定
textual完整的界面框架:用类 CSS 写样式、有布局引擎、有现成控件、有事件系统、支持鼠标和动画低到中整装公司:你说要几室几厅什么风格,剩下全包,还能随时改软装

给一条实用建议:只想让脚本的输出好看点,用 rich;想做一个能上下选择、有多个面板的真界面,用 textual;除了学习和维护老代码,别再从零写 curses 了。这就像今天没人会自己烧砖建房——不是因为烧砖不光荣,而是有更好的现成材料。

下面是三条路各写一小段,你可以直接感受一下代码量的差距。同样一件事:在屏幕上显示一个带边框的面板,里面写一行字。

# 路一:curses —— 坐标全靠自己算
import curses

def main(scr):
    curses.curs_set(0)                      # 隐藏光标
    h, w = scr.getmaxyx()                   # 先问清屏幕多大
    box = scr.subwin(5, 40, h//2 - 2, w//2 - 20)   # 自己算居中位置
    box.box()                               # 画边框
    box.addstr(2, 4, "Hello from curses")   # 自己算文字位置
    scr.refresh(); box.refresh()
    scr.getch()                             # 等一个按键

curses.wrapper(main)


# 路二:rich —— 一行就有边框和居中
from rich.console import Console
from rich.panel import Panel

Console().print(Panel("Hello from rich", title="面板", expand=False))


# 路三:textual —— 声明式,写起来像网页
from textual.app import App, ComposeResult
from textual.widgets import Static

class Demo(App):
    CSS = "Static { border: round green; width: 40; align: center middle; }"
    def compose(self) -> ComposeResult:
        yield Static("Hello from textual")

Demo().run()

注意这三段的心智负担差别,而不是代码行数。curses 那段里,你脑子里得同时装着"屏幕多大、面板多大、除以二居中、文字往里缩几格"这一堆算术;textual 那段你只是说"我要一个 40 宽、绿色圆角边框、居中的东西",剩下的框架去算。这个转变就是从"手工计算"到"声明意图",也是整个界面开发史的主线。好比点菜:一个是你自己进厨房按克数配料,一个是你只说"要一份微辣的"。

Recap · 收束

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

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