Skip to content
Select theme

Drawing your first frame with Ratatui

Step 2 of 8, and the project starts. Step 1 mapped the track and committed no code, so the starting state here is an empty directory. This step ends with a program that opens, draws one bordered box, redraws it on a tick, and gives the terminal back when q is pressed — the whole skeleton every later step hangs behaviour on. The end state is tagged tui-ch02.

The code lives in tui-ratatui/ at the root of the examples repository, and that directory is the cargo project itself:

Terminal window
cargo init --name rlog

Two crates, and the split between them matters. Ratatui draws: widgets, layout, cell buffers. It never touches a terminal. Crossterm talks to the terminal: raw mode, the alternate screen, the keyboard. Ratatui 0.30 reorganised itself into smaller crates — ratatui-core, ratatui-crossterm, ratatui-widgets — but the ratatui facade re-exports the whole surface, so it stays the one dependency to write down. This build resolves ratatui 0.30.2 and crossterm 0.29.0.

Cargo.toml
[package]
name = "rlog"
version = "0.1.0"
edition = "2024"
[dependencies]
crossterm = "0.29"
ratatui = "0.30"

Rendering is a pure function of state, so the state comes first. At this step the whole state is a frame counter; the later steps grow it into the log tail, the process table and the selection, and draw never stops being a function that borrows it.

The frame itself is a bordered block with a title, filling whatever area it is given, and one line of text inside it. draw takes a &mut Frame, ratatui’s per-draw canvas, already sized to the terminal, and renders widgets into it:

use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::widgets::{Block, Paragraph};
struct App {
frames: u64,
}
fn draw(frame: &mut ratatui::Frame, app: &App) {
let block = Block::bordered().title(" rlog ");
frame.render_widget(
Paragraph::new(format!("frame {} — press q to quit", app.frames)),
block.inner(frame.area()),
);
frame.render_widget(block, frame.area());
}
fn main() {
let app = App { frames: 1 };
let mut terminal = Terminal::new(TestBackend::new(46, 7)).unwrap();
terminal.draw(|frame| draw(frame, &app)).unwrap();
// Print the cells the backend was given, the way a terminal would show them.
let buffer = terminal.backend().buffer();
for y in 0..buffer.area.height {
let line: String = (0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect();
println!("{}", line.trim_end());
}
}

TestBackend is ratatui’s in-memory terminal: it keeps a grid of cells instead of writing escape sequences. That is how the frame gets checked without a terminal at all, and it holds the same draw the program further down calls. The output is the frame, cell for cell:

┌ rlog ──────────────────────────────────────┐
│frame 1 — press q to quit │
│ │
│ │
│ │
│ │
└────────────────────────────────────────────┘

The box fills whatever space it is given, so the width follows the terminal; the run at the end of this article shows the same draw at 80 columns.

terminal.draw is worth understanding once, because everything the later steps do happens inside its closure. It asks the backend how big the screen is, builds a Frame of that size, runs the closure against it, then compares the finished buffer with the one currently on screen and writes only the cells that differ. The closure never allocates the canvas and never talks to the terminal; it fills in the one it is handed. That diff step is also why the frame count climbing does not smear the screen: on each pass after the first, exactly one cell changes.

Ratatui owns a canvas, but the canvas only means something once the terminal cooperates. Two calls do that, and they are crossterm’s, not ratatui’s:

  • Raw mode turns off the line discipline: keys arrive one at a time instead of a line at a time, nothing is echoed, and Ctrl-C arrives as a key event rather than a signal.
  • The alternate screen switches the terminal to a second buffer. The program draws on that one; the shell’s scrollback is untouched, and it comes back the moment the program leaves.

The setup enters raw mode first, then the alternate screen; teardown reverses it. The reverse order on the way out is the one that matters: raw mode goes last, so the shell’s first prompt after the program exits is printed with echo and line editing already restored.

One more piece belongs here. A panic while the terminal is in raw mode leaves it wrecked — every line the shell prints afterwards lands wherever the cursor happens to sit, and the wreckage outlives the program. The hook in main restores the terminal before the panic message prints.

The loop draws a frame every pass and waits one tick for a key. event::poll takes a timeout and returns false when it expires, which is what gives the loop a clock of its own; the data refreshes of the later steps hang off the same tick. q, Esc and Ctrl-C break it.

The KeyEventKind::Press check earns its place: crossterm reports every key event with a kind — press, repeat or release — and only a press should count. Terminals that report releases would otherwise hand the loop two events per keypress.

This block is marked compile-only rather than runnable: a program that enters raw mode needs a terminal to enter it, and the sandbox that checks the article’s blocks has none. The run below is the real one, the same binary the tag builds.

Terminal window
cargo run

The capture below is the real screen, an 80-column terminal with q pressed after a couple of seconds — the count itself depends on how long the program ran before the key landed, so a reader’s own number will differ. The middle rows are empty in the actual capture too and are trimmed here:

┌ rlog ────────────────────────────────────────────────────────────────────────┐
│frame 11 — press q to quit │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘

Two things to watch in it. The frame count climbs on every pass, so the loop is genuinely redrawing; and between passes ratatui writes only the cells that changed, which is what keeps a redraw from flickering. Step 5 is where that claim gets tested against a live log file.

Step 3 reads the events this loop currently discards: keys, the mouse, and a resize that reflows the layout. The track overview keeps the full list current as articles land.