name: ratatui-tui-builder
description: Build terminal user interfaces using ratatui - widgets, layouts, event handling, rendering, and application patterns. Use when creating TUIs, implementing terminal UIs, building interactive CLI apps, or working with ratatui/crossterm. Keywords: ratatui, TUI, terminal UI, crossterm, widgets, layout, event handling, immediate mode rendering
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| {
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:
let text = Text::raw("hello").centered();
let paragraph = Paragraph::new("content")
.block(Block::bordered())
.style(Style::default().fg(Color::Yellow));
let text = Text::raw("wrong");
text.centered();
Critical: Setter methods consume self and return modified instance. Use #[must_use] to catch this mistake.
Application Architecture
Standard App Structure
struct App {
input: String,
input_mode: InputMode,
cursor_position: usize,
state: AppState,
data: BTreeMap<String, String>,
history: Vec<String>,
messages: VecDeque<String>,
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 {
while let Ok(update) = self.state_rx.try_recv() {
self.apply_update(update);
}
terminal.draw(|frame| self.draw(frame))?;
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),
Constraint::Min(10),
Constraint::Percentage(50),
])
.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) {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(3),
])
.split(frame.area());
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.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
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();
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
frame.render_widget(my_widget, area);
frame.render_stateful_widget(list_widget, area, &mut list_state);
Styling and Colors
Color Types
use ratatui::style::Color;
Color::Black, Color::Red, Color::Green, Color::Yellow,
Color::Blue, Color::Magenta, Color::Cyan, Color::Gray,
Color::DarkGray, Color::LightRed, etc.
Color::Indexed(196)
Color::Rgb(255, 128, 0)
Color::Indexed(240)
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));
use ratatui::style::Stylize;
let text = "Hello".yellow().bold();
Text Hierarchy (Span → Line → Text)
use ratatui::text::{Span, Line, Text};
let span = Span::styled("Error: ", Style::default().fg(Color::Red));
let line = Line::from(vec![
Span::raw("Status: "),
Span::styled("OK", Style::default().fg(Color::Green).bold()),
]);
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)),
]),
]);
let centered = Line::from("Title").centered();
Event Handling
Input Modes Pattern
#[derive(PartialEq)]
enum InputMode {
Normal,
Editing,
}
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
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):
let (state_tx, state_rx) = crossbeam_channel::unbounded();
let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded();
thread::spawn(move || {
background_work(cmd_rx, state_tx);
});
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
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();
state_tx.send(StateUpdate::Data {
key: "test".into(),
value: "123".into(),
}).unwrap();
let mut app = App::new(state_rx, cmd_tx);
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