// lesson: render-diff
Rendering โ Damage and Diffs
You can now maintain a model of what the screen should show. The renderer's job is to make the physical terminal agree with the model โ and the interesting question is how much you have to send to get there.
The cost of the naive frame
The blunt approach re-sends everything:
hide cursor ยท home ยท for each row: chars + \r\n ยท position cursor ยท show cursor
For a 50ร200 window that's ~10 KB per frame. Hold a key with auto-repeat at 30 Hz and you're pushing 300 KB/s โ through a pty, maybe through tmux, maybe over an ssh link to another continent. On the LAN you won't notice. At 200 ms round-trip with a congested uplink, "hold the down arrow" turns into a slideshow. And it's all waste: between two frames of a text editor, typically one row changed.
The fix has been reinvented by every serious screen program since the 1980s (curses made it famous): keep the previous frame, diff against it, send only the damage.
prev frame (what the terminal shows)
next frame (what the model says)
โ
โผ
for each row that differs:
position cursor at first difference
re-send the changed span
Now a keystroke that changes one row costs ~10 bytes: one CUP, a few
characters. That's a 1000ร reduction, achieved with a memcmp and a
loop โ the single highest-leverage optimization in the terminal
world.
Diffing well, without diffing perfectly
How fine-grained should the diff be? The spectrum:
- Frame-level: anything changed โ redraw all. (The naive plan.)
- Row-level:
memcmpeach row pair; changed rows are redrawn whole. Simple, catches the common cases (one line edited, status bar ticking). - Span-level: within a changed row, find the first and last differing columns and send just that span. One CUP + the span.
- Run-level: multiple separate dirty spans per row, each with its own CUP. Optimal output, fiddlier loop โ and now positioning sequences (~8 bytes each) compete with just re-sending the clean gap between two nearby spans. True minimality is a cost model, not a diff.
Production emulators mix heuristics ("if the row is mostly dirty, send it all") because close enough is genuinely fine: the payoff plateau is reached at span-level. That's what you'll build: per row, first-diff to last-diff, one CUP, one span.
Two disciplines make a diff renderer correct rather than just fast:
- The prev frame must be the truth. After emitting the diff, the new frame becomes prev โ and nothing else may write to the terminal behind the renderer's back, or the model and reality diverge and stale cells haunt the screen until the next full redraw. (That's why full-screen apps repaint everything on Ctrl+L and on resize: it's the "I no longer trust prev" reset.)
- Positioning must be absolute. Tempting shortcut: "the cursor is already on row 4 col 10 after that span, the next span starts at col 15, just print the gap". Real emulators do track that โ and every bug where the optimizer's idea of the cursor drifts from the terminal's produces smeared garbage. Start with one absolute CUP per span; optimize only against measurements.
A last habit of the pros, worth knowing: bracket the whole frame
with hide-cursor / show-cursor (the flicker fix from the screen
lesson), and if you ever chase visible tearing on slow links, look
up "synchronized output" (ESC[?2026h) โ the modern extension that
lets a program mark frame boundaries explicitly.
โบ Diff Two Frames
25 ptsrender_diff(prev, next, out): append to out (your abuf,
implementation provided) the byte sequence that transforms a
terminal showing prev into one showing next:
- For each row where the frames differ: exactly one absolute CUP
(
\x1b[<row+1>;<col+1>H, 1-based!) targeting the first differing column, followed by the row's characters from the first through the last differing column. - Rows with no differences contribute zero bytes.
- Equal frames produce an empty buffer.
The tests replay your output through a reference terminal
interpreter seeded with prev and require it to end up exactly
next (correctness), then meter your byte counts (efficiency): an
unchanged frame emits nothing, a one-cell change stays under 16
bytes, and a one-row change doesn't touch other rows.
Log in to submit a solution and earn points.