跳转到内容

Ratatui Kit

像写前端一样,写终端应用。

组件、Hooks、输入层、路由与全局状态——构建在 Ratatui 与 Tokio 之上。你只描述 UI,渲染循环交给运行时。

ratatui-kit · todo_app.rs tasks 5 · all
search
Press a to add a task
tasks / all
    state
    open: 4
    done: 1
    selected: Review runtime
    event

    ready

    j/k move · space toggle · f filter · d delete · a add · q quit 这个终端是活的——点它,然后按 j / k
    quick start

    同一个计数器,去掉全部杂务

    左边是纯 Ratatui:终端、循环、计时器、退出都归你管。右边是 ratatui-kit:只剩状态和 UI——其余都是运行时的事。

    纯 ratatui · 21 行 ratatui-kit · 17 行

    main.rs · plain ratatui
    fn main() -> std::io::Result<()> {
        let mut terminal = ratatui::init();
        let mut count = 0_u64;
        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(())
    }
    counter.rs · ratatui-kit
    #[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())))
            }
        )
    }
    01 · 添加依赖 cargo add ratatui-kit tokio

    默认不启用任何 feature——按需打开 router、atom、input、tree。

    02 · 写下组件 src/main.rs

    use_state 存状态,use_future 跑异步,element! 声明组件树;组件实例跨帧保留。

    03 · 跑起来 cargo run

    终端进入全屏,计数器每秒 +1,按 q 退出并自动恢复终端。

    你熟悉的模型,接上终端

    ratatui-kit 不替代 Ratatui——它补上成长中的终端应用需要的组件、状态与事件层。

    element!

    声明式语法

    JSX 风格的过程宏:if / for / match 在子块里是一等公民,{ expr } 内嵌任意 Rust 表达式,widget() 桥接原生 ratatui widget。

    use_state · use_future

    Hooks 与状态

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

    use_input_layer

    输入层互斥

    事件路由到激活的输入层,而不是广播给每个组件。打开弹窗或进入编辑态,背景自动让出键盘。

    Waker

    响应式渲染

    状态写入唤醒渲染循环——只在真实变化时重绘,不靠定时器,也不靠手动。

    Palette · ThemeOverride

    主题系统

    一份共享 Palette 就是全部颜色的唯一来源。换掉它即可重绘整棵组件树,也可以用 Atom 驱动运行时换肤。

    RouterProvider · Atom

    路由与全局状态

    带历史栈的 Router 管理多个页面;Atom 管理跨页面共享的业务状态。

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

    模态打开或进入编辑时,框架把键盘交给该层——背景组件彻底收不到按键。

    阅读输入层参考 →
    input layer 1 — search SearchInput 编辑期申请;当前已关闭
    input layer 2 — confirm modal 独占:背景列表收不到任何按键
    root scope — task list j/k 导航、f 过滤、d 删除

    按键事件 ─▶ 第 2 层 · 模态——背景一无所获

    input_mutex.rs
    // Request an input layer when entering edit mode
    let edit_layer = hooks.use_input_layer(editing.get(), true);
    
    // The handler only receives key events while this layer is active
    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),
                _ => {}
            }
            // The background list never sees these keys
            EventResult::Consumed
        },
    );

    一次状态写入,唤醒整个渲染循环

    一次赋值,所有依赖自动重渲染——没有重绘调用,没有定时器。这个按钮就是全部思想:

    counter 组件
    Counter
    ├─ Border ───────── count: 07
    └─ Text ─────────── count: 07

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

    14 个与业务无关的组件——布局、输入、弹层、选择、虚拟列表——每个都有可运行示例和真实录屏。点击左侧列表,或聚焦后按 j / k 浏览。

    弹性容器、带边框分组与段落渲染——把组件树切成可预期终端区域的原语。

    查看文档 →
    hello_world.rs
    View(flex_direction: Direction::Vertical, gap: 1) {
        Text(text: "header")
        View(height: Constraint::Fill(1)) {
            Text(text: "body")
        }
        Text(text: "footer")
    }

    超出视口的内容先渲染进更大的缓冲区,再按偏移显示——自带 j/k、翻页与滚动条。

    查看文档 →
    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 组件录屏

    按给定宽度硬换行,再把真实行数写回布局高度,让 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 组件录屏

    渲染 tui_input 状态的单行输入框;写入、提交与退出由页面 handler 驱动。

    查看文档 →
    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 组件录屏

    带编辑态的搜索框:按 s 进入输入并开启独占层——激活、提交、校验与互斥全包。

    查看文档 →
    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 组件录屏

    内置独占输入层的确认弹窗:确认/取消、按钮焦点与互斥,由 open 控制。

    查看文档 →
    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 组件录屏

    无按钮的通知弹窗:展示消息、触发 on_close,期间拦截下层输入。

    查看文档 →
    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 组件录屏

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

    查看文档 →
    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 组件录屏

    两级交互的多选列表:Space 切换草稿选择(on_change),Enter 提交(on_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 组件录屏

    基于 tui-tree-widget 的树形选择(tree feature):折叠展开、路径式选择与输入互斥。

    查看文档 →
    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 feature):只渲染可视窗口——数万行数据配合自定义行渲染。

    查看文档 →
    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 组件录屏

    长列表多选的组合范式:VirtualList 管游标与窗口渲染,业务侧维护 HashSet。

    查看文档 →
    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