跳转到内容

Ratatui Kit

RATATUI KIT

把终端 UI 写成可组合的应用

组件、Hooks、输入层、路由和全局状态,一套你熟悉的 React 式模型,构建在 Ratatui 和 Tokio 之上。

cargo run --example todo_app
用 ratatui-kit 构建的 todo 应用运行录屏:增删任务、过滤与状态切换

快速入门

同一个计数器,少写一半代码

纯 ratatui · 手动挡

一切都得自己来

进入和退出终端、手写渲染循环、用 Instant 维护计时器、轮询并分发每个按键——计数器还没开始,样板代码已经堆满了 main。

ratatui-kit · 自动挡

只描述状态和 UI

use_state 管状态、use_future 跑异步、element! 声明界面。终端生命周期、渲染循环和退出都交给运行时——同一个计数器,少写一半代码。

main.rs · 纯 ratatui
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())))
        }
    )
}

从空目录到会动的终端,三步。

  1. 安装依赖

    把 ratatui-kit 和 Tokio 加进 Cargo.toml。框架默认不开任何 feature,按需再打开 router、atom、input 等。

  2. 写第一个组件

    use_state 管状态、use_future 跑异步、element! 声明 UI。组件实例由运行时跨帧保留,写入状态自动唤醒渲染。

  3. 跑起来

    cargo run,终端进入全屏。计数器每秒自增,按 q 退出、Ctrl+C 强制结束,退出后终端自动恢复。

Cargo.toml
[dependencies]
ratatui-kit = "0.10.2"
tokio = { version = "1", features = [
    "rt-multi-thread", "macros", "time",
] }
src/main.rs
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())))
        }
    )
}
cargo run --example counter
counter 示例每秒自增、按 q 退出的终端录屏

像写前端应用一样写终端 UI

Ratatui Kit 不替代 Ratatui,而是在它之上补齐组件更新、状态管理和事件路由, 让大型终端应用也能保持结构清晰。

声明式语法

JSX 风格的过程宏 —— UI 由数据描述,而不是手写绘制指令。一套你熟悉的写法,把组件树编译成终端布局。

  • if / for / match 是子节点块里的一等公民
  • { expr } 内嵌任意返回 Element 的 Rust 表达式
  • widget() / stateful() 桥接原生 ratatui widget

Hooks 状态

use_state、use_future、use_effect 与自定义 Hook 按调用顺序组织。状态句柄是 Copy 的,支持 count += 1 运算符重载,随组件卸载自动释放。

输入层互斥

事件不再广播给所有组件,而是按当前活动输入层路由。弹窗或进入编辑态时申请一层,背景列表自动让出键盘,从根上消除焦点争抢。

响应式渲染

状态写入唤醒渲染循环,框架只在真正变化时重绘,而非命令式刷屏。基于 generational-box 的响应式句柄,让终端 UI 像前端框架一样按需更新。

主题

共享 Palette 是唯一颜色真源 —— 每个组件都据它派生样式。换掉 palette 即可给整棵树重新配色,可覆盖单一组件类型,也可交给 Atom 驱动实现运行时换肤。

内置组件

布局、输入、弹层、选择与虚拟列表开箱即用,业务中立、可组合。

路由与全局状态

Router 管理多页面与历史栈,Atom 管理跨页共享的业务状态。

事件按层路由,而不是广播

打开弹窗或进入编辑态时,框架把键盘交给当前层,背景组件自动让出输入。 这几行就是 examples/input/input_mutex.rs 在做的事。

阅读输入层参考
input_mutex.rs
// 进入编辑态时申请一个输入层
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
    },
);
cargo run --example input_mutex
终端里弹窗接管键盘事件后,背景列表停止响应输入的录屏

需要的组件,大多已经内置

从布局原语到虚拟列表,14 个内置组件保持业务中立、可组合、输入语义清晰。 向下滚动逐个浏览,每个都有可运行 example 和真实录屏。

01 / 14

View · Border · Text

最基础的布局与文本原语:flex 容器、边框分组、段落渲染,把组件树切成可预测的终端区域。

查看 View · Border · Text 文档
02 / 14

ScrollView

可滚动容器:超出视口的内容先渲染到更大缓冲区再按 offset 显示,支持 j/k、翻页与滚动条。

查看 ScrollView 文档
03 / 14

WrappedText

长正文:先按指定宽度硬换行,再把真实行数写回布局高度,让外层 ScrollView 正确计算滚动范围。

查看 WrappedText 文档
04 / 14

Input

单行输入框的显示组件,渲染 tui_input 状态;写入、提交、退出都交给页面 handler 控制。

查看 Input 文档
05 / 14

SearchInput

带编辑态的搜索框:按 s 进入输入并开独占输入层,封装激活、提交、清空、校验与输入互斥。

查看 SearchInput 文档
06 / 14

Modal

底层弹层:负责绘制遮罩、定位内容并为子树提供当前输入层,确认与关闭逻辑由外层组合。

查看 Modal 文档
07 / 14

ConfirmModal

确认弹窗:内部建独占输入层,封装确认/取消、按钮焦点切换与输入互斥,受控 open 决定显示。

查看 ConfirmModal 文档
08 / 14

AlertModal

提示型弹窗:无确认/取消按钮,只展示一段信息,关闭键按下时回调 on_close,期间截断下层输入。

查看 AlertModal 文档
09 / 14

ShortcutInfoModal

快捷键帮助弹窗:按分组展示快捷键,内容超高时交给内部 ScrollView 滚动,并保持弹窗输入互斥。

查看 ShortcutInfoModal 文档
10 / 14

Select

单选列表:内部持有 ListState,把 j/k、方向键、Home/End 和 Enter 变成列表导航与选择回调。

查看 Select 文档
11 / 14

MultiSelect

多选列表:交互分两层 —— Space 切换草稿选择触发 on_change,Enter 提交选择触发 on_select。

查看 MultiSelect 文档
12 / 14

TreeSelect

树形选择(tree feature):复用 tui-tree-widget 处理层级导航、折叠展开、路径型选择与输入互斥。

查看 TreeSelect 文档
13 / 14

VirtualList

长列表(virtual-list feature):复用 tui-widget-list 只渲染可见窗口,可承载几万行并自定义行渲染。

查看 VirtualList 文档
14 / 14

VirtualMultiSelect

长列表多选的组合模式:VirtualList 管光标与窗口化渲染,业务用外部 HashSet 保存多选集合。

查看 VirtualMultiSelect 文档
查看全部组件文档
hello_world.rs
View(flex_direction: Direction::Vertical, gap: 1) {
    Text(text: "header")
    View(height: Constraint::Fill(1)) {
        Text(text: "body")
    }
    Text(text: "footer")
}
scrollview.rs
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)
        }
    }
}
cargo run --example scrollview
ScrollView 组件运行录屏
wrapped_text.rs
ScrollView(scroll_view_state: scroll_state, block: Block::bordered()) {
    WrappedText(
        text: BODY,
        wrap_width: 72,
        style: Style::new().white(),
    )
}
cargo run --example wrapped_text
WrappedText 组件运行录屏
input.rs
Border(height: Constraint::Length(3)) {
    Input(
        input: input.read().clone(),
        placeholder: "Type and press Enter".to_string(),
        cursor_style: Style::new().bg(Color::Yellow),
    )
}
cargo run --example input
Input 组件运行录屏
search_input.rs
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
    },
)
cargo run --example search_input
SearchInput 组件运行录屏
modal.rs
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"))
    }
}
cargo run --example modal
Modal 组件运行录屏
confirm_modal.rs
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),
)
cargo run --example confirm_modal
ConfirmModal 组件运行录屏
alert_modal.rs
AlertModal(
    open: alert_open.get(),
    title: Line::from("Workspace is current"),
    message: format!("{selected_label} is synced."),
    on_close: move |_: ()| alert_open.set(false),
)
cargo run --example alert_modal
AlertModal 组件运行录屏
shortcut_info_modal.rs
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),
)
cargo run --example shortcut_info_modal
ShortcutInfoModal 组件运行录屏
select.rs
Select<&'static str>(
    items: items,
    default_index: Some(1),
    highlight_symbol: "> ",
    empty_message: "No environments",
    on_select: move |item: &'static str| {
        selected.set(item);
    },
)
cargo run --example select
Select 组件运行录屏
multi_select.rs
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());
    },
)
cargo run --example multi_select
MultiSelect 组件运行录屏
tree_select.rs
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);
    },
)
cargo run --example tree_select
TreeSelect 组件运行录屏
virtual_list.rs
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)
    },
)
cargo run --example virtual_list
VirtualList 组件运行录屏
virtual_multi_select.rs
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 run --example virtual_multi_select
VirtualMultiSelect 组件运行录屏

从一个组件走到完整应用

首页只承担一件事:把你带到正确的第一篇文档,再让示例和参考材料持续接上。

先从一个会动的计数器开始

写一个每秒自增的组件,再逐步加上异步状态、输入层和路由。

cargo add ratatui-kit
开始学习