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
- Document
crates/zgui-dom/src/arena/document.rs- *mut DocumentStore
Points to one heap allocation that never moves.
- DocumentStore
crates/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.
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,
}| Kind | What it is | Matches selectors | On the element chain |
|---|---|---|---|
Document | The document node. Always slot zero. Parent of the root element and of nothing else. | no | no |
Element | The only kind selectors match against. | yes | yes |
Text | A run of text. Its content lives in a column, not in the record. | no | no |
Marker | A position holder with no presence of its own. | no | no |
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.
| Group | Fields |
|---|---|
| Hot set, the first cache line | back-pointer to the store, NodeKey, NodeKind, NamespaceId, NodeFlags, ClassSpan, local name, id handle, the interaction-state word, the invalidation word |
| Links and ordinals | parent, first and last child, previous and next sibling, previous and next element sibling, first element child, sibling ordinal, child count, two ordinal epochs |
| Engine bookkeeping | which 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.
| Column | Shape | Holds |
|---|---|---|
attrs | sparse | attributes other than id and class |
inline_style | sparse | the parsed style attribute |
custom_states | sparse | the author-defined states :state(name) matches |
text | sparse | text content, for text nodes |
boxes | sparse | the boxes this node generated |
semantics | sparse | what this node means to an accessibility tree |
listeners | sparse | which events this node listens for, under which identities |
props | sparse | imperative properties, which selectors cannot see |
observed | sparse | which measurements are watched, and what was last delivered |
anim | sparse | a cheap animation's private values |
state_mask | sparse | which interaction-state bits any active selector could match on |
paint_key | dense | identity of everything the painted appearance depends on |
a11y_key | dense | identity 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.
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
&NodeInnerfor 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.
| Field | Chain | What reads it |
|---|---|---|
parent | — | every ancestor walk, and invalidation marking |
first_child, last_child | plain | the style traversal, box-tree construction, text collection |
prev_sibling, next_sibling | plain | insertion and removal, and the paint order |
prev_element, next_element | element only | + and ~ |
first_element_child | element only | :first-child, :empty, the root election |
sibling_ordinal | element only | :nth-child and its family |
child_count | plain | how 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.
| Type | Names |
|---|---|
ElementName | an element's local name |
AttrName | an attribute's local name |
ClassName | one entry of an element's class list |
Ident | an identifier written in a style sheet |
CustomPropertyName | a custom property, stored without its -- prefix |
NamespaceId | which namespace a name belongs to, as a one-byte index |
Atom | the 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
| Name | Stored as | Why |
|---|---|---|
| local name | web_atoms::LocalName, by value in the record | the engine borrows a reference into the record rather than rebuilding an atom per selector test |
| class list | ClassSpan, two u32 into the document's ClassPool | eight bytes, Copy, and safe to read through a plain cell |
id | Ident, eight bytes, resolved through IdentTable | the record needs Copy; the engine asks for a borrowed atom |
| every other attribute | a SmallVec in the sparse attrs column | values 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.
| Shape | Example | Why it survives |
|---|---|---|
| Plain data | the back-pointer, the key, the kind, the local name | written only under an exclusive borrow of the document, so a traversal only ever reads it |
Cell<T> where T: Copy + Sync | the links, the flags, the class span | get is a load and set a store; two concurrent loads are not a race, and stores happen only between traversals |
| An atomic | the state word, the selector flags, the sibling ordinal | the fields the engine itself writes from a worker |
RefCell | — | forbidden. 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
Nodehandle incrates/zgui-dom/src/stylo/— is callable from every worker at once. - Exactly two of those methods write.
insert_selector_flagsrecords 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 throughfetch_orfor that reason, andcrates/zgui-dom/tests/workers.rsruns eight threads writing one distinct flag each and asserts that every write survives on both the element and the parent. DocumentisSendandSync. A document can be moved between threads, and shared access to one yields shared access to aSyncstore.
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.
| Group | Methods |
|---|---|
| Create | create_element, create_text, create_marker |
| Structure | insert, detach, parent, set_text |
| Selector-visible state | set_attribute, set_classes, toggle_class, set_style_text, set_style_property, set_custom_property, set_ui_state, set_custom_state |
| Invisible to selectors | set_property, set_semantics |
| Listeners | add_listener, remove_listener |
| Anchors a view cannot create | root, overlay_root |
| Reading back | text_content, observe |
What a second implementation has to provide
- Every
NodeIdit returns carries its ownDocumentId. A handle from another document is a programming error it may assert on in debug builds. insertmoves a node that is already in the tree rather than duplicating it.detachremoves a node from its parent and leaves it usable, because a view detaches and reattaches subtrees as its content changes.- A setter with
Noneremoves the thing rather than setting it to an empty value. set_ui_stateaccepts only the control states that mirror an accessibility property. Hover, focus and activation are computed by the input system, and a view that could assert them would be lying to it.set_style_propertytakes the property as a&strrather than an interned name, because the backend has to resolve it against its own engine's property table. An unknown name is dropped with a diagnostic rather than rejected at the seam.observedelivers after layout has settled and before anything is painted, so a view that repositions itself from what it observes is painted in its final place in the same frame. Dropping the returnedObservationHandlederegisters.overlay_rooton a backend with no native popup surface answers with the window's own overlay root for that band, which is what keeps a portal expressible everywhere.
Three implementations exist in the tree, which is what makes this a boundary rather than an indirection.
| Implementation | Crate | What it is for |
|---|---|---|
DocumentDom | zgui-view-dom | a real zgui-dom document |
StubDom | zgui-view | an 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 |
RecordingDom | zgui-testkit-view | a 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.
| Seam | Consumer inside the tree | Default answer | Installed by |
|---|---|---|---|
PresentationalHints | the style engine's legacy-attribute hook, on every restyled element | contributes nothing | Document::install_presentational_hints |
LinkResolver | Document::refresh_link_state, on every attribute write | nothing is a link | Document::install_link_resolver |
ReplacedContent | Document::intrinsic_of, for every node flagged replaced | no intrinsic size | Document::install_replaced_content |
SheetLoader | the style sheet parser's @import arm | every request refused | Document::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 style — width, 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_root
position: fixed; inset: 0- content
data-layer=content · z-index: 10 - popover
data-layer=popover · z-index: 20 - modal
data-layer=modal · z-index: 30 - toast
data-layer=toast · z-index: 40
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.
| Mechanism | What it does |
|---|---|
| A single-writer token | The 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 guard | A 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 flag | A 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.
- Expand what the child-list changes imply about the other children.
- Hand every element the restyle hint it earned.
- 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 shared reference joins any batch that is already open.
Runs once, when the outermost call returns.
Runs at f.restyle; records are taken rather than borrowed.
Clears the bookkeeping bit on every element that carried a record.
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
Invalidation
The bits a node owes, how obligations propagate and retire, and how damage rectangles merge.
The style engine
What the traversal does with the snapshots, the hints and the marks this page produces.
The frame
Where every stage named here sits in the loop, and what wakes it.
Crate map
Every crate, its layer, and the rule that keeps the arrows pointing one way.
The frame
The frame loop step by step, what each step reads and writes, when the loop parks, and the exact set of things that wake it again.
Invalidation and damage
The fifteen obligation bits a node can owe, how a mark propagates and a walk retires it, and how damage rectangles are produced and merged.