Ratatui TUI Builder
Expert guidance for building terminal user interfaces with ratatui - a Rust crate for creating sophisticated TUI applications using immediate mode rendering.
Core Concepts
Immediate Mode Rendering
Ratatui uses immediate mode rendering where the UI is reconstructed every frame based on current application state:
loop {
terminal.draw(|f| {
// UI rebuilt every frame from current state
if state.condition {
f.render_widget(SomeWidget::new(), layout);
} else {
f.render_widget(AnotherWidget::new(), layout);
}
})?;
}
Key Advantage: UI logic directly mirrors application state without manual synchronization.
Builder-Lite Pattern
All ratatui widgets use the builder-lite pattern for fluent configuration:
// Correct - chain methods or capture result
let text = Text::raw("hello").centered();
let paragraph = Paragraph::new("content")
.block(Block::bordered())
.style(Style::default().fg(Color::Yellow));
// WRONG - methods return new value, original unchanged
let text = Text::raw("wrong");
text.centered(); // Has no effect! Result discarded
Critical: Setter methods consume self and return modified instance. Use #[must_use] to catch this mistake.
Application Architecture
Standard App Structure
struct App {
// User interaction state
input: String,
input_mode: InputMode, // Normal vs Editing
cursor_position: usize,
// Application state
state: AppState,
data: BTreeMap<String, String>,
// Display state
history: Vec<String>,
messages: VecDeque<String>,
// Communication channels (if multi-threaded)
state_rx: Receiver<StateUpdate>,
cmd_tx: Sender<Command>,
}
Event Loop Pattern (Non-blocking with Updates)
impl App {
fn run(&mut self) -> io::Result<()> {
let mut terminal = setup_terminal()?;
loop {
// 1. Process pending updates (non-blocking)
while let Ok(update) = self.state_rx.try_recv() {
self.apply_update(update);
}
// 2. Render current state
terminal.draw(|frame| self.draw(frame))?;
// 3. Handle input with timeout (enables responsive updates)
if event::poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
match self.handle_key_event(key) {
KeyResult::Quit => break,
KeyResult::Command(cmd) => self.cmd_tx.send(cmd)?,
KeyResult::Continue => {}
}
}
}
}
restore_terminal(terminal)?;
Ok(())
}
}
Why event::poll() with timeout?
- •Allows checking for state updates even without user input
- •Ensures UI refreshes at least every 50ms (20 FPS)
- •Critical for real-time visualization
Terminal Setup and Cleanup
fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
Terminal::new(backend)
}
fn restore_terminal(
mut terminal: Terminal<CrosstermBackend<io::Stdout>>
) -> io::Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
Critical: Always restore terminal state on exit - implement Drop or use panic hooks.
Layouts
Coordinate System
Origin (0, 0) is top-left corner:
- •x-coordinates: left → right
- •y-coordinates: top → bottom
Basic Layout Splitting
use ratatui::layout::{Layout, Direction, Constraint};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Fixed 3 rows
Constraint::Min(10), // At least 10 rows
Constraint::Percentage(50), // 50% of remaining
])
.split(frame.area());
Constraint Types
- •Length(u16): Fixed size (rows/columns)
- •Percentage(u16): Relative to parent (e.g., 50%)
- •Ratio(u16, u16): Fractional allocation (e.g., 1/3)
- •Min(u16): Minimum size
- •Max(u16): Maximum size
Important: Ratio and Percentage are relative to parent size - may cause unexpected results when mixing with fixed constraints.
Nested Layouts (Multi-Pane UI)
fn draw(&self, frame: &mut Frame) {
// Vertical split: header, main, footer
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(10), // Main content
Constraint::Length(3), // Footer
])
.split(frame.area());
// Horizontal split of main area
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50), // Left pane
Constraint::Percentage(50), // Right pane
])
.split(outer[1]);
self.draw_header(frame, outer[0]);
self.draw_left_pane(frame, main[0]);
self.draw_right_pane(frame, main[1]);
self.draw_footer(frame, outer[2]);
}
Grid Layouts
// Modern approach (v0.30+)
let rows = Layout::vertical([Constraint::Length(5); 3])
.split(area);
let cells: Vec<Rect> = rows
.iter()
.flat_map(|row| {
Layout::horizontal([Constraint::Percentage(33); 3])
.split(*row)
})
.collect();
// Render widgets into each cell
for (i, &cell) in cells.iter().enumerate() {
frame.render_widget(
Block::bordered().title(format!("Cell {}", i)),
cell
);
}
Widgets
Common Widget Types
- •Block: Foundational widget with borders, titles, styling
- •Paragraph: Styled and wrapped text content
- •List: Selectable vertical items
- •Table: Data in rows/columns with selection
- •BarChart: Grouped or ungrouped bar graphs
- •Gauge: Progress visualization
- •Tabs: Tab bar interface
- •Canvas: Custom shapes and drawings
- •Chart: Line or scatter plots
Widget vs StatefulWidget
Widget trait: Consumed during rendering (one-time use)
pub trait Widget {
fn render(self, area: Rect, buf: &mut Buffer);
}
StatefulWidget trait: Maintains internal state (e.g., scroll position)
pub trait StatefulWidget {
type State;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State);
}
Modern variants (v0.26+): WidgetRef and StatefulWidgetRef allow rendering by reference for reuse.
Rendering Widgets
// Simple widget frame.render_widget(my_widget, area); // Stateful widget frame.render_stateful_widget(list_widget, area, &mut list_state);
Styling and Colors
Color Types
use ratatui::style::Color; // Named colors (16 standard terminal colors) Color::Black, Color::Red, Color::Green, Color::Yellow, Color::Blue, Color::Magenta, Color::Cyan, Color::Gray, Color::DarkGray, Color::LightRed, etc. // Indexed colors (256 palette: 0-255) Color::Indexed(196) // Bright red // RGB colors Color::Rgb(255, 128, 0) // Orange // Grayscale (indices 232-255) Color::Indexed(240) // Mid-gray
Applying Styles
use ratatui::style::{Style, Modifier};
let paragraph = Paragraph::new("Hello")
.style(Style::default()
.fg(Color::Yellow)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD));
// Using Stylize trait (shorthand)
use ratatui::style::Stylize;
let text = "Hello".yellow().bold();
Text Hierarchy (Span → Line → Text)
use ratatui::text::{Span, Line, Text};
// Span: styled segment
let span = Span::styled("Error: ", Style::default().fg(Color::Red));
// Line: collection of spans (single row)
let line = Line::from(vec![
Span::raw("Status: "),
Span::styled("OK", Style::default().fg(Color::Green).bold()),
]);
// Text: collection of lines (multi-line)
let text = Text::from(vec![
Line::from("First line"),
Line::from(vec![
Span::raw("Second line with "),
Span::styled("color", Style::default().fg(Color::Cyan)),
]),
]);
// Alignment
let centered = Line::from("Title").centered();
Event Handling
Input Modes Pattern
#[derive(PartialEq)]
enum InputMode {
Normal, // Navigation/commands
Editing, // Text input
}
fn handle_key_event(&mut self, key: KeyEvent) -> KeyResult {
match self.input_mode {
InputMode::Normal => match key.code {
KeyCode::Char('q') => KeyResult::Quit,
KeyCode::Char('i') => {
self.input_mode = InputMode::Editing;
KeyResult::Continue
}
_ => KeyResult::Continue,
},
InputMode::Editing => match key.code {
KeyCode::Esc => {
self.input_mode = InputMode::Normal;
KeyResult::Continue
}
KeyCode::Enter => {
let input = self.input.drain(..).collect::<String>();
self.history.push(input.clone());
KeyResult::Command(parse_command(&input))
}
KeyCode::Char(c) => {
self.input.insert(self.cursor_position, c);
self.cursor_position += 1;
KeyResult::Continue
}
KeyCode::Backspace => {
if self.cursor_position > 0 {
self.input.remove(self.cursor_position - 1);
self.cursor_position -= 1;
}
KeyResult::Continue
}
_ => KeyResult::Continue,
},
}
}
Cursor Positioning
// Set visible cursor position during rendering
if self.input_mode == InputMode::Editing {
frame.set_cursor_position((
input_area.x + self.cursor_position as u16 + 1,
input_area.y + 1,
));
}
Event Handling Architectures
1. Centralized - Simple, single location, doesn't scale 2. Message Passing - Centralized capture, distributed handling via channels 3. Distributed - Each module handles own events, may duplicate code
Recommendation: Choose based on app complexity - centralized for simple apps, message passing for multi-threaded or modular apps.
Component Architecture Pattern
For complex applications, organize as components with traits:
trait Component {
fn init(&mut self) -> Result<()>;
fn handle_events(&mut self, event: Event) -> Result<()>;
fn handle_key_events(&mut self, key: KeyEvent) -> Result<()>;
fn update(&mut self, action: Action) -> Result<()>;
fn render(&mut self, frame: &mut Frame, area: Rect);
}
Benefits:
- •Encapsulation: Components own private state
- •Modularity: Decoupled event handling and rendering
- •Scalability: Composition for complex apps
Multi-Threading Pattern
For apps requiring background work (e.g., Raft consensus + TUI):
// Main thread: TUI event loop
let (state_tx, state_rx) = crossbeam_channel::unbounded();
let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded();
// Spawn background thread
thread::spawn(move || {
background_work(cmd_rx, state_tx);
});
// TUI loop
let mut app = App::new(state_rx, cmd_tx);
app.run()?;
Key Points:
- •Use
try_recv()to drain pending updates non-blocking - •Event loop timeout enables UI updates without user input
- •Ring buffers (
VecDeque) prevent unbounded memory growth
Common Patterns and Best Practices
Color-Coded Event Display
fn draw_logs(&self, frame: &mut Frame, area: Rect) {
let logs: Vec<ListItem> = self.messages
.iter()
.map(|msg| {
let style = match msg.event_type {
EventType::Error => Style::default().fg(Color::Red),
EventType::Warning => Style::default().fg(Color::Yellow),
EventType::Info => Style::default().fg(Color::Green),
EventType::Debug => Style::default().fg(Color::Gray),
};
ListItem::new(msg.text.as_str()).style(style)
})
.collect();
let list = List::new(logs)
.block(Block::default().borders(Borders::ALL).title("Events"));
frame.render_widget(list, area);
}
Ring Buffers for Logs
// Prevent unbounded growth
if self.messages.len() > 1000 {
self.messages.pop_back();
}
self.messages.push_front(new_message);
Panic Hooks for Terminal Cleanup
use std::panic;
fn install_panic_hook() {
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
let _ = restore_terminal();
original_hook(panic_info);
}));
}
Testing TUI Applications
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tui_state_updates() {
let (state_tx, state_rx) = crossbeam_channel::unbounded();
let (cmd_tx, _cmd_rx) = crossbeam_channel::unbounded();
// Send mock updates
state_tx.send(StateUpdate::Data {
key: "test".into(),
value: "123".into(),
}).unwrap();
let mut app = App::new(state_rx, cmd_tx);
// Process updates
while let Ok(update) = app.state_rx.try_recv() {
app.apply_update(update);
}
assert_eq!(app.data.get("test"), Some(&"123".to_string()));
}
}
Project Integration
When implementing TUIs in this project:
- •Follow ROADMAP.md - Check Phase 2 for TUI implementation tasks
- •Use crossbeam channels - For Raft thread ↔ TUI thread communication
- •50ms poll timeout - For ~20 FPS update rate
- •Multi-pane layout - 4-pane design per README requirements
- •Color-coded events - Visual feedback for different event types
- •Non-blocking updates - Use
try_recv()to drain all pending state before rendering
Reference Documentation
For deeper details, see REFERENCE.md which contains:
- •Complete URL index of ratatui documentation
- •Detailed widget examples and configurations
- •Advanced layout techniques
- •Async event handling patterns
- •Production deployment recipes
When to Use This Skill
Activate this skill when:
- •Implementing terminal user interfaces
- •Working with ratatui or crossterm
- •Building interactive CLI applications
- •Creating multi-pane TUI layouts
- •Handling keyboard/mouse events in terminal apps
- •Styling terminal output with colors
- •Implementing immediate mode rendering patterns