Terminal — Rust and Ratatui
These are the steps to set up and run a Crux app as a terminal UI (TUI) application using Ratatui. This is a great way to build lightweight, keyboard-driven interfaces that share the same core logic as your web and mobile apps.
This walk-through assumes you have already added the shared library to your repo, as described in Shared core and types.
Because both the core and the shell are written in Rust and run in the same process, there is no FFI boundary — the shell calls the core directly with no serialization overhead.
Create the project
Our TUI app is just a new Rust project, which we can create with Cargo.
cargo new tui
Add it to your Cargo workspace by editing the root Cargo.toml:
[workspace]
members = ["shared", "tui"]
Add the dependencies to tui/Cargo.toml:
[package]
name = "tui"
version = "0.1.0"
authors.workspace = true
edition.workspace = true
repository.workspace = true
license.workspace = true
keywords.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
shared = { path = "../shared" }
ratatui = "0.30.0"
crossterm = "0.29.0"
We depend on shared (our Crux core), ratatui (the TUI framework), and
crossterm (for terminal input handling).
The shell
The entire TUI shell lives in a single main.rs. Let's walk through the key
parts.
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
DefaultTerminal, Frame,
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Color, Style, Styled, Stylize},
symbols::border,
text::{Line, Text},
widgets::{Block, Paragraph, Widget},
};
use shared::{Core, Counter, Effect, Event as AppEvent};
const BUTTONS: [(&str, AppEvent); 3] = [
("Increment", AppEvent::Increment),
("Decrement", AppEvent::Decrement),
("Reset", AppEvent::Reset),
];
#[allow(clippy::cast_possible_truncation)]
const NUM_BUTTONS: u16 = BUTTONS.len() as u16;
struct App {
core: Core<Counter>,
selected: usize,
exit: bool,
}
impl App {
fn new() -> Self {
Self {
core: Core::new(),
selected: 0,
exit: false,
}
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
while !self.exit {
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn draw(&self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
fn handle_events(&mut self) -> io::Result<()> {
match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event);
}
_ => {}
}
Ok(())
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Char('q') | KeyCode::Esc => self.exit = true,
KeyCode::Left | KeyCode::Char('h') => self.select_prev(),
KeyCode::Right | KeyCode::Char('l') => self.select_next(),
KeyCode::Enter | KeyCode::Char(' ') => self.press_selected(),
KeyCode::Char('+' | '=') => self.dispatch(AppEvent::Increment),
KeyCode::Char('-') => self.dispatch(AppEvent::Decrement),
KeyCode::Char('0') => self.dispatch(AppEvent::Reset),
_ => {}
}
}
const fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
const fn select_next(&mut self) {
if self.selected < BUTTONS.len() - 1 {
self.selected += 1;
}
}
fn press_selected(&self) {
let (_, ref event) = BUTTONS[self.selected];
self.dispatch(event.clone());
}
fn dispatch(&self, event: AppEvent) {
for effect in self.core.process_event(event) {
match effect {
Effect::Render(_) => {
// The shell re-renders on the next loop iteration
}
}
}
}
}
impl Widget for &App {
fn render(self, area: Rect, buf: &mut Buffer) {
let view = self.core.view();
let title = Line::from(" Simple Counter ".bold());
let instructions = Line::from(vec![
" Select ".into(),
"<←→>".blue().bold(),
" Confirm ".into(),
"<Enter>".blue().bold(),
" Quit ".into(),
"<Q> ".blue().bold(),
]);
let block = Block::bordered()
.title(title.centered())
.title_bottom(instructions.centered())
.border_set(border::THICK);
let inner = block.inner(area);
block.render(area, buf);
// Split inner into: space for subtitle | main content (count+buttons) | bottom pad
// count(3) + gap(1) + buttons(3) = 7
let [top_space, main_content, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(7),
Constraint::Fill(1),
])
.areas(inner);
// -- Subtitle (vertically centered in the space above the counter) --
let [_, subtitle_area, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(top_space);
let sub_title = Line::from("Rust Core, Rust Shell (Ratatui)".bold());
Paragraph::new(sub_title)
.centered()
.render(subtitle_area, buf);
// -- Main content areas --
let [count_area, _, buttons_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(3),
])
.areas(main_content);
// -- Count display --
let counter_text = Text::from(vec![Line::from(view.count.yellow().bold())]);
let count_block = Block::bordered().border_set(border::PLAIN);
Paragraph::new(counter_text)
.centered()
.block(count_block)
.render(count_area, buf);
// -- Buttons --
ButtonBar::new(self.selected).render(buttons_area, buf);
}
}
struct ButtonBar {
selected: usize,
}
impl ButtonBar {
const fn new(selected: usize) -> Self {
Self { selected }
}
}
impl Widget for ButtonBar {
fn render(self, area: Rect, buf: &mut Buffer) {
let button_width: u16 = 14;
let gap_width: u16 = 2;
let total_width = button_width * NUM_BUTTONS + gap_width * (NUM_BUTTONS - 1);
let [_, button_strip, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(total_width),
Constraint::Fill(1),
])
.areas(area);
let constraints: Vec<Constraint> = BUTTONS
.iter()
.enumerate()
.flat_map(|(i, _)| {
if i < BUTTONS.len() - 1 {
vec![
Constraint::Length(button_width),
Constraint::Length(gap_width),
]
} else {
vec![Constraint::Length(button_width)]
}
})
.collect();
let cols = Layout::horizontal(constraints).split(button_strip);
let colors = [Color::Green, Color::Yellow, Color::Red];
for (i, (label, _)) in BUTTONS.iter().enumerate() {
let col = cols[i * 2]; // even indices are buttons, odd are gaps
let is_selected = i == self.selected;
let color = colors[i];
let (text_style, bdr_set) = if is_selected {
(
Style::new().fg(Color::Black).bg(color).bold(),
border::THICK,
)
} else {
(Style::new().fg(color), border::PLAIN)
};
let line = Line::from((*label).set_style(text_style));
let btn_block = Block::bordered()
.border_set(bdr_set)
.border_style(text_style);
Paragraph::new(line)
.centered()
.style(text_style)
.block(btn_block)
.render(col, buf);
}
}
}
fn main() -> io::Result<()> {
ratatui::run(|terminal| App::new().run(terminal))
}
How it works
The TUI shell follows the same pattern as any Crux shell, but with a terminal render loop instead of a UI framework:
-
Event loop — Ratatui runs a loop that draws the UI and then waits for keyboard input. Each keypress is mapped to an app
Event(e.g. pressing+sendsEvent::Increment). -
Dispatching events — The
dispatchmethod sends events to the core viacore.process_event()and processes the resulting effects. For this simple example, the only effect isRender, which is a no-op in the TUI — the shell re-renders on every loop iteration anyway. -
Rendering the view — On each frame, the shell calls
core.view()to get the currentViewModeland renders it using Ratatui widgets. The counter value is displayed in a bordered box with a row of selectable buttons below it. -
No serialization — Because both the core and the shell are Rust running in the same process, we call
Core::new(),core.process_event(), andcore.view()directly with native Rust types.
Build and run
cargo run -p tui
Your app should look something like this in the terminal:
┏━━━━━━━━━━━━━━ Simple Counter ━━━━━━━━━━━━━━┓
┃ ┃
┃ Rust Core, Rust Shell (Ratatui) ┃
┃ ┃
┃ ┌───────────────────┐ ┃
┃ │ 0 │ ┃
┃ └───────────────────┘ ┃
┃ ┃
┃ ┃ Increment ┃ │ Decrement │ │ Reset │┃
┃ ┃
┗━━ Select <←→> Confirm <Enter> Quit <Q> ━━━━┛
</div>
</div>