不占一个单元格的终端边框
这篇文章(以及它背后的代码)是在 AI 的大量帮助下写成的。我不会为此道歉。如果这让你不舒服,欢迎关掉这个标签页。
我是 Ratatui 的维护者,所以见过很多人们构建的 TUI。它们中很多最终看起来都颇为相似:无聊背景上的朴素方框,缺少了其他媒介中优秀设计带给我们的许多东西。
边框是问题的一部分。它们必须占用空间,因此成了人们想要构建的面板的一部分。一种选择是去掉它们,只用颜色,但边框在划定和包围内容方面真的很好用。在终端里有各种方式可以算是有边框,但没有一种是 100% 好的。我手动画过足够多的 ┌──┐,所以对这件事有看法。
通常的做法是使用 Unicode Box Drawing 区块,它有 128 个用于线、角和连接的字符。不知怎的,这仍然不够:

所以我一直在开发一种终端协议,改为在单元格的边缘绘制细线。我已经把它做成了 Ghostty 的一个补丁(在此之前 Rio 里还有一个更早的版本),并用 Ratatui 为它构建了演示应用。我希望得到终端和 TUI 作者们的反馈。

那张截图里的每一条线都是单元格边框。它们都不占用行或列。标签页的标签位于每个窗格的上边缘,文本字段的下划线正好在文字下方,按钮则只有一行高并且是圆角的。
这是同一个应用在没有单元格边框的终端里的样子,回退到使用框线字符:

每个窗格的每一侧都会失去一行和一列,因此日志窗格少显示两行,笔记的换行方式也不同。标签页的标签移到了边框内部。文本字段改用下划线属性,复选框和按钮则变成了方括号。
我会用它做什么
- 边框,显而易见。不会在每一侧吃掉一行和一列的窗格和面板。
- 覆盖在其他内容之上、并且看起来确实位于顶层的菜单和弹出窗口。
- 面板风格的应用程序,其布局是一组各自需要边缘的区域。
- 像文本字段、按钮和复选框这样的控件,它们希望有一条线紧贴着文字。
- 总体上让东西看起来更整洁。面板背景外边缘上的边框看起来干净得多,文字也可以一直延伸到边框边上。
近距离观察
设置表单,先是带单元格边框的,然后是回退到框线字符的:


标签页也是同样的道理。使用单元格边框时,它们位于窗格的上边缘。使用框线字符时,它们需要在边框内部占用自己的一行:


图表
单元格之间的线也适用于图表。btop 风格的盲文图表用点填满每个单元格,因此如果不牺牲一行,就无处放置网格线或平均线。使用单元格边框时,它们位于点与点之间的单元格边缘上。下面是这样一个用 Ratatui 构建的监视器:

以及回退版本,它不得不放弃网格线:

动起来的样子
下面是平铺面板的演示:四处移动面板、打开菜单和「关于」对话框:

演示进行到一半时,会打开单元格棋盘格,展示所有东西如何与网格对齐。在这里切换:


制表符字符做不到的事
根本原因在于,制表符字符是文本。每条线都是一个占满整个单元格的字形,几乎所有让人头疼的问题都由此而来。
空间是最明显的一个。在我这台 16 英寸笔记本上,用一款我确实看得清的字体,终端大约是 200x50(你的偏好设置会让这个数字上下浮动一些)。这个尺寸下,列基本够用,行则不然。一个带两三层嵌套面板的布局,很容易就把 50 行里的 6 行或 8 行全花在线上。为了装饰让出这么多屏幕,代价太大了。
另外,如果相邻两个单元格里都有内容,你就无法在它们之间画一条线。所以想紧贴文字给输入框加下划线,或者给按钮描边,就只能先在周围加内边距。圆角勉强能用,但圆角字形只有一个背景色,当面板背景与周围不同时,每个角上就会冒出一小块颜色不对的方块。至于线本身——粗细和位置——长什么样全由字体说了算。
接下来是复制粘贴。在框里选中一些文字,框也会跟着被选中。每个从 TUI 往 Slack 里粘贴过的人都熟悉那套清理 │ 的小舞蹈。有时你确实想要那个框(比如把表格粘到聊天里),但多数时候它是垃圾。单元格边框不是文本,所以永远不会被复制——这一点同样是双刃剑。
工作原理
单元格边框是一种单元格属性,就像背景色一样。每个单元格最多可以携带十二条线段:四条边各自的两半,以及从中心伸出的四条臂。每条线段有自己的颜色、宽度、位置和样式,由终端把它们绘制在单元格网格之上。边框存储在单元格里,因此会随文本一起滚动、擦除、插入和重排。擦除或滚动时会发生什么,已经有现成答案——和颜色一样。
边被拆成两半,是为了让边框能和象限块字符(▖▗▘▝ 之类)对齐。这样就能轻松画出比网格缩进半格的形状,同时还在它周围加上整齐的边框。是的,填充仍然要用一个字符,这部分也许有更好的做法。一次解决一个问题。
同样的思路还可以扩展到 sextant 和 octant 字符,或者四分之一、八分之一的宽度,只要设计上有需要。
样式与接合


线条可以是实线、双线、虚线或点线。单元格的每个角和中心既可以是方的也可以是圆的,半径可配置,弧线带抗锯齿。圆角可以指定一个外部颜色,这样当面板背景与其背后的内容不同时,圆弧之外的区域也能被正确绘制。
文章标题:不占单元格的终端边框
正文: 当相邻的两个单元格都在共享边线上绘制线条时,它们会合并成一条线。优先级更高的描边获胜,其次是更宽的,再次是位于右侧或下方的。这样,聚焦窗格可以提高自身的优先级,从而保持完整的轮廓,无论相邻窗格以什么顺序绘制。我最初的尝试是“右侧/底部总是获胜”,但这个方案一直用到聚焦窗格被相邻窗格夺走一半轮廓为止。
线格式
所有内容都是一个带命名空间的 DCS 字符串:
ESC P 1 + b cell-border ; OP ; FIELDS... ESC \
常见路径是使用画笔,它像当前背景色一样保持在光标中。设置画笔,打印一些文本,打印的单元格就会获得边框:
ESC P 1 + b cell-border;pen;48;32;7aa2f7;0;0 ESC \ 底边,蓝色:下划线
hello
ESC [ 0 m SGR 0 清除画笔
还有矩形命令,可以在现有单元格上设置或清除边框而不触碰画笔,以及一个能力查询。应用发送查询后再发送 DA1。如果 DA1 回复先于 cell-border 回复到达,说明终端不支持该功能,应用就回退到制表符并预留它们所需的空间。不理解该 DCS 的终端会忽略它,就像对待任何未知 DCS 一样。
终端所需的最低限度是画笔、矩形命令和实线描边。我尽量让第一版保持精简,因为总会有“再加一个……还有这个”的东西,而那是条滑坡。
线条样式、圆角连接、优先级和外侧颜色是能力回复中列出的扩展字段,未知字段会被忽略,因此可以后续添加。我宁愿早期决策尽可能都是双向门。
规范自称为版本 1(就是 DCS 1 + b 中的那个 1)。这是旧意义上的 v1,在我们都被 semver 拖住之前。它不意味着完成或稳定,它意味着那是我愿意写下来的第一个版本。你可以把它读作 v0.1,或者草稿,或者随便你喜欢的什么。
先例
其中部分内容之前已经做过:
- DECterm 中的 DEC 制表线(DA1 回复中的扩展 43)围绕矩形绘制线条。它们随文本滚动,但它们是独立图层:清屏后它们会留在原地,而且它们始终是正常文本颜色下的一个像素宽。
- SGR 51 到 65:框线、圆圈、上划线和表意文字侧线。一些终端用它们绘制边框,但它们是开/关属性,没有颜色、宽度或连接,也没有底边的属性。
- Win32 控制台有按单元格的上、左、右边缘网格标志。PDCurses 和 X/Open curses(WA_TOP、WA_LEFT 等)有类似的属性。这些是最接近本协议的,但它们没有底边(Win32)、颜色、宽度或半线。
- IBM 3270 终端可以勾勒整个输入字段。
这些方案都没有提供的是每条边缘上具有自己颜色和宽度的线条。原理文档更详细地介绍了先例,并解释了协议中的每个选择。
我自己的第一个原型是在 Rio 中实现的(raphamorim/rio#1947),使用的是规范的早期版本,用 APC 而不是 DCS。反馈指出 APC 不适合复用:Kermit 95 自 90 年代以来就一直将 APC 载荷作为 Kermit 命令执行。DCS 是结构化终端命令已经存在的地方,而且终端会忽略它们不认识的 DCS 字符串,所以我切换到了它。
棘手的(边缘)情况
当我第一次把这个展示出来时,大多数问题都是关于这些的。规范对所有这些都有详细说明,但这里大致是我最终确定的方案。
多路复用器是人们最先问到的东西。tmux 之类的工具是根据自己对单元格网格的理解来重绘窗格的,所以把这些序列透传过去只会把边框放错位置。一个不了解该协议的多路复用器应当丢弃这些序列并自行应答 DA1,于是运行在其中的应用会像在其他地方一样回退到制表字符。而了解该协议的多路复用器则可以像存储其他属性一样存储边框并重绘它们。
共享边缘上相互冲突的线条在终端渲染时依据上面的优先级规则解决,因此应用写入单元格的顺序无关紧要。
擦除、插入和删除的行为都与背景色一致。被擦除的单元格会采用当前画笔,插入或删除的行会带着它们的边框一起移动。回滚缓冲区、备用屏幕和重排基本是白送的,因为边框就存在于单元格里。
关于成本:Ghostty 的改动约 1,650 行,其中相当一部分是测试。每个单元格获得一个 16 位边框 ID,它正好放进原本就存在的填充位,所以单元格不会变大,没有边框的屏幕也不产生额外成本。这些 ID 指向一张由整个终端共享的去重边框集合表(每个约 100 字节)。一个典型的 TUI 只会用到少数几个不同的集合,但这张表永远不会被释放,所以在最坏情况下(65,535 个 ID 全部用上)大约在 10-15 MB。
渲染只对带边框的单元格做额外工作。每个这样的单元格会查看它的四个邻居以解析共享边缘,因此改动一行也会重建它上下相邻的行。直线是每个单元格几个实心四边形。圆角成本更高,因为抗锯齿的弧线是作为一排排小四边形绘制的,大致每个边缘像素一个。我还没有对这些做过正经的基准测试,这也是我最希望别人帮忙看一眼的部分。
试一试
规范与理由在我的 Ghostty fork 上。该 Ghostty 分支实现了 version 1 的全部内容。我目前只在 macOS 上构建并运行过,所以 GTK 和 OpenGL 构建尚未测试。要构建并运行它(需要 Zig 0.17 和 Xcode):
git clone -b joshka/cell-borders https://github.com/joshka/ghostty.git
cd ghostty
zig build -Doptimize=ReleaseFast -Dxcframework-target=native
open zig-out/Ghostty.app
在那个 Ghostty 里,同一分支上的 gallery 脚本会绘制上面的样式画廊。它会先检测支持情况,所以这也是快速检查另一个终端实现的好办法:
python3 scripts/cell-borders.py # the default view
python3 scripts/cell-borders.py --styles # line styles, joints and outside fill
运行之后,你可以用按键在各个视图之间切换:p 放置,c 颜色,h 块,j 连接点,e 半格,s 样式,l 实时。w 循环切换描边宽度,b 关闭边框以便对比,q 退出。每个视图也有对应的命令行参数(--placement、--styles 等),如果你想直接从某个视图开始。
平铺窗格演示在 Ratatui 分支上。当终端不支持单元格边框时,它会回退到制表字符,上面的对比截图就是这么做的:
git clone -b joshka/cell-borders https://github.com/joshka/ratatui.git
cd ratatui
cargo run -p tiling-panes
用鼠标拖动窗格即可重新平铺。n 和 v 拆分,x 关闭,右键打开窗格菜单。按 b 在单元格边框和制表字符之间切换,或按 g 给交替的单元格加底纹以看清边缘位置。
图表截图里那个 btop 风格的监视器也在同一分支上。考虑到其中很大一部分是 AI 写的,我确实短暂考虑过把它叫做 bslop:

cargo run -p cell-border-top
这是一个真正的系统监视器(它会读取你实际的 CPU、内存、网络和进程),因此相比 panes demo,它能更好地测试这种效果在繁忙屏幕上的表现。1 到 4 用于显示和隐藏这些框,b 和 g 的作用与 panes demo 中相同,h 或 ? 会列出其余按键。由于依赖 sysinfo,它需要比 Ratatui 其他部分更新的 Rust(1.95)。
我希望听到的反馈
我并不执着于这就是确切的解决方案,但我认为这是一个值得解决的问题,而且我希望它不只是一个终端的功能(也不只是花一个周末做 yak shaving 的量)。如果你从事终端开发,你会实现它吗?哪些部分看起来代价很高?如果你开发 TUI 库或应用,你的哪些控件会用到它?它们会需要哪些这里没有提供的东西?欢迎在 Ghostty 的讨论区留言。