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 project
Section titled “The project”The code lives in tui-ratatui/ at the root of
the examples repository, and that directory is the cargo
project itself:
cargo init --name rlogTwo 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.
[package]name = "rlog"version = "0.1.0"edition = "2024"
[dependencies]crossterm = "0.29"ratatui = "0.30"State, then frame
Section titled “State, then frame”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.
The terminal
Section titled “The terminal”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-Carrives 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
Section titled “The loop”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.
//! rlog — a terminal UI that follows a log file and watches the process table.//!//! Step 2 of the track: the project skeleton, raw mode, the alternate screen,//! and one bordered box drawn by ratatui. The later steps fill the frame in.
use std::io;use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};use crossterm::execute;use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,};use ratatui::prelude::*;use ratatui::widgets::{Block, Paragraph};
/// How long the loop waits for a key before redrawing anyway. One tick of the/// program's clock; the refresh schedules of later steps are measured against/// it.const TICK: Duration = Duration::from_millis(250);
/// The whole state of the program at this step: how many frames it has drawn./// Every later step grows this struct — the log tail, the process table, the/// selection — and `draw` stays a pure function of it.struct App { frames: u64,}
fn main() -> io::Result<()> { // A panic while the terminal is in raw mode leaves it unusable: every line // the shell prints afterwards lands wherever the cursor happens to sit. // Restore the terminal first, then let the default hook print the panic. let default_panic_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { let _ = leave_full_screen(); default_panic_hook(info); }));
enable_raw_mode()?; execute!(io::stdout(), EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; let mut app = App { frames: 0 }; let result = run(&mut terminal, &mut app);
leave_full_screen()?; result}
/// Undo what `main` set up: leave the alternate screen, then raw mode. The/// order matters — raw mode is what makes the escape sequences arrive intact.fn leave_full_screen() -> io::Result<()> { execute!(io::stdout(), LeaveAlternateScreen)?; disable_raw_mode()}
/// The render loop. Every pass draws a frame, then waits one tick for a key./// `q`, `Esc` and `Ctrl-C` end the loop and give the terminal back.fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> { loop { app.frames += 1; terminal.draw(|frame| draw(frame, app))?;
if event::poll(TICK)? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press && is_quit(key) { break; } } } } Ok(())}
/// The whole frame for this step: one bordered box filling the screen, with/// the frame count inside it. The count is what makes the loop's redraws/// visible — ratatui writes only the cells that changed.fn draw(frame: &mut 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());}
/// The keys that end the program. `Ctrl-C` arrives as a key event in raw mode,/// not as a signal, so it has to be handled here or not at all.fn is_quit(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))}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.
Running it
Section titled “Running it”cargo runThe 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.