Document Object Model

Azul's DOM differs from a browser DOM in four places:

  1. Hierarchy lives separately from node data. The relationships (parent, prev_sibling, next_sibling, last_child) are in one array. The content (tag, class, inline CSS, callbacks) is in a parallel array. They're indexed by the same node id.
  2. Both arrays are flat Vecs in DOM tree order. Parent before children. So a slice data[self..self.last_child + 1] is the subtree rooted at self. No pointer-chasing to walk a subtree.
  3. The DOM is frozen after layout() returns. There is no insertChild, no setAttribute, no mutation observers. To change the tree, the next layout() call returns a new Dom. The framework diffs old against new and migrates state across.
  4. CSS is stored in a compact, layout-hot cache. Common enum properties (display, position, float, overflow) are bit-packed into a single u64 per node. The numbers and the cold paint properties live in two more arrays. The point is to make the per-node working set small enough that the layout pass stays in L2 instead of round-tripping to RAM. The compact-cache implementation itself is documented separately, in internals/styling/compact-cache.md.

The result is a tree like this:

         hierarchy[0..5]                  data[0..5]
       ┌───────────────┐               ┌──────────────────────┐
   0   │ parent: -     │ <body>        │ NodeData { ... }     │
   1   │ parent: 0     │   <div>       │ NodeData { ... }     │
   2   │ parent: 1     │     <span>    │ NodeData { ... }     │
   3   │ parent: 0     │   <p>         │ NodeData { ... }     │
   4   │ parent: 3     │     "text"    │ NodeData { ... }     │
       └───────────────┘               └──────────────────────┘

Indices into both arrays match. The layout engine traverses by index, not by pointer, and reads from compact arrays whose memory layout it controls.

Cache hierarchy

Layout itself isn't algorithmically hard. It's a tree walk plus a lot of if/else. The expensive part isn't the math; it's pulling each node's properties out of memory.

A modern CPU has a tiered memory hierarchy. Cycle counts are approximate but the order of magnitude is right:

  • L1 data cache — 32 to 128 KB per core. ~4 cycles to read.
  • L2 — 256 KB to several MB per core. ~12 cycles.
  • L3 — 4 to 32 MB shared. ~30 to 60 cycles. Doesn't exist on embedded targets.
  • Main RAM — gigabytes. ~100 to 300 cycles. A full miss costs more than running 100 instructions.

Layout reads the same per-node fields once per relayout pass. If the working set fits in L2, the second pass is essentially free. If it spills to RAM, every node fetch stalls the pipeline.

The relevant per-node sizes in the layout hot path:

NodeHierarchyItem            32 B    parent + 3 sibling/child indices
StyledNodeState              10 B    :hover / :focus / :active per node
NodeFlags                     4 B    contenteditable, tab index, anonymous
compact-cache tier 1          8 B    display/position/float/etc bit-packed
compact-cache tier 2 (hot)   68 B    width, height, margin, padding, ...
compact-cache tier 2 (cold)  28 B    paint-only properties (color, opacity)
compact-cache tier 2b (text) 24 B    text-related layout

per-node total (hot)        ~150 B
per-node total (warm)       ~170 B   add cold + text tiers
NodeData (cold during layout) 152 B  read once for inline CSS, classes

For 1,000 nodes the layout-hot working set is ~150 KB. That fits in L2 on every desktop chip and most embedded ones. For 10,000 nodes it's ~1.5 MB, still L2 on a modern Apple/Intel core. For 100,000 nodes it's ~15 MB, which is L3 on desktop and main memory on embedded.

The numbers indicate when virtual views, lazy panels, and other ways to keep the rendered subtree small start to matter. See Virtual Views.

What's in a node

Each node is split across the two parallel arrays.

NodeHierarchyItem carries four indices into the same array — the node's parent, its previous and next siblings, and its last descendant:

pub struct NodeHierarchyItem {
    pub parent: usize,            // 0 means "no parent"
    pub previous_sibling: usize,
    pub next_sibling: usize,
    pub last_child: usize,        // index of last descendant
}

Because children sit contiguously after their parent in tree order, data[self_idx ..= last_child] is the whole subtree rooted at self_idx. No pointer-chasing, no recursion needed to copy a subtree.

NodeData carries everything that defines a single node:

pub struct NodeData {
    pub node_type: NodeType,                          // HTML tag or leaf (Text/Image/Icon/VirtualView)
    pub callbacks: CoreCallbackDataVec,               // event handlers; empty for ~80% of nodes
    pub style: Css,                                   // inline CSS with implicit :scope, INLINE priority
    pub flags: NodeFlags,                             // tab index, contenteditable, anonymous
    pub accessibility: Option<Box<AccessibilityInfo>>, // ARIA, only on accessible nodes
    pub extra: Option<Box<NodeDataExt>>,              // attributes, dataset, menus, virtual view, ...
}

style: Css is the same struct the cascade uses everywhere else; inline rules carry their conditions (:hover, :focus, @theme dark, @os macos) directly. The two Option<Box<...>> fields keep the common case small — a typical paragraph or div pays nothing for the accessibility or extras boxes. About 95% of nodes never trigger the extra allocation.

A function of state

It helps to remember what the browser DOM was originally for. In the 1990s, web pages arrived over slow modems as a stream of HTML, and the browser had to render while the document was still being received. document.write injected new nodes mid-parse; appendChild, removeChild, and the rest of the mutation API let scripts patch the tree as more bytes arrived. Mutability wasn't a design goal — it was a constraint of streaming over a 14.4k modem.

When SPAs took over, the streaming use case mostly went away, but the mutation API stayed. React's contribution was to talk users out of using it: model the UI as a function of state, render the whole tree on every change, and let a reconciler diff old against new. Vue, Solid, Svelte, and Elm all converged on the same shape. The browser's imperative DOM became an implementation detail the framework hid.

Azul has no streaming parser to support and no legacy mutation API to preserve, so it makes „UI is a function of state“ the rule from the start. The Dom returned from layout() becomes the framework's copy:

  1. A callback returns Update::RefreshDom.
  2. The framework re-invokes the layout function.
  3. A fresh Dom is built from the application data.
  4. The framework diffs the new tree against the previous one and migrates focus, scroll, dataset, and merge-callback state across matched nodes.

There is no handle to the live tree, no insertChild / setAttribute / mutation observer surface. Removing the mutation API has two payoffs: half the bugs that show up in any non-trivial UI come from „this listener saw stale state because something else mutated the tree first,“ and a tree the framework owns is far easier to lay out incrementally than a tree the application can change at any time.

The reconciliation algorithm — what counts as „matching“ old and new nodes, what migrates, what fires lifecycle events — is documented in Reconciliation.

State that has to survive a tree rebuild (a video decoder, a GL texture, the cursor inside a focused input) doesn't live in the tree shape. It hangs off the node as a dataset. See Datasets and Merge Callbacks.

Building DOMs

The recursive Dom value

Dom is the form actually constructed in user code:

pub struct Dom {
    pub root: NodeData,
    pub children: DomVec,
    pub css: CssVec,
    pub estimated_total_children: usize,
}

A Dom is a subtree: a root NodeData, its children, and any component-level stylesheets attached via .with_component_css(Css). The framework flattens the recursive form into the parallel NodeHierarchyItem / NodeData arrays once, at the start of the cascade. Every builder method on Dom (with_class, with_callback, with_css) is a shorthand that delegates to the same method on self.root.

Node constructors

Each HTML element has a Dom::create_<tag>() constructor. Most are const fn and don't allocate until a child is added:

use azul::prelude::*;

let _ = Dom::create_div();
let _ = Dom::create_section();
let _ = Dom::create_article();
let _ = Dom::create_main();
let _ = Dom::create_nav();
let _ = Dom::create_header();
let _ = Dom::create_footer();

Text-bearing constructors take a string and wrap a Text child inside the element:

use azul::prelude::*;

let _ = Dom::create_h1_with_text("Title");
let _ = Dom::create_h2_with_text("Section");
let _ = Dom::create_p_with_text("A paragraph.");
let _ = Dom::create_span_with_text("inline");
let _ = Dom::create_strong_with_text("important");
let _ = Dom::create_code_with_text("println!()");
let _ = Dom::create_text("standalone text node");

NodeType (in core/src/dom.rs) lists every variant. The set covers all HTML elements plus the SVG subset plus four leaf types: Text, Image, Icon, and VirtualView.

For elements with non-trivial accessibility surface, the primary constructor takes an a11y struct as a required argument. There's a matching _no_a11y variant that opts out explicitly. The longer name on the opt-out is the point: it signals that a11y was skipped on purpose, and it makes the absence visible during code review — the soft-force pattern.

use azul::prelude::*;

// Primary form: a11y is part of the call signature.
let save = Dom::create_button("Save", SmallAriaInfo::label("Save document"));

// Explicit opt-out, longer name.
let ok = Dom::create_button_no_a11y("OK".into());

Most interactive elements use the generic SmallAriaInfo (label, role, description). A few (<progress>, <meter>, <dialog>) have type-specific structs because their a11y surface needs more than that. Static, non-interactive elements (div, span, p, the headings, inline text formatters) don't take a11y info — their role is implicit from the element type.

See Accessibility for the full list of elements that follow the soft-force pattern, the type-specific aria structs, and how the framework translates them into the platform-specific accessibility trees (UIA, AT-SPI, NSAccessibility).

IDs, classes, attributes

use azul::prelude::*;

let _ = Dom::create_div()
    .with_id("sidebar".into())
    .with_class("panel".into())
    .with_class("scrollable".into())
    .with_attribute(AttributeType::AriaLabel("notification banner".into()))
    .with_attribute(AttributeType::Lang("en".into()));

IDs and classes aren't separate fields. They're stored as AttributeType::Id and AttributeType::Class entries in the node's attribute list. The selector .panel { ... } matches every node whose attribute list contains Class("panel").

AttributeType (in core/src/dom.rs) is a strongly-typed enum: Href, Src, Alt, AriaLabel, Required, MaxLength(i32), ContentEditable(bool), and so on. There's a Custom fallback for arbitrary name="value" pairs. Attributes aren't inline CSS — they feed accessibility, attribute selectors like [lang="en"], and HTML/XML serialization.

Adding children

Three ways to attach children:

use azul::prelude::*;

// 1. One at a time. Each call grows .children by one.
let a = Dom::create_div()
    .with_child(Dom::create_h2_with_text("Title"))
    .with_child(Dom::create_p_with_text("Body"));

// 2. Replace the child vec wholesale.
let kids: DomVec = vec![Dom::create_span_with_text("x"), Dom::create_span_with_text("y"), Dom::create_span_with_text("z")].into();
let b = Dom::create_div().with_children(kids);

// 3. Collect from an iterator into a parent.
let c: Dom = (0..3).map(|i| Dom::create_li_with_text(format!("Item {}", i))).collect();
// Produces a NodeType::Div containing three <li> children.

with_child calls add_child, which pushes onto the underlying Vec and updates estimated_total_children. That's amortised O(1) per call. with_children(DomVec) is one allocation total.

estimated_total_children is maintained by every add_child and set_children call. The framework reads it to pre-size the flat arena during conversion. If children is mutated directly, call fixup_children_estimated() before returning.

Defining a clipping path

Two public mechanisms cover the common cases:

  • with_clip_mask(ImageMask) takes a raster alpha mask. Use it for irregular shapes that already exist as image data.
  • with_css("clip-path: ...;") parses the CSS property into the node's inline-CSS list. Applied during the cascade.
use azul::prelude::*;

fn build(mask: ImageMask) -> Dom {
  let raster = Dom::create_image(ImageRef::null_image(0, 0, RawImageFormat::R8, U8VecRef::from(&[][..])))
    .with_clip_mask(mask);

  let css_form = Dom::create_div()
    .with_css("clip-path: circle(40px at 50% 50%);");

  Dom::create_body().with_child(raster).with_child(css_form)
}

A clip-path set on a parent applies to every descendant.

Inline CSS

The primary way to attach CSS is .with_css(...) on the node itself. The method takes a string, parses it through the same pipeline the cascade uses elsewhere, and stores the result in NodeData::style: Css.

use azul::prelude::*;

let item = Dom::create_div().with_css("
    color: blue;
    font-size: 14px;
    :hover { color: red; }
    @theme dark { color: white; background: #222; }
");

The parsed rules carry their conditions directly: :hover, :focus, :active, @os, and @theme blocks all live inside the same Css value. Conditions are re-evaluated per frame, so @theme dark { ... } flips when the user toggles dark mode without any re-layout. Inline rules are tagged rule_priority::INLINE so they win the cascade against author CSS.

After the recent unification, the inline store is a regular Css — the legacy css_props: CssPropertyWithConditionsVec field is gone. with_css_props(vec) still works as a compatibility shim that maps each property to a single-declaration rule at INLINE priority.

Component-level stylesheets

Reusable components ship a parsed stylesheet that travels with the subtree. Attach it on the component's root with .with_component_css(Css):

use azul::prelude::*;

let widgets = Css::from_string(".panel { padding: 8px; }".into());
let panel: Dom = Dom::create_div()
    .with_class("panel".into())
    .with_component_css(widgets);

A browser cascades every stylesheet against every node in one global pass — .panel { ... } in one tab can match a .panel in any iframe that imports the same stylesheet, and changes there force a global restyle. Azul's component CSS travels with the subtree. The framework merges every component-level Css together when it flattens the tree, but the rules a component ships only have a chance to match the nodes the component itself owns. Anything outside the component's subtree is invisible to its selectors. This is a soft scope (the framework doesn't enforce a Shadow-DOM boundary), but it follows from the way components label their roots and avoids the cross-component restyle storms a global cascade produces. For hard scoping, write selectors that nest under the component's root class.

User-level theming sits at the outermost layer: the system @theme dark block, the system:* color keywords, and the optional end-user ricing file all target the framework-wide hooks. Component CSS doesn't fight user theming because the two layers target different selectors. See Components for the component-pack model and Theming for the full theming model and the AZ_RICING opt-out.

The cascade runs once, after the LayoutCallback returns. NodeData::style: Css and Dom::css: CssVec are opaque state during layout(). The framework collects the rules at the end of the callback, sorts by (priority, specificity), and walks the tree once to fill the compact cache. Selector matching, inheritance, and the compact-cache build all happen there. CSS work inside layout() is cheap because each call is just a parse and a push. For the internal cache layout that the layout engine reads, see internals/styling/compact-cache.md.

Inside the layout callback

A layout() callback receives application data and a LayoutCallbackInfo describing the window. Returning a Dom finishes the pass; the framework reconciles, lays out, and renders.

use azul::prelude::*;

struct AppModel {
    user_name: String,
    locale: Locale,
}

extern "C" fn layout(data: &mut RefAny, info: LayoutCallbackInfo) -> StyledDom {
    let model = match data.downcast_ref::<AppModel>() {
        Some(m) => m,
        None => return StyledDom::default(),
    };

    let strings = Strings::for_locale(model.locale);

    // Window-aware layout: switch to a single-column layout below 768px.
    let body = if info.window_width_less_than(768.0) {
        Dom::create_body()
            .with_css("display:flex; flex-direction:column;")
            .with_child(navbar_compact(&model.user_name, &strings))
            .with_child(content_area(&strings))
    } else {
        Dom::create_body()
            .with_css("display:grid; grid-template-columns:240px 1fr;")
            .with_child(sidebar(&model.user_name, &strings))
            .with_child(content_area(&strings))
    };

    body.with_component_css(app_stylesheet()).style_dom()
}

The LayoutCallbackInfo exposes everything needed to make the returned Dom adapt to the running window. Responsive sizing (window_width_less_than, window_width_between, window_height_*, and the raw get_window_width / get_window_height) covers the per-tree branch cases (a hamburger menu vs a sidebar) — the per-property cases (@media, @theme) are handled by inline CSS. The framework re-invokes layout() whenever the window crosses a breakpoint, the system theme flips, or a route switch fires, so the width branch is always re-evaluated against the live window. The callback can read info.relayout_reason() to find out why it was called — Resize, ThemeChange, RouteChange, RefreshDom, or Initial — and skip work that doesn't need to repeat (analytics fetches, locale-pack loading) when the trigger was just a resize.

Other helpers: get_dpi_factor returns 1.0 / 2.0 / etc. for asset selection; get_active_route() / get_route_param(key) for router-driven trees; get_image(name) for registered images; get_system_style() for the current SystemStyle snapshot; get_gl_context() for canvas-backed nodes; get_system_fonts() for font availability checks (CJK / RTL fallbacks).

A worked example covering window-size, DPI, theme, route, and Fluent localization in a single layout pass:

use azul::prelude::*;
use azul::desktop::fluent::{FluentLocalizerHandle, FluentFormatArg};

struct AppModel {
    user_name: String,
    locale: String,                    // BCP-47, e.g. "fr-FR"
    localizer: FluentLocalizerHandle,
    unread_count: u32,
}

extern "C" fn layout(data: &mut RefAny, info: LayoutCallbackInfo) -> StyledDom {
    let model = match data.downcast_ref::<AppModel>() {
        Some(m) => m,
        None => return StyledDom::default(),
    };

    // i18n: a localized greeting + a pluralized inbox count.
    let greeting = model.localizer.translate(
        model.locale.as_str().into(),
        "greeting".into(),
        Some(&[FluentFormatArg::str("name", &model.user_name)].into()),
    );
    let inbox = model.localizer.translate(
        model.locale.as_str().into(),
        "inbox-count".into(),
        Some(&[FluentFormatArg::num("count", model.unread_count as i64)].into()),
    );

    // DPI-aware logo: prefer the @2x variant on Retina/HiDPI screens.
    let logo = if info.get_dpi_factor() >= 1.5 { "logo@2x" } else { "logo" };
    let logo_img = info.get_image(&logo.into())
        .map(Dom::create_image)
        .unwrap_or_else(Dom::create_div);

    // Theme-aware accent color picked outside CSS (for a value the
    // cascade can't reach — e.g. a canvas paint color).
    let accent = match info.theme {
        WindowTheme::DarkMode => "#79b8ff",
        WindowTheme::LightMode => "#0046bf",
    };

    // Route-driven content: /settings vs /inbox vs default.
    let main = match info.get_route_pattern().as_str() {
        "/settings" => settings_page(&model),
        "/inbox" => inbox_page(&model, &inbox),
        _ => home_page(&model, &greeting),
    };

    // Window-size-driven layout: hamburger nav under 768px, sidebar above.
    let shell = if info.window_width_less_than(768.0) {
        Dom::create_body()
            .with_css("display:flex; flex-direction:column;")
            .with_child(top_bar(logo_img, accent))
            .with_child(main)
    } else {
        Dom::create_body()
            .with_css("display:grid; grid-template-columns:240px 1fr;")
            .with_child(sidebar(logo_img, &greeting, accent))
            .with_child(main)
    };

    shell.with_component_css(app_stylesheet()).style_dom()
}

The output is a StyledDom (dom.style_dom() runs the cascade and returns the framework-owned form). Returning it hands ownership to the framework, which reconciles against the previous frame and schedules layout + paint.

Routing

A multi-page app registers a layout callback per URL pattern on the AppConfig — the framework picks the right one for the active route and re-runs it on switch_route:

use azul::prelude::*;

extern "C" fn layout_home(_: &mut RefAny, _: LayoutCallbackInfo) -> StyledDom { todo!() }
extern "C" fn layout_user(_: &mut RefAny, info: LayoutCallbackInfo) -> StyledDom {
    let id = info.get_route_param("id").map(|s| s.as_str()).unwrap_or("");
    Dom::create_h1_with_text(format!("User #{}", id)).style_dom()
}

fn main() {
    let mut config = AppConfig::create();
    config.add_route("/", layout_home);
    config.add_route("/user/:id", layout_user);

    let app = App::create(initial_data, config);
    app.run(WindowCreateOptions::new(layout_home));
}

A :name segment captures the path component as a parameter readable via info.get_route_param("name"). On desktop the route is in-memory state; on a web build the same routes also map to HTTP endpoints with history.pushState() integration.

A user callback navigates with CallbackInfo::switch_routeinfo.set_route_param(key, value) modifies a single param in place without changing the active pattern:

extern "C" fn open_user(data: RefAny, mut info: CallbackInfo) -> Update {
    let id = match data.downcast_ref::<u64>() {
        Some(i) => *i,
        None => return Update::DoNothing,
    };
    let params = vec![StringPair {
        key: "id".into(),
        value: id.to_string().into(),
    }].into();
    info.switch_route("/user/:id".into(), params);
    Update::RefreshDom
}

The framework swaps the active layout callback on the next frame and reconciles the new tree against the previous one. See Routing for the full pattern syntax, multi-route layouts, and the web-vs-desktop differences.

Parsing from XHTML

Dom::create_from_parsed_xml is the public entry point. Given an Xml value, it returns a Dom ready to return from layout():

use azul::prelude::*;

let xml_text = "";
let parsed = Xml::from_str(xml_text.into()).unwrap();
let dom: Dom = Dom::create_from_parsed_xml(parsed);

The XML parser walks <html><head><style>...</style></head><body>...</body>, parses each <style> block into a Css, and attaches it scoped to the node it was found inside. The cascade runs on the next layout pass like any other DOM.

ComponentMap is the registry of XML-defined components. See Components for how the framework looks up <card title="..."/> against a registered library.

Parsing from SVG

The same XML pipeline accepts <svg> tags inside the body and turns them into vector nodes that render alongside the rest of the Dom. No extra wiring is required: the parser recognises the SVG namespace, walks the geometry, and stamps an SvgNodeData on each shape. A clip-mask attribute on an SVG element resolves the same way as a CSS clip-path: (see Defining a clipping path above).

use azul::prelude::*;

let xhtml = r#"
<html>
  <body>
    <svg viewBox="0 0 100 100" width="200" height="200">
      <circle cx="50" cy="50" r="40" fill="#1d4f8b"/>
      <rect x="10" y="10" width="40" height="40" fill="#5dade2"/>
    </svg>
  </body>
</html>
"#;
let parsed = Xml::from_str(xhtml.into()).unwrap();
let dom: Dom = Dom::create_from_parsed_xml(parsed);

The same dom is renderable without a window. Run the binary with AZ_BACKEND=headless and the framework rasterises into an in-memory framebuffer instead of opening a window. Combine with AZ_DEBUG=<port> to drive take_screenshot from a shell script and get a base64 PNG back. That's the path snapshot tests, PDF export, and CI machines without a display server use.

For the full SVG geometry model (paths, tessellation, GPU tessellated nodes), the standalone Svg::from_string parser that returns a RawImage, and the GPU stroke pipeline, see SVG. For the headless backend in detail, see Headless Rendering.

Callbacks

use azul::prelude::*;

struct Counter { value: i64 }
extern "C" fn on_click(mut data: RefAny, _info: CallbackInfo) -> Update {
    let mut c = match data.downcast_mut::<Counter>() { Some(c) => c, None => return Update::DoNothing };
    c.value += 1;
    Update::RefreshDom
}

fn build(state: RefAny) -> Dom {
  Dom::create_button_no_a11y("+1".into())
    .with_callback(EventFilter::Hover(HoverEventFilter::MouseUp), state, on_click)
}

with_callback(filter, data, callback) attaches a RefAny and a function pointer. The handler fires when the matching event reaches the node. The callback returns an Update that tells the framework whether to re-run layout, re-render, or do nothing.

What the callback can actually do — read the dataset, query the hit-test, dispatch to siblings, focus another node, schedule a timer, post a thread message — lives in Callbacks. Event filtering and propagation order are in Events and Input.

The framework's reconciler matches new nodes against old ones when a fresh tree is returned. Cursor position, focus, and dataset state migrate across the diff for matched nodes. See Reconciliation.

Datasets

with_dataset(OptionRefAny) attaches arbitrary user data to a node. Callbacks read it via CallbackInfo::get_dataset. The dataset is the canonical place for UI-layer state. The cursor inside an input. The expansion flag on a tree-view row. A marker struct that says „I am the save button“ so a generic callback can dispatch. See Datasets for the navigation patterns.

For state that must survive a subtree rebuild, pair the dataset with with_merge_callback. The framework calls it with the old and new RefAny values during reconciliation. Heavy resources can move from the old node to the new one. See Merge Callbacks for a worked FFmpeg example.

Virtual Views

A VirtualView is a node whose contents come from a separate callback that runs only when the framework needs them. Use it for infinite lists, lazy panels, and embedded sub-DOMs that own their own scroll math. The callback receives a VirtualViewCallbackReason (InitialRender, DomRecreated, BoundsExpanded, EdgeScrolled(_), ScrollBeyondContent). Use the reason to skip work when the call is just a parent re-render. See Virtual Views for the rendered-vs-virtual coordinate model and a virtualised-table walkthrough.

Debugging

Run the binary with AZ_DEBUG=<port> set and App::create starts an HTTP debug server on that port. It accepts JSON commands and returns JSON responses. The inspector sees the same tree the renderer is about to draw, so a query reflects what's on screen.

AZ_DEBUG=8765 cargo run --bin my_app

# Synchronous queries:
curl -X POST http://localhost:8765/ -d '{"type":"get_state"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_dom_tree"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_node_hierarchy"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_node_css_properties", "selector":".panel"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_layout_tree"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_display_list"}'

# Synthesised events (dispatched into the same callback path real input uses):
curl -X POST http://localhost:8765/ -d '{"type":"click", "selector":".button-primary"}'
curl -X POST http://localhost:8765/ -d '{"type":"text_input", "text":"hello"}'

The synthesised events go through the exact same dispatch path as real input. A scripted click runs the same hit test, the same event filters, and the same callback as a user mouse click. That makes the debug server the basis for end-to-end tests: drive the app from a shell script or a Python harness, assert on the JSON responses, and the result is an integration test that exercises the real layout, the real callbacks, and the real reconciliation pass. The test pattern, the assertion vocabulary, and the CI recipe are covered in End-to-End Testing.

For tests that don't need a window (snapshot tests, PDF export, CI machines without a display server), the same debug API is also reachable in Headless Rendering, which runs the full layout and rendering pipeline into a Vec<u8> framebuffer.

The get_node_hierarchy response carries a component field for each node, allowing navigation back to the component that produced it. The inspector uses it to draw a Component Tree alongside the DOM Tree:

{
  "index": 17, "node_type": "div", "tag": "card",
  "id": null, "classes": ["card", "card--info"],
  "parent": 12, "children": [18, 19, 20],
  "component": {
    "component_id": "shadcn:card",
    "data_model": { "title": "First", "body": "alpha" }
  },
  "rect": { "x": 16.0, "y": 16.0, "width": 320.0, "height": 88.0 },
  "events": ["MouseUp"], "tab_index": 0
}

Picking a node in the DOM Tree surfaces the component that produced it, and (if its render function lives in a registered library) the inspector links back to the source. See Components for how a library wires its components into the registry.

Coming Up Next

  • Callbacks — What CallbackInfo exposes, dataset reads, focus/scroll dispatch
  • Routing — URL patterns, route params, and per-route layout callbacks
  • Reconciliation — Diffing, restyle scope, and damage-rect repaint
  • Datasets — Attaching state to a node for navigation and per-instance state
  • Components — Reusable UI fragments — named functions of (args) -> Dom
  • Styling with CSS — Stylesheets, selectors, and the cascade
  • Theming@theme dark, system:* colors, and end-user ricing

Back to guide index