Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AbstractTUI Architecture

AbstractTUI is a standalone Rust engine that treats the terminal as a real display device. A layered compositor with damage tracking sits under a fine-grained reactive component model; pixel graphics and software-rasterized 3D are first-class citizens of the same scene, themed by a shared design-token system.

Most terminal UI stacks pick one of two camps: immediate mode, which rebuilds the whole frame every tick and diffs it, or retained widget trees with coarse invalidation. AbstractTUI takes a third architecture. State lives in signals; a write re-runs exactly the computations that depended on it, and those computations damage exactly the screen regions they own. There is no virtual DOM to diff and no full-frame rebuild to pay for. An idle application burns zero CPU; a blinking status cell damages one cell.

Layer map

Every module sits at a fixed layer and depends only on layers below it. The testing module cuts across the whole stack: an in-memory terminal double and a VT100/xterm interpreter let any layer be exercised headlessly against ground truth.

graph BT
    base["base<br/>geometry, color, errors, shared vocabulary"]

    subgraph terminal["terminal layer"]
        term["term<br/>raw mode, capabilities, restore"]
        input["input<br/>byte stream to events"]
    end

    subgraph drawing["drawing layer"]
        render["render<br/>surfaces, compositor, diff, presenter"]
        text["text<br/>measurement, wrapping"]
        anim["anim<br/>clock, tweens, cell shaders"]
    end

    reactive["reactive<br/>signals, memos, effects, scheduler"]
    layout["layout<br/>flex and grid solver"]
    ui["ui<br/>element tree, event routing, focus"]

    subgraph content["content layer"]
        canvas["canvas<br/>sub-cell dot grids, vector strokes"]
        widgets["widgets<br/>built-in library"]
        gfx["gfx<br/>bitmaps, animation, mosaic, pixel protocols"]
        three["three<br/>GLB loading, 3D rasterizer"]
    end

    theme["theme<br/>design tokens, theme registry"]
    app["app<br/>frame loop, overlays, runtime"]
    boot["boot<br/>splash identity"]

    testing["testing<br/>capture terminal, VT model, harnesses"]

    base --> terminal
    terminal --> drawing
    drawing --> reactive
    reactive --> layout
    layout --> ui
    ui --> content
    content --> theme
    theme --> app
    app --> boot
    testing -. exercises every layer .-> terminal
    testing -.-> drawing
    testing -.-> app

The engine is deliberately standalone. Runtime dependencies are limited to libc (unix), windows-sys (windows), unicode-width, unicode-segmentation, and miniz_oxide (PNG inflate). ANSI emission, input parsing, the flexbox solver, the signals runtime, JSON parsing (for glTF), PNG chunking and defiltering, base64, sixel encoding, and the 3D math and rasterizer are all implemented in-crate.

Above the crate sits one more deliberate layer: the sibling-crate extension family (ADR-0004). Genuinely new domains — graph layout + rendering (abstracttui-graph), mermaid diagrams (abstracttui-mermaid) — ship as separate crates in an in-repo cargo workspace (extensions/*), built and tested against core HEAD in CI but installed by downstreams only when needed. Extensions consume the PUBLIC API exclusively (the same “no private engine privileges” rule the built-in widgets live under); a capability an extension needs and cannot reach is, by definition, a feature request against the core crate. They inherit the dependency posture (hand-rolled parsers, std + the family), the token discipline, and the honest-degradation principle; publish order is core first, family the same day. The family guide is graphs-and-diagrams.md.

Pillar 1: fine-grained reactivity

The reactive module implements signals, memos, and effects with ownership scopes, in the SolidJS tradition rather than the React one. Reads are tracked: while a computation runs, every Signal it reads records an edge to it. A write marks direct observers dirty and transitive observers for re-check, then flushes queued effects — immediately after the write, or once at the end of a batch. Each effect pulls its sources up to date before running, so it observes a single consistent world (diamond dependencies cannot glitch). Memos recompute lazily and stop propagation when the new value compares equal.

Ownership scopes tie state to component lifetime: signals, memos, effects, and cleanups created on a Scope die when that scope is disposed, which is what happens when a dynamic view region unmounts. The UI consequence is the important one: components are plain functions that run once to build a view blueprint. Reactivity lives in dyn_view regions that re-run when the signals they read change — a parent never re-renders a child, and there is no tree diff. A changed region marks damage for exactly the cells it owns.

Draw closures are pure over data captured at view-build time; reading a tracked signal inside a draw closure is a debug-mode panic. This is what keeps the frame model (below) airtight: painting cannot create new damage.

Background threads reach this world through the live-data lane (channel_source, latest_source, bounded_source, interval): producers post values, a waker coalesces any burst into one wakeup, and the bound signal is written on the UI thread at the next frame’s update phase — the single-writer rule is preserved by construction. Overflow policies and drop counters keep back-pressure honest. Reconnect rides the same lanes: reactive::connection owns the connection state machine and its jittered retry schedule (Backoff), with worker reports crossing on the posted-jobs lane and retries armed on the timer heap — offline costs zero wakeups until the retry is due. See Live data.

Pillar 2: the compositor

The render module owns everything between “widgets wrote cells” and “bytes reached the terminal”:

  • Z-ordered layers. Each layer is a cell surface with an offset, opacity, a blend mode, an optional color transform, and an optional per-cell shader. Animations translate or fade whole layers without re-rendering their content.
  • Blending. Colors are RGBA. Blend::Normal is source-over; Blend::Additive accumulates light (for glows, particles, scanline highlights). Alpha means transparency while compositing, and “terminal default color” once a frame reaches the presenter.
  • Per-cell shaders transform cells as a pure function of (x, y, t, cell). Shaders are billed by damage: a shader runs only where damage exists, so a static shader is paid once at install and never again. An animated shader is an animation — advancing its clock damages what the shader’s changed_region hint declares (default: the whole layer) and requests the next frame like any tween. The hint contract is stability outside the declared rect, property-tested for every built-in shader.
  • Damage tracking. Every draw records its own damage automatically. Damage may honestly over-approximate: the diff re-checks equality, so stale damage costs microseconds, never wrong pixels.
  • Frame diff and presentation. The flattened frame is diffed against what the terminal currently shows, producing minimal runs. The presenter turns runs into byte-economical ANSI: cursor-motion economy, SGR run minimization, truecolor with 256/16-color downlevel, DEC 2026 synchronized output so frames land atomically, and a scroll-region optimization that detects full-width band shifts (log append, list scroll) and replays them as DECSTBM scroll commands instead of repainted rows. All bytes are buffered and flushed to the terminal exactly once per frame.

All output flows through the presenter — including foreign payloads. Image protocols emit through Presenter::external_write, which flushes pending runs, positions the real cursor, emits the payload, and invalidates cursor and SGR assumptions afterward. Nothing writes to the terminal behind the presenter’s back.

Pillar 3: capability-driven graphics

The gfx module serves bitmaps through the best channel the terminal actually offers, on an explicit quality ladder:

  1. kitty graphics — upload once by id, place and move by escape, true deletion;
  2. iTerm2 (OSC 1337) — full base64-PNG re-emit at the cursor;
  3. sixel — paletted raster at the cursor;
  4. unicode mosaic — colored half-block, quadrant, sextant, or braille glyphs, with optional dithering. This is plain cells, so it works on any terminal and composites like any other content.

Which channel applies is decided by detected capabilities, never by assumption, and every degradation is labeled with a reason rather than applied silently — MosaicMode::auto returns both the chosen mode and why. The three module renders into the same pipeline, so a 3D viewport and a PNG follow identical presentation rules.

The frame lifecycle

The application runtime drives one strictly-sequenced pass per frame on the UI thread:

flowchart LR
    U["USER<br/>drain posted jobs,<br/>dispatch events in one<br/>reactive batch,<br/>flush effects"]
    L["LAYOUT<br/>re-solve dirty<br/>subtrees"]
    D["DRAW<br/>run draw closures for<br/>damaged regions only"]
    C["COMPOSE<br/>flatten layers,<br/>shaders, blending"]
    P["PRESENT<br/>diff to minimal ANSI,<br/>one flush"]
    S["SWAP<br/>frame becomes<br/>previous"]

    U --> L --> D --> C --> P --> S
    S -->|sleep until input, timer,<br/>or a requested frame| U

User code runs only in the USER phase. Input dispatch is wrapped in one reactive batch; effect flush is where dynamic views remount and layout re-solves are requested. From LAYOUT onward no user code runs, therefore no signal writes, therefore no re-entrant damage: the frame’s damage set is sealed when LAYOUT begins. Signal writes from other threads arrive only as posted jobs, and posted jobs run only in the USER phase — a write landing mid-frame wakes the loop and is drained by the next frame. Late damage is never lost and never double-painted, by construction rather than by discipline. One engine-owned addition happens inside DRAW itself: an image pre-pass folds the rects vacated by moved or removed image placements into the frame’s damage (and, where a byte protocol left pixels the cell model cannot see, poisons the previous-frame model so the diff re-emits them) — deterministic driver bookkeeping, not user code, so the seal against re-entrant damage stands.

The cursor follows the same economy. The default is the terminal’s native cursor, parked by the presenter, so a focused-but-idle text field costs nothing. A composited or animated cursor is an animation: it requests frames and is billed as one.

The damage promise

The frame model rolls up into one product guarantee:

An idle AbstractTUI app costs zero: zero bytes written, zero heap allocations, zero shader work.

This is enforced by tests, not stated as an aspiration. In-tree tests pin each clause: an idle frame emits zero bytes (render::present::tests::zero_runs_zero_bytes, and the third frame of render::pipeline_tests::full_pipeline_small_damage_small_bytes), a no-change frame allocates nothing (alloc_budget::presenter_no_change_frame_emits_and_allocates_nothing), steady-state diff and present allocate nothing (alloc_budget::diff_present_steady_state_allocates_nothing), a static shader on an idle layer performs zero shade calls (render::compositor::tests::shader_runs_only_for_damaged_cells_and_never_when_static), and the guarantee holds through the whole app layer with the modern mounts in play — a streaming Feed, an armed interval, a parked Select popup, a parked protocol image — where sixteen idle turns through the real driver allocate nothing and write nothing (alloc_budget::idle_turns_with_feed_interval_parked_popup_and_parked_image_allocate_nothing), and again with the AV surfaces mounted — a settled Meter, a quiet AudioScope, armed key state, and a bound push-to-talk (alloc_budget::idle_turns_with_parked_meter_scope_and_key_state_allocate_nothing).

Idle really means idle: the event loop blocks in a terminal read with zero wakeups until input, a resize, a cross-thread wake, or a timer deadline arrives. Animations never poll — an active animation requests one more frame through the scheduler and simply stops asking when it settles, and each animated layer is billed for exactly the damage it declares.

The active path is budgeted too: diff plus present of a full-change 200x60 frame runs in roughly 450 microseconds median on an M-class laptop, and the steady-state hot path performs no heap allocation. The irreducible byte cost of truecolor styling is the SGR payload itself; 256-color caps are the lever for byte-constrained links.

The terminal layer

The term and input modules are the platform boundary, kept small enough to audit line by line. The posture:

  • Raw mode and session lifecycle. enter switches to raw mode, the alternate screen, and the requested modes; leave undoes everything in exact reverse order. Restore is layered three deep: explicit leave, Drop if you forget, and a process-global term::emergency_restore for panic hooks. Cursor style, window title, pixel-mouse mode, and kitty keyboard flags are all tracked and reset — including from a panic. App::run installs the panic hook before anything else, so a panic in any draw closure or handler still restores the screen.
  • Capability detection is evidence, not folklore. Detection runs in two passes: an instant, conservative environment pass for the first frame, then an active query probe that runs concurrently and can both raise and lower the answer — a terminal that replies “mode not recognized” is believed. Color depth, kitty keyboard and graphics, sixel, synchronized output, cell pixel geometry, and pixel-mouse support are all probed with safe timeouts. NO_COLOR and TERM=dumb are honored.
  • Kitty keyboard protocol. Progressive enhancement flags are pushed on enter and popped on leave. Under the kitty protocol (or xterm’s modifyOtherKeys) the engine decodes press/repeat/release and chords such as Ctrl+Enter or Shift+Enter that are byte-identical to plain Enter on the classic wire. Applications should treat those chords as enhancements; arrows, Home/End, PgUp/PgDn, and F1-F12 with any modifier combination are reliable everywhere.
  • Mouse, including pixel coordinates. SGR mouse tracking delivers cell coordinates always; raw pixel coordinates ride alongside only when pixel reporting is verifiably active. Pixel reporting is a mid-session toggle (applications flip it while a pointer hovers an image), with the same latch-and-restore machinery as the cursor style.
  • Bracketed paste is the only paste path. Paste is fuzz-hardened: multi-megabyte pastes stream in bounded chunks, byte-exactly, with embedded escape sequences neutralized as content. Copy-to-clipboard uses OSC 52, gated on detection; the read form of OSC 52 is deliberately never emitted — it would let any application read the user’s clipboard.
  • Keyboard input is never silently dead. If the platform refuses to poll the terminal descriptor (a real macOS quirk with /dev/tty that the engine detects and avoids), the reader falls back to a working descriptor with a labeled degradation surfaced through startup notices, or fails with an actionable error. An app that starts is an app that receives keys.
  • One event stream that never lies. Keys, mouse, paste, focus, resize, and terminal query replies arrive ordered through one reader. Unknown or hostile escape sequences are swallowed and surfaced as Unknown events — foreign bytes cannot forge keystrokes; the parser never panics on any input and is continuously fuzzed. Resize comes from platform ground truth (never parsed from bytes), is deduplicated, and is re-checked on every wake so a missed signal cannot leave a stale layout.
  • tmux, honestly. Inside tmux, pixel graphics are off by default because tmux swallows the protocols unless passthrough is enabled — which is invisible from the environment. The engine verifies passthrough per session with a wrapped round-trip probe and only then enables the kitty and iTerm2 paths, wrapped automatically. tmux cannot reflow passthrough images across scrolling or pane splits; that limit is cosmetic and stated.
  • Suspend/resume is a first-class verb on unix: full restore, stop the process group, re-enter on resume. On Windows it returns an explicit Unsupported error.

The 3D pipeline

The three module loads binary glTF (GLB) with a validation-first posture: typed accessors are checked against the buffers they index, unsupported features are rejected by name (sparse accessors, Draco and meshopt compression, non-triangle primitive modes), and recoverable gaps degrade with labels (external URIs, normal/metallic-roughness/occlusion maps, morph weights). A two-million-triangle budget is enforced from metadata before any allocation happens.

Rendering is a software perspective rasterizer: near-plane and guard-band clipping, top-left fill rule, z-buffer, perspective-correct depth and UVs, lambert-plus-ambient shading, base-color textures with a box-filter mip chain. Node TRS and matrix hierarchies animate via LINEAR and STEP keyframe tracks; skinned meshes blend up to four joints per vertex. Pose sampling is pure in t and allocation-free at steady state, so playback costs are predictable. Output lands in an RGBA framebuffer that flows through the same image ladder as any bitmap — mosaic cells universally, pixel protocols where the terminal proves them. A model of 20k triangles or fewer renders in under 2 ms at typical viewport sizes; ~120k triangles holds 30 fps with headroom on one core (reproduce with cargo test --release -- --ignored perf_three_envelope --nocapture).

Platform posture

macOS and Linux are the verified platforms. Every unix code path is exercised by a live pseudo-terminal test suite — signal-driven resize, job-control suspend, and keystroke flow under a real controlling terminal — and tmux passthrough has been proven live against tmux 3.7b.

Windows support is best-effort and honestly labeled: the backend compiles cleanly and is lint-clean against the MSVC target, its platform-independent logic (UTF-16 surrogate pairing, wake latching, resize deduplication) is unit-tested on every host, and its console usage was written against Microsoft’s documented semantics — but it has not yet executed on a live Windows machine. Treat the first Windows run as a beta event, not a certified path. The rendering path itself is identical ANSI everywhere (Windows 10+ VT processing), so the platform delta is confined to the terminal layer.

Diff/present correctness is property-tested against the in-crate VT interpreter: the bytes the presenter emits, applied to the previous screen, must reproduce the intended screen — including wide-glyph pairs at scroll boundaries. The input parser is fuzzed with hostile corpora on every build.