Back to catalog

ratatui-tui-builder

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

  • Category: discovered
  • Tags: github, community
  • Version: 1.0.0
  • Author: walid
  • Downloads: 0
  • Updated: 2025-11-22

Download the latest version (v1.0.0) from this page.

SKILL.md

---
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:

```rust
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:

```rust
// 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

```rust
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)

```rust
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

```rust
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

```rust
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)

```rust
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

```rust
// 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)
```rust
pub trait Widget {
    fn render(self, area: Rect, buf: &mut Buffer);
}
```

**StatefulWidget trait**: Maintains internal state (e.g., scroll position)
```rust
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

```rust
// 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

```rust
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

```rust
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)

```rust
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

```rust
#[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

```rust
// 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:

```rust
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):

```rust
// 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

```rust
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

```rust
// Prevent unbounded growth
if self.messages.len() > 1000 {
    self.messages.pop_back();
}
self.messages.push_front(new_message);
```

### Panic Hooks for Terminal Cleanup

```rust
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

```rust
#[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:

1. **Follow ROADMAP.md** - Check Phase 2 for TUI implementation tasks
2. **Use crossbeam channels** - For Raft thread ↔ TUI thread communication
3. **50ms poll timeout** - For ~20 FPS update rate
4. **Multi-pane layout** - 4-pane design per README requirements
5. **Color-coded events** - Visual feedback for different event types
6. **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