zgui
Architecture

The document

The node arena, why addresses hold still, how the tree is linked and interned, and the seams a document language of your own plugs into.

The document is the retained tree everything below the view layer reads. It is one arena of node records in zgui-dom, built through a trait called Dom, and safe to read from every style worker at once while the cascade runs over it. This page assumes the guide and the architecture overview.

What a document is

  • Documentcrates/zgui-dom/src/arena/document.rs
    • *mut DocumentStore

      Points to one heap allocation that never moves.

      • DocumentStorecrates/zgui-dom/src/arena/store.rs
        • ChunkArena<NodeInner>

          Node records whose addresses remain stable.

        • Vec<NodeKey>

          Maps each slot number to the key for its current occupant.

        • ClassPool

          Splits and interns every class name once.

        • IdentTable

          Resolves id values to borrowed atoms.

        • Vec<Namespace>

          One namespace per one-byte handle.

        • Columns

          Thirteen side tables keyed by NodeKey.

        • SharedRwLock

          Shared by every sheet in this document.

        • HostSeams

          The four document-language hooks.

    • Cell<OptIndex> root

      Identifies the document's root element.

    • EditState

      Holds the write token, poison flag, and batch scratch.

The Document and its stable backing store

Document owns the store through a pointer. Moving a document moves the pointer and not the allocation, which matters because every record holds a back-pointer to the store that owns it. That back-pointer is what lets a node handle be one machine word: Node<'doc> is a &NodeInner and nothing else, and everything a style worker needs — classes, attributes, the shared lock, the host hooks — is reached by following it.

The four node kinds

pub enum NodeKind {
    Document,
    Element,
    Text,
    Marker,
}
KindWhat it isMatches selectorsOn the element chain
DocumentThe document node. Always slot zero. Parent of the root element and of nothing else.nono
ElementThe only kind selectors match against.yesyes
TextA run of text. Its content lives in a column, not in the record.nono
MarkerA position holder with no presence of its own.nono

A marker is how a view says where content will go before the content exists. Show, For, Dynamic and Portal each mount a marker at the place they were written, and re-insert their content beside it as it changes. A marker takes part in sibling order and takes no part in element order, so a conditional region that appears and disappears never shifts what :nth-child or + matches on its neighbours. crates/zgui-dom/src/node/kind.rs says the same thing as a reason to refuse the alternative: a hidden element would not work, because display does not affect selector matching.

What a node carries

The record is NodeInner in crates/zgui-dom/src/node/inner.rs, declared field by field through a macro that gates every field type. It is written in two halves, and the split is measured rather than stylistic: the first sixty-four bytes are exactly what selector matching and the ancestor walk touch, so they are one cache line. A unit test asserts that the parent link sits at offset 64.

GroupFields
Hot set, the first cache lineback-pointer to the store, NodeKey, NodeKind, NamespaceId, NodeFlags, ClassSpan, local name, id handle, the interaction-state word, the invalidation word
Links and ordinalsparent, first and last child, previous and next sibling, previous and next element sibling, first element child, sibling ordinal, child count, two ordinal epochs
Engine bookkeepingwhich element children owe work, the engine's selector flags, its bookkeeping bits, the post-order counter, its per-element style data

const _: () = assert!(size_of::<NodeInner>() == 168); holds the record at 168 bytes in a release build (crates/zgui-dom/src/node/inner.rs). A field added without noticing stops the build, because a field here is paid for on every node of every document.

Everything about a node that is not Copy lives beside it in a column — a side table keyed by the node's own name. crates/zgui-dom/src/arena/columns.rs declares thirteen.

ColumnShapeHolds
attrssparseattributes other than id and class
inline_stylesparsethe parsed style attribute
custom_statessparsethe author-defined states :state(name) matches
textsparsetext content, for text nodes
boxessparsethe boxes this node generated
semanticssparsewhat this node means to an accessibility tree
listenerssparsewhich events this node listens for, under which identities
propssparseimperative properties, which selectors cannot see
observedsparsewhich measurements are watched, and what was last delivered
animsparsea cheap animation's private values
state_masksparsewhich interaction-state bits any active selector could match on
paint_keydenseidentity of everything the painted appearance depends on
a11y_keydenseidentity of everything the accessible description depends on

Sparse means PagedVec: one page of a thousand entries allocated on first write. Dense means SlotVec: one entry per slot. The split is arithmetic, not taste. A dense attribute column costs twenty-four bytes on every node whether or not the node has an attribute, and crates/zgui-dom/tests/memory.rs asserts that one dense column of the widest sparse value type would cost more than twenty times what ten empty sparse ones do.

Two columns deliberately hold less than their names suggest. listeners holds registrations and no handler closures; observed holds a mask and the last delivered values and no delivery channel. Both halves that are missing are reference-counted and therefore cannot live in a structure that worker threads share.

Why addresses hold still

zgui-arena is the crate underneath, and it makes three promises.

An address, once handed out, holds still. ChunkArena<T> stores values in blocks of 512 that are allocated whole and never moved or resized (BLOCK_LEN, crates/zgui-arena/src/chunk/block.rs). A &T it hands out stays valid while unrelated values are inserted, removed and read.

A handle is checked, not trusted. Key<T> packs a slot number, an occupancy counter and the identity of the arena that minted it into eight bytes that are never all zero. A key to a value that is gone resolves to nothing rather than to whatever moved in.

Removal is deferred by a frame. ChunkArena::remove marks a value dead and leaves it in place. ChunkArena::recycle, called once when a frame ends, drops it and offers the slot back.

bits 63–52
Document
bits 51–48
Arena kind
bits 47–32
Generation
bits 31–0
Slot number
DomainId · bits 63–48
The 64-bit NodeKey layout

That layout is why an application never needs a lookup table to relate the two numbering schemes. NodeId, the opaque handle a view holds, puts the document in its top twelve bits and leaves the remaining fifty-two to the backend. A NodeKey puts the document in the top twelve bits too, so crates/zgui-view-dom/src/id.rs converts between them with the identity function.

What that buys the cascade

The cascade is the step that decides one computed style per element by matching every rule against it. The style engine runs it in parallel: it hands one element to each worker, and each worker walks outwards from that element — to the parent for a descendant combinator, back along the sibling chain for +, down for :empty.

Three consequences follow, and each of them needs a different one of the arena's promises.

  • A worker holds a &NodeInner for the length of its element's match, while other threads keep reading and the document keeps being read around it. A growable vector cannot promise that: it reallocates, and one reallocation invalidates every reference into it at once.
  • A node removed part-way through a frame is still readable through its key for the rest of that frame. The accessibility walk over its boxes, a pending observation asking for its bounds and the style engine's own record map all still hold names into it.
  • A slot number freed during a frame is not handed out again until the frame ends, so a slot number cannot come to mean a different node part-way through one. That is what makes NodeIndex — four bytes, no generation — safe to use inside a frame's own walks.

crates/zgui-dom/tests/workers.rs runs the last of these for real: eight threads walk one document sixty-four times each, and one of the tests asserts that the address of a record a worker is holding never changes underneath it.

How the tree is linked

A node has children in two senses and they are different lists.

FieldChainWhat reads it
parentevery ancestor walk, and invalidation marking
first_child, last_childplainthe style traversal, box-tree construction, text collection
prev_sibling, next_siblingplaininsertion and removal, and the paint order
prev_element, next_elementelement only+ and ~
first_element_childelement only:first-child, :empty, the root election
sibling_ordinalelement only:nth-child and its family
child_countplainhow many children of any kind

The plain chain holds everything: elements, text nodes and markers. The element-only chain skips everything that is not an element. Both are maintained at insertion, in crates/zgui-dom/src/node/links/, and nowhere else — they are only correct together.

Deriving the second from the first at match time would turn a constant-time step into a scan past however much text happens to be in the way, once per candidate, on the hottest path in the engine. So each step of + is one pointer hop.

use zgui_dom::{Document, NodeKind};
use zgui_interned::{ClassName, ElementName};

let mut document = Document::new();
let root = document.append(document.document_index(), NodeKind::Element, ElementName::new("root"));
let item = document.append(root, NodeKind::Element, ElementName::new("item"));
document.append(root, NodeKind::Text, ElementName::new("#text"));
let last = document.append(root, NodeKind::Element, ElementName::new("item"));
document.set_classes(item, &[ClassName::new("selected")]);

// The element-only chain skips the text node, which is what keeps sibling combinators O(1).
let store = document.store();
assert_eq!(store.core(item).next_element(), Some(last));

Positions are numbered lazily

:nth-child needs a position among element siblings. Recomputing one per insertion makes building an n-item list quadratic: appending a thousand rows would renumber a thousand times.

Instead a structural change bumps the parent's ordinals_epoch, and DocumentStore::ordinal_of pays for one pass over the child list the first time anything asks afterwards. Read a position through the store and never off the record: the window in which the stored number is stale opens at every insertion and every removal.

The numbering runs on a shared borrow, so two readers can be inside it at once. That is sound because it is idempotent — both compute the same numbers from the same chain, every store is atomic, and the epoch that publishes the numbering is stored last with release ordering.

Interned names

Interning means keeping one shared copy of a string and handing out a handle to it. Two handles holding the same text are the same handle, so comparing them is a pointer test whatever the length of the text.

zgui-interned is the vocabulary every layer above the document speaks.

TypeNames
ElementNamean element's local name
AttrNamean attribute's local name
ClassNameone entry of an element's class list
Identan identifier written in a style sheet
CustomPropertyNamea custom property, stored without its -- prefix
NamespaceIdwhich namespace a name belongs to, as a one-byte index
Atomthe interned string all of the above are newtypes over

Each is eight bytes and Copy. PartialEq for Atom is core::ptr::eq. Hash deliberately hashes the text rather than the address, so a map keyed by names iterates identically on every run of the same program.

Interning is exact and case-sensitive: ElementName::new("DIV") and ElementName::new("div") are different names. A document language whose names are case-insensitive normalises before it interns, so matching stays a pointer comparison. Interned strings are never freed, which is correct for a vocabulary that is small and effectively fixed and wrong for attacker-controlled text.

Where the document keeps each kind of name

NameStored asWhy
local nameweb_atoms::LocalName, by value in the recordthe engine borrows a reference into the record rather than rebuilding an atom per selector test
class listClassSpan, two u32 into the document's ClassPooleight bytes, Copy, and safe to read through a plain cell
idIdent, eight bytes, resolved through IdentTablethe record needs Copy; the engine asks for a borrowed atom
every other attributea SmallVec in the sparse attrs columnvalues are strings, and most nodes have none

class="btn btn-primary large" is one string in the source and three names in the matcher. It is split and interned when it is written, in crates/zgui-dom/src/arena/class_pool.rs, and a node's record holds nothing but the range its names occupy. Selector matching therefore never splits a string, never allocates and never compares characters — it compares handles inside a slice.

The pool is append-only, so rewriting a node's classes appends a new run. DocumentStore keys already-interned runs by their contents, which is what stops a state class being toggled on and off for an hour from growing the pool without bound. crates/zgui-dom/tests/memory.rs pins it: ten thousand nodes sharing two class names hold two entries between them.

zgui-interned depends on no style engine at all. A style engine and a document language each have an interned-string type of their own, and those types carry their language's vocabulary with them. Naming one in a shared signature would spread that vocabulary across every crate that reads a name. The one translation happens at the document's own boundary, in one direction, and it is a lookup rather than a conversion.

Thread-safe reads while the cascade runs

Every field of the node record obeys a discipline, and the discipline is enforced by the compiler at the declaration site rather than by review.

ShapeExampleWhy it survives
Plain datathe back-pointer, the key, the kind, the local namewritten only under an exclusive borrow of the document, so a traversal only ever reads it
Cell<T> where T: Copy + Syncthe links, the flags, the class spanget is a load and set a store; two concurrent loads are not a race, and stores happen only between traversals
An atomicthe state word, the selector flags, the sibling ordinalthe fields the engine itself writes from a worker
RefCellforbidden. borrow is a non-atomic read-modify-write of a counter, so two workers reading the same ancestor race on it even though both accesses are logically reads
pub unsafe trait CellDisciplined {}

node_inner! declares the record and emits one const _ per field that gates its type on that trait, so a borrow counter introduced into the record is a compile error on the line that introduces it (crates/zgui-dom/src/node/discipline.rs). The store makes the matching promise about itself:

pub const fn assert_sync<T: Sync>() {}

const _: () = crate::assert_sync::<DocumentStore>();

That is what makes an Rc parked in a column, or a scratch buffer behind a borrow counter parked on the store, a build failure rather than a data race. It is exported so that a consumer adding a side table of its own can make the same assertion.

What the discipline permits

  • The whole of the style engine's DOM surface — seven traits, all implemented on the one Node handle in crates/zgui-dom/src/stylo/ — is callable from every worker at once.
  • Exactly two of those methods write. insert_selector_flags records that a selector cared about this element, on the element and on its parent, from whichever thread happens to hold the child. The bookkeeping-bit setters are the other. Both go through fetch_or for that reason, and crates/zgui-dom/tests/workers.rs runs eight threads writing one distinct flag each and asserts that every write survives on both the element and the parent.
  • Document is Send and Sync. A document can be moved between threads, and shared access to one yields shared access to a Sync store.

The rule that makes the cells' between-traversals-only stores true rather than hoped for is that no exclusive borrow of the document may be held across a style traversal. The frame is what enforces it: a view builds and a listener dispatches through a shared reference, and the restyle and the end-of-frame recycle take the document exclusively at points where neither is in flight.

The Dom seam

Everything a view does to a tree of nodes goes through one trait, in crates/zgui-view/src/dom/mod.rs, and nothing in the view layer touches a tree any other way.

pub trait Dom {
    fn create_element(&self, name: ElementName) -> NodeId;
    fn create_text(&self, data: &str) -> NodeId;
    fn create_marker(&self) -> NodeId;

    fn insert(&self, parent: NodeId, child: NodeId, before: Option<NodeId>);
    fn detach(&self, node: NodeId);
    fn parent(&self, node: NodeId) -> Option<NodeId>;

    fn set_classes(&self, el: NodeId, classes: &[ClassName]);
    fn toggle_class(&self, el: NodeId, class: ClassName, on: bool);
    fn set_attribute(&self, el: NodeId, name: AttrName, value: Option<&str>);
    // … twenty-three methods in total
}

The operations are small and imperative — create a node, put it somewhere, change one thing about it — because that is the set a retained view layer issues: roughly ten calls per changed node per frame.

GroupMethods
Createcreate_element, create_text, create_marker
Structureinsert, detach, parent, set_text
Selector-visible stateset_attribute, set_classes, toggle_class, set_style_text, set_style_property, set_custom_property, set_ui_state, set_custom_state
Invisible to selectorsset_property, set_semantics
Listenersadd_listener, remove_listener
Anchors a view cannot createroot, overlay_root
Reading backtext_content, observe

What a second implementation has to provide

Three implementations exist in the tree, which is what makes this a boundary rather than an indirection.

ImplementationCrateWhat it is for
DocumentDomzgui-view-doma real zgui-dom document
StubDomzgui-viewan in-memory tree, so the view layer's own tests have a backend, and so a new backend has a small complete one to read
RecordingDomzgui-testkit-viewa StubDom that writes down every change made to it, for asserting on a transcript

A view holds the installed backend as DomHandle, a newtype over Rc<dyn Dom> that dereferences to the backend. A reactive binding captures one, because a binding re-runs long after the build that created it has returned.

ViewHost and EventSink

Dom is the node tree. Two more traits complete the set a view is described against, and neither question is one a node tree can answer.

ViewHost (crates/zgui-view/src/host/mod.rs) is everything a view can ask of, or command in, the engine that laid the tree out. Twenty-three methods: resolved geometry (border_box, window_box, scale), scrolling, focus, selection, animation counts, timers, and installing or removing a style sheet at run time.

pub trait ViewHost {
    fn border_box(&self, node: NodeId) -> Option<Rect<DevicePx, Device>>;
    fn window_box(&self, node: NodeId) -> Option<Rect<DevicePx, Device>>;
    fn scale(&self) -> f32;
    fn focused(&self) -> Signal<Option<NodeId>, LocalStorage>;
    fn install_stylesheet(&self, name: &str, css: &str);
    // …
}

Every geometry answer is as of the last completed frame. Reading layout in the middle of a build cannot be made both correct and cheap. A view that has to react to geometry as it changes registers an observation through Dom::observe instead.

Splitting ViewHost from Dom keeps both implementable alone: a backend can bring its node tree up first and answer geometry with nothing until it has a layout engine.

EventSink (crates/zgui-view/src/event/sink.rs) is where a listener's commands go.

pub trait EventSink {
    fn capture_pointer(&mut self, node: NodeId);
    fn release_pointer(&mut self, node: NodeId);
    fn request_focus(&mut self, node: NodeId);
    fn synthesize(&mut self, node: NodeId, event: EventKind);
}

A listener runs while the document is mid-mutation, so a command that took effect immediately would re-enter a mutation that has not finished. Everything a listener asks for is appended here and carried out once the dispatch it was issued in has completed. The runtime implements it; a test implements it to assert on what a component asked for, and DiscardCommands drops every command for a test that only cares about signal writes.

The four document-language seams

A document core that carried a markup language would carry a URL loader, a security policy and a browsing history with it. zgui-dom carries none of them, and these four traits in crates/zgui-dom/src/host/ are why it can afford not to. Each names one decision the core cannot make for itself, each requires Send + Sync + 'static because it is reachable from a node handle, and each has a do-nothing implementation installed by default.

SeamConsumer inside the treeDefault answerInstalled by
PresentationalHintsthe style engine's legacy-attribute hook, on every restyled elementcontributes nothingDocument::install_presentational_hints
LinkResolverDocument::refresh_link_state, on every attribute writenothing is a linkDocument::install_link_resolver
ReplacedContentDocument::intrinsic_of, for every node flagged replacedno intrinsic sizeDocument::install_replaced_content
SheetLoaderthe style sheet parser's @import armevery request refusedDocument::install_sheet_loader

SheetLoader

#[non_exhaustive]
pub enum SheetRequest {
    Ready(SharedString),
    Pending,
    Rejected,
}

pub trait SheetLoader: Send + Sync + 'static {
    fn load(&self, base: &str, href: &str) -> SheetRequest;
}

base and href are text rather than a parsed URL type, because resolving one against the other is the loader's decision. Ready continues the parse in the same call, which is what @import requires: the imported rules take the position of the @import, and a rule set cannot be spliced into the middle of a sheet afterwards. load runs on the parsing thread and never on a style worker. zgui-style ships two loaders, EmbeddedSheets and FilesystemSheets.

LinkResolver

pub trait LinkResolver: Send + Sync + 'static {
    fn is_link(&self, element: Node<'_>) -> bool;

    /// Only asked of elements `is_link` accepted. The default is "no".
    fn is_visited(&self, element: Node<'_>) -> bool {
        let _ = element;
        false
    }
}

:link, :visited and :any-link are the only selectors in CSS whose answer depends on a concept no document core can define. What counts as a link is a property of the document language; whether one has been visited is a property of a browsing history.

The resolver is not consulted during matching. It is consulted when a node's attributes change, and its answer is folded into that node's interaction-state word — the same word every other state pseudo-class is answered from. That is a correctness requirement: the style engine invalidates :link by comparing state words across a mutation, so an answer that lived only inside the matcher would change without invalidating anything and the old style would stay on the screen. It is also why install_link_resolver re-asks about every element already in the document.

ReplacedContent

A replaced node is one whose size and appearance are decided by something the document does not own: an image, a video frame, an externally rendered surface. A node is replaced because its record carries NodeFlags::IS_REPLACED, and nothing else.

pub struct Intrinsic {
    pub size: Option<Size<CssPx, Css>>,
    pub ratio: Option<f32>,
    pub baseline: Option<f32>,
}

pub trait ReplacedContent: Send + Sync + 'static {
    fn intrinsic(&self, id: ReplacedId) -> Intrinsic;
}

Every field is optional and they are independent. A decoded image has a size and therefore a ratio. A stream can report a ratio before it reports a size. A live surface that has produced no frame can have neither. Reporting a guess is worse than reporting nothing, because layout resolves auto sizing against whichever fields are present.

This seam answers the sizing question only. A replaced node's pixels are an atlas sprite or an external texture, which belong to the scene rather than to the document; the painting half is a separate hook declared beside the scene and keyed by the same ReplacedId. The split is not only tidiness — this half is consulted from layout workers and so must be shareable across threads, while a paint source holds device resources that usually cannot be.

PresentationalHints

A presentational hint is a style declaration that a document language derives from an attribute that is not stylewidth, bgcolor and align in HTML, for instance. Such declarations cascade in an origin of their own, below every author rule and above the user-agent origin.

pub trait PresentationalHints: Send + Sync + 'static {
    fn hints_for(
        &self,
        element: Node<'_>,
        visited: VisitedHandlingMode,
        out: &mut dyn Push<ApplicableDeclarationBlock>,
    );
}

element is always an element, because the only caller is the engine's per-element hook. visited says which half of a link's style is being computed. Push order is cascade order within the origin. zgui's own implementation, NoPresentationalHints, contributes nothing — and that is the correct answer rather than a placeholder, because a document with no markup language on top of it has no legacy attributes.

What is reachable from an application

The standard app installs a ReplacedContent multiplexer. The image loader and the wgpu surface host write to separate intrinsic tables, and the multiplexer gives layout one source. The other three document-language seams keep their default implementations. In particular, @import is rejected unless a lower-level host installs a sheet loader.

The family has more members that cannot live in zgui-dom. The paint half of replaced content is a scene concept. zgui-wgpu supplies it through the runtime's embed host. Custom elements have separate layout and paint sources. The hooks a script engine needs — an animation-frame callback, a microtask checkpoint, dispatch interception — are frame-loop concepts and are installed on the runtime as HostBinding.

Overlay roots

Every window has six nodes before a single view is built, created in crates/zgui-view-dom/src/dom/build.rs through the same batch API as everything else.

  • #document
    • root

      The window's root element.

      • overlay_rootposition: fixed; inset: 0
        • contentdata-layer=content · z-index: 10
        • popoverdata-layer=popover · z-index: 20
        • modaldata-layer=modal · z-index: 30
        • toastdata-layer=toast · z-index: 40
The overlay nodes created for every window

They exist up front rather than on demand because the framework's own style sheet is written against exactly this shape (crates/zgui-style/src/sheets/ua.rs). A layer created when its first portal appeared would order the bands by whichever opened first, so a toast raised before a dialog would paint beneath it.

OverlayLayer is an ordered enum and its declared order is the paint order, asserted in crates/zgui-view/src/dom/overlay.rs. OverlayLayer::Popover is the default.

Portalled content is stored under a layer node, not under where it was written. PortalState holds four things: the marker it left at the written position, the band, the content's own state, and the overlay root the content is under once mounted.

impl Anchor for PortalState {
    fn mount(&mut self, dom: &DomHandle, parent: NodeId, before: Option<NodeId>) {
        dom.insert(parent, self.marker, before);
        let overlay = dom.overlay_root(self.marker, self.layer);
        self.overlay = Some(overlay);
        self.content.mount(dom, overlay, None);
    }
}

first_node answers with the marker and never with the content, so as far as the portal's siblings are concerned the content is not there. The layer nodes carry pointer-events: none and their children carry pointer-events: auto, which is what stops an empty overlay band from swallowing input across the whole window.

Dom::root exists for the same reason overlay_root does. A view can attach a listener only to a node it created, so without it there is no way to hear about a press somewhere else in the document — which is exactly what dismissing an open menu by clicking past it requires.

Mutation batching

Document has two ways to write a node, and they are not alternatives.

The construction path — append, detached, set_classes, set_id, set_attribute, set_state — writes the node directly. It records nothing and marks nothing, because a document that is being built has no computed styles to invalidate and nothing downstream that has seen it. DocumentDom never uses it: even the six nodes a window has before any view exists go through the other path.

The editing path is for a document something has already looked at. A change there is only half of what has to happen; the other half is bookkeeping the style engine cannot do for an embedder, and every part of it silently does nothing when it is left out. So it is not a set of setters. It is one batch API, and both paths maintain the two child chains through the same code, so the chains cannot disagree.

pub fn edit<R>(
    &self,
    filter: &dyn StyleFilter,
    body: impl FnOnce(&mut Edit<'_>) -> R,
) -> Result<R, Poisoned>;

It takes a shared reference, because every method a view calls holds one — including a listener that changes the document from inside a dispatch that is itself running inside a batch. Three mechanisms make that sound, all in crates/zgui-dom/src/mutate/edit/session.rs.

MechanismWhat it does
A single-writer tokenThe first thread to open a batch records itself; another thread's attempt fails rather than proceeding. Released and acquired with the orderings that put one thread's batch entirely before the next thread's.
A depth counter owned by a guardA batch opened inside an open one joins it. End-of-batch work runs once, when the depth returns to zero. The guard is what stops a body that unwinds from stranding the counter.
A poison flagA batch that unwound left records describing neither the old state nor the new. The document refuses every later change and says so, rather than becoming an interface that accepts input and never updates.

What each write records

An Edit method applies one change and everything that change owes.

A snapshot, if one is needed. The engine works out what a change can affect by comparing the element as it is now against a record of how it was. Two records exist, and the cheaper one is the default: a state write stores the previous state word and no attributes at all, which is what keeps hovering a row of a large table from copying that row's class list. A class, identifier or attribute write widens the record to carry the previous values, and widening late is correct because a state write earlier in the same batch has not touched them.

One record per element per batch. The first change is the one worth recording, and whether a record already exists is asked of the element's own bookkeeping word rather than of the map.

A mark on the ancestors. The engine's traversal descends only where something says there is work below, so a hint recorded on an element that nothing leads to is never read.

A restyle hint. How far the engine has to go: re-match this element, re-match this element and its subtree, or skip matching and re-run only the cascade. A theme switch that changes one custom property on the root needs no selector matching anywhere in the document. A hint that is too wide is slow and a hint that is too narrow is wrong, so every entry is the narrowest hint that is provably enough.

A structural entry, for a change to a child list. Inserting or removing a child changes what :nth-child, :empty, + and ~ match on the other children — elements nothing touched, so it is not expressible as a record of the changed node.

What the close of a batch does

The order is fixed, in Document::close_batch.

  1. Expand what the child-list changes imply about the other children.
  2. Hand every element the restyle hint it earned.
  3. Ask for the frame that will show the result.

Step one is deferred to the close for a reason that is measurable. A change invalidates the positions of every element sibling under its parent, so asking "which sibling is earliest affected" per change would renumber the child list per change. One entry per parent per batch pays for one renumber however many changes there were, which is the difference between a thousand-row reorder being linear and being quadratic. An entry names up to four anchors exactly; the fifth degrades to "every child" (crates/zgui-dom/src/mutate/structure.rs).

What a change can skip

StyleFilter answers whether a change can affect any computed style at all, and is passed to each call rather than installed, because the compiled rule set it is answered from cannot be sent between threads.

pub trait StyleFilter {
    /// The interaction-state bits any selector that could match `element` depends on.
    fn states_for(&self, element: Node<'_>) -> ElementState {
        let _ = element;
        ElementState::all()
    }

    /// Whether any selector in the active sheet set mentions this class name.
    fn names_class(&self, class: ClassName) -> bool {
        let _ = class;
        true
    }

    /// Whether any selector mentions this attribute, or matches attributes without naming one.
    fn names_attr(&self, attr: AttrName) -> bool {
        let _ = attr;
        true
    }

    /// Whether the filter's answers are currently unusable: the one frame in which the sheet set
    /// changed, during which the dependency index still describes the previous set.
    fn is_disabled(&self) -> bool {
        true
    }
}

Every default answers "this may matter". EverythingMatters takes all four, which is correct and merely not cheap: a change the filter rejects is applied without recording anything and without entering the style engine at all.

states_for is cached per element in the state_mask column, because a document-wide answer is worthless — any real sheet styles :hover somewhere, so a document-wide mask has the hover bit set and every hover anywhere takes the slow path. The cache depends on the element's own identity, so it is dropped inside the write that changes it: a class change, an identifier change, a move that changes whether the element is the root, and a change to the sheet set, which drops every cached answer at once.

What the style engine sees

A listener writes a signal
Document::edit

A shared reference joins any batch that is already open.

close_batch

Runs once, when the outermost call returns.

Document::take_snapshots

Runs at f.restyle; records are taken rather than borrowed.

SnapshotStore::clear

Clears the bookkeeping bit on every element that carried a record.

How a signal write reaches the style engine

The records are taken rather than borrowed, in crates/zgui-style/src/driver/snapshots.rs, so the restyle owns them for its whole duration. A change made while a restyle runs belongs to the next restyle and starts a fresh set rather than joining the one in flight. They are cleared as soon as the restyle finishes, because a record that outlives the change it describes makes the next change compare against the wrong past.

A frame that changes the document does not cost a second frame. Document::begin_frame turns a redraw request made during a frame into "another frame is owed", and changes_serviced — called at the exact line where the stages that produce changes end and the stages that consume them begin (crates/zgui-runtime/src/window/frame.rs) — clears that. Without it every interaction cost one extra frame that damaged nothing.

Ending the frame

zgui_dom::arena::end_frame drops the subtrees that were taken out during the frame and are still out, recycles their slots, and compacts every sparse column page that nothing is stored on any more. That is narrower than "everything remove was called on": a subtree put back before the frame ends was never really removed, and a list that moves a row does exactly that.

crates/zgui-dom/tests/memory.rs runs two hundred rounds of mounting and unmounting a hundred rows and asserts three things afterwards: the document holds no node from a round that ended, the slot high-water mark did not move after the first round, and no column page is still allocated.

Next

On this page