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
// 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:
CUP— always available, pays for two coordinates.CHA— same row only, pays for one coordinate.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 gapSparse 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.
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
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.
OSC 8 hyperlinks
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 lThe 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
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
- Single writer.
Presenter::new(writer, caps)takes ownership of the writer; concurrent writes corrupt the ANSI stream. See one-writer-rule. - 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).
- No redundant codes. Property tests in the same file pin
present(diff) = present(diff_without_redundant_runs)modulo redundant SGR suppression. - 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
- Diff — where
ChangeRuns come from. - Cell & Buffer — the data the presenter consumes.
- Synchronized output — DEC 2026 details and proof sketch.
- Grapheme pool — how wide / complex cells are looked up for emission.
- Screen modes — API and
screen modes — concept — inline vs.
alt-screen impacts on what the presenter may emit (no
CSI 2Jin inline). - One-writer rule — the governing invariant for the output stream.