omp-tui 0.1.0

Retained-mode terminal UI components, rendering, input, and terminal integration for omp
Documentation

omp-tui

omp-tui builds retained terminal interfaces from declarative component trees. You describe a screen once, route terminal events into Ui, and let Ui update and repaint the smallest safe region. Renderer then writes only the changed cells while preserving native terminal scrollback.

Most applications use dom! for their initial tree, stable id attributes for later updates, and Ui::values() to read interactive state.

Structure

  • components and the dom! macro define retained layout, text, navigation, data, and input trees; runtime markup and typed builders provide alternate construction paths.
  • Ui, App, and the event/input modules retain widget state and route keyboard, mouse, paste, resize, and application events.
  • Frame and Renderer turn component output into differential terminal updates, while terminal, graphics, notify, and protocol-specific modules manage lifecycle and terminal capabilities.
  • editcore, rich, markdown, latex, syntax, scene, and shader provide editing and richer content pipelines. build.rs validates icons.tsv and generates the icon lookup catalog.

Philosophy

Build the component tree once, preserve interaction state, and repaint only the smallest safe terminal region. Keep terminal-specific negotiation and escape handling inside the crate so applications can focus on declarative structure and explicit event-driven updates.

Mental model

An omp-tui application has four layers:

  1. Components describe layout, content, and interaction. Build them with dom!, runtime markup, or Rust builders.
  2. Ui owns the retained component tree, focus, widget state, dom, hit regions, and the current Frame.
  3. Events mutate that retained tree through handle_key, handle_paste, handle_mouse, set_text, and related methods.
  4. Renderer differentially paints the resulting frame to a terminal writer.

Build the Ui once and keep it. Rebuilding after every key press discards editor contents, selection, focus, tab state, and scroll offsets.

Add the crate

Inside this repository, declare internal crates at the workspace root and inherit them from the application crate:

# Cargo.toml
[workspace.dependencies]
omp-tui = { path = "crates/tui" }
# crates/my-app/Cargo.toml
[dependencies]
omp-tui = { workspace = true }

The application uses omp_tui; Cargo converts the package hyphen to an underscore in Rust paths.

omp-tui owns terminal lifecycle and input decoding; applications do not need a separate terminal-backend dependency.

Build a first screen

dom! is the usual starting point. It expands to ordinary component builders and can interpolate Rust values without parsing a string at runtime.

use omp_tui::{Ui, UiContext, dom};

#[derive(Clone, Copy)]
enum RunState {
    Ready,
    Running,
    Failed,
}

fn build_ui(width: u16, state: RunState, show_help: bool) -> Ui {
    let jobs = ["index workspace", "run checks", "publish report"];

    Ui::from_root(
        dom! {
            <col gap=1 pad="1 2">
                <box border=round title="Build">
                    <col gap=1>
                        <text bold fg=accent>{"Pipeline"}</text>
                        for job in jobs {
                            <row gap=1>
                                <i:check/>
                                <text>{job}</text>
                            </row>
                        }
                        match state {
                            RunState::Ready => <text fg=muted>{"Ready"}</text>,
                            RunState::Running => <text fg=info>{"Running"}</text>,
                            RunState::Failed => <text fg=err>{"Failed"}</text>,
                        }
                    </col>
                </box>
                if show_help {
                    <text dim>{"Tab moves focus; Enter activates; Esc cancels."}</text>
                }
            </col>
        },
        width,
        UiContext::default(),
    )
}

let ui = build_ui(80, RunState::Running, true);
assert!(ui.height() > 0);

The macro requires one root element. Use <col> or <row> when the screen has several top-level regions.

Choose a construction style

The library supports three ways to build the same retained component model.

Style Use it when Entry point
dom! The structure lives in Rust and needs interpolation or control flow Ui::from_root(dom! { ... }, width, context)
Runtime markup Layout arrives as configuration, generated text, or editable source Ui::from_markup(source, width, context)
Rust builders A custom component or abstraction is clearer as typed Rust Ui::from_root(Col::new().child(...), width, context)

Runtime markup is parsed when Ui is constructed and returns ParseError on malformed input. It supports implicit Markdown text between tags, but it cannot execute Rust expressions or Rust control flow.

dom! is checked by the Rust compiler. It accepts arbitrary Rust expressions in braces and child-level for, if, and match constructs. Text in macro markup must be a string literal or a braced expression; unlike runtime markup, a bare word is not text.

Builders are useful for reusable functions and custom Component implementations. Builder children implement IntoChildren, so a child can be one component, Option<T>, an array, a SmallVec, or a Vec.

dom! syntax

Elements and attributes

Tags map to built-in component builders:

# use omp_tui::dom;
# let initial = "hello";
let tree = dom! {
    <box border=round pad="1 2" w=60% bg="#20242c">
        <input id=query value={initial} placeholder="Filter"/>
    </box>
};
# let _ = tree;

Attribute forms:

  • bold, grow, mask, and similar flags mean true.
  • fg=accent, border=round, and align=center are string values. A bare identifier is not a Rust variable.
  • title="Results" and pad="1 2" preserve spaces.
  • w=50% is a percentage.
  • value={initial}, h={rows}, and bold={enabled} evaluate Rust expressions.
  • Dashed names such as pad-x and custom names such as data-kind are accepted.

Known attributes become typed Prop entries. Unknown attributes are retained as custom properties for custom elements.

Text and expression children

Use a string literal or expression inside text-like tags:

# use omp_tui::dom;
# let name = "Ada";
let tree = dom! {
    <col>
        <text>{"literal text"}</text>
        <text>{name}</text>
        <md>{"**Markdown** with `code`"}</md>
        <latex>{r"\frac{1}{2}"}</latex>
        <pre>{"┌──┐\n└──┘"}</pre>
    </col>
};
# let _ = tree;

A braced expression in a container is a component child rather than text:

# use omp_tui::{components::TextLeaf, dom};
let extra = TextLeaf::new().text("built in Rust");
let tree = dom! { <row>{extra}</row> };
# let _ = tree;

Icons

<i:name/> is shorthand for a semantic icon. Dashed names such as <i:log-in/> are accepted. The active Charset chooses a Unicode, Nerd Font, or ASCII glyph:

# use omp_tui::dom;
let tree = dom! {
    <row gap=1>
        <i:info/>
        <text>{"Details"}</text>
    </row>
};
# let _ = tree;

<icon name={name}/> is useful when the icon name is dynamic.

for, if, and match

Control flow appears wherever the owning component accepts children. Bodies may contain multiple elements and may nest other controls.

# use omp_tui::dom;
# let rows = [("alpha", true), ("beta", false)];
# let selected = Some("alpha");
let tree = dom! {
    <col>
        for (name, healthy) in rows {
            <row gap=1>
                if healthy {
                    <i:check/>
                } else {
                    <i:warning/>
                }
                <text>{name}</text>
            </row>
        }
        match selected {
            Some(name) => {
                <hr/>
                <text bold>{name}</text>
            },
            None => <text dim>{"Nothing selected"}</text>,
        }
    </col>
};
# let _ = tree;

These controls run when the component tree is built. They do not automatically re-run when widget state changes. For retained visibility driven by an input value, use when=; rebuild only when the application genuinely needs a different tree.

An <editor> is deliberately stricter: it accepts at most one editable input child and one <status>. Mutually exclusive if or match branches may choose those children, but a for loop cannot generate editor slots because it could produce duplicates.

Parent-owned data tags

Some tags describe data owned by their parent rather than standalone components:

Parent Allowed data child Purpose
<select> <option> Choice value, label, description, preview, and <td> cells
<option> or <tr> <td> One aligned grid cell
<table> <tr> One table row of cells
<status> <segment> Status-band segment
<tabs> <tab> Named pane
<tree> or <node> <node> Tree item and nested items
<todo> or <task> <task> Todo row with status, blocker note, and nested rows
<form> <field> Form field metadata
<wizard> <step> Named wizard step

The macro rejects a data tag under the wrong parent. Control flow may produce these data children under their correct owner.

Element reference

Every catalog element is listed below. “Shared” means the element also accepts the shared sizing, identity, visibility, padding, border, and background properties documented in the property reference. The Props column names behavior specific to that element; setting an unrelated known property is accepted and stored but has no effect unless a custom component reads it.

Construction-mode availability

Syntax dom! Runtime markup Notes
Catalog tags below Yes, except <spinner> Yes Both produce the same retained component types where available
<i:name/> Yes No Macro shorthand for Icon::named("name")
<ico:name/> No Yes, inside text and attribute values Resolved through the active Charset
Rust {expr}, for, if, match Yes No Evaluated while the tree is built
Bare Markdown text No Yes Runtime markup turns text between tags into Markdown leaves
Unknown tags Yes Yes Become CustomElement and resolve through UiContext::elements

Layout elements

<col>

A vertical child stack. It measures children at the available width and places them from top to bottom.

  • Children: Any component children.
  • Props: Shared; gap; align; valign. Child grow shares extra height when the column has a fixed h.
  • Typical use: The root of a screen, a form section, or the body inside a box.
# use omp_tui::dom;
let tree = dom! {
    <col h=12 gap=1 align=center>
        <text>{"Header"}</text>
        <spacer grow/>
        <text>{"Footer"}</text>
    </col>
};
# let _ = tree;

<row>

A horizontal child layout. It resolves child w, min, max, and grow, then distributes remaining width.

  • Children: Any component children.
  • Props: Shared; gap; align; valign; justify; wrap.
  • Special behavior: A <hr/> child becomes vertical automatically. With wrap, children stack vertically when their minimum widths do not fit.

<box>

A bordered vertical stack. Boxed::new() supplies a square border by default.

  • Children: Any component children.
  • Props: Shared; gap; align; valign; title; footer; title-align/footer-align; border; bc/edge; bleed.
  • Mode detail: Runtime markup also defaults pad-x=1; dom! does not, so set padding explicitly when the distinction matters.

<scroll>

A vertically scrollable stack. Arrow keys, Page Up/Down, and the mouse wheel move its viewport; focus movement chases focused descendants into view.

  • Children: Any component children, stacked without an implicit gap. Wrap them in <col gap=...> when spacing is needed.
  • Props: Shared; especially h, which fixes the viewport height.
  • Default: Eight rows when no h is supplied.

<hr/>

A horizontal divider, or a vertical divider inside a row.

  • Children: None.
  • Props: Shared sizing; border chooses the glyph family; title; fg; bc/edge; vertical.
  • Special behavior: <row> sets vertical on rule children automatically.

<table>, <tr>, and <td>

A columnar layout whose cells align vertically: every column is solved once across all rows (widest cell wins), surplus width goes to grow cells' columns, and a deficit shrinks the widest flexible column first.

  • <table> children: <tr> only; each <tr> holds <td> cells.
  • <table> props: Shared; gap (column spacing, default 2).
  • <tr> props: bg paints a full-width row band.
  • <td> content: Any children, laid out side by side; <td> props include align, w, min, max, grow, and truncate.
  • Truncation: A truncate cell flattens <pre>/<text> children — keeping each child's own style — into one line clipped by a single ellipsis at the cell edge, so multi-toned labels collapse as a unit. truncate=start clips the head instead, keeping the distinctive tail of ids and paths visible.
  • Interaction: None. Tables are layout-only; for a clickable, filterable list put the same <td> cells inside <select> options.

<spacer/>

Blank flexible space used to separate or push siblings.

  • Children: None.
  • Props: Shared sizing; normally grow, w, or h.
  • Mode detail: Runtime markup defaults to grow=1. In dom!, write <spacer grow/> explicitly for flexible space.

Text, rich content, and media

<text>

Verbatim text. It does not parse Markdown.

  • Content: String literals or expressions in dom!; raw body text in runtime markup.
  • Props: Shared; fg; bold; dim; italic; underline; reverse; strike; align; truncate; wrap; shimmer; reveal.
  • Wrapping: Word-wraps by default. wrap=char flows grapheme-exact to the width like a bare terminal, and full-width rows flag their break as a soft wrap — the renderer joins such boundaries through terminal autowrap (mid-word overflow breaks join under word wrap too), so native selection copies the line unbroken, on screen and in scrollback.
  • Updates: Ui::set_text(id, value) replaces its content. With reveal, a replacement that extends the current text continues the reveal from the shown prefix; any other replacement restarts it from nothing.

<md>

Markdown with tables, links, code highlighting, math, Mermaid, and Graphviz rendering.

  • Content: Markdown source.
  • Diagram fences: mermaid, plus dot/graphviz/gv; Graphviz rendering is pure Rust and never shells out to dot.
  • Props: Shared; text-style props; align; truncate.
  • Runtime detail: Noninteractive catalog or custom blocks can be embedded at line starts. Interactive tags are rejected inside Markdown.
  • Macro detail: dom! accepts only string/expression content inside <md>; build embedded components with Markdown’s Rust builder.
  • Updates: Ui::set_text reparses and relays out the document.

<latex>

LaTeX-style math rendered into terminal cells.

  • Content: A string literal or expression.
  • Props: Shared; text-style props; align; truncate.
  • Updates: Supports Ui::set_text.

<pre>

Verbatim preformatted terminal art. Newlines and spacing are preserved.

  • Content: A string literal or expression; runtime body text is trimmed only at outer line breaks.
  • Props: Shared; text-style props; align.
  • Updates: Supports Ui::set_text.

<callout>

A highlighted Markdown callout with an optional header, icon, and badge. The Rust builder type is Callout.

  • Content: Markdown source.
  • Props: Shared; text-style props; title; icon; badge; truncate.
  • Defaults: Without icon, the active charset supplies an informational icon.
  • Updates: Supports Ui::set_text.
# use omp_tui::dom;
let tree = dom! {
    <callout title="Build warning" badge=1 icon=warning fg=warn>
        {"The cache is stale; the next build will be slower."}
    </callout>
};
# let _ = tree;

<icon> and icon shorthand

A semantic icon resolved by Charset.

  • Content/name: In dom!, use <icon name={name}/> or <i:name/>. Runtime markup uses <icon>name</icon>, <icon icon=name/>, or inline <ico:name/>.
  • Props: Shared; fg; text-style props.
  • Fallback: Unknown names render as their bare name rather than disappearing.

<spinner> — runtime markup and Rust builder

An animated indeterminate activity glyph driven by the App loop. Tests and custom hosts can advance it directly with Ui::tick.

  • Availability: Runtime markup and components::Spinner; dom! currently treats <spinner> as a custom tag.
  • Props: Shared; fg; text-style props; an id allows set_text when constructed with the Rust builder.
  • Label: Runtime <spinner> is currently glyph-only. Use Spinner::new().label(...) for trailing text.

<img/>

A terminal image with a cell-rendered fallback.

  • Children: None.
  • Props: Shared; src; w; h; trim.
  • Source: src is a filesystem path to PNG or binary P6 PPM data. trim crops fully transparent margins before cell sampling, keeping padded logos visible as tiny thumbnails.
  • Graphics: UiContext::graphics selects cells, sixel, Kitty placeholders, Kitty direct placements, or iTerm2. For protocol images, pair Img::kitty(id, rows, cols) with Renderer::register_image.

Scene — Rust-built 3D viewport

A deterministic CPU ray tracer rasterized into braille cells and animated on the shared presentation clock.

  • Availability: Rust only — the shader is code. Mount components::Scene as a dom! expression child, or register a <scene> tag through Elements::builder() with a factory that captures your scene.
  • Props: Shared; bg paints a backdrop behind unlit (transparent) cells.
  • Scene: Build a physical scene from scene::{World, Object, Primitive, Material, Light} and pass its PathTracer, implement scene::Trace for custom animated shading, or pass a plain Fn(Ray) -> (Vec3, f32) closure for a still procedural view. Scene::size(cols, rows) fixes the cell viewport; Scene::still() paints once instead of waking every frame.
  • Transport: Finite spheres, quads, disks, and custom geometry are accelerated by an owning BVH. The bounded integrator traces direct shadows, GGX reflection, dielectric refraction, emissive and environment illumination, indirect bounces, and Russian-roulette termination without per-ray allocation.
  • Color: Vec3 colors are linear light; Vec3::rgb decodes sRGB literals and terminal output applies the sRGB transfer function after sampling.

Shader — Rust-built fullscreen effect

A CPU fragment shader rasterized into half-block pixels ( foreground over background, two pixels per cell), animated on the shared presentation clock.

  • Availability: Rust only — the shader is code. Mount components::Shader as a dom! expression child, or register a <shader> tag through Elements::builder() with a factory that captures your program.
  • Props: Shared; bg paints a backdrop behind unlit (transparent) cells.
  • Program: Implement shader::Program (advance sees the clock and pixel resolution, fragment shades one pixel, particles splats point sprites over the field), or pass a plain Fn(f32, f32) -> (Vec3, f32) closure for a still field. Shader::size(cols, rows) fixes the cell viewport; Shader::still() paints once instead of waking every frame.
  • Built-in: shader::Eclipse is the reference program — the stippled-eclipse landing shader ported from WebGPU. examples/eclipse.rs mounts it fullscreen; the chat demo's welcome card paints it as a backdrop through Surface::render.

Input and action elements

<input/>

A focusable, single-line text input.

  • Children: None.
  • Props: Shared; id; value; placeholder; mask; required; match.
  • Value: With id, Ui::values() returns a JSON string.
  • Validation: required and match are enforced when the input is inside an active wizard step.

<editor>

A multiline editor shell with a replaceable editable child and optional status band.

  • Children: At most one non-status input component and one <status>. With no input child, it creates the default multiline EditInput.
  • Props: Shared; id; value; border and sizing props.
  • Value: id and value are forwarded to the editable child; Ui::values() returns the expanded editor text.
  • Control flow: Mutually exclusive if/match branches may choose children. for cannot generate editor slots because it could create duplicates.
# use omp_tui::dom;
let tree = dom! {
    <editor id=body value="Initial text" border=round>
        <status>
            <segment fg=ok>{"ready"}</segment>
            <segment fg=muted>{"UTF-8"}</segment>
        </status>
    </editor>
};
# let _ = tree;

<button>

A focusable action with a text label.

  • Content: Text only. label= is the fallback, followed by id when no body label exists.
  • Props: Shared; id; label; submit; cancel; confirm; accent.
  • Events: cancel emits UiEvent::Cancel; submit emits Submit; otherwise an ID-bearing button emits Pressed(id).
  • Confirmation: confirm requires a second activation.

<radio/>

A compact, single-choice row of chips.

  • Children: None.
  • Props: Shared; id; options; value.
  • Options: options is whitespace-delimited; value selects the initial option.
  • Value: With id, exports the selected option as a JSON string.

<select> and <option>

A focusable choice list with optional filtering, multiple selection, previews, cell-based rows, and free-form values.

  • <select> children: <option> only.
  • <select> props: Shared; id; label; multi; filter; custom; h fixes the window height (the list scrolls); gap spaces option cells (default 2).
  • <option> content: Its visible label, optional component preview children, and optional <td> cells. Cell options render as one aligned grid across every option (the label remains the filter haystack), with the shared table solver and cell truncate semantics.
  • <option> props: value; label; desc; recommended.
  • Defaults: An option’s value defaults to its label. The first recommended option becomes the initial single selection, and focus enters a single select on its chosen option.
  • Filtering: A filterable single select types-to-filter directly — no / mode: printable keys, paste, Backspace, Ctrl+U, and Ctrl+W edit the query (shown with the hardware caret), matches are fuzzy-ranked best-first, / wrap, and Esc clears the query before bubbling Cancel. Multi selects keep the /-armed search so Space still toggles. filter="text" seeds the initial query.
  • Events: With an id, cursor motion surfaces UiEvent::Highlighted, activation (Enter or click) UiEvent::Changed, and query edits UiEvent::Filtered — hosts drive detail panes from these without touching the widget.
  • Value: Single selects export a string or null; multi exports an array.
# use omp_tui::dom;
let tree = dom! {
    <select id=theme label="Theme" filter>
        <option value=dark recommended desc="Low glare">{"Dark"}</option>
        <option value=light desc="High contrast">{"Light"}</option>
    </select>
};
# let _ = tree;

Cell options build pi-style browsers — aligned stat columns that survive narrow widths:

# use omp_tui::dom;
let tree = dom! {
    <select id=model filter h=6>
        <option value=fable label="anthropic/claude-fable-5">
            <td truncate grow><pre fg=muted>{"anthropic/"}</pre><pre>{"claude-fable-5"}</pre></td>
            <td align=end><pre fg=muted>{"1m"}</pre></td>
        </option>
    </select>
};
# let _ = tree;

Structured input, navigation, and feedback

<form> and <field>

<form> renders a compact collection of typed field definitions.

  • <form> children: <field> only.
  • <form> props: Shared; id.
  • <field> props: id; kind; label; desc; value; options; min; max; step; required; match.
  • Kinds: text (default), bool, enum, select, multi, and number.
  • Value: A form with id exports one JSON object keyed by field IDs. Boolean and number fields export JSON booleans and numbers; multi fields export arrays.

<tabs> and <tab>

A focusable tab bar with one active pane.

  • <tabs> children: <tab> only.
  • <tabs> props: Shared; id.
  • <tab> props: title; label is a dom! alias for title.
  • Value: An ID-bearing tab set exports the active tab title.
  • State: Switching tabs preserves each pane’s retained subtree.

<tree> and <node>

An expandable hierarchy with branch toggling and selectable leaves.

  • <tree> children: Root <node> records only.
  • <tree> props: Shared; id; guides draws ├─/└─ connector gutters instead of plain indentation (bare flag for the square family, or guides=round|heavy|double|dash).
  • <node> children: Nested <node> records.
  • <node> props: label; open.
  • Value: An ID-bearing tree exports the selected leaf path, joined with /, or null.

<todo> and <task>

A display-only task list in the coding agent's todo style: no focus, keys, or collapse state.

  • <todo> children: Root <task> records only.
  • <todo> props: Shared; guides selects the connector family (square by default).
  • <task> children: Nested <task> records. A task with children renders as a bold group header with an automatic done/total count over its descendant leaves; leaves render a status checkbox and label.
  • <task> props: label; status=pending|active|done|dropped|blocked (agent aliases in_progress, completed, and abandoned are accepted); desc carries the note shown as (blocked: …).
  • Styling: done paints ok with a struck label, active accent, dropped err struck, blocked warn with its note, and pending dim. Checkbox glyphs follow the active Charset.
  • Rust: components::Todo::counts() returns leaf (done, total) for host-built headers like 3/14 tasks.
# use omp_tui::dom;
let tree = dom! {
    <todo guides=round>
        <task label="Part A">
            <task status="done">{"write the parser"}</task>
            <task status="active">{"wire the renderer"}</task>
        </task>
    </todo>
};
# let _ = tree;

<wizard> and <step>

A multi-step flow with Back/Next navigation and validation.

  • <wizard> children: <step> only.
  • <wizard> props: Shared; submit.
  • <step> props: title; label is a dom! alias for title.
  • Validation: ID-bearing value components inside the active step can use required and match. Invalid input blocks Next and shows an error.
  • Completion: submit makes the final Next action emit UiEvent::Submit.

<status> and <segment>

A compact status band composed of styled segments.

  • <status> children: <segment> only.
  • <status> props: Shared; fg; bg/on; text-style props; align=end mirrors the caps for a band docked against the right edge (opening cap points into the background, closing edge sits flat on the margin).
  • <segment> content: Segment label text.
  • <segment> props: label; fg; bg/on; text-style props.
  • Styling: Segment style inherits the status style and may override it.

<progress/>

A determinate progress bar.

  • Children: None.
  • Props: Shared sizing; value; max; label.
  • Defaults: value=0, max=100; values are clamped to the maximum.
  • Presentation: The theme supplies filled, empty, label, and percentage colors.

Custom elements

Any unknown tag becomes a CustomElement.

  • Children: Any component children.
  • Props: Every known prop plus arbitrary custom attributes.
  • Resolution: Register the tag through Elements::builder() in UiContext::elements.
  • Fallback: Without a matching factory, the custom element retains and paints its fallback children.

Property reference

Known properties are parsed and type-checked in both construction modes. A property may still be ignored by a built-in that does not consume it; the element reference above names each built-in’s behavior-specific props.

Shared sizing, identity, and chrome

These properties apply to standalone retained components. Parent-owned records such as <option> and <field> use only the props listed in their own sections.

Prop Accepted values Effect
id String Stable lookup key for updates, values, conditions, and button events
when "source=value" or "source!=value" Removes the component from layout, paint, focus, and values while false
w Cell count or percentage such as 40% Preferred width; row parents resolve it, and images use it for sampling
min Integer Minimum row-child width; also the lower bound of number fields
max Integer Maximum row-child width; number-field upper bound; progress maximum
h Integer rows Fixed outer height; especially useful for scroll regions and flex columns
grow Flag or numeric weight Claims remaining width in a row or remaining height in a fixed-height vertical stack
pad N or "Y X" Vertical and horizontal inner padding
pad-x Integer cells Horizontal inner padding
pad-y Integer rows Vertical inner padding
border square, round, heavy, double, dash Adds border chrome; on <hr>, selects the stroke glyph family
bc, edge Color or start..end gradient Border color aliases; a gradient tints the border ring
bleed Flag Extends a background behind border cells
title String Border title, callout heading, tab title, or wizard-step title where applicable
footer String Label woven into the bottom border line of a framed container
title-align, footer-align start/left, center/middle, end/right Placement of the border title or footer along its frame line

Layout and text props

Prop Accepted values Consumers
gap Integer <col>, <row>, and <box> spacing
align start/left, center/middle, end/right Horizontal text placement and stack main-axis placement
valign start/top, center/middle, end/bottom, stretch/fill Box, column, and row cross-axis placement
justify start, center, end, between Row distribution of leftover width
wrap Flag or char Flag: lets a row stack vertically when it cannot fit. char on text: terminal-exact grapheme flow whose width breaks re-join in native copy
truncate Flag or start/end Clips text, Markdown, LaTeX, or callout content to one line with an ellipsis; start keeps the tail behind a leading ellipsis
vertical Flag Forces vertical rendering where supported; currently used by <hr> and set automatically by <row>
guides Bare flag or square/round/heavy/double/dash <tree> and <todo> connector gutters; the flag means square

Color and style props

Prop Accepted values Effect
fg Theme token, CSS color, or start..end gradient Foreground/style color on rendering elements; a gradient recolors painted cells
bg, on Theme token, CSS color, or gradient Background aliases; bg wins when both are present
angle Degrees, optionally with deg Gradient direction, normalized into 0..359
bold Flag Bold text/style
dim Flag Dim text/style
italic Flag Italic text/style
underline Flag Underlined text/style
reverse Flag Swaps foreground and background
strike Flag Struck-through text/style
anim Duration (180, 180ms, 0.4s; bare flag = 200ms) Tweens fg/bg/on/bc colors, gradient endpoints, w, and h from the on-screen value whenever their target changes
ease linear, in, out, in-out Easing curve for anim transitions; defaults to out
spin Duration (bare flag = 3s) Continuously rotates any fg/bg gradient by one revolution per period, on top of angle
shimmer Duration (bare flag = 2s) Sweeps a brightness crest across <text> content once per period on the shared clock. Additive: resting cells keep the authored style, the crest's shoulders lift an RGB foreground one-fifth toward white, and its peak lifts two-fifths and paints bold (foregrounds without channel data brighten via bold alone)
reveal Duration (bare flag = 250ms) Types streamed <text> content out progressively by grapheme cluster instead of popping whole chunks in: the reveal drains its backlog exponentially over the given horizon (bursts catch up smoothly), never slower than 90 clusters/s, and settles once even with the text. Appends via Ui::set_text resume from the shown prefix; non-extending replacements restart from nothing; reveal=0 shows text immediately
hover Theme token, CSS color, or gradient Border chrome while the pointer or focus rests on the component or a descendant: a solid recolors the ring, a gradient renders as a pointer-tracking glow that shimmers on the shared clock (keyboard focus paints the full ring); eases with anim/lift
lift Flag or integer rows (bare flag = 1) Reserves headroom above the component and raises its chrome into it while hovered, leaving a shadow-token drop shadow in the vacated rows

Theme tokens are fg, accent, info, ok, warn, err, muted, border, surface, hover, shadow, and contrast. An unstyled <box> frame or <hr> uses the border token; bc=/edge=/fg= override it. CSS forms include HTML color names, #rgb, #rrggbb, rgb(...), and rgba(...).

Runtime markup inherits fg, text-style flags, and truncate into descendants. dom! builds explicit Rust components and does not perform parser inheritance, so place these props on the rendering child when inheritance matters. Animation props are not inherited: anim transitions fire on the component that declares them, the first paint never animates, retargeting mid-flight resumes from the on-screen value, and kind changes (solid ↔ gradient, cells ↔ percent) snap. Hosts drive playback by sleeping until Ui::next_wake and calling Ui::tick.

Data, input, and action props

Prop Accepted values Consumers
value String, number, bool, or {expr} Input/editor initial text, segment selection, option value, field value, progress amount
options Whitespace-delimited string Segment choices and enum/select/multi form fields
label String Buttons, selects, options, fields, nodes, progress; macro alias for tab/step title
desc String Supporting text for options and form fields
kind text, bool, enum, select, multi, number Form field control type
step Integer Number-field increment
multi Flag Makes a select export multiple choices
filter Flag Enables interactive filtering on a select
custom Flag Allows a select’s free-form custom value
mask Flag Obscures an input’s displayed text without changing its exported value
recommended Flag Marks the initial preferred option in a single select
open Flag Expands a tree node initially
status pending, active, done, dropped, blocked <task> lifecycle state; drives its checkbox glyph and styling
required Flag Wizard validation for an ID-bearing value component
match Anchored simple pattern Wizard validation after trimming nonempty text
src Filesystem path PNG or P6 PPM image source
icon Icon name Callout leading icon; runtime <icon> name
badge String Compact callout header badge
submit Flag Submit button or submitting wizard
cancel Flag Cancel button
confirm Flag Requires two button activations
placeholder String Empty, unfocused input hint
accent Flag Accent-filled button treatment
focus Flag Joins the keyboard focus ring; a focused component renders its hover/lift chrome, and a focusable id-carrying <box> emits Pressed on Enter or click

match is intentionally smaller than regular expressions. It is anchored at both ends and supports literals, . for any character, classes such as [a-z0-9] and [^x], escapes, and postfix *, +, or ?.

Animation metadata props

Prop Accepted values Parsed value
anim Flag, milliseconds, 250ms, or 0.4s Transition duration; a bare flag means 200ms
ease linear, in, out, in-out Easing curve; defaults to ease-out
spin Flag, milliseconds, or seconds Rotation period; a bare flag means 3s
shimmer Flag, milliseconds, or seconds Crest sweep period; a bare flag means 2s
reveal Flag, milliseconds, or seconds Streamed-text catch-up horizon; a bare flag means 250ms

These are recognized Props metadata for animation-aware custom components. The current catalog does not universally animate merely because these props are present; use Ui::tick, PaintCtx::wake, and the anim module when implementing animated components.

Layout and styling

Layout primitives

  • <col> stacks children vertically.
  • <row> places children horizontally.
  • gap=N inserts space between adjacent children.
  • pad=N applies padding on both axes; pad="Y X", pad-x, and pad-y control them separately.
  • w=N and h=N request cell dimensions; w=N% requests a percentage width.
  • min and max constrain width where supported.
  • grow claims remaining space on the container axis. In a row that means width; in a fixed-height column it means height.
  • wrap lets a row stack when its minimum widths no longer fit.
  • <spacer/> is the clearest way to push siblings apart.

The layout engine owns final geometry. Prefer constraints and flex behavior over calculating absolute cell positions in application code.

Alignment

  • align=start|center|end positions content on the writing axis.
  • valign=start|center|end|stretch positions a container's children on the cross axis.
  • Rows stretch children by default; valign=start opts out.
  • justify=center|end|between distributes leftover row width. between anchors the first and last child at opposite edges.

Borders and backgrounds

border=square|round|heavy|double|dash frames a box, row, or column. title= writes into the top border. bc= and edge= set its color.

Components are transparent until bg= or its alias on= is present. A framed background normally stops inside the border; bleed extends it behind the frame.

Color and text style

Prefer semantic colors so the same screen works under a custom theme:

  • fg=accent, info, ok, warn, err, or muted
  • bg=accent or on=muted
  • bold, dim, italic, underline, strike, and reverse

CSS-style colors are also accepted: HTML names, #rgb, #rrggbb, rgb(...), and rgba(...). A two-stop value such as fg="magenta..cyan" creates a gradient; angle=90 makes it vertical.

Identity, state, and updates

Assign IDs to anything the application addresses

id= connects a retained component to update methods, output values, button events, and when= conditions:

# use omp_tui::{Ui, UiContext, dom};
let mut ui = Ui::from_root(
    dom! {
        <col>
            <text id=summary>{"Waiting"}</text>
            <scroll id=results h=8><md id="result-copy">{"No results"}</md></scroll>
            <input id=query placeholder="Filter"/>
        </col>
    },
    80,
    UiContext::default(),
);

assert!(ui.set_text("summary", "Running"));
assert!(ui.set_text("result-copy", "- alpha\n- beta"));
assert!(ui.set_height("results", 12));

set_text and set_height return false for an unknown ID; set_text also returns false when the component cannot replace text or the value did not change.

Call invalidate(id) after changing externally shared state read by a custom component. It remeasures and repaints the smallest safe region without replacing the component.

Read interactive values

Ui::values() returns a JSON object containing every visible, ID-bearing value component:

# use omp_tui::{Ui, UiContext, dom};
let ui = Ui::from_root(
    dom! {
        <form id=settings>
            <field id=name kind=text label="Name" value="Ada"/>
            <field id=theme kind=enum label="Theme" options="dark light" value=dark/>
            <field id=verbose kind=bool label="Verbose" value=true/>
        </form>
    },
    80,
    UiContext::default(),
);

let values = ui.values();
assert_eq!(values["settings"]["name"], "Ada");
assert_eq!(values["settings"]["theme"], "dark");
assert_eq!(values["settings"]["verbose"], true);

Standalone <input>, <editor>, <radio>, and <select> values appear at their own IDs. A <form id=...> groups its field IDs into a nested object.

Retained conditional visibility

when="source=value" and when="source!=value" show a component according to another named value:

# use omp_tui::dom;
let tree = dom! {
    <col>
        <radio id=mode options="basic advanced" value=basic/>
        <box when="mode=advanced" border=round>
            <input id="advanced-path" placeholder="Custom path"/>
        </box>
    </col>
};
# let _ = tree;

Conditions update after input events and text updates. Hidden components leave layout, painting, focus, and Ui::values() until their condition matches again.

Route application events

App is the canonical retained-UI host. It resolves capabilities, owns the terminal and renderer, routes native input, schedules animations, coalesces resizes, and presents damage between application events:

use std::io;

use omp_tui::{AppEvent, AppOptions, Key, Ui};

#[tokio::main]
async fn main() -> io::Result<()> {
    let mut app = AppOptions::new()
        .quit([Key::Ctrl('c'), Key::Ctrl('q')])
        .start(|env| {
            Ui::from_markup(
                r#"<scroll id="pane" h=12><text>Hello</text></scroll>"#,
                env.viewport.width,
                env.ctx,
            )
            .expect("static markup parses")
        })
        .await?;

    while let Some(event) = app.next().await? {
        if let AppEvent::Resized(viewport) = event {
            app.ui_mut()
                .set_height("pane", viewport.height.saturating_sub(2));
        }
        // Read submitted values or apply dependent updates here. App presents
        // those mutations when `next()` is called again.
    }
    Ok(())
}

App::next returns application-level outcomes after routing input into the tree:

  • Updated means input changed or damaged the tree. Read App::ui().values() and apply dependent mutations before the next call presents it.
  • Submitted means the focused widget submitted.
  • Pressed(id) carries the ID of an activated button.
  • Resized(size) means the resize storm settled. Ui::resize already applied the new width; update fixed-height components before the next call rebuilds the terminal view.

Ctrl-C quits by default. AppOptions::quit replaces the quit chords, and keep_on_cancel prevents a top-level UiEvent::Cancel from stopping the host. Once stopped, next continues to return None.

Mouse reporting

Inline sessions leave the mouse to the terminal, so native text selection and scrollback keep working. Pointer-driven screens opt in with AppOptions::mouse() (or TerminalOptions::mouse(true) for immediate-mode hosts), which enables click, drag, motion, and wheel reports for the whole session. The alternate screen always enables reporting while it is active and restores the inline policy on exit.

Async host

App::handle returns a cloneable UiHandle for tasks and synchronous threads. update queues an arbitrary mutation; set_text and invalidate cover the common retained-tree updates; shutdown cancels the host. Sends never block and become no-ops after the App is gone.

The App-installed image loader reads and decodes <img> sources on Tokio's blocking pool. Layout first paints the themed box placeholder; delivery remeasures and repaints the image at the smallest safe retained-tree region.

Hosts that need raw key or mouse events can drop down to Terminal and multiplex Terminal::next — one async mailbox of TerminalEvents (decoded input, resize, debug queries) — with their own timers using tokio::select!. Pass terminal response events through Terminal::handle_input_event and resolve TerminalEvent::Resize with Terminal::take_resize. The chat example is the immediate-mode reference.

Custom keybindings

Every Terminal starts with the default Keymap. Edits ship to the event actor's live decoder and apply to the next decoded chord:

# fn configure(terminal: &mut omp_tui::Terminal) {
use omp_tui::{Chord, Key, Mods};

let alt_n = Chord::new(Key::Char('n'), Mods { alt: true, ..Mods::default() });
terminal.edit_keymap(|keymap| keymap.bind(alt_n, Key::PageDown));
# }

Keymap::disable masks a chord, including its identity fallback; Keymap::unbind removes a table entry and restores fallback handling. Exact bindings win before shift-folded spellings and identity fallbacks. InputDecoder exposes keymap() and keymap_mut() accessors for applications that decode their own byte streams.

Renderer stability boundary

The final stable_rows argument to Ui::present and Renderer::rebuild declares an immutable document prefix. Use 0 for a fully mutable application. Use a larger value only for rows that will never change again, such as completed transcript entries moving into native scrollback. Mutating an already committed stable row is rejected because terminal scrollback is not addressable.

Resize without losing state

Call ui.resize(new_width) rather than rebuilding the Ui. Update fixed viewport components with set_height, then rebuild the renderer's terminal view. This preserves active tabs, editor text, selections, focus, and scroll positions.

Paste and clipboard

Paste flows through one pipeline regardless of how the bytes arrive; components only ever see Component::paste text (or, one level up, InputEvent::Paste).

  • Bracketed paste is enabled for every session. The decoder reassembles chunked payloads (64 MiB cap, 1 s inactivity recovery), decodes tmux's re-encoded control bytes, normalizes newlines, and strips C0 controls before InputEvent::Paste is emitted.
  • Enhanced paste (OSC 5522) is probed via DECRQM and enabled when the terminal supports it (TerminalCaps::paste_events; kitty today). A terminal-level paste then arrives as an out-of-band clipboard offer instead of bracketed text, which is how an image paste reaches the app. Terminal answers the offer conversation internally — MIME listing, priority pick (png > jpeg > webp > gif > text/plain), chunked transfer — and the assembled Pasted payload surfaces through Terminal::take_paste, mirroring take_resize. App dispatches it automatically; immediate-mode hosts check take_paste after a consumed handle_input_event (see the chat example's user_event).
  • Ctrl+V / Ctrl+Shift+V resolve to the semantic keys Key::Paste and Key::PasteRaw in the default Keymap. When the focused component leaves them unclaimed, App reads the system clipboard on a detached thread — image first, then file-manager file URLs, then text — and routes the result back through the paste pipeline. The raw spelling reads text only and inserts it verbatim via Component::paste_raw: no drop classification, no large-paste collapse, so bulk text stays inline and editable. An empty bracketed paste (macOS Cmd+V with an image-only pasteboard) triggers the smart read. Backends live in the paste module: arboard (with a process-lifetime Linux handle so the X11 selection owner survives) plus platform bridges — pbpaste/osascript file URLs, wl-paste/xclip/xsel, PowerShell for Windows and WSL interop, Termux. All block; see the module docs for the detached-thread contract.
  • Ordering: input that arrives while a clipboard read is in flight is queued and replayed afterwards, so an Enter typed right after Ctrl+V submits with the paste instead of before it. Quit chords bypass the queue, and a read that outlives its 10 s ceiling is abandoned (its late result dropped by generation) so a hung backend can never wedge input. Reads run on detached threads — never tokio's blocking pool, which cannot abort a running task and would stall runtime shutdown behind a wedged native clipboard. The chat example gets the same guarantees by pausing its terminal.next() branch behind an absolute deadline.
  • Dropped paths: paste::dropped_paths classifies pasted text that is really a drag-and-drop — quoted or backslash-escaped paths, file:// URLs (percent-decoded), Windows drive/UNC anchors, multi-file drops, and the unescaped-space macOS screenshot form. An editor with a bound Attachments queue stages image paths (existing files only) as <icon> #N chips whose submit-time payload is the path; pasted images persist to a temp file first and route the same way.
  • Copy: Terminal::copy_to_clipboard writes OSC 52 (works over SSH) and spawns a best-effort native write.

Overlays

An overlay is a viewport layer — a model picker, a confirmation dialog, a persistent sidebar — composited above the document without disturbing it. Each overlay is its own retained Ui stacked on the presenting one:

# use omp_tui::{dom, OverlayAnchor, OverlayOptions, Ui, UiContext};
# let mut ui = Ui::from_markup("<text>base</text>", 80, UiContext::default()).unwrap();
let picker = ui.show_overlay(
    dom! {
        <box border=round title="Switch Model">
            <select id=model>
                <option value="fable">{"claude-fable-5"}</option>
                <option value="opus">{"claude-opus-5"}</option>
            </select>
        </box>
    },
    OverlayOptions::default().anchor(OverlayAnchor::Center).min_width(44),
);

// The overlay is a full retained Ui: address it through its id.
let choice = ui.overlay(picker).map(|overlay| overlay.values());
ui.close_overlay(picker);

Behavior:

  • Placement is declarative. OverlayOptions resolves against the viewport at every present: anchor (nine positions, Center default), width/max_height as cells or percentages (Dim::Cells, Dim::Pct), margin insets, offset_x/offset_y nudges, explicit row/col overrides, and min_viewport to gate the layer on small terminals. The default width is min(80, available).
  • Modal layers capture input (the default). The topmost visible modal overlay receives every key and paste. A cancel from inside a layer (Esc, or a <button cancel>) dismisses it before anything else: the App runtime closes that layer and returns AppEvent::OverlayClosed(id) (quit-on-cancel only applies to the base tree), while manual hosts see UiEvent::Cancel and call close_active_overlay, which dismisses the layer that emitted it even when a higher-z non-modal pane sits above it in the stack (close_top_overlay pops the stack top regardless of modality). The base tree keeps its focus untouched, so closing an overlay restores the previous interaction exactly. Mouse input inside the overlay's bounds is routed to it and occluded from the document; clicks outside still reach the base tree.
  • Scrollback stays clean. Overlays are composited by the renderer as z-ordered viewport layers. The document keeps scrolling and committing while a layer is open: a row leaving the viewport is repainted from the raw document before it enters native scrollback, so overlay cells can never leak into terminal history.
  • Stacking nests. Later show_overlay calls stack on top; explicit z on OverlayOptions orders layers regardless of creation order, and ties stack newest-on-top. set_overlay_hidden parks a layer without losing its editor text, selection, or scroll state.

Non-modal layers and sidebars

OverlayOptions::non_modal() turns a layer into a persistent pane instead of a dialog: keys and paste stay with the base tree, Esc never dismisses it, and the App runtime keeps presenting inline instead of holding the alternate screen — the document keeps committing to native scrollback beneath the pane, and a row entering history is repainted from the raw document first, so sidebar cells never reach history.

# use omp_tui::{dom, Dim, OverlayAnchor, OverlayOptions, Size, Ui, UiContext};
# let mut ui = Ui::from_markup("<text>transcript</text>", 120, UiContext::default()).unwrap();
let sidebar = ui.show_overlay(
    dom! {
        <col pad="0 1" gap=1>
            <text bold>{"Session"}</text>
            <hr/>
            <spacer grow/>
            <text dim>{"ctrl+b toggles"}</text>
        </col>
    },
    OverlayOptions::default()
        .anchor(OverlayAnchor::Right)
        .width(Dim::Cells(28))
        .non_modal()
        .fill_height()
        .min_viewport(Size::new(100, 0)),
);

// Hand the keyboard to the pane and back; a click inside or outside
// the band does the same.
ui.focus_overlay(sidebar);
assert_eq!(ui.focused_overlay(), Some(sidebar));
ui.blur_overlay();

Keyboard hand-off:

  • The topmost visible modal overlay always wins the keyboard; a focused non-modal pane resumes when it closes.
  • focus_overlay activates the pane's focus ring so its chrome shows where typing lands. A click inside the band focuses it; a click outside, an unconsumed Esc, or a <button cancel> inside blurs it back to the base tree (nothing is dismissed).
  • focused_overlay() reports the pane holding the keyboard; top_overlay() reports whichever layer currently receives keys, modal or focused. The hardware caret follows the same ownership: the active layer places it (or hides it when it has no caret of its own), while passive panes let the document's caret show through.
  • Hiding (set_overlay_hidden) or closing the focused pane returns the keyboard to the base tree.

fill_height() stretches a retained overlay tree to the full available viewport height on every present (margins and max_height still apply), so grow and valign lay the rail out like a full-height column; without it the band follows content height. Raw-frame Layer hosts size their frame directly instead — see examples/chat for a full-height, click-to-focus sidebar over an immediate-mode document.

Teardown: the final inline screen persists into native scrollback once the shell resumes, so a pane must not be left composited at exit. App scrubs automatically on drop; manual hosts call Renderer::clear_layers() (after releasing any alternate-screen hold) before dropping the Terminal, which repaints the pane's bands from the raw document.

Limitations: direct-drawn images (sixel, iTerm2, Kitty direct) are not occluded by overlays — cell-based Kitty placeholder graphics are. The transient resize preview paints only the document; overlays reappear at resize settle.

Terminal lifecycle

Terminal::enter takes exclusive ownership of the controlling terminal, installs emergency restore hooks, enables raw input and the supported keyboard protocol, and starts the input pump. Terminal::leave is idempotent: it disables keyboard enhancement before draining late input, clears progress, restores the previous title and terminal modes, and finally restores raw mode. Drop performs normal teardown on early returns; panic and fatal-signal paths use an allocation-free blind restore. Terminal::emergency_restore exposes that crash-path restore when an application has its own fatal handler.

Entry resets ANSI insert mode (IRM 4) and new-line mode (LNM 20) so cell writes replace in place and Return decodes once; queried prior states are restored on normal and emergency teardown. Appearance (2031) and in-band resize (2048) notifications are enabled and disabled only when the session owns them.

TerminalOptions::default() lets Terminal::enter negotiate capabilities while feeding probe-window bytes into the same streaming decoder the live pump owns. TerminalOptions::new(caps) supplies capabilities resolved beforehand; add .probe_results(probe) when they came from negotiate so preserved bytes, partial escape sequences, and queried prior mode states reach entry without loss. Optional CursorStyle, probe timeout, and stderr-capture policy are also configured here. Terminal::caps() returns the resolved session capabilities. While the session is active, Terminal::set_title sets the window title safely and Terminal::set_progress reports Progress::Value, Error, Indeterminate, Paused, or Clear. Teardown clears both automatically.

Detect terminal capabilities

detect() is the fast, environment-only path. negotiate(timeout) -> (TerminalCaps, ProbeResults) adds a bounded controlling-terminal probe; ProbeResults::preserved_input retains every non-probe byte in original order. negotiate_async performs the same work on Tokio's blocking pool. Prefer Terminal::enter(TerminalOptions::default()) when capabilities are not needed beforehand: entry negotiates internally, completed key, mouse, paste, and focus events are ready on the first read, terminal responses remain internal, and partial sequences continue in the live pump's decoder.

When capabilities are needed before entry, pass both halves back with TerminalOptions::new(caps).probe_results(probe). Keep the UI context and renderer aligned with terminal.caps() fields such as graphics, sync_output, screen_to_scrollback, hyperlinks, cell_px, and inside_tmux. TerminalCaps records terminal identity, selected graphics and notification protocols, keyboard support, pixel geometry, appearance, resize support, and multiplexer state; TerminalCaps::resolve applies probe results or an explicit graphics override.

The graphics detector recognizes these crate-specific overrides:

Variable Effect
OMP_FORCE_IMAGE_PROTOCOL kitty, iterm/iterm2, or sixel; another nonempty value forces cell rendering
OMP_TUI_CHARSET ascii, unicode, or nerd overrides the glyph tier inferred from the emulator
OMP_NO_KITTY_PLACEHOLDERS A truthy value disables Kitty Unicode placeholders
OMP_KITTY_PLACEHOLDERS A truthy or falsy value explicitly enables or disables placeholders
OMP_NO_SYNC_OUTPUT Any nonempty value disables synchronized output
OMP_SYNC_OUTPUT 1 enables and 0 disables synchronized output
OMP_FORCE_SYNC_OUTPUT 1 enables synchronized output
OMP_NO_HYPERLINKS 1 disables OSC 8 hyperlinks
OMP_FORCE_HYPERLINKS 1 enables OSC 8 hyperlinks

For placeholder overrides, truthy means 1, true, on, yes, or y; falsy means 0, false, off, no, or n, case-insensitively.

Themes, glyphs, and appearance

Pass an explicit UiContext when the default theme or terminal capability tier is not appropriate:

# use omp_tui::{Charset, Color, Theme, Ui, UiContext, dom};
let context = UiContext {
    charset: Charset::Ascii,
    theme: Theme {
        accent: Color::Rgb(0x7c, 0x9c, 0xff),
        warn: Color::Rgb(0xff, 0xc8, 0x57),
        ..Theme::default()
    },
    ..UiContext::default()
};

let ui = Ui::from_root(dom! { <text fg=accent>{"portable"}</text> }, 40, context);
# let _ = ui;

Charset::Unicode, NerdFont, and Ascii change icons and structural glyphs without changing markup. When negotiation reports the background, UiContext::with_terminal_caps selects Appearance::Dark or Appearance::Light and the matching theme. Terminal::appearance returns the latest classification, and Terminal::on_appearance_change observes later terminal changes.

The context stays swappable after construction: Ui::set_context applies a new context to the retained tree and every stacked overlay, discarding cached themed output and relaying out — no rebuild, and widget state (scroll, selection, filter queries, animations) survives. App does this automatically when the terminal flips between dark and light: a stock palette follows the flip, a custom theme is preserved, and either way the host surfaces AppEvent::Appearance so the app can refresh colors it derived outside the theme. Structure parsed from markup is retained; swapping elements affects future parses only.

Graphics protocols and images

Graphics selects one of five renderer paths:

  • Cells decodes PNG or binary P6 PPM sources into colored half-block cells.
  • Sixel materializes registered PNGs as DEC sixel images.
  • KittyPlaceholders uses Kitty Unicode placeholder cells for registered images.
  • KittyDirect uses cursor-positioned Kitty placements.
  • Iterm2 emits iTerm2 inline images.

<img src=.../> and components::Img always retain a cell fallback. For a protocol image, build Img::kitty(id, rows, cols) and register the same nonzero, 24-bit ID with Renderer::register_image(id, png_bytes). Before first presentation, select Renderer::set_graphics(caps.graphics), apply caps.cell_px with set_cell_pixel_size, and call set_tmux_passthrough(caps.inside_tmux). The companies example shows the complete registration flow.

Hyperlinks

Markdown links and autolinks carry hyperlink identity automatically. Rust-built rich text can attach a target with Style::link(url). Hyperlink identities remain in the frame regardless of terminal support; Renderer::set_hyperlinks(caps.hyperlinks) controls whether they materialize as OSC 8 output. OMP_NO_HYPERLINKS=1 and OMP_FORCE_HYPERLINKS=1 override conservative detection.

Notifications

Build a Notification and pass it with the detected capabilities to notify. Delivery selects Kitty OSC 99, OSC 9, or the terminal bell; the bell path can also use the Linux freedesktop notification service.

use std::io;

use omp_tui::{Notification, Urgency, detect, notify};

fn main() -> io::Result<()> {
    let caps = detect();
    let notification = Notification::builder()
        .title("Build complete")
        .body("All checks passed")
        .urgency(Urgency::Normal)
        .build();
    let mut out = io::stdout();
    notify(&mut out, &caps, &notification)
}

Custom elements and components

Use a custom Component when a view needs behavior the built-ins do not provide. A component supplies properties, identity, measurement, height, painting, and optionally placement and input methods. Allocate its stable identity with next_slot().

Unknown dom! tags become CustomElement instances. Register factories through Elements::builder() and place the resulting registry in UiContext::elements. This lets application markup use domain names such as <build-summary> while the factory returns an ordinary component tree.

Prefer composing built-ins before implementing Component; composition automatically inherits layout, focus routing, styling, conditional visibility, and incremental repaint behavior.

Test without a terminal

Ui paints its initial Frame during construction, and input methods are terminal-independent. Most behavior tests need neither raw mode nor a real writer:

# use omp_tui::{Key, Ui, UiContext, UiEvent, dom};
let mut ui = Ui::from_root(
    dom! { <input id=query value="a"/> },
    20,
    UiContext::default(),
);

assert_eq!(ui.values()["query"], "a");
assert_eq!(ui.handle_key(Key::Char('b')), UiEvent::None);
assert_eq!(ui.values()["query"], "ab");
assert!(ui.frame().size().height > 0);

Test application outcomes and submitted values rather than builder plumbing. Use Renderer<Vec<u8>> only when the test specifically concerns emitted terminal bytes or differential painting.

Debug a running app (OMP_TTY + OMP_TUI_DEBUG)

Two environment variables make a live application scriptable without a real terminal:

  • OMP_TTY=<pty-slave-path> reroutes every terminal open — input, rendered frames, capability probes — to that device. A harness holding the master side captures the exact byte stream a terminal would see. SIGWINCH does not reach an override device; set the window size with TIOCSWINSZ on the master and trigger the resize op below.
  • OMP_TUI_DEBUG=<unix-socket-path> makes Terminal::enter start a server thread that binds a socket there and answers one JSON request per line. The wire speaks TerminalEvent directly: injected input rides the same mailbox as decoded terminal bytes, screen ops answer from the snapshot the renderer publishes on every paint, and retained-state ops ride the mailbox as TerminalEvent::Debug queries that an App answers from live retained state.

Each request is {"op": ...}; each response is one JSON line with "ok":

op fields effect
info viewport, document height, window top, overlay summary
text the visible viewport as text — whatever was painted last, alternate screen included
frame the full document frame as text rows (App hosts)
tree component tree: kinds, ids, rectangles, visibility, focus, overlay bands (App hosts)
values Ui::values() of the base tree (App hosts)
keys keys inject decoded keys: "tab C-a enter 'literal text'"
event event inject serialized TerminalEvents verbatim
bytes data feed raw bytes through the live input decoder
paste text inject a bracketed paste
mouse x, y, action inject a gesture (click, drag, release, move, wheel-up, ...)
resize re-read tty geometry, then run the normal resize/settle flow
quit inject C-c, the conventional quit chord

Injected input lands in the ordinary event mailbox, so the host observes it exactly like terminal input — quit chords, focus routing, and overlay dismissal all apply. frame, tree, and values need a retained tree: App hosts answer them through the same mailbox (TerminalEvent::Debugomp_tui::respond_debug_query), and the server times the request out for hosts that ignore the query.

Inside this repository the .omp/tools/tui.ts agent tool wraps the whole loop: it spawns an example or bin on a Bun-native PTY (a real controlling terminal, so SIGWINCH resizes and immediate-mode hosts work) with OMP_TUI_DEBUG set, then exposes screenshots, tree dumps, input injection, resizes, and raw byte-stream statistics as one session-based tool.

Common mistakes

  • Rebuilding after every event: this resets retained widget state. Route events into the existing Ui.
  • Using bare text in dom!: write <text>{"hello"}</text> or <text>{value}</text>. Bare implicit Markdown belongs to runtime markup.
  • Forgetting braces around Rust values: fg=color means the literal string "color"; fg={color} evaluates the variable.
  • Using a data tag under the wrong owner: <option> belongs under <select>, <tab> under <tabs>, and so on.
  • Treating construction-time if as reactive: use when= for value-driven retained visibility.
  • Rebuilding on resize: call resize and set_height so focus, selections, and scroll offsets survive.
  • Passing screen rows to handle_mouse: translate them to document rows when content is taller than the viewport.
  • Marking mutable content stable: keep stable_rows at 0 unless the prefix is permanently immutable.
  • Skipping terminal restoration: enter through Terminal; its explicit leave, Drop, panic hook, and fatal-signal handlers restore the modes it owns.

Run the bundled examples

cargo run -p omp-tui --example gallery
cargo run -p omp-tui --example chat
cargo run -p omp-tui --example companies
cargo run -p omp-tui --example footers

gallery is one tabbed application hosting every showcase pane: Markdown, Math, Mermaid, and Graphviz rendering, a dom!-built macro pane, a live editor-driven preview, the Anim prop-tween lab (autoplaying, with scene hotkeys), the Overlay modal demo (Ctrl+K/Ctrl+G), the fullscreen Eclipse shader, and the chat example's model Picker inline. It demonstrates dom!, retained updates, unclaimed-key routing (AppEvent::Key), mouse input, resize handling, and differential rendering in one compact application. chat remains the standalone interactive chat demo with its picker, sidebar, and alt-screen welcome scene.