声明式语法
JSX 风格的过程宏 —— UI 由数据描述,而不是手写绘制指令。一套你熟悉的写法,把组件树编译成终端布局。
- if / for / match 是子节点块里的一等公民
- { expr } 内嵌任意返回 Element 的 Rust 表达式
- widget() / stateful() 桥接原生 ratatui widget
RATATUI KIT
组件、Hooks、输入层、路由和全局状态,一套你熟悉的 React 式模型,构建在 Ratatui 和 Tokio 之上。
快速入门
进入和退出终端、手写渲染循环、用 Instant 维护计时器、轮询并分发每个按键——计数器还没开始,样板代码已经堆满了 main。
use_state 管状态、use_future 跑异步、element! 声明界面。终端生命周期、渲染循环和退出都交给运行时——同一个计数器,少写一半代码。
fn main() -> std::io::Result<()> {
let mut terminal = ratatui::init(); // 手动进入 / 退出终端
let mut count = 0_u64; // 状态散落在 main
let mut last = Instant::now(); // 自己维护计时器
loop { // 手写渲染循环
terminal.draw(|f| ui(f, count))?;
if last.elapsed() >= Duration::from_secs(1) {
count += 1; // 手动推进状态
last = Instant::now();
}
if event::poll(Duration::from_millis(50))? {
if let Event::Key(k) = event::read()? {
if k.code == KeyCode::Char('q') {
break; // 自己处理退出
}
}
}
}
ratatui::restore(); // 别忘了恢复终端
Ok(())
} #[component]
fn Counter(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let mut count = hooks.use_state(|| 0_u64);
hooks.use_future(async move {
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
count += 1;
}
});
element!(
Border(border_style: Style::new().yellow()) {
Text(text: Line::from(format!("Counter: {:02}", count.get())))
}
)
} 从空目录到会动的终端,三步。
把 ratatui-kit 和 Tokio 加进 Cargo.toml。框架默认不开任何 feature,按需再打开 router、atom、input 等。
use_state 管状态、use_future 跑异步、element! 声明 UI。组件实例由运行时跨帧保留,写入状态自动唤醒渲染。
cargo run,终端进入全屏。计数器每秒自增,按 q 退出、Ctrl+C 强制结束,退出后终端自动恢复。
[dependencies]
ratatui-kit = "0.10.2"
tokio = { version = "1", features = [
"rt-multi-thread", "macros", "time",
] } use ratatui_kit::prelude::*;
#[tokio::main]
async fn main() {
element!(Counter).fullscreen().await.unwrap();
}
#[component]
fn Counter(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let mut count = hooks.use_state(|| 0_u64);
hooks.use_future(async move {
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
count += 1;
}
});
element!(
Border(border_style: Style::new().yellow()) {
Text(text: Line::from(format!("Counter: {:02}", count.get())))
}
)
}
Ratatui Kit 不替代 Ratatui,而是在它之上补齐组件更新、状态管理和事件路由, 让大型终端应用也能保持结构清晰。
JSX 风格的过程宏 —— UI 由数据描述,而不是手写绘制指令。一套你熟悉的写法,把组件树编译成终端布局。
use_state、use_future、use_effect 与自定义 Hook 按调用顺序组织。状态句柄是 Copy 的,支持 count += 1 运算符重载,随组件卸载自动释放。
事件不再广播给所有组件,而是按当前活动输入层路由。弹窗或进入编辑态时申请一层,背景列表自动让出键盘,从根上消除焦点争抢。
状态写入唤醒渲染循环,框架只在真正变化时重绘,而非命令式刷屏。基于 generational-box 的响应式句柄,让终端 UI 像前端框架一样按需更新。
共享 Palette 是唯一颜色真源 —— 每个组件都据它派生样式。换掉 palette 即可给整棵树重新配色,可覆盖单一组件类型,也可交给 Atom 驱动实现运行时换肤。
布局、输入、弹层、选择与虚拟列表开箱即用,业务中立、可组合。
Router 管理多页面与历史栈,Atom 管理跨页共享的业务状态。
// 进入编辑态时申请一个输入层
let edit_layer = hooks.use_input_layer(editing.get(), true);
// 只有这一层激活时,handler 才会收到键盘事件
hooks.use_event_handler(
EventScope::Layer(edit_layer),
EventPriority::High,
move |event| {
let Event::Key(key) = event else {
return EventResult::Ignored;
};
match key.code {
KeyCode::Enter => editing.set(false),
KeyCode::Char(c) => draft.write().push(c),
_ => {}
}
// 背景列表不会再看到这些按键
EventResult::Consumed
},
);
从布局原语到虚拟列表,14 个内置组件保持业务中立、可组合、输入语义清晰。 向下滚动逐个浏览,每个都有可运行 example 和真实录屏。
最基础的布局与文本原语:flex 容器、边框分组、段落渲染,把组件树切成可预测的终端区域。
查看 View · Border · Text 文档快捷键帮助弹窗:按分组展示快捷键,内容超高时交给内部 ScrollView 滚动,并保持弹窗输入互斥。
查看 ShortcutInfoModal 文档多选列表:交互分两层 —— Space 切换草稿选择触发 on_change,Enter 提交选择触发 on_select。
查看 MultiSelect 文档长列表(virtual-list feature):复用 tui-widget-list 只渲染可见窗口,可承载几万行并自定义行渲染。
查看 VirtualList 文档长列表多选的组合模式:VirtualList 管光标与窗口化渲染,业务用外部 HashSet 保存多选集合。
查看 VirtualMultiSelect 文档View(flex_direction: Direction::Vertical, gap: 1) {
Text(text: "header")
View(height: Constraint::Fill(1)) {
Text(text: "body")
}
Text(text: "footer")
} ScrollView(
flex_direction: Direction::Vertical,
block: Block::bordered(),
) {
for (i, row) in rows.iter().enumerate() {
View(key: i, height: Constraint::Length(1)) {
Text(text: row)
}
}
}
ScrollView(scroll_view_state: scroll_state, block: Block::bordered()) {
WrappedText(
text: BODY,
wrap_width: 72,
style: Style::new().white(),
)
}
Border(height: Constraint::Length(3)) {
Input(
input: input.read().clone(),
placeholder: "Type and press Enter".to_string(),
cursor_style: Style::new().bg(Color::Yellow),
)
}
SearchInput(
value: query.read().to_string(),
placeholder: "Press s to search".to_string(),
on_change: move |next: String| query.set(next),
on_submit: move |value: String| {
submitted.set(value);
true
},
)
Modal(
open: open.get(),
width: Constraint::Length(68),
height: Constraint::Length(12),
) {
Border(top_title: Line::from("Details").centered()) {
Text(text: Line::from("Modal content"))
}
}
ConfirmModal(
open: confirm_open.get(),
title: Line::from("Delete release?"),
content: format!("Remove {selected_label}?"),
confirm_text: "Delete".to_string(),
on_confirm: move |_: ()| confirm_open.set(false),
on_cancel: move |_: ()| confirm_open.set(false),
)
AlertModal(
open: alert_open.get(),
title: Line::from("Workspace is current"),
message: format!("{selected_label} is synced."),
on_close: move |_: ()| alert_open.set(false),
)
ShortcutInfoModal(
open: shortcuts_open.get(),
title: Line::from("Shortcut reference"),
sections: vec![ShortcutInfoSection::new(
"Navigation",
[("Down", "j"), ("Up", "k")],
)],
on_close: move |_: ()| shortcuts_open.set(false),
)
Select<&'static str>(
items: items,
default_index: Some(1),
highlight_symbol: "> ",
empty_message: "No environments",
on_select: move |item: &'static str| {
selected.set(item);
},
)
MultiSelect<&'static str>(
items: items,
highlight_symbol: "> ",
on_change: move |items: Vec<&'static str>| {
selected_count.set(items.len());
},
on_select: move |items: Vec<&'static str>| {
submitted.set(items.len());
},
)
TreeSelect<&'static str>(
active: true,
items: demo_items(),
default_selection: vec!["components", "select"],
node_open_symbol: "- ",
node_closed_symbol: "+ ",
on_select: move |id: &'static str| {
submitted.set(id);
},
)
VirtualList<Line<'static>>(
item_count: 10_000,
default_index: Some(42),
render_item: |ctx: &ListBuildContext| {
let style = if ctx.is_selected {
Style::new().black().on_green()
} else {
Style::new()
};
(Line::styled(format!("row {:05}", ctx.index + 1), style), 1u16)
},
)
VirtualList<Line<'static>>(
state: list_state,
item_count: item_count,
render_item: move |ctx: &ListBuildContext| {
let mark = if selected.contains(&ctx.index) { "[x]" } else { "[ ]" };
(Line::styled(format!("{mark} Row {:05}", ctx.index + 1), Style::default()), 1u16)
},
)
首页只承担一件事:把你带到正确的第一篇文档,再让示例和参考材料持续接上。
写一个每秒自增的组件,再逐步加上异步状态、输入层和路由。
cargo add ratatui-kit