abstracttui 0.2.13

A reactive, compositor-grade terminal UI engine: fine-grained signals, layered rendering with damage tracking, images (kitty/iTerm2/sixel/mosaic), software-rasterized 3D (GLB), themes and animation.
Documentation
# 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.

```mermaid
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"]
        widgets["widgets<br/>built-in library"]
        gfx["gfx<br/>bitmaps, 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](adr/0004-extension-packaging.md)).
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 core backlog item. 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](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](live-data.md).

## 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:

```mermaid
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.