rdom-tui 0.5.0

Terminal rendering layer for rdom-core — flexbox layout, TUI styles, key/mouse events. Use rdom-core directly for headless DOM manipulation.
Documentation

rdom-tui

Terminal rendering + runtime for rdom-core. The "how should this render to a terminal, and how do events reach my handlers" half of the DOM — flexbox layout, CSS-faithful cascade (specificity, !important, :hover / :focus, ::before / ::after, var(--…), ::selection), an event loop, hit testing, mouse + keyboard routing, focus navigation, pointer capture, text selection, clipboard.

The pure DOM tree lives in rdom-core. This crate parameterises Dom<Ext> with TuiExt and layers on everything presentational and interactive.

use rdom_tui::prelude::*;

let mut dom: TuiDom = TuiDom::new();
// ... build the tree ...
let sheet = Stylesheet::new()
    .rule(".hero", TuiStyle::new().fg(Color::Red))?;

App::new(dom, sheet)?.run() // blocks, returns on Ctrl-C / quit

See examples/ for three self-contained programs; the in-tree rdom-showcase crate tours every primitive.

Quick start

use rdom_tui::prelude::*;

let mut dom: TuiDom = TuiDom::new();
let root = dom.root();

// Build the tree.
let hero = dom.create_element("div");
dom.node_mut(hero).add_class("hero").unwrap();
dom.append_child(root, hero).unwrap();

// Author some rules.
let sheet = Stylesheet::new()
    .rule(".hero", TuiStyle::new()
        .fg(Color::Red)
        .padding(Padding::all(1))
        .border(Border::single()))
    .unwrap();

// Run the cascade.
dom.cascade(&sheet);

// Read the final values.
let c = dom.node(hero).computed().unwrap();
assert_eq!(c.fg, Color::Red);
assert_eq!(c.padding, Padding::all(1));
assert_eq!(c.border, Border::single());

Stylesheets

Build rules with the fluent API. Selector errors surface at stylesheet-build time — not at render time.

let sheet = Stylesheet::new()
    .rule("#nav",             TuiStyle::new().width(Size::Fixed(24)))?
    .rule(".row:hover",       TuiStyle::new().bg(Color::DarkGray))?
    .rule("input:focus",      TuiStyle::new().border_fg(Color::Blue))?
    .rule("tree-item::before", TuiStyle::new().content(Content::Str("▾ ".into())))?
    .rule(":not(.active) a", TuiStyle::new().dim(true))?;

Selector grammar is the same as rdom_core::selectors: type, universal (*), #id, .class, [attr], [attr=value], [attr~=value], [attr|=value], [attr^=value], [attr$=value], [attr*=value], compounds, combinators ( , >, +, ~), :not(...), and pseudo-classes (:first-child, :last-child, :only-child, :empty, :root, :hover, :focus). Pseudo-elements ::before / ::after are stripped from the selector suffix before rdom-core parsing.

Specificity is spec-faithful: (inline, id, class+attr+pseudo_class, type+pseudo_element) compared lexicographically. Same-specificity ties break on source order. !important inverts origin priority, so a UA !important rule beats an author !important rule.

Stylesheet::new() bakes in UA defaults (:disabled { color: <muted>; user-select: none }, …). Stylesheet::bare() skips them for tests.

Pseudo-elements and content

let sheet = Stylesheet::new()
    .rule("tree-item::before", TuiStyle::new()
        .content(Content::Str("▾ ".into()))
        .fg(Color::Gray))?;

Content can be a literal string, a var() reference, a concat of parts, or explicit suppression:

Content::Str("▾ ".into())
Content::Var("arrow".into())
Content::Concat(vec![
    Content::Str("▾ ".into()),
    Content::Var("label".into()),
])
Content::None  // content: none; — suppresses the pseudo-element

Pseudo-elements inherit from the host element's computed style, not from the host's parent.

Legacy fallback: if no rule supplies content:, the cascade falls back to TuiExt.before_content / after_content (settable via node.set_before_content("→") on TuiNodeMutExt).

Custom properties and var()

let sheet = Stylesheet::new()
    .define_var("accent", "#3d90ce")
    .define_var("muted",  "gray")
    .rule(".primary",   TuiStyle::new().fg_var("accent"))?
    .rule(".secondary", TuiStyle::new().fg(TuiColor::var_with(
        "unknown", TuiColor::Literal(Color::White))))?;

var() references are tried in this order:

  1. Look up the name in the element's custom-property map (its own --* declarations layered over the parent's, CSS Variables 1). If found and parses as a color, use it.
  2. Otherwise, recurse into the explicit fallback chain (var(--a, var(--b, red)) walks left-to-right).
  3. If all fail, fall back to the property's inherit value (typically the parent's computed color).

The string color grammar is hex (#rgb, #rrggbb), ANSI named (red, blue, gray, lightcyan, ...), reset, or decimal 0..=255 for Color::Indexed.

Inline formatting

Block elements with display: inline children flow horizontally, wrap at word boundaries, and style each fragment with its own cascaded values — <p>prefix <code>inline</code> suffix</p> renders on one line with inline yellow while the rest stays default.

let sheet = Stylesheet::new()  // UA defaults include display: inline
    .rule("p",    TuiStyle::new().width(Size::Fixed(40)))?;
// Authors usually don't need to set display — UA defaults cover
// b, strong, em, i, u, code, span, a, br as inline; p, h1-3, pre
// as block.

What's supported:

  • display: inline — participates in the parent block's inline formatting context. UA defaults mark b, strong, em, i, u, code, span, a, br as inline.
  • Word wrap at whitespace, between CJK graphemes, and after hyphens. Long words overflow their line (CSS default — no char-break).
  • Auto-height IFC blocks grow to fit wrapped content; Fixed height clips overflowing lines.
  • white-space: normal / pre / pre-wrap / nowrap — normal collapses whitespace runs and trims IFC edges; pre preserves whitespace and treats \n as a hard break (no soft wrap); pre-wrap preserves whitespace, treats \n as a hard break, AND soft-wraps at spaces (HTML <textarea> default); nowrap collapses but never soft-wraps. Inherits.
  • <br> — hard break.
  • Nested inline styles compose — <b>bold <i>+italic</i></b> contributes both modifiers to the inner span.

What's out of scope:

  • Inline borders / margins (display: inline-block is supported as an atomic inline).
  • text-align, justification, baseline alignment.
  • UAX #14 line breaking (we use whitespace + CJK + hyphen).

Mixed block + inline children work as in CSS 2.1 §9.2.1.1: each run of inline content between block children is wrapped in an anonymous block box with its own inline formatting context. Static ::before / ::after are laid out as the host's first / last inline content, so text wraps around them; a ::before on a host that starts with a block child gets a line of its own (an <li>'s marker rides its block child's first line).

See the parse_and_render example for a working template.

Interaction state: :hover, :active and :focus

dom.set_hovered(Some(button));   // fires InteractionChanged(Hover)
dom.set_active(Some(button));    // fires InteractionChanged(Active)
dom.set_focused(Some(input));    // fires InteractionChanged(Focus)

The setters fire Mutation::InteractionChanged records so a DirtyTracker can invalidate the elements whose match flipped. :hover, :active and :focus-within also match every ancestor of the element holding the state (Selectors 4 §9.2 / §9.4), so li:hover applies while the pointer is over a <span> inside the <li>; the tracker restyles the old and new ancestor chains minus their common part. The App sets :active for the duration of a left-button press.

:focus-visible matches the focused element while dom.focus_visible() is true. The App keeps that bit with the browsers' heuristics — a key press makes focus evident, a mouse click does so only on a text field or editing host — and the UA focus tint keys on it; dom.set_focus_visible(bool) sets it directly (it fires InteractionChanged(FocusVisible)).

Incremental re-cascade

Full cascade walks the whole tree. For apps with many elements and frequent small mutations, install a DirtyTracker and cascade only the affected subtrees:

let mut dom: TuiDom = TuiDom::new();
let tracker = DirtyTracker::install(&mut dom);
let sheet = Stylesheet::new()
    .rule("div", TuiStyle::new().fg(Color::Red))
    .unwrap();

// Initial paint — cascade everything once.
dom.cascade(&sheet);

// Later, mutations fire observers; the tracker collects dirty roots.
let div = dom.create_element("div");
dom.append_child(dom.root(), div).unwrap();
dom.set_attribute(div, "class", "hero").unwrap();

// Re-cascade just the changed subtrees.
let roots = tracker.take_roots();
dom.cascade_subtrees(&sheet, &roots);

DirtyTracker handles: attribute + class changes (node+subtree), tree insertions/removals (with sibling-dependent re-matching for :first-child, +, ~), hover / active / focus changes (the previous and next targets' ancestor chains, minus their common part), and stylesheet swap. Text-content changes dirty only the elements whose :empty / :placeholder-shown they can flip; they (and selection moves) set flags an App turns into a layout / repaint.

The TuiNodeMutExt style setters (set_width, set_padding, …, set_inline_style) reflect into the style attribute like a CSSOM write, so the tracker sees them. Bypass the observer (e.g. writing a TuiExt field directly, such as TuiExt::set_inline_style)? Call tracker.mark_dirty(&mut dom, id) manually.

!important ladder

The cascade applies declarations in six ordered passes:

  1. UA normal → 2. Author normal → 3. Inline normal →
  2. Inline important → 5. Author important → 6. UA important.

Within each pass, candidates are sorted by (specificity, source_idx) ascending; later wins. This means an !important declaration in an author stylesheet beats !important on an inline style (matches browser behavior).

Architecture

  • rdom-core stays style-agnostic. No Color, no Stylesheet, no TuiStyle in the core crate.
  • ComputedStyle on TuiExt is the only post-cascade source of truth. Layout and paint should read node.computed(), never inline_style directly.
  • Which properties inherit is declared once, in rdom_style::property_dispatch::inherits; the cascade's inherit_inheritable_from is pinned to it by a test that probes every property.
  • Selector matching goes through rdom_core::Dom::matches_list for each candidate rule from the sheet's rightmost-selector index, once per rule per element. There is one matching engine.
  • MutationObserver is the invalidation mechanism. DirtyTracker is one observer; future devtools / a11y mirrors / reactive bindings can register their own without touching the cascade code.

Runtime — App, event loop, hit test, routing

App::run is the app-facing entry point. It wraps the DOM in a crossterm-driven loop that runs the "rendering steps" model borrowed from the HTML spec: drain events, tick, run requestAnimationFrame callbacks, cascade + layout + paint when dirty, then sleep on the next event. One paint per task-end, no matter how many mutations happen inside a single handler.

App::new(dom, sheet)?
    .tick_rate(Duration::from_millis(50))
    .on_tick(|ctx| {
        // drain background channels, mutate DOM, request redraw
        ControlFlow::Continue
    })
    .run()

What the runtime gives you, roughly in order of the RDOM_RUNTIME phases:

  • Hit testing — HitTestExt::hit_test(x, y) → Option<NodeId> and hit_test_path(x, y) → Vec<NodeId>. Handles overflow clipping + IFC fragment lookup + paint-order stacking.
  • Mouse routing — mousedown, mouseup, mousemove, click synthesized on the nearest common ancestor of down + up targets (matches HTML), auto-mouseover / mouseout on transitions, :hover cascade re-runs via InteractionChanged mutations.
  • Wheel scrolling — walks up from the hit target for the nearest overflow: Scroll | Auto ancestor that can still move in that direction (one at its rail end chains to the next). Cancelable via prevent_default on wheel.
  • Focus navigation — tabindex attribute parsing, Tab / Shift-Tab cycles focusable elements (positive indices first, then DOM order), focus-on-click walks up to nearest focusable. focus / blur (non-bubbling) + focusin / focusout (bubbling). :focus cascade responds.
  • Pointer capture — dom.set_pointer_capture(id) / release_pointer_capture(). While captured, mousemove / mouseup route to the captured element regardless of hit; auto-released on mouseup.
  • Text selection — Dom::selection() / set_selection() with a browser-faithful Selection { anchor, focus } model. Mouse drag, Shift+arrow (grapheme), Shift+Ctrl+arrow (word), Ctrl-A (select-all within focused element), double-click (word), triple-click (line). user-select: { Auto, Text, None, All, Contain } gates selectability; it is not inherited (CSS UI 4 §6.1) — the runtime resolves auto's used value from the parent. The selection drag takes no pointer capture: mousemove / mouseup keep targeting the element under the pointer and click goes to the common ancestor, as in browsers. ::selection pseudo-element paints selected cells with a reversed-fg/bg overlay.
  • Clipboard — Ctrl-C / Ctrl-X / Ctrl-V dispatch copy / cut / paste events (cancelable). Default action writes the serialized selection to the system clipboard via arboard; tests inject MemoryClipboard via App::with_clipboard.
  • Panic safety — a process-wide panic hook runs leave_tui_mode before the default hook prints, so the panic message lands on the main screen instead of the cleared alt-screen. App::run also wraps the loop in catch_unwind.

Listeners that want the key payload or mouse payload for the currently-dispatching event read it as typed detail off the core Event: ctx.event.detail.as_keyboard() returns Option<&KeyboardDetail> (DOM-faithful key: String, four-bool modifiers, repeat); ctx.event.detail.as_mouse() returns Option<&MouseDetail> (button + buttons bitmask + client_x/y + wheel deltas + modifiers). Translation from crossterm's KeyEvent / MouseEvent lives in tui_event::key_translate and runs inside the TuiEvent::keydown / keyup / keypress / click / mouse / wheel builders.

Features

  • test-util — exposes VirtualScreen (rdom_tui::VirtualScreen, also in the prelude), a headless VT emulator that replays the ANSI bytes a backend emits into an inspectable cell grid, for asserting what a real terminal would show after a sequence of frames, resizes and clears. It is for test suites: enable it on the dev-dependency (rdom-tui = { version = "…", features = ["test-util"] }), not on the normal one. It is not part of the default API, and docs.rs builds with it so the type is documented.
  • no-synchronized-output — skips the BSU / ESU synchronized-output sequences (DEC private mode 2026) for terminals that mishandle them. Off by default; terminals without support ignore the sequences.

Terminal notes

  • iTerm2 and hover. iTerm2 may ignore the any-motion mouse mode until it sees a real click, at launch and after every refocus, so :hover styles start following the pointer only after one click. Other terminals (Alacritty, Kitty, Ghostty, WezTerm) honor it immediately. The cause and the failed re-arm attempts are recorded in specs/DIVERGENCES.md §4.

Examples

cargo run -p rdom-tui --example counter_button    # click listener + text-node mutation
cargo run -p rdom-tui --example tab_form          # native form controls, focus navigation, submit
cargo run -p rdom-tui --example parse_and_render  # rdom-parser + rdom-css + rdom-tui composing

Each file is the whole program and uses App::run. No manual event loops, no direct enter_tui_mode in user code. Ten more demos (scrollable list, text selection, ARIA tree, sticky, border collapse, UA chrome, …) live in the workspace's rdom-showcase crate: cargo run -p rdom-showcase for the tour, or cargo run -p rdom-showcase --example <name> for one.

Benchmarks

cargo bench -p rdom-tui --bench runtime

Measures: hit-test on a 10k-node tree, dispatch depth-50 (full capture + bubble), full-frame cascade + layout + paint at 80×24 and 200×60, range serialization on a 10k-cell selection, scroll-list steady-state mutation (1k / 10k rows), Unicode paragraph wrap (10k graphemes, CJK + emoji). Results comparable to ratatui's criterion widget benches.

Further reading

  • DESIGN.md — architectural overview: crate map, non-negotiable invariants, roadmap.
  • DIVERGENCES.md — every deliberate departure from the web platform.
  • Module docs under src/style/, src/render/, and src/runtime/ — each submodule has a top-level doc comment with the specific role.