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:
cargo install cargo-ratcn
cargo new my-app
cd my-app
cargo ratcn initinit 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:
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:
cargo ratcn add --list
cargo ratcn add dialogadd 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:
| Feature | For |
|---|---|
crossterm | Terminal apps on a crossterm backend |
termina | Terminal apps using ratcn::terminal::Session, which opens and restores the terminal and can follow its colors |
ratzilla | Running in the browser through Ratzilla |
| (none) | Paint-only widgets, or your own backend |
cargo add ratcn --features crossterm
cargo add ratatui --no-default-features --features layout-cache,std,crosstermWiring 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:
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:
git clone https://github.com/kristoferlund/ratcn
cd ratcn
cargo run -p ledger93See 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.