Skip to content

Getting started

The recommended way to set up a terminal project is with the cargo-ratcn CLI.

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 with its termina feature and a compatible ratatui, writes ratcn.toml, and creates src/components/mod.rs. It configures terminal apps only.

In an interactive terminal, when src/main.rs is Cargo's untouched default, init offers three options:

  • Keep it unchanged
  • Create a minimal app
  • Create a demo app

Projects with custom application source keep it unchanged. Noninteractive runs also keep src/main.rs unchanged and complete project setup without a prompt.

Use cargo ratcn --help for commands or cargo ratcn add --help for add options. Use cargo ratcn --version (or -V) to check the installed CLI version.

A first app

Choose Create a demo app during init, then run cargo run to start it. The app follows the terminal's colors, centers a primary Hello button, and shows a World toast when pressed. 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.
        let _ = self.state.toasts.prune_expired(now);

        let area = frame.area();
        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()),
        );

        self.ratcn.render(frame, area, &self.state, theme, |ctx| {
            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))?;

        // Wait for input, or wake when the next toast is due to disappear.
        let timeout = app.state.toasts.time_until_next_expiry(now);
        match session.next(timeout)? {
            Some(SessionEvent::Input(event)) if is_quit(&event) => return Ok(()),
            Some(SessionEvent::Input(event)) => app.handle_event(event, started.elapsed()),
            _ => {}
        }
    }
}

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 if something happened. The generated loop opens and restores the terminal through Session; its update function remains the only state writer.

Keeping update in its own function means every state change is a plain call you can test without a terminal, and messages from elsewhere (a timer, a background task) get the same single path into state.

Copy a component

From your initialized project, list the available components and copy one:

sh
cargo ratcn add --list
cargo ratcn add dialog

add copies source from the exact ratcn package your project resolved into src/components/ and registers the component module. It adds mod components; when there is a single conventional crate entrypoint; otherwise, it asks you to add that declaration yourself. Import crate::components::dialog::Dialog to use your copy.

Existing component files are preserved unless you pass --force. cargo ratcn add dialog --force overwrites src/components/dialog.rs, including your edits.

Try the wizard

The wizard below is itself a ratcn app — 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 — Dialog and ScrollArea are composites and have no widget half:

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

It takes a theme and a couple of bools. If you already have focus and event handling you like, keep it — and adopt the runtime later, one component at a time, if you want to.

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.