Ratatui TUI Development
Quick Start
Copy template to project:
cp -r ~/.claude/skills/ratatui-tui/assets/templates/<template>/* .
Run:
cargo run
Template Selection
Complexity Template Use Case
Minimal hello-world
Learning, quick demos
Simple simple-app
Single-screen apps, tools
Async async-app
Background tasks, network
Full component-app
Multi-view, config, logging
Decision tree:
-
Need async/network? → async-app
-
Multiple screens/components? → component-app
-
Just a simple tool? → simple-app
-
Learning ratatui? → hello-world
Project Setup
Minimal Cargo.toml
[package] name = "my-tui" version = "0.1.0" edition = "2024"
[dependencies] ratatui = "0.30" crossterm = "0.29" color-eyre = "0.6"
Full Dependencies (component-app)
[dependencies] ratatui = "0.30" crossterm = { version = "0.29", features = ["event-stream"] } color-eyre = "0.6" tokio = { version = "1", features = ["full"] } futures = "0.3" clap = { version = "4", features = ["derive"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } config = "0.15" dirs = "6"
Optional: image support
ratatui-image = { version = "5", features = ["chafa-static"] }
Release Profile
[profile.release] lto = true codegen-units = 1 panic = "abort" strip = true
Core Loop: TEA (The Elm Architecture)
Model → Message → Update → View ↑ | └─────────────────────────┘
struct App { counter: i32, should_quit: bool, }
enum Message { Increment, Decrement, Quit, }
impl App { fn update(&mut self, msg: Message) { match msg { Message::Increment => self.counter += 1, Message::Decrement => self.counter -= 1, Message::Quit => self.should_quit = true, } }
fn view(&self, frame: &mut Frame) {
let text = format!("Counter: {}", self.counter);
frame.render_widget(Paragraph::new(text), frame.area());
}
}
Styling Rules
Use Stylize trait helpers:
use ratatui::style::Stylize;
// Good "text".bold() "text".dim() "text".cyan() "text".on_dark_gray() "text".bold().cyan()
// Avoid Style::default().fg(Color::White) // hardcoded white Style::default().fg(Color::Black) // hardcoded black Style::new().add_modifier(Modifier::BOLD) // verbose
Color palette:
-
Primary: .cyan() , .green()
-
Error: .red()
-
Warning: .yellow() (sparingly)
-
Muted: .dim() , .dark_gray()
-
Accent: .magenta()
Text wrapping:
use textwrap::wrap; use ratatui::text::Line;
let wrapped: Vec<Line> = wrap(&long_text, width as usize) .into_iter() .map(|cow| Line::from(cow.into_owned())) .collect();
See: references/style-guide.md
Widget Patterns
StatefulWidget
struct MyList { items: Vec<String>, }
struct MyListState { selected: usize, }
impl StatefulWidget for MyList { type State = MyListState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
// render with state.selected
}
}
// Usage frame.render_stateful_widget(my_list, area, &mut state);
Layout
let [header, main, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1), ]).areas(frame.area());
let [left, right] = Layout::horizontal([ Constraint::Percentage(30), Constraint::Fill(1), ]).areas(main);
Built-in State Types
-
ListState
-
for List widget
-
TableState
-
for Table widget
-
ScrollbarState
-
for Scrollbar
See: references/architecture-patterns.md
Async Event Handling
use crossterm::event::{EventStream, Event, KeyCode}; use futures::StreamExt; use tokio::select;
async fn run(mut app: App) -> Result<()> { let mut events = EventStream::new();
loop {
// Render
terminal.draw(|f| app.view(f))?;
// Handle events
select! {
Some(Ok(event)) = events.next() => {
if let Event::Key(key) = event {
match key.code {
KeyCode::Char('q') => break,
KeyCode::Up => app.update(Message::Up),
KeyCode::Down => app.update(Message::Down),
_ => {}
}
}
}
// Add other channels here (background tasks, timers)
}
if app.should_quit {
break;
}
}
Ok(())
}
See: references/async-patterns.md
Image Integration
use ratatui_image::{picker::Picker, StatefulImage, Resize}; use std::thread;
// Query terminal protocol support once at startup let mut picker = Picker::from_query_stdio()?;
// Load and resize in background thread let (tx, rx) = std::sync::mpsc::channel(); thread::spawn(move || { let dyn_img = image::open("photo.png").unwrap(); let protocol = picker.new_protocol(dyn_img, area.into(), Resize::Fit(None)); tx.send(protocol).unwrap(); });
// In render, use StatefulImage for efficient redraw if let Ok(protocol) = rx.try_recv() { image_state = Some(protocol); } if let Some(ref mut img) = image_state { frame.render_stateful_widget(StatefulImage::default(), area, img); }
Key points:
-
Use chafa-static feature for portable binaries
-
Query protocol once, not per-frame
-
Offload resize/encode to background thread
-
Use StatefulImage to avoid re-encoding on redraws
See: references/image-integration.md
Error Handling
use color_eyre::eyre::Result;
fn main() -> Result<()> { // Install hooks before anything else color_eyre::install()?;
// Set panic hook to restore terminal
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen
);
original_hook(panic_info);
}));
run()
}
Error propagation:
// Use ? for recoverable errors let file = std::fs::read_to_string(path)?;
// Use color_eyre context let config = load_config() .wrap_err("Failed to load configuration")?;
Release Build
cargo build --release
Binary at target/release/<name> .
Size optimization:
[profile.release] lto = true codegen-units = 1 panic = "abort" strip = true opt-level = "z" # size over speed
Templates Overview
hello-world (~25 lines)
Minimal ratatui demo using ratatui::run() .
simple-app (~80 lines)
Synchronous event loop, App struct, basic render.
async-app (~120 lines)
Tokio runtime, EventStream, select! pattern.
component-app (~300 lines)
Full modular structure:
-
main.rs
-
entry point
-
app.rs
-
App state, update logic
-
event.rs
-
event handling
-
ui.rs
-
rendering
-
action.rs
-
Action enum
-
tui.rs
-
terminal setup
-
config.rs
-
configuration with dirs
-
logging.rs
-
tracing setup
Common Patterns
Centered Popup
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let [_, center, _] = Layout::vertical([ Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2), ]).areas(area);
let [_, center, _] = Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
]).areas(center);
center
}
Key Bindings Display
let help = Line::from(vec![ " q ".bold().cyan(), "quit ".dim(), " ↑↓ ".bold().cyan(), "navigate ".dim(), " Enter ".bold().cyan(), "select ".dim(), ]);
Status Bar
let status = Line::from(vec![ " MODE ".bold().on_cyan(), format!(" {} items ", count).dim().into(), ]);
Checklist
Before shipping:
-
cargo fmt
-
cargo clippy --all-features clean
-
No unwrap() outside tests
-
Panic hook restores terminal
-
cargo build --release succeeds
-
Test on target terminal(s)