Screen 与 Scrollback:把可见网格做成可扩展历史
ScreenSet、PageList、alternate screen、压缩和搜索的内部关系
Screen 与 Scrollback:把可见网格做成可扩展历史
一个 terminal screen 不是二维数组这么简单。它要支持换行、宽字符、grapheme、selection pin、scrollback、alternate screen、压缩、搜索和 resize。Ghostty 把这些责任拆成 Screen、Page、PageList、ScreenSet 和附属模块。
ScreenSet:primary 与 alternate
Terminal 持有 ScreenSet,至少包含 primary screen 和 alternate screen。普通 shell 输出写 primary;全屏 TUI(vim、tmux、less)通常切到 alternate。切换时不能只换一个 buffer,还要保存 cursor、modes、scrolling region、viewport 和页面语义。
Terminal
└── ScreenSet
├── primary: Screen → PageList(scrollback + active)
└── alternate: Screen → PageList(通常无历史)这解释了为什么“退出 vim 后恢复原来的 shell 画面”不是 renderer 的工作:renderer 只显示当前 active screen,恢复语义由 Terminal/ScreenSet 维护。
Page 与 PageList
page.zig 描述 page 内的 cell、row、capacity、memory layout;PageList.zig 在更高层把多个 page 串成 intrusive doubly linked list。列表顺序是从最老的 scrollback 到当前 active page。
PageList.Node 的 storage 有 resident 和 compressed 两种状态:
Node
├── metadata: rows / cols / serial / size
└── data
├── resident: Page memory 可直接访问
└── compressed: encoded bytes,需要 restorepagePreservingState 对 resident page 返回 borrowed view,对 compressed page 用调用者 allocator 解码成 owned view。这种 API 明确表达了一个危险:borrowed pointer 只在 PageList 独占访问期间有效,不能把它逃逸到异步线程。
scrollback 的两个限制
Terminal.Options 同时支持 max_scrollback_bytes 和 max_scrollback_lines。前者控制内存/压缩后的容量边界,后者控制物理行数;有效限制会尽量保留至少一个标准页面,并按完整历史 page 修剪,而不是任意切断一行。
这是一种“按 page 回收”的取舍:它让 page 的分配、压缩、恢复和 pin 更简单,也意味着滚动历史不是精确到每一行的分页器。
Dirty flags 是跨层协议
Terminal 有 palette、reverse colors、clear、preedit、glyph glossary 等 dirty flags;Screen/Page 也有自己的行/页 dirty 信息。renderer 每一帧读取并清理它们,但 terminal 状态不是 renderer 的拥有者。
当 glyph glossary 变化时,即使 cell 字符本身没有改变,也可能影响已经可见的 PUA glyph,因此需要完整 rebuild。这类 flag 是一个重要设计:它用少量粗粒度失效信号避免把“每种协议状态变化”都变成 renderer API。
selection 与 search 为什么用 pin
scrollback 会增长、page 会被修剪或压缩,固定的 (row, col) 很快失效。point.zig、Selection.zig 和 search 模块使用能够指向 page/row 的 pin/point 语义,并在 active screen 改变后验证它们是否仍然有效。
renderer 的注释明确提醒:search matches 可能指向已经不存在的 PageList node,使用前必须 validate。这个警告揭示了一个真实并发边界:搜索线程得到的是异步结果,不能假设 UI render 时历史结构仍相同。
压缩的目的和位置
scrollback compression 并不进入打印热路径。renderer.Thread 负责增量调度,terminal/compress/ 提供 Page 编码,PageList 仍维护 resident/compressed 的表示。渲染当前 viewport 时,访问压缩页才 restore;历史越远,越可能节省内存。
设计上它把“逻辑历史”与“物理存储”分开:Terminal 只关心 page 语义,PageList 决定表示,compression codec 决定编码细节。压缩失败不是普通用户输入错误,而是内部一致性故障,源码倾向于记录并 panic,避免静默展示损坏数据。
resize 的真实含义
窗口 resize 会同时改变 cell 行列数、pixel size、PTY window size、page reflow 策略、cursor、scrolling region、renderer grid metrics 和 selection point。Termio.Thread 用 25ms coalesce 降低拖动期间的重复操作,Terminal 自己再执行语义更新。
不要把 resize 只理解为“重新设置 viewport”:它是一个跨 thread、跨协议、跨 GPU 的结构变化事件。