
Ratatui Tui
- 247 installs
- 17 repo stars
- Updated July 16, 2026
- blacktop/dotfiles
Ratatui-tui is a skill for building terminal UIs in Rust with the ratatui crate following 2026 best practices.
About
Ratatui-tui helps build terminal user interfaces with the ratatui Rust crate. A developer uses it when creating new TUI apps, adding widgets and layouts, wiring keyboard navigation and state management, or integrating images and loading animations. It covers the v0.30.1 API, the Elm Architecture, StatefulWidget, async event handling, and release optimization, with copyable templates to start from.
- Builds terminal UIs with ratatui following 2026 Rust best practices
- Covers v0.30.1 API, Elm Architecture, StatefulWidget, and async event handling
- Includes copyable templates and ratatui-image / tui-shimmer integration
Ratatui Tui by the numbers
- 247 all-time installs (skills.sh)
- Ranked #49 of 121 Rust skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
ratatui-tui capabilities & compatibility
- Capabilities
- ratatui tui · tui development
- Use cases
- frontend · ui design
What ratatui-tui says it does
Build terminal UIs with ratatui following 2026 Rust best practices.
Covers v0.30.1 API, Elm Architecture, StatefulWidget, color-eyre.
Current stable: **0.30.1** (2026-06-05, MSRV 1.88, edition 2024).
npx skills add https://github.com/blacktop/dotfiles --skill ratatui-tuiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 247 |
|---|---|
| repo stars | ★ 17 |
| Last updated | July 16, 2026 |
| Repository | blacktop/dotfiles ↗ |
What it does
Build a Rust terminal UI with ratatui, wiring widgets, layouts, keyboard navigation, and async events.
Who is it for?
Creating new TUI apps, adding widgets/layouts, and keyboard navigation or state management in ratatui.
When should I use this skill?
Creating a new TUI app, adding widgets or layouts, or reviewing TUI code.
What you get
A working Rust terminal UI is built with widgets, layouts, keyboard navigation, and async events.
By the numbers
- Targets ratatui v0.30.1 (2026-06-05)
Files
Ratatui TUI Development
Quick Start
1. Copy template to project:
cp -r ~/.agents/skills/ratatui-tui/assets/templates/<template>/* .Or generate from the official templates repo:
cargo install --locked cargo-generate
cargo generate ratatui/templates2. Run:
cargo runVersion Notes (0.30.x)
Current stable: 0.30.1 (2026-06-05, MSRV 1.88, edition 2024).
- Modular workspace: apps keep depending on
ratatui; widget libraries
should depend on ratatui-core for API stability and fewer dependencies.
- `ratatui::run(|terminal| ...)`: initializes the terminal, installs a
panic hook that restores it, runs the closure, and restores on exit.
- `Block::shadow(...)` (new in 0.30.1): drop shadows for blocks/popups.
- Breaking since 0.29:
block::Titleremoved,layout::Alignmentrenamed
to HorizontalAlignment, Flex::SpaceAround now matches flexbox semantics (use Flex::SpaceEvenly for the old behavior), Marker is non-exhaustive.
- Performance: disabling
default-featuresalso disableslayout-cache;
re-enable it explicitly or layout performance drops sharply.
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"] }
# Optional: shimmer text animation
tui-shimmer = "0.1"Release Profile
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = trueCore 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) // verboseColor 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; keep it on the app
let picker = Picker::from_query_stdio()?;
// Load and resize in a background thread (`area` is the target Rect
// from your layout; clone the picker so the original stays reusable)
let (tx, rx) = std::sync::mpsc::channel();
let mut worker = picker.clone();
thread::spawn(move || {
let dyn_img = image::open("photo.png").unwrap();
let protocol = worker.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
Shimmer / Loading Animation
tui-shimmer sweeps a highlight across text — the "Loading…"/"Thinking…" effect used by coding-agent TUIs.
use ratatui::style::Style;
use ratatui::text::Line;
use tui_shimmer::{shimmer_spans_with_style, shimmer_spans_with_style_at_phase};
// Time-driven (call every frame; re-render on a tick to animate)
let spans = shimmer_spans_with_style("Loading...", Style::new().cyan());
frame.render_widget(Line::from(spans), area);
// Deterministic: drive phase (0.0..1.0) from app state — testable, pausable
let phase = (self.start.elapsed().as_secs_f32() / 2.0) % 1.0;
let spans = shimmer_spans_with_style_at_phase("Working...", Style::new().cyan(), phase);Key points:
- Animation needs redraws: add a tick event (~80-120ms) to the event loop
(select! with tokio::time::interval, or event::poll timeout)
- Prefer the
_at_phasevariant with phase stored in the Model — keeps
rendering pure and animation testable
- True color with automatic fallback for limited terminals
- API is experimental until 1.0 — pin and review minor bumps
Error Handling
ratatui::run() / ratatui::init() install a panic hook that restores the terminal before panicking — do not write one by hand. Install color-eyre first so the terminal is restored before its report prints:
use color_eyre::eyre::Result;
fn main() -> Result<()> {
color_eyre::install()?; // eyre hooks before terminal init
// App::run is the app's own main loop (see templates), not a ratatui API
let result = ratatui::run(|terminal| App::default().run(terminal));
Ok(result?)
}Only write a manual panic hook when constructing Terminal/Backend by hand instead of via ratatui::init().
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 --releaseBinary at target/release/<name>.
Size optimization — replaces the Release Profile block above when binary size matters more than speed:
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z" # size over speedTemplates 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
}With a drop shadow (0.30.1+):
use ratatui::layout::Offset;
use ratatui::widgets::{Block, Shadow};
let popup = Block::bordered()
.title("Confirm")
.shadow(Shadow::dark_shade().offset(Offset::new(2, 1)));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(),
]);Multi-Agent TUI Review Workflow
workflows/tui-review.js is a dynamic-workflow template for Claude Code's Workflow tool. It fans out one reviewer per TUI dimension — TEA architecture, terminal safety, styling, event handling, render performance — then adversarially verifies each finding before reporting, so only confirmed issues survive. In agents without the Workflow tool (Codex, Gemini), skip the script and apply those five dimensions as a manual review checklist instead.
Treat it as a template, not a script to run verbatim: adjust the target path, dimensions, and severity threshold to the codebase. Run it after substantial TUI changes or before a release:
Workflow({
scriptPath: "~/.agents/skills/ratatui-tui/workflows/tui-review.js",
args: { path: "src/" },
})Or ask: "run the TUI review workflow from the ratatui-tui skill on src/".
Checklist
Before shipping:
- [ ]
cargo fmt - [ ]
cargo clippy --all-featuresclean - [ ] No
unwrap()outside tests - [ ] Terminal restored on all exit paths (
ratatui::run()orinit/restore) - [ ]
cargo build --releasesucceeds - [ ] Test on target terminal(s)
[package]
name = "async-app"
version = "0.1.0"
edition = "2024"
[dependencies]
ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
color-eyre = "0.6"
tokio = { version = "1", features = ["full"] }
futures = "0.3"
use std::time::Duration;
use color_eyre::eyre::Result;
use crossterm::event::{Event, EventStream, KeyCode};
use futures::StreamExt;
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Layout},
style::Stylize,
text::Line,
widgets::{Block, Borders, Paragraph},
};
use tokio::{select, time::interval};
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
install_panic_hook();
let mut terminal = ratatui::init();
let result = run(&mut terminal).await;
ratatui::restore();
result
}
fn install_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
ratatui::restore();
original_hook(panic_info);
}));
}
#[derive(Default)]
struct App {
counter: i32,
tick_count: u64,
should_quit: bool,
}
enum Message {
Increment,
Decrement,
Tick,
Quit,
}
impl App {
fn update(&mut self, msg: Message) {
match msg {
Message::Increment => self.counter += 1,
Message::Decrement => self.counter -= 1,
Message::Tick => self.tick_count += 1,
Message::Quit => self.should_quit = true,
}
}
fn view(&self, frame: &mut Frame) {
let [main_area, status_area, help_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(frame.area());
let counter_text = format!("Counter: {}", self.counter);
let paragraph = Paragraph::new(counter_text.bold().cyan())
.centered()
.block(Block::default().borders(Borders::ALL).title("Async App"));
frame.render_widget(paragraph, main_area);
let status = Line::from(vec![
" ASYNC ".bold().on_cyan(),
format!(" tick #{} ", self.tick_count).dim(),
]);
frame.render_widget(Paragraph::new(status), status_area);
let help = Line::from(vec![
" ↑/k ".bold().cyan(),
"increment ".dim(),
" ↓/j ".bold().cyan(),
"decrement ".dim(),
" q ".bold().cyan(),
"quit ".dim(),
]);
frame.render_widget(Paragraph::new(help), help_area);
}
}
async fn run(terminal: &mut DefaultTerminal) -> Result<()> {
let mut app = App::default();
let mut events = EventStream::new();
let mut tick = interval(Duration::from_secs(1));
loop {
terminal.draw(|frame| app.view(frame))?;
select! {
Some(Ok(event)) = events.next() => {
if let Event::Key(key) = event {
let msg = match key.code {
KeyCode::Char('q') => Message::Quit,
KeyCode::Up | KeyCode::Char('k') => Message::Increment,
KeyCode::Down | KeyCode::Char('j') => Message::Decrement,
_ => continue,
};
app.update(msg);
}
}
_ = tick.tick() => {
app.update(Message::Tick);
}
}
if app.should_quit {
break;
}
}
Ok(())
}
[package]
name = "component-app"
version = "0.1.0"
edition = "2024"
[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"
humantime = "2"
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
pub enum Action {
Tick,
Render,
Quit,
Navigate(Direction),
Select,
Error(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Up,
Down,
}
use color_eyre::eyre::Result;
use crate::action::{Action, Direction};
use crate::config::Config;
use crate::event::{AppEvent, EventHandler};
use crate::tui::Tui;
use crate::ui;
pub struct App {
config: Config,
items: Vec<String>,
selected: usize,
should_quit: bool,
}
impl App {
pub fn new(config: Config) -> Self {
Self {
config,
items: vec![
"Item 1".into(),
"Item 2".into(),
"Item 3".into(),
"Item 4".into(),
"Item 5".into(),
],
selected: 0,
should_quit: false,
}
}
pub async fn run(&mut self, tui: &mut Tui, mut events: EventHandler) -> Result<()> {
loop {
tui.draw(|frame| ui::render(frame, self))?;
let action = match events.next().await? {
AppEvent::Tick => Action::Tick,
AppEvent::Key(key) => self.handle_key(key),
AppEvent::Resize(_, _) => Action::Render,
};
self.update(action);
if self.should_quit {
break;
}
}
Ok(())
}
fn handle_key(&self, key: crossterm::event::KeyEvent) -> Action {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Char('q') => Action::Quit,
KeyCode::Up | KeyCode::Char('k') => Action::Navigate(Direction::Up),
KeyCode::Down | KeyCode::Char('j') => Action::Navigate(Direction::Down),
KeyCode::Enter => Action::Select,
_ => Action::Tick,
}
}
fn update(&mut self, action: Action) {
match action {
Action::Quit => self.should_quit = true,
Action::Navigate(Direction::Up) => {
self.selected = self.selected.saturating_sub(1);
}
Action::Navigate(Direction::Down) => {
if self.selected < self.items.len().saturating_sub(1) {
self.selected += 1;
}
}
Action::Select => {
tracing::info!("Selected: {}", self.items[self.selected]);
}
Action::Error(msg) => {
tracing::error!("Error: {}", msg);
}
Action::Tick | Action::Render => {}
}
}
pub fn items(&self) -> &[String] {
&self.items
}
pub fn selected(&self) -> usize {
self.selected
}
#[allow(dead_code)]
pub fn config(&self) -> &Config {
&self.config
}
}
use std::path::Path;
use std::time::Duration;
use color_eyre::eyre::{Result, WrapErr};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Config {
#[serde(with = "humantime_serde")]
pub tick_rate: Duration,
}
impl Default for Config {
fn default() -> Self {
Self {
tick_rate: Duration::from_millis(250),
}
}
}
impl Config {
pub fn load(path: Option<&Path>) -> Result<Self> {
let mut builder = config::Config::builder();
// Load from default location if exists
if let Some(config_dir) = dirs::config_dir() {
let default_path = config_dir.join("component-app").join("config.toml");
if default_path.exists() {
builder = builder.add_source(config::File::from(default_path));
}
}
// Load from explicit path if provided
if let Some(path) = path {
builder = builder.add_source(config::File::from(path.to_path_buf()));
}
// Environment variables with prefix
builder = builder.add_source(
config::Environment::with_prefix("COMPONENT_APP")
.separator("_")
.try_parsing(true),
);
let config = builder
.build()
.wrap_err("Failed to build configuration")?
.try_deserialize()
.wrap_err("Failed to deserialize configuration")?;
Ok(config)
}
}
mod humantime_serde {
use serde::{Deserialize, Deserializer};
use std::time::Duration;
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
humantime::parse_duration(&s).map_err(serde::de::Error::custom)
}
}
use std::time::Duration;
use color_eyre::eyre::Result;
use crossterm::event::{Event, EventStream, KeyEvent};
use futures::StreamExt;
use tokio::select;
use tokio::time::{Interval, interval};
#[allow(dead_code)]
pub enum AppEvent {
Tick,
Key(KeyEvent),
Resize(u16, u16),
}
pub struct EventHandler {
events: EventStream,
tick: Interval,
}
impl EventHandler {
pub fn new(tick_rate: Duration) -> Self {
// Create the interval once: a fresh interval's first tick completes
// immediately, so rebuilding it per call would starve keyboard input.
Self {
events: EventStream::new(),
tick: interval(tick_rate),
}
}
pub async fn next(&mut self) -> Result<AppEvent> {
select! {
Some(Ok(event)) = self.events.next() => {
match event {
Event::Key(key) => Ok(AppEvent::Key(key)),
Event::Resize(w, h) => Ok(AppEvent::Resize(w, h)),
_ => Ok(AppEvent::Tick),
}
}
_ = self.tick.tick() => {
Ok(AppEvent::Tick)
}
}
}
}
use color_eyre::eyre::Result;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
use crate::config::Config;
pub fn init_logging(_config: &Config, debug: bool) -> Result<()> {
let filter = if debug {
EnvFilter::new("debug")
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
};
// Log to file in config directory
let log_dir = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("component-app")
.join("logs");
std::fs::create_dir_all(&log_dir)?;
let log_file = std::fs::File::create(log_dir.join("app.log"))?;
tracing_subscriber::registry()
.with(filter)
.with(
fmt::layer()
.with_writer(log_file)
.with_ansi(false)
.with_target(true),
)
.init();
Ok(())
}
mod action;
mod app;
mod config;
mod event;
mod logging;
mod tui;
mod ui;
use clap::Parser;
use color_eyre::eyre::Result;
use crate::app::App;
use crate::config::Config;
use crate::event::EventHandler;
use crate::logging::init_logging;
use crate::tui::Tui;
#[derive(Parser)]
#[command(name = "component-app")]
#[command(about = "A ratatui TUI application")]
struct Cli {
/// Config file path
#[arg(short, long)]
config: Option<std::path::PathBuf>,
/// Enable debug logging
#[arg(short, long)]
debug: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let cli = Cli::parse();
let config = Config::load(cli.config.as_deref())?;
init_logging(&config, cli.debug)?;
tracing::info!("Starting application");
let mut tui = Tui::new()?;
let events = EventHandler::new(config.tick_rate);
let mut app = App::new(config);
tui.enter()?;
let result = app.run(&mut tui, events).await;
tui.exit()?;
result
}
use std::io::{self, Stdout};
use color_eyre::eyre::Result;
use crossterm::{
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
pub type CrosstermTerminal = Terminal<CrosstermBackend<Stdout>>;
pub struct Tui {
terminal: CrosstermTerminal,
}
impl Tui {
pub fn new() -> Result<Self> {
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
pub fn enter(&mut self) -> Result<()> {
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen)?;
// Set panic hook to restore terminal
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = Self::reset();
original_hook(panic_info);
}));
self.terminal.hide_cursor()?;
self.terminal.clear()?;
Ok(())
}
pub fn exit(&mut self) -> Result<()> {
Self::reset()?;
self.terminal.show_cursor()?;
Ok(())
}
fn reset() -> Result<()> {
disable_raw_mode()?;
execute!(io::stdout(), LeaveAlternateScreen)?;
Ok(())
}
pub fn draw<F>(&mut self, f: F) -> Result<()>
where
F: FnOnce(&mut ratatui::Frame),
{
self.terminal.draw(f)?;
Ok(())
}
}
use ratatui::{
Frame,
layout::{Constraint, Layout},
style::Stylize,
text::Line,
widgets::{Block, Borders, List, ListItem, Paragraph},
};
use crate::app::App;
pub fn render(frame: &mut Frame, app: &App) {
let [main_area, status_area, help_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(frame.area());
render_list(frame, app, main_area);
render_status(frame, status_area);
render_help(frame, help_area);
}
fn render_list(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app
.items()
.iter()
.enumerate()
.map(|(i, item)| {
let content = if i == app.selected() {
format!("> {}", item).bold().cyan()
} else {
format!(" {}", item).dim()
};
ListItem::new(content)
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title("Items".bold().cyan()),
);
frame.render_widget(list, area);
}
fn render_status(frame: &mut Frame, area: ratatui::layout::Rect) {
let status = Line::from(vec![" NORMAL ".bold().on_cyan(), " Ready ".dim()]);
frame.render_widget(Paragraph::new(status), area);
}
fn render_help(frame: &mut Frame, area: ratatui::layout::Rect) {
let help = Line::from(vec![
" ↑/k ".bold().cyan(),
"up ".dim(),
" ↓/j ".bold().cyan(),
"down ".dim(),
" Enter ".bold().cyan(),
"select ".dim(),
" q ".bold().cyan(),
"quit ".dim(),
]);
frame.render_widget(Paragraph::new(help), area);
}
[package]
name = "hello-world"
version = "0.1.0"
edition = "2024"
[dependencies]
ratatui = "0.30"
use ratatui::{
Frame,
crossterm::event::{self, Event, KeyCode},
style::Stylize,
widgets::Paragraph,
};
// Minimal demo: `expect` keeps it short. Real apps return Result and
// propagate with `?` (see the simple-app template).
fn main() {
let mut terminal = ratatui::init();
loop {
terminal.draw(render).expect("failed to draw frame");
if matches!(event::read().expect("failed to read event"), Event::Key(key) if key.code == KeyCode::Char('q'))
{
break;
}
}
ratatui::restore();
}
fn render(frame: &mut Frame) {
let text = "Hello, ratatui! Press 'q' to quit.".bold().cyan();
frame.render_widget(Paragraph::new(text).centered(), frame.area());
}
[package]
name = "simple-app"
version = "0.1.0"
edition = "2024"
[dependencies]
ratatui = "0.30"
crossterm = "0.29"
color-eyre = "0.6"
use std::time::Duration;
use color_eyre::eyre::Result;
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Layout},
style::Stylize,
text::Line,
widgets::{Block, Borders, Paragraph},
};
fn main() -> Result<()> {
color_eyre::install()?;
install_panic_hook();
let mut terminal = ratatui::init();
let result = run(&mut terminal);
ratatui::restore();
result
}
fn install_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
ratatui::restore();
original_hook(panic_info);
}));
}
#[derive(Default)]
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 [main_area, help_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
let counter_text = format!("Counter: {}", self.counter);
let paragraph = Paragraph::new(counter_text.bold().cyan())
.centered()
.block(Block::default().borders(Borders::ALL).title("Simple App"));
frame.render_widget(paragraph, main_area);
let help = Line::from(vec![
" ↑/k ".bold().cyan(),
"increment ".dim(),
" ↓/j ".bold().cyan(),
"decrement ".dim(),
" q ".bold().cyan(),
"quit ".dim(),
]);
frame.render_widget(Paragraph::new(help), help_area);
}
}
fn run(terminal: &mut DefaultTerminal) -> Result<()> {
let mut app = App::default();
loop {
terminal.draw(|frame| app.view(frame))?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
let msg = match key.code {
KeyCode::Char('q') => Message::Quit,
KeyCode::Up | KeyCode::Char('k') => Message::Increment,
KeyCode::Down | KeyCode::Char('j') => Message::Decrement,
_ => continue,
};
app.update(msg);
}
if app.should_quit {
break;
}
}
Ok(())
}
Ratatui Architecture Patterns
The Elm Architecture (TEA)
The recommended pattern for ratatui apps:
┌──────────────────────────────────────┐
│ │
▼ │
Model ──────► View ──────► Terminal │
│ │ │
│ │ │
│ ▼ │
│ Events │
│ │ │
│ ▼ │
└────────── Update ◄───── Message ─────┘Core Components
/// Application state (Model)
struct App {
items: Vec<String>,
selected: usize,
mode: Mode,
should_quit: bool,
}
/// All possible state changes (Message)
enum Message {
SelectNext,
SelectPrev,
Enter,
ChangeMode(Mode),
Quit,
}
impl App {
/// Pure state transition (Update)
fn update(&mut self, msg: Message) {
match msg {
Message::SelectNext => {
if self.selected < self.items.len().saturating_sub(1) {
self.selected += 1;
}
}
Message::SelectPrev => {
self.selected = self.selected.saturating_sub(1);
}
Message::Enter => {
// Handle selection
}
Message::ChangeMode(mode) => {
self.mode = mode;
}
Message::Quit => {
self.should_quit = true;
}
}
}
/// Render current state (View)
fn view(&self, frame: &mut Frame) {
// Render widgets based on self
}
}Main Loop
fn run(mut app: App, mut terminal: Terminal<impl Backend>) -> Result<()> {
loop {
// Render
terminal.draw(|frame| app.view(frame))?;
// Handle input
if event::poll(Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
let msg = match key.code {
KeyCode::Char('q') => Message::Quit,
KeyCode::Up | KeyCode::Char('k') => Message::SelectPrev,
KeyCode::Down | KeyCode::Char('j') => Message::SelectNext,
KeyCode::Enter => Message::Enter,
_ => continue,
};
app.update(msg);
}
}
if app.should_quit {
break;
}
}
Ok(())
}Component Trait Pattern
For larger apps with multiple reusable views:
use ratatui::Frame;
use crossterm::event::KeyEvent;
/// Result of handling an event
pub enum EventResult {
Consumed, // Event was handled
Ignored, // Pass to parent
Action(Action), // Trigger app-level action
}
/// Component trait for reusable UI elements
pub trait Component {
/// Handle keyboard input
fn handle_key(&mut self, key: KeyEvent) -> EventResult;
/// Render the component
fn render(&self, frame: &mut Frame, area: Rect);
/// Optional: handle tick for animations
fn tick(&mut self) {}
/// Optional: focus management
fn focus(&mut self) {}
fn blur(&mut self) {}
}Example Component
pub struct ItemList {
items: Vec<String>,
state: ListState,
focused: bool,
}
impl ItemList {
pub fn new(items: Vec<String>) -> Self {
let mut state = ListState::default();
if !items.is_empty() {
state.select(Some(0));
}
Self { items, state, focused: false }
}
pub fn selected(&self) -> Option<&String> {
self.state.selected().map(|i| &self.items[i])
}
}
impl Component for ItemList {
fn handle_key(&mut self, key: KeyEvent) -> EventResult {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.state.select_previous();
EventResult::Consumed
}
KeyCode::Down | KeyCode::Char('j') => {
self.state.select_next();
EventResult::Consumed
}
KeyCode::Enter => {
if let Some(item) = self.selected() {
EventResult::Action(Action::Select(item.clone()))
} else {
EventResult::Ignored
}
}
_ => EventResult::Ignored,
}
}
fn render(&self, frame: &mut Frame, area: Rect) {
let items: Vec<ListItem> = self.items.iter()
.map(|s| ListItem::new(s.as_str()))
.collect();
let border_style = if self.focused {
Style::default().cyan()
} else {
Style::default().dim()
};
let list = List::new(items)
.block(Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title("Items"))
.highlight_style(Style::default().bold().on_dark_gray())
.highlight_symbol("> ");
frame.render_stateful_widget(list, area, &mut self.state.clone());
}
fn focus(&mut self) { self.focused = true; }
fn blur(&mut self) { self.focused = false; }
}Action Pattern
For decoupling input handling from state changes:
/// App-level actions
#[derive(Debug, Clone)]
pub enum Action {
Tick,
Render,
Quit,
Navigate(Direction),
Select,
ChangeMode(Mode),
Error(String),
// Domain-specific actions
LoadData,
SaveData,
Refresh,
}
#[derive(Debug, Clone)]
pub enum Direction {
Up,
Down,
Left,
Right,
}Action Channel Pattern
use tokio::sync::mpsc;
struct App {
action_tx: mpsc::UnboundedSender<Action>,
action_rx: mpsc::UnboundedReceiver<Action>,
}
impl App {
fn new() -> Self {
let (action_tx, action_rx) = mpsc::unbounded_channel();
Self { action_tx, action_rx }
}
async fn run(&mut self) -> Result<()> {
loop {
// Receive actions from anywhere
if let Some(action) = self.action_rx.recv().await {
match action {
Action::Quit => break,
Action::Render => self.render()?,
action => self.handle_action(action)?,
}
}
}
Ok(())
}
fn handle_action(&mut self, action: Action) -> Result<()> {
match action {
Action::Navigate(dir) => { /* ... */ }
Action::LoadData => {
// Spawn background task
let tx = self.action_tx.clone();
tokio::spawn(async move {
// Load data...
tx.send(Action::Render).ok();
});
}
_ => {}
}
Ok(())
}
}State Management Strategies
Single State Struct (Simple Apps)
struct App {
// All state in one place
items: Vec<Item>,
selected: usize,
filter: String,
mode: Mode,
error: Option<String>,
}Nested State (Medium Apps)
struct App {
state: AppState,
config: Config,
}
struct AppState {
list: ListState,
input: InputState,
mode: Mode,
}
struct ListState {
items: Vec<Item>,
selected: usize,
scroll: usize,
}
struct InputState {
value: String,
cursor: usize,
}Component-Based State (Large Apps)
struct App {
components: Components,
focus: FocusTarget,
mode: Mode,
}
struct Components {
sidebar: Sidebar,
main_view: MainView,
status_bar: StatusBar,
command_palette: Option<CommandPalette>,
}
enum FocusTarget {
Sidebar,
MainView,
CommandPalette,
}Modal/Dialog Pattern
enum Modal {
None,
Confirm { message: String, on_confirm: Action },
Input { prompt: String, value: String },
Error { message: String },
}
struct App {
state: AppState,
modal: Modal,
}
impl App {
fn handle_key(&mut self, key: KeyEvent) {
// Modal gets first priority
match &mut self.modal {
Modal::Confirm { on_confirm, .. } => {
match key.code {
KeyCode::Char('y') => {
let action = on_confirm.clone();
self.modal = Modal::None;
self.handle_action(action);
}
KeyCode::Char('n') | KeyCode::Esc => {
self.modal = Modal::None;
}
_ => {}
}
}
Modal::None => {
// Normal key handling
}
// ...
}
}
fn view(&self, frame: &mut Frame) {
// Render main UI
self.render_main(frame);
// Render modal on top
if let Some(modal_area) = self.modal_area(frame.area()) {
self.render_modal(frame, modal_area);
}
}
}Mode-Based State Machine
#[derive(Default, Clone, Copy, PartialEq)]
enum Mode {
#[default]
Normal,
Insert,
Visual,
Command,
}
impl App {
fn handle_key(&mut self, key: KeyEvent) {
match self.mode {
Mode::Normal => self.handle_normal_key(key),
Mode::Insert => self.handle_insert_key(key),
Mode::Visual => self.handle_visual_key(key),
Mode::Command => self.handle_command_key(key),
}
}
fn handle_normal_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('i') => self.mode = Mode::Insert,
KeyCode::Char('v') => self.mode = Mode::Visual,
KeyCode::Char(':') => self.mode = Mode::Command,
// Normal mode bindings...
_ => {}
}
}
fn handle_insert_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
// Insert mode bindings...
_ => {}
}
}
}Best Practices
1. Keep state normalized - avoid duplicated data 2. Make updates pure - update() should only modify state, no I/O 3. Batch related state - group logically related fields 4. Use enums for modes - exhaustive matching catches bugs 5. Separate concerns - input handling, state, rendering 6. Prefer composition - build complex UIs from simple components
Ratatui Async Patterns
Why Async?
Sync event loops block on input, making background tasks impossible:
- Network requests
- File I/O
- Timers/animations
- External process output
EventStream Setup
Enable the event-stream feature in crossterm:
[dependencies]
crossterm = { version = "0.29", features = ["event-stream"] }
tokio = { version = "1", features = ["full"] }
futures = "0.3"Basic Async Pattern
use crossterm::event::{Event, EventStream, KeyCode};
use futures::StreamExt;
use tokio::select;
#[tokio::main]
async fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let mut terminal = ratatui::init();
let result = run(&mut terminal).await;
ratatui::restore();
result
}
async fn run(terminal: &mut Terminal<impl Backend>) -> Result<()> {
let mut app = App::default();
let mut events = EventStream::new();
loop {
// Render current state
terminal.draw(|frame| app.view(frame))?;
// Wait for events
select! {
Some(Ok(event)) = events.next() => {
if let Event::Key(key) = event {
match key.code {
KeyCode::Char('q') => break,
KeyCode::Up => app.select_prev(),
KeyCode::Down => app.select_next(),
_ => {}
}
}
}
}
if app.should_quit {
break;
}
}
Ok(())
}Multiple Event Sources
use tokio::sync::mpsc;
use tokio::time::{interval, Duration};
async fn run(terminal: &mut Terminal<impl Backend>) -> Result<()> {
let mut app = App::default();
let mut events = EventStream::new();
// Tick timer for animations/updates
let mut tick = interval(Duration::from_millis(250));
// Channel for background task results
let (tx, mut rx) = mpsc::unbounded_channel::<BackgroundResult>();
loop {
terminal.draw(|frame| app.view(frame))?;
select! {
// Terminal events (keyboard, mouse, resize)
Some(Ok(event)) = events.next() => {
app.handle_event(event);
}
// Periodic tick
_ = tick.tick() => {
app.tick();
}
// Background task results
Some(result) = rx.recv() => {
app.handle_background_result(result);
}
}
if app.should_quit {
break;
}
}
Ok(())
}Background Tasks
Fire-and-Forget
impl App {
fn start_load(&mut self, tx: mpsc::UnboundedSender<BackgroundResult>) {
self.loading = true;
tokio::spawn(async move {
let result = load_data().await;
tx.send(BackgroundResult::DataLoaded(result)).ok();
});
}
fn handle_background_result(&mut self, result: BackgroundResult) {
match result {
BackgroundResult::DataLoaded(data) => {
self.loading = false;
self.data = data;
}
}
}
}With Progress Updates
enum BackgroundResult {
Progress(usize, usize), // current, total
Complete(Data),
Error(String),
}
async fn load_with_progress(
tx: mpsc::UnboundedSender<BackgroundResult>,
) {
let items = get_items().await;
let total = items.len();
for (i, item) in items.into_iter().enumerate() {
process_item(item).await;
tx.send(BackgroundResult::Progress(i + 1, total)).ok();
}
let data = finalize().await;
tx.send(BackgroundResult::Complete(data)).ok();
}Cancellable Tasks
use tokio_util::sync::CancellationToken;
struct App {
cancel_token: Option<CancellationToken>,
// ...
}
impl App {
fn start_task(&mut self, tx: mpsc::UnboundedSender<BackgroundResult>) {
// Cancel any existing task
if let Some(token) = self.cancel_token.take() {
token.cancel();
}
let token = CancellationToken::new();
self.cancel_token = Some(token.clone());
tokio::spawn(async move {
select! {
result = do_work() => {
tx.send(BackgroundResult::Complete(result)).ok();
}
_ = token.cancelled() => {
tx.send(BackgroundResult::Cancelled).ok();
}
}
});
}
fn cancel_task(&mut self) {
if let Some(token) = self.cancel_token.take() {
token.cancel();
}
}
}Debouncing Input
Useful for search-as-you-type:
use tokio::time::{sleep, Duration, Instant};
struct App {
search_query: String,
last_input: Instant,
pending_search: bool,
}
impl App {
fn handle_search_input(&mut self, c: char) {
self.search_query.push(c);
self.last_input = Instant::now();
self.pending_search = true;
}
fn tick(&mut self, tx: &mpsc::UnboundedSender<BackgroundResult>) {
// Debounce: only search after 300ms of no input
if self.pending_search
&& self.last_input.elapsed() > Duration::from_millis(300)
{
self.pending_search = false;
self.start_search(tx.clone());
}
}
}Rate Limiting Renders
Avoid rendering too frequently:
use std::time::{Duration, Instant};
const MIN_FRAME_DURATION: Duration = Duration::from_millis(16); // ~60fps
async fn run(terminal: &mut Terminal<impl Backend>) -> Result<()> {
let mut last_render = Instant::now();
let mut needs_render = true;
loop {
// Only render if needed and enough time has passed
if needs_render && last_render.elapsed() >= MIN_FRAME_DURATION {
terminal.draw(|frame| app.view(frame))?;
last_render = Instant::now();
needs_render = false;
}
select! {
Some(Ok(event)) = events.next() => {
if app.handle_event(event) {
needs_render = true;
}
}
_ = tick.tick() => {
if app.tick() {
needs_render = true;
}
}
// ...
}
}
}Async File Operations
use tokio::fs;
async fn load_file(path: &Path, tx: mpsc::UnboundedSender<Action>) {
match fs::read_to_string(path).await {
Ok(content) => {
tx.send(Action::FileLoaded(content)).ok();
}
Err(e) => {
tx.send(Action::Error(format!("Failed to load: {}", e))).ok();
}
}
}
async fn save_file(path: &Path, content: String, tx: mpsc::UnboundedSender<Action>) {
match fs::write(path, &content).await {
Ok(()) => {
tx.send(Action::FileSaved).ok();
}
Err(e) => {
tx.send(Action::Error(format!("Failed to save: {}", e))).ok();
}
}
}Network Requests
use reqwest::Client;
struct App {
client: Client,
// ...
}
impl App {
fn fetch_data(&self, tx: mpsc::UnboundedSender<Action>) {
let client = self.client.clone();
let url = self.api_url.clone();
tokio::spawn(async move {
match client.get(&url).send().await {
Ok(response) => {
match response.json::<ApiResponse>().await {
Ok(data) => tx.send(Action::DataReceived(data)).ok(),
Err(e) => tx.send(Action::Error(e.to_string())).ok(),
};
}
Err(e) => {
tx.send(Action::Error(e.to_string())).ok();
}
}
});
}
}Event Handler Module
For larger apps, separate event handling:
// event.rs
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
pub enum AppEvent {
Key(KeyEvent),
Resize(u16, u16),
Tick,
Background(BackgroundResult),
}
pub struct EventHandler {
events: EventStream,
tick_rate: Duration,
rx: mpsc::UnboundedReceiver<BackgroundResult>,
}
impl EventHandler {
pub fn new(
tick_rate: Duration,
rx: mpsc::UnboundedReceiver<BackgroundResult>,
) -> Self {
Self {
events: EventStream::new(),
tick_rate,
rx,
}
}
pub async fn next(&mut self) -> Result<AppEvent> {
let tick_delay = tokio::time::sleep(self.tick_rate);
select! {
Some(Ok(event)) = self.events.next() => {
match event {
Event::Key(key) => Ok(AppEvent::Key(key)),
Event::Resize(w, h) => Ok(AppEvent::Resize(w, h)),
_ => Ok(AppEvent::Tick),
}
}
Some(result) = self.rx.recv() => {
Ok(AppEvent::Background(result))
}
_ = tick_delay => {
Ok(AppEvent::Tick)
}
}
}
}Error Handling in Async
use color_eyre::eyre::{Result, WrapErr};
async fn run() -> Result<()> {
// Wrap errors with context
let data = load_config()
.await
.wrap_err("Failed to load configuration")?;
// Handle recoverable errors gracefully
match fetch_data().await {
Ok(data) => app.data = data,
Err(e) => {
app.error = Some(format!("Network error: {}", e));
// Continue running, show error to user
}
}
Ok(())
}Best Practices
1. Keep main loop simple - delegate to handlers 2. Use channels for communication - avoid shared mutable state 3. Handle all channel errors - .ok() for send, match recv 4. Cancel long tasks - use CancellationToken 5. Rate limit renders - 60fps is enough 6. Debounce user input - for search/filter operations 7. Show loading states - feedback during async operations 8. Log background errors - don't silently fail
Ratatui Image Integration
Overview
ratatui-image provides terminal image rendering using various protocols:
- Sixel - Wide support (xterm, foot, mlterm, etc.)
- Kitty - Kitty terminal native protocol
- iTerm2 - iTerm2 and compatible terminals
- Halfblocks - Unicode fallback, works everywhere
Setup
[dependencies]
ratatui-image = { version = "5", features = ["chafa-static"] }
image = "0.25"Feature Flags
| Feature | Description |
|---|---|
chafa-static | Statically link libchafa for portable binaries |
chafa | Dynamic link to system libchafa |
serde | Serialization support |
Recommendation: Use chafa-static for release binaries to ensure they work on any system.
Protocol Detection
Query terminal capabilities once at startup:
use ratatui_image::picker::Picker;
fn main() -> Result<()> {
// Query terminal for best supported protocol
let picker = Picker::from_query_stdio()?;
// Use picker throughout app lifetime
run(picker)
}Manual Protocol Selection
use ratatui_image::picker::{Picker, ProtocolType};
// Force specific protocol
let picker = Picker::new(ProtocolType::Sixel);
// Or with custom font size (for accurate sizing)
let mut picker = Picker::new(ProtocolType::Kitty);
picker.set_font_size((8, 16)); // width, height in pixelsBasic Usage
StatefulImage (Recommended)
For images that need to persist across redraws:
use ratatui_image::{picker::Picker, protocol::StatefulProtocol, StatefulImage, Resize};
struct App {
picker: Picker,
image: Option<StatefulProtocol>,
}
impl App {
fn load_image(&mut self, path: &Path, area: Rect) -> Result<()> {
let dyn_img = image::open(path)?;
// Create protocol with resize mode
self.image = Some(self.picker.new_protocol(
dyn_img,
area.into(),
Resize::Fit(None), // Fit within area, maintain aspect
));
Ok(())
}
fn view(&mut self, frame: &mut Frame) {
let area = frame.area();
if let Some(ref mut img) = self.image {
frame.render_stateful_widget(
StatefulImage::default(),
area,
img,
);
}
}
}Image (Simple, One-Shot)
For images rendered once:
use ratatui_image::{Image, Resize};
fn render_image(frame: &mut Frame, dyn_img: DynamicImage, area: Rect) {
let image = Image::new(&dyn_img)
.resize(Resize::Fit(None));
frame.render_widget(image, area);
}Note: Image re-encodes on every render. Use StatefulImage for persistent images.
Resize Modes
use ratatui_image::Resize;
// Fit within area, maintain aspect ratio
Resize::Fit(None)
// Fit with specific background color for letterboxing
Resize::Fit(Some(Rgba([0, 0, 0, 255])))
// Crop to fill area (may cut edges)
Resize::Crop(None)
// Scale to exact size (distorts if aspect differs)
Resize::Scale(None)Background Thread Pattern
Image encoding is CPU-intensive. Offload to background thread:
use std::sync::mpsc;
use std::thread;
struct App {
picker: Picker,
image: Option<StatefulProtocol>,
image_rx: Option<mpsc::Receiver<StatefulProtocol>>,
loading: bool,
}
impl App {
fn load_image_async(&mut self, path: PathBuf, area: Rect) {
self.loading = true;
let picker = self.picker.clone();
let (tx, rx) = mpsc::channel();
self.image_rx = Some(rx);
thread::spawn(move || {
if let Ok(dyn_img) = image::open(&path) {
let protocol = picker.new_protocol(
dyn_img,
area.into(),
Resize::Fit(None),
);
tx.send(protocol).ok();
}
});
}
fn tick(&mut self) {
// Check for completed image load
if let Some(ref rx) = self.image_rx {
if let Ok(protocol) = rx.try_recv() {
self.image = Some(protocol);
self.image_rx = None;
self.loading = false;
}
}
}
fn view(&mut self, frame: &mut Frame) {
let area = frame.area();
if self.loading {
frame.render_widget(
Paragraph::new("Loading image...".dim()),
area,
);
} else if let Some(ref mut img) = self.image {
frame.render_stateful_widget(
StatefulImage::default(),
area,
img,
);
}
}
}Async Pattern (Tokio)
use tokio::task::spawn_blocking;
use tokio::sync::mpsc;
async fn load_image_async(
picker: Picker,
path: PathBuf,
area: Rect,
tx: mpsc::UnboundedSender<AppEvent>,
) {
let result = spawn_blocking(move || {
let dyn_img = image::open(&path)?;
let protocol = picker.new_protocol(
dyn_img,
area.into(),
Resize::Fit(None),
);
Ok::<_, image::ImageError>(protocol)
}).await;
match result {
Ok(Ok(protocol)) => {
tx.send(AppEvent::ImageLoaded(protocol)).ok();
}
Ok(Err(e)) => {
tx.send(AppEvent::Error(e.to_string())).ok();
}
Err(e) => {
tx.send(AppEvent::Error(e.to_string())).ok();
}
}
}Handling Resize
Re-encode image when terminal resizes:
impl App {
fn handle_resize(&mut self, width: u16, height: u16) {
self.terminal_size = (width, height);
// Re-encode image for new size
if self.original_image.is_some() {
let area = self.image_area();
self.encode_image(area);
}
}
fn image_area(&self) -> Rect {
// Calculate area based on layout
Rect::new(0, 0, self.terminal_size.0, self.terminal_size.1 - 2)
}
}Image Gallery Example
struct Gallery {
picker: Picker,
images: Vec<PathBuf>,
current: usize,
cached: Option<StatefulProtocol>,
loading: bool,
}
impl Gallery {
fn next(&mut self) {
if self.current < self.images.len() - 1 {
self.current += 1;
self.cached = None; // Invalidate cache
}
}
fn prev(&mut self) {
if self.current > 0 {
self.current -= 1;
self.cached = None;
}
}
fn ensure_loaded(&mut self, area: Rect) {
if self.cached.is_none() && !self.loading {
self.load_current(area);
}
}
fn load_current(&mut self, area: Rect) {
let path = &self.images[self.current];
if let Ok(dyn_img) = image::open(path) {
self.cached = Some(self.picker.new_protocol(
dyn_img,
area.into(),
Resize::Fit(None),
));
}
}
fn view(&mut self, frame: &mut Frame) {
let [image_area, status_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
]).areas(frame.area());
// Ensure image is loaded for current area
self.ensure_loaded(image_area);
// Render image
if let Some(ref mut img) = self.cached {
frame.render_stateful_widget(
StatefulImage::default(),
image_area,
img,
);
}
// Status line
let status = format!(
" {}/{} | ←→ navigate | q quit ",
self.current + 1,
self.images.len()
);
frame.render_widget(
Paragraph::new(status.dim()),
status_area,
);
}
}Terminal Compatibility
| Terminal | Protocol | Notes |
|---|---|---|
| Kitty | Kitty | Native, best quality |
| iTerm2 | iTerm2 | Native support |
| WezTerm | Kitty, Sixel, iTerm2 | Multiple protocols |
| foot | Sixel | Good quality |
| xterm | Sixel | Enable with -ti vt340 |
| Alacritty | Halfblocks | No native image support |
| macOS Terminal | Halfblocks | No native support |
Fallback Strategy
fn get_picker() -> Picker {
// Try to query for best protocol
match Picker::from_query_stdio() {
Ok(picker) => picker,
Err(_) => {
// Fall back to halfblocks (always works)
Picker::new(ProtocolType::Halfblocks)
}
}
}Performance Tips
1. Query protocol once - at startup, not per-render 2. Use StatefulImage - avoids re-encoding on redraws 3. Offload encoding - use background thread for large images 4. Cache encoded images - store StatefulProtocol, not DynamicImage 5. Resize smartly - only re-encode on terminal resize 6. Use chafa-static - portable and well-optimized
Troubleshooting
Image Not Showing
1. Check terminal supports the protocol 2. Verify image path is correct 3. Ensure area has non-zero size
Poor Quality
1. Try different protocol (Kitty > Sixel > Halfblocks) 2. Check font size detection: picker.set_font_size((w, h)) 3. Use Resize::Fit instead of Scale
Slow Rendering
1. Use StatefulImage instead of Image 2. Offload encoding to background thread 3. Reduce image resolution before encoding
Artifacts on Resize
1. Clear the image area before re-rendering 2. Re-encode with new dimensions 3. Use terminal.clear() if needed
Ratatui Style Guide
Stylize Trait
Always use the Stylize trait for inline styling. Import it:
use ratatui::style::Stylize;Cheatsheet
// Modifiers
"text".bold()
"text".dim()
"text".italic()
"text".underlined()
"text".reversed()
// Foreground colors
"text".cyan()
"text".green()
"text".red()
"text".magenta()
"text".yellow()
"text".white()
"text".gray()
"text".dark_gray()
// Background colors
"text".on_black()
"text".on_dark_gray()
"text".on_cyan()
"text".on_red()
// Chaining
"text".bold().cyan()
"text".dim().on_dark_gray()
"header".bold().cyan().on_dark_gray()Color Semantic Mapping
| Purpose | Style | Example |
|---|---|---|
| Primary action | .cyan() | Selected item, active tab |
| Success | .green() | Completion, valid input |
| Error | .red() | Errors, invalid input |
| Warning | .yellow() | Caution (use sparingly) |
| Muted/secondary | .dim() | Help text, metadata |
| Accent | .magenta() | Highlights, special items |
| Key bindings | .bold().cyan() | Keyboard shortcuts |
What to Avoid
Hardcoded Colors
// Bad - hardcoded white/black don't adapt to terminal themes
Style::default().fg(Color::White)
Style::default().fg(Color::Black)
Style::default().bg(Color::Blue) // blue often unreadable
// Good - use semantic colors or let terminal theme handle it
"text".cyan()
"text".dim()
Style::default() // inherits terminal defaultVerbose Style Construction
// Bad - verbose
Style::new().add_modifier(Modifier::BOLD)
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
// Good - concise
"text".bold()
"text".bold().cyan()Manual Style Objects for Spans
// Bad
Span::styled("text", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
// Good
"text".bold().cyan()Text Wrapping
Use textwrap crate for wrapping long text:
use textwrap::wrap;
use ratatui::text::Line;
fn wrap_text(text: &str, width: u16) -> Vec<Line<'static>> {
wrap(text, width as usize)
.into_iter()
.map(|cow| Line::from(cow.into_owned()))
.collect()
}Wrapping with Style
fn wrap_styled(text: &str, width: u16) -> Vec<Line<'static>> {
wrap(text, width as usize)
.into_iter()
.map(|cow| Line::from(cow.into_owned().dim()))
.collect()
}Building Lines and Spans
Simple Line
let line = Line::from("Simple text");Mixed Styles
let line = Line::from(vec![
"Key: ".dim(),
"value".cyan(),
]);Status Bar Pattern
let status = Line::from(vec![
" MODE ".bold().on_cyan(),
" ".into(),
format!("{} items", count).dim(),
]);Key Binding Help
let help = Line::from(vec![
" q ".bold().cyan(),
"quit ".dim(),
" ↑↓ ".bold().cyan(),
"navigate ".dim(),
" Enter ".bold().cyan(),
"select".dim(),
]);Block Styling
use ratatui::widgets::{Block, Borders};
// Simple border
Block::default()
.borders(Borders::ALL)
.title("Title")
// Styled border
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().dim())
.title("Title".bold().cyan())
// Rounded corners
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)Table Styling
use ratatui::widgets::{Table, Row, Cell};
let rows = items.iter().enumerate().map(|(i, item)| {
let style = if i == selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
Row::new(vec![
Cell::from(item.name.clone()),
Cell::from(item.value.to_string().dim()),
]).style(style)
});
Table::new(rows, [Constraint::Fill(1), Constraint::Length(10)])
.header(Row::new(vec!["Name".bold(), "Value".bold()]))
.highlight_style(Style::default().on_dark_gray())List Styling
use ratatui::widgets::{List, ListItem};
let items: Vec<ListItem> = data.iter()
.map(|s| ListItem::new(s.as_str()))
.collect();
List::new(items)
.block(Block::default().borders(Borders::ALL).title("Items"))
.highlight_style(Style::default().bold().on_dark_gray())
.highlight_symbol("> ")Scrollbar
use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState};
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.symbols(scrollbar::VERTICAL);
let mut scrollbar_state = ScrollbarState::new(total_items)
.position(current_position);
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);Terminal Theme Compatibility
Design for both light and dark terminals:
1. Avoid pure white/black - use .dim() for low-contrast text 2. Test both themes - colors render differently 3. Use relative brightness - .dim(), .bold() adapt better 4. Prefer cyan/green/magenta - readable on most themes 5. Avoid blue - often too dark on dark terminals
Accessibility
- High contrast for important elements (
.bold()) - Low contrast for secondary info (
.dim()) - Don't rely solely on color - use symbols too
- Provide key binding hints
// Dynamic-workflow template for reviewing ratatui TUI codebases.
// Treat as a template: tune `DIMENSIONS`, severity threshold, and prompts
// to the codebase before running. Invoke via the Workflow tool with
// args: { path: "src/" } (defaults to "src/").
//
// Pattern: fan-out one reviewer per TUI dimension, then adversarially
// verify each finding as soon as its review completes (pipeline, no
// barrier). Only verified findings are reported.
export const meta = {
name: 'ratatui-tui-review',
description: 'Review ratatui TUI code across 5 dimensions, adversarially verify findings',
whenToUse: 'After substantial ratatui changes or before shipping a TUI release',
phases: [
{ title: 'Review', detail: 'one reviewer per TUI dimension' },
{ title: 'Verify', detail: 'adversarial refutation of each finding' },
],
}
const target = (args && args.path) || 'src/'
const FINDINGS = {
type: 'object',
required: ['findings'],
properties: {
findings: {
type: 'array',
items: {
type: 'object',
required: ['file', 'line', 'title', 'detail', 'severity'],
properties: {
file: { type: 'string' },
line: { type: 'number' },
title: { type: 'string' },
detail: { type: 'string' },
severity: { type: 'string', enum: ['critical', 'major', 'minor'] },
},
},
},
},
}
const VERDICT = {
type: 'object',
required: ['isReal', 'reason'],
properties: {
isReal: { type: 'boolean' },
reason: { type: 'string' },
},
}
const DIMENSIONS = [
{
key: 'architecture',
prompt: `TEA (Elm Architecture) compliance: single App/Model owning all state,
all mutations routed through update() via a Message/Action enum, view/render
functions take &self and perform no mutation or business logic, no rendering
from update. Flag state scattered across globals or rendered widgets.`,
},
{
key: 'terminal-safety',
prompt: `Terminal restoration and panics: uses ratatui::run() or init()/restore()
(which install a terminal-restoring panic hook) rather than hand-rolled raw-mode
setup without a hook; color_eyre::install() runs BEFORE terminal init; no
unwrap()/expect()/panic! outside tests; raw mode restored on every exit path
including errors propagated with ?.`,
},
{
key: 'styling',
prompt: `Styling rules: Stylize trait helpers (.bold(), .cyan(), .dim()) over
verbose Style::default().fg(...) chains; no hardcoded Color::White/Color::Black
(breaks light/dark terminals); consistent palette usage; text wrapped before
rendering into constrained areas.`,
},
{
key: 'events',
prompt: `Event handling: async apps use crossterm EventStream + tokio::select!
(never blocking event::read() inside async); sync apps poll with timeout when
animating; animations (e.g. tui-shimmer) driven by a tick event with phase
stored in the model, not wall-clock reads inside render; key handling covers
both press and repeat where it matters; quit always reachable.`,
},
{
key: 'rendering-perf',
prompt: `Render performance: no heavy allocation or I/O inside the draw closure;
layout-cache feature not accidentally disabled via default-features = false;
image protocols queried once at startup (Picker::from_query_stdio) with
StatefulImage reuse, never re-encoding per frame; widgets rebuilt cheaply or
cached when expensive.`,
},
]
phase('Review')
const results = await pipeline(
DIMENSIONS,
(d) =>
agent(
`Review the ratatui TUI code under ${target} for ${d.key} issues.
${d.prompt}
Read the relevant source files. Report only concrete issues with exact
file:line locations — no speculation about code you did not read.`,
{ label: `review:${d.key}`, phase: 'Review', schema: FINDINGS },
),
(review, d) =>
review
? parallel(
review.findings.map((f) => () =>
agent(
`Adversarially verify this ${d.key} finding in a ratatui codebase.
Finding: "${f.title}" at ${f.file}:${f.line} — ${f.detail}
Read ${f.file} and try to REFUTE it: is the code actually fine, is the
pattern intentional, or does the issue not exist at that location?
Default to isReal=false if you cannot confirm it from the code.`,
{ label: `verify:${d.key}:${f.file}`, phase: 'Verify', schema: VERDICT },
).then((v) => ({ ...f, dimension: d.key, verdict: v })),
),
)
: [],
)
const verified = results.flat().filter(Boolean)
const confirmed = verified.filter((f) => f.verdict && f.verdict.isReal)
const refuted = verified.length - confirmed.length
log(`${confirmed.length} confirmed findings (${refuted} refuted)`)
return {
target,
confirmed,
summary: {
critical: confirmed.filter((f) => f.severity === 'critical').length,
major: confirmed.filter((f) => f.severity === 'major').length,
minor: confirmed.filter((f) => f.severity === 'minor').length,
},
}