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 *;
let mut dom: TuiDom = new;
// ... build the tree ...
let sheet = new
.rule?;
new?.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 *;
let mut dom: TuiDom = new;
let root = dom.root;
// Build the tree.
let hero = dom.create_element;
dom.node_mut.add_class.unwrap;
dom.append_child.unwrap;
// Author some rules.
let sheet = new
.rule
.unwrap;
// Run the cascade.
dom.cascade;
// Read the final values.
let c = dom.node.computed.unwrap;
assert_eq!;
assert_eq!;
assert_eq!;
Stylesheets
Build rules with the fluent API. Selector errors surface at stylesheet-build time — not at render time.
let sheet = new
.rule?
.rule?
.rule?
.rule?
.rule?;
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 = new
.rule?;
Content can be a literal string, a var() reference, a concat of
parts, or explicit suppression:
Str
Var
Concat
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 = new
.define_var
.define_var
.rule?
.rule?;
var() references are tried in this order:
- 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. - Otherwise, recurse into the explicit fallback chain
(
var(--a, var(--b, red))walks left-to-right). - 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 = new // UA defaults include display: inline
.rule?;
// 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 markb,strong,em,i,u,code,span,a,bras 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—normalcollapses whitespace runs and trims IFC edges;prepreserves whitespace and treats\nas a hard break (no soft wrap);pre-wrappreserves whitespace, treats\nas a hard break, AND soft-wraps at spaces (HTML<textarea>default);nowrapcollapses 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-blockis 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; // fires InteractionChanged(Hover)
dom.set_active; // fires InteractionChanged(Active)
dom.set_focused; // 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 = new;
let tracker = install;
let sheet = new
.rule
.unwrap;
// Initial paint — cascade everything once.
dom.cascade;
// Later, mutations fire observers; the tracker collects dirty roots.
let div = dom.create_element;
dom.append_child.unwrap;
dom.set_attribute.unwrap;
// Re-cascade just the changed subtrees.
let roots = tracker.take_roots;
dom.cascade_subtrees;
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:
- UA normal → 2. Author normal → 3. Inline normal →
- 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-corestays style-agnostic. NoColor, noStylesheet, noTuiStylein the core crate.ComputedStyleonTuiExtis the only post-cascade source of truth. Layout and paint should readnode.computed(), neverinline_styledirectly.- Which properties inherit is declared once, in
rdom_style::property_dispatch::inherits; the cascade'sinherit_inheritable_fromis pinned to it by a test that probes every property. - Selector matching goes through
rdom_core::Dom::matches_listfor each candidate rule from the sheet's rightmost-selector index, once per rule per element. There is one matching engine. MutationObserveris the invalidation mechanism.DirtyTrackeris 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.
new?
.tick_rate
.on_tick
.run
What the runtime gives you, roughly in order of the RDOM_RUNTIME
phases:
- Hit testing —
HitTestExt::hit_test(x, y) → Option<NodeId>andhit_test_path(x, y) → Vec<NodeId>. Handles overflow clipping + IFC fragment lookup + paint-order stacking. - Mouse routing —
mousedown,mouseup,mousemove,clicksynthesized on the nearest common ancestor of down + up targets (matches HTML), auto-mouseover/mouseouton transitions,:hovercascade re-runs viaInteractionChangedmutations. - Wheel scrolling — walks up from the hit target for the nearest
overflow: Scroll | Autoancestor that can still move in that direction (one at its rail end chains to the next). Cancelable viaprevent_defaultonwheel. - Focus navigation —
tabindexattribute parsing,Tab/Shift-Tabcycles focusable elements (positive indices first, then DOM order), focus-on-click walks up to nearest focusable.focus/blur(non-bubbling) +focusin/focusout(bubbling).:focuscascade responds. - Pointer capture —
dom.set_pointer_capture(id)/release_pointer_capture(). While captured,mousemove/mouseuproute to the captured element regardless of hit; auto-released onmouseup. - Text selection —
Dom::selection()/set_selection()with a browser-faithfulSelection { 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 resolvesauto's used value from the parent. The selection drag takes no pointer capture:mousemove/mouseupkeep targeting the element under the pointer andclickgoes to the common ancestor, as in browsers.::selectionpseudo-element paints selected cells with a reversed-fg/bg overlay. - Clipboard — Ctrl-C / Ctrl-X / Ctrl-V dispatch
copy/cut/pasteevents (cancelable). Default action writes the serialized selection to the system clipboard viaarboard; tests injectMemoryClipboardviaApp::with_clipboard. - Panic safety — a process-wide panic hook runs
leave_tui_modebefore the default hook prints, so the panic message lands on the main screen instead of the cleared alt-screen.App::runalso wraps the loop incatch_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— exposesVirtualScreen(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
:hoverstyles 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 inspecs/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/, andsrc/runtime/— each submodule has a top-level doc comment with the specific role.