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:
cargo install cargo-ratcn
cargo new my-app
cd my-app
cargo ratcn initinit 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:
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:
cargo ratcn add dialogThe 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:
| 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:
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:
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.