crates/tui · Rust 2024 edition

把终端当成
一块可差分更新的画布

hand-tuihand-ai 仓库里的终端 UI 框架。它把"绘制 → 比对 → 只发变化"这一套,连同输入解析、Overlay 合成、硬件光标定位,做成了一个 ~9 千行 Rust 的小内核。

crate hand-tui 0.1.0
edition 2024
总行数 ~20.4k LOC
内建组件 16 个
运行时 tokio (full)
hand-tui · demo session
┌─ Container ───────────────────────────┐
  StatusBar  · idle · ⌥+⇥ focus  
                                       
  # Hello from hand-tui            
  markdown is rendered inline         
                                       
  Editor                              
    let mut tui = Tui::new(term);       
    tui.run(&handler).await?;          
└───────────────────────────────────────┘
» diff: 2 rows changed · cursor invariant ok

tl;dr

一句话结论

hand-tui = 声明式组件树 + 差分渲染器 + 异步输入流水线。 它不打整屏,只发改动;不接管事件循环,由 tokio 拉动; 组件之间通过 ComponentIdOverlayHandle 等稳定句柄解耦,背后是一套对终端"光标位置"的强不变量约束。

3
核心 trait
Component · Focusable · Terminal
16
内建组件
TextEditorMarkdownImage
~4ms
渲染节拍
RENDER_TICK_MS,约 240Hz 上限
1
光标不变量
帧之间始终落在"末行的下一行"
它解决了什么问题? 原生终端 IO 是"光标 + 字节流"的命令式接口;任何把整屏文本反复打到 stdout 的 TUI 在长会话里都会变成抖动、闪烁、滚动条噪音。 hand-tui 在这层之上引入了 "前一帧 / 当前帧 diff" 的策略,再用一个写死的光标位置不变量把"复杂在哪 / 简单在哪"切干净。

architecture

三层架构:应用 / 运行时 / I/O

逻辑分层很清爽:顶层是你写的组件树,中层Tui 运行时(持有组件树、渲染器、监听器、Overlay 栈),底层是终端 I/O 与 stdin 重组。三层间用 trait 解耦,方便测试和换底层。

App Components 业务实现的 impl Component
组合内建 16 个组件
Built-in Components Text / Editor / Markdown / SelectList / Image …
Tui (run loop) listener 链 → focused dispatch → root dispatch
render tick 合并请求
DiffRenderer · Overlay · CursorMarker 帧间 diff、Overlay 合成、APC 光标定位
Keybindings · Theme · Utils 键位映射 · ANSI 主题 · 宽度/截断/包行
StdinBuffer · parse_key 字节重组(半截转义、断点 UTF-8) · Kitty/CSI/SS3 解析
Terminal trait ProcessTerminal(生产) · TestTerminal(测试)

为什么 trait 分两层?

Component 给"任何能渲染成行的东西",Focusable 再加上"可接收键盘 + 光标位置"。这层细分让无焦点装饰元素 (StatusBar、Spacer、ProgressBar)写起来零样板,而真正吃键盘的 Input / Editor 才实现 Focusable

为什么 Terminal 是 trait?

ProcessTerminal 走真实 stdout、监听 SIGWINCH; TestTerminal 把所有写入存进 buffer,断言时直接读字符串。 所有 ~30 个集成测试都跑在 TestTerminal, 不依赖 PTY。

component model

Component / Focusable / Container

所有 UI 元素都收敛到这三个抽象。render 是"宽 → 行数组"的纯函数, handle_input 返回 HandledIgnored 让父级决定是否冒泡。

rust
pub trait Component: Send {
    /// 渲染:拿到当前可用宽度,吐若干行字符串
    fn render(&self, width: u16) -> Vec<String>;

    /// 输入:默认不处理,返回 Ignored 让父级继续派发
    fn handle_input(&mut self, _: &InputEvent) -> HandleResult {
        HandleResult::Ignored
    }

    fn invalidate(&mut self) {}
    fn wants_key_release(&self) -> bool { false }

    /// 统一的可见性钩子,默认"永远可见"
    fn hide(&mut self) { self.set_hidden(true); }
    fn show(&mut self) { self.set_hidden(false); }
    fn set_hidden(&mut self, _: bool) {}
    fn is_hidden(&self) -> bool { false }
}

关键设计 1:render 是纯函数。 它只看自身状态 + 宽度,不写终端。所有 ANSI 字节最后由 DiffRenderer 一次性发出。这一刀切下去后,组件可以独立 测试("给定 width=80,应该输出这 3 行"),也可以被 Overlay 合成器拿去 做行级合并。

关键设计 2:handle_input 显式回 Ignored 冒泡链由 Container::dispatch_to_focused 控制——先丢给 focused 子节点,未消费再倒序遍历其他子节点。这意味着即使最上层的 Toast 弹层在场, 底下的 Editor 也仍然能拿到自己的 Ctrl-S,只要 Toast 不主动消费。

Container::ComponentId 是稳定句柄。 add_child_with_id 返回一个 ComponentId(u64), 后续 focus / remove / lookup 全部走它——位置索引一变就会失效,这是早期 很多 TUI 框架踩过的坑。

焦点派发的优先级

正向(焦点优先)

  1. 当前 focused 子节点(如果非 hidden)
  2. 其余子节点:倒序遍历(最后添加 = 最顶层)
  3. 都不消费 → 回到 root 整体处理

逆向(消费即止)

一旦某层返回 Handled,派发立即停止; InputListener 也可以在组件派发前消费事件(consume = true), 或者改写 raw payload(data = Some(...))用于做迁移期的兼容垫片。

differential rendering

差分渲染:只发改动那几行

DiffRenderer 是这套框架最值得拿出来讲的地方。它围绕 一个写死的"光标位置不变量",把"打整屏"压缩到了 "上次帧 vs 这次帧的最小差集"。

不变量:每次 diff() 调用结束后, 硬件光标恰好落在"上一帧最后一行 下面的那一行第 0 列"。 下一次 diff() 进来时,先把光标 上移 N 行, 就可以用 \x1b[{n}B(下移 n 行)寻址到该区域内任意一行。
rendered region (prev frame) row 0 ┃ ▌ StatusBar · idle row 1 ┃ row 2 ┃ ▎ # Hello from hand-tui row 3 ┃ ▎ markdown is rendered inline row 4 ┃ row 5 ┃ ▍ Editor row 6 ┃ ┄ let mut tui = Tui::new(term); ← cursor invariant: 1 row past last next diff() 1. begin sync output ESC[?2026h 2. home cursor up ESC[7A 3. walk down to first changed row 4. repaint only [first .. last] window 5. if shrunk → clear leftover rows 6. restore cursor to "1 past last" 7. end sync output ESC[?2026l

diff 算法的四种分支

场景 判定 处理
首帧 first_render == true 整段 full_render,缓存 prev_lines
无变化 first_changed == min(prev,new) 且行数相同 返回空串 不写一个字节
等长改写 找到首末两个不同行 只重绘 [first .. last] 窗口
增长 / 收缩 new_len ≠ prev_len 增长 → 多余行直接 append;收缩 → 用 \x1b[2K 把多出的旧行清空

同步输出 ESC[?2026h/l

每次 diff 前后包一对 "synchronized output" 标记。支持的终端(Kitty、 WezTerm、新一代 iTerm2)会把整段命令攒到一起再绘制, 消除"先空一行再补回来"的抖动;不支持的终端忽略这俩字节,零成本。

测试覆盖即"反向验证不变量"

render.rs 里的 7 个测试,本质都是在验证"光标偏移正确"—— 比如 shrink_clears_extra_rows_and_preserves_cursor 检查 收缩 3→1 行时,必须出现 \x1b[3A 和至少 2 个 \x1b[2K。任何破坏不变量的改动都会立刻挂掉。

input pipeline

从字节到 InputEvent 的五段流水线

终端输入是个噪声很大的字节流:SSH 抖一下 F1 就会断成两半, 中文 IME 会触发 CSI 27 u,鼠标滚轮发 SGR 1006hand-tui 把这条"字节 → 语义事件"的路切成五段,每段职责单一。

io
stdin reader
tokio 任务非阻塞 read,喂入 buffer
reassemble
StdinBuffer
补全半截转义 + 跨边界 UTF-8
classify
parse_key
CSI · SS3 · Kitty · 可见字符
intercept
InputListener
可 consume 或 rewrite payload
dispatch
focused → root
先焦点子节点,再 root 兜底

StdinBuffer 的设计哲学

"同步 push → 同步 Vec<Event>",无回调、无隐式 timeout。 异步消费者用 channel_from_buffer 包一层就行。 这意味着所有重组逻辑都能用纯字节切片单测,跑得飞快。

失败模式直接写在了模块注释里:"F1 doesn't work over slow SSH" / "Chinese input shows mojibake under load"—— 这种把"会出什么 bug"写在源码里的姿态,调试期会感谢自己。

InputEvent 的五种变体

enum InputEvent {
  Key(Key),         // 已识别的语义键
  Raw(String),      // 未分类的原始字节
  Paste(String),    // bracketed paste 合成
  Resize{cols,rows},// SIGWINCH
  Tick,             // 动画/防抖周期
}

这五种变体是组件唯一会看到的输入抽象——下面所有字节级噪声都被吃在 流水线里了。

paste 缓冲与背压

Tui::paste_buffer 是一个 Option<String>: 当某次 stdin chunk 看到 \x1b[200~ 但没等到对应的 \x1b[201~,就开始攒;下一个 chunk 接上后才合成一个 InputEvent::Paste。这避免了"大段粘贴被切成几百个 Key 事件" 把 Editor 的 undo 栈撑爆的情况。

overlay & cursor

Overlay 合成与硬件光标定位

组件树是"扁平 + 顺序"的,但实际产品里你经常需要弹一个 Dialog、Toast、 自动补全列表——这些就是 Overlay。hand-tui 把 Overlay 实现成"在最终 Vec<String> 上做行级覆盖", 而不是给组件树插一层 z-index。

两套合成器,渐进迁移

  • legacy Overlay + render_with_overlay:单 Overlay、五种锚点(中心 / 四角)、 可选边框 / 背景调暗。
  • rich OverlayOptions + compose_overlays:用于 Tui 的可堆叠多 Overlay,支持自定义 anchor、margin、 位置回退。

样式泄漏防护(style-leak)

Overlay 经常用背景色 / bold,如果合成行没以 \x1b[0m 收尾,下一帧 diff 会把这些 SGR "黏"到下方区域。compose_overlays 因此做了两件事:

  1. 每条被 Overlay 覆盖的行末尾追加 \x1b[0m
  2. Overlay 下方的第一行开头追加 \x1b[0m

Tui::hide_overlay 还会强制下一帧走 full render, 防止 prev_lines 缓存里残留 Overlay 字节。

CURSOR_MARKER:用 APC 序列藏硬件光标位置

问题:render 输出是字符串,组件不知道自己被渲染到了第几行; 但 Editor 这种组件必须告诉终端"硬件光标该闪在哪格"。
rust
pub const CURSOR_MARKER: &str = "\x1b_hand:c\x07";
//                                 ^^^^^^^^^^^^^^^^^
//                                 APC: ESC _  payload  BEL
// 终端把 APC 当 0 宽度无副作用,所以即便意外漏出去也不留痕

焦点组件把这个 marker 嵌进自己 render 出的行里。Tui 每帧扫描 extract_cursor_position

  1. 从下往上找位于 viewport 内的最后一个 marker,记下 (row, col)
  2. 把所有 marker 字节从所有行里剥掉,避免泄到终端
  3. 渲染完后用 \x1b[{n}A\x1b[{col}G 把硬件光标挪到那个位置
  4. 下一次 diff 前再把光标"还回"区域底部,维持 DiffRenderer 的不变量

整套设计的精彩之处在于:组件根本不需要知道自己在第几行, 只要在自己的 render 里"在该闪光标的地方插这串字节"就行。

built-in components

16 个内建组件

覆盖从最朴素的 Text 到带 undo/redo 的 Editor, 再到 Kitty/iTerm2 图像协议的 Image。 所有组件都 纯 Rust、零外部 binary 依赖。

组件 定位 亮点 / 复杂度 LOC
TextComponent 静态 / 动态文本 可选 padding;最简单的 sanity 组件 188
TruncatedTextComponent 带省略号截断 truncate_to_width,ANSI 感知 113
SpacerComponent 空白占位 装饰用,impl Component 极简示例 61
StatusBarComponent 顶 / 底状态栏 左中右三段;可配主题色 127
ProgressBarComponent 水平进度条 带 label 和百分比 163
BoxComponent 带 padding / 背景的包装 用于聚合视觉块 212
ToastComponent 通知栈 Info / Success / Warning / Error 四档 186
LoaderComponent 动画 spinner 可换帧组、可换颜色 232
CancellableLoaderComponent 可取消的 Loader 带取消键位 + 超时钩子 200
InputComponent 单行输入 history / placeholder / prefix 535
SelectListComponent 可导航列表 Home / End、过滤;选择回调 585
SettingsListComponent 设置面板 toggle / choice / text 三种行 503
ImageComponent 内联图像 Kitty / iTerm2 协议 + ASCII fallback 303
MarkdownComponent Markdown 渲染 pulldown-cmark;标题/代码块/列表/粗体 1118
AutocompleteComponent 下拉补全 异步 Provider、滚动、键位导航 1289
EditorComponent 多行编辑器 undo/redo 合并、kill-ring、IME、autocomplete 2515

复杂度集中在三个组件

EditorComponent (2515 LOC)

带 grapheme-aware 编辑、视口滚动、paste markers (>10 行或 >1000 字符自动转 placeholder)、 500ms 内的相邻 insert 合并入同一个 undo entry。 autocomplete 走 20ms 防抖。

AutocompleteComponent (1289 LOC)

AutocompleteProvider trait 异步返回 Suggestion 列表;内建 CombinedAutocompleteProviderPathAutocompleteProviderSlashCommandProvider。 触发器分 Slash / At 两类。

theme

主题系统:四种颜色表示,一个 Style 链式 API

Color 枚举支持 Named / Index(u8) / Rgb / Hex, 每种自动展开为对应的 ANSI 前景 / 背景序列。 Style 是 builder 风格,.apply(text) 输出 \x1b[…]m TEXT \x1b[0m,自带 reset 收尾。

rust
use hand_tui::theme::{Theme, Style, Color, NamedColor};

let dark  = Theme::dark();
let light = Theme::light();

let styled = Style::new()
    .fg(Color::Named(NamedColor::Green))
    .bold()
    .apply("styled text");

// 任何一种颜色表示都行
let c1 = Color::Hex("#ff6600".to_string());
let c2 = Color::Rgb { r: 255, g: 122, b: 69 };
let c3 = Color::Index(208);

Color 实现了 Serialize/Deserialize, 所以主题可以直接写进 JSON 配置——这对"让用户在配置文件里自定义颜色" 的场景非常关键。

known limits

已知边界与权衡

这是 hand-tui 0.1,README 把"和上游 TS 实现不一致的地方" 整齐地列了出来。这种主动揭短的姿态值得抄。

behavior EditorComponent::yank_pop

M-y 循环 kill-ring 时会清掉 in-flight 的 redo 历史; 上游 TS 会保留。

behavior SelectList 程序化变更不触发回调

set_filter / set_selected_index 只在用户 主动导航时触发 on_selection_change

behavior KeybindingsManager::unset

永久禁用一个绑定,而不是恢复默认。要恢复默认得自己重新注册。

runtime Tui::run 单次性

同一个 Tui 调两次 run,不手动拆 stdin reader 的话会泄漏后台 task。当前最佳实践:每个 session 新建一个 Tui

panic ProcessTerminal::Drop

panic = "abort" 编译时不走栈展开,Drop 不跑, 终端可能停在 raw / alt-screen 状态。需要的话装个 panic hook。

unicode ZWJ 序列宽度

彩虹旗 🏳️‍🌈 在这套实现里宽度算 1(上游 TS 用 Intl.Segmenter 算 2)。本质是 unicode-width 不建模 grapheme cluster 宽度。

quickstart

最小可运行示例

render-only(同步)

rust
use hand_tui::{Container, Component, TextComponent};

fn main() {
    let mut root = Container::new();
    root.add_child(Box::new(
        TextComponent::new("Hello from hand-tui"),
    ));

    let lines = root.render(80);
    for line in lines {
        println!("{line}");
    }
}

不进 raw mode、不接管终端——纯函数式输出,适合调试组件。

full Tui(异步)

rust
use hand_tui::{Tui, ProcessTerminal, EditorComponent};

#[tokio::main]
async fn main() -> hand_tui::TuiResult<()> {
    let term = ProcessTerminal::new()?;
    let mut tui  = Tui::new(Box::new(term));

    let editor_id = tui.root_mut()
        .add_child_with_id(Box::new(
            EditorComponent::new(),
        ));
    tui.set_focus(Some(editor_id));

    tui.run().await
}

run().await 才会接管终端、起 stdin reader、跑 ~4ms 节拍的渲染循环。

takeaways

读完这套代码可以带走什么

  1. 把"光标位置不变量"当成约束级语言写进 doc comment。 这种把"模块内的隐性契约"写在源文件最顶头的姿态,让任何后人改这段代码 都明白"哪一条不能动"。
  2. 用 marker 字节传通信。 APC(ESC _ ... BEL)是个被遗忘的角落——零宽、终端忽略, 天然适合做"组件 → 框架"的元数据通道。CURSOR_MARKER 是个干净的范例。
  3. 把同步重组和异步分发切干净。 StdinBuffer 一行 async 都没有,所有失败模式都能用 Vec<u8> 的单测覆盖;异步只活在最外层的 Tui::run
  4. 纯函数 render 是组件级测试的前提。 "宽度 → 行数组"的签名让所有组件 test 都长一个样: assert_eq!(c.render(80), vec![...])
  5. 主动列举边界比假装没有更有说服力。 README 把和上游不一致的 6 个点全摆出来——这反而让人敢用, 因为知道哪些坑已经标记。
下一步建议读什么: tui.rs(2453 行的运行时核心,看 run 主循环怎么编排)→ editor.rs(2515 行的最复杂组件,看 grapheme + undo 合并)→ terminal_image.rs(879 行的图像协议协商)。