Skip to content

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 ButtonWidget and BorderWidget, that render directly with Ratatui.
  • Focusable components, such as Button, Input, List, and TextArea, that plug into a View tree.

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.

PieceRole
AppStateYour app-owned state, including FocusState, theme, form values, selected rows, dialogs, and toasts.
MsgYour app's message enum. Components emit these messages; your update function handles them.
ViewThe focus tree. It registers interactive children, routes events, and renders children into Ratatui layout areas.
updateYour function that mutates AppState in response to Msg.

The library enters your app at two call sites:

rust
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.

rust
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.

  • Button emits a message when pressed.
  • Input reads an InputState and emits the next InputState after edits.
  • List reads the focused row and emits the next focused row.
  • Dialog wraps a nested View; your app still owns whether it is open.
  • Toast is 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(...).

rust
.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.

rust
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.

ResultMeaning
Emit(msg)A component handled the event and produced an app message.
ConsumedA component handled the event without producing a message.
IgnoredNo 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.

rust
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.

Where To Go Next

  • Read View for focus scopes, nested views, and ViewCtx.
  • Read Button, Input, and List for component-specific event APIs.
  • Use paint-only widgets directly when you only need pixels and not focus or messages.