Skip to Content
ftui-renderOverview

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

End-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 / PTY

The 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

examples/render.rs
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

  1. 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.
  2. Dirty-row soundness. Any cell mutation marks its row dirty; the diff is allowed to skip only non-dirty rows.
  3. Scissor-stack monotonicity. Each push_scissor(rect) produces an intersection that does not grow; safe nested clipping without per-cell bounds checks.
  4. Presenter state tracking. Cursor, SGR style, and link state are tracked so the emitter suppresses redundant codes.
  5. 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