Skip to Content
ftui-renderPresenter

Presenter

Presenter is the end of the render pipeline: it takes a Buffer and a BufferDiff, walks the ChangeRuns the diff produced, and writes ANSI bytes to a Write sink. Every decision it makes — which cursor op to use, whether to emit an SGR code, whether to wrap the frame in a sync bracket — is driven by a tracked terminal state it updates in lockstep with each emission.

The presenter’s job is byte economy. A terminal at 60 Hz with 2000 changes per frame is emitting 120 000 operations per second; each redundant SGR code costs bytes on the wire, CPU on the terminal’s parser, and visible time until paint. The presenter aims to emit the cheapest valid sequence — nothing more, nothing less.

This page documents the cost model, the state tracker, the OSC 8 link registry interaction, and the DEC 2026 synchronized-output wrapping that produces flicker-free updates on supported terminals.

Motivation

A naive “emit CUP(y,x) before every cell” works but is wasteful: for a run of 10 contiguous cells in the same row, one CUP followed by the cells is half the bytes of 10 separate CUPs. For a cursor already on the correct row, CHA (column-only) is shorter still. And when the cursor is just a few cells away, CUF (forward) or CUB (back) beats both.

These three options form a tiny optimization problem per run: given the current cursor position, what is the cheapest ANSI sequence to put it at (y, x0)? The presenter encodes this as a byte-count model and picks the minimum.

A parallel problem applies to SGR state: emitting \x1b[1m before every bold cell is wasteful if the previous cell was already bold. The presenter tracks the last emitted SGR state and suppresses redundant codes.

Cost model

crates/ftui-render/src/presenter.rs
// digit_count(n) is 1 for n < 10, 2 for n < 100, etc. fn cup_cost(row: u16, col: u16) -> usize { // CSI (2) + row digits + ';' (1) + col digits + 'H' (1) 4 + digit_count(row.saturating_add(1)) + digit_count(col.saturating_add(1)) } fn cha_cost(col: u16) -> usize { // CSI (2) + col digits + 'G' (1) 3 + digit_count(col.saturating_add(1)) } fn cuf_cost(n: u16) -> usize { /* CSI + n? + 'C' */ } fn cub_cost(n: u16) -> usize { /* CSI + n? + 'D' */ }

For each run, cheapest_move_cost((from_x, from_y), (to_x, to_y)) returns the minimum of:

  1. CUP — always available, pays for two coordinates.
  2. CHA — same row only, pays for one coordinate.
  3. CUF(n) / CUB(n) — same row, short distance.

Sitting on the same row, CHA beats CUP by the digit count of the row plus one byte (the ;), so the presenter always prefers it when valid. When the cursor is within a few columns of the target, CUF / CUB save another byte over CHA. The comment in crates/ftui-render/src/presenter.rs:L140-L156 explains the dominance argument.

Per-row plan: sparse vs merged

A row with two small change runs and a gap between them admits two emission strategies:

change runs: ····XXX······YYYY······· └─┐ └─┐ CUP CUP strategy A: sparse │ │ overwrite overwrite 3 cells 4 cells strategy B: merged CUP └──▶ overwrite 3 + write 6 gap cells + overwrite 4 ↑ content from buffer fills the gap

Sparse costs cup + 3 cells + cup + 4 cells; merged costs cup + 3 cells + 6 gap cells + 4 cells. Which is cheaper depends on the gap size and the number of cells: the presenter solves this per row via a tiny dynamic program (RowPlan, presenter.rs:L180 onward) that considers every prefix-sum boundary and picks the minimum total cost.

cost(plan)=imove_cost(pi1,pi)+i(cells_writteniccell)\text{cost}(\text{plan}) = \sum_i \text{move\_cost}(p_{i-1}, p_i) + \sum_i (\text{cells\_written}_i \cdot c_\text{cell})

The DP is O(runs²) per row, but in practice the run count is small (single digits); the whole planner runs in microseconds.

SGR state tracking

crates/ftui-render/src/presenter.rs
pub struct Presenter<W: Write> { writer: BufWriter<W>, caps: TerminalCapabilities, // Tracked terminal state — never re-emitted if unchanged. current_fg: PackedRgba, current_bg: PackedRgba, current_attrs: CellAttrs, current_link: u16, cursor: (u16, u16), // ... (see src) }

Before emitting a cell, the presenter compares the cell’s (fg, bg, attrs) against the tracked state and emits the delta — if only the foreground changed, only \x1b[38;2;r;g;bm is written, not a full reset. A change from Bold+Italic to just Italic emits the minimal “disable bold” sequence, not a \x1b[0m reset followed by re-applying every other flag.

At the end of the frame, the presenter emits \x1b[0m if any flags are still set, to leave the terminal in a neutral SGR state for any output that follows.

When cell.attrs.link_id() != 0, the presenter looks up the URL in the LinkRegistry and emits an OSC 8 payload:

\x1b]8;;https://example.com\x1b\\<link text>\x1b]8;;\x1b\\

Redundant link state is suppressed the same way SGR is: if the previous cell was already inside link N, and the current cell is also link N, no payload is re-emitted. Link IDs are deduped across the frame — the same URL registered twice yields one OSC 8 payload per run that uses it.

URLs are bounded by MAX_SAFE_HYPERLINK_URL_BYTES = 4096 and sanitized for control characters before emission (presenter.rs:L56-L58). Untrusted URLs cannot inject arbitrary escapes via the OSC 8 wrapper.

DEC 2026 synchronized output

On terminals where caps.use_sync_output() is true — and that’s not any multiplexer, per the capability policy — the presenter wraps the whole frame in:

ESC [ ? 2026 h ... frame bytes ... ESC [ ? 2026 l

The terminal buffers every byte between the brackets and paints the result atomically. Users see a complete frame or the previous frame — never a half-painted intermediate. This is the difference between flicker and glass-smooth updates on iTerm, WezTerm, Kitty, and Ghostty. See synchronized-output for the proof sketch and the compatibility matrix.

Minimal present

examples/present.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; let mut old = Buffer::new(80, 24); let mut new = Buffer::new(80, 24); for (i, ch) in "hello".chars().enumerate() { new.set(i as u16, 0, Cell::from_char(ch)); } let diff = BufferDiff::compute(&old, &new); let caps = TerminalCapabilities::detect(); let mut sink = Vec::<u8>::new(); let mut presenter = Presenter::new(&mut sink, caps); presenter.present(&new, &diff)?; // returns PresentStats (bytes, runs, ...) std::io::stdout().write_all(&sink)?; std::mem::swap(&mut old, &mut new);

Invariants

  1. Single writer. Presenter::new(writer, caps) takes ownership of the writer; concurrent writes corrupt the ANSI stream. See one-writer-rule.
  2. State tracking mirrors emission. Every emitted SGR / link / cursor op updates the tracker atomically; an error mid-emission leaves the tracker and the terminal in sync (any exception path emits a reset).
  3. No redundant codes. Property tests in the same file pin present(diff) = present(diff_without_redundant_runs) modulo redundant SGR suppression.
  4. Sync brackets only when safe. The presenter consults caps.use_sync_output() — the capability gate already disables sync in muxes.

Do not share one Presenter across threads. The state tracker is single-owner by construction: the cursor position, SGR state, and link state are not synchronized. Two concurrent present calls produce interleaved ANSI that no terminal can parse. If you need to render from multiple threads, render into separate buffers, merge them on one thread, and present once.

Output buffering

The writer is wrapped in a 64 KB BufWriter (BUFFER_CAPACITY). A frame’s worth of ANSI is typically well under this — the buffer flushes exactly once at the end of present. This matches the “single write” design principle: the terminal sees one frame as one write(2), which interacts well with sync-output brackets and with PTY edge-triggered polling.

Cross-references

Where next