Skip to content

Getting started ​

The quickest way to a running app is the cargo-ratcn CLI. It sets up a terminal project, can write a first app for you, and copies components into your code when you want to own them. ratcn needs Rust 1.88, or 1.90 for the browser build.

Initialize a terminal app ​

Install the CLI, create a Cargo package, and initialize it:

sh
cargo install cargo-ratcn
cargo new my-app
cd my-app
cargo ratcn init

init adds ratcn and a compatible ratatui to the project, and offers to write a first app for you. It never touches a main.rs you have already written.

A first app ​

Choose Create a demo app during init, then cargo run. The app follows the terminal's colors and centers a Hello button that pops a World toast. Ctrl+C exits.

The generated src/main.rs:

rs
use std::{
    io,
    time::{Duration, Instant},
};

use ratatui::layout::Constraint;
use ratcn::{
    Button, ButtonSize, Theme, Toast, ToasterState, ToasterWidget,
    runtime::{EventResult, FocusState, Ratcn},
    terminal::{Session, SessionEvent, SessionOptions, termina},
};

/// Everything the app knows. `update` is the only place it changes.
struct AppState {
    focus: FocusState,
    toasts: ToasterState<'static>,
}

#[derive(Clone)]
enum Msg {
    FocusChanged(FocusState),
    Hello,
}

impl AppState {
    fn update(&mut self, msg: Msg, now: Duration) {
        match msg {
            Msg::FocusChanged(focus) => self.focus = focus,
            Msg::Hello => self.toasts.push(Toast::success("World"), now),
        }
    }
}

struct App {
    state: AppState,
    ratcn: Ratcn<AppState, Msg>,
}

impl App {
    fn new() -> Self {
        Self {
            state: AppState {
                focus: FocusState::default(),
                toasts: ToasterState::default(),
            },
            ratcn: Ratcn::new().focus(|state: &AppState| &state.focus, Msg::FocusChanged),
        }
    }

    /// Route one event; apply whatever message it produced.
    fn handle_event(&mut self, event: termina::Event, now: Duration) {
        if let EventResult::Emit(msg) = self.ratcn.handle_event(event, &self.state) {
            self.state.update(msg, now);
        }
    }

    fn draw(&mut self, frame: &mut ratatui::Frame, theme: &Theme, now: Duration) {
        // Ratcn never reads a clock; the app says what time it is.
        self.state.toasts.prune_expired(now);

        let area = frame.area();
        self.ratcn.render(frame, area, &self.state, theme, |ctx| {
            let button = Button::new("Hello")
                .size(ButtonSize::Large)
                .on_press(|| Msg::Hello);
            let button_area = area.centered(
                Constraint::Length(button.width()),
                Constraint::Length(ButtonSize::Large.height()),
            );
            ctx.component("hello", button, button_area);
        });
        frame.render_widget(ToasterWidget::new(&self.state.toasts, now).themed(theme), area);
    }
}

fn main() -> io::Result<()> {
    let started = Instant::now();
    let mut app = App::new();
    let mut session = Session::open(SessionOptions::new().mouse().adaptive())?;

    loop {
        let now = started.elapsed();
        let theme = session.theme();
        session
            .terminal_mut()
            .draw(|frame| app.draw(frame, &theme, now))?;
        // A hand over the button, in terminals that can show one.
        session.set_pointer_shape(app.ratcn.pointer_shape())?;

        // Wait for input, or wake when the next toast is due to disappear.
        let timeout = app.state.toasts.time_until_next_expiry(now);
        if let Some(SessionEvent::Input(event)) = session.next(timeout)? {
            let quit = is_quit(&event);
            app.handle_event(event, started.elapsed());
            // A text field copies its selection on Ctrl+C; only a Ctrl+C that
            // copied nothing quits.
            match app.ratcn.take_clipboard() {
                Some(text) => session.set_clipboard(&text)?,
                None if quit => return Ok(()),
                None => {}
            }
        }
    }
}

fn is_quit(event: &termina::Event) -> bool {
    use termina::event::{KeyCode, KeyEventKind, Modifiers};

    matches!(
        event,
        termina::Event::Key(key)
            if key.kind == KeyEventKind::Press
                && key.code == KeyCode::Char('c')
                && key.modifiers.contains(Modifiers::CONTROL)
    )
}

Two calls do the work. render declares what is on screen this frame and paints it; handle_event routes one input event and hands back a message when something happened. update applies that message, and it is the only place the state changes, which makes every change a plain function call you can test without a terminal.

Copy a component ​

When you want to change how a component looks or behaves beyond what its options allow, copy its source into your project:

sh
cargo ratcn add dialog

The copy lands in src/components/. Import crate::components::dialog::Dialog instead of ratcn::Dialog, and the component is yours to edit. cargo ratcn add --list shows what is available. An existing copy is only replaced with --force, which overwrites your edits.

Try the wizard ​

The wizard below is itself a ratcn app, built from buttons, a select, and a list. Press Enter to move through it, or Tab into a step to make its choice. Its source is demos/wizard.

Other backends ​

init configures terminal apps using termina. For another backend, add ratcn with the matching feature:

FeatureFor
crosstermTerminal apps on a crossterm backend
terminaTerminal apps using ratcn::terminal::Session, which opens and restores the terminal and can follow its colors
ratzillaRunning in the browser through Ratzilla
(none)Paint-only widgets, or your own backend
sh
cargo add ratcn --features crossterm
cargo add ratatui --no-default-features --features layout-cache,std,crossterm

Wiring the runtime into a custom loop, native or browser, is covered in Host integration.

Paint-only widgets ​

Most interactive components paint through a plain Ratatui widget you can use on its own:

rust
frame.render_widget(
    ButtonWidget::new("Save").themed(&theme).focused(is_focused),
    area,
);

It takes a theme and the interaction states you track, such as focused.

Dialog and ScrollArea are the exceptions: they are composites, with no widget of their own.

Running the demos ​

Every demo runs in your terminal from a checkout of the repository:

sh
git clone https://github.com/kristoferlund/ratcn
cd ratcn
cargo run -p ledger93

See Demos for what each one shows.

Where to go next ​

  • Demos: run something and read its source.
  • Components: what each built-in can do, with live previews.
  • State and messages: the ownership rules everything else builds on. The best next read if you plan to build something real.
  • Themes: presets, and writing your own palette.