Skip to content

Latest commit

 

History

History
2100 lines (1690 loc) · 51.9 KB

File metadata and controls

2100 lines (1690 loc) · 51.9 KB
name ratatui
description Rust terminal UI framework - widgets, components, layouts, events, input handling, and state management for TUI apps
metadata
author version tags
mte90
1.0.0
rust
tui
terminal
cli
user-interface
ratatui
ecosystem
tachyonfx
mousefood
ratzilla

Ratatui

Rust terminal UI framework.

Overview

Ratatui is a Rust library for building terminal user interfaces (TUI). It provides a set of widgets and tools for creating interactive command-line applications.

Key Features:

  • Multiple layout systems (blocks, flex, horizontal, vertical)
  • Built-in widgets (buttons, checkboxes, calendars, charts, tables)
  • Event-driven input handling
  • Cross-platform support
  • Mouse support
  • ANSI escape sequences
  • Multiple buffer rendering

Installation

# Cargo.toml
[dependencies]
ratatui = "0.28"

Quick Start

Basic Application

use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout},
    style::{Color, Style},
    widgets::{Block, Borders, Paragraph},
    Frame, Terminal,
};
use std::io;

fn main() -> io::Result<()> {
    // Initialize terminal
    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)?;

    // Main loop
    loop {
        terminal.draw(|f| {
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Length(3), Constraint::Min(0)])
                .split(f.area());

            let title = Paragraph::new("Hello, Ratatui!")
                .block(Block::bordered().title("Welcome"))
                .style(Style::default().fg(Color::Cyan));
            f.render_widget(title, chunks[0]);

            let instructions = Paragraph::new("Press 'q' to quit")
                .block(Block::bordered().title("Instructions"));
            f.render_widget(instructions, chunks[1]);
        })?;

        // Handle events (add your own event handling)
        break;  // Exit for now
    }

    Ok(())
}

Layout System

Block Layout

use ratatui::layout::{Constraint, Direction, Layout};

let chunks = Layout::default()
    .direction(Direction::Horizontal)
    .constraints([
        Constraint::Percentage(30),  // 30%
        Constraint::Length(50),     // 50 characters
        Constraint::Min(10),        // At least 10
        Constraint::Ratio(1, 4),    // 1/4 of remaining
    ])
    .split(area);

Flex Layout

use ratatui::layout::Flex;

let chunks = Layout::default()
    .direction(Direction::Horizontal)
    .flex(Flex::Center)  // Center content
    .constraints([Constraint::Length(20)])
    .split(area);

Nested Layouts

let chunks = Layout::default()
    .direction(Direction::Vertical)
    .constraints([
        Constraint::Length(3),
        Constraint::Min(0),
    ])
    .split(area);

let sub_chunks = Layout::default()
    .direction(Direction::Horizontal)
    .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
    .split(chunks[1]);

Widgets

Paragraph

use ratatui::widgets::{Block, Borders, Paragraph, Wrap};

let paragraph = Paragraph::new("Your text here")
    .block(Block::bordered().title("Title"))
    .style(Style::default().fg(Color::White))
    .wrap(Wrap { trim: true });

// Render
f.render_widget(paragraph, area);

Block

use ratatui::widgets::{Block, BorderType, Borders};

let block = Block::bordered()
    .title("My Block")
    .title_style(Style::default().fg(Color::Yellow))
    .border_type(BorderType::Rounded)
    .border_style(Style::default().fg(Color::Blue));

let inner = Paragraph::new("Content");
f.render_widget(block.inner(area), area);
f.render_widget(inner, block.inner(area));

Button

use ratatui::widgets::Button;

let button = Button::default()
    .text("Click Me")
    .style(Style::default().fg(Color::White).bg(Color::Blue))
    .pressed_style(Style::default().fg(Color::Blue).bg(Color::White));

f.render_widget(button, area);

Checkbox

use ratatui::widgets::Checkbox;

let checkbox = Checkbox::new("Enable feature", true)
    .style(Style::default().fg(Color::White))
    .check_style(Style::default().fg(Color::Green));

f.render_widget(checkbox, area);

List

use ratatui::widgets::List, ListItem;

let items = [
    ListItem::new("Item 1"),
    ListItem::new("Item 2"),
    ListItem::new("Item 3"),
];

let list = List::new(items)
    .block(Block::bordered().title("Items"))
    .style(Style::default().fg(Color::White))
    .highlight_style(Style::default().fg(Color::Yellow))
    .highlight_symbol(">> ");

f.render_widget(list, area);

Table

use ratatui::widgets::{Table, Row, Cell};

let rows = vec![
    Row::new(vec!["Row1", "Data1"]),
    Row::new(vec!["Row2", "Data2"]),
];

let table = Table::new(
    rows,
    // Column widths
    &[Constraint::Length(10), Constraint::Min(20)],
)
    .block(Block::bordered().title("Table"))
    .header_style(Style::default().fg(Color::Yellow))
    .widths(&[Constraint::Length(10), Constraint::Min(20)]);

f.render_widget(table, area);

Gauge

use ratatui::widgets::Gauge;

let gauge = Gauge::default()
    .label("Progress")
    .gauge_style(Style::default().fg(Color::Green))
    .percent(75);

f.render_widget(gauge, area);

Sparkline

use ratatui::widgets::Sparkline;

let data = vec![1, 5, 3, 7, 2, 8, 5, 3, 6, 4];

let sparkline = Sparkline::default()
    .data(&data)
    .style(Style::default().fg(Color::Cyan))
    .bar_set(" ▎▏");

f.render_widget(sparkline, area);

Calendar

use ratatui::widgets::{Calendar, Chrono};

let calendar = Calendar::default()
    .block(Block::bordered().title("2024"))
    .chrono(Chrono::Monthly)
    .show_months(true);

f.render_widget(calendar, area);

Chart

use ratatui::widgets::{Chart, Axis, Dataset};

let data = vec![
    (0.0, 1.0),
    (1.0, 3.0),
    (2.0, 2.0),
    (3.0, 5.0),
];

let chart = Chart::new(vec![Dataset::default()
    .data(&data)
    .name("Series")
    .style(Style::default().fg(Color::Cyan))])
    .block(Block::bordered().title("Chart"))
    .x_axis(Axis::default().bounds([0.0, 4.0]))
    .y_axis(Axis::default().bounds([0.0, 6.0]));

f.render_widget(chart, area);

Input Handling

Event Handling

use ratatui::event::{Event, EventHandler, KeyEvent, MouseEvent};

fn handle_events(events: &mut EventHandler) -> Option<Event> {
    // Try to read event (non-blocking)
    if let Ok(event) = events.try_read() {
        return Some(event);
    }
    None
}

// Key events
if let Some(Event::Key(key)) = handle_events(&mut handler) {
    match key.code {
        KeyCode::Char('q') => break,
        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
        _ => {}
    }
}

// Mouse events
if let Some(Event::Mouse(mouse)) = handle_events(&mut handler) {
    match mouse.kind {
        MouseEventKind::LeftClick => {
            // Handle click at mouse.column, mouse.row
        }
        MouseEventKind::ScrollDown => {
            // Handle scroll
        }
        _ => {}
    }
}

State Management

use ratatui::widgets::ListState;

struct AppState {
    items: Vec<String>,
    selected: usize,
    list_state: ListState,
}

impl AppState {
    fn new(items: Vec<String>) -> Self {
        let mut list_state = ListState::default();
        list_state.select(Some(0));
        
        Self { items, selected: 0, list_state }
    }
    
    fn next(&mut self) {
        if let Some(selected) = self.list_state.selected {
            let next = (selected + 1) % self.items.len();
            self.list_state.select(Some(next));
            self.selected = next;
        }
    }
    
    fn previous(&mut self) {
        if let Some(selected) = self.list_state.selected {
            let prev = if selected == 0 {
                self.items.len() - 1
            } else {
                selected - 1
            };
            self.list_state.select(Some(prev));
            self.selected = prev;
        }
    }
}

Styling

Styles

use ratatui::style::{Color, Modifier, Style, Stylize};

let style = Style::default()
    .fg(Color::White)
    .bg(Color::Black)
    .add_modifier(Modifier::BOLD)
    .add_modifier(Modifier::ITALIC);

// Apply to widget
let paragraph = Paragraph::new("Styled text")
    .style(style);

Color Palette

// Terminal colors
Color::Reset        // Reset to terminal default
Color::Black
Color::Red
Color::Green
Color::Yellow
Color::Blue
Color::Magenta
Color::Cyan
Color::White

// Bright variants
Color::DarkGray
Color::LightRed
Color::LightGreen
Color::LightYellow
Color::LightBlue
Color::LightMagenta
Color::LightCyan
Color::Gray

// Indexed colors (256-color)
Color::Indexed(42)

// RGB colors
Color::Rgb(255, 128, 0)

Modifiers

use ratatui::style::Modifier;

// Text modifiers
Modifier::BOLD
Modifier::DIM
Modifier::ITALIC
Modifier::UNDERLINED
Modifier::REVERSED
Modifier::HIDDEN
Modifier::CROSSED_OUT

Mouse Support

use ratatui::event::{Event, EventKind, MouseEventKind};

terminal.draw(|f| {
    // Enable mouse handling
    let event = Event::Mouse(MouseEvent {
        kind: MouseEventKind::Moved,
        column: 10,
        row: 5,
        ..
    });
    // Handle in event loop
})?;

Example: Interactive List

use ratatui::{
    backend::CrosstermBackend,
    event::{Event, KeyCode, KeyEventKind},
    layout::Constraint,
    style::Stylize,
    widgets::{Block, Borders, List, ListItem, ListState},
    Frame, Terminal,
};
use std::io;

fn main() -> io::Result<()> {
    let items = vec![
        ListItem::new("Option 1"),
        ListItem::new("Option 2"),
        ListItem::new("Option 3"),
        ListItem::new("Option 4"),
    ];

    let mut list_state = ListState::default();
    list_state.select(Some(0));

    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)?;

    loop {
        terminal.draw(|f| {
            let list = List::new(items.clone())
                .block(Block::bordered().title("Select Option"))
                .style(Style::default().fg(Color::White))
                .highlight_style(Style::default().fg(Color::Yellow).add_modifier(ratatui::style::Modifier::BOLD))
                .highlight_symbol(">> ");

            f.render_stateful_widget(list, f.area(), &mut list_state);
        })?;

        // Handle input
        if let Event::Key(key) = terminal.peek_event()? {
            if key.kind == KeyEventKind::Press {
                match key.code {
                    KeyCode::Down => {
                        if let Some(i) = list_state.selected {
                            list_state.select(Some((i + 1) % items.len()));
                        }
                    }
                    KeyCode::Up => {
                        if let Some(i) = list_state.selected {
                            list_state.select(Some(if i == 0 { items.len() - 1 } else { i - 1 }));
                        }
                    }
                    KeyCode::Enter => {
                        if let Some(i) = list_state.selected {
                            println!("Selected: {}", items[i]);
                        }
                    }
                    KeyCode::Char('q') => break,
                    _ => {}
                }
            }
        }
    }

    Ok(())
}

Best Practices

1. Separate State

// Good: Separate state from view
struct App {
    items: Vec<Item>,
    selected: usize,
    // ... state
}

// In draw
f.render_stateful_widget(list, area, &mut self.list_state);

2. Handle Resize

use ratatui::event::Event;

if let Ok(Event::Resize(width, height)) = term.read_event() {
    term.resize(width, height)?;
}

3. Panic Hook

// Restore terminal on panic
std::panic::set_hook(Box::new(|_| {
    let _ = ratatui::restore();
}));

4. Buffered Rendering

// Render to buffer first for complex UIs
let mut terminal = Terminal::new(CrosstermBackend::new(io::BufWriter::new(buf)))?;

TUI Design Principles

Keyboard-First Interaction

TUIs should prioritize keyboard navigation over mouse interaction:

// Consistent keybindings across views
match key.code {
    // Navigation
    KeyCode::Up | KeyCode::Char('k') => move_previous(),
    KeyCode::Down | KeyCode::Char('j') => move_next(),
    KeyCode::Left | KeyCode::Char('h') => move_left(),
    KeyCode::Right | KeyCode::Char('l') => move_right(),
    
    // Actions
    KeyCode::Char('a') => add_item(),
    KeyCode::Char('d') => delete_item(),
    KeyCode::Char('e') => edit_item(),
    KeyCode::Enter => select_item(),
    KeyCode::Escape => go_back(),
    KeyCode::Char('q') => quit(),
    
    // Help
    KeyCode::Char('?') | KeyCode::F(1) => show_help(),
    _ => {}
}

Key Principles:

  • Display hotkeys prominently in status bars or help sections
  • Use Vim-like bindings where appropriate (j/k for up/down)
  • Make destructive actions require confirmation (e.g., 'd' then 'y' to confirm)
  • Provide context-sensitive help per view

Visual Hierarchy

Use contrast and positioning to guide users:

// High contrast for important elements
let title = Paragraph::new("Critical Alert")
    .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));

// Muted styles for secondary information
let hint = Paragraph::new("Press 'q' to quit")
    .style(Style::default().fg(Color::DarkGray));

// Highlight selected items
let selected_style = Style::default()
    .fg(Color::Black)
    .bg(Color::Yellow)
    .add_modifier(Modifier::BOLD);

Design Rules:

  • Primary actions: Bright colors (Cyan, Yellow, Green)
  • Secondary info: Muted colors (Gray, DarkGray)
  • Errors/Warnings: Red/Orange with bold modifier
  • Selected focus: High contrast (inverse or bright bg)
  • Use borders to separate logical sections

Immediate Visual Feedback

Users need instant feedback on every interaction:

// Show loading state
if app.is_loading {
    let spinner = ["\\", "|", "/", "-"][app.spinner_frame % 4];
    let loading = Paragraph::new(format!("{} Loading...", spinner))
        .style(Style::default().fg(Color::Cyan));
    f.render_widget(loading, status_area);
    app.spinner_frame += 1;
}

// Show confirmation messages
if let Some(message) = app.last_action {
    let toast = Paragraph::new(message)
        .style(Style::default().fg(Color::Green))
        .alignment(Alignment::Center);
    f.render_widget(toast, toast_area);
}

Feedback Types:

  • Progress indicators: Spinners, progress bars for long operations
  • Status messages: Temporary toast notifications for actions
  • Selection highlighting: Always show what's currently focused
  • Mode indicators: Clear visual distinction between modes (normal/insert)
  • Error states: Red borders, shake animations, or error dialogs

Responsive Layouts

Design for various terminal sizes (80, 132, 256 columns):

// Use flexible constraints
let chunks = Layout::default()
    .direction(Direction::Horizontal)
    .constraints([
        Constraint::Min(20),      // Minimum width for sidebar
        Constraint::Percentage(50), // Flexible main content
        Constraint::Max(40),      // Optional info panel
    ])
    .split(area);

// Hide optional panels on small screens
let show_sidebar = width > 100;
let show_info = width > 140 && height > 25;

Responsive Patterns:

  • Always use Min() for minimum readable width
  • Hide non-essential panels on small terminals
  • Stack vertically when horizontal space is limited
  • Test at 80x24, 120x40, and 200x60

Usability & Accessibility

Color Contrast Guidelines

Ensure readability across terminal emulators:

// Safe color combinations (high contrast)
let good_combo = Style::default().fg(Color::Yellow).bg(Color::Black);
let good_combo2 = Style::default().fg(Color::Cyan).bg(Color::Blue);

// Avoid low-contrast combinations
let bad_combo = Style::default().fg(Color::Green).bg(Color::Blue); // Hard to read
let bad_combo2 = Style::default().fg(Color::DarkGray).bg(Color::Black); // Too dim

Color Best Practices:

  • Foreground should be significantly brighter than background
  • Test with grayscale conversion (remove all color, check contrast)
  • Provide themes for different terminal backgrounds (light/dark)
  • Avoid red/green combinations (color blindness)
  • Use text modifiers (bold, underline) as secondary indicators

Screen Reader Support

TUIs have limited screen reader compatibility, but can improve:

// Provide text alternatives
let aria_label = format!("List of {} items, {} selected", items.len(), selected);
let descriptive_text = Paragraph::new(aria_label)
    .style(Style::default().fg(Color::DarkGray));

// Logical reading order (top-to-bottom, left-to-right)
// Avoid complex multi-pane layouts that confuse screen readers

Accessibility Tips:

  • Offer a pure CLI fallback mode for screen reader users
  • Use clear, descriptive labels (not just icons)
  • Maintain consistent element ordering
  • Provide verbose help text that explains context
  • Document keyboard shortcuts in help section

Discoverability

Make features findable without memorization:

// Context-sensitive help
fn render_help(f: &mut Frame, current_view: &str) {
    let help_text = match current_view {
        "list" => vec![
            "↑/k - Move up",
            "↓/j - Move down",
            "Enter - Select",
            "d - Delete",
            "a - Add new item",
            "? - Show all shortcuts",
        ],
        "editor" => vec![
            "i - Insert mode",
            "Esc - Normal mode",
            "dd - Delete line",
            "yy - Yank line",
            "p - Paste",
        ],
        _ => vec!["? - Show available commands"],
    };
    
    let help = List::new(help_text)
        .block(Block::bordered().title("Shortcuts"));
    f.render_widget(help, help_area);
}

Discoverability Patterns:

  • Show most-used shortcuts in status bar
  • Implement command palette (Ctrl+K or /) to search commands
  • Provide tooltips on hover (mouse support)
  • Contextual help that changes per view
  • Progressive disclosure (basic help → full help)

Error Handling & Recovery

Design forgiving interfaces:

// Confirmation for destructive actions
if action == Action::Delete && !app.confirmed {
    let dialog = ConfirmDialog::new("Delete this item?")
        .yes_label("Yes, delete")
        .no_label("Cancel")
        .danger();
    f.render_widget(dialog, popup_area);
    return; // Wait for confirmation
}

// Undo support
app.history.push(current_state.clone());
if action == Action::Undo {
    app.current_state = app.history.pop().unwrap();
}

Error Prevention:

  • Require confirmation for destructive actions
  • Provide undo/redo where possible
  • Show preview before committing changes
  • Clear error messages with recovery steps
  • Auto-save work in progress

Performance Optimization

Minimize Redraws

Only update changed regions:

// Track what changed
if app.state_changed {
    terminal.draw(|f| render_app(f, &app))?;
    app.state_changed = false;
}

// Use Clear widget for popups to prevent bleeding
use ratatui::widgets::Clear;
Clear.render(popup_area, buf);

Efficient Event Handling

// Debounce rapid events
let mut last_render = Instant::now();
let render_interval = Duration::from_millis(16); // ~60fps

if key_event.is_some() || last_render.elapsed() > render_interval {
    terminal.draw(|f| render_app(f, &app))?;
    last_render = Instant::now();
}

Memory Management

// Pre-allocate buffers for repeated rendering
struct RenderCache {
    buffer: Vec<String>,
    last_modified: Instant,
}

// Reuse widget instances where possible
static BUTTON_STYLE: Lazy<Style> = Lazy::new(|| Style::default().fg(Color::Blue));

Complete Example

use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout},
    style::{Color, Stylize},
    widgets::{Block, Borders, Paragraph},
    Frame, Terminal,
};
use std::io;

struct App {
    counter: i32,
}

impl App {
    fn new() -> Self {
        Self { counter: 0 }
    }
    
    fn increment(&mut self) {
        self.counter += 1;
    }
    
    fn decrement(&mut self) {
        self.counter -= 1;
    }
    
    fn draw(&self, f: &mut Frame) {
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3),
                Constraint::Min(0),
            ])
            .split(f.area());

        let title = Paragraph::new(format!("Counter: {}", self.counter))
            .block(Block::bordered().title("Counter App"))
            .style(Style::default().fg(Color::Cyan))
            .centered();
        
        let instructions = Paragraph::new("Use UP/DOWN arrows, 'q' to quit")
            .block(Block::bordered().title("Instructions"))
            .style(Color::Gray)
            .centered();

        f.render_widget(title, chunks[0]);
        f.render_widget(instructions, chunks[1]);
    }
}

fn main() -> io::Result<()> {
    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)?;
    let mut app = App::new();

    loop {
        app.draw(&mut terminal);
        
        if let Ok(event) = terminal.read_event() {
            use ratatui::event::{Event, KeyCode, KeyEventKind};
            
            if let Event::Key(key) = event {
                if key.kind == KeyEventKind::Press {
                    match key.code {
                        KeyCode::Up => app.increment(),
                        KeyCode::Down => app.decrement(),
                        KeyCode::Char('q') => break,
                        _ => {}
                    }
                }
            }
        }
    }

    Ok(())
}

Advanced State Management Patterns

Model-View-Update (MVU/Elm Architecture)

Ideal for predictable data flow in complex TUIs:

use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;

// MODEL: Application state
#[derive(Default)]
struct App {
    counter: i32,
    mode: AppMode,
    items: Vec<String>,
    selected: Option<usize>,
}

enum AppMode {
    Normal,
    Insert,
    Help,
}

// MESSAGES: Actions that trigger state changes
enum Msg {
    Increment,
    Decrement,
    AddItem(String),
    DeleteSelected,
    ToggleMode,
    Quit,
}

// UPDATE: State transformation logic
fn update(app: &mut App, msg: Msg) {
    match msg {
        Msg::Increment => app.counter += 1,
        Msg::Decrement => app.counter -= 1,
        Msg::AddItem(name) => {
            app.items.push(name);
            if app.selected.is_none() {
                app.selected = Some(0);
            }
        },
        Msg::DeleteSelected => {
            if let Some(idx) = app.selected {
                app.items.remove(idx);
                app.selected = if app.items.is_empty() {
                    None
                } else {
                    Some(idx.min(app.items.len() - 1))
                };
            }
        },
        Msg::ToggleMode => {
            app.mode = match app.mode {
                AppMode::Normal => AppMode::Help,
                AppMode::Help => AppMode::Normal,
                AppMode::Insert => AppMode::Normal,
            };
        },
        Msg::Quit => std::process::exit(0),
    }
}

// VIEW: Render function (pure, no side effects)
fn view(app: &App, frame: &mut ratatui::Frame) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(0),
            Constraint::Length(3),
        ])
        .split(frame.area());

    // Counter display
    let counter_text = format!("Counter: {}", app.counter);
    let counter = Paragraph::new(counter_text)
        .style(Style::default().fg(Color::Cyan))
        .block(Block::bordered().title("Counter"));
    frame.render_widget(counter, chunks[0]);

    // Item list
    let items: Vec<ListItem> = app.items
        .iter()
        .map(|i| ListItem::new(i.as_str()))
        .collect();
    
    let list = List::new(items)
        .block(Block::bordered().title("Items"))
        .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
        .highlight_symbol(">> ");
    
    frame.render_stateful_widget(
        list,
        chunks[1],
        &mut ListState::default().with_selected(app.selected),
    );

    // Mode indicator
    let mode_text = match app.mode {
        AppMode::Normal => "Mode: Normal (↑/↓ to navigate, a to add, d to delete, ? for help)",
        AppMode::Help => "Mode: Help (Press '?' to close)",
        AppMode::Insert => "Mode: Insert (Not implemented)",
    };
    let mode = Paragraph::new(mode_text)
        .style(Style::default().fg(Color::Green))
        .block(Block::bordered().title("Status"));
    frame.render_widget(mode, chunks[2]);
}

// MAIN LOOP: Event handling and message dispatch
fn main() -> io::Result<()> {
    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)?;
    let mut app = App::default();

    loop {
        terminal.draw(|f| view(&app, f))?;

        if let Event::Key(key) = terminal.read_event()? {
            let msg = match key.code {
                KeyCode::Char('q') => Msg::Quit,
                KeyCode::Up | KeyCode::Char('k') => {
                    if let Some(selected) = app.selected {
                        app.selected = Some(if selected == 0 {
                            app.items.len().saturating_sub(1)
                        } else {
                            selected - 1
                        });
                        continue; // No message, direct state update
                    }
                    continue;
                },
                KeyCode::Down | KeyCode::Char('j') => {
                    if let Some(selected) = app.selected {
                        app.selected = Some((selected + 1) % app.items.len().max(1));
                        continue;
                    }
                    continue;
                },
                KeyCode::Char('a') => Msg::AddItem("New Item".to_string()),
                KeyCode::Char('d') => Msg::DeleteSelected,
                KeyCode::Char('?') => Msg::ToggleMode,
                _ => continue,
            };
            update(&mut app, msg);
        }
    }
}

Flux Architecture Pattern

For complex applications with multiple stores:

use std::sync::{Arc, Mutex};
use crossbeam::channel::{unbounded, Sender, Receiver};

// Dispatcher: Central hub for all actions
struct Dispatcher {
    sender: Sender<Action>,
    subscribers: Vec<Box<dyn Fn(Action) + Send>>,
}

impl Dispatcher {
    fn new() -> Self {
        let (sender, receiver) = unbounded();
        let dispatcher = Self {
            sender,
            subscribers: Vec::new(),
        };
        
        // Spawn listener thread
        std::thread::spawn(move || {
            for action in receiver {
                // Broadcast to all subscribers
                // (simplified - real implementation needs proper synchronization)
            }
        });
        
        dispatcher
    }
    
    fn dispatch(&self, action: Action) {
        self.sender.send(action).unwrap();
    }
    
    fn subscribe(&mut self, callback: Box<dyn Fn(Action) + Send>) {
        self.subscribers.push(callback);
    }
}

// Actions: Describe what happened
enum Action {
    UserPressedKey(KeyCode),
    DataLoaded(Vec<String>),
    ErrorOccurred(String),
    TimerTick,
}

// Stores: Hold application state
struct ItemStore {
    items: Vec<String>,
    selected: Option<usize>,
}

impl ItemStore {
    fn on_action(&mut self, action: &Action) {
        match action {
            Action::DataLoaded(new_items) => {
                self.items = new_items.clone();
                self.selected = Some(0);
            },
            Action::UserPressedKey(KeyCode::Char('d')) => {
                if let Some(idx) = self.selected {
                    self.items.remove(idx);
                }
            },
            _ => {}
        }
    }
}

// Views: Render based on store state
fn render_items(store: &ItemStore, frame: &mut Frame) {
    // Render logic here
}

Component-Based Architecture

Object-oriented approach with trait-based components:

trait Component {
    fn render(&mut self, frame: &mut Frame, area: Rect);
    fn handle_events(&mut self, event: &Event) -> Option<Action>;
    fn update(&mut self, action: Action);
}

struct Sidebar {
    items: Vec<String>,
    selected: usize,
}

impl Component for Sidebar {
    fn render(&mut self, frame: &mut Frame, area: Rect) {
        let list = List::new(self.items.clone())
            .block(Block::bordered().title("Sidebar"));
        frame.render_stateful_widget(
            list,
            area,
            &mut ListState::default().with_selected(Some(self.selected)),
        );
    }
    
    fn handle_events(&mut self, event: &Event) -> Option<Action> {
        if let Event::Key(key) = event {
            match key.code {
                KeyCode::Up => {
                    self.selected = self.selected.saturating_sub(1);
                },
                KeyCode::Down => {
                    self.selected = (self.selected + 1) % self.items.len().max(1);
                },
                _ => {}
            }
        }
        None
    }
    
    fn update(&mut self, _action: Action) {
        // Handle state updates
    }
}

struct MainContent {
    // ...
}

impl Component for MainContent {
    // ...
}

struct App {
    sidebar: Sidebar,
    main: MainContent,
}

impl App {
    fn render(&mut self, frame: &mut Frame) {
        let chunks = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Length(20), Constraint::Min(0)])
            .split(frame.area());
        
        self.sidebar.render(frame, chunks[0]);
        self.main.render(frame, chunks[1]);
    }
    
    fn handle_event(&mut self, event: Event) {
        if let Some(action) = self.sidebar.handle_events(&event) {
            self.sidebar.update(action.clone());
            self.main.update(action);
        }
    }
}

Widget Composition & Custom Recipes

Composing Widgets

Build complex UIs by combining simple widgets:

fn render_card(frame: &mut Frame, area: Rect, title: &str, content: &str) {
    let block = Block::bordered()
        .title(title)
        .border_style(Style::default().fg(Color::Blue))
        .border_type(BorderType::Rounded);
    
    let inner = block.inner(area);
    let paragraph = Paragraph::new(content)
        .style(Style::default().fg(Color::White))
        .wrap(Wrap { trim: true });
    
    frame.render_widget(block, area);
    frame.render_widget(paragraph, inner);
}

// Usage
render_card(frame, area, "Info", "Some important data here...");

Custom Widget: Progress Bar

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Style},
    widgets::{Widget, Block},
};

struct ProgressBar {
    percentage: u16,
    label: String,
    block: Option<Block<'static>>,
}

impl ProgressBar {
    fn new(percentage: u16) -> Self {
        Self {
            percentage,
            label: String::new(),
            block: None,
        }
    }
    
    fn label(mut self, label: impl Into<String>) -> Self {
        self.label = label.into();
        self
    }
    
    fn block(mut self, block: Block<'static>) -> Self {
        self.block = Some(block);
        self
    }
}

impl Widget for ProgressBar {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let inner = self.block.map_or(area, |b| {
            let inner = b.inner(area);
            b.render(area, buf);
            inner
        });
        
        if inner.width < 2 || inner.height < 1 {
            return;
        }
        
        // Draw bar
        let bar_width = inner.width.saturating_sub(2) as u16;
        let filled = (bar_width * self.percentage) / 100;
        
        let mut x = inner.x + 1;
        for i in 0..bar_width {
            let cell = if i < filled { "█" } else { "░" };
            let style = if i < filled {
                Style::default().fg(Color::Green)
            } else {
                Style::default().fg(Color::DarkGray)
            };
            buf.set_string(x, inner.y, cell, style);
            x += 1;
        }
        
        // Draw label
        if !self.label.is_empty() {
            let label = format!(" {}% ", self.percentage);
            buf.set_string(
                inner.x + bar_width + 1,
                inner.y,
                &label,
                Style::default().fg(Color::White),
            );
        }
    }
}

// Usage
let progress = ProgressBar::new(75)
    .label("Loading")
    .block(Block::bordered().title("Progress"));
frame.render_widget(progress, area);

Custom Widget: Modal Dialog

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Style},
    widgets::{Block, Borders, Clear, Paragraph, Widget},
};

struct Modal {
    title: String,
    message: String,
    width: u16,
    height: u16,
}

impl Modal {
    fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            message: message.into(),
            width: 60,
            height: 10,
        }
    }
}

impl Widget for Modal {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // Calculate centered position
        let x = area.x + (area.width.saturating_sub(self.width)) / 2;
        let y = area.y + (area.height.saturating_sub(self.height)) / 2;
        let modal_area = Rect::new(x, y, self.width, self.height);
        
        // Clear area to prevent content bleeding
        Clear.render(modal_area, buf);
        
        // Render modal content
        let block = Block::default()
            .title(self.title)
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::Yellow))
            .style(Style::default().bg(Color::Black));
        
        let inner = block.inner(modal_area);
        block.render(modal_area, buf);
        
        let paragraph = Paragraph::new(self.message)
            .style(Style::default().fg(Color::White))
            .wrap(Wrap { trim: true });
        paragraph.render(inner, buf);
    }
}

// Usage
let modal = Modal::new("Alert", "Operation completed successfully.");
frame.render_widget(modal, frame.area());

Reusable Layout Helpers

fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);
    
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
}

// Usage for popups
let popup_area = centered_rect(60, 40, frame.area());

Styled Text Building Blocks

use ratatui::{
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
    widgets::Paragraph,
};

fn build_styled_header(title: &str, subtitle: &str) -> Paragraph {
    let title_line = Line::from(vec![
        Span::styled(title, Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD)),
        Span::raw(" - "),
        Span::styled(subtitle, Style::default()
            .fg(Color::Gray)
            .add_modifier(Modifier::DIM)),
    ]);
    
    let text = Text::from(vec![title_line]);
    Paragraph::new(text)
        .alignment(Alignment::Center)
        .block(Block::bordered().title("Header"))
}

Ecosystem Libraries

An effects and animation library for Ratatui applications. Build complex animations by composing and layering simple effects, bringing smooth transitions and visual polish to the terminal.

# Cargo.toml
[dependencies]
tachyonfx = "0.2"

Key features:

Mousefood

An embedded-graphics backend for Ratatui. Supports no_std environments.

# Cargo.toml
[dependencies]
mousefood = "0.1"

Key features:

  • Use Ratatui with embedded displays
  • Works with various embedded-graphics draw targets
  • Example: Tuitar - guitar learning tool built with Ratatui & Mousefood

Ratzilla

Build terminal-themed web applications with Rust and WebAssembly.

# Cargo.toml
[dependencies]
ratzilla = "0.1"

Key features:

Third-Party Widgets Showcase

Ratatui has a vibrant ecosystem of third-party widgets:

  • ratatui-image - Image widget with multiple graphics protocol backends (sixel, iTerm2, kitty, etc.)
  • ratatui-textarea - Multi-line text editor widget like HTML <textarea>
  • throbber-widgets-tui - Activity indicator, progress bar, loading icon, spinner
  • tui-big-text - Renders large pixel text using font8x8 glyphs
  • tui-checkbox - Customizable checkbox widget with custom styling and symbols
  • tui-logger - Widget for capturing and displaying logs
  • tui-menu - Menu widget for rendering nestable menus
  • tui-nodes - Node graph visualization widget
  • tui-piechart - Versatile pie chart widget with multiple symbol sets
  • tui-scrollview - Widget for creating scrollable views
  • tui-term - Pseudoterminal widget
  • tui-tree-widget - Tree data structure visualization widget
  • tui-widget-list - Stateful widget list implementation for Ratatui

Application Recipes

Better Panic Hooks

Use better-panic for pretty backtraces and human-panic for user-friendly error handling:

# Cargo.toml
[dependencies]
better-panic = "0.3"
human-panic = "1.2"
color-eyre = "0.6"
libc = "1.0"
strip-ansi-escapes = "0.2"
use better_panic::Settings;

pub fn initialize_panic_handler() {
    std::panic::set_hook(Box::new(|panic_info| {
        // Exit terminal cleanly
        crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen).unwrap();
        crossterm::terminal::disable_raw_mode().unwrap();
        
        // Show pretty backtrace
        Settings::auto()
            .most_recent_first(false)
            .lineno_suffix(true)
            .create_panic_handler()(panic_info);
    }));
}

For release builds, use human-panic for user-friendly messages:

use human_panic::{handle_dump, print_msg, Metadata};

pub fn initialize_panic_handler() -> Result<()> {
    std::panic::set_hook(Box::new(move |panic_info| {
        let meta = Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
            .authors(format!("authored by {}", env!("CARGO_PKG_AUTHORS")))
            .support(format!("You can open a support request at {}", env!("CARGO_PKG_REPOSITORY")));
        
        let file_path = handle_dump(&meta, panic_info);
        print_msg(file_path, &meta).expect("human-panic: printing error message failed");
        std::process::exit(libc::EXIT_FAILURE);
    }));
    Ok(())
}

Color-Eyre Error Hooks

Use color_eyre for beautiful error reports:

# Cargo.toml
[dependencies]
color-eyre = "0.6"
use color_eyre::Result;

fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;
    let terminal = tui::init()?;
    let result = run(terminal).wrap_err("run failed");
    if let Err(err) = tui::restore() {
        eprintln!("failed to restore terminal: {err}");
    }
    result
}

fn set_panic_hook() {
    let hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |panic_info| {
        let _ = restore();
        hook(panic_info);
    }));
}

Terminal and Event Handler

Create a reusable Tui struct with Terminal and EventHandler:

# Cargo.toml
[dependencies]
ratatui = "0.28"
tokio = { version = "1", features = ["sync", "task", "time"] }
tokio-util = "0.7"
futures = "0.3"
color-eyre = "0.6"
use std::ops::{Deref, DerefMut};
use std::time::Duration;

use color_eyre::eyre::Result;
use futures::{FutureExt, StreamExt};
use ratatui::backend::CrosstermBackend as Backend;
use ratatui::crossterm::{
    cursor,
    event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},
    terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use serde::{Deserialize, Serialize};
use tokio::{sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, task::JoinHandle};
use tokio_util::sync::CancellationToken;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event {
    Init, Quit, Error, Closed, Tick, Render,
    FocusGained, FocusLost, Paste(String),
    Key(KeyEvent), Mouse(MouseEvent), Resize(u16, u16),
}

pub struct Tui {
    pub terminal: ratatui::Terminal<Backend<std::io::Stderr>>,
    pub task: JoinHandle<()>,
    pub cancellation_token: CancellationToken,
    pub event_rx: UnboundedReceiver<Event>,
    pub event_tx: UnboundedSender<Event>,
}

impl Tui {
    pub fn new() -> Result<Self> {
        let terminal = ratatui::Terminal::new(Backend::new(std::io::stderr()))?;
        let (event_tx, event_rx) = mpsc::unbounded_channel();
        let cancellation_token = CancellationToken::new();
        let task = tokio::spawn(async {});
        Ok(Self { terminal, task, cancellation_token, event_tx, event_rx })
    }

    pub fn enter(&mut self) -> Result<()> {
        crossterm::terminal::enable_raw_mode()?;
        crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
        if self.mouse {
            crossterm::execute!(std::io::stderr(), EnableMouseCapture)?;
        }
        self.start();
        Ok(())
    }

    pub fn exit(&mut self) -> Result<()> {
        self.stop()?;
        if crossterm::terminal::is_raw_mode_enabled()? {
            self.flush()?;
            if self.mouse {
                crossterm::execute!(std::io::stderr(), DisableMouseCapture)?;
            }
            crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?;
            crossterm::terminal::disable_raw_mode()?;
        }
        Ok(())
    }

    pub async fn next(&mut self) -> Option<Event> {
        self.event_rx.recv().await
    }
}

impl Drop for Tui {
    fn drop(&mut self) {
        self.exit().unwrap();
    }
}

CLI Arguments

Use clap for command-line argument parsing:

# Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::Parser;

#[derive(Parser, Debug)]
#[command(version = version(), about = "My TUI App")]
struct Args {
    /// App tick rate in milliseconds
    #[arg(short, long, default_value_t = 1000)]
    tick_rate: u64,
    
    /// Enable mouse support
    #[arg(short, long)]
    mouse: bool,
}

fn main() {
    let args = Args::parse();
    // Use args.tick_rate, args.mouse, etc.
}

Widget Recipes

Custom Widgets

Create custom widgets by implementing the Widget trait:

use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget, style::Color};

pub struct MyWidget {
    content: String,
}

impl Widget for MyWidget {
    fn render(self, area: Rect, buf: &mut Buffer) {
        buf.set_string(area.left(), area.top(), &self.content, Style::default().fg(Color::Green));
    }
}

For stateful widgets, use StatefulWidget:

use ratatui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget, style::Style};

pub struct ListWidget {
    items: Vec<String>,
}

pub struct ListState {
    selected: usize,
}

impl StatefulWidget for ListWidget {
    type State = ListState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        // Render items, highlight selected one based on state.selected
    }
}

Block Widget

Use Block for framing and titling widgets:

use ratatui::widgets::{Block, BorderType, Borders};

let block = Block::default()
    .title("Header")
    .borders(Borders::ALL);

// Multiple titles with alignment
let block = Block::default()
    .title(Line::from("Left").left_aligned())
    .title(Line::from("Center").centered())
    .title(Line::from("Right").right_aligned())
    .border_style(Style::default().fg(Color::Magenta))
    .border_type(BorderType::Rounded)
    .borders(Borders::ALL);

f.render_widget(block, area);

Paragraph Widget

Display text with wrapping, alignment, and styling:

use ratatui::widgets::{Block, Borders, Paragraph, Wrap, Alignment};

// Basic usage
let p = Paragraph::new("Hello, World!");

// With styling and borders
let p = Paragraph::new("Hello, World!")
    .style(Style::default().fg(Color::Yellow))
    .block(Block::default()
        .borders(Borders::ALL)
        .title("Title")
        .border_type(BorderType::Rounded));

// Wrapping
let p = Paragraph::new("A very long text...")
    .wrap(Wrap { trim: true });

// Alignment
let p = Paragraph::new("Centered Text")
    .alignment(Alignment::Center);

// Styled text with Spans
let p = Paragraph::new(Text::from(vec![
    Line::from(vec![
        Span::styled("Hello ", Style::default().fg(Color::Yellow)),
        Span::styled("World", Style::default().fg(Color::Blue).bg(Color::White)),
    ])
]));

// Scrolling
let mut p = Paragraph::new("Long content...")
    .scroll((1, 0));  // Vertical, horizontal scroll

Rendering Recipes

Overwrite Regions (Popups)

Use the Clear widget to prevent content bleeding:

use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};

struct Popup {
    title: String,
    content: String,
}

impl Widget for Popup {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // Clear area first to avoid leaking content
        Clear.render(area, buf);
        
        let block = Block::new()
            .title(self.title)
            .borders(Borders::ALL);
        
        Paragraph::new(self.content)
            .block(block)
            .render(area, buf);
    }
}

// Usage
frame.render_widget(popup, popup_area);

Displaying Text

Use Span, Line, and Text for styled text:

use ratatui::{prelude::*, widgets::*};

// Span - styled text segment
let span = Span::raw("unstyled");
let span = Span::styled("styled", Style::default().fg(Color::Yellow));
let span = "using stylize trait".yellow();  // via Stylize trait

// Line - collection of Spans
let line = Line::from(vec![
    "hello".red(),
    " ".into(),
    "world".red().bold()
]);
let line = Line::from("hello world");
let line: Line = "hello world".yellow().into();
let line = Line::from("hello world").centered();

// Text - collection of Lines
let text = Text::from(vec![
    Line::from("line 1"),
    Line::from("line 2").blue(),
]);
let text = Text::from("multi\nline\ntext");

// Use with Paragraph
f.render_widget(Paragraph::new(text).block(Block::bordered()), area);

Backend Concepts

Ratatui supports multiple backends for terminal interaction:

Crossterm Backend (Default)

# Cargo.toml
[dependencies]
ratatui = { version = "0.28", default-features = false, features = ["crossterm"] }
crossterm = "0.28"
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::io::stdout;

let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;

Termion Backend

# Cargo.toml
[dependencies]
ratatui = { version = "0.28", features = ["termion"] }
termion = "1.5"
use ratatui::backend::TermionBackend;
use ratatui::Terminal;
use std::io::stdout;

let backend = TermionBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;

Termwiz Backend

# Cargo.toml
[dependencies]
ratatui = { version = "0.28", features = ["termwiz"] }
termwiz = "0.22"
use ratatui::backend::TermwizBackend;
use ratatui::Terminal;
use termwiz::caps::Caps;

let backend = TermwizBackend::new(Caps::new()?);
let mut terminal = Terminal::new(backend)?;

Test Backend

Useful for unit testing:

use ratatui::backend::TestBackend;
use ratatui::Terminal;

let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend);

// Render and check output
terminal.draw(|frame| {
    frame.render_widget(Paragraph::new("Test"), frame.area());
}).unwrap();

// Assert on terminal.backend() content

Mouse Capture

Each backend handles mouse capture differently. Enable mouse events:

use ratatui::crossterm::event::{EnableMouseCapture, DisableMouseCapture};

// Enable mouse capture
crossterm::execute!(stderr(), EnableMouseCapture)?;

// In your event handling
if let Event::Mouse(mouse_event) = event {
    match mouse_event.kind {
        MouseEventKind::LeftClick { column, row } => { /* handle click */ }
        MouseEventKind::ScrollDown => { /* handle scroll */ }
        _ => {}
    }
}

// Disable on exit
crossterm::execute!(stderr(), DisableMouseCapture)?;

Testing Recipes

Snapshot Testing with Insta

Use insta and TestBackend for snapshot testing:

# Cargo.toml
[dev-dependencies]
insta = "1.39"
use insta::assert_snapshot;
use ratatui::{backend::TestBackend, Terminal, widgets::Paragraph};

#[test]
fn test_render_app() {
    let app = App::default();
    let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
    terminal
        .draw(|frame| frame.render_widget(&app, frame.area()))
        .unwrap();
    assert_snapshot!(terminal.backend());
}

Run tests and accept snapshots:

cargo test
cargo insta review  # Review and accept changes

Debug Widget State

Render debug info for development:

struct AppState {
    show_debug: bool,
    // your app state
}

fn render(frame: &mut Frame, state: &AppState) {
    // Create area for debug view (0 width when disabled)
    let debug_width = u16::from(state.show_debug);
    let [main, debug] = Layout::horizontal([
        Constraint::Fill(1),
        Constraint::Fill(debug_width)
    ]).areas(frame.area());
    
    // Render main content
    frame.render_widget(&state.content, main);
    
    // Render debug info when enabled
    if state.show_debug {
        let debug_text = Text::from(format!("state: {state:#?}"));
        frame.render_widget(debug_text, debug);
    }
}

// Toggle with a key (e.g., 'd' key)
KeyCode::Char('d') => state.show_debug = !state.show_debug,

References

Official Resources

Ecosystem Libraries

Official Recipes

Concepts & Architecture

Community & Inspiration