Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

FAQ

Why another TUI library?

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

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

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

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

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

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

Does it work over SSH?

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

Which terminals support images?

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

Which image formats decode?

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

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

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

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

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

How do I test my app headlessly?

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

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

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

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

How do I capture a screenshot of my app?

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

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

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

Can I embed AbstractTUI in an existing event loop?

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

Why the near-zero dependency policy?

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

How do themes stay readable?

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

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

Can I use my own brand palette?

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

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

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

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

What happens on a dumb terminal, or with NO_COLOR?

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

Is Windows supported?

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

How big is the crate?

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

Can widgets be shared as libraries?

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

How do I see what is actually repainting?

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

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

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

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

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

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

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

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

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

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

Does it support the mouse?

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

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

Can drawer labels use text rotated by 90 degrees?

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

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

How do I let users pick a theme?

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

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

How does click_count() work for double-click?

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

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

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

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

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

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

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

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

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

Can users attach files by dropping them onto the terminal?

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