Skip to main content

Crate omp_tui

Crate omp_tui 

Source
Expand description

§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.

StyleUse it whenEntry point
dom!The structure lives in Rust and needs interpolation or control flowUi::from_root(dom! { ... }, width, context)
Runtime markupLayout arrives as configuration, generated text, or editable sourceUi::from_markup(source, width, context)
Rust buildersA custom component or abstraction is clearer as typed RustUi::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:

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

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:

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>
};

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

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

§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:

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

<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.

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>
};

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:

ParentAllowed data childPurpose
<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

Syntaxdom!Runtime markupNotes
Catalog tags belowYes, except <spinner>YesBoth produce the same retained component types where available
<i:name/>YesNoMacro shorthand for Icon::named("name")
<ico:name/>NoYes, inside text and attribute valuesResolved through the active Charset
Rust {expr}, for, if, matchYesNoEvaluated while the tree is built
Bare Markdown textNoYesRuntime markup turns text between tags into Markdown leaves
Unknown tagsYesYesBecome 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.
let tree = dom! {
    <col h=12 gap=1 align=center>
        <text>{"Header"}</text>
        <spacer grow/>
        <text>{"Footer"}</text>
    </col>
};
§<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.
let tree = dom! {
    <callout title="Build warning" badge=1 icon=warning fg=warn>
        {"The cache is stale; the next build will be slower."}
    </callout>
};
§<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.
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>
};
§<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.
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>
};

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

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>
};

§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.
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>
};
§<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.

PropAccepted valuesEffect
idStringStable 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
wCell count or percentage such as 40%Preferred width; row parents resolve it, and images use it for sampling
minIntegerMinimum row-child width; also the lower bound of number fields
maxIntegerMaximum row-child width; number-field upper bound; progress maximum
hInteger rowsFixed outer height; especially useful for scroll regions and flex columns
growFlag or numeric weightClaims remaining width in a row or remaining height in a fixed-height vertical stack
padN or "Y X"Vertical and horizontal inner padding
pad-xInteger cellsHorizontal inner padding
pad-yInteger rowsVertical inner padding
bordersquare, round, heavy, double, dashAdds border chrome; on <hr>, selects the stroke glyph family
bc, edgeColor or start..end gradientBorder color aliases; a gradient tints the border ring
bleedFlagExtends a background behind border cells
titleStringBorder title, callout heading, tab title, or wizard-step title where applicable
footerStringLabel woven into the bottom border line of a framed container
title-align, footer-alignstart/left, center/middle, end/rightPlacement of the border title or footer along its frame line

§Layout and text props

PropAccepted valuesConsumers
gapInteger<col>, <row>, and <box> spacing
alignstart/left, center/middle, end/rightHorizontal text placement and stack main-axis placement
valignstart/top, center/middle, end/bottom, stretch/fillBox, column, and row cross-axis placement
justifystart, center, end, betweenRow distribution of leftover width
wrapFlag or charFlag: lets a row stack vertically when it cannot fit. char on text: terminal-exact grapheme flow whose width breaks re-join in native copy
truncateFlag or start/endClips text, Markdown, LaTeX, or callout content to one line with an ellipsis; start keeps the tail behind a leading ellipsis
verticalFlagForces vertical rendering where supported; currently used by <hr> and set automatically by <row>
guidesBare flag or square/round/heavy/double/dash<tree> and <todo> connector gutters; the flag means square

§Color and style props

PropAccepted valuesEffect
fgTheme token, CSS color, or start..end gradientForeground/style color on rendering elements; a gradient recolors painted cells
bg, onTheme token, CSS color, or gradientBackground aliases; bg wins when both are present
angleDegrees, optionally with degGradient direction, normalized into 0..359
boldFlagBold text/style
dimFlagDim text/style
italicFlagItalic text/style
underlineFlagUnderlined text/style
reverseFlagSwaps foreground and background
strikeFlagStruck-through text/style
animDuration (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
easelinear, in, out, in-outEasing curve for anim transitions; defaults to out
spinDuration (bare flag = 3s)Continuously rotates any fg/bg gradient by one revolution per period, on top of angle
shimmerDuration (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)
revealDuration (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
hoverTheme token, CSS color, or gradientBorder 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
liftFlag 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

PropAccepted valuesConsumers
valueString, number, bool, or {expr}Input/editor initial text, segment selection, option value, field value, progress amount
optionsWhitespace-delimited stringSegment choices and enum/select/multi form fields
labelStringButtons, selects, options, fields, nodes, progress; macro alias for tab/step title
descStringSupporting text for options and form fields
kindtext, bool, enum, select, multi, numberForm field control type
stepIntegerNumber-field increment
multiFlagMakes a select export multiple choices
filterFlagEnables interactive filtering on a select
customFlagAllows a select’s free-form custom value
maskFlagObscures an input’s displayed text without changing its exported value
recommendedFlagMarks the initial preferred option in a single select
openFlagExpands a tree node initially
statuspending, active, done, dropped, blocked<task> lifecycle state; drives its checkbox glyph and styling
requiredFlagWizard validation for an ID-bearing value component
matchAnchored simple patternWizard validation after trimming nonempty text
srcFilesystem pathPNG or P6 PPM image source
iconIcon nameCallout leading icon; runtime <icon> name
badgeStringCompact callout header badge
submitFlagSubmit button or submitting wizard
cancelFlagCancel button
confirmFlagRequires two button activations
placeholderStringEmpty, unfocused input hint
accentFlagAccent-filled button treatment
focusFlagJoins 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

PropAccepted valuesParsed value
animFlag, milliseconds, 250ms, or 0.4sTransition duration; a bare flag means 200ms
easelinear, in, out, in-outEasing curve; defaults to ease-out
spinFlag, milliseconds, or secondsRotation period; a bare flag means 3s
shimmerFlag, milliseconds, or secondsCrest sweep period; a bare flag means 2s
revealFlag, milliseconds, or secondsStreamed-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:

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:

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:

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>
};

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:

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:

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.

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:

VariableEffect
OMP_FORCE_IMAGE_PROTOCOLkitty, iterm/iterm2, or sixel; another nonempty value forces cell rendering
OMP_TUI_CHARSETascii, unicode, or nerd overrides the glyph tier inferred from the emulator
OMP_NO_KITTY_PLACEHOLDERSA truthy value disables Kitty Unicode placeholders
OMP_KITTY_PLACEHOLDERSA truthy or falsy value explicitly enables or disables placeholders
OMP_NO_SYNC_OUTPUTAny nonempty value disables synchronized output
OMP_SYNC_OUTPUT1 enables and 0 disables synchronized output
OMP_FORCE_SYNC_OUTPUT1 enables synchronized output
OMP_NO_HYPERLINKS1 disables OSC 8 hyperlinks
OMP_FORCE_HYPERLINKS1 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:

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);

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.

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:

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":

opfieldseffect
infoviewport, document height, window top, overlay summary
textthe visible viewport as text — whatever was painted last, alternate screen included
framethe full document frame as text rows (App hosts)
treecomponent tree: kinds, ids, rectangles, visibility, focus, overlay bands (App hosts)
valuesUi::values() of the base tree (App hosts)
keyskeysinject decoded keys: "tab C-a enter 'literal text'"
eventeventinject serialized TerminalEvents verbatim
bytesdatafeed raw bytes through the live input decoder
pastetextinject a bracketed paste
mousex, y, actioninject a gesture (click, drag, release, move, wheel-up, …)
resizere-read tty geometry, then run the normal resize/settle flow
quitinject 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.

The crate covers terminal lifecycle and capability detection, including graphics protocol support and desktop notifications.

Re-exports§

pub use imagefmt::ImageFormat;
pub use paste::Pasted;
pub use paste::PastedImage;

Modules§

anim
Time-driven animation primitives: easing, tweens, and frame cycles. Time-driven animation primitives for retained and immediate paints.
components
Built-in layout, text, navigation, data, and input components.
imagefmt
Image format dimension probing without full decodes. Header-only image dimension probes.
latex
Best-effort terminal rendering for inline and display LaTeX math.
markdown
Width-aware Markdown rendering into styled terminal lines.
paste
Terminal protocol, dropped-path, and native clipboard paste handling. Paste handling across terminal protocol, dropped-path classification, and OS clipboards.
scene
Raytraced braille scenes.
shader
CPU fragment-shader effects packed into half-block cells. CPU fragment-shader toolkit: a GPU-style program trait and a rasterizer that packs per-pixel shading into half-block cells.
syntax
XML-ish syntax highlighting for markup shown in an editor.
ttyid
Stable controlling-terminal identity helpers. Stable identifiers for the terminal attached to standard input.
watchdog
Parent-process watchdogs for terminal-owning applications. Render-loop stall detection with an optional background probe.

Macros§

dom
Builds a component tree from declarative markup. Builds one component tree from markup with child-level for, if, and match control flow.

Structs§

App
Running retained-UI terminal host.
AppEnv
Inputs supplied to the AppOptions::start UI builder.
AppOptions
Configuration for AppOptions::start.
Cached
A component with memoized geometry, its last placed rectangle, and any in-flight property transitions.
Chord
A terminal chord exactly as decoded: native key plus full modifiers.
Clip
A per-row hard-clipping sink adapter.
Command
One slash-command palette entry completed by SlashCommands.
CompletionEdit
Buffer edit returned by EditorCompletion::tab: replaces range with insert and leaves the cursor after it.
DebugQuery
One correlated debug query routed through the event loop.
EditBuffer
Shared Pi-style flat text editing model used by the widget and chat editors.
Editor
Editable multiline input with Pi-compatible completion and editing.
EditorOptions
Feature switches for Editor::new; everything defaults on. Completion is not a switch: register one with Editor::set_completion.
Elements
Immutable registry of custom element factories.
EventCtx
Context supplied while routing an input event.
Frame
A complete declarative terminal viewport.
Grid
Table-grid glyph set resolved by Charset::grid: border rows as (left, junction, right) triples plus the row-interior separators.
Hit
A clickable region in document coordinates.
ImageLoader
Off-thread image decoder used by asynchronous UI hosts.
InputDecoder
Stateful terminal input framer.
Keymap
Chord-to-action table consulted before the identity fallbacks.
Layer
One z-ordered viewport layer: a rendered frame placed declaratively at present time.
LinkId
Stable process-local identity for one terminal hyperlink target.
Measure
Counts rendered rows and widths without storing their text.
Mods
Modifier bits attached to terminal input.
MouseReport
Lossless SGR mouse report with its routable gesture kind.
Notification
A structured desktop notification.
NotificationBuilder
Builder for Notification.
OverlayBand
Resolved viewport band of a layer.
OverlayId
Identity handle returned by crate::Ui::show_overlay.
OverlayMargin
Insets that keep an overlay away from viewport edges.
OverlayOptions
Declarative sizing, placement, and visibility options for an overlay.
PaintCtx
State shared by component painters for one frame.
PaintStats
Measurements from one native-scrollback paint.
ParseError
Markup rejection with byte position context.
Picker
Active completion dropdown state.
Prefix
An owned styled hanging prefix.
Prefixed
A sink adapter that emits first-row and continuation prefixes.
ProbeParser
Incremental demultiplexer for startup probe replies and application input.
ProbeResults
Replies collected by a runtime graphics capability probe.
Props
Typed component attributes.
Rect
A rectangular cell region clipped by drawing operations to the frame.
Renderer
Renders an immutable document prefix and a mutable viewport-local suffix.
Restyle
A sink adapter that transforms every style.
RichText
Flat rendered rich text with coalesced styled runs and row metadata.
Rows
A row-limiting sink adapter.
Size
Terminal dimensions measured in character cells.
SlashCommands
Pi-compatible slash-command completion over a fixed Command palette.
Style
Canonical visual attributes for one or more cells.
Suggestion
One selectable completion row.
Suggestions
Ranked dropdown suggestions returned by EditorCompletion::suggest.
Tee
A sink adapter that forwards and copies all output.
Terminal
Owns raw mode and every terminal mode enabled for an interactive session.
TerminalCaps
Graphics-related terminal capabilities resolved for the current process.
TerminalOptions
Options controlling terminal entry.
Theme
Semantic color palette. Agents pick meanings; the theme picks colors — no widget hardcodes an RGB value.
TtyOut
Terminal output sink: stdout normally, the OMP_TTY device when set.
Ui
A parsed, laid-out, retained component tree painting into a Frame.
UiContext
Presentation context threaded through parse, layout, and paint.
UiHandle
Cloneable remote for mutating the Ui from any thread or task.
VisualRow
One visual word-wrapped row borrowed from an EditBuffer.
Wrap
A word-wrapping rich sink adapter.

Enums§

AltScreenUse
Why staged alternate-screen ownership is being taken (Terminal::stage_alt_enter).
AppEvent
Host-level event returned by App::next.
Appearance
Terminal-reported background appearance.
Border
Border glyph set for framed containers (<box>, bordered <row>/<col>).
BufferOutcome
Whether an editing command changed the buffer.
Charset
Glyph capability tier, mirroring the unicode | nerd | ascii symbol presets in the coding agent.
Color
A terminal foreground or background color.
CssColor
A parsed CSS color value, before lowering to a terminal Color.
CursorStyle
Cursor shape requested while the terminal is owned by the application.
DebugOp
Debug-protocol ops carried by TerminalEvent::Debug.
Dim
Width request for a row child.
EditOutcome
Result of handling one terminal key event.
Flow
Result of routing an input event through a component.
Graphics
Terminal image rendering capability.
HitTag
Meaning attached to a mouse hit rectangle.
Icon
Semantic icon generated from the canonical three-tier TSV catalog.
InputEvent
One framed event from the streaming terminal input decoder.
JamoWidth
Terminal policy for Hangul Compatibility Jamo (U+3131..=U+318E).
Key
Decoded keyboard input, terminal-agnostic.
Mouse
Mouse gestures in document cell coordinates.
MouseButton
Physical button encoded by an SGR mouse report.
NotificationAction
Terminal actions advertised by a structured notification.
NotificationSound
Sound requested for a structured notification.
NotifyProtocol
Terminal notification protocol selected from emulator identity.
OutputState
Health of the renderer’s bounded terminal output queue.
OverlayAnchor
A viewport edge or corner used to position an overlay.
Progress
OSC 9;4 taskbar progress state.
Prop
A well-known component property.
PropValue
A parsed component property value.
SuggestionDisplay
Display content for one completion row.
SystemColor
A CSS system color keyword, resolved against the Theme’s semantic palette rather than a fixed RGB value.
TabAction
Provider verdict for a Tab press, from EditorCompletion::tab.
TerminalEvent
One decoded terminal event.
TerminalId
Terminal emulator identity inferred from environment markers.
TerminalPlatform
Host platform distinctions that affect terminal graphics support.
TerminalResponse
A terminal-generated reply separated from user key input.
UiEvent
Outcome of one input event.
Urgency
The importance assigned to a desktop notification.

Traits§

Component
A retained UI component.
EditorCompletion
Pluggable completion engine registered with Editor::set_completion.
ElementFactory
Builds the component behind an unknown element tag.
IntoChildren
Flattens child-builder inputs into cached children.
IntoComponent
Converts a component-like value into a boxed component.
Pipeline
Functional adapters available on every rich sink.
RichSink
Receives styled runs and row breaks. run text contains neither newlines nor escapes; external ANSI text must cross decompose exactly once.

Functions§

decode_keys
Decodes a complete byte slice without retaining partial framing state.
decompose
Decomposes external terminal text into maximal escape-free styled slices.
detect
Detects terminal graphics capabilities from the process environment.
detect_from
Detects terminal graphics capabilities from an injectable environment.
negotiate
Negotiates terminal graphics capabilities while preserving every non-probe byte received during the probe window.
negotiate_async
negotiate on the blocking pool, for tokio hosts.
next_slot
Allocates a fresh Slot — every component constructor (including external Component implementations) takes its identity from here.
notify
Delivers a notification through the protocol selected by caps.
probe_terminal
Performs a blocking startup capability probe.
respond_debug_query
Answers one crate::TerminalEvent::Debug query; retained hosts call this with the JSON payload for the query’s id. Late or duplicate responses are dropped.

Type Aliases§

Slot
Stable component identity. A slot is never an arena index.
SuggestionList
Ranked dropdown rows; inline up to eight before spilling.