Rust 87 🦀 Building Command-Line Terminal Interfaces (ratatui)
Ratatui is an immediate-mode rendering library for terminal user interfaces. The distinction from a retained-mode GUI framework is architectural: on every frame, the application must rebuild and render the entire widget tree, and Ratatui diffs the result against the previous frame to determine which terminal cells need updating . This model gives the developer complete control over the event loop, state management, and asynchronous integration, at the cost of writing the loop and the state transitions yourself. Ratatui does not provide an event system or input handling; it provides the widgets, the layout engine, and the buffer diffing that make the terminal output efficient .
The library was forked from tui-rs in 2023 after the original project was archived, and it has become the de facto standard for Rust TUIs, with a large widget ecosystem and active maintenance . Version 0.30 modularized the crate into a workspace (ratatui-core, ratatui-widgets, and per-backend crates), added no_std compatibility, and introduced a ratatui::run() entry point that simplifies the initialization-and-restore boilerplate . The backend is pluggable: crossterm is the default and works on most platforms, with termion and termwiz as alternatives .
This chapter covers the application skeleton (initialize, loop, restore), the layout and widget model, the text primitives (Span, Line, Text), the event-handling pattern that Ratatui leaves to the developer, and the architectural decision between immediate-mode and retained-mode libraries. It assumes familiarity with Rust’s ownership model and basic terminal concepts.
Key point: Ratatui is immediate-mode: on every frame, you render the entire UI, and the library diffs the buffer to update only the changed cells. The application owns the event loop and the state. This is why Ratatui integrates cleanly with async runtimes like Tokio and why it does not provide a built-in event system—the loop is yours to write.
Why immediate-mode rendering dominates Rust TUIs
The buffer diff makes full re-rendering cheap. Rendering the entire widget tree on every frame sounds expensive, but Ratatui compares the new buffer against the previous one and writes only the cells that changed to the terminal. For a static UI with a blinking cursor, the diff is a few cells; for a dashboard with live data, the diff is the cells whose values changed . The cost is proportional to the change, not the size of the UI.
The developer owns the loop. Ratatui does not run a background thread or a callback dispatcher. The main function initializes the terminal, enters a loop, draws a frame, reads an event, updates the state, and repeats. This structure is what allows the application to integrate with an async runtime: the event read can be replaced with a tokio::select! that waits on both terminal input and a channel from a background task . A retained-mode library that owns the loop would require a different integration pattern.
Retained mode is the alternative, and it is less active. Cursive is the primary retained-mode library in Rust. It provides a widget tree that is updated and redrawn automatically, with built-in event handling and a callback-based API that feels closer to traditional GUI frameworks . The trade-off is control: the framework owns the loop, and integrating external async events requires working within its callback model. Cursive’s release cadence has slowed, while Ratatui’s has not .
The trade-off. Immediate mode requires the developer to write the event loop, manage the state, and decide when to redraw. A retained-mode framework handles these concerns. For a simple form or dialog, Cursive may be faster to build. For a dashboard that streams data from multiple async sources, Ratatui’s control over the loop is the reason to choose it.
a. The application skeleton: initialize, loop, restore
Every Ratatui application follows the same three steps. The ratatui::init() function returns a DefaultTerminal that has entered the alternate screen and raw mode, and it installs a panic hook that restores the terminal if the application panics. The ratatui::restore() function returns the terminal to its original state . If the application exits without calling restore, the terminal is left in raw mode with the alternate screen active, and the user must start a new terminal session to recover .
use ratatui::widgets::Paragraph;
use crossterm::event::{self, Event, KeyCode};
fn main() -> std::io::Result<()> {
// Initialize: alternate screen, raw mode, panic hook.
let mut terminal = ratatui::init();
// Main loop: draw, read event, update state.
let result = run(&mut terminal);
// Restore: leave alternate screen, disable raw mode.
ratatui::restore();
result
}
fn run(terminal: &mut ratatui::DefaultTerminal) -> std::io::Result<()> {
loop {
terminal.draw(|frame| {
let text = Paragraph::new("Hello, Ratatui!");
frame.render_widget(text, frame.area());
})?;
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Char('q') {
return Ok(());
}
}
}
}
The terminal.draw method takes a closure that receives a Frame. The frame provides the available area and the render_widget method. After the closure returns, Ratatui diffs the buffer and writes the changes to the terminal . The closure should render the entire UI for that frame; partial rendering is not how the model works.
For applications that need to suspend the TUI (to launch an external editor, for example), the pattern is to call ratatui::restore() before suspending and ratatui::init() again after resuming. The Ratatui component template demonstrates this with start and stop key events that shell out to another TUI .
b. Layout and widgets: the rendering model
Ratatui’s layout engine splits the terminal area into rectangles using constraints. Constraint::Length(n) fixes a dimension, Constraint::Min(n) sets a minimum, Constraint::Percentage(n) allocates a proportion, and Constraint::Fill(n) distributes remaining space . Layouts nest: a vertical split can contain a horizontal split, which can contain further splits, producing a responsive structure that adapts to the terminal size.
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Paragraph};
fn draw(frame: &mut ratatui::Frame) {
let chunks = Layout::vertical([
Constraint::Length(3), // Header
Constraint::Min(0), // Main content
Constraint::Length(1), // Footer
])
.split(frame.area());
frame.render_widget(
Paragraph::new("Header").block(Block::bordered()),
chunks[0],
);
frame.render_widget(
Paragraph::new("Content").block(Block::bordered()),
chunks[1],
);
frame.render_widget(Paragraph::new("q: quit"), chunks[2]);
}
The widget set is broad: Paragraph for text, List for scrollable lists with selection state, Table for tabular data, Gauge and LineGauge for progress, Sparkline for inline charts, Chart for line and scatter plots, BarChart for bar charts, and Block for borders and titles . Stateful widgets like List and Table take a &mut state object in render_stateful_widget, which stores the selection index and scroll offset .
The Block widget is the container: it draws borders, a title, and padding around any other widget. Most widgets accept a Block via the .block() method, which is how a Paragraph or List gets a border and a title .
c. Text primitives: Span, Line, and Text
Ratatui’s text model has three layers. A Span is a styled segment of text—a string with a Style that specifies foreground color, background color, and modifiers like bold or italic. A Line is a collection of Spans that share an alignment. A Text is a collection of Lines .
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text};
let line = Line::from(vec![
Span::raw("Status: "),
Span::styled("OK", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw(" | "),
Span::styled("12ms", Style::default().fg(Color::Yellow)),
]);
let text = Text::from(vec![
Line::from("Line one"),
Line::from("Line two").centered(),
]);
The Stylize trait provides shorthand methods: "text".green(), "text".bold(), "text".italic(). The trait is implemented for &str and for Span, Line, and Text, so styling can be applied at any layer . For a Paragraph with multiple lines of different styles, the Text object is the container; the Paragraph widget renders each line and respects the alignment of each Line unless overridden.
Complete Example Session
// ============================================
// PART 1: DEPENDENCIES
// ============================================
// Cargo.toml
// ratatui = "0.30"
// crossterm = "0.29"
// ============================================
// PART 2: APPLICATION STATE
// ============================================
struct App {
counter: i32,
should_quit: bool,
}
impl App {
fn new() -> Self {
Self { counter: 0, should_quit: false }
}
fn on_key(&mut self, key: crossterm::event::KeyEvent) {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Up => self.counter += 1,
KeyCode::Down => self.counter -= 1,
_ => {}
}
}
}
// ============================================
// PART 3: UI RENDERING
// ============================================
use ratatui::layout::{Constraint, Layout};
use ratatui::widgets::{Block, Paragraph};
use ratatui::style::{Color, Style, Stylize};
fn ui(frame: &mut ratatui::Frame, app: &App) {
let chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(frame.area());
frame.render_widget(
Paragraph::new("Counter App")
.block(Block::bordered())
.centered(),
chunks[0],
);
let value = Paragraph::new(format!("Count: {}", app.counter))
.block(Block::bordered())
.style(Style::default().fg(Color::Cyan));
frame.render_widget(value, chunks[1]);
frame.render_widget(
Paragraph::new("↑: increment ↓: decrement q: quit").dim(),
chunks[2],
);
}
// ============================================
// PART 4: MAIN LOOP
// ============================================
use crossterm::event::{self, Event};
fn main() -> std::io::Result<()> {
let mut terminal = ratatui::init();
let mut app = App::new();
loop {
terminal.draw(|frame| ui(frame, &app))?;
if let Event::Key(key) = event::read()? {
app.on_key(key);
}
if app.should_quit {
break;
}
}
ratatui::restore();
Ok(())
}
// ============================================
// PART 5: STATEFUL LIST
// ============================================
use ratatui::widgets::{List, ListItem, ListState};
struct ListApp {
items: Vec<String>,
state: ListState,
}
fn list_ui(frame: &mut ratatui::Frame, app: &mut ListApp) {
let items: Vec<ListItem> = app.items
.iter()
.map(|s| ListItem::new(s.as_str()))
.collect();
let list = List::new(items)
.block(Block::bordered().title("Items"))
.highlight_style(Style::default().add_modifier(ratatui::style::Modifier::BOLD))
.highlight_symbol("> ");
frame.render_stateful_widget(list, frame.area(), &mut app.state);
}
// ============================================
// PART 6: NESTED LAYOUT
// ============================================
fn nested_layout(frame: &mut ratatui::Frame) {
let outer = Layout::horizontal([
Constraint::Percentage(30),
Constraint::Percentage(70),
])
.split(frame.area());
let inner = Layout::vertical([
Constraint::Length(3),
Constraint::Min(0),
])
.split(outer[1]);
frame.render_widget(Paragraph::new("Sidebar"), outer[0]);
frame.render_widget(Paragraph::new("Header"), inner[0]);
frame.render_widget(Paragraph::new("Body"), inner[1]);
}
// ============================================
// PART 7: ASYNC INTEGRATION
// ============================================
// Use tokio::select! to wait on both terminal events
// and background task messages.
//
// tokio::select! {
// Some(msg) = rx.recv() => { /* update state */ }
// Ok(evt) = event::read() => { /* handle key */ }
// }
// ============================================
// PART 8: SUSPEND AND RESUME
// ============================================
// ratatui::restore();
// std::process::Command::new("vim").arg("file").status()?;
// terminal = ratatui::init();
// terminal.clear()?;
// ============================================
// PART 9: PANIC SAFETY
// ============================================
// ratatui::init() installs a panic hook that
// restores the terminal. For custom hooks:
// std::panic::set_hook(Box::new(|info| {
// ratatui::restore();
// eprintln!("{info}");
// }));
// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// 1. ratatui::init() → alternate screen, raw mode.
// 2. Loop: terminal.draw(ui), event::read(), update.
// 3. ratatui::restore() on exit and on panic.
// 4. Layout splits area into rectangles.
// 5. Widgets render into those rectangles.
// 6. Stateful widgets take &mut state.
// 7. Async: replace event::read with select!.
Quick Reference
Application Skeleton
| Step | Function | Purpose |
|---|---|---|
| Initialize | ratatui::init() | Alternate screen, raw mode, panic hook |
| Draw | `terminal.draw( | frame |
| Restore | ratatui::restore() | Return terminal to original state |
Layout Constraints
| Constraint | Behavior |
|---|---|
Length(n) | Fixed size of n |
Min(n) | At least n, grows with space |
Percentage(n) | n percent of available space |
Fill(n) | Distributes remaining space proportionally |
Core Widgets
| Widget | Purpose | Stateful |
|---|---|---|
Paragraph | Text display, wrapping, alignment | No |
Block | Borders, title, padding | No |
List | Scrollable items, selection | Yes (ListState) |
Table | Tabular data | Yes (TableState) |
Gauge | Progress bar | No |
Sparkline | Inline data chart | No |
Chart | Line/scatter plots | No |
Text Primitives
| Type | Contains | Styled |
|---|---|---|
Span | A string | Yes (Style) |
Line | Vec<Span> | Yes, with alignment |
Text | Vec<Line> | Yes, with alignment |
Best Practices
✅ Do This:
// Use ratatui::init() for the panic hook. // ✅
let mut terminal = ratatui::init();
// Render the entire frame in the draw closure. // ✅
terminal.draw(|frame| ui(frame, &app))?;
// Use Constraint::Min for flexible content. // ✅
Constraint::Min(0) // Grows to fill available space.
// Use stateful widgets for lists and tables. // ✅
frame.render_stateful_widget(list, area, &mut state);
// Restore the terminal before exiting. // ✅
ratatui::restore();
❌ Don’t Do This:
// Forget to call ratatui::restore(). // ❌
// Terminal left in raw mode; user must restart.
// Render only part of the UI in the draw closure. // ❌
// Immediate mode expects the full frame.
// Block the UI thread with synchronous I/O. // ❌
// Use async or spawn a thread for long operations.
// Use a retained-mode mental model. // ❌
// Widgets are rebuilt every frame, not updated in place.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Terminal left in raw mode | restore() not called | Call on exit and on panic |
| UI does not update | Event loop not redrawing | Draw on every loop iteration |
| Layout overflows | Constraints sum exceeds area | Use Min(0) and Fill |
| List selection not visible | No highlight_style | Set style and symbol |
| Async events missed | Blocking event::read() | Use tokio::select! or a thread |
Real-World Examples
1. Minimal Init and Restore
let mut terminal = ratatui::init();
terminal.draw(|f| f.render_widget("Hi", f.area()))?;
ratatui::restore();
2. Vertical Layout
Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area);
3. Bordered Paragraph
Paragraph::new("Content").block(Block::bordered().title("Panel"))
4. Stateful List
frame.render_stateful_widget(list, area, &mut state);
5. Styled Span
Span::styled("OK", Style::default().fg(Color::Green))
6. Centered Text
Line::from("Title").centered()
7. Key Event Match
if let Event::Key(key) = event::read()? { match key.code { ... } }
8. Async Select
tokio::select! { Some(msg) = rx.recv() => {}, Ok(evt) = event::read() => {} }
9. Suspend for External Editor
ratatui::restore(); Command::new("vim").status()?; terminal = ratatui::init();
10. Custom Panic Hook
std::panic::set_hook(Box::new(|info| { ratatui::restore(); eprintln!("{info}"); }));
Visual
Immediate-Mode Rendering
┌──────────────────────────────────────────────┐
│ FRAME N │
│ │
│ ui() renders entire widget tree → Buffer │
│ │ │
│ ▼ │
│ Diff against Buffer N-1 │
│ │ │
│ ▼ │
│ Write changed cells to terminal │
│ │
│ FRAME N+1 │
│ Same cycle. Only diffs are written. │
│ │
│ Cost proportional to change, not UI size. │
│ │
└──────────────────────────────────────────────┘
Application Skeleton
┌──────────────────────────────────────────────┐
│ main() │
│ │ │
│ ▼ │
│ ratatui::init() │
│ alternate screen, raw mode, panic hook │
│ │ │
│ ▼ │
│ loop { │
│ terminal.draw(|f| ui(f, &app)) │
│ event::read() → app.on_key(key) │
│ if app.should_quit { break } │
│ } │
│ │ │
│ ▼ │
│ ratatui::restore() │
│ │
└──────────────────────────────────────────────┘
Layout Nesting
┌──────────────────────────────────────────────┐
│ Area │
│ ┌────────────┬───────────────────────────┐ │
│ │ │ Header (Length 3) │ │
│ │ Sidebar │───────────────────────────│ │
│ │ (30%) │ Body (Min 0) │ │
│ │ │ │ │
│ │ │ │ │
│ └────────────┴───────────────────────────┘ │
│ │
│ Horizontal split → [30%, 70%] │
│ Vertical split of right → [Length 3, Min 0] │
│ │
└──────────────────────────────────────────────┘
Text Primitives
┌──────────────────────────────────────────────┐
│ Text │
│ ├── Line 1 (alignment) │
│ │ ├── Span "Status: " (no style) │
│ │ └── Span "OK" (green, bold) │
│ │ │
│ └── Line 2 (centered) │
│ └── Span "Welcome" │
│ │
│ Span: styled string │
│ Line: spans + alignment │
│ Text: lines │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Rendering model | Immediate mode with buffer diff |
| Initialization | ratatui::init() |
| Restore | ratatui::restore() |
| Draw call | `terminal.draw( |
| Layout | Layout::vertical/horizontal + constraints |
| Core widgets | Paragraph, Block, List, Table, Gauge, Chart |
| Stateful widgets | List, Table with *State |
| Text primitives | Span → Line → Text |
| Event handling | Developer-owned loop, crossterm::event::read |
| Async integration | Replace event::read with tokio::select! |
Key takeaways:
- Ratatui is immediate-mode. The application renders the entire widget tree on every frame, and Ratatui diffs the buffer to update only the changed cells. The cost is proportional to the change, not the size of the UI .
- The application owns the event loop. Ratatui provides no event system. The
mainfunction initializes the terminal, enters a loop that draws and reads events, and restores the terminal on exit. This structure is what allows integration with async runtimes . ratatui::init()andratatui::restore()are the lifecycle bookends.initenters the alternate screen and raw mode and installs a panic hook.restorereturns the terminal to its original state. Forgettingrestoreleaves the terminal in an unusable state .- Layout is constraint-based and nestable.
Layout::verticalandLayout::horizontalsplit the area into rectangles usingLength,Min,Percentage, andFillconstraints. Nested splits produce responsive UIs that adapt to terminal size . - Stateful widgets take a
&mutstate object.ListandTableuseListStateandTableStateto track selection and scroll position. The state lives in the application, not in the widget . - Text is built from
Span,Line, andText. ASpanis a styled string, aLineis a collection of spans with alignment, and aTextis a collection of lines. TheStylizetrait provides shorthand styling methods . - Retained mode is the alternative, but immediate mode dominates. Cursive is the primary retained-mode library, but Ratatui’s adoption, ecosystem, and async integration have made it the standard choice for Rust TUIs .
Remember: A Ratatui application is a loop. The terminal is initialized, the loop draws a frame and reads an event, the state is updated, and the loop repeats until the user quits. The immediate-mode rendering model means the UI is rebuilt from state on every frame, and the buffer diff makes that cheap. The layout engine splits the terminal into rectangles, the widgets render into those rectangles, and the text primitives style the output. The event loop is yours, which is why Ratatui integrates with Tokio and why it does not provide an event system. The three functions that matter most are init, draw, and restore. Get the loop right, and the rest is widgets.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!