# 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](https://docs.rs/abstracttui)). 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:
```rust
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.
## ui — elements, views, composition
`Element` is the view-tree builder: layout style, children, focusability,
event handlers, keyboard shortcuts, and an optional draw closure.
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:
```rust
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.
## 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.
```rust
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
);
```
## 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`. The catalog:
- **Block** — the bordered panel primitive: title, fill, focus ring, `BorderKind`.
- **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`.
- **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, and a caret-cell anchor for completion dropdowns (`TextAreaState` is the app wire).
- **List** — virtualized selectable list; variable-height items, sticky selection by key, `scroll_to`.
- **Feed** — virtualized, append-only, keyed rich items (markdown, plain text, code fences, custom draws): the chat/log/transcript surface. Appends are O(1); a streaming tail item re-typesets only its open markdown block; 10k items draw one screenful.
- **Table** — fixed/percent/flex columns, styled header, virtualized rows, selection, sort-indicator hook (the app sorts).
- **Tabs** — tab bar over lazily mounted panels; only the active panel is mounted.
- **Scroll** — clipped viewport over oversized content, mounted once so state, focus, and hit testing survive scrolling. The content extent is measured by the layout solver (`content_size` is an optional override), and `follow_tail` binds the pinned-to-bottom idiom.
- **Checkbox** — `[x] label` bound to a `Signal<bool>`.
- **RadioGroup** — one-of-N bound to a `Signal<usize>`; one tab stop, Up/Down move the selection.
- **Progress** — bar with sub-cell precision; optional ok→warn→error ramp.
- **Spinner** — indeterminate activity glyph, pure over a caller-owned frame index.
- **Badge** — small tinted label for status chips, counts, tags (`Tone`).
- **Separator** — horizontal or vertical rule, optionally labeled.
- **Charts** — `Sparkline`, `LineChart`, `BarChart` on sub-cell grids.
- **Grid** — container widget over `Display::Grid`; spans ride each child's own style.
- **Image** — bitmap display through the mosaic pipeline (`ImageFit`; `Bitmap` re-exported beside it).
- **Viewport3D** — orbiting 3D view of a `three::Model`: `.orbit(yaw, pitch, zoom)`, `.animate(clip, t)`, `.on_orbit`/`.on_zoom` deltas; camera state lives app-side in signals.
- **MarkdownView / RichTextView / CodeView** — typeset markdown, wrapped styled spans, read-only highlighted code.
- **Logo** — the AbstractTUI wordmark for headers, about screens, empty states.
### 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::StreamSession`, so a token
append costs one open block, never the document. `total_rows()` is the
reactive content extent, and `clear()` rebuilds bounded windows:
```rust
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)
}
```
### 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:
```rust
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)
}
```
### Modal content that can overflow
Put the overflow inside a `Scroll` and keep the fixed rows fixed — the
defaults now 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:
```rust
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 (and Shift+Enter where
the kitty protocol reports it) inserts 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:
```rust
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)
}
```
### Completion dropdown (anchored panel)
`app::anchored` ships the passive half of the anchored-popup substrate
(backlog 0500) and the completion controller riding it (backlog 0120):
`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:
```rust
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)
}
```
Providers run synchronously with the query typed after the trigger;
an empty Vec closes the dropdown. The OWNED and TOOLTIP popup modes
(the select/combobox family, hover tips) are future 0500 work on the
same placement engine.
## 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:
```rust
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** (`app::anchored`) — a passive anchored popup layer:
placed against an anchor rect (below-preferred, flip-above, viewport
clamp), stacked above everything live via `Overlays::top_z()`, never
focused (keys stay with the anchor's owner), closed by its opener's
scope death. `Completion` builds the caret-anchored completion
dropdown on top of it (see the widgets section).
- **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).
- **KeymapHelp** — a ready-made `?` help modal listing the shortcuts
reachable from the current focus plus every registered global action.
## 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](troubleshooting.md#i-cant-select-text-with-the-mouse)
for the zero-code terminal bypasses). Three cloneable, thread-local
handles, all in `app::selection` (functions re-exported in the prelude):
```rust
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 now 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 Down/Drag/Up only**:
dragging paints the theme's `selection_fg`/`selection_bg` inks over the
composed frame (damage-contract honest — only changed cells repaint), and
releasing copies. While a selection is visible, Enter / `c` / Ctrl+C copy
again and Esc or a click clears — Ctrl+C only quits when no selection is
visible. Wheel scrolling, hover, and every other key route normally the
whole time. Copies travel as OSC 52 through the presenter's byte custody;
terminals that did not advertise the capability still get the bytes
(harmless) plus a one-time labeled startup notice, and 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. The logical text↔cells mapping is future work (backlog
0160), not this feature.
- **Linear row flow, clamped to a pane.** The selection flows like a
terminal's own: anchor to right edge, full middle rows, left edge to
head. Both ends clamp to the pane under the drag *anchor* — the content
box of the nearest clipping or padded ancestor (a `Scroll` viewport, a
bordered `Block`), else the whole tree — so sibling panes and border
glyphs never leak into a copy.
- **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.
## theme — design tokens
Widgets consume `TokenId`s 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:
```rust
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.
## 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:
```rust
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. `Feed`'s
streaming items ride it; it is widget-agnostic:
```rust
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)
);
```
## gfx — images
`gfx::decode_image(bytes)` sniffs the magic bytes (containers lie, bytes do
not) and decodes PNG or baseline JPEG 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:
```rust
use abstracttui::base::{Rect, Rgba};
use abstracttui::gfx::{render_to_cells, Bitmap};
use abstracttui::term::Capabilities;
let img = Bitmap::new(16, 8, Rgba::rgb(180, 90, 30));
let cells = render_to_cells(&img, Rect::new(2, 1, 8, 4), &Capabilities::default());
assert_eq!(cells.len(), 8 * 4);
```
- `render_to_cells` picks the best mosaic mode for the probed terminal and
returns ready-to-blit cell patches; `MosaicMode::auto(&caps)` returns both
the mode and the reason it was chosen (half-block, quadrant, sextant, or
braille; optional Floyd–Steinberg dithering).
- `widgets::Image` is the widget form — always mosaic, because a draw
closure owns cells, not escape bytes.
- `gfx::ImageSession` manages the pixel protocols (kitty, iTerm2, sixel):
slots keyed by the caller, content versions, minimal traffic per channel —
kitty transmits once and re-places on move; iTerm2 and sixel honestly
re-emit. Bytes reach the terminal through the presenter, and tmux
passthrough wrapping applies automatically when capabilities prove it.
## 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:
```rust
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:
```rust
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.
## 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:
```rust
use abstracttui::prelude::*;
use abstracttui::app::Driver;
use abstracttui::testing::CaptureTerm;
let size = Size::new(20, 4);
let mut app = App::new(size);
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"));
```
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.
## Stability and limits
Plain statements of current behavior:
- **JPEG** decoding is baseline sequential only; progressive and arithmetic
variants reject by name. **PNG** supports 8-bit depths without interlacing
(Adam7 rejects by name).
- **Sixel** uses one palette per emission: multiple live sixel images
recolor each other — prefer one per screen. iTerm2 and sixel have no
placement model (moves re-emit the payload); only kitty gets placement
escapes and true deletes.
- **Pixel protocols** are verified byte-for-byte against protocol models,
not live terminals; unicode mosaic is the universal, always-safe path.
- **3D animation** supports LINEAR and STEP interpolation; CUBICSPLINE and
morph weights skip with labels; rotations nlerp (shortest path), not
slerp. Skinning reads `JOINTS_0`/`WEIGHTS_0` (four joints per vertex,
linear blend). Textures: base color only, REPEAT wrap, per-triangle mips.
- **Mosaic** color resolution is two colors per cell (the glyph split
carries the rest); braille conveys structure, not color; sextant glyphs
need a recent font and are an explicit opt-in.
- **Ambiguous-width characters** follow `unicode-width` narrow semantics. A
terminal configured ambiguous-wide breaks cell layout for every terminal
application; the presenter's cursor discipline bounds the drift but
cannot erase it.
- **Capacity ceilings** degrade with labels, never unbounded growth: 4096
distinct long grapheme clusters per surface (then U+FFFD), 65535
hyperlinks per surface (then plain text), with counters exposed.
- **Scroll optimization** requires DECSTBM/SU/SD compliance — present in
every VT100 descendant — and can be forced off via `PresenterOpts`.
- **Windows** compiles clean and its extracted logic is unit-tested on every
host, but it has not yet run on a live Windows machine; treat a first
Windows deployment as a beta event. macOS and Linux are the live-verified
platforms.