Frame Pipeline
The FrankenTUI render loop is seven discrete steps. Each step is a deterministic function of its inputs and the layer below it. Once you know the seven steps, you can trace any frame from “user pressed a key” to “ANSI bytes on stdout” without guessing.
This page is paired with the architecture page. Architecture shows the four layers; this page walks the pipeline one step at a time. Read them in either order.
The pipeline is what makes snapshot testing, replay debugging, and shadow-run validation possible. If any step were non-deterministic or hid side effects, the whole chain would break. So every step is a pure function: inputs in, outputs out, no globals, no wall clock, no stdout.
Mental model
Step 1 — Input
TerminalSession reads raw bytes from crossterm. InputParser decodes
CSI/OSC/DCS sequences, bracketed paste, and the Kitty keyboard protocol.
GestureRecognizer promotes MouseDown/MouseMove/MouseUp into
semantic events like Click, DoubleClick, DragStart, DragMove,
DragEnd.
Output: a typed Event value.
Step 2 — Model update
The runtime converts Event into Self::Message via the From<Event>
impl you provide, then calls update(&mut self, msg). The model mutates
in place and returns a Cmd<Message> for side effects — HTTP calls, FS
reads, timers, subprocess I/O. Cmd::none() means “no side effect.”
Output: a (possibly mutated) model and a Cmd.
Step 3 — View
The runtime calls view(&mut Frame). You render your widget tree into the
Frame — a per-render-cycle drawing context that wraps a mutable Buffer
plus supporting state (style stack, link registry, layout arena).
view() must be pure: same model in, same frame out. If you read
SystemTime::now() or any mutable global here, snapshot tests will
flake and shadow-run validation will diverge.
Output: a Frame with its backing Buffer fully populated.
Step 4 — Buffer
The Frame write operations land in a 2D Buffer: row-major, a 16-byte
Cell per position, plus a dirty-row bitmap and per-row dirty-span
intervals. Every mutation marks its row dirty in O(1); the diff step
later skips unchanged rows entirely.
Cell layout:
┌─────────┬─────────┬─────────┬────────┬─────────┐
│ content │ fg │ bg │ attrs │ link_id │
│ (4 B) │ (4 B) │ (4 B) │ (2 B) │ (2 B) │
└─────────┴─────────┴─────────┴────────┴─────────┘
Cell (16 bytes)Four cells per 64-byte cache line. SIMD-friendly 128-bit equality via
bits_eq().
Output: a populated Buffer plus dirty metadata.
Step 5 — Diff
BufferDiff::compute(&prev, &next) walks only the dirty rows. Within each
dirty row, it walks only the dirty-span intervals. Adjacent changed cells
are coalesced into ChangeRuns so the presenter can emit one cursor move
per run, not one per cell.
For very sparse updates on very large viewports, the diff also consults a summed-area table (a 2D prefix sum over change counts) to skip empty tiles in O(1).
Output: a list of ChangeRuns — each a contiguous stretch of changed
cells with its (x, y) start.
Step 6 — Presenter
The Presenter turns ChangeRuns into ANSI bytes. It does three things
that a naive emitter wouldn’t:
- Cost-model cursor positioning. For each row with changes, it picks
between
CSI {row};{col}H(CUP) andCSI {col}G(CHA) based on the byte cost of each, given the current cursor position. - State-track the style. It tracks the current fg/bg/attrs and only emits SGR sequences when they change. Rendering 100 cells of the same style emits one SGR, not 100.
- Buffer into 64 KB. All bytes for the frame go into a single buffer so the writer does one syscall per frame.
Output: a buffer of ANSI bytes, ready to hand to the writer.
Step 7 — Writer
TerminalWriter wraps the payload in DEC 2026 synchronized-output
brackets where supported. The terminal displays the whole frame
atomically — no partial paints ever visible. This is what makes
FrankenTUI flicker-free even at a very high frame rate.
Then the writer flushes to stdout. One syscall per frame. The one-writer
rule is enforced here: TerminalWriter is the single owner of stdout,
and nothing else in the process writes to it.
Output: ANSI bytes delivered to the terminal.
Inline mode deviations
Inline mode (ScreenMode::Inline { ui_height: N }) keeps a stable UI
region at the bottom of the terminal while log output scrolls above. The
first six pipeline steps are unchanged. Step seven deviates:
- Strategy A (Scroll region / DECSTBM).
ESC [ top ; bottom rconstrains scrolling to the log region. The UI region is untouched by terminal-side scrolling. - Strategy B (Overlay redraw). For terminals with unreliable DECSTBM (some multiplexers, older emulators), the writer saves the cursor, clears the UI area, writes new log lines, redraws the UI, and restores the cursor — all inside one sync bracket pair.
- Strategy C (Hybrid). Default. Uses scroll region on the fast path and falls back to overlay redraw when capability probing detects an unreliable implementation.
In all three cases the user’s scrollback history is preserved — you can scroll up and see the original log lines.
See screen modes for the capability-probe details.
Alt-screen deviations
Alt-screen mode (ScreenMode::AltScreen) enters the alternate buffer on
startup and exits it on drop. The seven steps are otherwise unchanged.
Scrollback is preserved because TerminalSession exits the alt screen
before dropping raw mode.
Pitfalls
view() runs every frame. Do not allocate or compute heavy work inside
it. Pre-compute in update() and render pre-computed state. The frame
budget (default 16 ms for 60 Hz) is enforced by a conformal gate; a
slow view() triggers the degradation cascade.
Do not cache a &mut Frame across await points. Frame is per-cycle,
and its lifetime ends when view() returns. Async work belongs in
Cmd, not view().
Reading the wall clock inside view() makes the pipeline
non-deterministic. Snapshot tests will flake, and shadow-run validation
will see false diffs. If you need time, plumb it through the model from
an Every::new(Duration, ...) subscription.