ratatui-tui
Ratatui TUI Development
Quick Start
-
Copy template to project:
cp -r ~/.agents/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 widgetTableState- for Table widgetScrollbarState- 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-staticfeature for portable binaries - Query protocol once, not per-frame
- Offload resize/encode to background thread
- Use
StatefulImageto 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 pointapp.rs- App state, update logicevent.rs- event handlingui.rs- renderingaction.rs- Action enumtui.rs- terminal setupconfig.rs- configuration with dirslogging.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-featuresclean - No
unwrap()outside tests - Panic hook restores terminal
-
cargo build --releasesucceeds - Test on target terminal(s)
More from blacktop/dotfiles
code-simplifier
Simplifies and refines code for clarity, consistency, and maintainability while preserving all functionality. Focuses on recently modified code unless instructed otherwise.
3handoff
Generate optimized handoff prompts for delegating work to another LLM agent. Use when handing work to GPT-5.x/Codex, Claude 4.x, Gemini 3.x, or Grok 4.x, either as a shared-workspace sub-task handoff or a fresh-context handoff for a new session or model. Triggers on requests like "create a handoff prompt", "delegate this task to another agent", "hand this off", or "prepare context for another agent".
1rust-profiling
Profile Rust code using samply to identify CPU bottlenecks. Use when performance is slow, before optimizing, or when the user asks to profile.
1go-performance
Measure and improve Go program performance using current Go 1.26-era workflow. Use when profiling Go code, diagnosing CPU or memory bottlenecks, investigating latency or contention, writing or fixing benchmarks, comparing benchmark results, using pprof or trace data, applying PGO, or tuning hot-path Go code.
1second-opinion
Run an external LLM code review with Codex CLI, Gemini CLI, or both. Use when the user asks for a second opinion, external review, Codex review, Gemini review, or wants a model-vs-model review of current changes, a branch diff, a specific commit, or a GitHub pull request.
1humanizer
|
1