Skip to Content
OverviewArchitecture

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:

  1. Terminal lifecycle via TerminalSession: raw mode, alt-screen toggling, mouse and focus-reporting enable, and cleanup on drop.
  2. 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.
  3. Gesture recognition via GestureRecognizer (2,100+ lines): promoting raw MouseDown/MouseMove/MouseUp into Click, 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 Event values from the input layer, convert them to Self::Message via From<Event>, hand them to update().
  • Effect execution. Run Cmd values returned from update() 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 buffer

Responsibilities:

  • 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 where old[x,y] ≠ new[x,y], coalesced into ChangeRuns.
  • Presentation. The Presenter tracks 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.

See ftui-render reference.

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 TerminalSession leaves 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::Drop always 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.

Where next