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 (Buttonincluded), layout vocabulary, signals, hooks, andAppitself.App::simple(|cx| ...)— builds the app, mounts your root component, enters the raw terminal, and runs the event loop until quit. Thecxparameter is yourScope: 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);— aSignal. Signals are smallCopyhandles, 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 finishedView, 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_COLORandTERM=dumbare 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
helloto the fulldashboard, with the keys each answers to. For content-heavy apps start withtranscript(streaming markdown chat),reader(tables, images, TOC, search), andvoice_mock(push-to-talk and live meters, no audio required); for app chrome start withshell(full pages behind aPageHosttab bar plusDrawerpanels 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::Normalis source-over;Blend::Additiveaccumulates 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’schanged_regionhint 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:
- kitty graphics — upload once by id, place and move by escape, true deletion;
- iTerm2 (OSC 1337) — full base64-PNG re-emit at the cursor;
- sixel — paletted raster at the cursor;
- 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.
enterswitches to raw mode, the alternate screen, and the requested modes;leaveundoes everything in exact reverse order. Restore is layered three deep: explicitleave,Dropif you forget, and a process-globalterm::emergency_restorefor panic hooks. Cursor style, window title, pixel-mouse mode, and kitty keyboard flags are all tracked and reset — including from a panic.App::runinstalls 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_COLORandTERM=dumbare 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/ttythat 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
Unknownevents — 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
Unsupportederror.
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:
- element handlers along capture → target → bubble;
KeyChordshortcuts registered on the root → focus path;- the built-in Tab traversal;
- the global
Actionsregistry.
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 (TextAreaStateis the app wire). - List — virtualized selectable list; variable-height items, sticky selection by key,
scroll_to, bindableselection/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 withContextMenu. 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 aScrollof 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 literallyList’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_rowscaps 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 pluggableFileSourceseam (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_sizeis an optional override) and can be read back throughextent_signal;follow_tailbinds the pinned-to-bottom idiom;scrollbar_auto_hidehides the bar while content fits, andscrollbar_widthwidens its reserved gutter. - Checkbox —
[x] labelbound to aSignal<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,BarCharton sub-cell grids, with optional relative time axes fed from aTimeSerieshistory 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;Bitmapre-exported beside it). Measures as its native cell footprint, so it holds real space inAuto-sized rows/panels. - Viewport3D — orbiting 3D view of a
three::Model:.orbit(yaw, pitch, zoom),.animate(clip, t),.on_orbit/.on_zoomdeltas; 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.Feedanswers the same way, so every content widget reports its real height during the layout solve rather than after the first paint. Wrapping inScrollis also what gives a document the WHEEL, PgUp/PgDn and a draggable thumb — thescroll_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’sMermaidFenceis 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
vtoggle, 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_closemay 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
buttonlabeledClose {title}(orClose 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_clickare 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 whoserow_accessoryreturnsNoneleave 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 rideList::rich_items(same length asitems; 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  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:
| gesture | result |
|---|---|
| press on the thumb | takes hold; nothing moves |
| drag after that press | the thumb tracks the pointer row for row, and keeps steering after the pointer leaves the strip (pointer capture) |
| press on bare track | teleports: the thumb centers on the pressed row |
| release | commits where it stands — no snap-back |
| any of the above with SELECT MODE on | still 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.
Modal content that can overflow
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.
Navigation chords (Codex-compatible)
Both text widgets take Codex’s default editor keymap
(codex-rs/tui/src/keymap.rs), so muscle memory carries across:
| Codex binding | Keys | Effect |
|---|---|---|
move_word_left / move_word_right | Alt+b / Alt+f, Alt+←/→, Ctrl+←/→ | caret by word |
move_line_start / move_line_end | Home / End, Ctrl+A / Ctrl+E | caret to line start/end |
delete_backward_word | Alt+Backspace, Ctrl+Backspace, Ctrl+W | delete word before caret |
delete_forward_word | Alt+Delete, Ctrl+Delete, Alt+D | delete 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+WandCtrl+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+KandCtrl+Yare 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 aSignal<usize>; type-ahead inside the popup jumps by label prefix, a repeated char cycles.Combobox— the popup includes the trigger row and mounts a realTextInputthere (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 aSignal<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
opensynchronously — a redirect belongs inon_changeor 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.
DrawerDocktherefore 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 aDrawerDocklabel mode. - Tabs are keyboard-operable and semantic. Tab/Shift+Tab focuses the
rail’s
Role::Tabnodes; 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. openis the API. ASignal<Option<String>>of the drawer id. The dock renders and mutates it; the app may write it any time — external writes switch panels without firingon_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_changedoes 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 whereTabs::on_changehands an index — deliberate, not drift: aTabsstrip is positional (its panels are an ordered list), whilePageHostpages are id-addressed identities that navigation state (active(Signal<String>)) names directly. - Tab bar: two rows (titles + the
border_focuscell strip); activetext+BOLD, idletext_muted, badgesinfo, groundsurface. 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, aModalor a modalDrawer,focus_initlands 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):
| terminal | drop spelling |
|---|---|
| Terminal.app | backslash-escaped specials, space-joined multi-drop |
| iTerm2 ≥ 3.4 | backslash-escaped (advanced pref: single-quoted) |
| Ghostty | backslash-escaped; GTK multi-drop is NEWLINE-joined |
| WezTerm | SpacesOnly default; Posix/Windows* = double-quoted; None = raw |
| kitty | raw path as-is (no escaping, by policy) |
| Windows Terminal | double-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):AnchoredPanelis the PASSIVE layer (never focused — keys stay with the anchor’s owner;Completionbuilds the caret-anchored dropdown on it),Popupis the OWNED modal tree above the whole live stack withDismissReason-named endings (the Select family rides it), andTooltipis the delay-timed passive tip. A tip carries either content shape:TipContent::Labelis one line of plain text on a draw layer with no tree, andTipContent::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 morerow and an over-wide label ends in an ellipsis. A tip opens on hover OR on its ANCHOR taking focus, closes onMouseLeave,FocusOutor the anchor moving under it, andEscapedismisses 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, soTooltip::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 calledApp::run(). Mounting aTooltipnow declares the need (Overlays::require_pointer_motion) and the driver arms mode 1003 for it;RunConfig::hover_inkremains 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 hovercardwalks 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), anduse_caps(cx)— the driver’s LIVECapabilities(env pass at enter, upgraded as active-probe replies fold in). Read it in adyn_viewfor 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’siinspector) 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,noneand the DECLARED levels only (verbatim, deduplicated, declared order — an unknown level string likeultrathinkrenders verbatim: the gateway is the authority on what a model supports). Empty declared list =auto/nonealone. - 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
noneby 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; committingnonechanges 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/releasedcarry 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_bginks 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
Scrollbars, theList/Table/FilePickerinternal bars, and an orbitingViewport3D— so with select mode on the thumb still scrolls and the camera still orbits. Your own drag widgets declare one withElement::drag_zone; an invisible or non-overflowing bar returnsNoneand 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):
| Key | Effect |
|---|---|
| Enter | copy the region, then clear (one-shot) |
c / Ctrl+C | copy the region, then clear (one-shot) |
| Esc | cancel — clear without copying |
| anything else | routes 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
Scrollviewport, a borderedBlock), 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 pinnedScrollholds 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.followitself 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 ; 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
DotCanvascovers a cell rect with a finer grid —DotMode::Brailleis 2x4 dots per cell (a WxH panel = a 2Wx4H dot canvas),DotMode::Quadrant2x2 (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 howLineChartlayers its series).blit_styledtakes a fullrender::Stylepatch 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, soClippedCanvasclipping 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 intests/alloc_budget.rs). - Eighth-block fills:
fill_v/fill_hdraw partial gauge/bar fills at 8 steps per cell (theBarChart/Progressvocabulary); the glyph rampsV_EIGHTHS/H_EIGHTHS, the quadrant table andbraille_bitare 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_cellspicks 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::Imageis 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::Sextantis 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 anAnimation(frames, per-frame delays, loop count). A still decodes as a one-frame animation, so one path shows any picture.widgets::AnimatedImageplays 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.mpgare recognized, named, and refused with anffmpegconversion line. See graphics-and-3d.md for the external-decoder pattern that plays video.gfx::ImageSessionmanages 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:
| constant | cells | measured |
|---|---|---|
GlyphScale::COMPACT | 4x2 | cheapest that reads for mixed text; beats TIGHT for a row less |
GlyphScale::COMPACT_WIDE | 6x2 | clears on the numbers, out of the aspect band — kept as the worked example of why the band exists |
GlyphScale::FLOOR | 4x3 | clear for everything in braille; marginal for mixed text in sextant |
GlyphScale::TIGHT | 3x3 | uppercase 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::HalfBlocknow 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 iscols == 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 toVtScreen::to_textfor the same screen.to_ansi()— SGR-styled text you cancatinto any truecolor terminal. Minimal escapes: one SGR transition per style change (the presenter’s own builders), rows separated bySGR 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 withCHA— 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 withtextLength(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 viato_svg_with(fg, bg). A generated sample lives atdocs/captures/transcript-stream.svg(the capture pipeline emits.svgbeside every.txtstill).
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.mpgreject 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-widthnarrow 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:layeredsugiyama-lite,forcebounded seeded placement,gridlabeled fallback; honesty markers for broken cycles and degradations) andGraphView(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 ontoabstracttui-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 thanborder.
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—shadowpre-composited overbgat theme build, so it is opaque. This is whatBlock::shadowpaints: widgets never do color math themselves.
Chart ramp
chart[0..8]— eight hue-separated series colors, all legible onbg. 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 againstsurface_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:
| family | themes |
|---|---|
| Abstract originals | abstract-dark, abstract-light, abstract-aurora, abstract-paper, abstract-ember, abstract-midnight, abstract-dawn |
| Observer | observer-night |
| Catppuccin | catppuccin-mocha, catppuccin-macchiato, catppuccin-frappe, catppuccin-latte |
| Rosé Pine | rose-pine, rose-pine-moon, rose-pine-dawn |
| Tokyo Night | tokyo-night |
| Nord | nord |
| One | one-dark, one-light |
| Dracula | dracula |
| Monokai | monokai |
| Gruvbox | gruvbox |
| Solarized | solarized-dark, solarized-light |
| Everforest | everforest-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:
| pair | floor |
|---|---|
text / grounds | 4.5:1 (7:1 is the target, reported not enforced) |
text_muted / bg | 3.0:1 |
text_faint / bg | 2.5:1 (the deliberate decoration tier) |
accent, accent_alt, semantics, link / bg | 3.0:1 |
selection_fg / selection_bg | 4.5:1 |
border / bg | 1.5:1 |
border_focus / bg | 2.0:1 |
cursor / bg | 3.0:1 |
syntax inks / surface_raised | 4.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 onRegistration::warningsas 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.
- The selection pair says “this is the thing keys act on” (list rows, table rows, selected text).
- A
border_focusstroke says “this pane owns the keyboard” (bordered widgets and panes). accentink 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_focusstroke 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_groundexist 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:
| channel | how it draws | moves / resizes | removal | requires |
|---|---|---|---|---|
| kitty graphics | upload once by id, place by escape | cheap re-place, no retransmit | true delete | kitty graphics protocol |
| iTerm2 | full base64-PNG re-emit at the cursor | full re-emit | cells overdraw | OSC 1337 support |
| sixel | paletted raster at the cursor | full re-emit | cells overdraw | sixel + known cell pixel geometry; one shared palette |
| unicode mosaic | colored glyphs (it is cells) | free | free | any 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-blitCellPatches.widgets::Image— the widget:Image::from_path("logo.png")orImage::from_bitmap(Arc<Bitmap>)(theBitmaptype is re-exported beside the widget and in the prelude), withfit(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 anExternalSink(the presenter adapts), and tmux passthrough wrapping is applied automatically when capabilities call for it.SyncOutcometells 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:
| family | subpixels per cell | PSNR |
|---|---|---|
| HalfBlock | 1×2 | 20.5 dB |
| Quadrant | 2×2 | 23.5 dB |
| Sextant | 2×3 | 24.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::ImageSessionat 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,.mpgare 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 aVec. - 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_0only (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):
| asset | triangles | 160×96 | 320×192 |
|---|---|---|---|
| synthetic sphere (untextured, gouraud) | 16,128 | 0.76 | 0.97 |
| helmet (JPEG textured + mips) | 15,452 | 1.31 | 1.82 |
| helmet (untextured) | 15,452 | 1.18 | 1.59 |
| x-wing (PNG textured + mips) | 119,999 | 7.53 | 8.21 |
| skinned sphere (animated, all vertices blended) | 65,024 | 2.90 | 3.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) plusGraphView, 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 ontoabstracttui-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
| Pass | Use for | Shape |
|---|---|---|
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 layering | seeded, 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 fallback | near-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:
| Mermaid | Status | Accepted spellings | Behavior |
|---|---|---|---|
flowchart / graph TD/TB/LR/BT/RL | YES | header keyword + direction token | layered layout (BT/RL as transposes) |
| Node shapes | YES | id, id[text], id(text), id{text}, id([text]), id((text)), id[[text]], id[(text)], id{{text}}, id>text]; quoted "text" inside brackets | cards; shape = accent + badge sigil (see below) |
| Edges | YES | any body length (-->, --->, ---, -----), dotted (-.->, -..->, -.-), thick (==>, ===); heads >, x, o; <--> | strokes; dotted/thick as stroke styles |
| Edge labels | YES | postfix |label| and infix (-- label -->, -. label .->, == label ==>) | drawn centred in the rank corridor |
Chaining / & groups | YES | A --> B --> C; A & B --> C & D | one edge per link; & is a cross product |
subgraph | FLATTENED | subgraph id, subgraph id [Title], nesting, direction inside | members render; the GROUP BOX does not, with a notice |
sequenceDiagram | YES | participant id [as alias]; messages ->>, -->>, ->, --> with : text; Note left of/right of/over | deterministic columns/rows — no solver |
| sequence blocks | YES | alt + else, opt, loop, par + and, nested | a labeled frame with dashed branch dividers |
| sequence activations | YES | activate id / deactivate id, and the ->>+ / -->>- suffixes | a bar on the lifeline; nested bars step right |
sequence rect/critical/break/box | NO | — | atomic fallback |
stateDiagram-v2 (flat) | YES | [*], id, id : label, --> with : label | compiles to the flowchart engine |
classDiagram, erDiagram, gantt, pie, journey, mindmap, timeline, gitGraph | NO | — | atomic fallback |
classDef, style, click, linkStyle, class, direction, %%{init} | IGNORED | recognized-and-dropped WITH a notice; %% comments drop silently | render 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 flattenedsubgraphwith its notice, sequence control-flow blocks, activations, and an honest fallback (or pass a.mmdpath).cargo run --example mermaid_doc— a markdown document whose ```mermaid fences render as diagrams in place (or pass a.mdpath).
Honest limits (v1)
- Open links (
---) carry theopenstyle hint and render as arrowless strokes inGraphView(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 andsubgraphare named fallbacks, not silent acceptance — growth is new table rows with tests. - Force layouts report
rank0 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):
| helper | signal shape | delivery | use for |
|---|---|---|---|
channel_source(cx) | Signal<Vec<T>> | every value, in order, unbounded | low-rate event streams |
latest_source(cx, initial) | Signal<T> | newest value; intermediates coalesce at the source | progress, telemetry, presence |
bounded_source(cx, capacity, policy) | Signal<Vec<T>> window + Signal<IngestStats> | at most capacity retained; overflow per policy, counted | anything 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 asdropped, the event counts asfold_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 pollevents.is_closed()/is_current()to stop early. - Cancellation is scope death. The connection dies with
cxlike everything else: armed retry removed, dial fn dropped, workers seeis_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).
| Library | Language | Update and rendering model |
|---|---|---|
| ratatui | Rust | Immediate 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. |
| Textual | Python | Retained DOM-like widget tree with CSS styling and reactive attributes on an asyncio loop; apps can also be served to a browser. |
| Bubble Tea | Go | The Elm Architecture: messages update a model, the view renders text, the runtime diffs frames; bubbles/lipgloss supply components and styling. |
| notcurses | C | Imperative stacked planes with best-in-class terminal media (images, video, sixel/kitty); bindings for many languages; a thin widget layer. |
| Ink | JS/TS | React for the terminal: components and hooks reconciled through a virtual DOM onto Yoga flexbox; strongest in Node CLI tooling. |
| AbstractTUI | Rust | Fine-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(alsoviewer3d,images) prints what the engine detected — color depth, image protocols, keyboard enhancements, tmux state. In code:caps.summary()(multi-line) orcaps.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.
-
Hold the bypass modifier your terminal already ships. Every major emulator can bypass mouse capture for one drag:
Terminal Bypass gesture iTerm2 Option+drag (also Cmd if configured) macOS Terminal.app Fn+drag (Option+drag selects rectangles) kitty Shift+drag WezTerm Shift+drag GNOME Terminal/VTE (incl. Tilix, xfce4-terminal) Shift+drag Alacritty Shift+drag Windows Terminal Shift+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.
-
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. -
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 (orc/Enter/Ctrl+C) copies the selected screen text to the system clipboard through OSC 52.cargo run --example feeddemonstrates it. A live selection freezes every follow-tailScrollfor 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.