Architecture
FrankenTUI is organized as four stacked layers: Input, Runtime, Render, and Output. Each layer owns a specific responsibility and depends only on the layer below it. Cross-crate dependencies flow in one direction, and there are no cycles.
This page explains each layer, what it is responsible for, and which crates implement it. If you are new to the codebase, read this page and then the frame pipeline page, which walks the same architecture as a seven-step render cycle.
The layers exist for the same reason kernels have layers: to keep the
surface you can break small. Widget authors never touch raw ANSI. Runtime
authors never parse bracketed paste. Rendering the same Model twice
produces the same byte stream, because every layer between the model and
stdout is a deterministic function of its input.
Mental model
ASCII form, for copy-paste:
┌──────────────────────────────────────────────────────────────────┐
│ Input Layer │
│ TerminalSession (crossterm) → Event (ftui-core) │
│ InputParser → GestureRecognizer → semantic events │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Runtime Loop │
│ Program / Model (ftui-runtime) → Cmd → Subscriptions │
│ update(Message) → (Model', Cmd) │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Render Kernel │
│ Frame → Buffer → BufferDiff → Presenter → ANSI │
│ 16-byte Cell, dirty-row tracking, cost-model presenter │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Output Layer │
│ TerminalWriter (inline or alt-screen) │
│ One-writer rule; DEC 2026 sync brackets │
└──────────────────────────────────────────────────────────────────┘Layer 1 — Input
Crate: ftui-core
The input layer translates raw bytes from the terminal into typed events that the runtime can reason about. It is responsible for three separable jobs:
- Terminal lifecycle via
TerminalSession: raw mode, alt-screen toggling, mouse and focus-reporting enable, and cleanup on drop. - Input parsing via
InputParser(3,200+ lines): CSI / SS3 / DCS / OSC / APC disambiguation, Kitty keyboard protocol, bracketed paste, four mouse encodings (X10, SGR, URXVT, SGR-Pixels), and UTF-8 streaming across partial reads. - Gesture recognition via
GestureRecognizer(2,100+ lines): promoting rawMouseDown/MouseMove/MouseUpintoClick,DoubleClick,TripleClick,DragStart,DragMove,DragEnd, and multi-key chord recognition.
The layer’s output is a stream of typed Event values. Nothing above this
layer sees bytes; nothing below this layer sees semantic gestures.
TerminalSession owns everything about the terminal’s mutable state. On
Drop — including panic — it restores the terminal. If you need to bypass
the session and write directly to stdout, don’t. Route through
TerminalWriter.
See ftui-core reference for the full API surface.
Layer 2 — Runtime
Crate: ftui-runtime
The runtime is an Elm/Bubbletea-style loop over a Model trait. You
implement the trait; the runtime drives it:
pub trait Model: Sized {
type Message: From<Event> + Send + 'static;
fn init(&mut self) -> Cmd<Self::Message>;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
fn view(&self, frame: &mut Frame);
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>>;
}Responsibilities:
- Event pump. Pull
Eventvalues from the input layer, convert them toSelf::MessageviaFrom<Event>, hand them toupdate(). - Effect execution. Run
Cmdvalues returned fromupdate()on a queue-telemetry-backed executor with configurable backpressure. - Subscription management. Start and stop long-running event sources
(timers, FS watchers, process subscriptions) based on what
subscriptions()returns each frame. - Rollout policy. Route the loop through one of three lanes (Legacy, Structured, Asupersync) and optionally shadow-compare before enabling a new lane.
- Telemetry. Emit canonical spans and evidence-ledger entries for every decision point.
The runtime is the only layer allowed to hold a mutable reference to the model. It is also the only layer allowed to initiate a render.
See ftui-runtime reference for the full API surface.
Layer 3 — Render kernel
Crate: ftui-render (with style from ftui-style, text from
ftui-text, layout from ftui-layout, widgets from ftui-widgets)
The render kernel converts a view(&mut Frame) call into an ANSI byte
stream. It is structured as a pure pipeline:
Frame (per-frame drawing context)
└── Buffer (2D grid of 16-byte Cells)
└── BufferDiff::compute(prev, next)
└── Presenter (state-tracked ANSI emission)
└── bytes in a 64 KB bufferResponsibilities:
- Buffer management. A 2D cell grid with row-major layout, scissor stack for clipping, dirty-row bitmap, and dirty-span intervals per row.
- Diff computation.
BufferDiff::compute()produces exactly the set of cells whereold[x,y] ≠ new[x,y], coalesced intoChangeRuns. - Presentation. The
Presentertracks current cursor and style state to avoid redundant escape sequences, and picks between cursor-positioning strategies (CUP vs CHA) per row using a byte-level cost model. - Grapheme pooling. Complex graphemes (emoji, ZWJ sequences) are interned into a pool; inline ASCII never allocates.
- Synchronized output. Every frame is wrapped in DEC 2026 sync brackets where supported, guaranteeing atomic display.
The kernel has zero unsafe code (#![forbid(unsafe_code)] at the
crate root) and zero hidden I/O — the ANSI bytes leave via a return
value, not a write! call.
Layer 4 — Output
Crate: ftui-runtime (the TerminalWriter lives here, coordinating
with TerminalSession from ftui-core)
The output layer enforces the one-writer rule: only one owner of
stdout writes, ever. TerminalWriter serializes bytes from the presenter
and chooses between inline-mode and alt-screen-mode delivery:
- Inline mode. Uses DECSTBM scroll regions, overlay redraw, or a hybrid strategy (selected by capability probe) to keep a UI region stable while log output scrolls above.
- Alt-screen mode. Classic full-screen takeover. Scrollback preserved
on exit because
TerminalSessionleaves the alt screen on drop.
See screen modes — concept (deep-dive) and screen modes — API (reference), plus synchronized output.
Dependency direction
ftui-widgets ──depends-on──▶ ftui-layout, ftui-text, ftui-style, ftui-render
ftui-runtime ──depends-on──▶ ftui-render, ftui-core
ftui-render ──depends-on──▶ ftui-style, ftui-text, (ftui-extras for VFX)
ftui-core ──depends-on──▶ crossterm
ftui-extras ──depends-on──▶ ftui-render, ftui-style (opt-level=3)
ftui-harness ──depends-on──▶ all of the above (test-only)No cycles. The dependency rule is enforced at the Cargo.toml level.
Key invariants
These invariants hold at every layer boundary. Violating one is a bug.
- Cell is exactly 16 bytes. SIMD comparison and cache-line alignment depend on it.
- Buffer dimensions are immutable after construction. Resize creates a new buffer; the old one is dropped.
- Scissor pushes monotonically intersect with the current clip region. You cannot widen the scissor by pushing; only narrow it.
- Only one writer touches stdout, and that writer is
TerminalWriter. TerminalSession::Dropalways restores terminal state — including on panic.- Rendering is a deterministic function of state. Given the same
Model, viewport, and capability set, the ANSI byte stream is bit-for-bit identical.
See design decisions for the why behind each invariant.
Pitfalls
Do not call write!(stdout, ...) or print! from inside view() or
update(). The one-writer rule is real; if you bypass TerminalWriter
you will see corruption on the very next frame when the presenter
tries to reconcile a cursor state it no longer owns.
Do not hold a &mut Buffer across a .await — it isn’t Send, and more
importantly, rendering is synchronous by design. If you need async work,
return a Cmd::perform(...) from update() and handle the result in
a follow-up message.
Do not spawn a thread that writes to stdout directly. If you need a
background effect that appears to write to the UI, model it as a
Subscription that emits messages — update() then calls into the
widget tree, and the next render cycle reflects the change.