Hedronite · Frontend Dev · Rust · Thu 2026-10-15

Ratatui 0.30 from zero a full-screen terminal status panel

Take the terminal over, redraw it from state, send only what changed, and hand it back as you found it.

Lesson Class: Dev (Rust terminal UI · Ratatui)
Tested with: ratatui 0.30.2 · crossterm 0.29.0 · cargo 1.99.0 · macOS
Lab: crate/ · cargo test (8 tests) · pseudo-terminal capture
Source: Ratatui docs and docs.rs · The Linux Command Line (Shotts), ch. 3 · Running Linux, ch. 4
Take Over, Give Back
ratatui::run enters raw mode and the alternate screen, then restores both on every exit.
Redraw Everything, Send the Difference
Each frame renders the whole UI into a Buffer; the flush writes only changed cells.
Areas, Then Widgets
Layout splits a Rect by constraints; widgets write cells, and later ones overwrite earlier ones.
Draw it all; send what changed.

The problem

Suppose you run a job queue and want to watch it from a terminal. You want a small panel with the queue name, a count of acknowledged jobs, and a hint line at the bottom. The first thing most people try is println! on every update. That produces a stream of lines, and each new status pushes the previous one up and off the top, so you read a scrolling history instead of a panel.

The shell prompt is also a problem. Your program and the shell share the same lines, so a prompt that reappears after your program exits lands wherever the cursor happened to stop. Any other output printed in the meantime lands on top of your status text.

The harder problem is input. A terminal emulator is the program that draws the text window you type into, and the operating system's terminal driver sits between it and your program. By default that driver runs in what Unix calls canonical (or cooked) mode: it collects your typed characters, lets you fix mistakes with backspace, echoes them back, and hands the program a finished line only after you press Enter. The stty -a command lists these settings, and canonical mode appears there as icanon. Your program therefore cannot react to a single Right-arrow press or a q the moment it happens.

A text user interface (TUI) is a program that owns the whole terminal window, draws it as a grid of character cells, and reacts to each key as it arrives. Ratatui is a Rust library for building one. The example below builds that status panel from an empty crate, runs it in a real terminal, and then tests it without any terminal at all.

The idea, in plain words

Take over the whole terminal for a while. On every pass through your main loop, redraw the complete screen from your program's state into an in-memory grid, and let the library compare that grid with the previous one and send only the cells that changed. When you finish, give the terminal back exactly as you found it, with your program's output gone and the earlier shell content showing again.

Everything below is one of those four moves: take over, redraw from state, send the difference, give back.

Concept A: the terminal lifecycle

Definitions and the why

Two terminal features make a takeover possible. Raw mode is a mode where the terminal does not process input or output for you. Echoing typed characters, line buffering, and special handling such as CTRL-C are all disabled, so your program receives each key press as it is typed instead of waiting for Enter. Without raw mode you are back to the line-at-a-time problem, because the operating system keeps holding input until a newline arrives.

The alternate screen is a second screen buffer that many terminals provide, separate from the main screen. While it is active the terminal shows it and hides the main screen's content. When your program leaves it, the alternate screen's content is cleared and what you had before launching reappears. Without it, your panel would paint over whatever the main screen was showing and would still be sitting there, half-overwritten by the next prompt, after the program ended. Not every terminal emulator supports the alternate screen.

Both features are switched on and off by escape sequences, short byte strings that begin with the escape character (written \e below) and that the terminal interprets as commands instead of printing as text. Ratatui draws the interface, and Crossterm is the Rust library that talks to the terminal: it emits those escape sequences and reads keyboard events. Ratatui reaches Crossterm through a backend, the layer that turns "put this text in this cell" into real terminal output. Crossterm is the default backend.

The project declares both libraries, pinned to the exact versions tested here (macOS, rustc and cargo 1.99.0, edition 2024). Ratatui 0.30.2 pulls in ratatui-core 0.1.2, ratatui-widgets 0.3.2 and ratatui-crossterm 0.1.2, and requires Rust 1.88 or newer.

[package]
name = "status_tui"
version = "0.1.0"
edition = "2024"

[dependencies]
ratatui = "=0.30.2"
crossterm = "=0.29.0"

The = signs pin exact versions. Ratatui's documentation recommends adding the backend as a direct dependency, so that you can write crossterm::event yourself. It also warns that mismatched Crossterm versions cause compilation errors, missed events, and terminal-restoration problems. Running cargo tree -i crossterm here printed a single crossterm v0.29.0, used by ratatui-crossterm v0.1.2 and in turn by ratatui v0.30.2. That single line confirms your own Crossterm and Ratatui's are the same copy.

Worked example: src/main.rs

use std::io;

use crossterm::event;
use status_tui::App;

fn main() -> io::Result<()> {
    if std::env::args().any(|a| a == "--no-restore") {
        // Deliberately broken: set the terminal up, draw once, never restore it.
        let mut terminal = ratatui::init();
        let app = App::default();
        terminal.draw(|frame| frame.render_widget(&app, frame.area()))?;
        return Ok(());
    }

    ratatui::run(|terminal| {
        let mut app = App::default();
        while !app.exit {
            terminal.draw(|frame| frame.render_widget(&app, frame.area()))?;
            app.handle_event(&event::read()?);
        }
        Ok(())
    })
}

The second half of main is the real program. ratatui::run calls ratatui::init, which enables raw mode, enters the alternate screen, and installs a panic hook: a function Rust calls when the program panics, here one that restores the terminal before the panic message prints. It then runs your closure with a mutable reference to the terminal, and when the closure returns it calls ratatui::restore, which disables raw mode and leaves the alternate screen. The actual source of run in 0.30.2 is three lines: let mut terminal = init(); let result = f(&mut terminal); restore(); result. Because the result is kept until after restore(), an Err from your loop is printed to a normal terminal where you can read it; the second mistake below shows exactly that happening.

The loop follows a fixed pattern. It draws one frame, then blocks in event::read() until something happens, hands that event to the app, and checks app.exit again. event::read waits until an event arrives. If your program needs to do other work while waiting, you would call event::poll with a timeout first, but this panel has nothing else to do.

The --no-restore branch is the deliberately broken version. It calls init, draws once, and returns without ever calling restore. Ratatui added init and restore in 0.28.1 and run in 0.30.0, so older tutorials show manual setup. Manual setup is still useful for custom backends or output streams.

To see these switches happen, the program was run inside a 30 by 8 pseudo-terminal, a software terminal that the script utility creates so that another program can be driven as if a person were typing at it, and every byte it wrote was captured. In the normal run the escape stream began with \e[?1049h (enter the alternate screen) and ended with \e[?1049l\e[?25h (leave it, then show the cursor). Everything the panel draws sits between those two markers.

Common mistake 1: skipping the restore

Symptom. After running ./target/debug/status_tui --no-restore, the shell looks broken. Typed characters are invisible, there is no line editing, and output that follows no longer returns to the left edge at each newline, so lines drift rightward in a staircase. stty -a shows -icanon -echo, where it showed icanon echo before the run and after a normal run. The captured output ends ...\e[?25happ exit=0, with no \e[?1049l anywhere and no \r after app exit=0.

Cause. ratatui::init turned raw mode on and the program exited without turning it off. A leading minus in stty output means the setting is off, so -icanon is no line editing and -echo is no echo. The missing \e[?1049l means the alternate screen was never left. The missing \r (carriage return) shows that the terminal is no longer converting a bare newline into "return to column 0 and go down", which is the output processing raw mode disables.

Fix. Use ratatui::run, which pairs the setup and restore around your closure on every exit path. If you manage the terminal by hand, call restore on every path out. To rescue a wrecked shell, type reset (blind, since nothing echoes) and press Enter. You can inspect the settings with stty -a.

Common mistake 2: no terminal, or no keyboard

Two related failures show up when a TUI is launched somewhere other than an interactive shell, and they look different enough that it pays to know both.

Symptom A. Started with no terminal at all, as in a continuous integration (CI) job or a background service (captured here with echo q | ./target/debug/status_tui > out 2>&1), the program exits with status 101 after printing:

\e[?1049l
thread 'main' panicked at .../ratatui-0.30.2/src/init.rs:366:16:
failed to initialize terminal: Os { code: 6, kind: Uncategorized, message: "Device not configured" }

Cause A. init panics if enabling raw mode, entering the alternate screen, or measuring the terminal size fails. With no controlling terminal (the terminal a process inherits from the shell that started it), there is nothing to put into raw mode, and macOS reports "Device not configured". The \e[?1049l printed first is the panic hook doing its job: it restored the terminal before the panic message appeared, so even this failure would leave a real screen usable.

Symptom B. Started from a real terminal but with standard input redirected (./target/debug/status_tui < /dev/null), the panel draws its first frame and then the program exits with status 1. The tail of the captured stream reads \e[?1049l\e[?25hError: Custom { kind: Other, error: "Failed to initialize input reader" }.

Cause B. Setup succeeded because a terminal was present, but Crossterm's event reader could not start, so the first event::read()? returned an error (tested on macOS with Crossterm 0.29.0). Notice the order of bytes: the error text comes after \e[?1049l\e[?25h. That is ratatui::run keeping the result until restore has run, so the message lands on the normal screen instead of vanishing with the alternate one.

Fix. Run the program from an interactive terminal with the keyboard attached. Under a test runner, a pipe, or CI, test the rendering through a buffer instead, as shown at the end of this page.

Concept B: the draw loop and the buffer

Definitions and the why

Terminal::draw is the center of the loop. You pass it a closure, and the closure receives a Frame, which is the handle for drawing one screen's worth of output. Its most important method is render_widget, which takes any widget (a value that knows how to draw itself) and an area to draw it in. The widget draws into a Buffer, an in-memory grid of Cells. A Cell is the smallest renderable unit, holding one symbol and its style (colors and attributes), much like a pixel in a graphical interface. Nothing reaches the terminal while the closure runs. Compare that with writing text through Crossterm directly, where output can happen immediately.

When the closure returns, draw flushes. Ratatui uses a double buffer: it computes the difference between the current buffer and the previous one, writes only those cells to the terminal, and then swaps the buffers. Ratatui's documentation calls it an immediate mode rendering library, meaning your code describes the whole screen from state on every frame and keeps no widget objects alive between frames; the diff is how that stays cheap.

The consequence is that your closure must draw the entire UI on every call. Before a new Frame is made, Ratatui wipes the current buffer clean, so a closure that draws only the part that changed leaves everything else blank, and the diff then erases it from the screen. A test that drew the full panel, then drew a second frame containing only Paragraph::new("acked: 2") on row 3 (Paragraph is Ratatui's widget for a block of text), ended with a screen whose eight rows were all blank except the one reading acked: 2. The border, the queue name, and the key hints were gone. Call draw once per pass through your main loop, and keep all rendering inside the closure.

Worked example: counting changed cells

This test renders the app at counter 9 and at counter 10 into two buffers and asks for the difference. Buffer::empty(area) creates a grid of blank cells the size of area, and calling render on &App directly, with no Terminal at all, writes the panel into it:

#[test]
fn diff_sends_only_changed_cells() {
    let area = Rect::new(0, 0, 30, 8);
    let mut prev = Buffer::empty(area);
    let mut next = Buffer::empty(area);
    (&App { counter: 9, exit: false }).render(area, &mut prev);
    (&App { counter: 10, exit: false }).render(area, &mut next);
    let diff = prev.diff(&next);
    println!("cells in frame: {}", area.area());
    println!("cells changed:  {}", diff.len());
    for (x, y, cell) in &diff {
        println!("  ({x},{y}) -> {:?}", cell.symbol());
    }
    assert_eq!(diff.len(), 2);
}

prev.diff(&next) is the same comparison the terminal performs during a flush, exposed as a public method, so the test can count the cells a real redraw would send. The loop prints each changed cell's coordinates and symbol, and the assertion pins the count so that a later layout change that accidentally redraws more of the screen shows up as a failing test. Its output is:

cells in frame: 240
cells changed:  2
  (7,3) -> "1"
  (8,3) -> "0"

The area is 30 columns by 8 rows, so the frame holds 240 cells. Buffer::diff returns a list of (x, y, cell) entries for the cells that differ, and only two do: the text acked: 9 became acked: 10, so column 7 changed from 9 to 1 and column 8 gained a 0. Both are on row 3 (rows start at 0). The border, the title, and the hint line are rebuilt identically in both buffers, so they cost nothing to send.

The live program shows the same effect in the bytes it writes. Here is the escape stream from a real run with keys Right, Right, q, one line per frame:

\e[?1049h\e[1;1H┌\e[1;3Hstatus\e[1;10H────────────────────┐\e[2;1H│queue:\e[2;9Hingest\e[2;30H│\e[3;1H└────────────────────────────┘\e[4;1Hacked:\e[4;8H0\e[8;1H<-\e[8;4Hdec\e[8;9H->\e[8;12Hinc\e[8;17Hq\e[8;19Hquit\e[m\e[m\e[m\e[0m\e[?25l
\e[4;8H1\e[m\e[m\e[m\e[0m\e[?25l
\e[4;8H2\e[m\e[m\e[m\e[0m\e[?25l
\e[?1049l\e[?25h

Frame 1 writes every non-blank cell, using \e[row;colH to move the cursor and then the text. Blank cells are skipped because the previous buffer was already blank, so there is nothing to change. Frames 2 and 3 each carry one cell of content: \e[4;8H1 means "cursor to row 4, column 8, write 1", and the next frame writes 2 at the same spot. The terminal's rows and columns count from 1, which is why this is row 4 and column 8 while the buffer coordinates for the same digit are (7,3). The \e[m and \e[0m codes reset the text style and \e[?25l hides the cursor; they close every frame. The last line leaves the alternate screen and shows the cursor again. Each key press therefore costs one cursor move, one character, and a few fixed bytes, however large the panel is.

Common mistake: widgets overwrite each other cell by cell

Symptom. You draw a short label over longer text in the same area, expecting the label to replace it, and the screen shows twotent1 when you drew two over content1. A test asserting "two " fails with left: "twotent1 " and right: "two ".

Cause. Rendering means writing symbols into cells. Different widgets can write the same cell, so rendering order affects the final UI, and a widget only changes the cells it writes. two writes three cells, and the remaining cells of content1 stay.

Fix. Render the Clear widget into the area first. Its implementation loops over every cell in the area and resets it to the default, so everything underneath is blank before the next widget draws:

let mut buf = Buffer::empty(area);
Paragraph::new("content1").render(area, &mut buf);
Clear.render(area, &mut buf);
Paragraph::new("two").render(area, &mut buf);

The real output was without Clear: |twotent1 | and with Clear: |two |. The vertical bars are printed by the test to mark the two ends of the row. The 12-cell area is why the lines are padded with spaces: every cell in a buffer holds a symbol, and a blank cell holds a space.

Concept C: widgets, layout, and input

Definitions and the why

A widget is any type that implements the Widget trait, whose only required method is fn render(self, area: Rect, buf: &mut Buffer). A Rect is a rectangle of cells described by its position and size. Coordinates run left to right and top to bottom with the origin (0, 0) in the top left corner, x first and then y, both u16 values.

A constraint is a rule that says how large one slice of a split should be. Constraint::Length(n) asks for exactly n rows or columns, and Constraint::Fill(1) takes whatever space is left over. Layout::vertical([...]) stacks slices from top to bottom, and .areas(area) splits a Rect into one sub-Rect per constraint. Layouts can also be nested.

Without layout code you would compute every row number by hand, and each of those numbers would be tied to one terminal size. With a layout, the header stays three rows, the footer stays one, and the body absorbs whatever height is left, so that the same code produces a sensible panel on a short window and a tall one.

Input has its own rule. Crossterm reports key events with a kind, and your code should react only to KeyEventKind::Press. On Windows, Crossterm also emits release and repeat events, so a program that counts every key event otherwise sees duplicates for key down, key repeat, and key up.

Worked example: src/lib.rs

use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
    buffer::Buffer,
    layout::{Constraint, Layout, Rect},
    widgets::{Block, Paragraph, Widget},
};

#[derive(Debug, Default)]
pub struct App {
    pub counter: u8,
    pub exit: bool,
}

impl App {
    /// Apply one terminal event. Only key presses change state.
    pub fn handle_event(&mut self, event: &Event) {
        if let Event::Key(key) = event {
            if key.kind == KeyEventKind::Press {
                self.handle_key(*key);
            }
        }
    }

    pub fn handle_key(&mut self, key: KeyEvent) {
        match key.code {
            KeyCode::Right => self.counter = self.counter.saturating_add(1),
            KeyCode::Left => self.counter = self.counter.saturating_sub(1),
            KeyCode::Char('q') => self.exit = true,
            _ => {}
        }
    }
}

impl Widget for &App {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let [header, body, footer] = Layout::vertical([
            Constraint::Length(3),
            Constraint::Fill(1),
            Constraint::Length(1),
        ])
        .areas(area);

        Paragraph::new("queue: ingest")
            .block(Block::bordered().title(" status "))
            .render(header, buf);
        Paragraph::new(format!("acked: {}", self.counter)).render(body, buf);
        Paragraph::new("<- dec  -> inc  q quit").render(footer, buf);
    }
}

The state lives in App, a counter and an exit flag. handle_event filters on KeyEventKind::Press and passes real presses to handle_key, which maps Right and Left to saturating arithmetic and q to setting exit. Saturating arithmetic stops at the type's limits instead of overflowing; the counter is a u8, and Ratatui's own counter tutorial deliberately leaves a plain -= 1 in place to show the panic it causes at zero. Splitting the key handling into its own method also means tests can drive the app without emulating a terminal.

The rendering is implemented for &App, a reference, because drawing does not change any state, and this way the loop can keep using app after draw returns. render splits the area into three named Rects, draws a Paragraph (a widget that shows text) inside a bordered Block (a widget that draws a box with a title) in the header, and plain paragraphs in the body and footer.

For a 30 by 8 area, the layout test printed these Rects:

header=Rect { x: 0, y: 0, width: 30, height: 3 }
body=Rect { x: 0, y: 3, width: 30, height: 4 }
footer=Rect { x: 0, y: 7, width: 30, height: 1 }

The two fixed slices take 3 and 1 rows, and Fill(1) receives the remaining 4, starting at row 3. Rendering App { counter: 7, exit: false } into a 30 by 8 Buffer::empty (no terminal involved) gave this grid:

|┌ status ────────────────────┐|
|│queue: ingest               │|
|└────────────────────────────┘|
|acked: 7                      |
|                              |
|                              |
|                              |
|<- dec  -> inc  q quit        |

The bordered block fills the three header rows and its inner row holds the queue name. acked: 7 sits on the first body row, the middle of the body is blank, and the hint line is on the last row. The vertical bars at the edges of each line are only the test's own delimiters, printed so trailing spaces stay visible.

Common mistake 1: counting every key event

Symptom. On Windows, one tap of a key moves the counter more than once, and holding it moves it further still, while the same build behaves correctly on macOS or Linux. The test reproduces this on any platform by building a Press, a Repeat, and a Release of the Right key with KeyEvent::new_with_kind and feeding all three to two apps; it printed filtered counter = 1, naive counter = 3.

Cause. The naive app called handle_key for every key event and ignored its kind. Every one of the three events carries KeyCode::Right, so each looked like a fresh press to the code. Crossterm delivers release and repeat events on Windows, which is why the bug hides on other systems.

Fix. Check key.kind == KeyEventKind::Press before acting, as handle_event does. The filtered app, which went through that check, counted 1.

Common mistake 2: wrong number of names for areas

Symptom. The program panics with invalid number of rects: expected 3, found 2.

Cause. The code destructured into three names, let [_a, _b, _c], but the layout was built from only two constraints, Length(3) and Fill(1). areas returns a fixed-size array, so the number of constraints must be known at compile time and must equal the number of names. A mismatch panics at run time.

Fix. Make the count of names match the count of constraints. If the number of constraints is only known at run time, use Layout::split, which returns a shared slice (Rc<[Rect]>) instead of an array. If you would rather handle a mismatch than crash, try_areas returns an error in the same situation.

Putting it together

Follow one run of the status panel from launch to quit. When you start it, ratatui::run enables raw mode, enters the alternate screen with \e[?1049h, and installs the panic hook. Your closure creates App::default(), and the loop's first draw renders the whole panel into the buffer. Because the previous buffer is blank, the flush writes every non-blank cell: the border, the title, queue: ingest, acked: 0, and the hint line. The program then blocks in event::read.

You press Right. The terminal delivers the key at once, not after Enter, because raw mode is on. The event is a key press, so handle_key raises the counter to 1. The loop returns to draw, which wipes the buffer and renders the full panel again from the new state. The diff against the previous frame contains a single cell, so Ratatui writes \e[4;8H1 and the short style-reset and hide-cursor codes, nothing more. A second Right press does the same with 2. Then you press q: handle_key sets app.exit, the while condition fails, and the closure returns Ok(()). run calls restore, which disables raw mode and sends \e[?1049l, so the shell comes back with its earlier contents and your prompt in the right place. That ending is the guarantee the problem section lacked: the panel updates in place, input arrives per key, and the terminal is handed back as you found it.

The same App can be tested with no terminal, using a TestBackend. That is a backend that renders to an in-memory buffer instead of a real screen. Ratatui's documentation recommends testing widgets directly against a Buffer, as the earlier tests did, and keeping TestBackend for integration tests of the whole terminal UI.

#[test]
fn terminal_draw_on_test_backend() {
    let mut terminal = Terminal::new(TestBackend::new(30, 8)).unwrap();
    let mut app = App::default();
    let first = terminal.draw(|f| f.render_widget(&app, f.area())).unwrap().count;
    app.handle_key(KeyCode::Right.into());
    let second = terminal.draw(|f| f.render_widget(&app, f.area())).unwrap().count;
    println!("frame counts: {first} then {second}");
    println!("{}", terminal.backend());
    assert_eq!(lines(terminal.backend().buffer())[3].trim_end(), "acked: 1");
}

The test builds a Terminal over a 30 by 8 TestBackend and runs the exact draw closure from main. It then sends a Right key through handle_key and draws again, without any raw mode, escape sequences, or pseudo-terminal involved. KeyCode::Right.into() converts a bare key code into a KeyEvent with no modifiers, which is enough for handle_key. The helper lines (defined in the test file) reads each row of a buffer back into a String, so the assertion can compare the fourth row with plain text. draw returns a completed-frame value whose count is the frame's number, starting at zero, and the test printed frame counts: 0 then 1. Printing the backend shows its current screen, one quoted string per row:

"┌ status ────────────────────┐"
"│queue: ingest               │"
"└────────────────────────────┘"
"acked: 1                      "
"                              "
"                              "
"                              "
"<- dec  -> inc  q quit        "

After one Right press, the screen holds acked: 1 where the first frame showed acked: 0. All eight tests in the project pass in 0.01 seconds, and none needs a terminal, which means they run unchanged in CI where Symptom A above would otherwise stop the real program. Compare this screen with the earlier Buffer::empty render: the rows match apart from the counter, which confirms that the widget code does not care whether it draws into a bare buffer, a test backend, or a real terminal. What differs is the reach of each test. The bare-buffer tests check one widget's output, while the TestBackend test also runs Terminal::draw, the frame counter, and the buffer swap that a real program depends on.

Check yourself

  1. Why does the program receive a Right-arrow press immediately instead of after Enter? Raw mode disables line buffering, echo, and special-character handling, so each key is delivered as it is typed.
  2. **A colleague's draw closure renders only the counter, because "only that changed". What do they see?** The buffer is wiped before each new Frame, so the rest of the UI is blank in the new buffer and the diff erases it from the screen. The closure has to render the whole UI every time.
  3. **After a crash-free run of your own init-based program, the shell shows no typed characters. What happened and what do you type?** restore was never called, so raw mode is still on (stty -a shows -icanon -echo). Type reset, and in the code use ratatui::run or call restore on every exit path.
  4. **let [a, b] = Layout::vertical([Length(3), Fill(1), Length(1)]).areas(area); panics. Why, and what are two fixes?** There are three constraints but two names. Add a third name, or use Layout::split when the count is only known at run time.
  5. **Why does the second frame of the interactive run send only \e[4;8H1 plus a few fixed style and cursor codes?** Ratatui diffs the new buffer against the previous one. Only the cell showing the counter's digit changed, so only that cell's content is written.

Sources