Terminal UIs with Ratatui
22 min read
Build full-screen, interactive terminal applications in Rust with ratatui — the equivalent of Node’s blessed, ink, or terminal-kit, but with an immediate-mode rendering model that will feel surprisingly close to React’s render function.
Quick Overview
Section titled “Quick Overview”A TUI (Terminal User Interface) is a full-screen, keyboard-driven app that runs inside your terminal: think htop, lazygit, vim, or k9s. In Rust, the dominant library is ratatui (the maintained successor to the unmaintained tui-rs). Its mental model is immediate mode: you do not build a persistent tree of widget objects and mutate them — instead, on every frame you call a single draw closure that describes the entire screen from your application state, and ratatui diffs that description against the previous frame and writes only the changed cells to the terminal. If you have used React, this is the same idea as a render() function: UI as a pure function of state. You pair ratatui with a backend (almost always crossterm) that handles raw mode, the alternate screen, and keyboard/mouse events.
Note: This page covers the ratatui rendering model, widgets, and the event loop. For colored line-oriented output (not full-screen), see Colored Output; for progress bars and spinners, see Progress Bars.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”In the Node ecosystem, the closest analog is Ink — React for the terminal. You declare components, hold state with hooks, and Ink re-renders when state changes. Here is a small task list with keyboard navigation:
// app.tsx — run with: npx tsx app.tsx// deps: ink, react, @types/reactimport React, { useState } from "react";import { render, Box, Text, useInput, useApp } from "ink";
const TASKS = ["Write docs", "Review PR", "Fix the build", "Ship it"];
function App() { const [selected, setSelected] = useState(0); const { exit } = useApp();
useInput((input, key) => { if (input === "q" || key.escape) exit(); if (key.downArrow || input === "j") setSelected((i) => (i + 1) % TASKS.length); if (key.upArrow || input === "k") setSelected((i) => (i === 0 ? TASKS.length - 1 : i - 1)); });
return ( <Box flexDirection="column" borderStyle="round"> {TASKS.map((task, i) => ( <Text key={task} inverse={i === selected}> {i === selected ? "> " : " "} {task} </Text> ))} </Box> );}
render(<App />);Ink hides the event loop entirely: useState triggers re-renders, useInput wires up keyboard handling, and React’s reconciler decides what to repaint. The cost is a heavy dependency tree (React, Yoga layout engine, the reconciler) and the usual JavaScript runtime overhead.
Rust Equivalent
Section titled “Rust Equivalent”Create a project and add the two crates. cargo new selects the newest stable edition automatically — the current stable toolchain is Rust 1.96.0 on the 2024 edition.
cargo new task_tuicd task_tuicargo add ratatuicargo add crosstermThat writes the current versions into Cargo.toml:
[dependencies]crossterm = "0.29.0"ratatui = "0.30.0"Note: ratatui re-exports its default backend, so
cargo add ratatuialready pulls in crossterm transitively. We addcrosstermexplicitly because we use its event types (KeyCode,Event) directly.
Here is the same task list in src/main.rs:
use std::io;use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph},};
struct App { tasks: Vec<String>, state: ListState, should_quit: bool,}
impl App { fn new() -> Self { let mut state = ListState::default(); state.select(Some(0)); Self { tasks: vec![ "Write docs".into(), "Review PR".into(), "Fix the build".into(), "Ship it".into(), ], state, should_quit: false, } }
fn next(&mut self) { let i = self.state.selected().map_or(0, |i| (i + 1) % self.tasks.len()); self.state.select(Some(i)); }
fn previous(&mut self) { let i = self.state.selected().map_or(0, |i| { if i == 0 { self.tasks.len() - 1 } else { i - 1 } }); self.state.select(Some(i)); }}
fn main() -> io::Result<()> { let mut terminal = ratatui::init(); let app = App::new(); let result = run(&mut terminal, app); ratatui::restore(); result}
fn run(terminal: &mut DefaultTerminal, mut app: App) -> io::Result<()> { while !app.should_quit { terminal.draw(|frame| render(frame, &mut app))?; handle_events(&mut app)?; } Ok(())}
fn handle_events(app: &mut App) -> io::Result<()> { if event::poll(Duration::from_millis(100))? && let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { match key.code { KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Down | KeyCode::Char('j') => app.next(), KeyCode::Up | KeyCode::Char('k') => app.previous(), _ => {} } } Ok(())}
fn render(frame: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(3)]) .split(frame.area());
let items: Vec<ListItem> = app .tasks .iter() .map(|t| ListItem::new(Line::from(Span::raw(t)))) .collect();
let list = List::new(items) .block(Block::default().title(" Tasks ").borders(Borders::ALL)) .highlight_style( Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ) .highlight_symbol("> ");
frame.render_stateful_widget(list, chunks[0], &mut app.state);
let help = Paragraph::new("j/k: move q: quit") .block(Block::default().borders(Borders::ALL).title(" Help ")) .style(Style::default().add_modifier(Modifier::DIM));
frame.render_widget(help, chunks[1]);}Run it with cargo run. You get a full-screen task list with a highlighted selection, arrow/j/k navigation, and q to quit. On exit, ratatui::restore() puts the terminal back exactly as it was.
Tip: A TUI takes over your terminal, so its visual output cannot be pasted as text here the way a
println!can. The code above is compile-verified; later in Best Practices we render a widget to a headlessTestBackendand show the real cell buffer it produces.
Detailed Explanation
Section titled “Detailed Explanation”ratatui::init() and ratatui::restore()
Section titled “ratatui::init() and ratatui::restore()”let mut terminal = ratatui::init();// ... run the app ...ratatui::restore();These two convenience functions (added in ratatui 0.28+) bundle the boilerplate every TUI needs:
init()enables the terminal’s raw mode (so keystrokes arrive immediately, without line-buffering or echo), switches to the alternate screen (a separate full-screen buffer, so your shell scrollback is untouched), installs a panic hook so a crash still restores the terminal, and returns aDefaultTerminal(an alias forTerminal<CrosstermBackend<Stdout>>).restore()reverses all of that: leaves the alternate screen and disables raw mode.
In the pre-0.28 world you wrote enable_raw_mode(), execute!(stdout, EnterAlternateScreen), and the reverse by hand. You may still see that in older tutorials; init/restore is the current idiom.
Warning: If your program returns or panics between
init()andrestore()without restoring, the terminal is left in raw mode and your shell becomes unusable (no echo, no newline on Enter). The panic hook installed byinit()handles panics; we cover the early-return case in Common Pitfalls.
The draw loop is immediate mode
Section titled “The draw loop is immediate mode”while !app.should_quit { terminal.draw(|frame| render(frame, &mut app))?; handle_events(&mut app)?;}This is the heart of every ratatui app, and the part that surprises developers coming from object-oriented or retained-mode UI toolkits (Qt, GTK, the DOM). You do not create a List object once and call list.setSelected(2) later. Instead, every iteration:
terminal.draw(closure)calls your closure with a freshFrame. Inside, you construct brand-new widget values from the currentappstate and render them. These widgets are cheap, short-lived value types — they are dropped at the end of the frame.- ratatui compares the frame’s cell buffer against the previous frame’s buffer (a double buffer) and writes only the differing cells to the terminal. This diffing is why a TUI does not flicker even though you “redraw everything.”
handle_eventsreads input and mutatesapp. The next loop iteration renders the new state.
This is exactly React’s model: UI = f(state). Your render function is pure with respect to drawing; all the mutation lives in event handling. The difference from Ink/React is that there is no reconciler and no hidden re-render trigger — you own the loop and decide when to redraw.
Widgets, Block, and layout
Section titled “Widgets, Block, and layout”let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(3)]) .split(frame.area());frame.area() is the full terminal rectangle (a Rect of { x, y, width, height } in character cells). Layout is ratatui’s flexbox-equivalent: it splits a Rect into sub-rectangles according to Constraints. Here, Length(3) reserves exactly 3 rows for the help box and Min(1) gives the rest to the list. Other constraints include Percentage(50), Ratio(1, 3), Fill(1) (proportional, like flex-grow), and Max(n).
A Block is the chrome around a widget: borders and a title. List, Paragraph, Gauge, etc. each take a .block(...) to draw inside.
Stateful vs. stateless widgets
Section titled “Stateful vs. stateless widgets”frame.render_stateful_widget(list, chunks[0], &mut app.state);Most widgets are stateless: render_widget(widget, area) draws them and forgets them. But some widgets need state that survives across frames — a List needs to remember which item is selected and how far it is scrolled, a Table and Scrollbar likewise. That state lives in your App (here ListState), and you hand it in via render_stateful_widget(widget, area, &mut state). The widget reads and updates the state during rendering (for example, adjusting the scroll offset so the selected item stays visible). This split keeps the widget itself a throwaway value while persisting only the minimal data that must outlive a frame.
Event handling with poll and read
Section titled “Event handling with poll and read”if event::poll(Duration::from_millis(100))? && let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press{ match key.code { /* ... */ }}event::poll(timeout) returns true if an event is waiting, blocking at most timeout. If it returns true, event::read() retrieves the event without blocking. The 100 ms timeout means: redraw at least ten times a second even if no key is pressed (useful for clocks, spinners, or live data). The chained if ... && let ... && ... is a let-chain, stable on the 2024 edition — it replaces the nested if let you would have written on older editions.
Note: Checking
key.kind == KeyEventKind::Pressmatters on Windows, where crossterm reports both key-press and key-release events. Without this filter, every keystroke fires your handler twice. This is one of the most common cross-platform TUI bugs.
Key Differences
Section titled “Key Differences”| Concept | Node (Ink / blessed) | Rust (ratatui) |
|---|---|---|
| Rendering model | Retained component tree + React reconciler | Immediate mode: rebuild every widget each frame |
| What triggers a redraw | setState / hooks (automatic) | You call terminal.draw(...) in your loop |
| Widget lifetime | Persistent objects you mutate | Throwaway values constructed per frame |
| State storage | Component-local (useState) | Your own structs; widget “state” (ListState) is explicit |
| Event loop | Hidden by the framework | You own the loop and the poll/read calls |
| Layout | Yoga (CSS flexbox) in Ink | Layout + Constraint (flexbox-like, integer cells) |
| Async / concurrency | Single-threaded event loop | Free to spawn threads or a tokio task; send updates via a channel |
| Dependencies | React + reconciler + Yoga (large) | ratatui + crossterm (small, no runtime) |
| Diffing | Virtual DOM diff | Cell-buffer diff (double buffer) |
The deepest difference is ownership of the loop. In Ink you never see the loop; in ratatui the loop is your main. This is more code, but it means there is no hidden machinery: you can interleave input, timers, and background-thread messages explicitly, and you can reason precisely about exactly when a redraw happens.
Note: “Immediate mode” describes the API you use, not how the screen is updated. The terminal is still updated incrementally via buffer diffing — ratatui does not repaint every cell every frame. You describe the whole UI each frame; ratatui applies only the deltas.
Common Pitfalls
Section titled “Common Pitfalls”Returning early without restoring the terminal
Section titled “Returning early without restoring the terminal”If a ? propagates an error or you return between init() and restore(), you skip the restore and leave the terminal in raw mode. The fix is the structure used above: call init(), run a fallible run(...) that returns a Result, always call restore(), and only then propagate the result.
fn main() -> std::io::Result<()> { let mut terminal = ratatui::init(); let result = run(&mut terminal); // may return Err ratatui::restore(); // runs no matter what result // propagate after restoring}For 0.30, there is also ratatui::run(|terminal| { ... }), a helper that initializes, runs your closure, and restores even on early return — verified to exist in this version:
fn main() -> std::io::Result<()> { ratatui::run(|terminal| { // init + restore are handled for you, including on early return loop { terminal.draw(|frame| { /* ... */ })?; break; } Ok(()) })}Using the deprecated frame.size()
Section titled “Using the deprecated frame.size()”Older code calls frame.size(); it was renamed to frame.area() and size() is now deprecated. Calling it still compiles but warns:
fn render(frame: &mut ratatui::Frame) { // deprecated: triggers a real compiler warning frame.render_widget(ratatui::widgets::Paragraph::new("hi"), frame.size());}The actual warning from cargo build:
warning: use of deprecated method `ratatui::Frame::<'_>::size`: use `area()` instead --> src/main.rs:3:71 |3 | frame.render_widget(ratatui::widgets::Paragraph::new("hi"), frame.size()); | ^^^^ | = note: `#[warn(deprecated)]` on by defaultUse frame.area() instead.
Passing state to render_widget instead of render_stateful_widget
Section titled “Passing state to render_widget instead of render_stateful_widget”A List with selection needs render_stateful_widget. If you reach for the wrong method and pass the state as a third argument, you get an arity error — this snippet does not compile:
use ratatui::{Frame, widgets::{List, ListState}};
fn render(frame: &mut Frame, state: &mut ListState) { let list = List::new(vec!["a", "b"]); // does not compile (error[E0061]: this method takes 2 arguments but 3 were supplied) frame.render_widget(list, frame.area(), state);}The real error from cargo build:
error[E0061]: this method takes 2 arguments but 3 arguments were supplied --> src/main.rs:6:11 | 6 | frame.render_widget(list, frame.area(), state); | ^^^^^^^^^^^^^ ----- unexpected argument #3 of type `&mut ListState` |note: method defined here --> .../ratatui-core-0.1.0/src/terminal/frame.rs:93:12 |93 | pub fn render_widget<W: Widget>(&mut self, widget: W, area: Rect) { | ^^^^^^^^^^^^^help: remove the extra argument | 6 - frame.render_widget(list, frame.area(), state); 6 + frame.render_widget(list, frame.area()); |The fix is to call frame.render_stateful_widget(list, frame.area(), state).
Blocking the loop with event::read() when you need to tick
Section titled “Blocking the loop with event::read() when you need to tick”event::read() blocks until input arrives. If you call it directly in a loop that also needs to animate (a spinner, a clock, live metrics), the screen freezes between keystrokes. Always gate it behind event::poll(timeout)? so the loop wakes up on the timeout to redraw, even when the user is idle.
Forgetting the KeyEventKind::Press filter on Windows
Section titled “Forgetting the KeyEventKind::Press filter on Windows”As noted above, crossterm delivers both press and release key events on Windows. Code tested only on macOS/Linux will appear to “double-press” on Windows. Filter on key.kind == KeyEventKind::Press.
Best Practices
Section titled “Best Practices”-
Separate state, update, and view. Keep an
Appstruct (state), free functions or methods that mutate it in response to events (update), and a purerender/uifunction that only reads state and draws (view). This is the Elm/React architecture and it makes a TUI testable. -
Make rendering a pure function so you can test it headlessly. ratatui ships a
TestBackendthat renders into an in-memory cell grid with no real terminal — perfect for unit tests and CI. Here is a self-contained program that renders a greeting into a 20×3 buffer and prints the cells it produced:use ratatui::{Terminal,backend::TestBackend,widgets::{Block, Paragraph},};fn draw_greeting(name: &str) -> Terminal<TestBackend> {let backend = TestBackend::new(20, 3);let mut terminal = Terminal::new(backend).unwrap();terminal.draw(|frame| {let p = Paragraph::new(format!("Hi {name}")).block(Block::bordered());frame.render_widget(p, frame.area());}).unwrap();terminal}fn main() {let terminal = draw_greeting("Ada");let buffer = terminal.backend().buffer();let row0: String = (0..20).map(|x| buffer[(x, 0)].symbol()).collect();let row1: String = (0..20).map(|x| buffer[(x, 1)].symbol()).collect();println!("{row0}");println!("{row1}");}Running it with
cargo runprints the actual rendered cells:┌──────────────────┐│Hi Ada │You can assert against an expected buffer with
Buffer::with_lines, which keeps tests readable. See Section 13: Testing for the broader testing toolkit. -
Prefer the
Stylizeshorthands for terse styling. Instead ofStyle::default().fg(Color::Blue).add_modifier(Modifier::BOLD), importratatui::style::Stylizeand write.blue().bold()directly on strings and widgets:"Counter".bold(),Block::bordered().blue(),Paragraph::new(text).centered(). -
Use a tick rate to decouple input latency from animation. Compute the poll timeout from
tick_rate - last_tick.elapsed()so you redraw on a steady cadence while still responding to keys instantly. The Real-World Example does exactly this. -
For long-running work, do it on a thread and send updates over a channel. ratatui’s loop must stay responsive. Spawn a
std::thread(or a tokio task — see Section 11: Async) for I/O or computation, send progress via anmpscchannel, andtry_recvit in your loop to update state. For pure progress reporting outside a TUI, indicatif is simpler. -
Respect terminal capabilities. Not every terminal supports truecolor or every Unicode box-drawing glyph. ratatui degrades gracefully, but for color-sensitive output also honor the
NO_COLORconvention — see Colored Output.
Real-World Example
Section titled “Real-World Example”A compact service monitor (a tiny k9s/htop): a tab bar, a stateful list of services on the left, a load gauge for the selected service on the right, a footer with key hints, and a tick-based update that simulates live metrics. This is fully compile-verified and clippy-clean.
use std::io;use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Layout}, style::{Color, Modifier, Style, Stylize}, text::Line, widgets::{Block, Gauge, List, ListItem, ListState, Tabs},};
/// A single monitored service and its simulated load.struct Service { name: String, load: u16, // 0..=100}
struct App { services: Vec<Service>, selected: ListState, tab: usize, last_tick: Instant, should_quit: bool,}
impl App { fn new() -> Self { let mut selected = ListState::default(); selected.select(Some(0)); Self { services: vec![ Service { name: "api-gateway".into(), load: 22 }, Service { name: "auth-service".into(), load: 64 }, Service { name: "worker-pool".into(), load: 81 }, Service { name: "postgres".into(), load: 35 }, ], selected, tab: 0, last_tick: Instant::now(), should_quit: false, } }
/// Advance simulated state. In a real app this is where you poll metrics. fn on_tick(&mut self) { for (i, svc) in self.services.iter_mut().enumerate() { let delta = ((self.last_tick.elapsed().as_millis() / 50) as u16 + i as u16) % 7; svc.load = (svc.load + delta) % 101; } }
fn select_next(&mut self) { let i = self.selected.selected().map_or(0, |i| (i + 1) % self.services.len()); self.selected.select(Some(i)); }}
const TICK_RATE: Duration = Duration::from_millis(250);
fn main() -> io::Result<()> { let mut terminal = ratatui::init(); let result = run(&mut terminal, App::new()); ratatui::restore(); result}
fn run(terminal: &mut DefaultTerminal, mut app: App) -> io::Result<()> { while !app.should_quit { terminal.draw(|frame| ui(frame, &mut app))?;
// Wake up at least every tick so the gauges animate even when idle. let timeout = TICK_RATE.saturating_sub(app.last_tick.elapsed()); if event::poll(timeout)? && let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { match key.code { KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Tab => app.tab = (app.tab + 1) % 2, KeyCode::Down | KeyCode::Char('j') => app.select_next(), _ => {} } } if app.last_tick.elapsed() >= TICK_RATE { app.on_tick(); app.last_tick = Instant::now(); } } Ok(())}
fn ui(frame: &mut Frame, app: &mut App) { let [header, body, footer] = Layout::vertical([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ]) .areas(frame.area());
// Header: tab bar. let tabs = Tabs::new(vec!["Services", "Logs"]) .block(Block::bordered().title(" monitor ")) .select(app.tab) .highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); frame.render_widget(tabs, header);
// Body: split into a list (left) and a gauge (right). let [left, right] = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(body);
let items: Vec<ListItem> = app .services .iter() .map(|s| ListItem::new(Line::from(format!("{:<14} {:>3}%", s.name, s.load)))) .collect(); let list = List::new(items) .block(Block::bordered().title(" Services ")) .highlight_style(Style::default().bg(Color::Blue).fg(Color::White)) .highlight_symbol("> "); frame.render_stateful_widget(list, left, &mut app.selected);
let idx = app.selected.selected().unwrap_or(0); let svc = &app.services[idx]; let color = if svc.load > 75 { Color::Red } else { Color::Green }; let gauge = Gauge::default() .block(Block::bordered().title(format!(" {} load ", svc.name))) .gauge_style(color) .percent(svc.load); frame.render_widget(gauge, right);
let footer_line = Line::from(" Tab: switch j/↓: next q: quit ").dim(); frame.render_widget(footer_line, footer);}Things worth highlighting:
- The tick rate decouples redraw cadence from input.
event::poll(timeout)blocks for at most the time remaining until the next tick, so keys feel instant while the gauges animate four times a second. Layout::vertical([...]).areas(rect)returns a fixed-size array you destructure directly into[header, body, footer]— cleaner than indexingchunks[0],chunks[1].- The gauge color is recomputed from state every frame (red above 75%, green below). That is immediate mode in one line: the view is a pure function of
svc.load. - Only the
ListStatepersists across frames; every widget value is rebuilt eachuicall and dropped at frame end.
Further Reading
Section titled “Further Reading”- Ratatui official website and tutorials — the authoritative guide, including the “Counter App” and “JSON Editor” walkthroughs.
- Ratatui on docs.rs — full widget and API reference for the current release.
- Crossterm on docs.rs — the backend’s event, style, and terminal APIs.
- The Elm Architecture — the model/update/view pattern that ratatui apps adopt for state management.
Related sections in this guide:
- clap Basics and clap Derive — parse the arguments and flags that launch your TUI.
- Subcommands — wire a TUI behind a
monitorsubcommand of a larger CLI. - Progress Bars — for line-oriented progress (indicatif), the simpler alternative when you do not need a full-screen UI.
- Colored Output — ANSI color and
NO_COLORfor non-fullscreen output. - Distribution — ship your TUI as a single binary with
cargo installor prebuilt releases. - Section 11: Async — drive a TUI from a tokio event loop and background tasks.
- Section 13: Testing — the broader testing toolkit behind the
TestBackendpattern above. - Section 01: Getting Started and Section 02: Basics — project setup and the syntax used throughout.
- Section 19: WebAssembly — ratatui can even target the browser via a wasm backend, reusing the same render code.
Exercises
Section titled “Exercises”Exercise 1: A counter app
Section titled “Exercise 1: A counter app”Difficulty: Beginner
Objective: Internalize the draw loop by building the smallest possible interactive TUI.
Instructions: Create a TUI that displays a single integer counter centered on screen inside a bordered block. Pressing + or Up increments it, - or Down decrements it, and q quits. Use ratatui::init()/restore() and the Stylize shorthands (.bold(), .centered()).
Solution
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};use ratatui::{ DefaultTerminal, Frame, style::Stylize, widgets::{Block, Paragraph},};
fn main() -> io::Result<()> { let mut terminal = ratatui::init(); let result = run(&mut terminal); ratatui::restore(); result}
fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { let mut counter: i64 = 0; loop { terminal.draw(|frame: &mut Frame| { let text = format!("Counter: {counter}\n\n(+/-: change, q: quit)"); let widget = Paragraph::new(text) .block(Block::bordered().title(" Counter ".bold())) .centered() .blue(); frame.render_widget(widget, frame.area()); })?;
if let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { match key.code { KeyCode::Char('q') => return Ok(()), KeyCode::Char('+') | KeyCode::Up => counter += 1, KeyCode::Char('-') | KeyCode::Down => counter -= 1, _ => {} } } }}Because there is no animation, this version blocks on event::read() directly — no poll needed. It only redraws when a key is pressed.
Exercise 2: A chat-style input box
Section titled “Exercise 2: A chat-style input box”Difficulty: Intermediate
Objective: Manage editable text state and a two-pane layout.
Instructions: Build a TUI with a vertical layout: a scrolling List of submitted messages on top, and a single-line input box (3 rows, bordered) at the bottom. Typing appends characters to the input; Backspace removes the last character; Enter pushes the current input into the message list and clears it; Esc quits. Empty input on Enter should do nothing.
Solution
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Layout}, style::Stylize, widgets::{Block, List, ListItem, Paragraph},};
struct App { input: String, messages: Vec<String>, should_quit: bool,}
fn main() -> io::Result<()> { let mut terminal = ratatui::init(); let app = App { input: String::new(), messages: Vec::new(), should_quit: false }; let result = run(&mut terminal, app); ratatui::restore(); result}
fn run(terminal: &mut DefaultTerminal, mut app: App) -> io::Result<()> { while !app.should_quit { terminal.draw(|frame| ui(frame, &app))?; if let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { match key.code { KeyCode::Esc => app.should_quit = true, KeyCode::Enter => { if !app.input.is_empty() { app.messages.push(std::mem::take(&mut app.input)); } } KeyCode::Backspace => { app.input.pop(); } KeyCode::Char(c) => app.input.push(c), _ => {} } } } Ok(())}
fn ui(frame: &mut Frame, app: &App) { let [list_area, input_area] = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).areas(frame.area());
let items: Vec<ListItem> = app.messages.iter().map(|m| ListItem::new(m.as_str())).collect(); let list = List::new(items).block(Block::bordered().title(" Messages ")); frame.render_widget(list, list_area);
let input = Paragraph::new(app.input.as_str()) .block(Block::bordered().title(" Type, Enter to send, Esc to quit ")) .yellow(); frame.render_widget(input, input_area);}Note std::mem::take(&mut app.input) moves the current String out (leaving an empty one) and pushes it without cloning — idiomatic and allocation-free.
Exercise 3: Test a render function headlessly
Section titled “Exercise 3: Test a render function headlessly”Difficulty: Advanced
Objective: Prove your view is a pure function of state by testing it with TestBackend — no real terminal involved.
Instructions: Write a function render_status(frame: &mut Frame, connected: bool) that draws "ONLINE" when connected is true and "OFFLINE" otherwise. Then write two #[test]s that render it into a 1-row TestBackend and assert the resulting buffer equals the expected text using Buffer::with_lines. Confirm cargo test passes.
Solution
use ratatui::{Frame, widgets::Paragraph};
fn render_status(frame: &mut Frame, connected: bool) { let text = if connected { "ONLINE" } else { "OFFLINE" }; frame.render_widget(Paragraph::new(text), frame.area());}
fn main() { println!("run `cargo test` for the headless render tests");}
#[cfg(test)]mod tests { use super::*; use ratatui::{Terminal, backend::TestBackend, buffer::Buffer};
fn render_to_buffer(connected: bool, w: u16) -> Buffer { let mut terminal = Terminal::new(TestBackend::new(w, 1)).unwrap(); terminal .draw(|frame| render_status(frame, connected)) .unwrap(); terminal.backend().buffer().clone() }
#[test] fn shows_online_when_connected() { let buffer = render_to_buffer(true, 7); assert_eq!(buffer, Buffer::with_lines(["ONLINE "])); }
#[test] fn shows_offline_when_disconnected() { let buffer = render_to_buffer(false, 7); assert_eq!(buffer, Buffer::with_lines(["OFFLINE"])); }}Running cargo test produces the real output:
running 2 teststest tests::shows_offline_when_disconnected ... oktest tests::shows_online_when_connected ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe "ONLINE " literal has a trailing space because the buffer is 7 cells wide and "ONLINE" is 6 characters; the unused cell renders as a space. This is exactly the kind of off-by-one a headless test catches before it ships.