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

Getting started

From install to first pixels, step by step. Every snippet here compiles against the current release; the longer ones are lifted from the crate’s own doctests, which run in CI.

Install

cargo add abstracttui

AbstractTUI targets the Rust 2021 edition and builds on macOS, Linux, and Windows with a deliberately small dependency set (unicode-width, unicode-segmentation, miniz_oxide, plus the platform FFI crate). There is no native library to install and no GPU requirement.

Building against a working copy instead of a release — [patch.crates-io] pointing at a path, which is how a sibling app tracks the engine — has one rule that is not obvious: pin the version requirement to the exact version the checkout carries (abstracttui = "0.6.0", not "0.6"). A patch whose version falls outside your requirement is not an error; cargo ignores it and resolves from crates.io instead, so you build against a published version while believing you build against your tree. The symptom and a check that fails loudly are in troubleshooting.md.

Your first app

use abstracttui::prelude::*;

fn main() -> abstracttui::base::Result<()> {
    App::simple(|cx| {
        let count = cx.signal(0);
        Element::new()
            .style(LayoutStyle::column())
            .child(dyn_view(LayoutStyle::line(1), move || {
                text(format!("count: {}", count.get()))
            }))
            .child(Button::new("+1").on_click(move || count.update(|c| *c += 1)).view(cx))
            .child(text("Tab focuses · Enter clicks · Ctrl+C quits"))
            .build()
    })
}

Line by line:

  • use abstracttui::prelude::*; — one import covers the common path: widgets (Button included), layout vocabulary, signals, hooks, and App itself.
  • App::simple(|cx| ...) — builds the app, mounts your root component, enters the raw terminal, and runs the event loop until quit. The cx parameter is your Scope: the handle you create reactive state with. A panic hook is installed first, so the terminal is restored even if your code panics.
  • let count = cx.signal(0); — a Signal. Signals are small Copy handles, so you can move them into as many closures as you like. .get() is a tracked read; .set(v) and .update(|v| ...) write and notify exactly the computations that read it.
  • Element::new().style(LayoutStyle::column()) — the element tree. Styles describe layout (direction, size, grow, gap, padding); children stack top to bottom in a column.
  • dyn_view(LayoutStyle::line(1), move || ...) — a reactive region. The closure re-runs whenever a signal it reads changes, and only this region’s cells are redrawn. LayoutStyle::line(1) is a full-width, one-row slot.
  • Button::new("+1").on_click(...).view(cx) — a widget builder. .view(cx) resolves the active theme from context and returns a finished View, ready for .child(..). Every themed widget in the prelude supports this shape.
  • text(...) — a static text leaf. It never re-renders because it reads no signals.
  • Defaults you did not write — Tab/Shift+Tab move focus, Enter and Space activate the focused widget, Ctrl+C quits. Override any of them by consuming the event in a handler or shortcut. Nothing is focused until the first Tab or click; the next section shows how to start with the caret already in a field.

Adding interactivity

TextInput binds to a Signal<String> and reports edits through on_change (after every edit) and on_submit (on Enter). Combined with dyn_view, state flows from keystroke to screen with no wiring in between:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

fn form(cx: Scope) -> View {
    let name = cx.signal(String::new());
    let saved = cx.signal(false);

    Element::new()
        .style(LayoutStyle::column().gap(1).padding(Edges::all(1)))
        .child(
            TextInput::new()
                .placeholder("your name")
                .value(name)                          // bind an external signal
                .on_change(move |_| saved.set(false)) // any edit invalidates
                .view(cx),
        )
        .child(Button::new("Save").on_click(move || saved.set(true)).view(cx))
        .child(dyn_view(LayoutStyle::line(1), move || {
            let status = if saved.get() { "saved" } else { "unsaved" };
            text(format!("{} — {}", name.get(), status))
        }))
        .build()
}
}

Run it with App::simple(form). Tab moves between the input and the button; the input handles cursor movement, selection (Shift+arrows), word jumps (Alt+arrows), and paste for you. Mouse clicks focus and activate the same widgets — no extra code.

Start with the caret in the field. A root tree focuses nothing at boot, so the form above waits for a Tab or a click before it accepts typing. Ask for focus at mount with .autofocus(), available on the element form of the focusable widgets:

#![allow(unused)]
fn main() {
let t = use_theme(cx).get().tokens;

TextInput::new()
    .placeholder("your name")
    .value(name)
    .element(cx, &t)   // Element, not View
    .autofocus()       // focused from frame one
    .build()
}

Mark exactly one widget per screen — the one that should own the keyboard. Modal overlays need no such marking: they establish their own initial focus when they open. The API guide has the full rule, including why a bare-letter global shortcut such as q-to-quit is a trap in an app with a text field.

The same one-import surface covers the rest of a form or chat screen: Select/Combobox/MultiSelect for choices (a one-row trigger opening an anchored popup), TextArea + TextAreaState for a multiline composer that grows with its content, and Feed (abstracttui::widgets) with Scroll::follow_tail for streaming transcripts — markdown items speak the full doc vocabulary, GFM tables and task lists included. The API guide walks each one; cargo run --example transcript and --example components show them composed, and --example reader is the document side: tables, in-flow images, a TOC panel, and find-in-document.

Layout basics

Layout is a flexbox-style solver. The vocabulary: LayoutStyle::column() / LayoutStyle::row() set direction, gap(n) spaces children, padding(Edges) insets content, and sizes come from Dimension::Cells(n), Dimension::Percent(f), or grow:

#![allow(unused)]
fn main() {
// A fixed sidebar and a growing main pane.
Element::new()
    .style(LayoutStyle::row().gap(1).padding(Edges::all(1)))
    .child(
        Element::new()
            .style(LayoutStyle::column().width(Dimension::Cells(20)))
            .child(text("sidebar"))
            .build(),
    )
    .child(
        Element::new()
            .style(LayoutStyle::column().grow(1.0)) // takes the remaining width
            .child(text("main"))
            .build(),
    )
    .build()
}

The rule for multi-pane layouts: give every pane that should share leftover space a grow, and fixed panes an explicit size. A pane with neither takes only its content size. LayoutStyle::fill() (fill the parent on both axes) and LayoutStyle::line(n) (full width, n rows) cover the two most common shapes.

grow shares what is left over after every child’s starting size, and that starting size defaults to the child’s content. So a pane whose content can exceed the viewport — a transcript, a log, a long list — asks for all of it before anything is shared. Two consequences follow, and both surprise people: fixed siblings are the ones that pay for the surplus, and grow ratios apply to nothing when there is no leftover, so a 3:1 intent between two content-heavy panes renders 1:1. Pair grow with basis(Cells(0)) on any such pane so it starts at zero, takes only leftover, and lets the ratio own the axis:

#![allow(unused)]
fn main() {
// The growing pane demands no room of its own; the chrome row keeps its 3.
let transcript = LayoutStyle::column().grow(1.0).basis(Dimension::Cells(0));
let composer = LayoutStyle::column().height(Dimension::Cells(3)).shrink(0.0);
}

Scroll already carries that default, but basis describes one element only: a wrapper around a Scroll re-derives its own starting size from its content unless you give the wrapper basis(Cells(0)) too. Put it on the element that sits directly in the pressured column.

For two-dimensional layouts there is a track-based Grid — columns and rows declared as Track::Cells(n), Track::Percent(f), Track::Fr(f), or Track::Auto, with row-major auto-placement and spans:

#![allow(unused)]
fn main() {
// A label column and a growing field column; children fill row by row.
Grid::new(vec![Track::Cells(12), Track::Fr(1.0)], vec![])
    .gap(1)
    .child(text("Name:"))
    .child(TextInput::new().view(cx))
    .child(text("Notes:"))
    .child(TextInput::new().view(cx))
    .view()
}

The grid example (cargo run --example grid) cycles three track recipes over the same children — the fastest way to build intuition.

Theming in 3 lines

#![allow(unused)]
fn main() {
set_theme_by_id("nord");           // switch — every themed region repaints
let theme = use_theme(cx);         // reactive handle to the active theme
let tokens = theme.get().tokens;   // 36 semantic color tokens (bg, text, accent, …)
}

Widgets never name colors; they consume semantic tokens (bg, surface, text, accent, ok/warn/error, chart slots, syntax inks, …), so one set_theme_by_id restyles the entire app. 26 themes ship built in — try cargo run --example themes for a live picker with measured contrast ratios, or set ABSTRACTTUI_THEME=<id> in the environment (the convention every example honors). Custom themes register at runtime through theme::register, which audits contrast floors and either refuses or labels violations, depending on the mode you choose.

Showing an image

Decode once, wrap in an Arc, hand it to the Image widget:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use abstracttui::gfx;

let bytes = std::fs::read("photo.jpg")?;
let bitmap = Arc::new(gfx::decode_image(&bytes)?);
}
#![allow(unused)]
fn main() {
use abstracttui::widgets::ImageFit;

// In your component:
Image::from_bitmap(bitmap.clone())
    .fit(ImageFit::Contain) // largest size that fits, aspect preserved
    .view(cx)
}

gfx::decode_image sniffs the actual bytes (containers lie, bytes don’t) and decodes PNG or JPEG — baseline and progressive alike; unknown formats are rejected by name, never with a panic. The widget renders unicode mosaic — colored sub-cell glyphs that work in any terminal — and picks the best mosaic mode for the terminal’s detected glyph and color support (.mode(MosaicMode::Sextant) pins the densest family when you know the font carries it — see graphics-and-3d.md). Animations work the same way: AnimatedImage::from_path("loading.gif") plays an animated GIF or an APNG. Video files are refused by name — the engine decodes no video codecs. For pixel-perfect output over the kitty, iTerm2, or sixel protocols, gfx::ImageSession manages placement at the app level; the images example (cargo run --example images) shows both paths side by side, naming the channel it chose.

A 3D teaser

#![allow(unused)]
fn main() {
use std::sync::Arc;
use abstracttui::three;

let view = three::quick_view("model.glb")?; // load + framed camera + light
let model = Arc::new(view.model);
}
#![allow(unused)]
fn main() {
// In your component:
Viewport3D::new(model.clone())
    .orbit(0.6, 0.35, 1.0) // yaw, pitch, zoom — drive these from signals
    .view(cx)
}

three::quick_view loads a GLB file with a camera framed on the model’s bounds and a default light. Viewport3D software-rasterizes the scene into cells: drag orbits, the wheel zooms, and the widget reports deltas through .on_orbit/.on_zoom so camera state lives in your signals. Animated, skinned models play through .animate(clip, t). The loader supports binary GLB with triangle meshes, embedded PNG/JPEG textures, LINEAR/STEP animation, and 4-joint skinning; unsupported features are rejected by name or degraded with a label, never guessed at. cargo run --example viewer3d -- path/to/model.glb is the full viewer, with measured fps in the status row.

Plain terminals vs feature-rich terminals

You write one app; the engine adapts it to the terminal it finds. Capabilities (color depth, kitty keyboard/graphics, sixel, synchronized output, pixel geometry) are detected in two passes — an instant environment pass for the first frame, then an active probe that can both raise and lower the answer based on what the terminal actually replies. Everything degrades in the open:

  • Truecolor styling steps down to 256 or 16 colors; NO_COLOR and TERM=dumb are honored.
  • Images step down the protocol ladder to unicode mosaic, which works everywhere. Inside tmux, pixel protocols are enabled only after a live passthrough probe proves they arrive.
  • Key combos like Ctrl+Enter or Shift+Enter exist only on terminals with the kitty protocol or modifyOtherKeys (legacy terminals send bytes identical to plain Enter) — treat them as enhancements, and keep baseline bindings on keys that work everywhere: arrows, Home/End, PgUp/PgDn, F1–F12.

Every degradation is recorded as a labeled startup notice. Read them reactively with the use_startup_notices(cx) hook and render them in a status line or toast — the dashboard example does exactly that, and its --caps flag prints the full capability report without needing a tty.

Testing your app headlessly

No pty needed: drive the same pipeline production uses against a captured terminal, feed input as bytes, and assert on the rendered screen. This is the crate’s own doctest on App:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::app::Driver;
use abstracttui::testing::CaptureTerm;

let size = Size::new(20, 4);
let mut app = App::new(size);
app.mount(|cx| {
    let n = cx.signal(0);
    Element::new()
        .shortcut(KeyChord::plain(Key::Char('+')), move |_| n.update(|v| *v += 1))
        .child(dyn_view(LayoutStyle::line(1), move || text(format!("n = {}", n.get()))))
        .build()
}).unwrap();

let mut term = CaptureTerm::new(size);
let cfg = RunConfig { probe: false, ..RunConfig::default() };
let mut driver = Driver::new(&mut app, &mut term, cfg).unwrap();
driver.turn(&mut app, &mut term).unwrap();          // first frame
assert!(term.screen().to_text().contains("n = 0"));

term.push_input(b"+");                              // a keypress
driver.turn(&mut app, &mut term).unwrap();          // dispatch + repaint
assert!(term.screen().to_text().contains("n = 1"));
}

CaptureTerm records every byte and models the screen; Driver::turn runs one full frame cycle — input dispatch, effects, layout, damage-driven redraw, diff, present. Every focus and damage path is the real one. For pure component tests you can skip the driver entirely: mount into a ui::UiTree, dispatch events, and draw into a ui::BufferCanvas — every widget suite in the crate is written that way.

When a test (or a bug report) needs to show what the screen looked like, capture it as a value and export plain text, replayable ANSI, or an SVG — see the API guide’s “Screenshots & captures” and cargo run --example screenshot, which demos both the key-binding and the headless-test recipe.

Where next

  • Architecture — signals, the damage contract, the compositor, and the render pipeline.
  • API guide — the public surface, module by module.
  • Theming — the token model, custom themes, contrast floors.
  • Graphics and 3D — images end-to-end, the GLB pipeline, the boot splash.
  • Live data — background threads into the UI, bounded and honest.
  • Graphs and diagrams — the extension family: graph auto-layout + GraphView (abstracttui-graph) and the honest mermaid subset (abstracttui-mermaid), installed only when you need them.
  • FAQ and Troubleshooting.
  • Examples catalog — 31 runnable programs (three of them in the extension crates) ordered as a learning path, from the 53-line hello to the full dashboard, with the keys each answers to. For content-heavy apps start with transcript (streaming markdown chat), reader (tables, images, TOC, search), and voice_mock (push-to-talk and live meters, no audio required); for app chrome start with shell (full pages behind a PageHost tab bar plus Drawer panels from both edges).

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.

AbstractTUI API Guide

A guided tour of the public API, module by module. This is not a reference — the item-by-item rustdoc is the reference (cargo doc --open, or browse docs.rs). The goal here is orientation: what each module is for, the types you will actually touch, and the idioms the engine expects. Snippets are lifted from the crate’s compiled doctests wherever possible, so they match the shipped code.

The prelude

use abstracttui::prelude::*; is all an application needs for the common path. The prelude is curated to the app-code surface only: engine and test types (UiTree, Driver, create_root, canvases) stay behind explicit imports. One deliberate absence: render::Style is not exported, because two Style types one glob apart is a trap. Layout style is exported as LayoutStyle (box geometry — direction, size, gap); paint style is spelled render::Style in full, inside draw closures, where it belongs.

reactive — signals, memos, effects

Signal<T> is tracked state, Memo<T> is derived state, and an effect is a computation that re-runs when anything it read changes. Handles are Copy; state is owned by the Scope that created it and dies when that scope is disposed. batch coalesces writes so effects observe one consistent world; untrack reads without subscribing. The model in one compiled example:

#![allow(unused)]
fn main() {
use abstracttui::reactive::{batch, create_root};
use std::{cell::RefCell, rc::Rc};

let log = Rc::new(RefCell::new(Vec::new()));
let (root, ()) = create_root(|cx| {
    let count = cx.signal(0);
    let doubled = cx.memo(move || count.get() * 2);
    let log2 = log.clone();
    cx.effect(move || log2.borrow_mut().push(doubled.get()));
    count.set(3);
    batch(|| {
        count.set(4);
        count.set(5); // coalesced: the effect sees only 10
    });
});
assert_eq!(*log.borrow(), vec![0, 6, 10]);
root.dispose();
}

(create_root is the standalone entry point; inside an app, App::mount hands your component a ready Scope.) Two time-aware helpers round out the module: animate(cx, source, easing, duration) returns a signal following source through eased transitions (settled values cost zero frames), and after(delay, f) runs a one-shot closure on the UI thread, costing zero wakeups until due.

Timers arm AND fire against the loop’s clock, never a stray wall-clock read: inside a driven turn the driver publishes its (injectable) clock, so one injected test clock scripts after/interval deadlines end to end (an after(0) armed under an injected timeline comes due on that timeline, even when real time has raced ahead). Custom loop authors driving run_due_timers(now) publish the same value around their user-code phases with reactive::set_loop_clock(Some(now)) — the driver does this automatically — and None restores real-time arming for bare rigs.

reactive::connection — lifecycle + jittered reconnect

connection(cx, backoff, dial) owns what every networked app hand-rolls around its transport: the state vocabulary, the retry schedule, the armed retry timer, and cancellation. The engine does NO network I/O — dial runs on the UI thread once per attempt, spawns the app’s transport work (spawn_worker plus the HTTP client, socket, or subprocess of your choice — the transport stays the app’s call), and reports through the Clone + Send ConnectionEvents reporter: connected(), degraded(reason), failed(reason), closed() (clean, terminal). Reports apply on the UI thread in the next phase U; reports from a SUPERSEDED attempt (a zombie worker racing the retry that replaced it) or after close are inert and counted (stale_reports, the dead_sends convention). Workers poll is_closed()/is_current() as their stop conditions.

conn.state() is a Signal<ConnState> the UI renders like any other: Connecting, Connected, Degraded(reason), Reconnecting { attempt, next_in } (render “retry #2 in 1.4s” from the fields), Closed — a closed vocabulary by design (transport semantics must not grow into it). conn.close() is the UI-side terminal close; conn.retry_now() skips a pending wait; scope disposal closes, cancels the armed timer, and drops the dial fn.

Backoff is the pure schedule: FULL jitter — uniform in [0, min(cap, base × 2^attempt)] — with defaults base 500 ms, ×2, cap 30 s, reset() on success (the machine calls it on connect), and seeded(n) for deterministic tests. Jitter is not optional politeness: un-jittered fleets retry in lockstep after a server restart (the thundering herd). While reconnecting the loop stays parked — the one armed one-shot costs zero wakeups until due, and a Closed connection costs nothing forever (test-pinned). See live-data.md § “Connection lifecycle” for the state diagram and a worker-thread example.

ui — elements, views, composition

Element is the view-tree builder: layout style, children, focusability, event handlers, keyboard shortcuts, an optional draw closure, and an optional intrinsic measure (.measure(fn(Size) -> Size)) so a draw widget can answer Auto sizing like a text leaf instead of defaulting to zero. Components are plain functions fn(Scope, Props) -> View — no trait, no registry. They run once; reactivity comes from dyn_view(style, f), which re-runs f when the signals it reads change and re-renders only that region. Props structs carry data fields, Callback<T> fields for typed events out, and View fields as slots for children:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::widgets::Button;

struct CardProps {
    title: String,
    on_close: Callback<()>, // typed event out
    children: View,         // slot
}

fn card(cx: Scope, props: CardProps) -> View {
    let close = props.on_close.clone();
    Element::new()
        .style(LayoutStyle::column())
        .child(
            Element::new()
                .style(LayoutStyle::row())
                .child(text(props.title))
                .child(Button::new("x").on_click(move || close.call(())).view(cx))
                .build(),
        )
        .child(props.children) // the slot mounts where the component says
        .build()
}
}

Events route capture → target → bubble with hit testing and focus management; KeyChord shortcuts attach to any element. For app-scale state, the endorsed pattern is a store struct of signals provided as context — cx.provide_context(store) at the root, cx.use_context() anywhere below. Signals are Copy handles, so cloning the store shares state: no prop drilling, no reducer framework.

Focus — who receives keys

Every key and paste goes to the focused node, falling back to the tree root when nothing is focused. A root tree starts with nothing focused: focus arrives when a node asks for it (.autofocus()), when the user Tabs (Tab/Shift+Tab walk the focusables in visual order), when a click lands on one (the nearest focusable ancestor-or-self of the hit target — clicking empty space keeps the keyboard where it is), or when the app sets it (app.tree().focus_first() after mount, or set_focus; programmatic focus crosses focus traps, which constrain Tab cycling only).

An app whose first screen has a text field should say so. Without .autofocus(), the first keystrokes reach the root instead of the field, and the user has to Tab or click into it before typing lands. Widget builders expose the element form for exactly this — .view(cx) is the one-call shape, .element(cx, &t) the same widget as an Element you can still decorate:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::widgets::{TextArea, TextAreaState};

fn composer(cx: Scope) -> View {
    let t = use_theme(cx).get().tokens;
    let state = TextAreaState::new(cx);
    TextArea::new()
        .state(&state)
        .placeholder("Message — Enter sends")
        .rows(1, 4)
        .element(cx, &t)   // Element, not View
        .autofocus()       // focused from frame one
        .build()
}
}

The same .element(cx, &t).autofocus() route works for TextInput, List, and the other focusable widgets. The last .autofocus() mounted wins, so a screen that mounts several marks only the one that should own the caret. An autofocused field paints no placeholder by default — see TextArea for .placeholder_while_focused(true).

Modal trees do this for you. Overlays that own input — Modal, a modal Drawer, ChoicePrompt — establish initial focus when they open: an .autofocus() node wins, otherwise the first focusable, otherwise the panel’s content element. A modal answers keys from frame one with no ritual; the root tree is the one that needs the opt-in.

Resolution order for one key, first to consume it wins:

  1. element handlers along capture → target → bubble;
  2. KeyChord shortcuts registered on the root → focus path;
  3. the built-in Tab traversal;
  4. the global Actions registry.

Focused widgets therefore always beat global bindings — a focused editor consumes ordinary characters, so a bare-letter shortcut such as q-to-quit fires only while focus is elsewhere, including at boot while nothing is focused. In an app that has a text field anywhere, give global verbs a modified chord (Ctrl+Q, Ctrl+C) and let autofocus put the caret in the field.

Double-click

Terminals report only raw press/release — the engine synthesizes multi-click counts (same button, within 400 ms, within 1 cell; wheel, drags, and a different button reset the chain; modifiers don’t break it). Every mouse-Down handler can read the press’s chain position via ctx.click_count(): 1 = isolated press, 2 = a double-click’s second press, 3 = a triple’s third. Both presses deliver normally — nothing is delayed waiting for a second click — so the convention is one if in a press handler: single-click selects, double-click activates (click 1 did the selecting; click 2 additionally commits). Table::on_activate ships exactly that; List’s click-on-selected picker gesture subsumes it without a timer (examples/activate.rs shows both side by side). The recipe for hand-rolled rows (custom cards, graph nodes):

#![allow(unused)]
fn main() {
use abstracttui::ui::{MouseButton, MouseKind, UiEvent};

// inside .on(Phase::Bubble, |ctx, ev| { ... })
if let UiEvent::Mouse(m) = ev {
    if matches!(m.kind, MouseKind::Down(MouseButton::Left)) {
        // select the row under m.pos here (every press), then:
        if ctx.click_count() >= 2 {
            // open/commit the row — guard that BOTH presses hit the
            // same logical row (Table gates on already-selected), or a
            // fast click-walk down adjacent rows would spuriously open.
        }
    }
}
}

Counts flow only where time flows: the driver publishes its (set_clock-injectable) clock as the ambient event time each turn, so apps under App::run get counts for free and tests script double-click timing through the same injected clock animations use — and the same clock is what timer deadlines arm against (reactive::set_loop_clock is the custom-loop half of that one-clock story; see the reactive section). A bare UiTree driven directly has no time source and every press deterministically counts 1 — harnesses opt in with ui::set_event_time(Some(t)). Custom input paths outside tree dispatch can embed their own ui::ClickChain (the pure state machine: observe(now, &event) -> count, configurable window/tolerance) and read ui::event_time() for the engine’s clock.

layout — flex and grid

The layout solver is a flexbox subset over integer cells: Direction row/column, grow/shrink/basis, gap, padding, margin, min/max, percent and absolute positioning, plus wrapping (wrap(), cross_gap). Rounding is largest-remainder, so children tile their container exactly. Display::Grid adds track grids: columns and rows are Track::Cells(n), Track::Percent(f), Track::Auto (content-sized), or Track::Fr(w) (weighted leftover); children auto-place row-major and can span via col_span/row_span. Overflow (Visible/Clip/Scroll) is the clipping and wheel-routing vocabulary: layout itself never clips — solved rects stay truthful — so a child bigger than its parent paints past it under the default Visible. LayoutStyle::clip() truncates children at the content box, and LayoutStyle::scroll() clips and marks the node as the scroll container wheel routing and ensure-visible look for.

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

// Sidebar + growing content in a row.
let sidebar = LayoutStyle::default().width(Dimension::Cells(24));
let content = LayoutStyle::default().grow(1.0);

// A label/field form as a track grid.
let form = LayoutStyle::default().grid(
    vec![Track::Cells(12), Track::Fr(1.0)], // columns
    vec![Track::Auto, Track::Auto],         // rows
);
}

Small terminals & content pressure

The engine guarantees, at any viewport size and any content volume: a child flex crushes to zero area is CLEAN ABSENCE — its draw closure does not run (so a hand-rolled bar can never smear onto a sibling’s row), the collapse is named by a startup notice in debug builds, and the row repaints correctly when the child returns. Modal and Drawer clamp inside the viewport at open AND re-clamp on every resize; tab strips window with overflow indicators; wide glyphs never tear at a truncation or clip edge. Two recipes remain the app’s job: give chrome you want incompressible shrink(0.0) (or wrap the oversized middle in a Scroll, whose default basis(0) exerts no pressure), and render use_startup_notices somewhere visible — the engine names every zero-collapse into that lane, and a notice nobody renders is a debugging session someone else pays for.

shrink(0.0) describes ONE element among its own siblings. It does not reserve room in an ancestor, and no flexbox implementation makes that promise: a shrinkable container above your incompressible row can still be solved shorter than the row, and the surplus is then painted outside the container, where a later sibling’s paint lands on top of it. Apply shrink(0.0) to every chrome row in the pressured column — the outer row as well as the widget inside it — and give the growing pane beside them basis(Cells(0)) so it exerts no pressure in the first place. Debug builds name a column whose children need more rows than it has, in the same notices lane as the zero-collapse notice; clip() on the container turns the surplus into honest truncation instead.

widgets — the built-in library

Every widget is built from the same public ui + layout + theme surface user code has — widgets hold no engine privileges. They consume design tokens only, never raw colors; the canonical build is .view(cx) (theme from context), with an element form for explicit tokens — stateless widgets take just &TokenSet, no Scope. One honest exception: Meter and AudioScope are view(cx)-only (signal-driven — their tokens resolve TRACKED inside their own reactive region, so a fixed-token element form would need a wrapper node just to change the token source; the deliberate absence is documented in their rustdoc). Controlled-mode bindings are named after the STATE they bind: value (Select/Combobox), selection (List/Table), active (PageHost), folded (Disclosure), offset_x/offset_y (Scroll) — new widgets follow the state-name rule; the one historical outlier is Drawer::bind(Signal<bool>). The catalog:

  • Block — the bordered panel primitive: title, fill, focus ring, BorderKind, and an opt-in close affordance (on_close — a mouse-only ✕ on the title row; see the Block section below).
  • Button — clickable label; hover/pressed/focused/disabled visuals; Enter/Space or mouse fires on_click.
  • TextInput — single-line editor: grapheme-cluster-atomic cursoring, selection, word jumps, on_change/on_submit; .masked(true) for secret fields (bullets on screen AND in the accessibility export).
  • TextArea — multiline composer: soft wrap, vertical caret with goal column, grow-to-content between rows(min, max), submit-vs-newline policy, history recall, block paste, wheel scrolling (the window moves, the caret stays; any edit re-attaches), and a caret-cell anchor for completion dropdowns (TextAreaState is the app wire).
  • List — virtualized selectable list; variable-height items, sticky selection by key, scroll_to, bindable selection/offset_y, hover ink. Removable rows in one call (on_remove), or the general trailing accessory column (row_accessory, on_accessory_click), styled body labels (rich_items), timed double-click on the body (on_row_double_click), and non-selecting right-click/Shift+F10 row context requests (on_context_menu) that pair with ContextMenu. Vocabulary: on_select = selection changed (fires on movement); on_activate = the user committed this row (Enter/Space/click-on-selected); on_row_double_click = Table-style timed double-click when bound.
  • RowSelect — List’s keyboard over rows the engine does NOT render: wrap a Scroll of arbitrary (multi-line) Elements and it gains arrows, Home/End, Page keys, click-to-select, Enter/Space activation, ensure-visible by CONTENT rows, and sticky selection by key. The rows stay yours; the selection core is literally List’s (see its own section).
  • Feed — virtualized, append-only, keyed rich items (markdown in the full doc vocabulary — tables, lazy in-flow images, task lists — plus plain text, code fences, custom draws): the chat/log/transcript surface. Appends are O(1); a streaming tail item re-typesets only its open region (a streamed table renders as a table live); 10k items draw one screenful. A content-sized feed carries an intrinsic measure, so Scroll::new(Feed::new(&state).view(cx)) scrolls the true extent from the first frame.
  • Table — fixed/percent/flex columns, styled header, virtualized rows, selection, hover ink, sort-indicator hook (the app sorts). Vocabulary: on_select = selection changed (fires on movement); on_activate = the user committed this row (Enter/Space/double-click — a single click only selects; see the Table section below).
  • Tabs — tab bar over lazily mounted panels; only the active panel is mounted.
  • PageHost — the page-level tab host: N FULL pages behind one themed tab bar, exactly one mounted (see its own section below).
  • DrawerDock — the right-edge drawer rail: always-visible semantic keyboard tabs rendered portably as stacked graphemes, each fronting a docked side panel — at most one open, fully collapsed to the bare rail otherwise, with reactive badge dots (see its own section below).
  • Disclosure — the fold/unfold card: a one-row title header (glyph + truncating title + muted detail slot) that expands a body in place. Click or Enter/Space toggles; max_body_rows caps the body behind a scrollbar; state is widget-internal (initially_folded) or app-owned (folded(Signal<bool>)).
  • FilePicker — directory browser for modals and attach flows: breadcrumb header, type-to-filter input, entry rows with kind glyphs and an optional size column, opt-in multi-select, on_pick(Vec<String>); entries come from the pluggable FileSource seam (see the file-attachments section below).
  • Scroll — clipped viewport over oversized content, mounted once so state, focus, and hit testing survive scrolling. The content extent is measured by the layout solver (content_size is an optional override) and can be read back through extent_signal; follow_tail binds the pinned-to-bottom idiom; scrollbar_auto_hide hides the bar while content fits, and scrollbar_width widens its reserved gutter.
  • Checkbox — [x] label bound to a Signal<bool>.
  • RadioGroup — one-of-N bound to a Signal<usize>; one tab stop, Up/Down move the selection.
  • Progress — bar with sub-cell precision; optional ok→warn→error ramp.
  • Spinner — indeterminate activity glyph, pure over a caller-owned frame index.
  • Badge — small tinted label for status chips, counts, tags (Tone).
  • Separator — horizontal or vertical rule, optionally labeled.
  • Charts — Sparkline, LineChart, BarChart on sub-cell grids, with optional relative time axes fed from a TimeSeries history ring (see the history-rings section below).
  • Grid — container widget over Display::Grid; spans ride each child’s own style.
  • Image — bitmap display through the mosaic pipeline (ImageFit; Bitmap re-exported beside it). Measures as its native cell footprint, so it holds real space in Auto-sized rows/panels.
  • Viewport3D — orbiting 3D view of a three::Model: .orbit(yaw, pitch, zoom), .animate(clip, t), .on_orbit/.on_zoom deltas; camera state lives app-side in signals. Grows into its region by default (a draw widget has no intrinsic size to grow from, so the default layout claims the available space).
  • MarkdownView / RichTextView / CodeView — typeset markdown (doc vocabulary: GFM tables with the crush-honesty ladder, lazy in-flow images, task lists, plus outline/anchor rows and find-with-highlights — see the reader-surface section below), wrapped styled spans, read-only highlighted code (C-like, diff, json/yaml lexers). Both content views carry an intrinsic measure: Scroll::new(view) scrolls the true extent out of the box, and content-sized panels hug the document instead of collapsing it to zero. Feed answers the same way, so every content widget reports its real height during the layout solve rather than after the first paint. Wrapping in Scroll is also what gives a document the WHEEL, PgUp/PgDn and a draggable thumb — the scroll_offset(rows) setter is for apps that genuinely own the offset, and gets keyboard scrolling only.
  • widgets::FenceBlock — a fenced code block rendered as something other than code, INSIDE the document. MarkdownView::fence_block(claimant) installs it; the claimant measures the fences it recognizes and paints their rows, so a diagram lives in the document’s own scroll surface, outline and search index instead of forcing the app to splice widgets between document fragments. Fences it declines render as code, unchanged. The engine ships no diagram languages — abstracttui-mermaid’s MermaidFence is the reference claimant (see graphs-and-diagrams.md).
  • Meter / AudioScope — live level rendering: dB meter with real ballistics (instant attack, timed decay, peak hold) and a rolling braille waveform — see the live-levels section below.
  • Logo — the AbstractTUI wordmark for headers, about screens, empty states.

Block — the close affordance (on_close)

Panels sometimes need to be closed to free their space for the siblings. Block::on_close(f) opts a panel in: a muted ✕ renders at the title row’s right end (inside the border corner), hover tints it error (closing is a consequence-bearing action, so it does not take the neutral accent hover), press adds BOLD, and a click runs f. Whether a panel is closable is the APP’s decision, per panel, per build:

#![allow(unused)]
fn main() {
use abstracttui::widgets::Block;

// The whole close story: `on_close` writes app state, the layout dyn
// re-renders without the pane, the survivors re-flex into the space.
fn pane(t: &abstracttui::theme::TokenSet, hidden: abstracttui::reactive::Signal<bool>)
    -> abstracttui::ui::Element
{
    Block::new()
        .title("Discussion")
        .on_close(move || hidden.set(true))
        .element(t)
}
}

The rules, all deliberate:

  • Mouse-only, never focusable. A focusable ✕ would become the panel’s first tab stop and steal initial focus from the content. Keyboard close stays APP-side — bind whatever key the app means (a v toggle, a drawer’s Esc) to the same state the callback writes.
  • Click = press + release inside the ✕ run (the Button convention): press-and-drag-out cancels; the release deciding cell is the run the frame shows. Clicking the border corner glyph, the title, or the body never closes.
  • Truncation order (test-pinned): the TITLE yields first — it truncates and then disappears entirely before the ✕ gives up its padded 3-cell run (✕); under more pressure the pads drop (bare ✕ at the last interior cell); at 1–2 total columns the ✕ yields too — corners only, nothing ever paints on or outside the frame, and a zero-area-crushed block paints (and hits) nothing at all.
  • on_close may remove the panel synchronously — disposing the block’s scope from inside the callback is the normal usage (the engine-wide disposal law; widget bookkeeping lands first). After the panel dies its callback can never re-fire; a second click at the same screen cell acts on whatever LIVE panel re-flexed under it (browser-tab close-spam semantics).
  • A11y: the affordance reports as a button labeled Close {title} (or Close panel) in the accessibility tree; it never joins the tab order.
  • Borderless blocks (BorderKind::None) have no chrome row, so the ✕ floats over the top-right content cells — the app opted into both; wrap in a bordered block when that reads wrong.

Composition is unrestricted: the affordance works inside PageHost pages, Drawer overlays (screen-space clicks translate to the layer), and Scrolled columns mid-scroll — all test-pinned — and parked closable blocks cost zero idle bytes.

Code, diffs and data — lexers and their theme mappings

CodeView tints through the pluggable text::Highlighter seam (byte ranges + TokenKind; the built-in CLikeLexer is honest demo-grade), and widgets::code_token_color is the ONE place token kinds become theme inks. Diffs are line-oriented, not token-oriented, so they ride a dedicated additive vocabulary: text::DiffLexer classifies each line (DiffKind: added, removed, hunk header, file header, meta chrome, context — #[non_exhaustive], so downstream matches carry a _ arm rendering unknown kinds as body text), and widgets::diff_token_color maps it onto the SEMANTIC inks — added ok, removed error, hunk headers info, chrome text_muted — readable on the surface_raised code ground in every built-in theme (measured, test-pinned).

Structured data follows the same shape (wave 13): JSON and YAML live on the KEY-vs-VALUE distinction, which the C-like vocabulary cannot express, so text::JsonLexer/text::YamlLexer emit the dedicated DataKind vocabulary (Key, String, Number, Literal, Comment, Punct, Tag — #[non_exhaustive] like DiffKind), and widgets::data_token_color maps it in one place: keys syntax_func, strings syntax_string, numbers syntax_number, true/false/null (plus the YAML 1.1 bools and ~) syntax_keyword, comments syntax_comment, punctuation syntax_punct, YAML anchors/aliases/tags and document markers syntax_type. Bare YAML scalars stay body ink — prose is prose. Key detection is the space-after-colon rule in YAML (10:30:00 stays a plain scalar) and the plain-colon rule in JSON (minified {"a":1} tints like pretty-printed); JSONC/JSON5 comments tint so config dialects render instead of degrading.

Routing is by language label, best effort: CodeView::lang("diff") (also "patch"/"udiff"), lang("json") (also "jsonc"/"json5"/"jsonl"/"ndjson"), lang("yaml") (also "yml"); "rust"/"c" pick C-like presets; unknown labels change nothing. Markdown/Feed code fences labeled ```diff, ```json or ```yaml route automatically — one shared recipe per vocabulary, so a fence and a CodeView can never tint the same text differently:

#![allow(unused)]
fn main() {
use abstracttui::widgets::CodeView;

fn patch_pane(patch: &str, t: &abstracttui::theme::TokenSet) -> abstracttui::ui::Element {
    CodeView::new(patch).lang("diff").element(t)
}

fn payload_pane(body: &str, t: &abstracttui::theme::TokenSet) -> abstracttui::ui::Element {
    CodeView::new(body).lang("json").element(t)
}
}

Classification is stateless per line (scroll-position-invariant by design) and approximate by contract: a removed line whose content begins -- reads as a file header (the classic highlighter resolution), prose between hunks stays untinted, YAML multi-line quoted scalars mis-tint from the second line on, and block-scalar bodies render untinted (which for prose bodies is the right look anyway).

List — selection vs activation

Selection FOLLOWS MOVEMENT: arrows/Home/End/Page keys and clicks move the highlight, and on_select is the selection-changed notification — never wire commitment, navigation, or destruction to it. Activation is the EXPLICIT “user chose this row” event: on_activate fires on Enter (always), on Space (a List has no toggle meaning), and on a click on the already-selected row; a click on an unselected row only selects.

Picker vs browsing gestures. By default, double-clicks work by subsumption — click 1 selects, click 2 lands on the now-selected row and activates, with no timing requirement (the picker gesture is deliberately broader than Table’s timed double-click). For chat sidebars and other browsing surfaces that need strict SGR double-click (open-on-double, slow re-click only re-selects), bind List::on_row_double_click instead — it fires when EventCtx::click_count() >= 2 on the row body and supersedes on_activate for that press (same convention as Table).

Removable rows. List::on_remove is the one-call dismiss affordance: it draws the trailing ✕, routes the click, and re-settles selection on the rebuild. Remove the index from your own data and let your Dyn rebuild — the List never leaves selection naming a row that no longer exists. With List::key_fn + List::selection_key bound the selected item is re-found by key; when the selected row was the one removed, selection falls to the same slot clamped into the shorter list (the next row down, or the new last row).

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

fn channels(cx: Scope, rows: Signal<Vec<String>>) -> View {
    dyn_view(LayoutStyle::default().grow(1.0), move || {
        List::of(rows.get())
            .on_remove(move |i| rows.update(|v| { v.remove(i); }))
            .view(cx)
    })
}
}

Row accessories. List::row_accessory

  • List::on_accessory_click are the general form — a trailing column holding a badge, an unread count, or a per-row action. The engine owns body/accessory/scrollbar column widths. The accessory column is one click target edge to edge on the row that draws it; rows whose row_accessory returns None leave that column as ordinary body. Accessory clicks never change selection; scrollbar-column presses belong to the bar (see The scrollbar, which every scrolling widget shares). Styled body labels ride List::rich_items (same length as items; accessories stay plain text).

Row context actions. List::on_context_menu reports a secondary-button press over an item as a ListContext. The event carries the row index, the pointer cell, and the visible row rect in screen coordinates, so the same code works inside a root view, modal, or drawer. Shift+F10 invokes the callback for the selected row and first scrolls it into view.

The gesture is deliberately separate from selection and activation: right-clicking an unselected row does not call on_select, on_activate, or an accessory callback. Use the reported index to resolve a stable item id, then build a ContextMenu for that item:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use std::rc::Rc;

fn seats(cx: Scope, names: Vec<String>, ids: Vec<String>) -> View {
    let ids = Rc::new(ids);
    List::new(names)
        .on_context_menu(move |event| {
            let Some(seat_id) = ids.get(event.index).cloned() else { return };
            ContextMenu::new([
                ContextMenuItem::new("promote", "Promote"),
                ContextMenuItem::new("demote", "Demote").disabled(false),
                ContextMenuItem::new("mission", "Assign mission").hint("A"),
            ])
            .access_label(format!("{seat_id} actions"))
            .on_action(move |action| apply_seat_action(&seat_id, action))
            .open(cx, event.screen_position);
        })
        .view(cx)
}
}

ContextMenu is an owned popup: Up/Down/Home/End/Page keys move the highlight while skipping disabled actions; Enter or Space commits; a left press commits a row; Escape, an outside press, anchor disposal, or resize dismisses without running an action. The popup closes before on_action runs, so the callback may dispose the opener or open a replacement safely. Empty and fully disabled menus do not open.

Hover ink. Ink marks the hot ROW, bold marks the hot ZONE: the row under the pointer takes accent ink, and whichever of body/accessory the pointer is actually in adds bold. Moving from a row’s text onto its ✕ therefore keeps the row lit. A hot [List::on_remove] dismiss draws in error — dismissal is consequence-bearing, the same ruling the Block close affordance follows — while a plain row_accessory badge draws in accent, because painting an unread count red would misreport it. Selected rows keep their audited selection pair and take bold only, since the hover inks are not contrast-audited against selection_bg. Rows built with rich_items take the hover ink as their BASE ink, so spans that set their own color keep it.

Hover and clicks resolve through the same hit test, so the row that lights up is the row a press acts on. Motion with no button held is not reported by default — set RunConfig::hover_ink (via App::run_with) in apps that want it.

Viewport and filtering. Bind List::offset_y whenever the items change under a Dyn: the internal offset is re-minted by a rebuild, so without it a dismissal scrolls the reader back to the top. Bind List::selection for the same reason.

A List reports callback indices positionally in the rows it was given. When you hand it a filtered or sorted view, index i is a position in that view, not in your backing collection — carry the backing index alongside and map through it before mutating:

#![allow(unused)]
fn main() {
let shown: Vec<(usize, String)> = all.iter().cloned().enumerate()
    .filter(|(_, c)| c.contains(query.as_str()))
    .collect();
let back: Vec<usize> = shown.iter().map(|(i, _)| *i).collect();
List::of(shown.iter().map(|(_, c)| c.as_str()))
    .on_remove(move |row| {
        let Some(&real) = back.get(row) else { return };
        data.update(|v| { v.remove(real); });
    })
}

Both callbacks run after the List’s own bookkeeping (selection write, ensure-visible), so an on_activate may close the surrounding modal — disposing the List’s scope synchronously is safe. When on_activate is unbound, Enter and Space pass through to your shortcuts unchanged:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

fn theme_picker(cx: Scope, apply_and_close: impl FnMut(usize) + 'static) -> View {
    List::of(["dark", "light", "solarized"])
        .on_activate(apply_and_close) // Enter / Space / click-on-selected
        .view(cx) // browsing with arrows only moves the highlight
}
}

Presence-board pattern (rich rows, timed double-click, trailing action):

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::render::rich::{RichLine, RichText, Span};

List::new(names)
    .rich_items(rich_labels)
    .accessory_width(3)
    .row_accessory(|i, _| Some(format!("×{}", unread[i])))
    .on_row_double_click(|i| open_dm(i))      // body: timed double-click
    .on_accessory_click(|i| open_moderation(i)) // trailing column only
    .view(cx)
}

See cargo run --example presence_board and docs/faq.md § “scrollable rich list”.

RowSelect — keyboard selection over rows you render

List renders its own rows and they are one line each: an item’s extra rows reserve SPACE, and wrapped multi-row item CONTENT is not planned. A row that is genuinely two lines — a name above a mission, a title above a path — has to be your own tree in a Scroll, and that used to cost the keyboard.

RowSelect is the keyboard without the rendering. It WRAPS your content and drives the same selection core List drives, so the two cannot drift apart.

let sel = cx.signal(0usize);          // your row builder reads this
let sel_key = cx.signal(String::new());
let offset = cx.signal(0i32);         // SHARED with the Scroll

let scroll = Scroll::new(my_two_line_rows(members.clone(), sel))
    .offset_y(offset)
    .view(cx);

RowSelect::new(members.iter().map(|m| m.id))  // one stable key per row
    .row_heights(|_| 2)                       // rows in CELL ROWS
    .selection(sel)
    .selection_key(sel_key)                   // sticky across mutations
    .offset_y(offset)
    .on_activate(move |i| open(i))
    .wrap(cx, scroll)
    .build()

What it assumes. Row i occupies content rows [prefix[i], prefix[i+1]) from row_heights (default 1) — what a column of fixed-height children inside a Scroll lays out. Both ensure-visible and click hit-testing key off it, so declare the height you actually gave the row.

What it takes over. Navigation keys are claimed in the CAPTURE phase, before the content sees them, because the Scroll inside would otherwise scroll on the same arrows. Unmodified Up/Down/PageUp/PageDown/Home/End, and Enter/Space when on_activate is bound, belong to the RowSelect for its whole subtree — do not put a text editor inside a selectable row. Modified chords pass through untouched, and the wheel and the scrollbar stay the Scroll’s.

Tab stops. The wrapper is focusable by default so the keyboard reaches it even when nothing inside can hold focus. A Scroll is also focusable, so the canonical composition has two tab stops that behave identically. focusable(false) gives you exactly one when the content already carries it.

Sticky selection is the half worth testing. With selection_key bound, a rebuild re-finds the key’s CURRENT index — a mutation that moves the selected row keeps that row selected. When the key is gone (the row was removed) the SLOT is held, clamped: the next row down.

See cargo run --example roster — press m and watch the index move while the key does not.

Table — selection vs activation

Same split, browsing-surface edition: on_select notifies selection movement (arrows/Page/Home/End/click); on_activate is “open this row” — it fires on Enter (always), on Space (a single-select table has no toggle meaning; a future multi-select mode will claim Space as toggle within that mode), and on double-click: the second press of a click chain landing on the already-selected row. Deliberately unlike List, a slow second click on the selected row does NOT activate — re-clicking a row to focus the pane must never open its editor. Both presses of a double-click deliver normally (click 1 selects, click 2 activates; selection is never suppressed), a chained press that drifted onto a NEIGHBOR row only re-selects (fast click-walking down rows is browsing, not commitment), and a wheel between clicks resets the chain (the content under the cell moved). When on_activate is unbound, Enter and Space pass through to your shortcuts — same contract as List (and the gateway-console rule: a screen-level key must never be claimed by a widget with no consumer):

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::widgets::{ColWidth, Column};

fn providers(cx: Scope, open_editor: impl FnMut(usize) + 'static) -> View {
    Table::new(vec![
        Column::new("provider", ColWidth::Flex(1.0)),
        Column::new("status", ColWidth::Cells(12)),
    ])
    .rows(vec![/* ... */])
    .on_activate(open_editor) // Enter / Space / double-click
    .view(cx) // single-click only selects
}
}

TextInput — masked (secret) fields

.masked(true) renders one • per grapheme cluster (a ZWJ emoji family is one bullet; each bullet occupies its cluster’s width, so scroll and cursor geometry match the unmasked field) and exports the same bullets through access_value — the accessibility snapshot is shipped off-process by automation consumers, so a masked field never leaks plaintext through the semantic tree either. Editing, selection, cursor math, and paste are untouched; the bound value signal holds the real text. One deliberate exception: Alt+arrow word jumps treat the whole masked value as a single word (start/end, like Home/End, Shift-extension included) — true word boundaries would reveal the secret’s word count and word lengths through caret motion. For a reveal toggle, rebuild the field with masked(false) inside a dyn_view_scoped over your reveal signal.

Feed — streaming transcripts

An app owns a cloneable FeedState handle and mutates it; the Feed widget windows over it. Items are keyed identities (push with a known key replaces); a streaming item rides md::DocStreamSession, so a token append costs one open region, never the document. Markdown items speak the full DOC vocabulary: an agent answer streaming a GFM table renders as a TABLE live (the whole in-flight table is the open region until its first non-pipe line seals it), task lists wear checkboxes, ~~strikethrough~~ strikes, and ![alt](path) images typeset from a header-only probe (decode happens lazily when an image row first draws — items measure and window without decoding). clear() rebuilds bounded windows.

A content-sized feed (one you give no explicit layout) typesets at the width the layout solver offers, so it reports its real height on the first frame and a surrounding Scroll measures the true extent immediately. An empty feed occupies no rows. total_rows() is the reactive content extent for chrome such as “N more rows”; it is published one turn after the solve, so drive layout from the widget and read this signal for display:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::widgets::{Feed, FeedItem, FeedState};

fn transcript(cx: Scope) -> View {
    let feed = FeedState::new(cx);
    feed.push("q1", FeedItem::markdown("**you** — hello"));
    feed.push_stream("a1"); // a live answer…
    feed.stream_append("a1", "# Str"); // …fed token by token
    feed.stream_append("a1", "eaming");

    let follow = cx.signal(true); // render it: "following / scrolled"
    Scroll::new(Feed::new(&feed).view(cx))
        .follow_tail(follow)
        .view(cx)
}
}

Feed — rich lines (multi-ink without a custom block)

FeedItem::rich / rich_lines / .rich_block carry the engine’s span model (render::RichText) into feed items: a severity-tinted log line or a chat header is spans, not a FeedBlock::Custom draw closure with hand-rolled wrapping. Rich blocks typeset through the same span-preserving wrap and row walk as every other block (cell-exact parity with RichTextView, test-pinned), so wrapping, windowing and damage behave exactly like Text. Span styles are patches: fg: None spans inherit the item’s theme ink; explicit inks are resolved Rgba and render verbatim (rebuild items to retint on theme switch). Rich items are replace-on-update; token streaming stays push_stream:

#![allow(unused)]
fn main() {
use abstracttui::render::rich::{RichLine, Span};
use abstracttui::render::Style;
use abstracttui::widgets::FeedItem;

fn log_line(t: &abstracttui::theme::TokenSet, ts: &str, body: &str) -> FeedItem {
    FeedItem::rich_lines(vec![RichLine::from_spans(vec![
        Span::new("ERROR ", Style::new().fg(t.error)),
        Span::new(format!("{ts} "), Style::new().fg(t.text_muted)),
        Span::plain(body), // fg-less: wears the item ink per theme
    ])])
}
}

(The public FeedBlock enum stays exhaustive through 0.2.x, so the rich kind rides FeedItem constructors; FeedBlock::Rich proper is budgeted for 0.3.)

Feed — syncing from a Signal<Vec<T>>

When the transcript’s source of truth is a FOLD (a vector recomputed by events) rather than an append-only stream, FeedState::sync owns the diff: keys are identities, fingerprints detect change, and the optional visibility closure is the one truth for filtering. Appends at the tail take the O(1) push path, changed fingerprints update in place, and anything violating push order — shrink, reorder, mid-list insert or visibility flip — takes the rebuild path inside the engine. A rebuild re-renders every visible item, so a source that reorders on every drain rebuilds on every drain — for feeds ordered by mutable rank, sync a stable order and sort at render time, or accept O(visible) per change. Float fingerprints must compare by bits (f32::to_bits) — NaN never equals itself and re-renders the item every drain. A synced feed has ONE writer (the bridge); foreign writes are not silent, though: the bridge detects them (a mutation counter) and self-heals at the next drain with a full rebuild — stray items evicted, order restored to source order. Render/key closures run on change, never per frame:

#![allow(unused)]
fn main() {
use abstracttui::widgets::{FeedItem, FeedState, SyncSpec};

struct Msg { id: String, rev: u64, hidden: bool, text: String }

fn wire(cx: abstracttui::reactive::Scope, feed: &FeedState,
        items: abstracttui::reactive::Signal<Vec<Msg>>) {
    feed.sync(cx, items, SyncSpec::new(
        |m: &Msg| m.id.clone(),           // identity
        |m| m.rev,                         // cheap change fingerprint
        |m| FeedItem::markdown(&m.text),   // pixels, built on change
    ).visible(|m| !m.hidden));
}
}

When the items live INSIDE a larger reactive shape — one field of a Signal<Fold> whose stats mutate under the same signal, or a focus-selected convo’s nested vec — FeedState::sync_with(cx, move |read| fold.with(|f| read(&f.items)), spec) is the same bridge behind a borrow-based source: the closure hands the current items over in place (zero copies), every signal it reads becomes a dependency of the sync effect, and a stats-only write re-runs the drain but the fingerprint walk renders nothing; sync itself delegates here.

Feed — selection by key

Feed::selected_key(sig) binds a Signal<Option<String>>: the selected item’s row band grounds in the theme’s selection_bg while item inks stay (a transcript keeps its severity/syntax colors). Selection is app-driven state — the app writes the signal and can pair it with FeedState::row_of(key) (the item’s first content row) to drive a wrapping Scroll’s offset to the selected item. Unknown keys highlight nothing.

Feed — capped preview blocks (max_rows)

Transcript previews cap their bodies: FeedItem::max_rows(n) bounds the most recently appended block at n typeset rows TOTAL, applied post-typeset at the width the engine typesets at (the row count only exists after the wrap — a consumer cannot precompute it). Content that occupies at most n rows renders unchanged; overflow shows the first n - 1 rows and spends the last row on an honest marker — “… (+K more lines)” in text_muted, where K is the hidden row count at the current width (it changes on resize). FeedItem::overflow_marker(|k| ...) overrides the wording. Extent and windowing count the marker row, so a capped block is never taller than n; chain per block (.block(a).max_rows(3).block(b).max_rows(8)); streaming items are unaffected (caps live on static blocks).

Every row-based block kind caps — Text, Rich, Markdown and Code. Markdown and Code cap RENDERED rows, not source lines: a six-paragraph body is eleven rows (five separators), and max_rows(4) shows three of them under a “(+8 more lines)” marker. That is the only count a reader sees and the only one the extent agrees with; capping the source instead would re-typeset different rows and could not report an honest K. A cut lands wherever row n - 1 falls, which for a long document can be inside a table or a fence. Custom is the one kind with no cap: it declares its own height and paints its own rect, so the feed has no rows to count — max_rows on a Custom block is a debug assert and a release no-op:

#![allow(unused)]
fn main() {
use abstracttui::widgets::FeedItem;

fn tool_result(body: &str) -> FeedItem {
    FeedItem::text(body)
        .max_rows(6)
        .overflow_marker(|k| format!("… (+{k} more lines — full text in the run ledger)"))
}
}

Feed — item press (click hit info)

Feed::on_item_press(|key, row_within_item| ...) fires on a left press over an item’s rows — row 0 is the item’s first typeset row, so “the user clicked this card’s title row” is row_within_item == 0. Presses on the gap between items or past the tail fire nothing (honest geometry, never rounded to a neighbor), and an unbound feed attaches no handler at all. The row math is public as FeedState::item_at_row(row) -> Option<(key, row_within_item)> (the inverse of row_of) for apps with their own pointer logic. The callback runs after the feed releases its state borrow, so it may mutate the FeedState (re-push the pressed item) or dispose the feed’s scope.

Disclosure — the fold/unfold card

Progressive disclosure for transcripts, message boards and settings panes: a one-row header — fold glyph (▸/▾), truncating title (title_muted(true) renders it in text_muted for ambient cards), optional right-aligned muted detail slot (static string, or LIVE via detail_signal(Signal<String>) — writes repaint the row without remounting it, so a focused header keeps its focus; empty = no detail; the signal wins over the static slot) — over a body that mounts on expand and UNMOUNTS on fold (a folded card costs zero idle work). Click the title row, or Enter/Space while it is focused (one tab stop; focus wears the selection pair). The card is borderless two-tone chrome (header surface_raised, body surface) — wrap it in a Block when you want a frame.

max_body_rows(n) (default 8) limits the unfolded body: shorter content takes its natural height, taller content scrolls inside the capped region with a scrollbar that auto-hides when it fits; max_body_rows(0) removes the cap. Bodies: Disclosure::text / Disclosure::markdown (typeset once through the shared Feed recipe, kept across folds) or .body(|scope| view) for any View — the closure runs once per EXPANSION on a generation scope, so durable state belongs in signals outside it.

State is uncontrolled by default (initially_folded, default folded); folded(Signal<bool>) hands the policy to the app — the signal’s current value is the state, toggling writes it back, and a “collapse all” is a loop of writes. on_toggle(|folded_now| ...) fires after the state write (disposal-safe):

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

fn cycle_card(cx: Scope, n: usize, reasoning_md: &str) -> View {
    Disclosure::markdown(format!("cycle {n}"), reasoning_md)
        .detail("12 lines")
        .max_body_rows(6)
        .view(cx) // folded by default: the header is the summary
}
}

The message-card recipe (Feed + Disclosure semantics)

A hub/chat transcript wants Feed virtualization AND per-card fold — that composes from parts. Keep fold state in a Signal<HashMap<key, bool>>, include it in the FeedState::sync fingerprint (rev, folded) so a toggle re-typesets exactly the changed item, render folded items as one rich header line (+ an optional max_rows preview) and unfolded items as header + body blocks, then wire both toggle surfaces: Enter on the selected key (Feed::selected_key), and click via on_item_press gated on row_within_item == 0 — flip the key’s entry, bump the fingerprint, and the feed re-renders that card in place (extent and follow-tail stay exact because item heights are re-typeset, never guessed). A Feed-NATIVE card item kind (engine-owned title row inside the feed’s own block vocabulary) remains future work: feed blocks are draw-only regions, so the per-item WIDGET is Disclosure, and inside a Feed the pattern above is the supported shape.

Monitors stop hand-rolling sample rings: TimeSeriesState (reactive) or TimeSeries (plain) take push(t, value) — t is a Duration on the app’s clock, never a wall-clock read — quantize time into cadence slots, and retain a bounded window (drop-by-age new(cadence, window) or drop-by-count with_slots). Missed slots pad with NAN, so a sampling pause draws as a HOLE through the charts’ existing gap contract instead of compressing the x-axis. LineChart::time_axis(span) embeds relative labels in the axis rule row — “now” anchored at the plot’s right edge, nice ticks leftward, density adapting to width — and Sparkline::time_axis(span) adds an optional label row. Feed the span from the ring so warmup labels the REAL covered time:

#![allow(unused)]
fn main() {
use std::time::Duration;
use abstracttui::prelude::*;
use abstracttui::widgets::{LineChart, TimeSeriesState};

const TICK: Duration = Duration::from_millis(250);

fn traffic(cx: Scope, t: &TokenSet, sample: impl Fn() -> f32 + 'static) -> View {
    let rx = TimeSeriesState::new(cx, TICK, TICK * 72); // 18s window
    {
        let rx = rx.clone();
        let mut n = 0u32;
        interval(cx, TICK, move || {
            n += 1;
            rx.push(TICK * n, sample());
        });
    }
    let tokens = *t;
    dyn_view(LayoutStyle::default().grow(1.0), move || {
        LineChart::new(vec![rx.samples()]) // tracked: re-renders per push
            .range(0.0, 100.0)
            .time_axis(rx.span())
            .element(&tokens)
            .build()
    })
}
}

ThinkingFold — the reasoning-text fold

The model-thinking card (app-kits/1250): a [Disclosure]-based fold, FOLDED by default (reasoning is detail, the answer is the content), with a muted “Thinking” title, an optional token-count detail slot, and streaming semantics on a cloneable state handle:

use abstracttui::prelude::*;

let thinking = ThinkingFoldState::new(cx);
let card = ThinkingFold::new(&thinking)
    .max_body_rows(8)          // taller thoughts scroll inside the cap
    .view(cx);
// as reasoning DELTAS arrive from result metadata:
thinking.append(fragment);      // open-tail re-typeset only
thinking.set_detail("213 tk");  // the token-count slot
// streams may deliver a trailing complete aggregate — LAST WINS:
thinking.complete(aggregate);   // REPLACES the accumulated fragments

The input is result metadata, never parsed out of reply prose — what counts as reasoning is the app’s (and its gateway’s) call; the widget renders what it is handed. The body typesets through the same recipe as MarkdownView/Feed (fences and tables tint mid-stream), capped at max_body_rows (default 8) with a scrollbar past it.

Streaming semantics, pinned by tests: complete replaces the fragments (providers recompose — the aggregate is the truth, and a second complete replaces again); fragments arriving AFTER complete are ignored (append returns false — a late straggler must not corrupt the completed text). The folded header carries a dot indicator while streaming that advances PER APPEND — data-driven, no timer: a quiet stream freezes it and the card schedules nothing (zero idle); completion clears it. Fold state survives streaming and fold cycles (folded(Signal<bool>) for the 0850 collapse-all policy); one MOUNTED card per state (the body feed typesets at one width). A ThinkingFold cannot live INSIDE a Feed item (feed blocks are draw-only, first-app/0280) — place it beside its feed segments in the turn column, as examples/reasoning.rs does.

Meter and AudioScope — live levels

Meter renders level data with real ballistics: instant attack, timed decay (default 20 dB/s over the meter span, frame-clocked and frame-rate-independent — a stalled stream shows a falling bar, not a frozen one), and a peak-hold marker (~1.5 s, then it falls to the level). One channel or N bands, eighth-block sub-cell fill, zone colors from the ok/warn/error theme tokens:

let level = cx.signal(0.0f32);              // fed by the recorder lane
Meter::new(level).db_floor(-60.0).view(cx); // horizontal dB channel
Meter::bands(band_frames).bar(3, 1).view(cx); // vertical spectrum bars

The idle law (pinned by tests): a silent meter decays to its fixpoint and STOPS requesting frames — unchanged input over any number of turns costs zero frames and zero allocations. Only real motion bills the frame loop.

AudioScope draws a rolling waveform from a Signal<Vec<f32>> window on the braille chart substrate. Pair it with bounded_source and OverflowPolicy::DropOldest: the source’s retained window IS the scope’s ring (with honest drop accounting riding along). The scope owns no clock — when the data stops, the last frame stays and nothing re-renders.

Both are view(cx)-only — the catalog’s one exception to the element(&tokens) form. They are signal-driven by construction, and their theme tokens resolve tracked inside the same reactive region that follows the level data; a fixed-token element build would need a wrapper node just to change where tokens come from, so it is a deliberate absence, not an oversight.

examples/voice_mock.rs composes all of it — push-to-talk, meters, scope, a fake transcription feed — with no audio and no network (the capture gesture itself is app::PushToTalk, described with the app runtime below).

Scroll follow-tail

follow_tail(Signal<bool>) packages the log/transcript idiom: while true the offset tracks the content bottom across appends and resizes; any user scroll above the bottom sets it false; reaching the bottom edge re-arms it. The signal is app-visible both ways — set it true for a “jump to latest” key. Without content_size the extent comes from the layout solver’s measurement of the mounted content:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

fn log_pane(cx: Scope, content: View) -> View {
    let pinned = cx.signal(true);
    Scroll::new(content) // extent measured — no height bookkeeping
        .follow_tail(pinned) // pinned until the user scrolls up
        .view(cx)
}
}

freeze_follow_tail(bool) holds a pinned scroller still WITHOUT disengaging it: the visible rows stay put, appends grow below the viewport, and thawing re-pins to the tail as it stands then. The engine drives it from the selection layer so a drag over a streaming transcript copies the text it highlights (see app::selection); apps can drive it for their own freezes.

The scrollbar

Every widget that scrolls a viewport — Scroll, List, Table, FilePicker, and the folds built on them — paints the same strip in its rightmost column and answers the same gestures:

gestureresult
press on the thumbtakes hold; nothing moves
drag after that pressthe thumb tracks the pointer row for row, and keeps steering after the pointer leaves the strip (pointer capture)
press on bare trackteleports: the thumb centers on the pressed row
releasecommits where it stands — no snap-back
any of the above with SELECT MODE onstill the bar’s: the strip is a drag zone, so the screen-text layer stands down over it

The thumb’s length is proportional to the visible fraction with a floor of 3 rows (the exact proportion of a long transcript rounds to zero), and it never fills a track that still has travel: a thumb parked on the last cell means the offset really is at its maximum. The strip is a RESERVED gutter, so content never re-wraps when the bar appears or hides.

Scroll::scrollbar_width(cells) (default 1, max 4) widens the strip for apps with room for a comfortable mouse target — the cells come out of the content, so a pane that measures its own text against the viewport width must widen with it. Scroll::scrollbar_auto_hide(true) hides the bar while the content fits; the column stays reserved and the invisible strip steers nothing.

The thumb takes accent ink while the pointer is over the strip or a drag is live, in every widget that draws one — and so does the row under the pointer (List, Table, FilePicker): hover shifts INK to accent with the background untouched, while a selected row keeps its audited pair and takes BOLD instead. Hover motion only reports where the app opted in (RunConfig::hover_ink); a drag always does.

Put the overflow inside a Scroll and keep the fixed rows fixed — the defaults do the bookkeeping: Scroll’s default layout is grow(1.0).basis(Cells(0)) (it absorbs overflow instead of demanding its content size), one-row controls default shrink(0.0) (an overflowing sibling can never crush them to zero rows), and Modal::open floors declared fixed sizes. Opt out per row with an explicit min_h(0); debug builds log any fixed-size child that still collapses:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::widgets::Button;

fn approval(cx: Scope, details: View) -> View {
    Element::new()
        .style(LayoutStyle::column().gap(1))
        .child(text("Approve this tool call?")) // fixed row: stays
        .child(Scroll::new(details).view(cx))   // absorbs the overflow
        .child(Button::new("Approve").view(cx)) // never crushed to 0
        .build()
}
}

TextArea — the multiline composer

The chat/console input surface. TextAreaState (the FeedState pattern) owns the durable wire: the value signal, the caret byte, focus, the history store, programmatic edits, and caret_cell() — the caret’s solved screen cell, which anchors completion dropdowns. The widget soft wraps at its width, grows with content inside rows(min, max) and then scrolls internally; Enter submits while Alt+Enter, Ctrl+J (the universal chord — 0x0a IS Ctrl+J on the legacy wire, so it works on every terminal) and Shift+Enter where the kitty protocol reports it insert a newline — flip it with SubmitPolicy::EnterInserts. Up/Down navigate the buffer first and reach for history only at the edges; the in-progress draft survives a recall round trip. Pastes insert whole, newlines included — never a submit — and on_paste can intercept a paste before insertion (file drops, paste policy for secret fields): see File attachments below.

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

fn composer(cx: Scope) -> View {
    let state = TextAreaState::new(cx);
    let st = state.clone();
    TextArea::new()
        .state(&state)
        .placeholder("Message — Enter sends, Alt+Enter newline")
        .rows(1, 4)
        .on_submit(move |msg| {
            st.push_history(msg); // Up recalls it later
            st.clear();
        })
        .view(cx)
}
}

Focus it at startup. .view(cx) builds the widget unfocused, and a root tree focuses nothing on its own — so a composer that should own the keyboard from frame one is built through the element form, .element(cx, &t).autofocus().build(). See Focus — who receives keys.

Placeholder while focused. By default the placeholder paints only while the field is empty AND unfocused — the classic yield-to-the-caret rule — which means an .autofocus()ed composer (focused from boot) never shows its hint at all. Opt in with .placeholder_while_focused(true): the hint then also paints while focused-and-empty, one cell past the caret in the same text_faint ink, so the caret block stays visible beside it — the convention modern editors ship. The option is off by default; TextInput has the same option.

Both text widgets take Codex’s default editor keymap (codex-rs/tui/src/keymap.rs), so muscle memory carries across:

Codex bindingKeysEffect
move_word_left / move_word_rightAlt+b / Alt+f, Alt+←/→, Ctrl+←/→caret by word
move_line_start / move_line_endHome / End, Ctrl+A / Ctrl+Ecaret to line start/end
delete_backward_wordAlt+Backspace, Ctrl+Backspace, Ctrl+Wdelete word before caret
delete_forward_wordAlt+Delete, Ctrl+Delete, Alt+Ddelete word after caret

Three spellings per gesture is what Codex binds, and for good reason: macOS has no Option-arrow escape of its own, so iTerm2’s “Natural Text Editing” preset sends readline’s ESC b/ESC f, while CSI 1;3D is the kitty/WezTerm form and CSI 1;5D the Linux/Windows one. Shift rides along on any of them to extend the selection by word. Ctrl+Home / Ctrl+End remain document start/end (beyond Codex’s table).

Two consequences worth knowing:

  • A focused editor CONSUMES these chords — including Alt+b/Alt+f/ Alt+d, Ctrl+W and Ctrl+A/Ctrl+E — which otherwise reach your keymap. That is the cost of matching Codex; check for collisions with your own bindings. Ctrl+B, Ctrl+F, Ctrl+D, Ctrl+U, Ctrl+K and Ctrl+Y are NOT claimed: Codex binds them for character motion, kill and yank, but they collide with app chords far more often, so they stay yours.
  • A masked TextInput (.masked(true)) treats the whole value as ONE word for every one of these, motion and delete alike: word boundaries in a secret would otherwise be readable from caret positions.

Completion dropdown (anchored panel)

app::anchored ships the passive half of the anchored-popup substrate and the completion controller riding it: place_panel places below-preferred, flips above when cramped, and clamps into the viewport; AnchoredPanel mounts the result as a NON-modal overlay above everything live (Overlays::top_z() + 1) that never takes focus — keys stay with the composer — and closes with its opener’s scope. Completion registers trigger-character providers and wraps the composer view; while the dropdown is open, Down/Up move the highlight, Enter/Tab accept (the candidate’s insert replaces the whole token), Esc dismisses, further typing refilters, and clicking a row accepts it:

#![allow(unused)]
fn main() {
use abstracttui::app::anchored::{Completion, CompletionCandidate};
use abstracttui::prelude::*;

fn composer_with_commands(cx: Scope, app: &App) -> View {
    let state = TextAreaState::new(cx);
    let composer = TextArea::new().state(&state).rows(1, 4).view(cx);
    Completion::new()
        .trigger('/', |query| {
            ["help", "quit"]
                .iter()
                .filter(|c| c.starts_with(query))
                .map(|c| CompletionCandidate::new(format!("/{c}"), format!("/{c} ")))
                .collect()
        })
        .attach(cx, &app.overlays(), &state, composer)
}
}

Triggers fire for whitespace-delimited tokens; Completion::trigger_at(char, TriggerPosition, provider) additionally scopes WHERE the token may sit — StartOfInput (the draft’s first token, leading whitespace tolerated: slash commands), StartOfLine (a line’s first token), or the Anywhere default that plain trigger registers — and a token outside its policy never opens the dropdown nor consults the provider.

place_panel prefers below and flips above only when below cannot fit the content; the opener can state the mirror bias — Completion::placement(PanelPlacement::AbovePreferred) / AnchoredPanel::open_passive_biased / the pure place_panel_biased — so a bottom composer’s short candidate list sits above the caret instead of on the chrome row below; the default stays BelowPreferred everywhere.

Providers run synchronously with the query typed after the trigger; an empty Vec closes the dropdown. The OWNED mode (Popup, a modal tree above the whole live stack with DismissReason-labeled endings: commit, Escape, outside press, anchor scope death, and viewport resize — a resize stales both the solved placement and the captured anchor, so an open popup closes rather than float at stale coordinates) and the TOOLTIP mode (Tooltip::attach, a delay-timed passive tip opened by hover OR by the anchor taking focus) ship beside it on the same placement engine — the select family below rides the owned mode.

Select / Combobox / MultiSelect — the choice controls

One family over one popup substrate, three faces (app::select, re-exported in the prelude). All three render as a one-row focusable trigger (side strokes carry focus, ▾ affordance, text_faint placeholder); Enter/Space or a click opens an anchored popup that layers above EVERYTHING live — a select inside stacked modals works — and is placed below the trigger, flipped above when cramped. Inside, Up/Down/PageUp/PageDown move a HIGHLIGHT (never the bound value), Enter commits, Esc abandons, and an outside press dismisses without acting on what is below. on_change fires on COMMIT only, and only when the value actually changed; Select::commit_on_move(true) is the opt-in live-preview exception (Escape then restores the pre-open value). Options carry a stable key, a label, an optional muted right-aligned hint, and disabled (skipped by movement, out of the focus order). The closed control reports Role::Button (a select trigger is a button that opens a menu; a dedicated Select role is parked in the 0.3 breaking budget) with the current choice as its access value; popups report Menu/MenuItem.

  • Select — closed one-of-N bound to a Signal<usize>; type-ahead inside the popup jumps by label prefix, a repeated char cycles.
  • Combobox — the popup includes the trigger row and mounts a real TextInput there (zero visual jump); typing filters (case-insensitive substring), the filter text is never the value, a non-matching buffer commits nothing, and a count/“no matches” line is part of the popup.
  • MultiSelect — checkbox-marked rows; Space (or click) toggles a working copy without closing, Enter commits the whole set into a Signal<Vec<String>> of keys (canonical option order), Esc abandons it. The collapsed row joins the chosen labels and degrades to “N selected” when they overflow.
#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::theme::themes;

fn theme_picker(cx: Scope) -> View {
    let picked = cx.signal(usize::MAX); // nothing chosen yet
    Combobox::new(
        themes().iter().map(|t| SelectOption::new(t.label)).collect(),
    )
    .value(picked)
    .placeholder("type to search themes…")
    .on_change(|i| {
        set_theme_by_id(themes()[i].id);
    })
    .view(cx)
}
}

Inside an App the popup finds the overlay store through reactive context automatically; outside one (bare-tree tests), pass .overlays(&overlays) explicitly. The faces live app-side (they need the overlay store; widgets sits below app in the layer map), but they are plain token-consuming components with the standard .view(cx) / .element(cx, &tokens) builds.

Programmatic open — SelectHandle. Command-summoned pickers (/theme, /model typed into a composer) open a face without a trigger gesture: build a cloneable SelectHandle, attach it with .handle(&h) on any of the three faces, and call h.open() from a command handler or shortcut — it returns true when the popup is open after the call. The popup anchors at the trigger’s LAST-PAINTED rect, so a face that has never rendered refuses (false) — open on the frame after mounting (the documented one-frame caveat). Disabled faces, empty option lists, and unmounted faces (the wire dies with the face’s scope; dyn_view regenerations rewire automatically) also return false, never panic:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

fn command_picker(cx: Scope) -> (View, SelectHandle) {
    let picker = SelectHandle::new();
    let view = Combobox::new(vec![
        SelectOption::new("nord"),
        SelectOption::new("aurora"),
    ])
    .handle(&picker)
    .placeholder("theme…")
    .view(cx);
    (view, picker) // `/theme` handler calls picker.open()
}
}

widgets::DrawerDock — the right-edge drawer rail

The team-page pattern in cells: a persistent rail of vertical tabs on the right edge, each fronting a docked side panel. At most one drawer is open; while none is, the panel column vanishes entirely and the content takes every cell up to the rail. The panel DOCKS in layout — content reflows around it. (The transient, sliding, scrimmed cousin is app::drawer; reach for the dock when the drawers are permanent chrome, for the drawer when they are an occasional overlay.)

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::widgets::DrawerDock;

fn shell(cx: Scope, content: View) -> View {
    let open = cx.signal(None::<String>); // None = collapsed
    let desk_waiting = cx.signal(true);   // durable: lives OUTSIDE the builders
    DrawerDock::new(content)
        .drawer("assistant", "Assistant", |cx| assistant_panel(cx))
        .drawer("files", "Files", |cx| files_panel(cx))
        .drawer("desk", "Desk", move |cx| desk_panel(cx))
        .drawer_badge(move || desk_waiting.get()) // dot under the Desk tab
        .open(open)          // bindable both ways; keys just write it
        .panel_width(40)
        .view(cx)
}
}

The contract, stated plainly:

  • Click a tab to open its drawer (replacing any other). Click the ACTIVE tab or the panel header’s ✕ corner region to collapse. Esc also collapses, but only while focus sits INSIDE the panel — a text-only drawer that never takes focus closes by mouse alone, like the web reference.
  • Builders must not write open synchronously — a redirect belongs in on_change or an effect. The builder runs inside the panel’s own tracked computation, where a same-turn write silently desyncs state from screen; debug builds assert. Keep tab titles short: the rail renders tabs top-down at fixed height, and a tab past the viewport bottom is clipped and unreachable by mouse.
  • Rail labels are stacked text, not rotated text. Terminal cells carry a grapheme and styling attributes but no 90-degree transform or vertical writing mode. DrawerDock therefore prints one grapheme cluster per row and exposes the full title as the tab’s semantic label. A caller can display a pre-rendered rotated bitmap elsewhere, but that is image artwork rather than portable, searchable terminal text and is not a DrawerDock label mode.
  • Tabs are keyboard-operable and semantic. Tab/Shift+Tab focuses the rail’s Role::Tab nodes; Enter or Space performs the same toggle as a left click. The semantic tree retains each full title even though the visible rail uses stacked graphemes.
  • open is the API. A Signal<Option<String>> of the drawer id. The dock renders and mutates it; the app may write it any time — external writes switch panels without firing on_change, dock-driven transitions fire it after the state write (a handler may dispose the dock’s scope). There are no container-reserved chords: bind your own keys by writing the signal.
  • Closed = disposed (the PageHost recipe): only the open drawer’s body is mounted, inside a generation scope that dies on close/switch. Durable drawer state lives in app signals created outside the builders, which re-read them on remount.
  • Badges (drawer_badge, attaches to the last-added drawer): while the closure answers true, an accent dot renders under that tab’s label — “something waits behind this drawer”, readable without opening it. Resolved reactively inside the rail’s region.

cargo run --example drawer_dock is the demo; 1-5/0 drive the same signal the tabs write.

widgets::PageHost — the page-level tab host

Full complex pages behind one themed tab bar. Tabs is the small in-content strip; a PageHost is the app-shell container: N pages addressed by id, exactly one mounted.

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

fn shell(cx: Scope, alerts: Signal<u32>) -> View {
    PageHost::new()
        .page("overview", "Overview", move |gcx| overview(gcx))
        .page("reader", "Reader", move |gcx| reader(gcx))
        .page("settings", "Settings", move |gcx| settings(gcx))
        .badge("overview", move || {
            let n = alerts.get();
            (n > 0).then(|| n.to_string())
        })
        .number_jump(true) // opt-in: plain digits 1-9 jump
        .view(cx)
}
}
  • Pages are builders (FnMut(Scope) -> View) receiving a per-activation GENERATION scope: only the active page is mounted; switching disposes the outgoing page’s scope (signals, effects, timers, focus) and builds the incoming one fresh.
  • No keep-alive, by design: a hidden-but-mounted page’s timers would keep ticking — the zero-idle law forbids it. Durable page state lives in app-owned signals created OUTSIDE the builders (the compose store pattern); builders re-read them on remount. Demo: examples/shell.rs (type into Settings, leave, return).
  • Controlled or uncontrolled: .active(Signal<String>) hands the app the navigation signal (navigation state IS a signal — external writes switch pages, on_change does not fire for them); otherwise the host owns an internal signal and .initial(id) picks the start page. Unknown ids fold to the first page. on_change(|id|) fires after the active write on host-driven switches (disposal-safe). It hands you the page ID where Tabs::on_change hands an index — deliberate, not drift: a Tabs strip is positional (its panels are an ordered list), while PageHost pages are id-addressed identities that navigation state (active(Signal<String>)) names directly.
  • Tab bar: two rows (titles + the border_focus cell strip); active text+BOLD, idle text_muted, badges info, ground surface. Badges are reactive getters — a count change repaints the bar only. Overflow WINDOWS the strip around the active tab (sticky window start; ‹/› indicators are prev/next click targets); oversized titles truncate with an ellipsis. Clicks hit-test the bar plan AS DRAWN — the pixels on screen — so a model change landing between a draw and a press (a badge widening) can never shift a tab under the pointer; nothing to configure. One tab stop, Role::Tabs, access value "Title (i/N) [badge]".
  • Chords (default Ctrl+PgUp/PgDn; .chords(prev, next) replaces, and EMPTY sets disarm the interceptor entirely — the reserved keys return to the content) are CONTAINER-RESERVED: intercepted at Capture phase on the host root, because scrollable widgets match PageUp/PageDown modifier-blind and would eat a bubble-layer chord; plain PgUp/PgDn always stay with the content. Matching is normalized — both wire spellings of a shifted letter fire. Chords are live while focus is anywhere inside the host; with nothing focused, keys target the tree root, so a host mounted AS the root element answers from frame one (otherwise establish focus: click/Tab/focus_first — inside a MODAL overlay, a Modal or a modal Drawer, focus_init lands on the bar and chords answer with no ritual). A chord/digit switch re-anchors focus on the host root so the next chord is never dead after the focused node died with its page.
  • Digit jumps ride the shortcut table (never capture): a focused text input keeps its digits; declared labels surface in keymap-help.

The widget disposal-safety law

Every widget completes its own bookkeeping — every write to its scope-owned signals — BEFORE user callbacks run, so a callback may dispose the widget’s scope synchronously. Closing a modal from the button that confirmed it, from the list row that picked, from the composer that submitted, is the NORMAL shape, not a hazard; no one-tick “retire” deferral is needed anywhere. EventCtx calls (stop_propagation, focus/capture requests) are dispatch-owned flags and exempt — they are safe on either side of a callback.

Covered and pinned by a disposal test per site: Button::on_click (both mouse and keyboard arms), Checkbox/RadioGroup/Tabs on_change, TextInput and TextArea on_change/on_submit, List::on_select/on_activate, Table::on_select/on_activate/ on_sort_requested, and the select faces’ commit on_change (the popup follows its owner’s scope down — the anchor-unmount cascade). Popup::on_dismiss fires after the popup’s own teardown for the same reason. One knowable consequence: bookkeeping uses the state as the widget left it — a callback that mutates the widget’s state (a submit-and-clear composer) sees that mutation rendered by the NEXT event, not retroactively applied to the one that fired it.

The same law covers REENTRANCY, not just disposal: a callback may open a modal, write the widget’s own controlled signal, or flush effects synchronously — no widget holds a borrow across a user callback (the invariant is documented on widgets::SharedCallback and pinned by reentrancy tests on List and Select).

One deliberate refinement for the paste INTERCEPT (on_paste below): an interceptor decides whether widget writes happen at all, so it runs FIRST — the law’s guarantee is preserved from both arms (on Consume no widget write follows the hook; on Insert the widget re-checks its signals’ liveness and treats a hook that disposed the scope as consumed, never a dead-signal panic).

File attachments — paste intercept, drop classifier, FilePicker

Terminals have no drop protocol: dropping a file onto every major terminal PASTES its path, and the spelling varies per terminal. The engine owns the three surfaces a file-attaching app needs (examples/attachments.rs is the wired recipe):

1. The paste intercept. Element::on_paste runs in Capture phase on the path toward focus — before focused widget handlers — so you can classify paste on an idle app surface without mounting a hidden input. Return PasteAction::Consume to stop before the focus target; PasteAction::Insert lets the focused editor handle the paste normally.

TextInput::on_paste and TextArea::on_paste (uniform semantics) run when UiEvent::Paste reaches the focused editor, BEFORE insertion, with the RAW paste text:

TextArea::new()
    .on_paste(|pasted| match abstracttui::input::paste::classify(pasted) {
        Some(paths) => { attach(paths); PasteAction::Consume } // no text lands
        None => PasteAction::Insert, // byte-identical to an unhooked editor
    })

PasteAction::Insert = the default paste behavior exactly (TextInput folds line breaks to spaces, TextArea normalizes newlines); Consume = the editor inserts NOTHING, fires no on_change, touches neither caret nor history. The hook fires in masked fields too — return Consume unconditionally to block pasting into a password field. The enum is #[non_exhaustive] (ADR-0003 §3): foreign matches carry a _ arm.

2. The drop classifier. input::paste::classify(&str) -> Option<Vec<String>> — pure string parsing, zero I/O — answers “is this paste a file drop?” against the researched spellings of the real terminals (the corpus, with sources, lives in the input::paste module docs):

terminaldrop spelling
Terminal.appbackslash-escaped specials, space-joined multi-drop
iTerm2 ≥ 3.4backslash-escaped (advanced pref: single-quoted)
Ghosttybackslash-escaped; GTK multi-drop is NEWLINE-joined
WezTermSpacesOnly default; Posix/Windows* = double-quoted; None = raw
kittyraw path as-is (no escaping, by policy)
Windows Terminaldouble-quoted when spaces; WSL tabs single-quote
GNOME Terminal (VTE)file:// URIs converted to single-quoted paths
MATE Terminal (bug class)raw file:// uri-list reaches the app

Accepted shapes per token: POSIX absolute, ~/ home-relative (returned as-is — expansion is YOURS), Windows drive/UNC, file:// URLs (percent-decoded, empty/localhost host only). The asymmetry policy: a false positive EATS user text, a false negative just pastes — so every ambiguous case returns None: raw unescaped spaces (/a/My File.txt from kitty), unterminated quotes, interior blank lines, control characters, relative paths, non-file: URLs, and prose (“see /usr/bin for details”). Existence-checking stays app-side (the engine never touches the filesystem in the input path): fs-check the returned paths and offer a visible undo before silently attaching.

3. The picker. FilePicker — breadcrumb header (left-truncated), live type-to-filter input (the single focus stop), entry rows with kind glyphs + an optional size column, keyboard nav, opt-in multi-select, on_pick(Vec<String>):

FilePicker::new(StdFileSource::default())   // std::fs, dirs-first,
    .start_in("/Users/me/Documents")        // hidden files skipped
    .multi_select(true)                     // Space marks, badge counts
    .on_pick(|paths| attach(paths))         // may close the modal (law)
    .view(cx)

Keys: Enter descends a directory or picks a file (marked set when non-empty, else the current file); Backspace/Left go to the PARENT when the filter is empty (otherwise they edit the filter); Up/Down/ Page move the selection; Esc is never consumed — the host modal owns dismissal (wire it like every modal: a shortcut that closes). The widget is PURE: entries come from the FileSource seam (read_dir(path) -> Result<Vec<FileEntry>, String>), so tests stay hermetic and archives/remote listings slot in; StdFileSource reads synchronously once per navigation (local fs is fine; a stalling network mount is the app’s risk — the seam is the escape hatch). Errors render honestly in the list area (“cannot read: …”), and a parked picker costs zero idle bytes.

app — the runtime

App::simple is the whole happy path: mount a component, enter the terminal, run until quit. This compiled example is the canonical first app — Tab focuses, Enter/Space clicks, Ctrl+C quits, all by default:

use abstracttui::prelude::*;
use abstracttui::widgets::Button;

fn main() -> abstracttui::base::Result<()> {
    App::simple(|cx| {
        let count = cx.signal(0);
        Element::new()
            .style(LayoutStyle::column())
            .child(dyn_view(LayoutStyle::line(1), move || {
                text(format!("count: {}", count.get()))
            }))
            .child(Button::new("+1").on_click(move || count.update(|c| *c += 1)).view(cx))
            .child(text("Tab focuses · Enter clicks · Ctrl+C quits"))
            .build()
    })
}

For more control, App::new(size) + mount + run splits the steps, and App::quitter() hands out a cloneable programmatic-quit handle. Ctrl+C arrives as an ordinary key (raw mode); the quit-by-default policy is overridden by any handler that consumes the event.

Around the core loop the module provides:

  • Overlays — z-ordered layers above the main tree (LayerHandle, ImageHandle) for popups, menus, and pixel images.
  • Modal — a centered, focus-trapped overlay panel: input is fully owned while open, Tab cycles inside, state created in the modal’s scope dies on close. Toast — top-right chips that slide in, park for their duration at zero frame cost, then slide out and remove their layer.
  • AnchoredPanel / Popup / Tooltip (app::anchored) — the three routing modes of the anchored-popup substrate, one placement engine (below-preferred, flip-above, viewport clamp): AnchoredPanel is the PASSIVE layer (never focused — keys stay with the anchor’s owner; Completion builds the caret-anchored dropdown on it), Popup is the OWNED modal tree above the whole live stack with DismissReason-named endings (the Select family rides it), and Tooltip is the delay-timed passive tip. A tip carries either content shape: TipContent::Label is one line of plain text on a draw layer with no tree, and TipContent::card(size, build) mounts a widget subtree, which is what a rich preview card needs. A card larger than the space the viewport can lend is marked rather than silently cut — a truncated card gets a … N more row and an over-wide label ends in an ellipsis. A tip opens on hover OR on its ANCHOR taking focus, closes on MouseLeave, FocusOut or the anchor moving under it, and Escape dismisses an open one — consumed only then, so an Escape with no tip up still reaches the dialog behind it. The keyboard trigger sits on the ROOT of the view you pass and nowhere deeper: focus transitions are delivered target-only while hover is delivered per-node along the hovered path, so Tooltip::attach(cx, ov, "…", d, Button::new(…)) gets Tab and an anchor that merely contains the focusable does not. The engine will not make your anchor focusable for you — that would insert a tab stop into your traversal order, which is the app’s call. You do not have to arm mouse motion for a tip. Hover is recomputed only from mouse reports and the default session posture reports motion only while a button is held, which used to make a tooltip open on CLICK and stay shut on hover in any app that just called App::run(). Mounting a Tooltip now declares the need (Overlays::require_pointer_motion) and the driver arms mode 1003 for it; RunConfig::hover_ink remains what it always was — an app opting into hover INK it merely wants. An app with no motion-dependent widget still pays nothing. cargo run --example hovercard walks all of it. All three close with their opener’s scope (see the widgets section for the completion and select details).
  • Hooks — use_theme(cx) (the app-level theme signal), use_viewport(cx) (terminal size as a signal), use_startup_notices(cx) (labeled startup degradations as a reactive list), and use_caps(cx) — the driver’s LIVE Capabilities (env pass at enter, upgraded as active-probe replies fold in). Read it in a dyn_view for capability-honest UI: key hints that say “Shift+Enter newline” only where the kitty protocol is actually live, graphics-channel labels that flip when the probe proves a better channel. Read-only by contract (writing capabilities stays the driver’s job); current_caps() is the untracked snapshot for plumbing.
  • KeymapHelp — a ready-made ? help modal listing the shortcuts reachable from the current focus plus every registered global action. Global actions resolve LAST in the driver’s key path: a key that nothing in the UI consumed — including one a MODAL overlay owned but did not consume — falls through to the action registry, which is what lets an action-bound toggle (the shell example’s i inspector) close the very modal drawer it opened.

app::ChoicePrompt — the modal decision gate

Block a flow on a structured question — agent approvals, setup choices, destructive confirmations with alternatives — and continue in the callback:

ChoicePrompt::new("Overwrite 3 modified files?")
    .option_detail("overwrite", "Overwrite them", "the local edits are lost")
    .option("keep", "Keep my copies")
    .allow_other("Something else…")
    .on_resolve(|outcome| match outcome {
        ChoiceOutcome::Answered(a) => apply(a),   // a.selected: ids, a.other: text
        ChoiceOutcome::Cancelled => (),           // explicit — never silent
    })
    .open(cx);

The question is plain data (ChoiceQuestion + ChoiceOption { id, label, detail } — approval questions arrive from elsewhere; ChoicePrompt::of accepts one). The gate opens a focus-trapped Modal over everything and resolves EXACTLY ONCE through on_resolve — Enter-commit, click-commit, the Confirm/Cancel buttons, Escape, and the returned handle’s cancel() all funnel into the same path; the modal is already closed when the callback runs, so it may dispose anything (including its opener) or open the next prompt. An outside click is swallowed, never a dismissal: a decision gate has explicit endings only. The one deliberate exception is the handle’s retire(): the HOST closes the gate with NO outcome — on_resolve never fires, and the consumed exactly-once flag guarantees no later ending (Esc, buttons, cancel()) ever will. Retiring says the host owns the outcome (it is replacing the prompt with another surface, or resolving the gated question through another lane), so “host retired, reopen later” stays distinguishable from the user’s Esc (Cancelled — “user dismissed, stay away”). Idempotent; a retire after resolution is a no-op.

Selection follows the engine-wide vocabulary (movement is not activation): single mode — the highlight IS the candidate (●), arrows/ Home/End/wheel move it, 1-9 jump (move only — a deliberate asymmetry), Enter or a click on the already-selected row commits { selected: [id] }; multiple mode (allow_multiple) — Space or a click toggles ☑/☐ marks, 1-9 jump-toggle (the mark is the selection act), Enter or Confirm commits the whole set canonicalized to option order (an empty set is a legal answer — the gate reports, the caller judges). Per-option SHORTCUT LETTERS (option_key(id, label, 'a') / ChoiceOption::key) are the explicit activation vocabulary: case-sensitive (a ≠ A), rendered as a dim (a) in the row, named in the hint, commit in single mode and jump-toggle in multiple; a declared key outranks the digit-jump lane. The key-spelling guarantee: a shifted letter has two wire spellings — the legacy wire bakes the shift into the char (Shift+A → Char('A')) while the kitty keyboard protocol reports the base key plus the modifier (Shift+A → Char('a') + SHIFT) — and a declared 'A' fires on BOTH; a declared 'a' never fires on Shift+A (case stays meaningful, only the spelling folds). The same fold covers every chord-match surface (Element:: shortcut, Actions, KeyState::pressed_chord) via KeyChord::normalized() — plain(Char('A')) and Mods::SHIFT + Char('a') are one chord, so a single registration works on every terminal; KeyEvent::means_char(c) is the same predicate for hand-rolled letter matchers. (Shifted non-letter symbols — ?, ~ — keep a wire split on kitty terminals: the shifted symbol is layout-dependent and the engine does not guess.) allow_other(label) appends an “Other” row; engaging it (highlight in single mode, checked in multiple) reveals an inline TextInput — autofocused, and its own key handling shields the list (digits and letters type, they never jump or activate) — whose trimmed text rides ChoiceAnswer::other; committing a hollow Other is refused with a visible note. Esc is LAYERED while the editor is focused: the first Esc retreats to the list (draft kept), the second cancels — the hint tells the truth per state. dismissable(false) is the must-choose mode for destructive gates: no Cancel button, no advertised Esc; Esc REFUSES visibly (“an answer is required”) while handle.cancel() keeps the programmatic lever. dismiss_label("Defer") renames the dismiss affordance everywhere it renders — the button, the hint’s Esc segment (Esc Defer; the unset default keeps the built-in “Esc cancels” — the engine never conjugates a caller label) and the advertised shortcut — for surfaces whose Esc is not a cancel (the approval consumer’s Esc DEFERS: the gated run keeps waiting). The OUTCOME stays ChoiceOutcome::Cancelled; the label names what the caller’s wiring does with it. Irrelevant under dismissable(false). ChoiceOption::danger(true) / .danger(id) tints a destructive option’s glyph+label with the Error token (the audited selection pair still wins while that row is highlighted with the list focused). option_with(ChoiceOption) is the escape hatch for detail+key+danger combinations. Option detail lines render muted under their label; long lists window around the highlight with an i/N position note; the panel sizes itself from the question and clamps into the viewport. The a11y tree carries the question (Heading), the options (MenuItem+“selected” / Checkbox+on/off per mode) and the revealed editor (Input) — the frozen-Role vocabulary, honestly mapped.

The body slot (.body(|mcx| view) + .body_rows(n), default 8): a structured region between the prompt heading and the options — the approval surface’s per-call cards, an alternate JSON view behind a caller-owned signal, a live tier line. The closure runs in the MODAL scope when the gate mounts (state created there dies on close), so a scrollable body is .body(|mcx| Scroll::new(cards).view(mcx)) and a reactive one is a dyn_view reading the caller’s signals — it re-renders live while the gate is up. Contract (v1): the body is a DISPLAY region — clipped to its solved row budget, panel-width, and the gate autofocuses the options so every key stays the options’ vocabulary (letters/digits/Space/Enter/Esc; a body Scroll never sees keys unless the user explicitly clicks it, and even then letters/Enter bubble past it to the gate). The WHEEL scrolls a Scroll-wrapped body while the pointer is over it and moves the highlight elsewhere. Height honesty: the options are allocated FIRST (never crushed by a tall body); the body absorbs what remains up to body_rows, floors at one row under pressure, and clips instead of painting over the rows below. Width: the panel is content-derived (options, prompt, hint, buttons) and the body closure is opaque to that measure, so a body wider than the question would size the panel declares its need with body_width(cols) — a minimum content width that participates in the same measure (the prompt then wraps at the widened width; options and hint gain the room), still clamped into the viewport with the existing margins: on a narrow terminal the body clips inside its region as before, never the options. Like body_rows, it participates only when a body is set. The prompt string still wraps/ellipsizes as before — structure belongs in the body, not in a mile-long prompt. The body adds only its own honest display entries to the a11y tree; the question/options contract is untouched.

ChoiceSequence::new(vec![q1, q2]).on_resolve(..).open(cx) chains several questions (each opens as the previous resolves); ChoiceSequenceOutcome is Completed(answers) or Cancelled { index, answers }. An empty question list completes synchronously. See examples/decide.rs for all three flavors.

app::Drawer — edge-anchored overlay panels

A drawer summons a FULL page (a Feed, a form, a reader) from a viewport edge, over the app, without touching its layout — the entity-app chat/inspector panel, translated to cells. Install once, then drive it through the handle or a bound signal:

#![allow(unused)]
fn main() {
use std::time::Duration;
use abstracttui::prelude::*;

fn wire_inspector(cx: Scope, page: impl Fn(Scope) -> View + 'static) -> DrawerHandle {
    let inspector = Drawer::new(DrawerEdge::Right)
        .size(DrawerSize::Percent(0.4))     // or Cells(n); cross axis fills
        .title("Inspector")                  // themed header + esc hint + ✕
        .motion(Duration::from_millis(160)) // Duration::ZERO = instant mode
        // (ZERO is also the deterministic-test mode: in a wall-time-less
        // headless harness a timed slide never advances, so the panel
        // renders nothing until real frames elapse — console-tui field note)
        .on_close(|why| { /* DrawerCloseReason::{Api, Escape, ..} */ })
        .install(cx, move |mount| page(mount));
    inspector.toggle();                      // open / close / is_open too
    inspector
}
}

Focus modes: DrawerFocus::Modal (default) is a focus-trapped tree — all input routes to the panel, Esc closes, a press outside closes when close_on_outside(true) (default), and a scrim(true) (default) veils the page with the theme’s overlay token. The titled header’s ✕ is a MOUSE-ONLY affordance (deliberately not focusable — chrome must never steal a modal’s initial focus from the content it frames, so focus_init lands on the page and a hosted PageHost’s chords answer from frame one); Esc is the keyboard close. Since 0.3.1 the whole trailing CORNER of the header row closes on a left press — the glyph, the padding margin beside it, and two cells of jitter room before it — because the ✕ alone is a one-cell target at the panel’s outermost column, which is exactly where a terminal whose window width is not an exact multiple of the cell width quantizes an edge click into the neighbouring column (a cell-perfect test suite cannot observe that miss; a field report can, and did). The title area stays inert. Closing releases the keyboard the INSTANT it begins: keys pressed during the closing slide route to the app (the departing panel is display-only — Esc-then- shortcut works at any typing speed), and a reopen that reverses the flight re-arms the trap and re-establishes initial focus. DrawerFocus::Passive is glanceable: keys stay with the main surface until the user clicks into the panel (the focused-overlay key rule); no scrim ever — dimming content that stays interactive would lie. The default diverges from the web AfDrawer (non-modal) deliberately: in a keyboard-first terminal an unfocused panel cannot even scroll.

Lifecycle honesty: a CLOSED drawer removes its layers and disposes its mount scope — build runs per open, and state that must survive close lives OUTSIDE the builder (the Tabs rule: create signals in the installing scope, capture them in build). A hidden-but-mounted tree would accumulate undrained damage and spin the frame loop; removal is the zero-idle-lawful shape. The slide bills exactly its band (Layer::set_origin damages old ∪ new bounds) and the flight requests frames only while easing — settled, parked or closed drawers cost zero bytes (test-pinned).

Stacking laws: drawers occupy fixed per-edge z slots in a band below MODAL_Z (Left < Right < Top < Bottom at corners), so a Modal opened from a drawer layers above it, owned popups (top_z() + 1) layer above everything live, and toasts stay on top. ONE drawer per edge: opening on an occupied edge finishes the incumbent instantly with DrawerCloseReason::Replaced. A terminal resize RE-CLAMPS (geometry re-solves, surfaces resize, an in-flight slide continues toward the fresh resting place) — unlike Popup, which dismisses, a drawer’s anchor is the edge itself and never goes stale. bind(Signal<bool>) is the controlled mode: external writes open/close, handle verbs write back — one truth. (Naming note: bind is the one departure from the state-name binding convention in the widgets intro — its state word would be “open”, which reads like a verb; renaming would be breaking and is not planned.) See examples/shell.rs (the ‘i’/‘g’ drawers) and examples/drawers.rs.

app::ThemeSwitcher — the theme menu button

Five columns of chrome that give any app runtime theming — a 3x1 chip (glyph plus a cell of padding each side) with a cell of margin each side so it stays off the terminal edge. Mount it in a header, tab bar or footer row; if that row uses a fixed-width slot, size the slot at 5:

use abstracttui::prelude::*;

// The menu face: ☾/☼ icon button (the glyph shows the CURRENT mode);
// Enter/Space/click opens an anchored popup of every visible theme
// grouped Dark / Light, current theme marked ●, house palettes first.
ThemeSwitcher::new()
    .on_change(|t| save_preference(t.id)) // fires when a switch STICKS
    .view(cx)

// The one-click face: no popup — each activation flips dark ↔ light
// via app::toggle_mode(), restoring your last theme of the target mode.
ThemeSwitcher::toggle().view(cx)

The popup rides the select-family machinery: arrows/Home/End/PageUp/ PageDown move (group headers are skipped), type-ahead jumps by label prefix and a repeated letter cycles its matches, Enter or a click commits, Escape restores the pre-open theme, a press outside keeps the preview. Movement previews LIVE — the Select::commit_on_move semantic — and the menu re-resolves its own tokens per step, so the list renders in the theme you are previewing. It is an owned anchored popup: modal above the whole live stack, SCREEN-space anchored (a switcher inside a Modal or Drawer opens its menu adjacent to the button, never displaced), flipping above the anchor when the space below is short — a footer placement opens upward automatically.

on_change fires once per switch that sticks (commit, or an outside-press that keeps a changed preview; every toggle flip) and never for preview steps, Escape-restores, or mechanical dismissals (resize, opener unmount). Live restyling needs no callback — the theme signal already drives it. Closed, the switcher is zero-idle. A11y: the trigger is button "theme" (toggle face: "toggle theme mode") whose value is the active theme’s label; the popup is a menu of menuitem rows.

Mode vocabulary underneath: ThemeMode, theme.mode(), theme::themes_by_mode(mode), app::toggle_mode() — see docs/theming.md. Demos: examples/themes.rs (both faces in the browser’s toolbar), examples/shell.rs (footer placement).

app::ReasoningSelect — the reasoning-effort control

The drop-in picker for a model’s thinking effort (app-kits/1250), and the ONE label grammar console footers share. The effort ladder is none | minimal | low | medium | high | xhigh plus auto (provider default) — REASONING_LADDER / REASONING_AUTO. The UI word is “reasoning”; the wire key apps write is thinking — the engine mints NO wire vocabulary: the control renders and emits VALUES, the app does the writing.

use abstracttui::prelude::*;

let effort = cx.signal(String::from("auto"));
ReasoningSelect::new(ReasoningFacts::capable(["low", "medium", "high"]))
    .value(effort)                       // controlled; omit for internal
    .on_change(|v| write_wire_thinking(v)) // fires once per changing commit
    .view(cx)

Capability facts arrive AS DATA (the thin-client rule): the app parses the gateway-served reasoning{thinking_support, reasoning_levels} block into a [ReasoningFacts] — capable(levels) / non_reasoning() / unknown() (block absent) — and the widget enforces the three-state coupling:

  • capable — the popup offers auto, none and the DECLARED levels only (verbatim, deduplicated, declared order — an unknown level string like ultrathink renders verbatim: the gateway is the authority on what a model supports). Empty declared list = auto / none alone.
  • non-reasoning — the control renders LOCKED (r: none (locked) — model does not reason), faint, out of the focus order, and refuses to open.
  • unknown — locked to none by default, but openable: one row, set anyway (capability unknown — passed verbatim), unlocks the FULL ladder for this instance. The lock annotation clears only when a value is actually committed through that ladder; committing none changes nothing and keeps it.

The popup rides the select-family machinery (arrows/Home/End/paging, type-ahead, Enter/click commits, Escape abandons, outside press dismisses) as an OWNED anchored popup: SCREEN-space anchored — inside a Modal or Drawer it opens adjacent to the trigger — flipping above when below is short. on_change fires once per commit and only when the committed value differs from the EFFECTIVE one (a locked display’s effective value is none, so overriding an unknown model to auto DOES fire; the select-family 0250 rule otherwise — field-gateway 0905 tracks the same-value-recommit gap family-wide). The widget never writes the bound signal uninvited. Closed, it is zero-idle. A11y: button "reasoning" whose value names the lock and its why (none (locked — capability unknown)); the popup is a menu "reasoning" of menuitem rows.

Reset on model change (the recipe): facts are constructor data — the widget does NOT own provider/model coupling. Remount with fresh facts when the model changes (build inside a dyn_view reading the model signal); per-instance state — the unknown-state override, an uncontrolled value — dies with the instance, so a stale “set anyway” can never leak onto the next model. examples/reasoning.rs shows it.

The footer grammar is public API — golden-tested, one source:

reasoning_label("high", LockState::Unlocked)      // "r: high"
reasoning_label("none", LockState::Locked)        // "r: none (locked)"
reasoning_label_glyph("none", LockState::Locked)  // "r: none ⊘"

The PLAIN form is canonical (self-describing, ASCII-safe, what a screen reader hears). The glyph form exists for width-tight footers: ⊘ U+2298 — chosen by the ThemeSwitcher-precedent width research (there is NO padlock outside Unicode emoji-data; ⚿/×/● are East-Asian-Ambiguous and go double-width under ambiguous-wide terminals; U+2298 measures 1 in BOTH unicode-width conventions and sits in a block emoji-data never touches).

These coupling rules mirror the shared abstractuic reasoning contract (the cross-seat plan’s contract v1); parity with the uic kit’s control is verified when its API lands. Reasoning TEXT belongs to ThinkingFold — metadata in, never prose-parsed.

app::keys — key press/release state (held keys)

Real-time surfaces (games’ move-while-held, voice push-to-talk) need key STATE over time, not key events. use_key_state(cx) arms a driver-fed service that taps the input stream BEFORE the routing seam drops releases:

use abstracttui::prelude::*; // use_key_state, KeyFidelity

let keys = use_key_state(cx);
// per frame / per tick:
let diagonal = keys.is_down(Key::Up) && keys.is_down(Key::Right);
// per turn edges (sealed by the driver's phase U):
let fired = keys.pressed_chord(KeyChord::plain(Key::Char(' ')));

Fidelity is the contract. keys.fidelity() answers what this session can honestly report:

  • KeyFidelity::Full — kitty release events are live (the terminal speaks the protocol AND the event-type flags are pushed; on probe-proven terminals this flips on within the first frames when the driver pushes the flags mid-session). is_down/keys_down/released carry true key state.
  • KeyFidelity::Degraded — a legacy wire only ever reports presses. Press edges (pressed, pressed_chord) stay honest; the down-set stays EMPTY and releases never fire. There is deliberately no repeat-timeout approximation: auto-repeat cadence cannot distinguish “held” from “tapping fast”, and a dropped repeat would fabricate a release mid-hold. Apps fall back to latch/tap semantics and label the gesture truthfully — hold_gesture_label(fidelity, chord) gives the wording (“hold Space” vs “press Space to start/stop”).

Hygiene: the terminal losing focus clears the down-set and synthesizes release edges for held keys (keys.focus_cleared() tells them apart from wire releases) — a key released while unfocused never sticks down. Reads are tracked signals: dyn_views and effects re-run on edges. Zero cost until the first use_key_state call arms the service, and zero per-turn cost while no keys move.

app::PushToTalk — the capture gesture

The voice capture contract over the key-state service (one binding, three decisions owned):

let ptt = PushToTalk::bind(cx, KeyChord::plain(Key::Char(' ')))
    .on_start(|| recorder.start())
    .on_stop(|reason| recorder.stop(reason)); // Released | FocusLost | Cancelled

let state = ptt.state();        // Signal<CaptureState>: Idle | Held | Latched
let hint  = ptt.gesture_label(); // truthful per fidelity, updates live

On Full fidelity the chord is hold-to-talk (press starts, release stops; a same-turn tap fires start then stop, in order). On Degraded wires the same chord becomes toggle-to-talk (PttMode::Latch) — never a fake hold. The terminal losing focus stops capture in EVERY mode (StopReason::FocusLost), and capture never auto-restarts when focus returns mid-hold: a fresh press is required. ptt.cancel() is the programmatic stop. Terminals cannot see unfocused keys — there are no global hotkeys here; audio capture itself is app-side.

app::selection — screen-text selection and clipboard copy

Terminals in mouse-capture mode route drags to the application, so native text selection stops working in every mouse-enabled TUI. The engine ships the whole answer stack (see the troubleshooting matrix for the zero-code terminal bypasses). Three cloneable, thread-local handles, all in app::selection (functions re-exported in the prelude):

#![allow(unused)]
fn main() {
use abstracttui::prelude::*; // selection(), mouse_capture(), copy_to_clipboard()

// Tier 3 — engine drag-select. Opt in once (or bind a key to toggle):
selection().set_enabled(true);   // left-drag paints a selection
selection().is_active();         // a region is visible
selection().clear();             // Esc and click do this too

// Tier 2 — native selection mode: hand the pointer back to the terminal.
mouse_capture().suspend();       // native drag-select works; no mouse events arrive
mouse_capture().resume();        // re-arm the entered mouse mode (e.g. on next key)

// The app-reachable clipboard verb (OSC 52 through presenter custody):
copy_to_clipboard("exact source text");
}

While selection is enabled, the engine claims left drags only — plain clicks pass through to the widgets (click-through). The click rules, stated plainly:

  • Left Down with no visible region: the drag anchor arms silently and the Down PASSES — the widget under the pointer arms its own pressed state in parallel, so a Button stays clickable with select mode on.
  • First Drag that leaves the anchor cell: the layer CLAIMS the gesture — dragging paints the theme’s selection_fg/selection_bg inks over the composed frame (damage-contract honest — only changed cells repaint), and releasing copies. At the claim, the press the tree already saw is resolved WITHOUT a click: the pressed widget receives a release outside every rect (release-inside-decides, so a Button un-presses without firing) and the pointer capture drops. Drags that never leave the anchor cell stay potential clicks (terminal cell quantization is the drag slop — a wiggly click still clicks).
  • Up with no drag (a plain click): passes — the widget fires.
  • Left Down while a region is VISIBLE: the click DISMISSES the selection — clear + consume, both halves of the click (Esc parity: the user was clearing a highlight, not aiming at the widget beneath).
  • Left Down inside a widget’s DRAG ZONE: the layer stands down — no anchor arms, and the whole gesture (Down, Drag, Up) belongs to the widget. Click-through is not enough for a widget that owns drags rather than clicks: a scrollbar thumb takes hold on the Down, and the claim one drag later would cancel that press. Every engine drag surface declares its zone — both Scroll bars, the List / Table / FilePicker internal bars, and an orbiting Viewport3D — so with select mode on the thumb still scrolls and the camera still orbits. Your own drag widgets declare one with Element::drag_zone; an invisible or non-overflowing bar returns None and owns nothing. The anchor decides: a drag that starts in content and crosses a strip keeps selecting.

Every copy ends the gesture: the region clears with the copy, so the app’s next keystrokes — including Enter and c — route normally at once. The key table while a region is visible (i.e. mid-drag):

KeyEffect
Entercopy the region, then clear (one-shot)
c / Ctrl+Ccopy the region, then clear (one-shot)
Esccancel — clear without copying
anything elseroutes to the app normally

Ctrl+C only quits when no region is visible. Wheel scrolling, hover, and every other key route normally the whole time.

Copies take the first route that works: OSC 52 through the presenter’s byte custody when the terminal advertises it, otherwise the host clipboard (pbcopy / wl-copy / xclip / clip.exe, controlled by RunConfig::platform_clipboard). Every copy that has a working route posts a startup notice naming its size — copied 240 characters (3 lines) to the clipboard — so a user can tell a copy landed, and landed whole, without leaving the app. A copy with no route left posts a labeled warning instead. Under tmux the sequence is deliberately not passthrough-wrapped (tmux consumes OSC 52 natively — set -g set-clipboard on).

Selection semantics, stated plainly:

  • Screen text, not widget content. What you copy is what the flattened frame shows: wide glyphs (CJK, emoji) are never split, blank cells read as spaces, trailing whitespace trims per row, rows join with \n. Soft-wrapped lines copy as separate rows; scrolled-away content cannot be selected. Copying a widget’s logical text (rather than rendered screen rows) is outside this feature’s scope.
  • Linear row flow, clamped to a pane. The selection flows like a terminal’s own: anchor to right edge, full middle rows, left edge to head. Both ends clamp to the pane under the drag anchor — the content box of the nearest clipping or padded ancestor (a Scroll viewport, a bordered Block), else the whole tree — so sibling panes and border glyphs never leak into a copy.
  • A live region freezes follow-tail. Because the region is screen space, content that keeps scrolling under it turns the copy into a lie — the highlight covers the rows you aimed at, the release copies whatever those cells hold by then. While a region is visible the engine calls widgets::scroll::freeze_follow_tail(true): every pinned Scroll holds its rows, appends grow below the viewport, and clearing the region (release-copy, Esc, a dismissing click) re-pins to the tail as it stands then. follow itself never flips, so “following / scrolled” chrome does not flicker for a drag. Streaming apps inherit this — nothing to wire — and may drive the same verb for their own freezes.
  • Zero idle cost. With no active selection the render hook is two empty checks; a parked selection renders no frames until something changes.

Terminal::set_mouse_reporting(bool) is the tier-2 verb underneath (implemented by both platform backends and testing::CaptureTerm; Driver::set_mouse_reporting is the immediate form for embedders). One platform note: job-control suspend (Ctrl+Z) re-enters with the original options, re-arming reporting — suspend again after resume if you keep it off.

app — the full-redraw verb (Ctrl+L class)

The damage contract trusts the terminal to keep every cell the engine painted. When that breaks EXTERNALLY — Cmd+K in Terminal.app, printf '\033c' from a stray process, an emulator glitch — model-side repaints cannot heal it: cells whose bytes did not change emit nothing, so the loss is permanent. Two verbs (exported at app:: and re-exported in the prelude) reach the driver’s “screen is unknown” resync — the same pair resize and suspend-resume run (previous-frame model poisoned, presenter re-anchored, every layer damaged, protocol images re-placed):

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

// The Ctrl+L binding every terminal app owes its users:
Element::new().shortcut(KeyChord::new(Mods::CTRL, Key::Char('l')), |_| {
    request_full_redraw() // next frame re-emits EVERY cell + re-places images
});

// Opt-in auto-heal: full redraw whenever the terminal reports
// focus-in (an external clear is nearly always followed by a focus
// round-trip, so the damage fixes itself before anyone looks):
set_redraw_on_focus_gained(true);
}

request_full_redraw() is callable from any component handler or posted job on the app thread; the driver drains it at its next turn (a call from a key handler is honored within the same turn). Cost is bounded and honest: one full-frame emission, then idle returns to zero bytes. The focus-regain opt-in defaults OFF — a full frame per focus-in is real byte cost under tmux pane-switching cadence, so existing sessions stay byte-identical unless the app asks (app::redraw_on_focus_gained() reads the policy back). Use these for terminal-side damage only; for ordinary content changes, signals and tree damage already repaint exactly what changed.

theme — design tokens

Widgets consume TokenIds resolved against the active theme’s TokenSet; they never hold raw colors. Twenty-six built-in themes ship in the registry: the abstract family (abstract-dark — the default — plus light, aurora, paper, ember, midnight, dawn), observer-night, catppuccin (mocha, macchiato, frappe, latte), rose-pine (plus moon, dawn), tokyo-night, nord, one-dark/one-light, dracula, monokai, gruvbox, solarized-dark/-light, and everforest-dark/-light.

Switching is one signal write: widgets that read the theme signal re-render fine-grained, and the app damages the whole tree so even static text repaints in the new palette:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

set_theme_by_id("catppuccin-mocha"); // false for unknown ids, nothing changes
}

theme::list() enumerates (id, label, dark) for a picker. Applications can add their own themes at runtime with theme::register(candidate, mode): every registration runs the full contrast audit, and the mode decides whether violations refuse the theme or register it with labeled findings.

A house palette goes through the engine’s own derivation rather than a reimplementation of it — theme::Palette takes the twelve authored colors the built-in seed table carries and Palette::derive() returns the ThemeCandidate you register:

#![allow(unused)]
fn main() {
use abstracttui::theme::{register, Palette, RegisterMode};

let mut palette = Palette::new("acme", "Acme", true);
palette.bg = "#101014".into();
// ... the other eleven authored colors ...
let reg = register(palette.derive()?, RegisterMode::Strict)?;
}

derive neither audits nor validates the id — register stays the one place a theme is judged — and a PaletteError names every malformed hex field at once. See docs/theming.md.

Two surfaces for grounds the theme does not own. theme::contrast::ink_on(&tokens, ground) returns the theme’s most readable authored ink for an arbitrary ground as Ink { color, token, contrast } — check contrast against floors::TEXT, because on a few theme/panel pairs no authored ink clears it. theme::contrast::ground_overlaps(id, &tokens, floor) reports pairs of the theme’s own grounds that measure below floor (floors::GROUND_SEPARATION_REPORT is the engine’s reporting threshold), walking the list TokenSet::grounds() publishes.

At 256 colors the driver keeps the theme’s grounds on distinct palette entries automatically; grounds your app mints are declared through RunConfig::extra_grounds (or Driver::set_extra_grounds). See docs/theming.md and cargo run --example grounds.

Polarity is first-class: ThemeMode::{Dark, Light} (closed — the decisive-ground invariant admits no third value), theme.mode() derived from the audited flag, and theme::themes_by_mode(mode) listing one mode’s themes in the curated order (house palette first, registrations trailing). app::toggle_mode() flips dark ↔ light restoring the last-used theme of the target mode (house palette on a cold start) — set_theme records every switch, so the round trip keeps your choices. The drop-in chrome control over all of this is app::ThemeSwitcher.

render — surfaces and paint (advanced)

Most applications never touch render directly — widgets and draw closures do. The two concepts worth knowing:

Surface is the cell buffer draw closures write into. Damage is recorded automatically by every write; the diff re-checks equality, so over-approximate damage costs microseconds, never wrong pixels.

render::Style is a patch, not an appearance. fg/bg at None keep what the target cell already has — text drawn over a filled panel keeps the panel’s background. Attributes are add/remove sets, so bold layers onto existing content. Style::absolute() opts out (remove everything first), and merge is sequential application — the later opinion wins:

#![allow(unused)]
fn main() {
use abstracttui::base::Rgba;
use abstracttui::render::{Attrs, Style};

// The common one-liner: ink + emphasis.
let err = Style::new().fg(Rgba::rgb(255, 80, 80)).bold();
assert_eq!(err.add, Attrs::BOLD);
assert_eq!(err.bg, None); // bg unset: keeps the panel underneath

// Patches compose; the later opinion wins where both have one.
let quoted = err.merge(Style::new().dim().fg(Rgba::rgb(150, 150, 150)));
assert_eq!(quoted.fg, Some(Rgba::rgb(150, 150, 150)));
assert_eq!(quoted.add, Attrs::BOLD | Attrs::DIM);
}

The one non-patch field is the hyperlink id: it always overwrites, because inheriting a stale link under a fresh label would be a correctness hazard.

For effects, layers accept per-cell shaders (CellShader; built-ins in anim::shaders). Shaders are billed by damage: static shaders cost nothing after installation; animated shaders damage only what their changed_region hint declares. For debugging: render::snapshot(&surface) prints a bordered character grid, snapshot_styles adds per-row style annotations, and Compositor::set_debug_damage(true) outlines every repaint region live.

md::StreamSession is the incremental entry into the markdown pipeline (text arriving over time: model output, a growing log). Closed blocks freeze — parsed once, never revisited — and only the open tail re-parses per append, with any chunking of the same bytes yielding blocks identical to md::parse of the whole source. An unclosed fence reports as code from the moment its opening line arrives. It is widget-agnostic; Feed’s streaming items ride its doc-vocabulary twin, md::DocStreamSession (next section):

#![allow(unused)]
fn main() {
use abstracttui::render::md::{self, MdStyles, StreamSession};

let styles = MdStyles::default();
let mut s = StreamSession::new(styles.clone());
s.append("# Title\n\nStreaming **bo");
s.append("ld** text.");
assert_eq!(s.closed_blocks().len(), 1); // the heading sealed and froze
assert_eq!(
    s.finish(),
    md::parse("# Title\n\nStreaming **bold** text.", &styles)
);
}

render::md — the doc vocabulary and the markdown reader surface

The core md::Block enum is exhaustive, so the extended block kinds live in md::DocBlock (#[non_exhaustive], wrapping the core set verbatim in DocBlock::Core): Table(TableBlock) — GFM header + alignment delimiter + body rows, inline styles inside cells, \| escapes; Image(ImageBlock) — a whole-line ![alt](src); and Task(TaskBlock) — - [ ] / - [x] items. md::parse_doc is the entry; for sources containing none of the extended constructs it is exactly md::parse wrapped in Core (test-pinned). Inline ~~strikethrough~~ joined the core span vocabulary (attribute-only: Attrs::STRIKE). md::DocStreamSession is the streaming twin of StreamSession for the doc vocabulary — same freeze/equivalence contract; a table OPENS once its header + delimiter lines are complete, grows a row per pipe line, and CLOSES (seals) at the first non-pipe line.

md::outline(source) extracts headings as Heading { level, text, anchor_id } with GitHub-compatible, deduplicated slugs (md::slugify). Width-resolved positions live on the widget: MarkdownView::outline_rows(source, &tokens, width) pairs each heading with the typeset ROW its text starts at (the TOC jump target), and MarkdownView::resolve_anchor(...) answers [text](#anchor) links.

The --- policy. A horizontal rule spends three things — ink, width and vertical space — and all three are the caller’s: MdRuleStyle { ink, width, space_before, space_after }, installed with MarkdownView::rule_style(...) or Feed::rule_style(...). ink is a TokenId (resolved against the LIVE theme every typeset, so the rule follows a theme switch) or a fixed Rgba; width is FullBleed, Measure (the box the block was typeset at) or Inset(cells); the two space counts are the rule’s own gap, which the following block does not add to — 1/1 is the historical three-row rule, 0/0 a one-row one. Defaults reproduce every earlier release byte for byte.

All three open together on purpose: a policy exposing one axis lets a consumer ship half an ordinal and believe it is finished. Two things it deliberately does NOT do — restyle the level-1 heading underline (a different block that paints the same chrome), and produce an invisible rule (an inset past the measure floors at one cell rather than paint nothing). Row positions move with it, so a styled view takes its scroll clamp, outline and search rows from the _ruled twins (rows_ruled, outline_rows_ruled, resolve_anchor_ruled, find_ruled) rather than the default-fold statics.

MarkdownView AND Feed markdown items render the full doc vocabulary (one shared typeset recipe — a feed item and a reader pane can never typeset the same source differently): tables typeset through the Table widget’s own column solver when they fit (one width policy), and under overflow walk the wave-13 honesty ladder — every column floors at min(natural, 3) cells and grows toward natural proportionally to need (per-cell ellipsis, columns are never crushed to zero), and when even the floors overflow the width, the grid degrades to a RECORD layout (Header: value lines per row, labels bold, records blank-separated — honest words, never vanished columns). Numeric columns with no written alignment marker right-align automatically (bare --- only; an explicit :-- is honored — TableBlock::declared carries the distinction). Headings keep depth readable: H3+ carry a faint hash prefix (### in text_faint) where the heading inks stop differentiating. Images render as MOSAIC rows in the flow — sized from a header-only probe (gfx::probe_dimensions) at typeset, DECODED LAZILY on first draw and cached by (path, size), with alt-text captions and labeled decode-failure states (pixel-protocol images in scrollable flow are deliberately out of scope; mosaic cells are cell-safe in any scroll context). Streaming feed items ride md::DocStreamSession (see “Feed — streaming transcripts” above).

Scrolling composes out of the box (wave 13): MarkdownView measures its typeset row count at the offered width and CodeView its line count × longest line, so Scroll::new(MarkdownView::new(doc).view(cx)).view(cx) is a complete scrolling markdown pane — no content_size hint, no app-managed offset (wheel, keys and the scrollbar just work; both views keep the basis(0)+grow default, so definite flex layouts are byte-stable and fixed siblings are never crushed). scroll_offset remains the app-managed door for transcript tails and TOC jumps (MarkdownView::rows is the same fold, so clamps never drift). Very long documents paint whole-extent under a Scroll; unbounded transcripts belong in Feed, which virtualizes.

Find-in-document: MarkdownView::find(source, &tokens, width, query, case_insensitive) returns MdSearchMatch { row, bytes, cells } over the TYPESET text (matches live in what the eye sees; offsets snap to grapheme clusters), and .highlights(matches, current) paints them non-destructively at draw in selection tones, the current match distinguished with BOLD+UNDERLINE. An empty query costs nothing. examples/reader.rs composes all of it into an mdpad-class reader.

canvas — Canvas & vector strokes

The sub-cell vector layer: the dot-grid math the shipped charts draw through, as public API — diagram extensions (abstracttui-graph, abstracttui-mermaid) and app draw closures use the same primitives. Sparkline/LineChart lines, BarChart bars and Progress fills all render through this layer.

#![allow(unused)]
fn main() {
use abstracttui::base::Point;
use abstracttui::canvas::DotCanvas;
use abstracttui::prelude::*;

fn trace(cx: Scope, samples: Signal<Vec<(f32, f32)>>) -> View {
    let t = use_theme(cx).get().tokens;
    let ink = t.chart(0); // resolve tokens at build time, as always
    dyn_view(LayoutStyle::default().grow(1.0), move || {
        let pts = samples.get();
        Element::new()
            .style(LayoutStyle::fill())
            .draw(move |canvas, rect| {
                let mut dots = DotCanvas::braille(rect.w, rect.h);
                for w in pts.windows(2) {
                    let c = (0.5 * (w[0].0 + w[1].0), 0.5 * (w[0].1 + w[1].1));
                    dots.bezier_quad(w[0], c, w[1], 0.25);
                }
                dots.blit(canvas, Point::new(rect.x, rect.y), ink);
            })
            .build()
    })
}
}
  • The dot-space model: a DotCanvas covers a cell rect with a finer grid — DotMode::Braille is 2x4 dots per cell (a WxH panel = a 2Wx4H dot canvas), DotMode::Quadrant 2x2 (universal glyph coverage where braille fonts are unreliable — the same degradation rationale as the image mosaic; the mode enum is #[non_exhaustive], sextant is a known candidate). Dot (0,0) is top-left; strokes clip at the grid edge, never panic.
  • Primitives: set/clear/get, line (Bresenham — far off-grid endpoints are pre-clipped so a panned diagram edge costs O(grid), never O(length)), polyline, bezier_quad/bezier_cubic (adaptive flattening to a flatness tolerance in dot units, depth- bounded at 4096 segments per curve), ellipse_arc (parameter-stepped, ≤ 2048 segments). All deterministic — same inputs, same dots, on every platform (arcs use an in-crate polynomial sin/cos instead of the platform libm) — and non-finite inputs draw nothing, the chart sample-skip contract.
  • The cell-color rule (documented z-order): a terminal cell carries ONE fg and ONE glyph, so blit(canvas, origin, color) paints every non-empty cell in one stroke color and SKIPS empty cells. Overlapping grids therefore compose at cell granularity: later blits win overlapping cells — glyph and color both, dots never merge across grids. Multi-color pictures = one grid per color, blitted back-to-front (exactly how LineChart layers its series). blit_styled takes a full render::Style patch instead of a bare color, so a stroke can carry attributes and a link id.
  • Composition is free: blits go through ui::Canvas/ ui::StyledCanvas, so ClippedCanvas clipping and damage tracking apply — a blit into a damaged region repaints only that region. clear_all() keeps the allocation; the whole stroke + blit steady state allocates nothing (pinned in tests/alloc_budget.rs).
  • Eighth-block fills: fill_v/fill_h draw partial gauge/bar fills at 8 steps per cell (the BarChart/Progress vocabulary); the glyph ramps V_EIGHTHS/H_EIGHTHS, the quadrant table and braille_bit are exported for callers building their own cell vocabularies.
  • Colors are caller-resolved Rgba (the widget token rule): resolve theme tokens — t.chart(i), t.accent — at view-build time and pass the values; the canvas layer invents no colors.

This layer is what the extension family draws its diagram edges with — for node-and-edge graphs and mermaid sources, use the sibling crates instead of hand-stroking (see the extensions section and graphs-and-diagrams.md).

gfx — images

gfx::decode_image(bytes) sniffs the magic bytes (containers lie, bytes do not) and decodes PNG or JPEG (baseline and progressive) into a Bitmap — owned RGBA8 with get/set, nearest and bilinear resize, cropping, and a box-filter mip chain. Unknown formats are rejected by name, telling the caller what does decode; truncated or hostile bytes are named errors, never panics.

Three presentation entry points, smallest first:

#![allow(unused)]
fn main() {
use abstracttui::base::{Rect, Rgba};
use abstracttui::gfx::{render_to_cells, Bitmap};
use abstracttui::term::Capabilities;

let img = Bitmap::new(16, 8, Rgba::rgb(180, 90, 30));
let cells = render_to_cells(&img, Rect::new(2, 1, 8, 4), &Capabilities::default());
assert_eq!(cells.len(), 8 * 4);
}
  • render_to_cells picks the best mosaic mode for the probed terminal and returns ready-to-blit cell patches; MosaicMode::auto(&caps) returns both the mode and the reason it was chosen (half-block, quadrant, sextant, or braille; optional Floyd–Steinberg dithering).
  • widgets::Image is the widget form — always mosaic, because a draw closure owns cells, not escape bytes. Its glyph family follows the terminal (MosaicMode::auto) unless .mode(...) pins one; MosaicMode::Sextant is the denser opt-in where the font carries the Unicode 13 block sextants. See graphics-and-3d.md.
  • gfx::decode_animation(bytes) decodes an animated GIF or an APNG into an Animation (frames, per-frame delays, loop count). A still decodes as a one-frame animation, so one path shows any picture. widgets::AnimatedImage plays one: .playing(signal) pauses, .repeat(bool) overrides the file’s own loop declaration, and each frame arms exactly one timer for its own delay (a paused or finished clip arms none). VIDEO IS NOT DECODED: .mp4, .mov, .avi, .webm, and .mpg are recognized, named, and refused with an ffmpeg conversion line. See graphics-and-3d.md for the external-decoder pattern that plays video.
  • gfx::ImageSession manages the pixel protocols (kitty, iTerm2, sixel): slots keyed by the caller, content versions, minimal traffic per channel — kitty transmits once and re-places on move; iTerm2 and sixel honestly re-emit. Bytes reach the terminal through the presenter, and tmux passthrough wrapping applies automatically when capabilities prove it.

gfx::bigtext — text and icons several cells tall

A terminal has one font size and no API makes a cell taller, so a bigger glyph means spending more cells and subdividing them with mosaic characters. gfx::bigtext rasterizes a string through the engine’s embedded 8x16 font and hands the result to gfx::mosaic, so the four vocabularies and the capability ladder you already use for images apply unchanged — there is no second encoder and no extra probe.

#![allow(unused)]
fn main() {
use abstracttui::base::Rgba;
use abstracttui::gfx::bigtext::{self, GlyphScale};
use abstracttui::gfx::mosaic::MosaicMode;

let ink = Rgba::rgb(220, 220, 220);
// The colour you are drawing ONTO. Sextant and quadrant fit two colours
// per cell, so a transparent ground is refused rather than rendered blank.
let ground = Rgba::rgb(20, 20, 24);

// Budget the space before you commit layout.
let size = bigtext::measure("AGORA", GlyphScale::FLOOR).unwrap();
assert_eq!((size.w, size.h), (24, 3));

// `render` returns Err for a character the font has no glyph for.
let grid = bigtext::render("AGORA", GlyphScale::FLOOR, MosaicMode::Sextant, ink, ground)
    .expect("Latin capitals are in the embedded font");
assert_eq!((grid.cols(), grid.rows()), (24, 3));
// `grid.cell_patches(origin)` yields (point, char, fg, bg) — the same shape
// the image path blits.
}

Choosing a scale — ask, do not assume. A GlyphScale is cells per character, and how small you can go depends on three things at once: WHAT you are drawing, WHICH mosaic symbols you are drawing it in, and how much margin you want. bigtext::smallest_clear(mode, content) answers all three by measuring; bigtext::legibility(&style, content) grades a scale you already have, and bigtext::closest_pair(&style, content) hands back the raw number (0 = the renderer produced the same picture twice) so you can set your own bar.

Content is the parameter that matters most: Uppercase, Text (lowercase and digits — the strict one) and Icons bottom out at different sizes. At 2x2 in braille the closest lowercase pair is 1 subpixel apart and the closest icon pair is 5, which is more room than 3x3 gives uppercase. There is no single floor, and the earlier has_margin() — a rectangle test against one constant, blind to both content and mode — has been removed. It refused 4x2 for having two rows while offering 3x3, which measures strictly worse: same uppercase margin, one row MORE, and two lowercase characters rendered identically.

The named scales are conveniences over that measurement, and each says what it is measured to be:

constantcellsmeasured
GlyphScale::COMPACT4x2cheapest that reads for mixed text; beats TIGHT for a row less
GlyphScale::COMPACT_WIDE6x2clears on the numbers, out of the aspect band — kept as the worked example of why the band exists
GlyphScale::FLOOR4x3clear for everything in braille; marginal for mixed text in sextant
GlyphScale::TIGHT3x3uppercase only — mixed text collides

Read the FLOOR row twice: sextants are what this module tells you to prefer for text, and at 4x3 mixed text there measures 3 subpixels — under the bar. smallest_clear(MosaicMode::Sextant, Content::Text) returns 4x4.

Two ways to be unreadable, and pairwise distance sees one of them. closest_pair answers are these two characters different. It cannot answer is either one still itself, and at small sizes those come apart: ● and ◆ at 6x2 braille measure 16 subpixels apart and both render as the same white bar.

So there is a second measurement. bigtext::fidelity_loss(c, &style) compares what the renderer draws against the same glyph drawn at its NATURAL proportions in the same footprint — 0.0 is a perfect match — and bigtext::least_faithful(&style, content) reports the worst character of a class, measured inside the class’s own run. Over FIDELITY_MAX (0.35), legibility returns Legibility::Distorted however far apart the pair measures. Distorted orders BELOW Marginal: a tight pair is one a careful reader can still resolve, a stretched glyph is not.

The two failures want different fixes, which is why they are different verdicts — a collision wants MORE cells, a distortion wants the same cells rebalanced between columns and rows.

The aspect band, the other half. The font’s glyphs are 8x16 and a cell is about 1:2, so against the full glyph box a cols x rows scale is undistorted when cols == rows — and a square footprint (cols == 2 * rows, what GlyphScale::square(rows) gives you) is already a 2x horizontal stretch. smallest_clear searches only within MAX_STRETCH (2x) of that in either direction, and GlyphScale::within_aspect_band() is that test as an API.

Neither term is redundant, and the band is the coarser one. It is referenced to the full 8x16 box, which the renderer never draws — the vertical crop below means the true undistorted point moves with the content. So the band passes 2x3 braille icons (a two-and-a-half times vertical stretch) and refuses 6x2 icons that measure inside FIDELITY_MAX. legibility applies both.

The band governs what the search OFFERS, not what you may draw — COMPACT_WIDE is still constructible and still fine for uppercase, which has no round strokes to lose.

Two consequences worth knowing:

  • Widening the columns found scales that were never reachable. MosaicMode::HalfBlock now clears at 6x5 (uppercase), 7x5 (mixed text) and 5x3 (icons). This page used to say no scale cleared for halfblock at all; that was a fact about a search which stopped at six columns, stated as a fact about the terminal — and for uppercase and icons it was not even that, since both were already inside the old ceiling and nobody had checked.
  • Every square(rows) sits exactly on the band’s wide edge, since that edge is cols == 2 * rows. square(2) is a 4x2 badge — the size a LONE icon wants, and measured, not the size a ROW of them wants: braille clears at 4x2 and sextant does not (0.36 loss, Distorted; its answer is 3x2). A row of icons crops as one run, so the box is the union of ⚠ ☑ → ● and taller than any single icon, which makes four columns a stretch. square(1) is in band and still a bad idea for a row of icons — 2x1 puts the closest icon pair 1 subpixel apart in braille and 0 in quadrant, which the band cannot help with, because aspect and legibility are two different questions and this module now asks both.

Choosing a vocabulary. mode is yours to pass, and the trade differs from the image case. Braille has the most subpixels per cell but terminals draw its dots with gaps, so a letter reads as a constellation; sextants are solid ink at a lower density and usually read better as type. Prefer MosaicMode::Sextant for text where the font carries the Unicode 13 sextants, and MosaicMode::auto(&caps) when you want the probed default.

Ground must be opaque for the two-colour fits. MosaicMode::Quadrant and MosaicMode::Sextant pick their glyph by fitting ink and ground against each other, and a transparent subpixel does not vote — so a transparent ground leaves the fit nothing to weigh and every cell comes back blank. Pass the colour you are drawing onto. render/render_with return BigTextError::TransparentGround rather than a correctly-sized empty grid, because that grid looks like a working call. Braille and HalfBlock threshold by luminance and carry transparency fine.

Weight and sampling. BigTextStyle carries the two remaining axes for callers that want them; render/rasterize take the defaults, and render_with/rasterize_with take the struct.

#![allow(unused)]
fn main() {
use abstracttui::gfx::bigtext::{BigTextStyle, GlyphWeight, Sampling};

let style = BigTextStyle::new(GlyphScale::FLOOR, MosaicMode::Sextant)
    .sampling(Sampling::Nearest)
    .weight(GlyphWeight::Bold);
}

Sampling::AreaAverage (the default) weights each source pixel by how much of it the target covers, which keeps thin strokes and leaves letters further apart — at 4x3 the closest pair is 8 subpixels rather than 4. Sampling::Nearest takes one source pixel per target: harder edges, and that margin halves. Solid display type at three rows often looks better point sampled; a run of arbitrary text needs the margin.

GlyphWeight::Bold is a synthetic weight — the crate carries one font, so this is the CSS font-weight axis rather than a family choice, and it dilates the glyphs one pixel the way a terminal has always faked a bold face. It raises pairwise distinctness at 3x3 and changes nothing from four rows up, but the gain is partly mechanical (dilation adds ink to every glyph) and at three rows it can close a counter. Treat it as a weight to choose, not an improvement to apply by default.

Limits, stated plainly. The embedded font carries 164 glyphs — Latin letters, digits and common punctuation — and no accented forms, so é, à and ñ return BigTextError::UnsupportedChar naming the character rather than being dropped. The vertical crop that recovers ascender space is applied across the whole string so the baseline and relative letter heights survive; a string containing a descender therefore needs more rows than one without, and mixed case reads weaker than capitals at the same scale.

cargo run --example bigtext walks a sixteen-step size sweep (width first at three rows, then height per width) and cycles symbols (s), weight (w) and sampling (a) from the keyboard, printing the closest letter pair under each combination — the only place these questions can actually be settled is your terminal in your font.

three — 3D models

three::quick_view(path) is the five-line hello: load a GLB, get a camera framed on the model’s bounds and a default light, render:

#![allow(unused)]
fn main() {
use abstracttui::three::{self, Framebuffer, SceneRenderer};

let view = three::quick_view("model.glb")?;
let mut fb = Framebuffer::new(160, 96);
SceneRenderer::new().render(&view.scene(), &mut fb);
// fb -> mosaic cells via gfx, or hand the model to widgets::Viewport3D.
}

Underneath: Model::load(bytes) / load_glb(path) parse and validate the GLB (unsupported features reject by name; recoverable gaps degrade with labels into model.warnings), Scene/Camera/Light describe the view, and SceneRenderer rasterizes with z-buffer, texturing, and mips. model.animations() lists clips; sample_pose_full(clip, t, &mut pose) produces node worlds and skin joint matrices, pure in t and allocation-free at steady state — loop with t % clip.duration(). One culling note: bare Scene::new culls back faces (procedural meshes are consistently wound); QuickView::scene() and Viewport3D render double-sided, because real-world exports are not.

term and input — the terminal, when you need it

Applications under App rarely touch these; embedders and diagnostics do. Capabilities::detect_env() is the free, instant, conservative environment pass; the active probe refines it concurrently at startup. caps.summary() is the multi-line human report (summary_line() the one-liner); scripts should read fields, not parse prose. EnterOptions declares the session posture — the default is the full-screen stance (alternate screen, hidden cursor, button-drag mouse, bracketed paste, focus events), with kitty keyboard flags as an explicit opt-in:

#![allow(unused)]
fn main() {
use abstracttui::term::{Capabilities, EnterOptions, TermRead, Terminal, UnixTerminal};
use std::time::{Duration, Instant};

let caps = Capabilities::detect_env(); // free, instant, conservative
let mut term = UnixTerminal::new()?;   // real device fd acquisition
term.enter(&EnterOptions::default())?; // raw mode + altscreen + modes

match term.read(Some(Instant::now() + Duration::from_secs(5)))? {
    TermRead::Input(bytes) => { /* feed input::Parser */ }
    TermRead::Resize(size) => { /* re-layout */ }
    TermRead::Wake => { /* another thread wants the loop */ }
    TermRead::Idle => { /* deadline expired */ }
}

term.leave()?; // also runs on Drop — the terminal always restores
}

input::Parser turns raw bytes into structured events — resumable across arbitrary chunk splits (mid-UTF-8, mid-escape), never panicking on any input. input::EventReader glues a terminal to the parser and owns the ESC-disambiguation deadlines.

Kitty keyboard flags follow the PROBE, not just the environment: the env pass claims the protocol only for terminals that speak it out of the box (kitty, ghostty, foot — WezTerm ships it config-off, so its claim waits for probe evidence), and when the active probe proves the protocol on a terminal env could not claim (iTerm2 ≥ 3.5, VS Code/Cursor, Warp), the driver pushes the standard flags mid-session via Terminal::set_kitty_keyboard — Shift+Enter-class chords start working without a restart. The verb updates the terminal’s session accounting, so leave pops exactly what was pushed and job-control suspend/resume stays symmetric (pop on suspend, re-push on resume). Embedders that enter with explicit RunConfig::enter options own their posture: the driver never upgrades it.

testing — the headless harness

The testing module ships in the library so applications can test against the same machinery the engine tests itself with: CaptureTerm is an in-memory terminal that records emitted bytes and models the screen, VtScreen is the VT100/xterm interpreter that serves as ground truth (“the bytes we emitted produce the frame we intended”), and app::Driver pumps real frames — the same pipeline production uses — without a tty:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::app::Driver;
use abstracttui::testing::CaptureTerm;

let size = Size::new(20, 4);
let mut app = App::new(size);
app.mount(|cx| {
    let n = cx.signal(0);
    Element::new()
        .shortcut(KeyChord::plain(Key::Char('+')), move |_| n.update(|v| *v += 1))
        .child(dyn_view(LayoutStyle::line(1), move || text(format!("n = {}", n.get()))))
        .build()
}).unwrap();

let mut term = CaptureTerm::new(size);
let cfg = RunConfig { probe: false, ..RunConfig::default() };
let mut driver = Driver::new(&mut app, &mut term, cfg).unwrap();
driver.turn(&mut app, &mut term).unwrap();          // first frame
assert!(term.screen().to_text().contains("n = 0"));

term.push_input(b"+");                              // a keypress
driver.turn(&mut app, &mut term).unwrap();          // dispatch + repaint
assert!(term.screen().to_text().contains("n = 1"));
}

Capabilities in a headless test. A capture terminal is not a tty, so undeclared capabilities (RunConfig::caps: None) resolve to Capabilities::headless() — full color, UTF-8, every terminal-bound feature off — and never to the environment of whoever runs the suite. That matters for color assertions: an environment pass on a host without COLORTERM quantizes every emitted color through the 256 cube, and token-against-token comparisons pass at either depth, so the verdict would move with the machine. Declare caps explicitly when a test needs a specific depth:

#![allow(unused)]
fn main() {
let cfg = RunConfig {
    caps: Some(Capabilities::with(|c| { c.truecolor = true; c.colors_256 = true; })),
    probe: false,
    ..RunConfig::default()
};
}

A custom Terminal implementation that IS attached to a terminal must override Terminal::is_tty (or call set_tty(true)), or it gets the headless set too. The substitution announces itself with a startup notice either way.

Input is fed as the terminal would send it, so every dispatch, focus, and damage path is the real one. For pure component tests, skip the driver: mount into a ui::UiTree, dispatch events, draw into a ui::BufferCanvas. Golden-snapshot assertions and deterministic fuzz helpers round out the module. When a test needs to EXPORT what the screen looked like — as evidence in a failure report or a docs artifact — capture it as a value and write text/ANSI/SVG: that is the Screenshots & captures section, and both of its capture surfaces work headlessly.

Screenshots & captures

render::Screenshot is a captured screen as a plain value — a grid of {glyph, fg, bg, underline color, attrs} cells (ShotCell) — with three deterministic exporters. It answers “what does the app actually show?” for debugging, documentation, and test evidence.

Two capture surfaces, one truth. In a running app, capture the frame as last presented (a pure read of the composed frame — no re-render, no damage side effects):

// Embedders/tests driving their own turns:
let shot = driver.screenshot();

// Component code (App::run consumed the App): the request verb — the
// same thread-local drain shape as `request_full_redraw`. The callback
// runs on the app thread with the screen as the user saw it when the
// request landed. No default hotkey exists; this binding IS the recipe:
Element::new().shortcut(KeyChord::plain(Key::F(12)), |_| {
    abstracttui::app::request_screenshot(|shot| {
        let _ = shot.write_svg("/tmp/screen.svg");
    });
})

In headless tests, capture from the byte side — the testing rig’s VT model, i.e. what the emitted bytes actually produced:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::app::Driver;
use abstracttui::testing::CaptureTerm;

let size = Size::new(24, 3);
let mut app = App::new(size);
app.mount(|_cx| Element::new().child(text("proof of pixels")).build()).unwrap();
let mut term = CaptureTerm::new(size);
let cfg = RunConfig { probe: false, ..RunConfig::default() };
let mut driver = Driver::new(&mut app, &mut term, cfg).unwrap();
driver.turn(&mut app, &mut term).unwrap();

let shot = term.screen().screenshot();          // bytes -> VT model -> value
assert!(shot.to_text().contains("proof of pixels"));
let svg = shot.to_svg();                        // attach to a test report
assert!(svg.contains("proof of pixels"));
}

Both surfaces produce the same value for the same screen (test-pinned), and Screenshot::from_surface(&Surface) captures any surface directly.

The exporters (pure functions, byte-deterministic; write_text / write_ansi / write_svg are the one-call file forms):

  • to_text() — plain UTF-8 lines, trailing blanks trimmed. Identical to VtScreen::to_text for the same screen.
  • to_ansi() — SGR-styled text you can cat into any truecolor terminal. Minimal escapes: one SGR transition per style change (the presenter’s own builders), rows separated by SGR 0 + CRLF, no trailing newline. Fidelity is test-pinned by a roundtrip law: replaying the export through the testing rig’s VT interpreter reproduces the capture exactly, including the cluster-fusion hazards (after ZWJ/VS16/ambiguous-width clusters and trailing regional indicators the export re-anchors the column with CHA — the presenter’s risky-cluster defense, in the row-relative form that keeps the bytes replayable from any scrollback position).
  • to_svg() — the docs/report artifact; GitHub renders it in READMEs. Backgrounds merge into per-run rects, text runs pin to their columns with textLength (font drift cannot shear the grid; wide glyphs run alone), decorations draw as explicit rects, exact RGB from the capture. Cells carrying “terminal default” colors render with a built-in neutral ink/paper; pass your own via to_svg_with(fg, bg). A generated sample lives at docs/captures/transcript-stream.svg (the capture pipeline emits .svg beside every .txt still).

Honesty notes. Cells under a kitty/iTerm2/sixel image are not the picture — the terminal shows pixels the cell plane cannot see. Driver::screenshot() stamps those placements from the live session bookkeeping into Screenshot::pixel_regions(); to_svg renders them as labeled placeholder veils, text/ANSI exports stay cell-plane-verbatim. Unicode-mosaic images ARE cells and capture as themselves. VT-model captures carry no regions (the rig consumes protocol payloads as counted, unmodeled frames). Hyperlink targets are not captured (a visual capture has no click surface — the styled debug dumps show them); blink exports as static; undercurl draws as a straight underline in both PNG and SVG.

Screenshot::to_png() / to_png_with(PngOpts) / to_bitmap() / write_png() render the capture as pixels the engine decides itself: text from an embedded 8x16 bitmap, and the ranges that must TILE — box drawing, block elements, braille, sextants — drawn geometrically, so strokes meet exactly at every cell boundary on any machine. Output is deterministic. to_svg remains the EMBEDDABLE artifact (GitHub renders it inline in a README) at the cost of depending on the viewer’s monospace font; the PNG is the faithful one. Capture is on-demand only: nothing here runs per-frame, and an idle app still costs zero.

Stability and limits

Plain statements of current behavior:

  • JPEG decoding covers the 8-bit Huffman frames: baseline, extended sequential, and progressive, in grayscale or YCbCr at 4:4:4, 4:2:2, 4:4:0, and 4:2:0. Arithmetic-coded, lossless, hierarchical, 12-bit, and CMYK variants reject by name. Chroma upsampling is nearest-neighbour. PNG supports 8-bit depths without interlacing (Adam7 rejects by name).
  • Animation decodes for animated GIF and APNG. Video does not decode at all: .mp4, .mov, .avi, .webm, and .mpg reject by name with a conversion command — their codecs are patent-pooled and out of scope for an in-tree decoder. A decoded sequence is held in memory (64 Mpx budget across its frames).
  • Screenshots export as text, ANSI, PNG, and SVG. The PNG is the faithful one — the engine draws every pixel, so box-drawing strokes meet and glyph metrics cannot drift; its coverage is ASCII plus the geometric ranges plus ~60 symbols, and anything else (CJK, emoji) draws a labeled placeholder. The SVG is the embeddable one — GitHub renders it inline in a README, at the cost of depending on the viewer’s monospace font, which can stretch glyphs and break stroke joins.
  • Sixel uses one palette per emission: multiple live sixel images recolor each other — prefer one per screen. iTerm2 and sixel have no placement model (moves re-emit the payload); only kitty gets placement escapes and true deletes.
  • Pixel protocols are verified byte-for-byte against protocol models, not live terminals; unicode mosaic is the universal, always-safe path.
  • 3D animation supports LINEAR and STEP interpolation; CUBICSPLINE and morph weights skip with labels; rotations nlerp (shortest path), not slerp. Skinning reads JOINTS_0/WEIGHTS_0 (four joints per vertex, linear blend). Textures: base color only, REPEAT wrap, per-triangle mips.
  • Mosaic color resolution is two colors per cell (the glyph split carries the rest); braille conveys structure, not color; sextant glyphs need a recent font and are an explicit opt-in.
  • Ambiguous-width characters follow unicode-width narrow semantics. A terminal configured ambiguous-wide breaks cell layout for every terminal application; the presenter’s cursor discipline bounds the drift but cannot erase it.
  • Capacity ceilings degrade with labels, never unbounded growth: 4096 distinct long grapheme clusters per surface (then U+FFFD), 65535 hyperlinks per surface (then plain text), with counters exposed.
  • Scroll optimization requires DECSTBM/SU/SD compliance — present in every VT100 descendant — and can be forced off via PresenterOpts.
  • Windows compiles clean and its extracted logic is unit-tested on every host, but it has not yet run on a live Windows machine; treat a first Windows deployment as a beta event. macOS and Linux are the live-verified platforms.

Extensions family — sibling crates on this API

Diagram-class capability ships OUTSIDE the core crate as sibling crates built on the public API above (ADR-0004: install only when needed, no cargo features, no private hooks). Each crate carries its own rustdoc — this guide does not duplicate it; the family guide with selection advice and worked examples is graphs-and-diagrams.md.

  • abstracttui-graph — graph auto-layout (GraphDesc -> Layout: layered sugiyama-lite, force bounded seeded placement, grid labeled fallback; honesty markers for broken cycles and degradations) and GraphView (cards, canvas-stroke edges, selection/pan/tooltips, zero idle).
  • abstracttui-mermaid — honest-subset mermaid: an exhaustive spelling table (the contract, shipped verbatim in the crate docs), flowcharts/flat-state compiled onto abstracttui-graph, solverless sequence diagrams, atomic fallback to the verbatim code fence with a named reason and a mermaid.live escape link.

Theming

AbstractTUI widgets never name colors — they name roles. Every drawable surface resolves a semantic token against the active theme, so an entire application restyles from a single switch, and every built-in palette is held to measured, test-enforced contrast floors.

This page covers the token model, the 26 built-in themes, runtime switching, the contrast guarantees, registering your own themes, and the styling conventions widget authors should follow. The complete hex value of every token in every theme lives in the generated reference: captures/themes-table.md.

The 36-token semantic model

A theme’s palette is a TokenSet: 36 resolved Rgba values, one per TokenId. The tokens are grouped by the job they do, not by hue:

Grounds — the layered backgrounds an app is built on.

  • bg — the application field; the deepest layer, fills the terminal.
  • surface — panel and card ground.
  • surface_raised — raised chrome: popovers, menus, active tabs, chips, and the declared ground for code blocks.
  • overlay — the modal scrim; deliberately carries alpha for the compositor to blend over whatever it covers.

Text tiers — three levels of copy, each with its own contrast floor.

  • text — body copy.
  • text_muted — secondary copy: labels, descriptions, timestamps.
  • text_faint — the decoration tier: placeholders, disabled glyphs, watermark art. Deliberately below the accessible-text grade; never used for information-carrying text.

Strokes

  • border — hairline strokes: pane separators, boxes, rules.
  • border_focus — the focus-ring ink; must read stronger than border.

Voice — where the theme’s personality lives.

  • accent — the theme’s identity color: primary actions, active states, brand marks. One accent per screen region works best.
  • accent_alt — a curated companion accent (gradients, secondary emphasis).
  • link — hyperlink ink (the underline comes from the style attribute, not the color).

Semantic states

  • ok, warn, error, info — success, caution, failure, and informational marks.

Selection pair

  • selection_bg / selection_fg — always used together, never mixed with other grounds. The pair means “this is the thing keys act on”.

Cursor and shadow

  • cursor — the caret/block-cursor ink when the engine draws its own.
  • shadow — a dim multiplier for cell-space drop shadows (carries alpha).
  • shadow_ground — shadow pre-composited over bg at theme build, so it is opaque. This is what Block::shadow paints: widgets never do color math themselves.

Chart ramp

  • chart[0..8] — eight hue-separated series colors, all legible on bg. Chart series pick a slot, never a color: slots 0–4 follow the accent/info/ok/warn/error family and slots 5–7 are curated companions, with a separation pass that keeps every series tellable-apart even in palettes where two source colors coincide. TokenSet::chart(i) clamps out-of-range indexes to the last slot, so indexing from arbitrary data can never panic.

Syntax family

  • syntax_keyword, syntax_string, syntax_number, syntax_type, syntax_func, syntax_punct, syntax_comment — code inks derived per theme from the audited accent/semantic family and contrast-guarded against surface_raised (the code ground). Comments deliberately recede at the 3:1 class; the other inks target 4.5:1.

By-id access exists for tooling (theme editors, debug overlays, config files): TokenId::ALL (all 36, stable order), tokens.get(id), tokens.set(id, rgba), TokenId::from_name("accent"), and tokens.iter() for (id, color) pairs.

The 26 built-in themes

theme::themes() returns the built-in registry; theme::get(id) looks a theme up by id (also honoring the "dark"/"light" aliases for the house pair); theme::resolve(id) falls back to the default for unknown ids and returns a labeled warning string alongside; theme::default_theme() is abstract-dark. theme::list() yields (id, label, dark) for every visible theme, built-ins first, then runtime registrations — the picker surface.

The family:

familythemes
Abstract originalsabstract-dark, abstract-light, abstract-aurora, abstract-paper, abstract-ember, abstract-midnight, abstract-dawn
Observerobserver-night
Catppuccincatppuccin-mocha, catppuccin-macchiato, catppuccin-frappe, catppuccin-latte
Rosé Pinerose-pine, rose-pine-moon, rose-pine-dawn
Tokyo Nighttokyo-night
Nordnord
Oneone-dark, one-light
Draculadracula
Monokaimonokai
Gruvboxgruvbox
Solarizedsolarized-dark, solarized-light
Everforesteverforest-dark, everforest-light

The ported families keep every hex value their upstream palette defines, verbatim. Tokens the upstream source does not define (borders, selection tints, focus rings, the chart ramp, the syntax family) are derived by documented, contrast-guarded rules — for example, borders composite the theme’s own text ink over the ground so gruvbox gets warm cream strokes rather than clinical gray.

Every token value of every theme, generated straight from the registry: captures/themes-table.md.

Switching themes at runtime

There is exactly one app-level theme signal. Reads are reactive, writes restyle the whole application:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

// Inside a component: read reactively. Any dyn_view that reads the
// signal rebuilds with fresh tokens when the theme changes.
fn header(cx: Scope) -> View {
    let theme = use_theme(cx);
    dyn_view(LayoutStyle::line(1), move || {
        let t = theme.get(); // &'static Theme: t.tokens, t.is_dark()
        text(format!("{} ({})", t.label, if t.is_dark() { "dark" } else { "light" }))
    })
}

// Anywhere: switch. Returns false (and changes nothing) for unknown ids.
set_theme_by_id("nord");

// Or with a handle from the registry / a runtime registration:
set_theme(abstracttui::theme::get("catppuccin-mocha").unwrap());
}

Mounting an app installs a watcher on the signal that damages the whole tree on switch, so even static text repaints, while regions that read the signal inside dyn_view re-render fine-grained. Theme::is_dark() is the supported way to make polarity-conditional choices (shadow strength, image dithering, artwork variants).

The shipped examples honor ABSTRACTTUI_THEME=<id> as a startup convention — set_theme_by_id at boot is all it takes to adopt the same convention in your app.

Theme modes & the switcher

Polarity is a first-class vocabulary: ThemeMode::{Dark, Light} is a closed enum (the decisive-ground invariant leaves no room for a third value), theme.mode() derives it from the audited dark flag — one source, never a second luminance threshold — and theme::themes_by_mode(mode) lists every visible theme of one mode in the same curated order list() presents: built-ins in registry order (the house palette of each mode first), runtime registrations trailing. The first theme of each mode is guaranteed to be the house palette — pickers and the toggle default rely on that order.

app::toggle_mode() flips dark ↔ light while keeping the user’s theme choice per mode: set_theme (the one signal-write choke point) records every switch as its mode’s last-used theme, so nord → toggle → abstract-light → toggle → nord round-trips. A mode never visited on this thread falls back to its house palette.

ThemeSwitcher is the drop-in control — one line in any app’s chrome:

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;

// In your header / tab bar / footer row:
let menu = ThemeSwitcher::new().view(cx); // ☾/☼ button; opens the grouped menu
let flip = ThemeSwitcher::toggle().view(cx); // same chip; one click flips the mode
}

Both faces are a 5-column control: a 3x1 chip — the glyph with one cell of padding each side, so the hit area is the visible shape — plus one cell of margin each side, which is what keeps it off the terminal edge when it is mounted last in a right-aligned chrome row. The chip carries a surface_raised ground in every state including idle, so it reads as pressable; hover and focus change the ink, not whether there is a ground. If your chrome gives the switcher a fixed-width slot, give it 5 columns. ThemeSwitcher::layout() replaces the geometry wholesale when you want something else — the face fills whatever box it is given and centres the glyph in it.

The menu face opens an owned anchored popup (modal, above the whole live stack — it layers and anchors correctly inside a Modal or Drawer) listing every visible theme grouped Dark then Light, group headers as skipped rows, the active theme marked ●. It rides the select-family machinery: Up/Down/Home/End/PageUp/PageDown move, type-ahead jumps by label prefix (a repeated letter cycles its matches), Enter or a click commits, Escape restores the pre-open theme, and a press outside keeps what you previewed. Movement previews the theme live — the Select::commit_on_move semantic, which exists for exactly this control — and the menu re-resolves its own tokens per step, so the list you are browsing is rendered in the theme it names. on_change(|theme| ...) fires once per switch that sticks (commit or outside-press with a changed theme; never on preview steps, never on Escape) — the hook for persisting a theme preference.

The glyph: the button shows the current mode — ☾ on dark themes, ☼ on light ones. A static ◐ would spend the cell on decoration; a mode-reflecting glyph makes the one cell double as the app’s polarity indicator, while hover/focus affordances and the a11y label (“theme”, value = the active theme’s label) carry the button-ness and the action. ☾ U+263E and ☼ U+263C are East-Asian-neutral (single-width in every convention) and absent from Unicode emoji-data — unlike ☀ U+2600, which some terminal stacks promote to a double-width emoji glyph.

Closed, the switcher is zero-idle: no layers, no timers — it re-renders only when the theme signal or its own hover/focus state is written. The popup’s subscriptions live on a per-open scope and die at dismissal. The full behavior reference (popup keys, on_change semantics, accessibility roles) is api.md § ThemeSwitcher; examples/themes.rs shows both faces in a toolbar and examples/shell.rs the footer placement.

Contrast guarantees

Every registered theme must pass theme::audit(id, &tokens) — a WCAG contrast audit that measures each documented pair with theme::contrast_ratio(a, b) and returns structured Violations (theme, rule, token, measured value, required floor). The built-in family passes with zero violations as a test invariant; the floors are public in theme::contrast::floors so your tooling audits against the same numbers:

pairfloor
text / grounds4.5:1 (7:1 is the target, reported not enforced)
text_muted / bg3.0:1
text_faint / bg2.5:1 (the deliberate decoration tier)
accent, accent_alt, semantics, link / bg3.0:1
selection_fg / selection_bg4.5:1
border / bg1.5:1
border_focus / bg2.0:1
cursor / bg3.0:1
syntax inks / surface_raised4.5:1 (comments 3.0:1)

Syntax floors are additionally capped at what the theme’s own body text achieves on the code ground — code can never be more readable than text, which matters for deliberately soft palettes.

Beyond the pairs, grounds must be decisive: a theme’s measured ground luminance must agree with its declared dark flag by a margin (|L(bg) − 0.5| ≥ 0.15). A mid-gray ground makes both text polarities marginal and breaks everything downstream that groups by polarity.

Audit exceptions are named per (theme, rule) pair, never blanket, and a stale exception fails the test suite. Exactly one exists: everforest-light’s text on raised chrome measures ~4.25:1 — both values are verbatim upstream colors, the rule is stricter than the mandated text/ground floor, and 4.25:1 still clears WCAG AA-large.

Text on a ground the theme never saw

The audit covers the theme’s own grounds. An application that paints a ground of its own — a custom card fill, a panel colour from a client’s brand — is outside it: across the built-in registry, body text on a mid-dark application panel falls below the 4.5:1 floor in 8 of the 26 themes, and on a bright one in 19 (solarized-light reaches 1.01, text the same colour as the panel beneath it).

theme::contrast::ink_on picks the theme’s most readable authored ink for any ground and tells you what it achieved:

#![allow(unused)]
fn main() {
use abstracttui::theme::contrast::{floors, ink_on};

let ink = ink_on(&t, my_panel);       // Ink { color, token, contrast }
let fg = if ink.contrast >= floors::TEXT { ink.color } else { warn_and_pick() };
}

It clears the text floor on 51 of the 52 theme/panel combinations measured. The ratio comes back rather than being swallowed because of the 52nd: a deliberately soft palette can hold no ink dark enough for a bright panel (everforest-light tops out at 3.49:1), and returning a bare colour would hand you unreadable text that looks like a considered choice.

This is a door, not a default — widgets ink themselves from the theme’s own tokens, and nothing in the paint path consults ink_on for you.

Grounds at 256 colours

The audit measures truecolor. Quantisation to the xterm-256 cube happens downstream at emit, and two grounds a theme authored a step apart can land on the same palette entry: measured across the registry, 15 of 260 ground pairs collapse, in 15 of the 26 themes — panel elevation rendering as flat.

The engine handles the theme’s own grounds for you: at Xterm256 the driver assigns each ground its own palette entry (quantize_set_256 decides the assignment, Presenter::set_palette_assignment installs it), re-deriving only when the theme or the colour depth changes. Truecolor output is unchanged, and so is a 256-colour app whose grounds do not collide.

Grounds your app mints are declared, because the separator can only keep apart what it is handed:

#![allow(unused)]
fn main() {
App::new(root).run_with(RunConfig {
    extra_grounds: vec![my_panel, my_folded_panel],
    ..Default::default()
})
}

Driver::set_extra_grounds is the same thing for a hand-driven loop.

Two limits worth knowing. This is 256 only: at Ansi16 the collapse still happens (98 of 260 pairs), because the 16 system registers are user-themable and no build-time decision can know what index 4 renders as. And separation of a foreground from its own background still wins over the ground assignment — text reading as its own background is the worse defect.

When separating every ground is the wrong answer

Giving every ground its own entry is right for a theme whose grounds are drawn apart, and wrong for one whose grounds are not. Seven built-in ground pairs come out of the assignment more separated at 256 colours than they are at truecolor — an edge the theme author never drew. Merging “close enough” grounds does not fix it: the same pair can be one an author left indistinct and one whose elevation the plain lookup collapses, so no rule over the two colour values is right about it.

So the intent is declared, not inferred — per pair, by the theme. You state it once on the candidate and the driver does the rest:

#![allow(unused)]
fn main() {
use abstracttui::render::color::PairIntent;
use abstracttui::theme::{register, RegisterMode, ThemeCandidate, TokenId};

let candidate = ThemeCandidate {
    id: "acme".into(),
    label: "Acme".into(),
    dark: true,
    tokens,
    // "These two grounds read as one surface — where the 256 palette
    // has already forced them together, leave them there."
    ground_intent: vec![(
        TokenId::SurfaceRaised,
        TokenId::SelectionBg,
        PairIntent::Same,
    )],
};
let theme = register(candidate, RegisterMode::Strict)?.theme;
}

That is the whole integration: Driver::sync_palette_assignment reads Theme::ground_intent off whichever theme is live, resolves it against TokenSet::grounds, and installs the resulting assignment. Nothing to call per frame and no assignment to build yourself.

Pairs are named in tokens, not indices — an index would silently mean a different pair the day the ground list reorders. Both tokens must be opaque grounds; register refuses anything else with RegisterError::NotAGround, in both modes, because a declaration over border protects nothing while reading as though it does.

Each pair has three states: Same, Distinct, and undeclared — the last being the absence of an entry, not a value you can write.

Distinct changes no bytes: it is what silence already does. What it buys is a claim that can be checked against the artifact. Declare two grounds Distinct and then author them at the same hex and you have contradicted yourself — register reports it (refusing in Strict, labelling in Labeled), where before the two would quietly share an entry and you would go on believing the edge was protected. theme::contrast:: declaration_contradictions is the same check, callable directly.

The mirror case is deliberately not reported: Same over two colours a mile apart asks for a merge that can never happen, because intent only ever releases a merge at a collision. That is inert, not wrong, and you may reasonably declare it ahead of a re-tint of your own palette.

render::color::quantize_set_256_into_with is the layer underneath, if you are building an assignment yourself rather than going through a theme:

#![allow(unused)]
fn main() {
use abstracttui::render::color::{quantize_set_256_into_with, GroundIntent, PairIntent};

let mut idx = vec![0u8; grounds.len()];
quantize_set_256_into_with(&grounds, GroundIntent::UNDECLARED, &mut idx);
quantize_set_256_into_with(
    &grounds,
    GroundIntent::new(&[(0, 2, PairIntent::Same)]),
    &mut idx,
);
}

Opting in cannot flip the default. A pair you did not name behaves exactly as if you had no declaration at all, so naming one pair never moves another. An empty declaration, and one listing nothing but Distinct, are both byte-for-byte identical to UNDECLARED across all 26 built-in themes. There is no way to spell “merge everything I did not mention” — a theme should not be able to give away its elevation by omission.

Intent releases a merge; it never creates one. Same lets two grounds share an entry when they collide. It never moves a ground that already had an entry of its own: forcing a collapse the palette did not ask for would invent the mirror of the defect this exists to fix.

Across the registry, declaring all ten pairs Same — the maximal opt-in — closes three of the seven invented edges, at the cost of re-collapsing 15 pairs the assignment keeps apart (one of them, catppuccin-frappe bg/surface, above the 1.10 report floor). That is the upper bound on both sides, and reaching it takes ten deliberate statements.

The other four invented edges are not the assignment’s: those grounds have different nearest entries already, so nothing displaced them and no declaration can reach them. They are a property of the xterm-256 lookup itself, survive with the whole set policy removed, and remain open.

Every built-in theme declares nothing, and that is a decision, not an oversight: none of the 26 authors has been asked, so elevation wins for all of them and the shipped bytes are unchanged.

extra_grounds cannot carry intent, and that is also a decision. The declaration covers the theme’s five grounds only; consumer grounds join the set after them and no declaration names them. Stated rather than left to be discovered, because the mechanism above would otherwise imply it. The reasoning: a theme has five named roles whose colours may coincide, and a widget picks a role rather than a colour — so two roles landing on one hex is normal and the intent question is real. An app passes raw colours. If two of yours are the same colour they already share an entry (byte-identical colours always do); if they are different colours you chose deliberately, keeping them apart is what you asked for. And a panel meant to read as one surface with surface should be t.surface, not a minted near-match. If you have a case this reasoning misses, it is worth raising rather than working around.

cargo run --example grounds walks the registry and shows this live — press i on solarized-dark, one-light or abstract-midnight and the app switches to a registered variant that declares the colliding pair, with the two grounds arriving as one colour. On the other 23 themes it says plainly that there is nothing to release.

To ask the question about your own theme, theme::contrast::ground_overlaps returns a GroundOverlap for every pair of opaque grounds measuring below the floor you pass (floors::GROUND_SEPARATION_REPORT, 1.10, is the threshold the engine’s own measurements report at). It is a report, not a rule: drawing two grounds alike can be deliberate. TokenSet::grounds() is the list it walks.

Registering a custom theme

theme::register(candidate, mode) is the runtime door:

#![allow(unused)]
fn main() {
use abstracttui::theme::{register, RegisterMode, ThemeCandidate, TokenSet};

let candidate = ThemeCandidate {
    id: "my-theme".into(),        // kebab-case: [a-z0-9-_], non-empty
    label: "My Theme".into(),
    dark: true,                   // audited against measured luminance
    tokens: my_tokens,            // a full TokenSet
    ground_intent: vec![],        // silence — see "When separating every
                                  // ground is the wrong answer"
};

match register(candidate, RegisterMode::Strict) {
    Ok(reg) => set_theme(reg.theme),
    Err(e) => eprintln!("{e}"),   // structured violations, not a boolean
}
}

The audit always runs; the mode declares what happens to findings:

  • RegisterMode::Strict — findings refuse the registration. The error carries the structured violation list plus role-hygiene findings (RegisterError::Rejected { violations, hygiene }), so a theme file can be treated as code: fix what the audit names.
  • RegisterMode::Labeled — the theme registers anyway, and every finding comes back on Registration::warnings as a #FALLBACK:-prefixed line. Use this for user-supplied themes where refusing would strand the user — and surface the warnings, never swallow them.

Identity problems refuse in both modes: an empty or malformed id is RegisterError::InvalidId, and shadowing a built-in id or one of its aliases is RegisterError::ReservedId — a user theme silently replacing nord would be spoofing, not customization.

Accepted registrations are &'static (leaked once, stable for the app’s life, ~300 bytes each), visible to theme::get, theme::list, and theme cycling. Re-registering an id replaces it for future lookups while old handles stay valid; re-registering a byte-identical candidate returns the existing handle without allocating.

Deriving tokens from your house colors

You rarely design 36 colors by hand, and you should not reimplement the transform that avoids it. theme::Palette is the same seed input the built-in table uses — twelve authored colors, as owned strings, because a palette read from a config file at runtime is not &'static — and Palette::derive() runs the engine’s own derivation over them:

#![allow(unused)]
fn main() {
use abstracttui::theme::{register, set_theme, Palette, RegisterMode};

let mut palette = Palette::new("acme", "Acme", /* dark */ true);
palette.bg = "#101014".into();
palette.surface = "#16161d".into();
palette.surface_raised = "#1e1e29".into();
palette.text = "#e6e6ef".into();
palette.text_muted = "#a3a3b8".into();
palette.text_faint = "#6b6b80".into();
palette.accent = "#ff6188".into();
palette.accent_alt = "#a29bfe".into();
palette.ok = "#7ee787".into();
palette.warn = "#f0c85a".into();
palette.error = "#ff6b6b".into();
palette.info = "#6ec7ff".into();

let candidate = palette.derive()?;                     // 12 colors -> a full TokenSet
let reg = register(candidate, RegisterMode::Strict)?;  // the audit above judges it
set_theme(reg.theme);
}

Hex accepts #rgb, #rrggbb and #rrggbbaa, with or without the #. derive is the transform and nothing else — it neither audits nor validates the id, so register remains the single place a theme is judged and there is no second audit to keep in step. Malformed input comes back as a PaletteError naming every bad field, so a config with three typos costs one round trip.

All twelve are required, deliberately: there is no “fill the rest from bg and accent” shortcut. The colors an app is most likely to be missing are the semantic inks — accent_alt, ok, warn, error, info — and which green means “resolved” in a product is a decision, not a shade.

Going through this door rather than around it is what keeps your theme in step with the engine: the built-in table and Palette parse into the same seed type and run the same derivation, pinned byte-for-byte across all 26 built-ins by a test, so a change to a contrast floor reaches your palette too.

The derivation primitives

theme::derive exposes the steps that transform uses, for tooling that needs a single value rather than a whole theme:

  • mix(a, b, t), lighten(c, t), darken(c, t) — sRGB-space mixes (the perceptual limits are documented at the definitions; these are for small nudges within one theme, not long decorative gradients).
  • mix_until_contrast(base, ink, anchor, t0, step, floor) — walk a mix upward until it clears a contrast floor against its ground (how borders are derived).
  • tint_until_readable(base, tint, fg, t0, step, t_min, floor) — walk a tint downward until the foreground stays readable on it (how selection backgrounds are derived).

Use these to compose the twelve authored colors a Palette wants — a surface from a lighten/darken step off your ground, say — rather than to rebuild the twelve-to-thirty-six transform Palette::derive already runs. Building a whole TokenSet by hand is supported (register accepts any ThemeCandidate), but a hand-rolled transform drifts from the engine’s the next time a floor moves, and nothing will report it.

Design guidance for widget authors

Widgets built on AbstractTUI should speak tokens and nothing else — the engine’s own widget sources are lint-checked for raw hex. The conventions that keep a screen coherent:

Three focus/selection mechanisms, in priority order.

  1. The selection pair says “this is the thing keys act on” (list rows, table rows, selected text).
  2. A border_focus stroke says “this pane owns the keyboard” (bordered widgets and panes).
  3. accent ink is hover garnish.

Never render two selection pairs at different strengths — one pair, one meaning.

The state table.

  • Normal: content inks on their ground.
  • Hover: recolors the actionable ink to accent — decoration only; a hover state must never carry information focus does not.
  • Focus: border_focus stroke on bordered widgets; the selection pair on borderless ones.
  • Disabled: text_faint, and out of the focus order entirely — a focused-disabled widget cannot exist.
  • Selected: persists when the pane is unfocused; the owning pane’s stroke says where keys go.

Hard rules.

  • Tokens only; no color arithmetic in widgets — pre-composited tokens like shadow_ground exist precisely so widgets never blend.
  • Placeholders (text_faint) disappear on first input.
  • Underline-as-affordance is drawn as cells, never as a text attribute alone, so it survives 16-color terminals.
  • Every widget draws inside its rect; long spans clip rather than leak.

For a live rendering of all of this, run the widgets and gallery examples (cargo run --example gallery), and see ../examples/README.md.

Graphics and 3D

AbstractTUI renders real pixels in the terminal: PNG/JPEG images through the best channel the terminal offers, and software-rasterized 3D models (GLB) with textures, lighting, and animation — all with hand-rolled decoders and no GPU requirement. Degradation is always labeled, never silent: when the engine falls back to a lesser channel, the result says so.

Images end-to-end

Bytes to picture

gfx::decode_image(bytes) sniffs the magic bytes (containers lie, bytes don’t) and decodes PNG or JPEG into a gfx::Bitmap — an owned RGBA8 image with pixel get/set, nearest and bilinear resize, cropping, and a box-filter mip chain. Unknown formats reject by name, telling the caller what does decode (“PNG and JPEG decode, GIF/WebP/AVIF/TIFF do not” — a message you can show verbatim). Truncated or hostile bytes produce named errors, never panics; the decoders are fuzz-hardened.

The JPEG side reads the 8-bit Huffman frames a camera or an image editor actually writes: baseline, extended sequential, and progressive, grayscale or YCbCr, at 4:4:4, 4:2:2, 4:4:0, or 4:2:0, with interleaved or per-component scans and restart markers. Progressive files — what most editors emit by default, and the usual shape of a photo saved for the web — decode through the full spectral-selection and successive-approximation ladder, so the result is the final picture, not a first-pass approximation. Arithmetic-coded, lossless, hierarchical, 12-bit, and CMYK JPEGs reject by name.

Picture to terminal: the capability ladder

The engine picks the best channel the terminal proves it supports — gfx::choose_channel(&caps.graphics()) (the ladder reads the graphics view of the capability report) — best first:

channelhow it drawsmoves / resizesremovalrequires
kitty graphicsupload once by id, place by escapecheap re-place, no retransmittrue deletekitty graphics protocol
iTerm2full base64-PNG re-emit at the cursorfull re-emitcells overdrawOSC 1337 support
sixelpaletted raster at the cursorfull re-emitcells overdrawsixel + known cell pixel geometry; one shared palette
unicode mosaiccolored glyphs (it is cells)freefreeany terminal

Capabilities come from detection, not folklore: an instant environment pass, then an active query probe that can raise and lower the answer. Run any of the dashboard, viewer3d, or images examples with --caps to print the report for your terminal.

Three entry points, smallest first

  • gfx::render_to_cells(bitmap, rect, &caps) — one call. Picks the best mosaic mode for the probed terminal and returns ready-to-blit CellPatches.
  • widgets::Image — the widget: Image::from_path("logo.png") or Image::from_bitmap(Arc<Bitmap>) (the Bitmap type is re-exported beside the widget and in the prelude), with fit (Contain/Cover/Fill/None), alignment, and a mosaic-mode override. The widget always renders mosaic cells: a widget draw closure owns cells, not escape bytes, so pixel-protocol placement lives one level up.
  • gfx::ImageSession — pixel protocols with a lifecycle. Slots are keyed by the caller (SlotKey), content changes are declared by version bump, and each sync emits the minimum traffic the channel allows: kitty transmits once, re-places on move, and deletes on drop; iTerm2 and sixel honestly re-emit their full payload on any change. Bytes flow through an ExternalSink (the presenter adapts), and tmux passthrough wrapping is applied automatically when capabilities call for it. SyncOutcome tells you whether cells need repainting, bytes were written, or nothing changed.

gfx::present_image / ImageRenderer sit under all three: capability ladder on top, RenderConfig for the knobs (kitty wire format, placement z-index, sixel register budget and dithering).

Mosaic modes

Mosaic renders pixels as colored glyphs with a two-colors-per-cell best fit (weighted least squares):

  • HalfBlock — 1×2 pixels per cell using ▀. Exact and universal.
  • Quadrant — 2×2, the 16-glyph quadrant set. Universal glyph coverage.
  • Sextant — 2×3, the 64-pattern sextant set. Denser, but its U+1FB00 glyphs need a recent font — explicit opt-in, since no font probe exists and missing glyphs render as tofu.
  • Braille — 2×4, dots by luminance threshold. Structure rather than color; the strongest choice on monochrome-class terminals.

MosaicMode::auto(&caps) picks for you and returns the reason as a label: non-UTF-8 locales get HalfBlock (U+2580 survives most legacy codepages), monochrome terminals get Braille, color terminals get Quadrant.

Animated pictures (and why video is not one of them)

The engine plays a frame sequence in the cell grid. Two formats decode in-tree: animated GIF and APNG. Both are permissively licensed, free of patent pools, need no dependency the crate does not already have, and are small enough to review.

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::widgets::{AnimatedImage, ImageFit};

fn build(cx: Scope) -> View {
AnimatedImage::from_path("loading.gif")
    .fit(ImageFit::Contain)
    .view(cx)
}
}

gfx::decode_animation(bytes) is the decoder behind it, magic-routed like decode_image, and a still decodes to a one-frame animation — so one code path shows any picture, moving or not. Animation::frame_at answers “which frame shows now” for a caller driving its own clock.

Video does not decode here. .mp4, .mov, .avi, .webm and .mpg carry H.264, H.265, VP9, or MPEG-4. Those are patent-pooled, and a correct real-time decoder for any one of them is larger than this entire crate, so the engine does not pretend: the container is recognized, named, and refused with the line that converts it into something that does play.

mp4/mov: video is not decoded (animated GIF and APNG are).
Convert with: ffmpeg -i IN -vf 'fps=12,scale=480:-1' OUT.gif

To play video as it is, decode it OUTSIDE the engine and feed frames in — the same shape a terminal app uses for audio. Spawn ffmpeg -i clip.mp4 -f rawvideo -pix_fmt rgba -, read frames on a thread into a reactive::bounded_source, build a gfx::Bitmap per frame, and show each one with Image::from_bitmap. Kill the child in the scope’s on_cleanup: an orphaned decoder holds the file and the CPU. That dependency is the CALLER’s, deliberately — the engine ships no bindings to ffmpeg, claims no video support, and works without it.

What playback costs. A moving picture is the one thing here that cannot be idle, so the cost is stated rather than hidden: each frame arms ONE timer for that frame’s own delay, so a playing clip costs one wakeup per frame, nothing between frames, and nothing at all once it is paused or finished. Bytes are the channel’s business — a mosaic repaint is a cell diff (only what changed), which makes the universal path the cheapest one for motion, while kitty, iTerm2, and sixel each re-send a full payload per frame.

Getting more resolution out of a picture

A mosaic cell carries one glyph and two colors, so a picture’s resolution is the glyph family’s subpixel density — and the family is the first knob to reach for.

widgets::Image, markdown image blocks, and gfx::render_to_cells all follow MosaicMode::auto, so a UTF-8 color terminal already draws at quadrant density (2×2 per cell). Pinning Sextant buys another step where you know the font carries the Unicode 13 block sextants:

#![allow(unused)]
fn main() {
use abstracttui::gfx::MosaicMode;
use abstracttui::widgets::{Image, ImageFit};

Image::from_bitmap(photo)
    .fit(ImageFit::Contain)
    .mode(MosaicMode::Sextant) // 2x3 subpixels — check your font first
    .view(cx)
}

Measured on a 1200×1599 photograph drawn into a 24×32 cell pane, scoring each family against one common reference:

familysubpixels per cellPSNR
HalfBlock1×220.5 dB
Quadrant2×223.5 dB
Sextant2×324.3 dB

Two more levers, in order of effect:

  • Give the image more cells. Resolution is cells × subpixels, so a pane twice as wide is worth more than any family change: the same photo at 40×50 cells gains ~1.4 dB per family over 24×32.
  • Reach the pixel protocols. kitty, iTerm2, and sixel send real pixels, which no mosaic family can match. That path is gfx::ImageSession at the app level (a widget draw closure owns cells, not escape bytes) — see the three entry points above.

Optional Floyd–Steinberg dithering (serpentine error diffusion) can pre-quantize the source to a palette before cell fitting — worth it when the output terminal is 256- or 16-color, where straight quantization would band gradients. Sixel emission has its own configurable dithering.

Images under tmux

Inside tmux, graphics protocols are off by default because tmux swallows them unless the user set allow-passthrough on — a setting invisible from the environment. The engine verifies passthrough per session with a wrapped round-trip probe and only then enables the kitty/iTerm2 paths, wrapping every payload automatically. Known cosmetic limit: tmux cannot reflow passthrough images across scrolling or pane splits. Mosaic works everywhere regardless.

Verifying image support on your terminal

Two commands answer “what does my terminal actually support?”:

cargo run --example caps     # the capability report
cargo run --example images   # see it: mosaic families + protocol placement

caps renders the live capability set — the probe’s upgrades appear on screen moments after launch, and the images via line names the channel the ladder picked (kitty graphics protocol, iterm2 inline images, sixel, or unicode mosaic). Apps can read the same facts through use_caps(cx) / current_caps(). images then shows the result: the four mosaic glyph families side by side, and p places the same picture through the chosen pixel protocol, labeled with the channel in the footer.

What to expect per terminal: kitty, WezTerm, and Ghostty take the kitty graphics path; iTerm2 and VS Code’s terminal take OSC 1337 inline images; foot and mlterm take sixel; Terminal.app and most others render unicode mosaic — which is not a failure but the universal fallback, at character-cell resolution. Under tmux, protocols engage only when the passthrough probe proves allow-passthrough on (see above). If a protocol row reads yes but you see mosaic, check cell pixel size — without pixel geometry the ladder stays conservative.

Not an image: hand-drawn vector strokes

Charts, gauges, and hand-rolled traces do not go through the image pipeline at all. The public sub-cell canvas (DotCanvas, in the prelude) draws braille/quadrant dot grids with line/bezier/arc strokes and eighth-block fills — the same layer the shipped charts render through, and what the diagram extensions stroke their edges with. The full surface (dot-space model, primitives, the cell-color rule) is api.md § canvas; for node-and-edge diagrams, prefer the abstracttui-graph / abstracttui-mermaid crates over hand-stroking (graphs-and-diagrams.md).

Not an image: text drawn several cells tall

gfx::bigtext rides the same mosaic encoder described above, but its source is the engine’s embedded 8x16 font rather than a decoded picture. It exists because a terminal has one font size: a bigger glyph means spending more cells and subdividing them, so a banner heading or an oversized icon is a rasterization problem, not a typography one.

Because it reuses the encoder, the capability ladder and every mosaic mode apply unchanged — but the best mode differs from the image case. Braille packs the most subpixels per cell and terminals draw its dots with visible gaps, which reads well for photographs and poorly for letterforms; sextants are solid ink at 2x3 and usually read better as type.

Two rules the surface makes explicit, both easy to get wrong by guessing: how small you can go is a MEASUREMENT and not a constant — bigtext::smallest_clear(mode, content) takes it, because uppercase, mixed text and icons bottom out at different sizes and the mosaic mode moves all three — and a square icon needs twice as many columns as rows because a cell is about 1:2, which GlyphScale::square(rows) does for you. The sharp edge worth knowing before you pick: GlyphScale::FLOOR (4x3) is clear for everything in braille and only MARGINAL for mixed text in sextant, the mode this page just told you to prefer for type; smallest_clear sends you to 4x4 there.

The second edge is the one that produced a bug report, and it took two fixes. Pairwise distance measures whether two rasterizations DIFFER, and more columns always buys more difference — so a cheapest-clear search with no bound walks to the widest scale it can and calls it best. It used to return 6x2 for braille text, where a filled ● reduces to six near-solid cells and looks like a white bar; the measure was right that one bar differs from the next by 16 subpixels and had no way to notice that neither was its glyph any more.

The search is now bounded to twice the glyph’s natural aspect in either direction (GlyphScale::within_aspect_band), which also lets it reach past six columns: MosaicMode::HalfBlock clears at 7x5 for mixed text, and this page used to say it never cleared at all. And legibility now measures the shape as well as the distance — bigtext::fidelity_loss compares what is drawn against the same glyph at its natural proportions, so a scale you name yourself is graded honestly too: 6x2 comes back Legibility::Distorted, not Clear. The full surface, its limits (no accented glyphs) and the measurements behind the floor are in api.md § gfx::bigtext; cargo run --example bigtext shows every scale and symbol set on your own terminal.

3D end-to-end

The five-line hello

#![allow(unused)]
fn main() {
use abstracttui::three::{self, Framebuffer, SceneRenderer};

let view = three::quick_view("model.glb")?;      // load + framed camera + light
let mut fb = Framebuffer::new(160, 96);
SceneRenderer::new().render(&view.scene(), &mut fb);
// fb -> mosaic cells via MosaicRenderer, or use Viewport3D below.
}

quick_view (and quick_view_bytes for in-memory GLB data) returns a QuickView with public model, camera, light, and stats fields — adjust the camera and light freely between frames, then call .scene(). stats reports decode cost (texture decode dominates on textured models; a 2048² JPEG-textured asset loads in the ~100 ms class — show a loading state around it). look_from(yaw, pitch) re-frames the camera on the model’s bounds: the “reset camera” a viewer needs.

What loads (the GLB subset)

Binary GLB containers with embedded buffers; TRIANGLES primitives; positions, normals, UVs, and vertex colors; u8/u16/u32 indices or non-indexed geometry; node TRS and matrix hierarchies; multiple scenes (the default scene wins); baseColorFactor and baseColorTexture (embedded PNG or JPEG); emissiveFactor; smooth-normal generation on request; and a 2-million-triangle budget enforced from metadata before decode.

Rejected by name: sparse accessors, Draco/meshopt compression, non-triangle primitive modes, and out-of-range anything. Labeled degradations (the model loads, with a warning): external URIs, unsupported texture maps (normal/metallic-roughness/occlusion), morph weights, and CUBICSPLINE animation channels (skipped).

Scene, camera, light

Scene<'_> borrows a Model and carries a Camera (orbit-style: target, yaw, pitch, distance, vertical FOV, near/far), a Light (directional: direction vector or spherical from_angles, ambient + diffuse terms), a background color, and a double_sided flag.

Culling defaults differ by entry, deliberately: bare Scene::new culls back faces (procedural meshes are consistently wound), while Viewport3D and QuickView::scene() render double-sided (real-world GLB exports are not, and holes read as bugs). Flip double_sided explicitly when the other trade-off fits.

The Viewport3D widget

#![allow(unused)]
fn main() {
let vp = Viewport3D::new(Arc::new(model))
    .orbit(yaw, pitch, zoom)      // plain floats each build; signals live app-side
    .mode(MosaicMode::HalfBlock)
    .animate(0, t)                // play clip 0 at time t (loops; static = rest pose)
    .light_angles(azimuth, elevation)
    .fog(0.15)
    .on_orbit(move |dyaw, dpitch| { /* write yaw/pitch signals */ })
    .on_zoom(move |steps| { /* write zoom signal */ })
    .element(&tokens);
}

The widget is pure over its props: same props, same pixels. Left-drag orbits (the pointer is captured for the drag, so fast drags keep steering outside the rect), the wheel zooms — but the widget only reports deltas through on_orbit/on_zoom; the app owns camera state and clamping. element(&TokenSet) takes no scope because the widget holds no reactive state. The default layout grows into whatever region the parent hands it (a viewport has no intrinsic size to measure), so an un-layout()ed viewport is visible by construction; pass .layout(...) to size it explicitly. Buffers persist inside the draw closure, so a steady-state repaint allocates nothing. light, background, spin (caller-driven auto-rotation), and cull_backfaces round out the builder.

For a complete interactive viewer, run cargo run --example viewer3d -- model.glb.

Animation playback

Model::animations() lists the clips; sample_pose_full(clip, t, &mut Pose) produces per-instance world matrices plus per-skin joint matrices — pure in t, clamped to the clip’s keyframe range (loop with t % clip.duration()), and allocation-free in steady state (the Pose scratch is reused across frames).

Supported interpolation: LINEAR and STEP; rotations use shortest-path nlerp. Skinning: up to 4 joints per vertex (JOINTS_0/WEIGHTS_0), linear blend, sanitized at load — out-of-range weighted joints reject, drifted weight sums renormalize with a label. An animated, skinned test asset ships in the repository (src/three/fixtures/animated_bar.glb).

Textures and mip-mapping

Base-color textures decode through the same image pipeline (embedded PNG / JPEG) and build a box-filter mip chain. The rasterizer picks a mip level per triangle from the texels-per-pixel ratio, with bilinear sampling within the level; wrap mode is REPEAT.

The boot splash

An optional two-second identity animation for app startup, played before your first frame. boot::should_splash(&caps) is the production gate: it returns the reason to skip when the render handle is not a tty, when ABSTRACTTUI_NO_SPLASH is set (any value except 0, so wrapper scripts can force-enable), when NO_COLOR is set, when TERM=dumb, or when the capability report itself says the terminal is dumb. Respect the reason — it is ready-made for a log line.

The sequence runs 2.0 s in four beats: arrival (three planes fly in, staggered, on an ease-out curve), alignment (at 0.9 s the planes lock into the mark and a 12-spark burst fires), reveal (at 1.4 s the wordmark tracks open from 4 cells of letter-spacing to 1), hold (settle, then done). Any key skips with a fast 120 ms fade, and a hard 2.5 s wall cutoff bounds the whole thing.

Two render paths read the same identity constants: a 3D path (the mark rendered by the three rasterizer, chosen on truecolor terminals) and a pure-cell 2D path with its own particle field (everywhere else). Try both: cargo run --example splash (--3d / --2d to force one).

Honest limits

  • JPEG: 8-bit Huffman frames only — baseline, extended sequential, and progressive. Arithmetic coding, lossless, hierarchical, 12-bit precision, and CMYK reject by name. Chroma upsampling is nearest-neighbour, which costs a code or two against a smooth upsampler at terminal resolutions. Scan component selectors are validated against the frame header; malformed scans reject rather than decode wrong.
  • PNG: 8-bit depths, no interlacing (Adam7 rejects by name).
  • Animation: animated GIF and APNG decode in-tree. Video does not — .mp4, .mov, .avi, .webm, .mpg are recognized, named, and refused with a conversion command, because their codecs are patent-pooled and far larger than this crate. Frames are held decoded in memory (budget: 64 Mpx across a sequence), so a long clip belongs on the external-decoder path rather than in a Vec.
  • Sixel: one palette per emission — multiple live sixel images recolor each other. Prefer one sixel image per screen.
  • iTerm2/sixel have no placement model: any move or resize re-emits the full payload; only kitty gets placement escapes and true deletes.
  • Pixel protocols are verified byte-for-byte against the protocol specifications and a protocol state model, not against every live terminal emulator; mosaic is the universal, always-correct path.
  • Screenshots: cells under a kitty/iTerm2/sixel placement are not the picture — captures export those regions as labeled veils rather than fake cells, while mosaic images capture as themselves (they ARE cells). See api.md § “Screenshots & captures”.
  • Animation: LINEAR/STEP only; CUBICSPLINE channels and morph weights skip with labels; rotation interpolation is nlerp, not slerp.
  • Skinning: JOINTS_0/WEIGHTS_0 only (4 joints per vertex), linear blend, no inverse-transpose normal handling (an approximation under non-uniform scale).
  • Textures: base color only; other maps are labeled and ignored; wrap is REPEAT (per-sampler modes are not read). Mip LOD is per-triangle, not per-pixel.
  • Mosaic: two colors per cell, by construction; braille carries structure, not color.
  • Rasterizer: near-plane and guard-band clipping, top-left fill rule, perspective-correct depth and UVs; vertex-color interpolation is screen-linear (invisible at cell scale).
  • Performance numbers are load-sensitive: the envelope below is from an idle machine; medians inflate several-fold under host contention.

Performance envelope

Measured medians, release build, on a quiet machine (ms/frame):

assettriangles160×96320×192
synthetic sphere (untextured, gouraud)16,1280.760.97
helmet (JPEG textured + mips)15,4521.311.82
helmet (untextured)15,4521.181.59
x-wing (PNG textured + mips)119,9997.538.21
skinned sphere (animated, all vertices blended)65,0242.903.26

The renderer is vertex-bound at cell scale: 4× the pixels costs +9–39%, while 7.4× the triangles costs ~5.7×. Rule of thumb: assets up to ~20k triangles render in well under 2 ms anywhere; a 120k-triangle asset fits a 30 fps budget with 3–4× headroom on one core.

Mosaic conversion adds, for a 200×60-cell target (worst case): half-block ~50 µs, braille ~0.5 ms, quadrant ~1 ms, sextant ~3.7 ms.

Reproduce on your machine:

cargo test --release -- --ignored perf_three_envelope --nocapture
cargo test --release -- --ignored perf_mosaic_200x60 --nocapture

Graphs and diagrams — the extension family

Core stays lean; diagram-class capability ships as sibling crates you install only when needed (ADR-0004):

  • abstracttui-graph — graph auto-layout (GraphDesc -> Layout: layered, force and grid passes) plus GraphView, a read-only graph widget with selection, pan, tooltips and canvas-stroke edges.
  • abstracttui-mermaid — honest-subset mermaid rendering: flowcharts and flat state diagrams compile onto abstracttui-graph, sequence diagrams render through a deterministic solverless plan, and everything outside the subset falls back atomically.

Both are ordinary crates on the public core API — no private hooks, no cargo features, the same dependency posture as core (std + the family; the mermaid parser is hand-rolled).

Installing

[dependencies]
abstracttui = "0.5"
abstracttui-graph = "0.4"    # graph layout + GraphView
abstracttui-mermaid = "0.4"  # mermaid subset (depends on -graph)

The one data contract: GraphDesc -> Layout

You describe the graph; a layout pass returns positions. Every pass shares the same input and output types — consumers select the ALGORITHM, never a different contract:

#![allow(unused)]
fn main() {
use abstracttui_graph::{layered, GraphDesc, LayeredOpts};

let desc = GraphDesc::new()
    .node("fetch", 9, 3)          // id, card width/height in cells
    .node("build", 9, 3)
    .edge("fetch", "build");
let layout = layered(&desc, &LayeredOpts::default());
// layout.nodes: Rect + rank per node; layout.edges: waypoint
// polylines; layout.bounds: the content size a Scroll advertises.
}

Honesty markers ride the output: cycle-broken edges are MARKED (EdgeLayout::broken, Layout::broken_edges()), and Layout::fallback names every degradation (node cap exceeded, duplicate ids dropped, unresolvable edges skipped, grid placement) — None means the requested algorithm ran cleanly. Every pass is deterministic (same input, identical Layout, golden-pinned; float arithmetic avoids transcendentals so goldens hold across platforms) and bounded (sweep counts, node caps, iteration budgets — documented on the option types).

Picking a pass

PassUse forShape
layered(&desc, &LayeredOpts)workflows, dependency/build graphs, pipelines, state machines — DAG-shaped data (cycles get broken and marked)sugiyama-lite: longest-path ranks, bounded median crossing-reduction sweeps, aligned-median coordinates, waypoints through rank gaps; directions TD/LR/BT/RL
force(&desc, &ForceOpts)knowledge graphs, networks — cyclic, dense, non-hierarchical data that defeats layeringseeded, alpha-cooled repulsion + edge springs + optional rank_bias; a bounded ACT that freezes on settle (never an idle animation — cache the Layout, re-render from the cache)
grid(&desc)the honest fallbacknear-square row-major placement, always labeled

The grid is also what layered degrades TO: past the node cap (default 512) it returns the grid placement with the cap named in Layout::fallback — a labeled grid beats a hung solver at terminal scale. Measured on a dev machine (unoptimized profile): 500 nodes / 718 edges lay out in ~14 ms (layered) and ~30 ms (force, budget 64).

GraphView

GraphView renders a Layout: node cards (title on the border, an optional kind-tinted left accent, a reactive badge slot), edges as sub-cell canvas strokes (smoothed beziers through the waypoints, arrowheads, dotted/thick styles from EdgeDesc::style, cycle-broken edges dotted in their own ink — drawn through core’s public canvas layer), the fallback label as a non-scrolling notice line, and pan via Scroll.

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui_graph::{GraphDesc, GraphStyle, GraphView, NodeDesc};

// Inside a view builder (cx: Scope), colors caller-resolved from the
// active theme per the widget token rule:
let t = use_theme(cx).get().tokens;
let style = GraphStyle::from_tokens(&t)
    .kind_accent("ok", t.ok)
    .kind_accent("error", t.error);
let view = GraphView::new(
    GraphDesc::new()
        .with_node(NodeDesc::new("a", 12, 3).label("Fetch").kind("ok"))
        .with_node(NodeDesc::new("b", 12, 3).label("Parse").kind("error"))
        .edge("a", "b"),
)
.style(style)
.badges(|id| (id == "a").then(|| "3".to_string()))
.tooltips(std::time::Duration::from_millis(300))
.on_node_press(|id| eprintln!("pressed {id}"))
.view(cx);
}

Interaction is ONE focus stop: arrows pan until a node is selected; Enter selects the first node, then arrows move the selection spatially (aligned-first, deterministic tiebreaks), Enter presses it (on_node_press), Escape deselects. Clicking a card selects and presses; hovering shows a tooltip when enabled. Layout runs at view-build time (an act): rebuild the view (dyn_view over your data signal) to relayout — a parked GraphView costs zero idle, test-pinned. Algorithm selection: .algo(GraphAlgo::Force(opts)), or .with_layout(layout) to render positions you computed (or dragged) yourself.

Run the examples: cargo run --example workflow (layered pipeline with a marked retry cycle) and cargo run --example network (force-directed).

Mermaid: the honest subset

Mermaid has no spec grammar, and “faithful” is the wrong bar for a terminal. abstracttui-mermaid renders an exhaustive, tested subset natively and falls back ATOMICALLY on everything else. The table below is the contract, verbatim from the crate docs (docs.rs/abstracttui-mermaid); the YES rows enumerate accepted SPELLINGS, and any spelling outside them triggers the fallback naming the first unrecognized line — unknown syntax is safe by construction:

MermaidStatusAccepted spellingsBehavior
flowchart / graph TD/TB/LR/BT/RLYESheader keyword + direction tokenlayered layout (BT/RL as transposes)
Node shapesYESid, id[text], id(text), id{text}, id([text]), id((text)), id[[text]], id[(text)], id{{text}}, id>text]; quoted "text" inside bracketscards; shape = accent + badge sigil (see below)
EdgesYESany body length (-->, --->, ---, -----), dotted (-.->, -..->, -.-), thick (==>, ===); heads >, x, o; <-->strokes; dotted/thick as stroke styles
Edge labelsYESpostfix |label| and infix (-- label -->, -. label .->, == label ==>)drawn centred in the rank corridor
Chaining / & groupsYESA --> B --> C; A & B --> C & Done edge per link; & is a cross product
subgraphFLATTENEDsubgraph id, subgraph id [Title], nesting, direction insidemembers render; the GROUP BOX does not, with a notice
sequenceDiagramYESparticipant id [as alias]; messages ->>, -->>, ->, --> with : text; Note left of/right of/overdeterministic columns/rows — no solver
sequence blocksYESalt + else, opt, loop, par + and, nesteda labeled frame with dashed branch dividers
sequence activationsYESactivate id / deactivate id, and the ->>+ / -->>- suffixesa bar on the lifeline; nested bars step right
sequence rect/critical/break/boxNO—atomic fallback
stateDiagram-v2 (flat)YES[*], id, id : label, --> with : labelcompiles to the flowchart engine
classDiagram, erDiagram, gantt, pie, journey, mindmap, timeline, gitGraphNO—atomic fallback
classDef, style, click, linkStyle, class, direction, %%{init}IGNOREDrecognized-and-dropped WITH a notice; %% comments drop silentlyrender proceeds

The link rule that decides chaining vs labels. A body of exactly TWO dashes (--, ==) OPENS a labelled link whose text runs to the closing body; THREE or more (---, ----) is a complete open link. So A -- B --> C is one labelled edge and A --- B --> C is a two-edge chain — mermaid’s own rule, and the only way to tell them apart without guessing.

Sequence frames are chrome drawn UNDER the conversation: where a frame’s border crosses a lifeline or an activation bar, the border wins the cell — one cell cannot show both, and a broken frame reads worse than a broken line. Block nesting is capped at 64; deeper input falls back by name rather than recursing the renderer into a stack overflow.

Labeled downgrades (rendered, and said out loud in a notice): the x and o arrowheads and <--> bidirectionality have no cell glyph and draw as plain arrows; <br/> in a label flattens to a word break because a card is one line; a subgraph renders its members without the group box.

Flat stateDiagram-v2 parses as a third front end to the flowchart IR ([*] becomes synthetic start/end nodes). Cell-honest shape mapping: terminal cards do not rotate into diamonds — a shape arrives as the card’s accent kind plus a badge sigil (id{..} → decision + ◆, id(..) → rounded + ○, id([..]) → stadium + ◎). Lexical notes (normalization, not new constructs): ; is a statement terminator, %% comments strip to end of line (quote-aware); the infix label form (--label-->), &-chaining, and edge chaining (A --> B --> C) are named v2 fallbacks.

The atomic fallback

If a diagram contains ANY construct outside the table (styling directives excepted), the WHOLE diagram renders as the code fence it already is — verbatim source, monospace — plus one notice naming the first unsupported construct and line, plus an optional mermaid.live link (live_link_url): the diagram travels in the URL FRAGMENT, never to a server; nothing is shared until the user opens the link. Partial rendering of a half-understood diagram misleads; the code block never lies.

Diagrams inside a markdown document

A ```mermaid fence renders as a diagram in place, in the document’s own scroll surface — not as a code block, and not as a separate widget the app has to splice in:

#![allow(unused)]
fn main() {
use abstracttui::widgets::MarkdownView;
use abstracttui_mermaid::MermaidFence;
use std::rc::Rc;

MarkdownView::new(source).fence_block(Rc::new(MermaidFence::new()))
}

The seam is widgets::FenceBlock in the core: a claimant measures a fence it recognizes and paints its rows. Fences it declines render as code, unchanged. The engine ships no diagram languages — this is how a sibling crate brings one without the core knowing it exists.

MermaidFence renders once per (source, width, theme) and serves rows from that, so scrolling a long document does not re-render its diagrams.

Usage

#![allow(unused)]
fn main() {
use abstracttui_mermaid::MermaidView;

// In a view builder:
let view = MermaidView::new(
    "graph TD\n  A[Start] --> B{Ship?}\n  B -->|yes| C(Done)",
)
.view(cx);

// Pure-data entry points for other consumers:
// parse(&str) -> Result<Diagram, Unsupported>   (IR or named reason)
// to_graph(&FlowchartIr) -> (GraphDesc, LayeredOpts)
// live_link_url(&str) -> String                  (the escape hatch)
}

Run the demos:

  • cargo run --example mermaid — nine embedded samples: chaining, infix labels, & groups, the shape vocabulary, a flattened subgraph with its notice, sequence control-flow blocks, activations, and an honest fallback (or pass a .mmd path).
  • cargo run --example mermaid_doc — a markdown document whose ```mermaid fences render as diagrams in place (or pass a .md path).

Honest limits (v1)

  • Open links (---) carry the open style hint and render as arrowless strokes in GraphView (mermaid’s undirected reading).
  • Sequence gaps size to ADJACENT-pair message labels; long labels between distant columns truncate with an ellipsis.
  • Edge chaining (A --> B --> C), infix labels, &-chaining and subgraph are named fallbacks, not silent acceptance — growth is new table rows with tests.
  • Force layouts report rank 0 for every node (no hierarchy is computed — honest, not missing data).

Live data: background sources into the UI

How a networked, long-lived app gets data from a background thread into signals — the ownership rule, the named bindings, bounded back-pressure, and the recurring time source. The runnable companion is examples/feed.rs.

The ownership rule (one sentence)

The reactive graph is single-threaded: background threads never touch signals — the only sanctioned crossing is a closure posted to the UI thread, and writing a signal from the wrong thread is a named panic (it tells you to use this page’s pattern), never silent aliasing.

Under the hood that crossing is reactive::WakeHandle::post(f): the closure crosses the thread boundary, wakes the event loop, and runs in the next frame’s phase U with full runtime access. Two guarantees come with it:

  • Ordered delivery — one producer’s posts apply in emit order (FIFO; cross-producer order is lock-acquisition order).
  • One frame per burst — any number of posts between two frames coalesce into one wake and one repaint; a post landing mid-frame applies in the next frame, exactly once.

And one cost rule: while a source is quiet, the app is byte-for-byte idle — no polling, no timers, the loop parks in a blocking read.

The bindings

You rarely post by hand. Three named helpers in reactive:: cover the shapes (all senders are Clone + Send; all signals die with the scope that created them, after which senders turn inert — sends apply nothing, count on dead_sends(), and are never unsafe):

helpersignal shapedeliveryuse for
channel_source(cx)Signal<Vec<T>>every value, in order, unboundedlow-rate event streams
latest_source(cx, initial)Signal<T>newest value; intermediates coalesce at the sourceprogress, telemetry, presence
bounded_source(cx, capacity, policy)Signal<Vec<T>> window + Signal<IngestStats>at most capacity retained; overflow per policy, countedanything that can flood

Bounded ingestion and back-pressure honesty

WakeHandle::post is the control lane: unbounded by contract, correct for low-rate messages. A flooding producer (chat hub, tool output, tail -f) needs the data lane:

#![allow(unused)]
fn main() {
let (tx, events, stats) = bounded_source::<String>(
    cx,
    400,                        // the retained window, and the bound
    OverflowPolicy::DropOldest, // what overflow MEANS is the app's call
);
}
  • DropOldest — ring: the newest tail survives (feeds, logs).
  • DropNewest — the head survives (capture the first N).
  • OverflowPolicy::coalesce(fold) — overflow merges into the newest survivor (progress updates that supersede each other). A fold that PANICS degrades labeled instead of poisoning the lane: the panic is caught, the value it consumed counts as dropped, the event counts as fold_panics, and later sends keep working.
  • There is no Block. Blocking a producer against the UI thread inverts liveness: the producer inherits every UI stall (a held scrollbar, a suspended terminal, a modal) as unbounded latency on its own sockets and locks, and can no longer answer the cancellation the UI is about to send it. Producers that must not lose data pause their reads upstream (the transport pushes back); they never park on the UI.

Honesty is part of the contract: every value lands in exactly one bucket of stats (IngestStats { delivered, dropped, coalesced, fold_panics } — the last counts events, its values are inside dropped), updated atomically with the window. Render dropped (and fold_panics) when nonzero — “1.2k shown · 34 dropped” is the labeled-degradation convention; silent loss is the failure mode this lane exists to prevent. delivered + dropped + coalesced always equals values sent; a DropOldest window aging out an already-shown item is retention churn, deliberately not re-counted as a drop. Memory is bounded by construction (≤ 2×capacity across the transit buffer and the window) and a burst costs one wake, one posted drain, one frame — no matter how many values arrive.

Producer-side guidance: drain everything available per read and send per item into the bounded lane (it batches internally), or batch into few post closures on the raw lane. One closure per burst is the intended cadence for high-rate sources.

The recurring time source

Time is the zeroth data source. reactive::interval is the engine-owned version of the self-rescheduling after(..) recursion, with the cancellation story the recursion never has:

#![allow(unused)]
fn main() {
let handle = interval(cx, Duration::from_secs(1), move || {
    now.set(clock_text()); // runs on the UI thread, phase U
});
// handle.cancel() stops it early; scope disposal cancels it anyway —
// a closed pane's poller cannot keep ticking by accident.
}

Fixed-delay drift policy: the next deadline is fire time + period. After a suspend of N periods it fires once and resumes cadence — missed ticks coalesce, there are no catch-up storms. Between fires an armed interval costs zero wakeups (the loop sleeps until the deadline); timers never frame-pace.

Connection lifecycle

Reconnect is the half of networking with no transport dependence at all, so the engine owns it: reactive::connection is the state machine, reactive::Backoff the retry schedule. The engine still does no network I/O — you supply the dial function; the transport stays your call. What you stop hand-rolling: the state enum, the backoff math, the retry timer, the cancellation story, and the answer to “what does the frame loop do while offline” (nothing — the one armed one-shot costs zero wakeups until due).

stateDiagram-v2
    [*] --> Connecting : connection(cx, backoff, dial)
    Connecting --> Connected : events.connected()
    Connecting --> Degraded : events.degraded(reason)
    Connecting --> Reconnecting : events.failed(reason)
    Connected --> Degraded : events.degraded(reason)
    Degraded --> Connected : events.connected()
    Connected --> Reconnecting : events.failed(reason)
    Degraded --> Reconnecting : events.failed(reason)
    Reconnecting --> Connecting : retry timer fires / retry_now()
    Connecting --> Closed : close() / events.closed()
    Connected --> Closed : close() / events.closed()
    Degraded --> Closed : close() / events.closed()
    Reconnecting --> Closed : close() (armed retry cancelled)
    Closed --> [*]

Every transition is a signal write — the UI renders the state like any other signal, and each state carries what honest rendering needs (Degraded(reason); Reconnecting { attempt, next_in } for “reconnecting (attempt 2) in 1.4s”). Success resets the schedule; Closed is terminal from either side (UI close(), transport closed(), or scope disposal) and costs nothing forever.

#![allow(unused)]
fn main() {
use abstracttui::prelude::*;
use abstracttui::reactive::spawn_worker;

let conn = connection(cx, Backoff::default(), move |events| {
    // Runs on the UI thread once per attempt: spawn the blocking
    // transport work and return immediately.
    let events = events.clone();
    spawn_worker("hub-stream", move || {
        match dial_hub() {                       // your transport
            Ok(stream) => {
                events.connected();
                while let Ok(msg) = stream.read() {
                    if events.is_closed() { return; }  // stop condition
                    tx.send(msg);          // into a source lane above
                }
                events.failed("stream ended");   // drop -> reconnect
            }
            Err(e) => events.failed(e.to_string()),
        }
    });
});
// Render it honestly — a badge, a status line, dimmed panes:
let state = conn.state();
dyn_view(LayoutStyle::line(1), move || match state.get() {
    ConnState::Connected => text("● online"),
    ConnState::Degraded(r) => text(format!("◐ degraded: {r}")),
    ConnState::Reconnecting { attempt, next_in } =>
        text(format!("○ retry #{attempt} in {next_in:.1?}")),
    ConnState::Connecting => text("… connecting"),
    ConnState::Closed => text("· closed"),
});
}

Why full jitter. A fleet of clients backing off base × 2^n with no jitter retries in lockstep after a server restart — every retry wave lands together and the herd re-kills the thing it is waiting for. The common hand-roll (linear 500ms × errors, capped, no jitter) has exactly that failure mode. Backoff draws uniformly from [0, min(cap, base × 2^n)] (defaults: base 500 ms, cap 30 s), so retries decorrelate while pressure on a dead endpoint still decays exponentially. Backoff::seeded(n) makes tests deterministic.

Three rules the machine enforces so you don’t have to:

  • Stale attempts can’t lie. Each dial gets a generation-stamped reporter; once a failure is accepted, later reports from that attempt (a zombie worker racing its replacement) are inert and counted (stale_reports) — attempt N can never flip attempt N+1’s state. Workers poll events.is_closed()/is_current() to stop early.
  • Cancellation is scope death. The connection dies with cx like everything else: armed retry removed, dial fn dropped, workers see is_closed(). conn.close() does the same on demand; conn.retry_now() skips a pending wait (the “retry now” button).
  • Offline is idle. Between transitions the loop stays parked; the only clock is the one armed one-shot. A visible countdown is an ordinary interval, billed as such — never a poll loop.

Catch-up after reconnect (cursors, replay, resubscription) is deliberately NOT here — it is transport/protocol policy (the app’s dial fn re-subscribes; per-channel cursors in the reference domain), and the state enum must never grow transport-specific fields.

Worker lifecycle

Spawn producers with reactive::spawn_worker(label, f): a worker panic is posted back and surfaces as a labeled app error (Driver turns it into Err), instead of a thread dying silently while the feed just… stops. A clean return is not an error. Give workers a stop flag and join them after App::run returns (the feed example’s teardown), so no thread outlives the terminal session.

Copy-paste starting point

The rendering side pairs the bounded window with the Feed widget (keyed rich items, windowed paint) inside Scroll with the engine’s follow-tail: the content extent is measured (no size hint), the offset sticks to the bottom until the user scrolls up, and setting the follow signal true jumps back to the latest. The window syncs into slot keys, so the Feed holds at most capacity items — bounded end to end. (FeedState::clear enables a simpler clear-and-repush sync; the slot-key recipe shown here re-typesets only the slots whose content actually changed.)

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use abstracttui::prelude::*;
use abstracttui::reactive::{bounded_source, spawn_worker, OverflowPolicy};
use abstracttui::widgets::{Feed, FeedItem, FeedState};

fn main() -> abstracttui::base::Result<()> {
    if !abstracttui::term::have_tty() {
        return Ok(());
    }
    let stop = Arc::new(AtomicBool::new(false));
    let mut app = App::new(Size::new(80, 24));
    let mut sender = None;
    app.mount(|cx| {
        let (tx, events, stats) =
            bounded_source::<String>(cx, 400, OverflowPolicy::DropOldest);
        sender = Some(tx);
        let feed = FeedState::new(cx);
        let feed_sync = feed.clone();
        cx.effect_labeled("window-sync", move || {
            events.with(|rows| {
                for (i, line) in rows.iter().enumerate() {
                    feed_sync.push(format!("slot-{i}"), FeedItem::text(line.clone()));
                }
            });
        });
        let follow = cx.signal(true); // read it for chrome; set true to jump
        Element::new()
            .style(LayoutStyle::column())
            .child(
                Scroll::new(Feed::new(&feed).gap(0).view(cx))
                    .follow_tail(follow)
                    .view(cx),
            )
            .child(dyn_view(LayoutStyle::line(1), move || {
                let s = stats.get();
                text(match s.dropped {
                    0 => format!("{} events", s.delivered),
                    d => format!("{} events · {d} dropped", s.delivered),
                })
            }))
            .build()
    })?;
    let tx = sender.take().expect("mounted");
    let stop_w = stop.clone();
    let worker = spawn_worker("my-source", move || {
        while !stop_w.load(Ordering::Relaxed) {
            // read your socket/process/channel here, then:
            tx.send("hello from the background".to_string());
            std::thread::sleep(std::time::Duration::from_millis(500));
        }
    });
    let result = app.run();
    stop.store(true, Ordering::Relaxed);
    worker.join().ok();
    result
}

Swap the sleep for a real read loop and this is a networked app: the transport (HTTP poll, WebSocket, subprocess pipe) is your choice — the engine’s job ends at the thread boundary, and this page is that boundary’s contract. The runnable version with bursty timing, pause, and an events/sec interval is examples/feed.rs.

Message cards that fold (per-item disclosure)

Hub watchers and chat transcripts render each message as a card with a one-row title that folds/unfolds. Standalone cards (settings panes, tool results outside a feed) are the Disclosure widget directly — capped scrolling body, click/Enter toggling, app-owned fold signals. INSIDE a Feed, keep the feed’s virtualization and compose the same semantics: fold state in a Signal<HashMap<key, bool>>, the fold bit folded into each item’s SyncSpec fingerprint ((rev, folded) — a toggle re-typesets exactly that item), folded items rendered as one rich header line, unfolded as header + body blocks, Enter on the selected key plus click-to-toggle via Feed::on_item_press gated on row_within_item == 0. The full recipe (and why the in-feed card is a pattern rather than an engine block kind today) lives in api.md → “The message-card recipe”.

Testing live-data apps

The headless harness works unchanged: drive Driver::turn against testing::CaptureTerm, send from a joined thread between turns, and assert on the rendered screen — one frame per burst, zero bytes while quiet. tests/wave_livedata.rs pins exactly those claims and is a gallery of the shapes.

Time is scriptable too: timers arm and fire against the loop’s clock, so an injected test clock drives interval cadence and Backoff retry deadlines deterministically — the driver publishes its clock around each turn automatically, and a custom loop calling run_due_timers(now) publishes the same value with reactive::set_loop_clock(Some(now)) (None restores real-time arming). See api.md § reactive for the one-clock story.

FAQ

Why another TUI library?

Most terminal UI libraries make one of two bets: immediate-mode (redraw everything every frame, diff at the end) or a retained widget tree with coarse invalidation. AbstractTUI makes a different one: fine-grained reactive signals driving a layered compositor with damage tracking. State lives in signals; only the regions that read a changed signal re-render; the compositor diffs only damaged cells; an idle app sits in a blocking read at zero CPU. On top of that sits capability-driven graphics (real images and software-rasterized 3D with labeled fallbacks) and a 36-token theme system with enforced contrast floors. If your app is a short-lived form, simpler designs are fine; AbstractTUI is built for long-running, composed, animated applications that should still cost nothing when nothing happens.

How does AbstractTUI compare to ratatui, Textual, Bubble Tea, notcurses, and Ink?

They all build full-screen terminal UIs; the architectural split is how updates reach the screen. Most established libraries are immediate-mode or reconciliation-based: the application (or a component layer above it) rebuilds its view every frame or every state change, and the library diffs the result before writing bytes. AbstractTUI is neither — state lives in fine-grained signals, a write re-runs exactly the computations that read it, and those re-renders damage only the screen regions they own, flowing through a z-ordered compositor (alpha blending, per-cell shaders), a frame diff, and a byte-economical presenter. There is no per-frame rebuild and no tree reconciliation, and the idle cost is exactly zero — an unchanged app emits zero bytes and allocates nothing, enforced by tests (architecture.md).

LibraryLanguageUpdate and rendering model
ratatuiRustImmediate mode: rebuild the widget tree each frame into a buffer, diff at present time. The largest Rust TUI ecosystem; event loop and async are yours to bring.
TextualPythonRetained DOM-like widget tree with CSS styling and reactive attributes on an asyncio loop; apps can also be served to a browser.
Bubble TeaGoThe Elm Architecture: messages update a model, the view renders text, the runtime diffs frames; bubbles/lipgloss supply components and styling.
notcursesCImperative stacked planes with best-in-class terminal media (images, video, sixel/kitty); bindings for many languages; a thin widget layer.
InkJS/TSReact for the terminal: components and hooks reconciled through a virtual DOM onto Yoga flexbox; strongest in Node CLI tooling.
AbstractTUIRustFine-grained signals drive damage into a layered compositor, then a frame diff and presenter; layout is a flexbox-style solver plus a track grid.

Two other differences are structural rather than stylistic. First, media: images (kitty/iTerm2/sixel/unicode-mosaic ladder), software-rasterized GLB 3D, and a sub-cell vector canvas are in the core crate with hand-rolled decoders — five small dependencies total; in the ecosystems above, comparable capability is external (ratatui-image), C-library-bound (notcurses), or absent. Second, testing: the headless harness drives the production pipeline — real dispatch, focus, damage, and emitted bytes — against an in-memory terminal with a VT interpreter, and Screenshot exports deterministic text, replayable ANSI, or SVG for goldens and round-trips, without a pty.

The honest limits, stated plainly: this is a young project (first public release 2026-07-21) with a small ecosystem next to ratatui’s, one core crate plus two extension crates, and few third-party widgets. Windows is compile-checked and unit-tested but not yet exercised on live hardware (macOS and Linux are the verified platforms). Accessibility roles and labels exist in the semantic tree, but no screen-reader bridge consumes them yet. There is no async-runtime integration — background work crosses on threads via wake handles, which is deliberate but means no built-in HTTP/WebSocket client. If you need battle-tested breadth today, ratatui (Rust) or Textual (Python) are the mature choices; AbstractTUI’s case is compositor-grade rendering, zero idle cost, and built-in media in one audit-in-a-sitting dependency graph.

Does it work over SSH?

Yes. Everything the engine does travels as bytes over the pty, which is exactly what SSH carries. Capabilities are detected from the terminal that is actually attached — your local emulator — via an environment pass plus an active query probe, so color depth, image protocols, and keyboard enhancements reflect what your end of the connection supports. Expect the same feature set you would get locally in the same emulator; only latency changes.

Which terminals support images?

Anything, at some rung of the ladder. Kitty-protocol terminals get the best channel (upload once, cheap moves, true deletes); iTerm2-protocol terminals get inline images with full re-emits; sixel terminals get paletted rasters; every terminal gets unicode mosaic, which is plain colored glyphs and needs nothing. The engine probes and picks; run cargo run --example caps for the live capability report (the images via line names your channel), and cargo run --example images to see the result. See graphics-and-3d.md for the full ladder and the per-terminal expectations.

Which image formats decode?

PNG, JPEG, and GIF, decided by the file’s magic bytes rather than its extension or a declared MIME type. PNG covers 8-bit depths without interlacing; JPEG covers the 8-bit Huffman frames — baseline, extended sequential, and progressive — in grayscale or YCbCr at 4:4:4, 4:2:2, 4:4:0, and 4:2:0, with interleaved or per-component scans and restart markers. Progressive is what most editors emit by default, so it decodes to the same final picture as a sequential file, not to a first-pass approximation.

Animations decode too — animated GIF and APNG, through gfx::decode_animation and the AnimatedImage widget. Video does not: .mp4, .mov, .avi, .webm, and .mpg are recognized and refused by name with a conversion command, because their codecs are patent-pooled and each is larger than this whole crate. To play video, decode it with an external tool and feed frames in (see graphics-and-3d.md).

Everything else rejects by name, with a message you can show a user verbatim: interlaced PNG, arithmetic-coded / lossless / hierarchical / 12-bit / CMYK JPEG, WebP/AVIF/TIFF, and the patent-pooled video codecs (H.264, H.265, VP9, AV1, MPEG-1/2/4). Nothing renders a wrong picture in place of an unsupported one. See graphics-and-3d.md for the decoder coverage and troubleshooting.md for what to do with a refusal.

Why is my emoji/wide-character layout off in one terminal?

Because terminals genuinely disagree about the width of some characters. Emoji presentation sequences (VS16), ZWJ families, and East-Asian-Ambiguous symbols render at different widths across emulators and configurations — there is no protocol to ask. The engine measures with a consistent width policy and defends its cursor after emitting a risky cluster, so a disagreement stays confined to that cluster instead of smearing everything after it on the line. If your terminal is configured ambiguous-wide, cell layout of every TUI breaks regardless; prefer the terminal’s default width configuration, and prefer unambiguous glyphs in structural chrome.

How do I test my app headlessly?

Drive the same pipeline production uses against a captured terminal — no tty needed:

#![allow(unused)]
fn main() {
use abstracttui::app::Driver;
use abstracttui::testing::CaptureTerm;

let mut term = CaptureTerm::new(size);
let mut driver = Driver::new(&mut app, &mut term, cfg)?;
driver.turn(&mut app, &mut term)?;             // one full frame cycle
assert!(term.screen().to_text().contains("n = 0"));
term.push_input(b"+");                          // bytes, as a terminal would send
driver.turn(&mut app, &mut term)?;
}

CaptureTerm records the emitted bytes and models the screen, so you assert on rendered text (or raw bytes) with every dispatch, focus, and damage path being the real one. A capture terminal is not a tty, so undeclared capabilities resolve to Capabilities::headless() (full color, UTF-8, terminal-bound features off) rather than to the environment of whoever runs the suite — set RunConfig::caps explicitly when a test needs a particular color depth. For pure component tests, skip the driver: mount into a ui::UiTree, dispatch events, draw into a buffer canvas.

How do I capture a screenshot of my app?

Capture the screen as a plain value and export it: Screenshot (in the prelude) exports deterministic plain text (to_text), replayable ANSI you can cat back into a terminal (to_ansi), and a GitHub-renderable SVG (to_svg). Three capture surfaces: driver.screenshot() for embedders and tests (the frame as last presented), app::request_screenshot(cb) from any key handler (bind your own key — there is deliberately no engine default), and term.screen().screenshot() in headless tests (what the emitted bytes actually produced). One honesty rule: pixel-protocol image regions export as labeled veils, never as fake cells. See api.md § “Screenshots & captures” and cargo run --example screenshot.

Can I draw custom vector graphics — or render graphs and diagrams?

For hand-rolled traces, the public sub-cell canvas (DotCanvas, in the prelude) gives you braille/quadrant dot grids with line/bezier/arc strokes and eighth-block fills — the same layer the shipped charts draw through (api.md § canvas). For node-and-edge diagrams, don’t hand-stroke: the sibling crates abstracttui-graph (auto-layout + GraphView) and abstracttui-mermaid (honest mermaid subset) install only when you need them — see graphs-and-diagrams.md.

Can I embed AbstractTUI in an existing event loop?

Yes. App::run is a convenience, not a requirement. Driver::turn runs exactly one frame cycle and never blocks — the blocking edge is a separate wait call, so your own loop decides when to pump. Headless surfaces (pump, draw) drive the reactive and layout pipeline without a terminal at all, and the unix terminal can be constructed over explicit file descriptors for embedders.

Why the near-zero dependency policy?

The dependency policy is a hard rule: std plus a minimal, low-level, permissively-licensed set — unicode-width, unicode-segmentation, miniz_oxide (inflate for PNG), and the platform bindings (libc on unix, windows-sys on Windows). Everything else is hand-rolled: ANSI emission, the input parser, the flexbox solver, the signals runtime, JSON parsing for glTF, PNG chunking and defiltering, JPEG decode, base64, sixel encoding, and the 3D math and rasterizer. The payoff is a dependency graph you can audit in one sitting, fast clean builds, no feature-flag matrix, and behavior that changes only when this crate changes.

How do themes stay readable?

Every theme — built-in or registered at runtime — is audited against WCAG-derived contrast floors: body text at 4.5:1, muted text at 3:1, accents and semantic marks at 3:1, selection text at 4.5:1, and so on down to hairline borders at 1.5:1. The built-in family passes with zero violations as a test invariant, and theme::register runs the same audit on your themes — refusing in strict mode or labeling every finding in labeled mode. See theming.md for the full table.

The audit covers the theme’s own grounds. If your app paints a ground of its own — a card fill, a client’s panel colour — pick its text with theme::contrast::ink_on(&tokens, ground), which returns the theme’s most readable authored ink together with the ratio it achieved, and declare the ground through RunConfig::extra_grounds so it stays distinct from the theme’s when colour downlevels to 256.

Can I use my own brand palette?

Yes, through the same derivation the built-in themes use. Fill a theme::Palette with your twelve authored colours (grounds, three text tiers, two accents, four semantic inks), call derive(), and register the result:

#![allow(unused)]
fn main() {
use abstracttui::theme::{register, set_theme, Palette, RegisterMode};

let mut palette = Palette::new("acme", "Acme", true);
palette.bg = "#101014".into();
// ... the other eleven ...
set_theme(register(palette.derive()?, RegisterMode::Strict)?.theme);
}

Borders, selection tints, focus rings, the chart ramp and the syntax family are derived for you by the contrast-guarded rules the registry runs, and register audits the result. Building a TokenSet by hand still works, but a hand-rolled derivation drifts from the engine’s the next time a floor moves. See theming.md.

What happens on a dumb terminal, or with NO_COLOR?

Both are honored. TERM=dumb (or an empty TERM) marks the terminal as not worth escaping at: the active capability probe is skipped entirely and the splash refuses to play. NO_COLOR forces color depth down regardless of what the terminal supports, and the raw fact is surfaced so themes can react. On limited-color terminals, the presenter quantizes to the 256- or 16-color palette pairwise — foreground and background are re-picked together so text never vanishes into its own background. At 256 colors the theme’s grounds additionally get one palette entry each, so panel elevation survives the downgrade instead of collapsing into the app field; grounds your own app mints are declared through RunConfig::extra_grounds.

Is Windows supported?

Best-effort, honestly labeled. macOS and Linux are the verified platforms: every unix code path is exercised by live pty tests, including signal-driven resize, job-control suspend, and keystroke flow under a real controlling terminal. The Windows backend compiles cleanly against the MSVC target, its platform-independent logic (UTF-16 pairing, wake latching, resize dedupe) is unit-tested on every host, and its console usage follows Microsoft’s documented semantics — but it has not been exercised on live Windows hardware. Treat a first Windows deployment as a beta event. (One concrete difference: suspend() returns an explicit Unsupported error on Windows — hide the Ctrl+Z binding there.)

How big is the crate?

One crate, no feature flags, no build script, three small library dependencies plus the platform bindings. The source is roughly 105k lines of Rust including its extensive inline test suites — decoders, rasterizer, layout solver, and signals runtime included, since none of that is pulled in from elsewhere.

Can widgets be shared as libraries?

Yes — a component is a plain function, so it ships like any Rust code. The convention: a props struct (with Callback<T> fields for typed events out and View fields for slots), a function that takes Scope and props and returns a View. Callback::default() is a no-op, so optional events cost nothing to leave unbound. The components example is the heavily commented reference: three reusable components composed repeatedly with different props into a settings screen.

How do I see what is actually repainting?

The compositor has a damage visualizer (render::Compositor::set_debug_damage(true)) that outlines exactly the regions each frame repaints. The switch lives on the compositor itself, so today it is for embedders driving the render pipeline directly (api.md § render) — under App::run the driver owns the compositor and exposes no toggle yet. The signal-side diagnosis needs no visualizer: if a “static” screen keeps repainting, something is writing a signal it shouldn’t (every dyn_view that reads it re-renders) — audit the writes before reaching for a profiler. Perf numbers only mean anything in --release builds.

Why can my app write the clipboard but not read it?

By design. Copy uses OSC 52 (gated on detection, since some terminals silently ignore it, and success is only reported when the capability holds). The read form of OSC 52 is deliberately never emitted: it would let any full-screen application silently read the user’s clipboard — a data-exfiltration vector. Paste reaches your app exclusively through bracketed paste, which is fuzz-hardened: multi-megabyte pastes stream in bounded chunks, byte-exactly, with embedded escape sequences neutralized as content.

Writing is easy to reach: copy_to_clipboard(text) from any handler, or enable the engine’s drag-select (selection()) so users copy what they see — both in the api.md selection section. If a copy never arrives, see troubleshooting.

When the terminal does not advertise OSC 52, copy falls back to the host clipboard (pbcopy / wl-copy / xclip), which spawns a child process on the UI thread. Set RunConfig::platform_clipboard to false to refuse that — worth doing in embeddings and in test harnesses, where a copy would otherwise overwrite the clipboard of whoever ran the suite. The two routes are exclusive: a copy the host clipboard accepted is not also written over OSC 52, because terminals and tmux cap that payload and the second write could truncate a long selection.

Why doesn’t my app start with the caret in its input box?

Because no widget claims the keyboard implicitly. Keys go to the focused node — with nothing focused they fall back to the tree root — and a root tree focuses nothing at boot, so the first field takes typing only after a Tab or a click. Say which widget should own the caret by building it through the element form and marking it: .element(cx, &t).autofocus(). Modal overlays are the exception and need no marking: a Modal, a modal Drawer, or a ChoicePrompt establishes initial focus when it opens, so it answers keys from frame one.

This is also why a bare-letter global shortcut is a trap in an app with a text field: a focused editor consumes ordinary characters, so a plain q binding is live exactly while no field holds focus — including at startup. Use Ctrl+Q or Ctrl+C for global verbs. See Focus — who receives keys and the troubleshooting entry.

Why doesn’t Ctrl+Enter (or Shift+Enter) do anything?

On the classic terminal wire, Ctrl+Enter, Shift+Enter, and Ctrl+Backspace are byte-identical to plain Enter / Ctrl+H — no parser can recover what the terminal never sent. They become distinct under the kitty keyboard protocol or xterm’s modifyOtherKeys, both of which the engine detects and decodes automatically. Treat these chords as enhancements, not baseline bindings; everything on arrows, Home/End, PgUp/PgDn, and F1–F12 with any modifier combination is reliable everywhere.

Does it support the mouse?

Yes: SGR-encoded mouse events (clicks, drags, wheel) in cell coordinates on every supported terminal, hover/click affordances in the built-in widgets, pointer capture for drags (the 3D viewport uses it for orbiting), and pixel-precision reporting where the terminal verifiably supports it — raw pixel coordinates ride alongside cell coordinates only when pixel reporting is actually active, never posing as cells.

List::on_context_menu exposes a right-button row gesture without changing selection or activating the row. Pair its screen-space ListContext with ContextMenu for an owned action menu; Shift+F10 provides the keyboard path.

Can drawer labels use text rotated by 90 degrees?

Not as terminal text. A terminal screen is a grid of cells containing grapheme clusters and styling attributes; ANSI/SGR has no glyph-rotation or vertical-writing command. DrawerDock uses the portable representation: one grapheme cluster per row, with the complete horizontal title retained as the tab’s semantic label.

A pre-rendered rotated bitmap can be displayed through a graphics channel, but it is image artwork rather than text: it is not searchable or selectable, requires a font rasterization step outside DrawerDock, and may degrade to a mosaic on terminals without a pixel protocol. AbstractTUI therefore does not offer a rotated text flag that silently changes those semantics.

How do I let users pick a theme?

The one-line answer is ThemeSwitcher (in the prelude): mount ThemeSwitcher::new().view(cx) in any header or footer row and you get a ☾/☼ menu button whose popup lists every visible theme grouped Dark/Light with live preview; ThemeSwitcher::toggle() is the no-popup face that flips dark ↔ light per click, restoring your last theme of the target mode. on_change is the hook for persisting the preference. See theming.md.

For your own picker UI: set_theme_by_id(id) switches at runtime and the whole app restyles through the one theme signal; theme::list() gives you (id, label, dark) for every visible theme, including ones your app registered, and theme::themes_by_mode(mode) lists one polarity in curated order. The shipped examples honor ABSTRACTTUI_THEME=<id> as a startup convention, and cargo run --example themes is a complete picker UI — card grid, live preview, measured contrast ratios — you can crib from.

How does click_count() work for double-click?

Terminals report raw press/release events only — the engine synthesizes multi-click counts in ClickChain. On each MouseKind::Down, EventCtx::click_count() returns 1 for an isolated press, 2 for the second press of a chain, and so on (saturating at 255).

A chain continues when the new press uses the same button, lands within 400 ms (inclusive) of the previous press, and stays within 1 cell (Chebyshev distance) of the previous press position. Modifiers do not break the chain. Up and Move do not reset it. Wheel and drag events reset the chain (content moved under the pointer). The driver publishes time via set_event_time each turn — without it, every press reads as 1 (deterministic in headless tests).

Widget conventions: List::on_activate uses the picker gesture (click the already-selected row — timing-free; double-click subsumes when on_row_double_click is unbound). Table and List::on_row_double_click use the browsing gesture (click_count() >= 2 on the already-selected row). Prefer these callbacks over hand-rolled Bubble handlers so reactive rebuilds do not drop selection state between presses.

How do I build a scrollable rich list (sidebar / presence board)?

Use List inside a flex child (it virtualizes and scrolls internally). For styled rows bind List::rich_items. To let readers dismiss a row, bind List::on_remove — it draws the ✕, routes the click, and keeps selection valid across the removal. For any other trailing action (unread badge, per-row menu) use List::row_accessory + List::on_accessory_click — the engine computes body/accessory/scrollbar columns; do not hand-roll m.pos.x - rect.x math. For open-on-double-click bind List::on_row_double_click.

When the rows come from a signal that changes (a removal, a filter), bind List::selection and List::offset_y on a scope that outlives the rebuild. The internal ones are re-minted by each build, which resets the highlight and scrolls back to the top. If the rows are a filtered view, the callback index is a position in THAT view — map it back to your backing collection before mutating. See cargo run --example interaction_affordances.

RichTextView alone has no intrinsic height — wrap it in Scroll and pass an explicit Scroll::content_size (or mount content with a measure callback). Note that a Scroll whose only child GROWS can never move: the content solves to exactly the viewport, so give it fixed-height children when you want it to scroll. See cargo run --example presence_board and the markdown scroll tests for composition patterns.

How do I handle paste on an idle app surface (not the composer)?

TextInput / TextArea expose on_paste on the focused field. For app-level routing (classify a file-path paste before it reaches a focused editor), attach Element::on_paste on an ancestor in Capture phase — it runs on the path toward focus before the focus target. Return PasteAction::Consume to stop insertion; PasteAction::Insert lets the focused widget handle the paste normally.

Can users attach files by dropping them onto the terminal?

Yes, with one honest caveat: terminals have no drop protocol — dropping a file PASTES its path, with per-terminal quoting. The engine gives you the pieces to turn that into a real attach flow: TextInput::on_paste / TextArea::on_paste intercept a paste before insertion, and input::paste::classify answers “is this paste a file drop?” against the researched spellings of the major terminals (ambiguous input always falls through to a normal paste — a false positive would eat user text). FilePicker is the explicit browse-and-pick door for when there is nothing to drag from. The wired recipe is cargo run --example attachments; the API walkthrough is api.md § File attachments.

Troubleshooting

Symptom → cause → fix, for the problems terminal reality actually produces. Two diagnostic surfaces recur below:

  • The capability report: cargo run --example dashboard -- --caps (also viewer3d, images) prints what the engine detected — color depth, image protocols, keyboard enhancements, tmux state. In code: caps.summary() (multi-line) or caps.summary_line() (one line).
  • Startup notices: labeled degradations are collected at startup and exposed reactively (use_startup_notices); render them in a footer or toast and problems name themselves.

Nothing renders at all

Cause: there is no terminal to render to. Either the process is not attached to a tty (output redirected, running under CI), or TERM=dumb (or empty) told the engine not to emit escapes at this terminal.

Fix: run inside a terminal emulator. If stdin/stdout/stderr are all redirected and /dev/tty is unavailable, terminal construction fails with an actionable error rather than emitting bytes into the void. For CI and tests, don’t fight it — drive the app headlessly with testing::CaptureTerm (see faq.md). Note the shipped examples deliberately exit 0 with a one-line notice when there is no interactive terminal.

Keyboard is dead under an unusual shell or launcher

Cause: some environments hand the process a terminal descriptor that cannot be polled (a real macOS quirk with /dev/tty). The engine detects this and falls back to a working descriptor instead of blocking forever.

Fix: usually none needed — an app that starts is an app that receives keys. The fallback is a labeled degradation: Terminal::degraded() returns the reason, and it lands in the startup notices. If keys are genuinely dead, check the notices first; if the engine could not find any workable descriptor it fails with an actionable error rather than starting deaf.

Typing does nothing until I click or Tab into the input

Symptom: the app starts, the composer or form field is visible, but the first characters go nowhere. Press Tab once, or click the field, and typing works from then on. A variant of the same cause: a single-letter global shortcut (q to quit) fires on the very first keystroke instead of being typed into the field.

Cause: nothing is focused. Keys go to the focused node and fall back to the tree root when there is none, and a root tree starts with nothing focused — no widget claims the keyboard implicitly, because the engine cannot know which one should own it. With focus at the root, characters reach no editor, and root-level KeyChord shortcuts see every key.

Check: press Tab. If the field takes focus (its side strokes turn to border_focus) and typing lands, this is the cause. Modal overlays are not affected — they establish initial focus when they open — so a composer inside a Modal that works while the root-level one does not confirms it too.

Fix: ask for focus at mount. Build the field through the element form of the widget and mark it:

#![allow(unused)]
fn main() {
let t = use_theme(cx).get().tokens;

TextArea::new()
    .state(&state)
    .rows(1, 4)
    .on_submit(submit)
    .element(cx, &t)   // Element, not View
    .autofocus()       // focused from frame one
    .build()
}

Mark one widget per screen — the last .autofocus() mounted wins. An app that would rather pick by document order can call app.tree().focus_first() after mount. While you are there, move any bare-letter global verb onto a modified chord (Ctrl+Q, Ctrl+C): a focused editor consumes ordinary characters, so a plain q shortcut is live exactly when no field holds focus, which includes the moment the app starts.

Verify: launch and type immediately — the characters land in the field, and the placeholder yields to the caret. An autofocused field shows no placeholder by default; .placeholder_while_focused(true) keeps the hint visible beside the caret.

See Focus — who receives keys for the full resolution order and getting-started.md for the same recipe in context.

Images don’t show (or fall back to blocky glyphs)

Cause: the terminal didn’t prove a pixel protocol. Image channels are enabled by detection — kitty graphics, iTerm2, or sixel (sixel also needs the cell pixel geometry) — and anything unproven falls back to unicode mosaic, with the degradation labeled, never silent.

Fix: check --caps to see which channel was chosen and why. Under tmux, graphics are off by default: tmux swallows the protocols unless allow-passthrough on is set, and that setting is invisible from the environment, so the engine verifies it per session with a wrapped round-trip probe and only then enables the pixel paths. Set set -g allow-passthrough on in ~/.tmux.conf, restart the session, and re-check --caps. Mosaic output is not a bug — on terminals with no pixel protocol it is the correct answer, and the quadrant/sextant/braille modes are a deliberate quality ladder within it.

An image refuses to decode by name

Symptom: the picture never appears and the widget (or the decoder’s Result) carries a named message such as image: unrecognized format or jpeg: arithmetic-coded JPEG not supported.

Cause: the bytes are outside what the engine decodes. decode_image routes on the MAGIC, never a declared MIME type or a file extension, so a .jpg that actually holds a WebP is rejected as an unrecognized format rather than decoded as JPEG. The supported set is PNG at 8-bit depths without interlacing, 8-bit Huffman JPEG (baseline, extended sequential, and progressive, grayscale or YCbCr), and GIF; animations add APNG through decode_animation. Arithmetic-coded, lossless, hierarchical, 12-bit, and CMYK JPEGs, interlaced (Adam7) PNGs, and WebP/AVIF/TIFF all reject by name rather than render wrong.

Cause, for a video file: the engine decodes NO video codecs. Every .mp4, .mov, .avi, .webm, and .mpg is recognized by container, named, and refused — those codecs are patent-pooled and each is larger than this whole crate. The message carries the conversion line.

Check: read the message — every rejection names the exact reason, and it is written to be shown to a user verbatim. file image.jpg (or djpeg -verbose) confirms the real container and JPEG variant.

Fix: convert the asset to a supported encoding — a PNG or JPEG export from any image editor, or a command-line converter (sips -s format png in.webp --out out.png on macOS). For video, the message’s own line does it: ffmpeg -i IN -vf 'fps=12,scale=480:-1' OUT.gif turns a clip into an animation the engine plays. To play video as it is, decode outside the engine and feed frames in — see graphics-and-3d.md. Truncated downloads report truncation specifically; fetch the file again before converting.

Verify: gfx::decode_image returns Ok and the widget shows the picture instead of the labeled broken-image state. See graphics-and-3d.md for the full decoder coverage.

Colors look wrong or washed out

Cause: the terminal did not advertise truecolor, so every 24-bit color is being quantized to the 256- (or 16-) color palette. Detection reads COLORTERM and TERM in the environment pass, and the active probe can both raise and lower the verdict. NO_COLOR, if set, forces color off deliberately.

Fix: use a truecolor terminal, or export COLORTERM=truecolor if your terminal genuinely supports it but doesn’t say so (common over some SSH hops that strip the variable). Check what was detected with --caps. One guarantee under quantization: foreground/background pairs are re-picked together, so text may band but never vanishes into its own background.

The screen flickers or tears during animation

Cause: the terminal doesn’t support synchronized output (DEC private mode 2026), so partially-painted frames can be displayed mid-write. Where the capability is detected, the engine brackets frames and the terminal displays each one atomically.

Fix: use a terminal that supports synchronized output (check --caps — sync appears in the summary line when detected). Everything still works without it; the engine’s damage tracking keeps writes small, which minimizes the visible window, but true tear-free animation needs the terminal’s cooperation.

Ctrl+Enter behaves exactly like Enter

Cause: on the legacy wire they are the same bytes. Ctrl+Enter, Shift+Enter, and Ctrl+Backspace are byte-identical to Enter / Ctrl+H — the information does not exist in the stream, so no parser can recover it.

Fix: use a terminal with the kitty keyboard protocol or xterm’s modifyOtherKeys — both are detected and decoded automatically, and these chords become distinct. In your own app, treat such chords as enhancements with a baseline alternative; arrows, Home/End, PgUp/PgDn, and F1–F12 with any modifier are reliable everywhere.

The boot splash doesn’t play

Cause: one of the deliberate gates fired. boot::should_splash skips when the render handle is not a tty, when ABSTRACTTUI_NO_SPLASH is set (to anything except 0), when NO_COLOR is set, when TERM=dumb, or when the capability report classifies the terminal as dumb.

Fix: if you want the splash, clear those variables and run on a real tty (cargo run --example splash to verify; ABSTRACTTUI_NO_SPLASH=0 explicitly opts back in under wrapper scripts that set it). The gate function returns the skip reason as a string — log it and the answer reads itself. Also remember any keypress skips the splash with a fast fade; a buffered keystroke at launch can end it almost immediately.

Frames are slow

Cause: usually one of three, in this order: a debug build (the rasterizer and mosaic fit are numeric code — --release is several times faster); a busy machine (the published envelope is from an idle box, and medians inflate several-fold under host contention); or your app damaging more than it thinks (a signal written every tick re-renders every region that reads it).

Fix: measure in --release first. Then audit what repaints: a supposedly idle screen that keeps painting means some signal is being written needlessly (every dyn_view that reads it re-renders). Embedders driving the render pipeline directly can also flip the compositor’s damage visualizer (render::Compositor::set_debug_damage(true)) to outline each frame’s repaint regions — under App::run the driver owns the compositor, so there is no app-level toggle yet. For 3D scenes, the perf envelope and its reproduction commands are in graphics-and-3d.md — the renderer is vertex-bound at cell scale, so triangle count matters far more than viewport size.

Wide characters are misaligned in some terminals

Cause: East-Asian-Ambiguous characters, emoji presentation sequences (VS16), and ZWJ families genuinely render at different widths across terminals — some split emoji families into components, some render ambiguous symbols double-wide under CJK configurations or emoji-font fallback. There is no protocol to query the terminal’s opinion.

Fix: the engine already confines the damage — after emitting a risky cluster it re-anchors the cursor, so a width disagreement stays inside that cluster instead of shifting the whole line (the classic smear). What it cannot fix: a terminal configured ambiguous-wide breaks the cell grid of every TUI. Keep the terminal’s default width configuration, and prefer unambiguous glyphs (plain ASCII, box drawing, block elements) in structural chrome.

A row vanishes (or content overlaps) on a small terminal

Cause: flex overflow pressure crushed a node to zero area — content demanded more rows/columns than the viewport has, and something had to give. The engine’s guarantees at any size: a zero-area node is CLEAN ABSENCE — its draw closure never runs, so it can never smear onto a sibling’s row; Modal and Drawer clamp into the viewport at open and re-clamp on every resize; tab strips window with overflow indicators; wide glyphs never tear at a clip edge. In debug builds every zero-collapse is named by a startup notice.

Fix: two app-side recipes. Give incompressible chrome (title bars, button rows, status lines) an explicit shrink(0.0) so the oversized MIDDLE gives instead — or wrap that middle in a Scroll, whose default basis(0) exerts no pressure. And render use_startup_notices somewhere visible: the engine names every collapsed node into that lane, and a notice nobody renders is a debugging session someone else pays for. The full contract: api.md § “Small terminals & content pressure”.

My input panel is a row short and stops following what I type

Cause: one failure, not two. A container above the input was solved shorter than the input itself, so the widget kept its full row count and the last of those rows landed outside its parent. Layout never clips, so that row is painted — and then the next sibling (a status bar, a hint line) paints over the same cells. The widget scrolls its text correctly for the rows it believes it has, which is why the row you lose is always the one the caret is on.

Fix: shrink(0.0) on the widget protects the widget’s own box among its siblings; it cannot reserve room in an ancestor. Put shrink(0.0) on the outer chrome row too, and give the growing pane beside it grow(1.0).basis(Cells(0)) so it starts at zero and takes only leftover instead of demanding its whole content height. Scroll carries that default already, but a wrapper around a Scroll re-derives its own starting size from content — put basis(Cells(0)) on the element that sits directly in the pressured column.

In debug builds a column whose children need more rows than it has names itself in the notices lane, so render use_startup_notices somewhere visible. If you would rather see honest truncation than a row that a neighbour may or may not overpaint, LayoutStyle::clip() on the container cuts the surplus at the content box.

Double-click doesn’t activate (in the app, or in a test)

Cause: several honest ones, in likelihood order. In a Table, a SLOW second click is deliberate: activation needs a true double-click (second press within 400 ms, within 1 cell, on the already-selected row) — re-clicking a row to focus its pane must never open its editor. A second press that drifted onto a NEIGHBOR row only re-selects (fast click-walking is browsing, not commitment), and a wheel between clicks resets the chain (the content under the cell moved). In a HEADLESS TEST, a bare ui::UiTree has no time source, so every press deterministically counts 1 — double-click needs time to flow.

Fix: in the app, none — Enter and Space always activate, and List’s click-on-selected gesture is timing-free. In tests, drive through the real Driver (it publishes its set_clock-injectable clock as the ambient event time each turn, so one injected clock scripts animations AND double-click timing), or opt a bare tree in with ui::set_event_time(Some(t)). Custom input paths outside tree dispatch embed their own ui::ClickChain. The full convention: api.md § “Double-click”.

My screenshot shows a labeled veil where an image should be

Cause: honesty, not loss. Cells under a kitty/iTerm2/sixel placement are not the picture — the terminal shows pixels the cell plane cannot see, so Driver::screenshot() stamps those placements into Screenshot::pixel_regions() and the SVG exporter draws a labeled placeholder veil instead of pretending. Text and ANSI exports stay cell-plane-verbatim; VT-model captures (headless tests) carry no regions at all — the rig counts protocol payloads without modeling their pixels.

Fix: if the still must contain the picture, render the image through the unicode-mosaic path for the capture — mosaic images ARE cells and capture as themselves. Otherwise accept the veil: it marks exactly the region the terminal owned. See api.md § “Screenshots & captures”.

Dropping a file pastes a path instead of attaching it

Cause: that is all a terminal can do. There is no drop protocol — every major terminal turns a file drop into a PASTE of the file’s path, each with its own quoting (backslash escapes, single or double quotes, file:// URIs). Without an intercept, the path lands in your composer as text.

Fix: intercept the paste and classify it. TextInput::on_paste / TextArea::on_paste run before insertion with the raw paste text; input::paste::classify parses the known drop spellings and returns the paths (or None for ordinary text — ambiguity always falls through to a normal paste, so prose containing a path is never eaten):

TextArea::new()
    .on_paste(|pasted| match abstracttui::input::paste::classify(pasted) {
        Some(paths) => { attach(paths); PasteAction::Consume }
        None => PasteAction::Insert,
    })

Existence-checking is deliberately yours (the engine does no I/O in the input path): fs-check the returned paths and show the result. If drops still arrive as text, your terminal may be one whose drop spelling is ambiguous by design — kitty pastes raw unescaped paths, so a path with spaces cannot be told apart from prose; offer FilePicker as the explicit door. The full walkthrough: api.md § File attachments and cargo run --example attachments.

I can’t select text with the mouse

Cause: mouse capture. The engine enables SGR mouse reporting for wheel scrolling and click routing, and a terminal in mouse-capture mode sends drags to the application instead of performing its own text selection. Every mouse-capturing TUI behaves this way — it is the protocol, not a bug.

Fix: three answers, cheapest first.

  1. Hold the bypass modifier your terminal already ships. Every major emulator can bypass mouse capture for one drag:

    TerminalBypass gesture
    iTerm2Option+drag (also Cmd if configured)
    macOS Terminal.appFn+drag (Option+drag selects rectangles)
    kittyShift+drag
    WezTermShift+drag
    GNOME Terminal/VTE (incl. Tilix, xfce4-terminal)Shift+drag
    AlacrittyShift+drag
    Windows TerminalShift+drag
    tmux (inside any of the above)the same modifier, per the OUTER terminal

    This selects raw screen cells — borders, gutters, and pane seams included — which is why the engine also offers the next two.

  2. A “native selection mode” keybinding (engine tier 2): the app calls app::selection::mouse_capture().suspend() — mouse reporting turns off, the terminal’s own selection (and clipboard) works at full native quality, and the app resumes with .resume() on its next keypress. See the api.md selection section.

  3. Engine drag-select with OSC 52 copy (tier 3): the app enables app::selection::selection(), and dragging paints a real selection highlight clamped to the pane under the anchor; releasing (or c/Enter/Ctrl+C) copies the selected screen text to the system clipboard through OSC 52. cargo run --example feed demonstrates it. A live selection freezes every follow-tail Scroll for the length of the drag, so a streaming transcript stops sliding under the highlight and the copy is the text you actually pointed at; clearing the region returns the pane to the live tail.

The engine’s copy doesn’t reach my clipboard

First, check the notices: every copy that has a working route posts one naming its size (copied 240 characters (3 lines) to the clipboard). If you see that receipt, the copy left the engine intact and the remaining suspects are the multiplexer and the terminal. If you see the labeled clipboard warning instead, no route was available.

Cause: OSC 52 is a write-only, fire-and-forget escape — the terminal either applies it or silently ignores it, and there is no reply to check. The engine falls back to the host clipboard (RunConfig::platform_clipboard, on by default) where OSC 52 is not advertised. Common blockers: the terminal does not support OSC 52 and no host helper is installed; tmux is in the middle (it consumes OSC 52 itself — set -g set-clipboard on in ~/.tmux.conf lets it forward the copy; the engine follows its verb policy and does not passthrough-wrap OSC 52, because tmux handles the sequence natively); or a security setting (some terminals gate clipboard writes behind a prompt or a setting, e.g. clipboard_control in kitty).

Fix: check the notices first, then your multiplexer’s set-clipboard, then the terminal’s clipboard permission setting. Size is rarely the issue: screen selections are a few kilobytes and every known OSC 52 cap (tmux’s historical ~74KB, kitty’s default 8MB) sits far above them. As a last resort the modifier-bypass matrix above always works — it never involves the application.

Hover highlights never light up

Cause: hover ink needs the terminal to report pointer motion with no button held (mode 1003). The default session arms button-and-drag tracking (1002) instead, so MouseEnter / MouseLeave only arrive during a drag. Clicks are unaffected — a control that looks dead on hover still works when pressed.

Fix: opt in with RunConfig::hover_ink:

#![allow(unused)]
fn main() {
app.run_with(RunConfig {
    hover_ink: true,
    ..RunConfig::default()
})
}

Tooltips are the exception, and you do not have to set anything. A Tooltip cannot work at all without motion — its whole contract is hover, delay, show — so mounting one declares the need itself (Overlays::require_pointer_motion) and the driver arms 1003 with hover_ink left false. Before that, an app that mounted a tooltip and called App::run() got a tip that opened when you PRESSED the mouse and stayed shut when you moved over the anchor. If you see that symptom on your own hover-driven widget, the fix is to declare the need the same way rather than to document a flag.

It is off by default because 1003 sends a report for every pointer cell crossed, which wakes the event loop of apps that have no hover visuals to paint — noticeably so over SSH or tmux. Set it when your UI reacts to hover (List row ink, Button hover, ThemeSwitcher’s glyph), and leave it off otherwise. Setting it keeps kitty-keyboard auto-detection; hand-building EnterOptions to reach MouseMode::AnyMotion would give that up.

Tests hang forever

Cause: the app was spawned in a harness with piped stdin that never reaches EOF. An idle app deliberately sits in a blocking read (zero CPU), so with a pipe that never sends bytes and never closes, it waits forever — that is correct behavior pointed at the wrong harness design.

Fix: don’t drive the real binary through pipes in tests. Use the canonical headless harness — testing::CaptureTerm plus Driver::turn — which runs the full production pipeline synchronously: push input bytes, turn one frame, assert on the rendered screen. Every test in this crate that exercises the app loop is written that way, and it needs no tty, no timeouts, and no sleeps.

A local checkout stops being the engine I build against

Symptom: you consume AbstractTUI from a working copy — [patch.crates-io] pointing at a path — the checkout moves to a new minor, and either a method you call disappears (no method named rule_style) or, worse, nothing fails at all and your frame-reading tests keep passing against a renderer you are not shipping.

Cause: a patch whose version falls outside your dependency requirement is not an error — it is ignored. abstracttui = "0.5.0" means ^0.5.0; once the checkout is 0.6.0 the patch no longer satisfies it, so cargo drops the patch, resolves the requirement from crates.io instead, and rewrites Cargo.lock to the published 0.5.0. Resolution fails only when nothing published satisfies the requirement either — which is exactly the case that stops arising once a crate has a release history.

Cargo does say so, on every resolve:

warning: patch `abstracttui v0.6.0 (/…/abstracttui)` was not used in the crate graph

but it is a warning: the resolve exits 0, and it scrolls past in CI output like any other. A suite that asserts on painted cells goes green against the wrong engine without a word.

Fix, two parts. Pin the requirement to the version the checkout carries, and re-pin when the checkout bumps:

abstracttui = "0.6.0"   # tracks the patch below on purpose; re-pin each minor

A family requirement ("0.6") re-arms the trap at the next minor: 0.7.0 in the tree, 0.6.x on the registry, and the graph resolves to the registry silently.

Then make it fail loudly, because a pin you have to remember to update is not a guarantee:

cargo tree -i abstracttui | head -1 | grep -q '^abstracttui v[^ ]* (' \
  || { echo 'abstracttui: patch not applied — building against crates.io'; exit 1; }

cargo tree -i prints the resolved package with its source: a patched build reads abstracttui v0.6.0 (/path/to/abstracttui), an unpatched one reads abstracttui v0.5.0 with no path. The check is red when the crate is absent from the graph too, so it cannot pass by finding nothing.

Do not test this by grepping Cargo.lock. The unused patch is still recorded there: in the failing case the lock holds two abstracttui entries — 0.5.0 from the registry and 0.6.0 from the path — and only the first is built. The machine-readable form of the same check is the resolve node id in cargo metadata --format-version 1, which reads path+file:///…#0.6.0 when the patch applied and registry+…#abstracttui@0.5.0 when it did not.