rust · tui

Terminal rendering is a diff problem

Jun 1, 2026 · 13 min read

The first version of every terminal dashboard is the same program: build the frame as one big string, clear the screen, print the frame, sleep, repeat. It works, it ships, and it flickers: a faint strobing that gets worse the busier the machine is, because what the user's eye keeps catching is the moment between the clear and the reprint, when the screen is deliberately blank.

The instinct is to look for the terminal's vsync. There isn't one. A terminal is an interpreter (we've covered what that means for security; here it bites as a rendering constraint), and it executes instructions as they arrive. Nothing batches your erase with the repaint that follows it. Write "clear the screen" and the terminal is entirely within its rights to composite exactly that state to glass before your next byte lands. (An opt-in fix called synchronized output exists, and it will make a cameo later, but it's a nicety you layer on top — not a foundation you get to assume.)

So you can't synchronize with the display. What you can do is stop emitting wrong intermediate states worth seeing. And here the terminal hands you one enormous advantage: you are the only writer. Everything on that screen is there because you put it there, which means you can know, without ever asking the terminal, exactly what the current frame looks like. Keep the previous frame, compare it with the next one, and send only the difference. No blank state, no flicker, and a frame that changed one line costs one line of bytes.

rendering = diff two cell grids; repaint only what changed previous frame L O A D 4 2 % | . . . . next frame L O A D 6 7 % / . . . . diff, cell by cell unchanged — kept on glass, skipped changed — the only cells repainted the rest of the screen is never touched — no clear, no flicker
Rendering is a diff between two cell grids: the parser compares the previous frame with the next one cell by cell, repaints only the handful that changed (gold), and leaves every unchanged cell untouched on the glass — no clear, no flicker.

That idea, implemented, is the log-update crate in inkrs, the Rust port of Ink's log-update.ts. It contains two renderers — a standard one that retracts and reprints the whole frame, and an incremental one that diffs per line — plus a sneaky optimization that scrolls a band of the screen in hardware.

The eraser

Before you can repaint a frame you have to be able to take one back. The primitive for that lives at the top of the crate, and it's worth reading byte by byte:

/// `ansi-escapes.eraseLines` equivalent.
///
/// `eraseLines(0)` returns "". For `count > 0`:
/// `(\x1b[2K \x1b[1A)` × `(count-1)` + `\x1b[2K` + `\x1b[G`.
pub fn erase_lines(count: u32) -> String {
    if count == 0 {
        return String::new();
    }
    let mut out = String::new();
    for i in 0..count {
        out.push_str("\u{001B}[2K");
        if i < count - 1 {
            out.push_str("\u{001B}[1A");
        }
    }
    out.push_str("\u{001B}[G");
    out
}

Three instructions, each chosen by where the cursor happens to be. After a frame is printed, the cursor sits at the bottom of it — that's just where printing left it. So retraction works upward: CSI 2K erases the entire line the cursor is on (without moving the cursor), CSI 1A steps up one row, and the loop alternates the two. Count the moves: erasing n lines takes only n − 1 steps up, because the cursor starts on the last line and finishes on the first — that's the if i < count - 1 guard. And since neither 2K nor 1A touches the column, the final CSI G snaps to column 1, so whatever gets printed next starts at a known position.

One counting subtlety that would otherwise bite you in the tests: a frame like "Hello\n" splits into two lines, ["Hello", ""]. That empty slot after the trailing newline is real — it's the row the cursor is parked on — and the renderer erases and climbs through it like any other line. The math in the sibling cursor-helpers crate is equally pedantic; its cursor_up returns an empty string for a count of zero, because — translating its Dutch comment — "ECMA-48 counts parameter 0 as 1, so [0A would still move the cursor one row." The distance between a working renderer and a haunted one is exactly this kind of off-by-one.

The standard renderer: retract, reprint

The standard renderer is erase_lines plus bookkeeping. Its state is two fields — the previous frame as a string, and how many lines it had — and its render path, lightly trimmed:

fn render_standard(&mut self, s: &str) -> bool {
    // ...
    if !self.state.has_changes(s, active_cursor) {
        return false;
    }
    // ...
    let mut combined = String::with_capacity(return_prefix.len() + s.len() + 32);
    combined.push_str(&return_prefix);
    combined.push_str(&erase_lines(self.state.previous_line_count as u32));
    combined.push_str(s);
    combined.push_str(&cursor_suffix);
    self.stream.write(&combined);
    self.state.previous_line_count = lines.len();
    // ...
}

Four decisions in ten lines. First, the cheapest frame is no frame: has_changes compares the new string against previous_output (and the requested cursor position against the previous one), and if nothing differs, nothing is written. A test pins that rendering "Hello\n" twice produces exactly one write. Second, everything goes out as one write call: prefix, erase, frame, cursor movement, concatenated into a single buffer. A terminal may still slice that buffer wherever it likes, but you've at least never handed it a clean seam between "screen erased" and "screen repainted". Third, the return_prefix: if the previous frame ended with a visible cursor parked somewhere in the middle — a text input — the renderer first hides it and walks it back down to the bottom-left, because the erase math assumes the cursor is at the bottom. Fourth, the cursor suffix moves the caret to wherever the new frame wants it.

This is correct, and it's what upstream Ink shipped for years. It's also wasteful in a very specific way: a spinner in the corner of a forty-line frame erases and rewrites forty lines, several times a second, to change one glyph. The bytes are cheap; the problem is that every row of the frame becomes a row the terminal may repaint, and on slow links or busy compositors that's visible churn. The diff is still too coarse. The unit of change is the line, not the frame.

The incremental renderer: pay per line

The incremental renderer keeps richer state: not just the previous output as a string, but previous_lines: Vec<String> — the previous frame, pre-split. Rendering starts the same way (no-change gate, hide-and-return prefix), then rewinds the cursor to the top of the old frame, and walks both line lists in lockstep. This loop is the heart of the crate:

for i in 0..(visible as usize) {
    let is_last_line = i + 1 == visible as usize;

    if next_lines.get(i).map(String::as_str)
        == self.state.previous_lines.get(i).map(String::as_str)
    {
        if !is_last_line || has_trailing_newline {
            buffer.push_str(CURSOR_NEXT_LINE);
        }
        continue;
    }

    buffer.push_str(&cursor_to(0));
    if let Some(line) = next_lines.get(i) {
        buffer.push_str(line);
    }
    buffer.push_str(ERASE_END_LINE);
    if !(is_last_line && !has_trailing_newline) {
        buffer.push('\n');
    }
}

The decision rule could not be more literal: line i of the new frame is compared against line i of the old frame, as strings, escape bytes included. Equal means the terminal already shows this line, so don't touch it — emit CSI E ("cursor to start of next line", the CURSOR_NEXT_LINE constant) and step over it. Different means: go to column 1, write the new line, then CSI K (ERASE_END_LINE) to clip whatever tail the old, longer line may have left behind, then \n to the next row. Everything accumulates into one buffer and goes out as one write — a test renders ten lines, changes five of them, and asserts the whole surgical update cost exactly one write call. Another renders three lines, updates the middle one, and asserts the output contains Updated but neither Line 1 nor Line 3.

Notice what the comparison being byte-equality buys: a line whose text is unchanged but whose color changed is a changed line — the SGR escapes differ — so restyling is repainted exactly like retyping. The diff doesn't need to understand styling to get styling right.

But it does need one guarantee, and it comes from earlier in this series. Rewriting an arbitrary subset of lines is only safe if no line borrows state from another — if line 12's red doesn't depend on an opener that line 3 emitted and the diff just skipped. That property doesn't hold for naively wrapped styled text, which is why the wrapping essay made such a fuss about it: every wrapped line closes its SGR runs before the newline and reopens them after, so every line is style-self-contained. Back then that looked like tidiness. Here it's load-bearing: the line diff is sound because the layer below promised that any line, repainted alone, renders identically. The two crates were designed as halves of one invariant.

What about the cursor? The renderer never asks the terminal where it is — it navigates by dead reckoning, because every byte that ever moved the cursor came out of this same buffer. If the new frame is shorter, the rewind first erases the surplus bottom rows (erase_lines) and then steps up. Otherwise it rewinds to the top of the frame, and how depends on a flag the runtime sets:

} else if self.state.frame_at_origin {
    // Alt-screen: the frame starts at screen row 0, so absolute home
    // is valid and more robust than a relative `cursor_up(n)` — after
    // DECSTBM operations (scroll region + ESC 7/8) the cursor position
    // can differ per terminal; an off-by-one would let the diff sink
    // one row and overwrite the input-field border.
    buffer.push_str("\u{001B}[H");
} else {
    // Inline: the frame starts at an arbitrary screen row — `\x1b[H`
    // would paint at the top of the *screen* and leave the old frame
    // standing. Rewinding relatively is safe here: DECSTBM hints never
    // occur inline (the hint validation requires alt-screen).
    let n = (self.state.previous_lines.len() as u32).saturating_sub(1);
    if n > 0 {
        buffer.push_str(&cursor_up(n));
    }
}

(The source comments are Dutch; the translation is mine.) On the alt-screen the frame's row 0 is the screen's row 0, so CSI H — absolute home — is unambiguous. Inline, below your shell prompt, the frame starts wherever it happens to start, and only relative movement makes sense. Keep that distinction in mind, because the next section is about the one feature that makes relative movement untrustworthy.

The hardware scroll: move the screen, not the bytes

Here's the case that humbles a line diff. A scrollable viewport shows rows 0–4 of a long list; the user scrolls down one. Every visible line changes — line 0 is now what line 1 was, and so on — so the diff dutifully rewrites the entire band. But almost nothing new is on screen. The change is a permutation, and terminals have had hardware for exactly this permutation since the VT100: DECSTBM, the scroll region.

log-update exposes it as a hint the caller attaches to the next render:

pub struct ScrollHint {
    /// Top frame row of the band (0-indexed).
    pub top: u32,
    /// Bottom frame row of the band (inclusive).
    pub bottom: u32,
    /// Content shift: positive = content moved up (scrolled down),
    /// negative = down.
    pub delta: i32,
}

The trick is where the hint acts. It doesn't bypass the diff — it lies to it, truthfully. First, shift_region applies the same shift to previous_lines that the terminal is about to apply to the glass. Then the renderer emits the scroll patch:

shift_region(&mut self.state.previous_lines, h.top as usize, h.bottom as usize, h.delta);
scroll_patch.push_str(termio::esc::CURSOR_SAVE);          // ESC 7
scroll_patch.push_str("\u{001B}[0m");
scroll_patch.push_str(&termio::csi::set_scroll_region(h.top + 1, h.bottom + 1));
if h.delta > 0 {
    scroll_patch.push_str(&termio::csi::scroll_up(h.delta as u32));
} else {
    scroll_patch.push_str(&termio::csi::scroll_down(h.delta.unsigned_abs()));
}
scroll_patch.push_str(termio::csi::RESET_SCROLL_REGION);  // CSI r
scroll_patch.push_str(termio::esc::CURSOR_RESTORE);       // ESC 8

After this runs, the model and the screen have made the same move — so the ordinary diff loop, running right afterward, finds the four shifted rows equal and steps over them with CSI E. The only difference it finds is the row the scroll exposed at the edge, and that's the only row it rewrites. The test for this renders r0..r4, scrolls by one to r1..r5, and asserts two things: the write starts with exactly \x1b7\x1b[0m\x1b[1;5r\x1b[1S\x1b[r\x1b8, and only r5 gets rewritten — the four surviving rows never reappear in the stream.

Every byte of that patch earns its place. ESC 7/ESC 8 save and restore the cursor, because both setting a scroll region and resetting it home the cursor per the spec. The CSI 0m reset is subtler: under BCE ("background color erase") the blank rows a scroll drags in are filled with the currently active background color — scroll with a component's background live and the new row arrives pre-painted the wrong color.

And then there's the honesty section, straight from the doc comment: the renderer applies the hint blind, and the caller must guarantee three conditions the renderer cannot see. One: the frame sits at the top of the screen — alt-screen mode — because ScrollHint speaks frame rows but DECSTBM addresses screen rows; only at the origin are those the same coordinates. (This is also why inline frames rewind relatively and never see hints.) Two: the band spans the full screen width. A scroll region moves entire rows; if your viewport owns only the left half of those rows, whatever lives to its right scrolls along with it. Three: the write is wrapped in DEC 2026 synchronized output — the promised cameo — so the intermediate state, region shifted but edge row not yet patched, is never composited to glass.

The renderer adds its own guards on top: the hint is one-shot (consumed by the next render, even a standard one, so a stale hint can never act on a later frame), rejected when out of bounds, and rejected whenever the frame shrinks — because the shrink path rewinds with relative cursor moves, and immediately after a DECSTBM patch the cursor position is, per the comment, "uncertain per terminal". The design principle sits in the same comment, and it's the right one: a rejected hint is by design never wrong — the line diff then simply does everything. The hint is an optimization on top of a correct baseline, never a requirement of it.

Where the model breaks: resize

Everything above rests on one assumption: previous_lines faithfully describes the glass. There is exactly one event that silently voids that assumption, and it's the user grabbing the window corner. On resize, the terminal reflows or clips your existing output by its own rules — some rewrap, some truncate, some do both depending on scrollback. Your model didn't change; the screen did.

A diff against a stale model is worse than useless — it would erase rows that aren't where it thinks they are and skip rows it wrongly believes are unchanged. So the runtime doesn't try to be clever. In inkrs-runtime, set_terminal_size does this:

let size_changed = columns != self.columns || rows != self.rows;
// ...
if size_changed {
    // A real terminal reflows/clips the existing content unpredictably
    // on resize, so `previous_lines` no longer describes the screen:
    // the incremental diff would erase against a stale image and skip
    // "unchanged" lines. A full clear + repaint (like upstream Ink on
    // resize) is the only reliable recovery.
    self.log_update.clear();
}

— then marks the component tree dirty and re-emits the whole frame at the new dimensions. clear() wipes the frame and resets the renderer's memory: previous lines, previous output, cursor bookkeeping, all gone. The next render finds previous_lines empty and repaints from scratch. A test named krimp_resize_hertekent_het_volledige_frame — "shrink-resize repaints the full frame" — pins the behavior from the outside: after the resize, even a line whose content didn't change must appear in the stream again. (One boundary worth knowing: the runtime installs no SIGWINCH handler; detecting the resize and calling set_terminal_size is deliberately the embedding application's job.)

This is the diff admitting defeat, and doing it correctly: when the model is wrong, you don't patch the model; you discard it and rebuild from zero. Diffing is an optimization over a correct baseline, and the baseline is always there to fall back to. Flicker on the rare resize is a fair price; flicker on every frame was the disease we came in with.

Rendering is bookkeeping

Step back and look at what log-update never does: it never asks the terminal anything. No cursor-position reports, no "what's on row 12" — the terminal barely offers such queries, and this renderer wouldn't want them. Every decision — skip this line, rewrite that one, scroll this band, burn it all down and repaint — is made against its own ledger: the previous frame, the previous line count, the previous cursor, one flag for "do I start at row 0". The escape sequences are just the ledger's edits, mailed to a display that keeps no books of its own.

That's the real lesson, and it's smaller and sharper than "diffing is fast". The terminal remembers nothing for you, so you remember everything for it — and once your records are trustworthy, the optimal frame writes itself: it's just the difference between two entries.


Thoughts? Find me on LinkedIn.