tl;dr
一句话结论
hand-tui = 声明式组件树 + 差分渲染器 + 异步输入流水线。
它不打整屏,只发改动;不接管事件循环,由 tokio 拉动;
组件之间通过 ComponentId、OverlayHandle
等稳定句柄解耦,背后是一套对终端"光标位置"的强不变量约束。
Component · Focusable · Terminal从
Text 到 Editor、Markdown、ImageRENDER_TICK_MS,约 240Hz 上限帧之间始终落在"末行的下一行"
hand-tui 在这层之上引入了 "前一帧 / 当前帧 diff"
的策略,再用一个写死的光标位置不变量把"复杂在哪 / 简单在哪"切干净。
architecture
三层架构:应用 / 运行时 / I/O
逻辑分层很清爽:顶层是你写的组件树,中层是 Tui
运行时(持有组件树、渲染器、监听器、Overlay 栈),底层是终端 I/O
与 stdin 重组。三层间用 trait 解耦,方便测试和换底层。
impl Component组合内建 16 个组件
render tick 合并请求
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 返回 Handled 或 Ignored 让父级决定是否冒泡。
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 不主动消费。
add_child_with_id 返回一个 ComponentId(u64),
后续 focus / remove / lookup 全部走它——位置索引一变就会失效,这是早期
很多 TUI 框架踩过的坑。
焦点派发的优先级
正向(焦点优先)
- 当前 focused 子节点(如果非 hidden)
- 其余子节点:倒序遍历(最后添加 = 最顶层)
- 都不消费 → 回到 root 整体处理
逆向(消费即止)
一旦某层返回 Handled,派发立即停止;
InputListener 也可以在组件派发前消费事件(consume = true),
或者改写 raw payload(data = Some(...))用于做迁移期的兼容垫片。
differential rendering
差分渲染:只发改动那几行
DiffRenderer 是这套框架最值得拿出来讲的地方。它围绕
一个写死的"光标位置不变量",把"打整屏"压缩到了
"上次帧 vs 这次帧的最小差集"。
diff() 调用结束后,
硬件光标恰好落在"上一帧最后一行 下面的那一行第 0 列"。
下一次 diff() 进来时,先把光标 上移 N 行,
就可以用 \x1b[{n}B(下移 n 行)寻址到该区域内任意一行。
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 1006。
hand-tui 把这条"字节 → 语义事件"的路切成五段,每段职责单一。
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 因此做了两件事:
- 每条被 Overlay 覆盖的行末尾追加
\x1b[0m - Overlay 下方的第一行开头追加
\x1b[0m
Tui::hide_overlay 还会强制下一帧走 full render,
防止 prev_lines 缓存里残留 Overlay 字节。
CURSOR_MARKER:用 APC 序列藏硬件光标位置
pub const CURSOR_MARKER: &str = "\x1b_hand:c\x07";
// ^^^^^^^^^^^^^^^^^
// APC: ESC _ payload BEL
// 终端把 APC 当 0 宽度无副作用,所以即便意外漏出去也不留痕
焦点组件把这个 marker 嵌进自己 render 出的行里。Tui
每帧扫描 extract_cursor_position:
- 从下往上找位于 viewport 内的最后一个 marker,记下 (row, col)
- 把所有 marker 字节从所有行里剥掉,避免泄到终端
- 渲染完后用
\x1b[{n}A\x1b[{col}G把硬件光标挪到那个位置 - 下一次 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 列表;内建 CombinedAutocompleteProvider、
PathAutocompleteProvider、SlashCommandProvider。
触发器分 Slash / At 两类。
theme
主题系统:四种颜色表示,一个 Style 链式 API
Color 枚举支持 Named / Index(u8) / Rgb / Hex,
每种自动展开为对应的 ANSI 前景 / 背景序列。
Style 是 builder 风格,.apply(text) 输出
\x1b[…]m TEXT \x1b[0m,自带 reset 收尾。
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(同步)
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(异步)
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
读完这套代码可以带走什么
- 把"光标位置不变量"当成约束级语言写进 doc comment。 这种把"模块内的隐性契约"写在源文件最顶头的姿态,让任何后人改这段代码 都明白"哪一条不能动"。
-
用 marker 字节传通信。
APC(
ESC _ ... BEL)是个被遗忘的角落——零宽、终端忽略, 天然适合做"组件 → 框架"的元数据通道。CURSOR_MARKER 是个干净的范例。 -
把同步重组和异步分发切干净。
StdinBuffer一行 async 都没有,所有失败模式都能用Vec<u8>的单测覆盖;异步只活在最外层的Tui::run。 -
纯函数 render 是组件级测试的前提。
"宽度 → 行数组"的签名让所有组件 test 都长一个样:
assert_eq!(c.render(80), vec![...])。 - 主动列举边界比假装没有更有说服力。 README 把和上游不一致的 6 个点全摆出来——这反而让人敢用, 因为知道哪些坑已经标记。
tui.rs(2453 行的运行时核心,看 run 主循环怎么编排)→
editor.rs(2515 行的最复杂组件,看 grapheme + undo 合并)→
terminal_image.rs(879 行的图像协议协商)。