ftui-render — Overview
ftui-render is the render kernel: a deterministic, stateless
pipeline that transforms a logical display description into a minimal
ANSI byte stream and writes it to the terminal. It does not read
input, does not own the terminal lifecycle, and does not know what a
widget is. It knows cells, colors, diffs, and cursor motion — nothing
more.
The pipeline has four well-defined stages, each implemented as a small, testable module:
Frame ──▶ Buffer ──▶ BufferDiff ──▶ Presenter ──▶ ANSI bytes
(API (2D grid (ChangeRun[], (stateful (written by
used by + dirty SIMD compare, ANSI emitter, the caller —
widgets) tracking) SAT skip) cost-model DP) not by this crate)Each stage hands its output to the next as plain data. The kernel is
input-agnostic (it does not know a mouse click from a file read)
and backend-agnostic (the final Vec<u8> can go to stdout, a PTY,
a snapshot test, or a WASM canvas). This is the smallest surface that
still lets us guarantee flicker-free updates on the supported terminal
matrix.
Motivation
Naive TUI renderers redraw the whole screen every frame. Terminals hate that: the cost of repainting 80 × 24 × (~15 bytes per cell) = ~29 KB per frame at 60 Hz is 1.7 MB/s of stdout traffic, and worse, most of it is changing SGR state the terminal has to parse back into internal state. The visible symptom is flicker — the terminal briefly shows partial frames mid-update.
A differential renderer writes only cells that changed, emitting minimal cursor moves between them. The diff computation must be cheap (SIMD, dirty tracking, tile skipping), the ANSI emission must track the terminal’s current cursor and SGR state to suppress redundant codes, and the whole pipeline must be deterministic so snapshot tests can freeze the output byte-for-byte.
Stage-by-stage
Buffer, a grapheme pool, an optional hit grid, and cursor state. Handed to Model::view() by the runtime.Frame16-byte Cell (4 per cache line), row-major Buffer with dirty-row bitmap, scissor stack (monotone intersection), dirty-span ranges.Cell & BufferBufferDiff::compute — block-based SIMD compare, dirty-row and tile skipping, ChangeRun coalescing into contiguous spans.DiffStateful ANSI emitter: cost-model DP between CUP and CHA, SGR state tracking, OSC 8 link dedup, DEC 2026 synchronized output.PresenterInterned store for multi-codepoint clusters (flags, ZWJ emoji, CJK). GraphemeId packs slot + generation + width into 32 bits.Grapheme PoolDEC 2026 sync brackets, when they’re safe to emit, and the flicker-free proof sketches.Synchronized OutputEnd-to-end sequence
runtime::tick
│
▼
Frame::new(w, h, &mut pool) [render/frame.rs]
│ Model::view(&mut frame)
▼
Buffer (width × height, row-major) [render/buffer.rs]
│ dirty_rows bitmap
│ dirty_spans per row
▼
BufferDiff::compute(old, new) [render/diff.rs]
│ SIMD compare within dirty rows
│ SAT tile skip (optional)
│ Vec<ChangeRun { y, x0, x1 }>
▼
Presenter::present(buffer, diff) [render/presenter.rs]
│ for each change run:
│ cost(CUP) vs cost(CHA) → cheapest move
│ emit SGR delta
│ emit cell bytes
│ optional DEC 2026 bracket
▼
Vec<u8> ──▶ TerminalWriter::write_all
│
▼
stdout / PTYThe runtime keeps two buffers and swaps them via std::mem::swap each
frame so the next diff compares against the just-presented state.
Minimal end-to-end
use std::io::Write;
use ftui_core::terminal_capabilities::TerminalCapabilities;
use ftui_render::buffer::Buffer;
use ftui_render::cell::Cell;
use ftui_render::diff::BufferDiff;
use ftui_render::presenter::Presenter;
fn main() -> std::io::Result<()> {
let mut old = Buffer::new(80, 24);
let mut new = Buffer::new(80, 24);
for (i, ch) in "Hello, World!".chars().enumerate() {
new.set(i as u16, 0, Cell::from_char(ch));
}
let diff = BufferDiff::compute(&old, &new);
let caps = TerminalCapabilities::detect();
let mut output = Vec::new();
let mut presenter = Presenter::new(&mut output, caps);
presenter.present(&new, &diff)?;
std::io::stdout().write_all(&output)?;
// Next frame will diff against `new`; swap now so old becomes the
// "previous" state for the next tick.
std::mem::swap(&mut old, &mut new);
Ok(())
}Invariants across the pipeline
- Cell is exactly 16 bytes (
#[repr(C, align(16))]+ compile-time assert). Four cells per 64-byte cache line, one 128-bit SIMD comparison per cell. - Dirty-row soundness. Any cell mutation marks its row dirty; the diff is allowed to skip only non-dirty rows.
- Scissor-stack monotonicity. Each
push_scissor(rect)produces an intersection that does not grow; safe nested clipping without per-cell bounds checks. - Presenter state tracking. Cursor, SGR style, and link state are tracked so the emitter suppresses redundant codes.
- One writer owns the stream. Concurrent writes corrupt the ANSI protocol; the runtime enforces single-writer ownership. See one-writer-rule.
Do not mutate a buffer between BufferDiff::compute() and
Presenter::present(). The diff captures a snapshot of changes;
if the underlying cells move out from under it, the presenter emits
bytes the terminal cannot reconcile — corrupt SGR, orphan wide-char
continuations, cursor off the grid. This is the single most common
rendering bug. Compute diff → present → swap; never interleave.
File map
- cell.rs (Cell, GraphemeId, PackedRgba, CellAttrs)
- buffer.rs (Buffer, scissor stack, dirty tracking)
- diff.rs (BufferDiff, ChangeRun, SIMD compare, SAT)
- diff_strategy.rs (Bayesian strategy picker — Beta-Bernoulli)
- presenter.rs (ANSI emitter, cost-model DP, DEC 2026)
- frame.rs (Frame API for widgets, hit grid)
- grapheme_pool.rs (interned strings, GraphemeId)
- link_registry.rs (OSC 8 hyperlink payloads)
- ansi.rs (raw ANSI emission helpers)
Cross-references
- Cell & Buffer — start here for the data model.
- Frame — the widget-facing API.
- Diff — the change-detection engine.
- Presenter — how bytes leave the kernel.
- Screen modes — inline vs. alt-screen integration.
- Bayesian diff strategy — the Beta-Bernoulli posterior that selects full-diff, dirty-row, or redraw.
- One-writer rule — ownership of the output stream.