跳到正文
Hacker News· vinhnx·· 5 小时前AI 评分30

Ratatui 维护者提出终端单元格边框协议,为 Ghostty 打补丁实现不占行列的细线边框

Terminal borders that don't cost a cell

AI 导读

Ratatui 维护者提出一种终端协议,在单元格边缘绘制细线边框,不占用任何行或列,已作为 Ghostty 补丁实现(此前在 Rio 有早期版本),并用 Ratatui 构建了演示应用。

正文 · AI 翻译

这篇文章(以及背后的代码)是在 AI 的大量帮助下写成的。我不会为此道歉。如果这让你不舒服,请随意关闭标签页。

我维护 Ratatui,所以见过很多人们构建的 TUI。其中许多最终看起来都差不多:无聊背景上的普通方框,缺少了其他媒介中优秀设计带给我们的许多东西。

边框是问题的一部分。它们必须占用空间,因此成了人们想要构建的面板的一部分。一个选择是去掉它们,只用颜色,但边框在划分和包围内容方面确实很好用。在终端中有很多方法可以勉强实现边框,但没有一个 100% 完美。我手绘过足够多的 ┌──┐,对此有些看法。

通常的做法是使用 Unicode 制表符块,它有 128 个字符用于线条、角和连接。不知何故,这仍然不够:

The Unicode Box Drawing block, U+2500 to U+257F, rendered in Ghostty

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

A tiling window manager demo built with Ratatui, using cell borders for panes, tabs, form fields and buttons

那张截图中的每条线都是单元格边框。它们都不占用行或列。标签页标签位于每个窗格的上边缘,文本字段在文本正下方带有下划线,按钮只有一行高且是圆角的。

这是同一个应用在不支持单元格边框的终端中的样子,回退到制表符字符:

The same demo drawn with box-drawing characters

每个窗格在每一边都损失了一行和一列,因此日志窗格少显示两行,笔记的换行方式也不同。标签页标签移到了框架内部。文本字段改用下划线属性,复选框和按钮变成了方括号。

我会用它做什么

  • 显然是边框。不会在每一边都吃掉一行和一列的面板和窗格。
  • 位于其他内容之上、并且看起来仍然在最上层的菜单和弹出窗口。
  • 面板风格的应用程序,其布局是一组各自需要边缘的区域。
  • 像文本字段、按钮和复选框这样希望线条紧贴其文本的控件。
  • 让东西总体上看起来更整洁。面板背景外边缘的边框看起来干净得多,文本可以直接延伸到它旁边。

近距离观察

设置表单,先是带单元格边框的,然后是回退到制表符的:

A form with underlined text fields, a checkbox and rounded buttons drawn with cell borders

The same form with the underline attribute and bracketed buttons

标签页也是一样。使用单元格边框时,它们位于窗格的上边缘。使用制表符时,它们需要在框架内单独占一行:

Pane tabs drawn on the top edge of the pane with cell borders

The same tabs inside a box-drawing frame

图表

单元格之间的线条也适用于图表。btop 风格的盲文图表用点填满每个单元格,所以如果不牺牲一行,就没有地方放置网格线或平均线。使用单元格边框时,它们位于点之间的单元格边缘上。这是一个用 Ratatui 构建的这种风格的监视器:

A braille CPU graph with gridlines and an average line drawn on the cell edges

以及回退版本,它不得不去掉网格线:

The same braille graph without gridlines

动态演示

这是平铺窗格演示,移动窗格、打开菜单和关于对话框:

An animation of panes being dragged, split and retiled, with a menu and dialog

进行到一半时,它会打开单元格的棋盘格,显示所有内容如何与网格对齐。在这里切换它:

Two panes drawn with cell borders The same panes with alternate cells shaded, showing the borders on cell edges

制表符字符做不到的事

根本原因在于制表符字符是文本。每条线都是一个占据整个单元格的字形,几乎所有关于它们的烦人之处都源于此。

空间是最明显的问题。在我这台 16 英寸笔记本上,用我能真正看清的字体,终端大概是 200x50(你的偏好设置会让这个数字有些浮动)。在这个尺寸下,列数基本没问题,行数不行。一个带有几层嵌套面板的布局,很容易就把这 50 行里的 6 到 8 行白白花在边框线上。为了装饰而放弃这么多屏幕空间,代价太大了。

你也不能在两个都有内容的单元格之间画一条线。所以,没法在文本正下方给一个文本字段加下划线,也没法给按钮描边,除非在它周围加内边距。圆角勉强能用,但角上的字形只有一种背景色,当面板背景与周围不同时,每个角上就会出现一小块颜色不对的方块。而线条本身是什么样,完全由字体决定,包括粗细和位置。

然后是复制粘贴。在框里选中一些文本,你会把框也一起选中。每个从 TUI 往 Slack 里粘贴过的人都知道那个小小的 │ 清理舞。有时候你确实想要那个框(比如把表格粘贴到聊天里),但大多数时候它是垃圾。单元格边框不是文本,所以永远不会被复制,这有利也有弊。

工作原理

单元格边框是一种单元格属性,就像背景色一样。每个单元格最多可以携带十二个线段:四条边各自的两半,以及从中心伸出的四条臂。每个线段都有自己的颜色、宽度、位置和样式,终端把它们绘制在单元格网格之上。边框存储在单元格里,所以它们会随文本一起滚动、擦除、插入和重排。擦除或滚动时会发生什么,已经有答案了,和颜色一样。

边被分成两半,这样边框就能与象限块字符(▖▗▘▝ 之类)对齐。这使得绘制一个从网格向内缩进半个单元格的形状、同时还能给它加上整齐的边框变得很容易。是的,填充仍然要用一个字符,这部分也许有更好的做法。一次解决一个问题。

同样的思路可以扩展到六分块和八分块,或者四分之一和八分之一宽度,如果设计需要的话。

样式与接合

The gallery script showing solid, double, dashed and dotted lines, rounded corners, forks and junctions

Rounded panels with and without an outside fill color. The first has square patches of background at its corners

线条可以是实线、双线、虚线或点线。单元格的每个角和中心可以是方形或圆角,半径可配置,弧线带抗锯齿。圆角可以指定一个外部颜色,这样当面板背景与背后不同时,弧线外的区域也能被正确绘制。

当两个相邻单元格都在共享边上画线时,它们会合并成一条线。priority 更高的笔画胜出,然后是更宽的,然后是右边或下边的。这样,一个获得焦点的窗格可以提高自己的优先级,保住完整的轮廓,无论邻居是按什么顺序绘制的。我最初的尝试是“右边/下边总是赢”,直到焦点窗格被邻居抢走了一半轮廓,这个方案才告终。

线格式

所有内容都是一个带命名空间的 DCS 字符串:

ESC P 1 + b cell-border ; OP ; FIELDS... ESC \

常见路径是一支笔,像当前背景色一样保存在光标中。设置它,打印一些文本,打印出的单元格就会获得边框:

ESC P 1 + b cell-border;pen;48;32;7aa2f7;0;0 ESC \     bottom edge, blue: an underline
hello
ESC [ 0 m                                              SGR 0 clears the pen

还有一些矩形命令,可以在不碰笔的情况下设置或清除现有单元格的边框,以及一个能力查询。应用发送查询,然后发送 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 分支实现了版本 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:

The full btop-style monitor drawn with cell borders. The hostname is blurred

要运行它:

cargo run -p cell-border-top

这是一个真正的系统监视器(它会读取你实际的 CPU、内存、网络和进程),所以相比窗格演示,它能更好地测试这在繁忙屏幕上的效果。1 到 4 显示和隐藏这些框,b 和 g 与窗格演示中的用法相同,h 或 ? 会列出其余按键。由于 sysinfo 依赖,它需要比 Ratatui 其余部分更新的 Rust(1.95)。

我想听到的反馈

我并不执着于这必须成为确切的解决方案,但我认为这是一个值得解决的问题,而且我希望它不只是一个终端的功能(也不只是一个周末的折腾)。如果你在做终端,你会实现这个吗?哪些部分看起来成本很高?如果你在做 TUI 库或应用,你的哪些控件会用到它,它们还需要这里没有提供的什么?欢迎在 Ghostty 讨论区留言。

来源:Hacker News · joshka.net