Introduction
ratcn is a component library for Ratatui apps: beautifully designed terminal UI components that you can copy, paste, theme, and own in your application code.
This site is a preview of the upcoming library. Not all components have been implemented yet. If there are specific components, patterns, or features you would like to see included, please open an issue. Early requests will help shape what gets prioritized before the first release.
The library has two layers:
- Paint-only widgets, such as
ButtonWidgetandBorderWidget, that render directly with Ratatui. - Focusable components, such as
Button,Input,List, andTextArea, that plug into aViewtree.
The important idea is that ratcn does not own your app loop or your state. Your app owns state, events, and updates. ratcn reads state while rendering and returns messages when something happens.
The Model
A typical ratcn app has four pieces.
| Piece | Role |
|---|---|
AppState | Your app-owned state, including FocusState, theme, form values, selected rows, dialogs, and toasts. |
Msg | Your app's message enum. Components emit these messages; your update function handles them. |
View | The focus tree. It registers interactive children, routes events, and renders children into Ratatui layout areas. |
update | Your function that mutates AppState in response to Msg. |
The library enters your app at two call sites:
root.render(frame, frame.area(), &state);
let result = root.handle_event(&event, &state);Everything else stays in your app.
Views
View is the composition and focus layer. It does not replace Ratatui layout. You still split areas with Layout, render plain Ratatui widgets directly, and call view.render_child(...) where an interactive child should appear.
View::new("root")
.child("save", Button::new("Save").on_press(Msg::Save))
.content(|frame, _state, view| {
view.render_child(frame, "save", view.area());
});Child declaration order is Tab order. Nested Views create nested focus scopes.
State
Interactive components are controlled. They do not keep hidden UI state.
Buttonemits a message when pressed.Inputreads anInputStateand emits the nextInputStateafter edits.Listreads the focused row and emits the next focused row.Dialogwraps a nestedView; your app still owns whether it is open.Toastis data; your app owns its lifecycle.
Focus is also app-owned. Store a FocusState in AppState and bind it at the root view with .focus(...).
.focus(|state: &AppState| &state.focus, Msg::FocusChanged)When focus changes, View::handle_event(...) returns EventResult::Emit(Msg::FocusChanged(next_focus)). Your update function stores that next focus snapshot.
Events And Messages
ratcn normalizes input into ratcn::view::Event. Your app passes each event to the root view.
match root.handle_event(&event, &state) {
EventResult::Emit(msg) => update(&mut state, msg),
EventResult::Consumed => {}
EventResult::Ignored => handle_app_shortcut(event),
}EventResult has three outcomes.
| Result | Meaning |
|---|---|
Emit(msg) | A component handled the event and produced an app message. |
Consumed | A component handled the event without producing a message. |
Ignored | No component handled the event; app-level shortcuts can handle it. |
Events descend to the focused leaf. Unhandled events bubble back up. This lets components own local behavior while your app keeps global shortcuts such as quit, help, or pane switching.
Minimal Example
This is the smallest useful shape: app state, messages, a root view, rendering, event routing, and update.
use ratcn::{Button, Theme};
use ratcn::view::{Event, EventResult, FocusPolicy, FocusState, View};
struct AppState {
focus: FocusState,
theme: Theme,
saved: bool,
}
enum Msg {
FocusChanged(FocusState),
Save,
}
fn build_view() -> View<AppState, Msg> {
View::new("root")
.focus(|state: &AppState| &state.focus, Msg::FocusChanged)
.theme(|state: &AppState| &state.theme)
.focus_policy(FocusPolicy::Wrap)
.child("save", Button::new("Save").on_press(Msg::Save))
.content(|frame, _state, view| {
view.render_child(frame, "save", view.area());
})
}
fn update(state: &mut AppState, msg: Msg) {
match msg {
Msg::FocusChanged(focus) => state.focus = focus,
Msg::Save => state.saved = true,
}
}
fn draw(root: &mut View<AppState, Msg>, frame: &mut ratatui::Frame, state: &AppState) {
root.render(frame, frame.area(), state);
}
fn handle_event(root: &mut View<AppState, Msg>, event: Event, state: &mut AppState) {
match root.handle_event(&event, state) {
EventResult::Emit(msg) => update(state, msg),
EventResult::Consumed => {}
EventResult::Ignored => {
// App-level shortcuts can run here.
}
}
}In a terminal app, draw(...) is called from your Ratatui draw callback, and handle_event(...) is called after converting backend events into ratcn::view::Event.