zgui
Architecture

The layout engine

How a styled document becomes boxes, how Taffy is driven, and how fragments, hit regions and paint order are produced without rebuilding them.

This page is about zgui-layout: the crate that turns computed styles into boxes, sizes and positions them, and produces the geometry every stage after it reads. It assumes the guide and the architecture overview.

Three levels

An element, a box and a fragment are three different things, and CSS forces the distinction.

LevelLives inNamed byRelationship
Elementzgui-domNodeKeywhat an author wrote
Boxzgui-layout, module boxtreeBoxKeyone element generates none, one, or several
Fragmentzgui-layout, module fragmentFragKeyone box produces one piece per line, column or page

BoxKey is re-exported from zgui-dom rather than declared here (crates/zgui-layout/src/lib.rs), because the document records which boxes each element generated. There is one box identity in the workspace, not two.

The rule that follows is the crate's whole reason for existing: every stage after layout reads fragments and never the layout algorithms' own results. Painting, hit testing and accessible geometry all read the fragment tree. That is what lets the layout algorithm underneath be replaced without touching any of them.

The box tree

One box is a BoxNode (crates/zgui-layout/src/node/box_node.rs). The fields that decide behaviour:

FieldTypeWhat it decides
sourceOption<NodeKey>the element it came from; None for an anonymous box
pseudoOption<PseudoKind>whether it realises ::before or ::after
childrenVec<BoxKey>layout order
paint_childrenVec<BoxKey>document order
fcFormattingContextthe rules it lays its own children out by
parent_fcFormattingContextthe rules the box above lays it out by
styleComputedStylea clone of the cascade result, shared with the element
kindBoxKindelement, anonymous wrapper, text run or marker
block_levelboolhow it takes part in the context around it
textOption<Box<str>>the characters a run lays out
paintedPaintedContentwhether the fragment paints an ordinary box, vector, replaced content, or custom content

Replaced-content metadata and custom-element registry references are rare. They live in separate, sparse columns with 64-box pages. An ordinary box pays only for the painted discriminator. It does not reserve space for a replaced identifier, natural dimensions, or custom revisions.

The two child lists are different lists with different orders, and both are needed. The layout list has order applied, display: contents flattened, and out-of-flow boxes moved onto the box that positions them. The paint list is document order, which is what painting, hit testing and accessible geometry need. Conflating them corrupts one of the two.

FormattingContext has nine values: None, Block, Flex, Grid, Inline, Replaced, Atomic, Table, MultiColumn. Table and MultiColumn are laid out as block containers today (crates/zgui-layout/src/tree/partial.rs), which keeps their children on the page rather than at zero. Only Block, Flex and Grid answer FormattingContext::is_container; the other three reach the leaf path, where a size comes from a measurement instead of from an algorithm.

How many boxes an element generates

Every case is settled once, while the tree is built, never while it is walked — the walk runs inside the layout algorithms' innermost loops (crates/zgui-layout/src/boxtree/mod.rs).

CaseBoxes
display: nonenone
display: contentsnone; the children splice into the parent's list
an ordinary elementone
an element with ::before and ::afterthree, plus one text run per generated string
a list itemone, plus a marker box
a text nodeone text run; an empty one generates none

classify (crates/zgui-layout/src/boxtree/classify.rs) reads display and position once and answers five questions: participation, formatting context, whether the box is out of flow, whether it establishes a containing block, and whether it generates a mark.

Anonymous boxes

An anonymous box is a box CSS requires that no element names. BoxKind::is_anonymous names four: AnonymousInlineRoot, TextRun, Marker and AnonymousBlock. The builder produces the first three; AnonymousBlock — the wrapper around the block-level children of a container that also has inline-level ones — is declared and not yet constructed.

The one a document is full of is AnonymousInlineRoot. A block container whose children are inline-level does not lay them out itself. Each maximal run of inline-level, in-flow children is wrapped in one anonymous box, and that box establishes the inline formatting context the run is broken into lines in (crates/zgui-layout/src/boxtree/anonymous.rs). Without the wrapper the run is handed to an algorithm that puts every child on a line of its own, which is what a paragraph must never do.

pub fn wrap_inline_runs(
    store: &mut LayoutStore,
    parent_style: &ComputedStyle,
    children: &[Placed],
) -> Vec<BoxKey>

Three properties of the wrapper are load-bearing.

  • A container whose children are all block-level is returned unchanged and allocates nothing.
  • An out-of-flow child flushes the run in progress: it keeps its place in paint order and is kept out of the layout list.
  • A box that already establishes an inline formatting context holds its children directly. Wrapping it again would nest a context inside itself.

The wrapper's style is inherited_style(parent) — inherited properties from the parent, everything else at its initial value. It is deliberately not the parent's style: a wrapper that inherited borders, background or padding would paint them a second time. What it inherits it shares rather than copies, and that matters beyond memory. A run's glyphs claim a brush slot against the identity of the cascade result the colour came from, so a wrapper holding an equal copy would take every string in the document out of reach of the colour written for it.

Blockification, order and out-of-flow re-parenting

Three more decisions belong to the container and are made when its children are linked (Builder::link in crates/zgui-layout/src/boxtree/build.rs).

  • Blockification. A flex or grid container's children are block-level whatever their own display said, because there is no line for an inline-level box to sit in. A text run that becomes a flex item is block-level and still text: it keeps FormattingContext::Inline, holding itself, because turning it into a block container would leave its characters with nothing to lay them out.
  • order. No layout algorithm here implements it. It is applied once, by a stable sort of the layout child list (crates/zgui-layout/src/boxtree/order.rs), leaving the paint list in document order. A list already in order is not sorted at all.
  • Out-of-flow re-parenting. The layout algorithms resolve an absolutely positioned child against its immediate parent; CSS resolves it against the nearest positioned ancestor. So an out-of-flow box is kept out of the layout list of the box it was written inside and appended to the layout list of the box that positions it (crates/zgui-layout/src/boxtree/absolute.rs). Its entry in the paint list stays where it was written. Attachment runs once, after the whole tree is built, because a containing block's child list is not final until its subtree is.

Taffy, and what each side owns

Taffy is the layout algorithm library this crate drives. It implements flexbox, grid and block flow over a tree it does not own: the caller implements traits that answer "what is this node's style", "lay this child out", "what does your cache hold". zgui implements those traits for LayoutTree and supplies everything else.

The feature set is pinned in the workspace manifest and asserted by the versions ledger.

taffy = { version = "0.12.2", default-features = false, features = [
    "std", "flexbox", "grid", "block_layout", "float_layout",
    "content_size", "calc", "detailed_layout_info",
] }
FeatureWhy it is set this way
default-features = falsetaffy_tree, taffy's own tree storage, is unwanted: the box tree is this crate's
stdnot optional in practice — taffy 0.12.2's detailed_layout_info names Box through the std prelude
flexbox, grid, block_layoutthe three formatting contexts with algorithms of their own
float_layoutlosing it silently deletes floats
content_sizelosing it silently zeroes every intrinsic size
calccalc() handles are resolved through resolve_calc_value
detailed_layout_infogrid track and line information the crate reads back

The division of labour:

Taffy ownszgui owns
flexbox, grid and block algorithmsthe box tree, its identities and its two child lists
the per-node cache protocolthe exact full-layout and size caches, and persistent intrinsic answers
the layout input and output typesStyleRef, a borrow that answers taffy's style questions
track sizing, item placement, margin collapseinline, replaced and atomic-inline leaves
device-pixel snapping, fragments, hit index, stacking order

No layout-engine style is ever built. The algorithms read styles through traits, and what implements those traits is StyleRef (crates/zgui-layout/src/style/) — a small borrow of one box's computed style. Lowering a computed style into a second struct, per box, per frame, would cost more than the layout it feeds.

The traits zgui implements on LayoutTree<'a, C> are LayoutPartialTree, LayoutFlexboxContainer, LayoutGridContainer, LayoutBlockContainer and CacheTree. compute_block_child_layout is overridden rather than left to its default, which drops the block context: floats and margin collapsing degrade silently across nested blocks without it, with no error and no sign in any result.

The entry points:

pub fn layout_root(&mut self, viewport: taffy::Size<f32>) -> bool          // ungated
pub fn relayout_root(&mut self, viewport: taffy::Size<f32>) -> gate::Relayout
pub fn layout_viewport(&mut self, width: f32, height: f32) -> bool

layout_root first measures content-keyword boxes that do not hold an intrinsic answer. It finds them through a maintained roster. A document with no content keywords does not require a tree walk. It then calls taffy::compute_root_layout.

The pass repeats when scroll_region::auto::revise changes a scrollbar gutter, up to scroll_region::auto::MAX_PASSES, which is 2. This operation also uses a roster. It examines only boxes with undecided overflow: auto; a document with none does not enter the fixpoint. The layout pass does no rounding. Rounding and fragment composition both need the cumulative absolute origin, so one later walk performs both operations.

Container queries are the other fixpoint. The styles inside a container depend on its resolved size, and its size depends on those styles, so there is no ordering that resolves it in one pass. crates/zgui-layout/src/container_query/ iterates to a fixed point with its own MAX_PASSES of 3; a document that has not settled by then is one whose queries contradict each other, and stopping with the third answer is better than not stopping.

Incremental patching

Laying a document out is the largest thing a frame does, and almost every frame asks for one without needing one. The first defence is the gate (crates/zgui-layout/src/tree/gate.rs):

pub enum Relayout { NoRoot, Held, Ran }

pub fn stands(store: &LayoutStore, viewport: Size<f32>) -> bool {
    let Some(root) = store.root() else { return false };
    store.laid_out_for(viewport) && !is_dirty(store, root)
}

Two and only two things stop a held result standing: something underneath changed, or the viewport moved. mark_dirty propagates to the root by construction, so a clean root means nothing is owed. A held pass bumps Counter::LayoutsHeld; a pass that ran bumps Counter::LayoutReachedRoot.

The composing half is not skipped with it, and must not be. A frame that laid nothing out can still owe a repaint, and the damage for it is collected by the fragment pass.

Which elements owe a box tree, not whether any does

An obligation propagates to the root, so the root's own word says only whether something owes a rebuild and never what. Reading the root rebuilds the document for one element's change. So the box-tree stage asks for the list (crates/zgui-layout/src/boxtree/build.rs):

pub const REBUILDS: Dirty = Dirty::REBUILD_BOX.union(Dirty::CHILDREN);

pub struct Owed {
    pub rebuilt: Vec<NodeIndex>,   // elements whose own boxes changed
    pub children: Vec<NodeIndex>,  // elements that gained or lost a child
}

pub fn retire(document: &mut Document, root: NodeIndex) -> Owed

The two lists are separate because they are answered about different elements. An element whose own style decides different boxes says so about itself; how those boxes are wrapped, ordered and blockified is the container's decision. An element whose child list changed says so as the container. Folding them together loses which is which.

The frame then tries three things in order, and only the third rebuilds (crates/zgui-runtime/src/window/frame.rs, build_boxes):

Splice. patch::rebuild(store, document, &owed) -> Option<Rebuilt> makes each named element's boxes again and puts them where the old ones were. A container that gained or lost a child is asked the narrower question first: build the children that moved, keep the ones that did not.

Rewrite text. patch::retext(store, document, root) -> Retext puts a text node's new characters into the box that already lays them out, and returns Retext::Patched(n) or Retext::Rebuild.

Rebuild. boxtree::build(store, document) replaces every box. Reached only when the splice reports a change it cannot confine or the rewrite reports one it cannot express.

Rebuilding is the fallback because a box's name is what fragment reuse, geometry diffing, the per-fragment paint record and damage scissoring are all keyed on. A frame that rebuilds is a frame in which none of the four can hit: every box is new, every fragment compares as changed, and the damage collapses to the root's ink, which is the whole window.

What a splice refuses, and why refusing is the point

A subtree may be spliced only when it is confined: the boxes it holds are exactly the boxes its own elements generate, nothing outside it is laid out from inside it, and the box that goes in takes part in its container the same way the box that came out did (crates/zgui-layout/src/boxtree/patch/subtree/). Each is proved rather than assumed.

takes_the_same_part asks three questions, and each names a way the container's arrangement would change under a box that is not being rebuilt with it:

  • Is it in the same list? Out-of-flow boxes hang under their containing block; in-flow boxes hang under the box their element's parent generated.
  • Is it the same kind of participant? A box that is block-level where the old one was inline-level changes how its siblings are wrapped, and the siblings carry no mark.
  • Is it the same kind of box? A formatting context or box kind that moved is a container laying its children out by different rules, which the boxes above it were sized against.

The new subtree is built and then thrown away rather than predicted. What a box becomes is decided by the builder over the whole subtree; a prediction computed at the splice site would be a second implementation of those rules, agreeing with the first only until one of them changed.

The largest case declined today is a box whose layout parent and paint parent are two different boxes — an out-of-flow box positioned further up than the element it was written inside. Repairing two lists reached two different ways, with only one of them reachable from the box being replaced, is a rebuild instead.

Rewriting text in place

A text run's characters are copied into its box when the box is built, and nothing else in a frame copies them again. Before retext existed, one changed character rebuilt the tree, which renamed every box, which made every fragment compare as changed, which grew the damage to the root's ink. One keystroke repainted the window.

A rewrite owes three things (crates/zgui-layout/src/boxtree/patch/text.rs):

  1. The flattened form of the containing inline formatting context has to go. It is checked against the sequence of boxes it was flattened from, and a box rewritten in place is the same box in the same position — so the check passes and the old characters are shaped and drawn. The walk climbs to the root dropping it, because which box establishes the context holding these characters has no single answer and each step is one pointer write.
  2. The layout of the box and every ancestor has to be thrown away. A string of a different width is a different measurement.
  3. A change it cannot express has to be refused. Text appearing where there was none, text disappearing entirely, or a font change: each changes which boxes exist.

Dirty::RESHAPE is deliberately not retired here. The fragment pass reads it to decide that a line holding different glyphs must be painted again where it stands.

The dirty region, exactly

pub fn mark_dirty(store: &mut LayoutStore, box_: BoxKey) -> u32
pub fn is_dirty(store: &LayoutStore, box_: BoxKey) -> bool
pub fn mark_all_dirty(store: &mut LayoutStore) -> u32

mark_dirty throws away one box's held answers and climbs, stopping at the first already-invalid ancestor. That early stop makes marking n boxes cost O(n + depth) rather than O(n × depth).

A box that has never been laid out is not an already-invalid one, and the walk does not stop at it: whole classes of box are never asked for a size of their own — a run of text is sized by the line box above it — and a change to one of those has to reach the box that was asked.

mark_all_dirty is what a scale-factor change forces. Every length handed to the algorithms is in device pixels, so no subtree escapes. It drops the per-box cache, including intrinsic answers, and also drops baselines and resolved inline lines. It forgets the viewport that produced the results. Leaving any of them behind gives a document that half rescales.

The measurement cache

Each box keeps two exact caches:

CacheCapacityKeyHolds
FullLayout1the dimensions, parent size, and requested axisone LayoutOutput
Measured16the complete size-only LayoutInputa Size<f32>

A full-layout cache has one slot because performing layout writes geometry into descendants. An older full answer could return the correct outer size while leaving the subtree placed for another question. A different full-layout question therefore replaces the held answer.

The size-only cache is wider because grid track sizing asks one item several min-content and max-content questions with different area estimates. Its fixed-size ring holds sixteen complete questions. When it is full, the oldest answer makes way.

Probe carries six things, compared by bits and never by value: the constraint on each axis, the containing block, the axis asked about, the sizing mode, and whether the box's vertical margins were allowed to collapse. A float comparison would make two NaNs unequal, so a degenerate constraint would miss for ever, and would make positive and negative zero equal, which they are not as an available space. The four constraint cases carry their own discriminant rather than being packed into one number, because packing makes a definite space of exactly infinity indistinguishable from the min-content keyword.

The collapsible flag is carried even though every size-only question is currently asked with it unset. A field left out because nothing varies it is a claim about the caller rather than about the question, and the day something varies it the memo answers one question with another's size — with no miss, no assertion and no symptom beyond a box of the wrong height.

Three rules keep the two caches consistent:

  • Only a size-only question uses the wider cache. A full layout stays in its single slot.
  • Both caches are emptied by the same call. BoxLayout::forget_layout clears them together, and nothing may empty one without the other. An answer kept in one while the other was emptied is a measurement from before the invalidation, served in preference to taking it again.
  • A size-only hit is completed. The slot holds only a size. cache_get restores the box's last reported baseline so the hit has the same result as the computation it replaces.

A miss costs a full nested layout of the box's subtree. A size hit costs one comparison per held entry and bumps Counter::SizesHeld; a computed answer bumps Counter::SizesMeasured. The ring is bounded because a window being dragged asks a new question of every box on every frame, and the answers to the old ones stay right for ever without being asked again.

Persistent intrinsic answers

The intrinsic cache holds the min-content and max-content answer for each axis of a box that uses fit-content, min-content, or max-content. An intrinsic probe has no known dimensions, no parent size, and a fixed min-content or max-content constraint. Its result does not depend on the containing block. The box can therefore keep the result across frames and viewport changes.

The intrinsic pre-pass measures only missing answers. It processes the content-keyword roster from the deepest box to the shallowest box, because an outer intrinsic box depends on the result of an inner one. A settled document takes no intrinsic measurements on another layout pass.

BoxLayout::forget_layout clears both exact caches and the intrinsic answers when content, style, device scale, or a gutter decision changes the answer. Immediately after an intrinsic probe, the pre-pass clears only the two exact caches. Those probes ran while the keyword was treated as auto; they must not answer the main layout. The new intrinsic answer remains valid.

AtomicMemo, TextStyles, and CalcArena remain on LayoutTree. They contain state for one pass.

MeasureContent

Three formatting contexts reach the leaf path: a run of text, replaced content the engine does not lay out, and an atomic inline. The third is answered inside the crate, because it is a nested layout of boxes it owns. The other two are answered by whoever drives the pass — which is what keeps the shaping engine and the image decoder out of the layout engine's dependencies, and what makes a layout test runnable with no fonts on disk.

pub trait MeasureContent {
    fn measure(&mut self, request: MeasureRequest<'_>) -> Measured;
    fn shape(&mut self, content: &ParagraphContent<'_>) -> ShapedSummary;
    fn break_lines(&mut self, key: ParagraphKey, request: &BreakRequest<'_>) -> BrokenParagraph;
    fn strut(&mut self, style: &TextStyle) -> StrutMetrics;
    fn paint_slot(&mut self, paint: &TextPaint) -> Brush;
}

Text is two questions and not one. Turning characters into glyphs is expensive; deciding where the lines fall in a given width is cheap. A layout algorithm asks a paragraph how big it is at many candidate widths while it resolves the flex or grid around it, so those probes have to cost the cheap half. shape is asked once per distinct content and break_lines once per width. A measurer that fused them would turn every width probe back into a full pass.

ShapedSummary carries the paragraph key and its ContentWidths. The widths are the point: they are a property of the glyphs alone, so an inline-axis intrinsic probe is answered from the summary and costs no line breaking at all.

The request and the answer:

MeasureRequest fieldMeaning
box_the box being sized
styleits computed style
knowndimensions layout has already fixed, which are authoritative
availablespace on each axis, with the box's own insets already taken off
scaledevice pixels per CSS pixel
final_passwhether the answer will be kept, or is one of several probes

Measured carries size, first_baseline and last_baseline. The two baselines are separate because CSS aligns an inline-block in normal flow on its last line box, and a first-line answer puts a multi-line one on the wrong line.

Two implementations ship in this crate. NoContent reports every box as empty and every paragraph as having no lines; it is the control in a test that has to distinguish "the content decided this" from "the box did". Paragraphs<S, R> (crates/zgui-layout/src/text/paragraphs.rs) holds a shaper, the paragraph cache and the brush table, and delegates replaced content to a second measurer, because knowing how big a picture is has nothing to do with knowing how wide a word is. The runtime wraps its own text engine in a third (crates/zgui-runtime/src/text.rs).

paint_slot claims a brush slot against the identity of the cascade result the colour came from, never against the colour. The slot has to survive a theme change that rewrites what is in it, and two runs that merely computed to the same colour must not be re-coloured together.

Fragments

A fragment is one painted piece of one box, in absolute device pixels. A box produces one per line for inline content, one per column, one per page. Everything downstream reads fragments.

GroupFieldsNotes
Identitykey, box_, node, parentnode is None for an anonymous box
Geometryborder_box, padding_box, content_box, border, paddingin the stacking context's space, before this fragment's own transform
Inkink, local_ink, subtree_inkwhat it paints, in device space, its own space, and unioned over its subtree
Spaceclip, clip_transform, transform, transform_hashinterned identifiers from zgui-scene
Orderstacking, scrollwhich context it is painted in, which region it moves with
Contentkind, flagswhat it draws, and what stages branch on
Foldssubtree_disjoint, subtree_rigidanswered over the subtree, on the unwind

FragmentKind is Box, Line { paragraph, line }, TextRun { paragraph, run }, Replaced { content }, Vector, or Scrollbar { axis, part }.

FragmentFlags is six bits: CLIPS_CHILDREN, IS_STACKING_CONTEXT, HAS_TRANSFORM, IS_STICKY, HAS_READ_EXTENT, HAS_BLENDING_DESCENDANT.

Four of these fields are subtle enough to be worth stating outright.

  • ink under-reported is stale pixels. It is the union of everything the fragment paints, including shadow spread, outline offset and filter bleed, and it is what damage is computed from. It is deliberately not what a fragment reads: a blurred fragment samples pixels outside every rectangle it writes, and that extent is carried in a separate registry so that the many fragments which read nothing do not inflate damage.
  • local_ink is the same union in the fragment's own space, and it is what the hit index is keyed by. An entry filed under a device rectangle stops being true the moment its coordinate system moves, and nothing walks a fragment whose matrix changed under it.
  • subtree_ink lets a paint pass skip a clean subtree in one test instead of descending it to find that nothing in it intersects the damage.
  • transform_hash is a fingerprint of the matrix. A coordinate system's name is structural and does not move when the matrix under it does, so without the fingerprint a movement whose device ink lands where the last one did — a square rotated through a right angle — compares identical and is never redrawn.

FragmentKind::same_piece decides whether two kinds name the same piece, which is a different question from equality. A paragraph is interned by the shaping of its characters, so typing one character issues a new ParagraphId; a line matched on equality would be destroyed and remade on every keystroke, unregistering its hit entry, discarding its paint record and forcing the painting order to be derived again for the whole document. Reusing a name across a change of paragraph owes the fragment a repaint, and keeping the two apart is the point.

The fragment pass

One walk writes the fragment tree, and it does five things at once because all five need the same descent and the same unwind: compose and snap each box's absolute position, compare the result against the fragment that was there, absorb what changed into the damage, fold the subtree answers on the unwind, and keep the hit index in step one entry at a time.

pub const ENTERS: Dirty = Dirty::RELAYOUT
    .union(Dirty::REPOSITION).union(Dirty::REFRAGMENT).union(Dirty::RESTACK)
    .union(Dirty::SCROLL).union(Dirty::REPAINT).union(Dirty::REHIT).union(Dirty::RESHAPE);

pub enum Change { Identical, TranslatedOnly, Changed }

pub fn rebuild(
    store: &mut LayoutStore,
    hit: &mut HitIndex,
    tables: &mut Tables<'_>,
    dirty: &mut impl FrameDirty,
    root: BoxKey,
    damage: &mut DamageSet,
)

Each child gets one of three answers:

AnswerWhenWhat it costs
cachedthe parent settled and the child is cleanreads the child's folded answer
translatethe child is clean and its whole subtree is rigidone offset per piece, plus the damage
visitanything elsea full compose and compare

can_skip is not the marks alone, and the difference is a wrong frame rather than a slow one. A box is marked when its own content changes, not when a sibling's does — and a sibling that grew moves everything the flow places after it. So the layout result is consulted as well: state.unrounded == state.composed, which asks whether the engine's answer for this box is the one its standing fragments were composed from.

can_translate adds one claim: the subtree is rigid. Three styles break rigidity and each is recorded rather than looked for later — a sticky box, whose shift is measured against a scrollport it does not travel with; a position: fixed box, which takes none of the scroll offsets above it; and a transformed box, whose matrix is composed against a border box that moved. A clip is not on the list, because a clipping box's rectangle moves with the box.

The folds are on the unwind, not on an ancestor walk. The walk descends only what changed, so a fold that reads each child's cached answer is correct for a subtree the walk never entered. A walk upwards from each blending fragment would never run at all for a fragment that was not visited. The difference is visible only in the frame where something under an untouched blurred panel animates.

An anonymous box has no marks of its own, and asking the document about no element gets Dirty::all(). Nearly a fifth of a real document's boxes are anonymous, and the subtree answer is consulted before a clean child is left alone — so each of them would make its whole subtree unskippable, which is what makes a fragment pass proportional to the document on a frame that changed one element. Owed::of (crates/zgui-layout/src/fragment/diff/dirty.rs) asks about it under the element it was generated for, and folds that element's subtree answer into the box's own answer, because an anonymous inline root draws the lines its descendants' glyphs sit in.

FrameDirty is the seam through which the pass reads and writes invalidation. Two implementations: Everything, which answers Dirty::all() and is what a first build genuinely wants, and DocumentMarks<'a>, which reads the document's own marks. ENTERS is retired after the walk, not during it, because the walk reads a node's marks twice — once to decide whether its subtree settled, and again to decide what a fragment that did not move nonetheless owes.

Hit regions and the hit index

Hit testing is answering "which fragments are under this point". A hit entry is the record that answers it for one fragment. The index lives beside the fragments and not with the input system that queries it: its bulk build reads the layout store, the pass that writes its entries is this crate's, and hit order is painting order, which is decided here.

pub struct HitEntry {
    pub frag: FragKey,
    pub node: Option<NodeKey>,
    pub order: DrawOrder,
    pub clip: ClipId,
    pub clip_space: Option<SpatialId>,
    pub space: Option<SpatialId>,
    pub pointer_events: PointerEvents,
    pub radii: Corners<Vec2<DevicePx>>,
    pub bounds: Rect<DevicePx, Device>,
    pub envelope: Rect<DevicePx, Device>,
}

Every rectangle here is in the coordinate system space names, and not one of them is in device pixels. That is the whole difference between an entry that has to be rewritten when its box is animated and one that does not: a matrix is a property of the space, so an entry that never mentions the device stays true for as long as the box occupies the same rectangle of its own space, however that space is moving.

envelope is the key rather than bounds, because a shadow or an outline is drawn outside the border box and a hierarchy keyed by anything smaller would dismiss a subtree that does cover the point. clip_space is held apart from space because a clip belongs to whichever ancestor imposed it and was measured before this fragment moved; testing the chain in the fragment's own space would let a translated box answer over the part of its ancestor's scrollport it was translated out of.

The forest

HitIndex holds a SlotVec<FragKey, HitEntry> and a forest: one bounding-volume hierarchy per coordinate system. A bounding-volume hierarchy is a tree whose interior nodes hold the bounding rectangle of everything below them, so a subtree whose rectangle misses the point is dismissed without being descended. One tree per space, because a rectangle is only a rectangle in the space it was measured in.

The trees are dynamic, not bulk-built (crates/zgui-layout/src/fragment/hit/rtree/), with MAX_ENTRIES = 8 and MIN_ENTRIES = 3. Every entry records its leaf, and every node knows its parent, so taking an entry out costs the depth of the tree and allocates nothing.

A move is usually not a move. A leaf's envelope covers up to eight neighbouring rectangles, so:

PlacementConditionCost
InPlacethe new rectangle still lies inside its own leaf's envelopeone write, no envelope touched
Stretchedit left the envelope but still meets itits leaf, and the envelopes above
Reinsertedit went somewhere else entirelya search for a new home

The first two are what a scrolled document does to this structure between two frames.

Carried and settled

There are three ways to write one entry and one call that closes a run of them. The difference between the three is the whole incremental story.

pub fn update(&mut self, frag: FragKey, entry: HitEntry)     // shape may have changed; counts as churn
pub fn translate(&mut self, frag: FragKey, entry: HitEntry)  // position only; never churn
pub fn carry(&mut self, frag: FragKey, entry: HitEntry)      // part of a run; defers the hierarchy
pub fn settle(&mut self)                                     // ends a run, in one pass

translate is not counted as churn because every entry of a scrolled container or a transformed subtree moves by the same vector, keeps its extent, and keeps its relationship to its neighbours. Counting them would make the two commonest whole-subtree movements there are rebuild the whole index every few frames, which is the exact cost the incremental path exists to avoid. update counts only when the hierarchy actually moved the entry between nodes, which most writes do not.

carry writes the entry immediately and defers the hierarchy repair. Answering a run one entry at a time asks each entry to fit inside a leaf drawn around where its neighbours used to be, which stretches that leaf across the gap. settle ends the run in one pass, and until it is called the hierarchy answers for the rectangles the run had before it. The fragment pass calls it on the line that ends the walk, and nothing may query the index in between.

The index is rebuilt wholesale for exactly two reasons, and they are the only two: painting order itself moved — a fragment that did not exist before has no place in the order and no incremental update can invent one — or so much has been updated one entry at a time that the hierarchy is no longer a good one. That second test is churn > len() / CHURN_FRACTION, with CHURN_FRACTION = 4.

A query maps the point into each space once rather than mapping every candidate rectangle out of one, then tests coverage, rounded corners, the clip chain and pointer-events, and sorts by DrawOrder reversed with the fragment name as tie-break. The answer is fragment names, not elements: turning them into the ancestor chain that dispatch walks is a question about elements, which this crate does not answer.

is_consistent() compares the forest's length against the entry count. The two are written together and can only disagree through a bug — an entry moved without its old rectangle being taken out answers hits it should not — and that is silent everywhere except here.

Stacking contexts and paint order

A stacking context is a group that composites as a unit: a box that establishes one is painted atomically, wherever the context sits in its parent's sequence. Painting order is a forest of them, and inside each one the contents are painted in the passes PaintLevel enumerates. The order of the variants is the painting order, so a sort by this value is a sort into CSS painting order and nothing else has to know the sequence.

pub enum PaintLevel {
    NegativeStacking, Block, Float, Inline, Positioned, PositiveStacking,
}

pub fn establishes(store: &LayoutStore, key: BoxKey) -> bool
pub fn level(store: &LayoutStore, key: BoxKey) -> PaintLevel
pub fn z_index(store: &LayoutStore, key: BoxKey) -> i32
pub fn id_of(key: BoxKey) -> StackingContextId
pub fn paint_order(store: &LayoutStore, root: BoxKey) -> Vec<BoxKey>

establishes reads the root box, position with a z-index, fixed, sticky, a z-index on a flex or grid item, opacity below one, a mix-blend-mode, isolation: isolate, any filter or backdrop-filter, any clip-path, and any of the four transform properties. The guide lists the same set from the author's side.

Three mechanisms make this cheap.

  • A context is named by the box that establishes it. id_of(key) is derived from the box's own index rather than issued by a counter, so it is the same identifier every frame for as long as the box lives. That is what lets a walk over part of the document leave the rest of the fragment tree's context identifiers alone.
  • The order walk descends the layout child list. That is the list that reaches every box exactly once: an anonymous wrapper is in it and in no document order, and an out-of-flow box is in the list of the box that positions it, which is where it is painted. At each box the children are sorted by (level, z_index, position); the sort is stable, so the tie-break is the order they are laid out in, which order has already moved — exactly as it moves painting.
  • The fragment tree encodes membership, not sequence. Each fragment carries stacking: Option<StackingContextId> and flags containing IS_STACKING_CONTEXT. The sequence itself is derived by paint_order when it is needed, and a fragment that has no place in it yet is what sets the pass's restacked flag and forces a bulk rebuild of the hit index.

Hit order and paint order cannot be allowed to diverge, so the hit entry carries DrawOrder rather than assigning one. An index that invented its own would answer differently from what is on the screen.

Positioned boxes, clipping and overflow

Positioning. establishes_containing_block(positioned, fc) consults position only. A transform, a filter or will-change also establishes a containing block for a fixed descendant, and each of those is a property of painting rather than of the box tree, so the box tree does not act on them. position: fixed is the one case a scroll must not move: anchored::ignores_scroll answers true for it, and neither the box nor anything inside it takes any part of the accumulated shift. A shift applied to one of those carries it off the screen at exactly the rate the page scrolls, while every measurement taken inside the process still agrees with itself.

Clipping. A clip is a chain, not a rectangle: a rounded card inside a scrollport inside another scrollport is three tests, and every one has to be applied. A box that clips adds one link to the chain it was drawn under, and a descendant carries the whole ancestry (crates/zgui-layout/src/fragment/clip.rs).

  • overflow: visible adds no link at all, which is the overwhelming majority of boxes.
  • The link is the padding box, because a scrollport's content is clipped inside the border.
  • The link takes the box's corner radii, resolved concentrically: an inner radius is the outer one less the border width, so the two curves stay the same distance apart all the way round. A radius smaller than the border collapses to a square corner.
  • A box's own border box is never clipped by itself. A shadow spreading outside a scrollport belongs to the scrollport, not to its contents.

Overflow. Layout owns the region — which boxes scroll, how large their content is, where the scrollport is, what gutter is reserved. It does not own the offset: how far a region has been scrolled changes many times a second and must never re-enter layout, so it is supplied to the fragment pass from outside and composed in there.

pub struct ScrollRegion {
    pub scrollport: Rect<DevicePx, Device>,
    pub content: Size<DevicePx, Device>,
}

pub fn is_scroll_container(style: &ComputedStyle) -> bool
pub fn region_of(store: &LayoutStore, key: BoxKey) -> Option<ScrollRegion>

auto counts as a scroll container. Whether it shows a bar depends on the content, but the clip and the scroll frame it establishes do not appear and disappear with the content. Which axes an auto box decided to scroll is kept between frames on BoxLayout::auto_scroll, so the next layout starts from the previous answer rather than from "reserves nothing" — which is what stops a gutter flickering while its content is edited. Scrollbar fragments are appended after the line fragments, so editing text inside a scrollport does not renumber the slots the bars occupy.

Coordinate spaces

zgui-geom tags geometry with the space it was measured in, using a zero-sized marker that costs nothing at run time and makes mixing spaces a compile error. There are three spaces and three scalars.

SpaceScalarOrigin and gridWho speaks it
CssCssPxviewport top-left, x right, y down; device-independentstyles, results reported to application code
DeviceDevicePxthe output surface's pixel grideverything handed to the renderer
LayoutAuexactly 1/60 of a CSS pixel, as a signed integerlength arithmetic that must not accumulate error

Au exists because layout adds, subtracts and distributes lengths constantly, and in binary floating point the result depends on the order the additions happened in — which shows up as a column one pixel wider than its neighbour for no visible reason. Sixty is divisible by 2, 3, 4, 5, 6, 10, 12, 15, 20 and 30, so halves, thirds and fifths of a pixel are all exactly representable.

What converts between them:

ConversionHow
Css to Devicemultiply by a Scale<Css, Device>, which names both endpoints
Css to Device, on the gridsnap_bounds (nearest edge), cover_bounds (a superset), snap_stroke, snap_edges
CssPx to Au and backCssPx::to_au, Au::to_css_px; exact within about ±69 905 CSS pixels
the style engine's Au to this onezgui_css::engine::geometry::from_au / to_au, exact in both directions

Three snapping rules, because three questions need different answers: snap_bounds rounds each edge to the nearest device pixel and is for geometry that is drawn; cover_bounds floors the near edges and ceils the far ones and is for geometry that bounds something — a clip, a damage rectangle, a scissor — where losing a fraction of a pixel means losing a pixel of content; and snap_stroke rounds a width but never to zero, because a hairline that disappears is more visible than one that is slightly too thick. All three break ties toward zero, so a shape and its mirror image are the same size.

Inside the layout engine itself the arithmetic is in device pixels as f32, because every absolute length in a style has already been multiplied by the scale by the time the tree reads it. The runtime therefore hands relayout_root the surface extent in device pixels, not the CSS extent (crates/zgui-runtime/src/window/frame.rs, lay_out). Au is used where the cascade hands lengths across, notably container-query sizes.

Snapping is not a pass of its own. The rule is round the cumulative absolute edges, and derive each size as the difference between two rounded edges (crates/zgui-layout/src/round/snap.rs). Rounding each box's own size instead lets a column of ten boxes drift by up to five pixels from the sum of its parts, and leaves one-pixel gaps between boxes that share an edge. The arithmetic runs inside the fragment walk, which already has the cumulative origin it needs.

One consequence worth stating: the type tag on a fragment's rectangles is Device throughout, but border_box, padding_box, content_box and local_ink are in local space — absolute layout coordinates with no transform applied, neither the fragment's own nor any ancestor's — while ink is in device space, because damage is measured in real pixels. Fragment::transform names the matrix between the two.

What it costs

Every figure below comes from the repository. The times are from docs/performance.md, which is generated by cargo xtask perf rather than typed in.

InteractionMeasuredBandSource
One class toggled on one element, 1 851 boxes11.34 µs15.12kitchen.click
One keystroke: one edit, one paragraph, one box301.39 µs407.40kitchen.keystroke
One scroll translation frame44.18 µs61.60scroll.translation
…relayouts inside it00, a ceilingscroll.translation.relayouts
…hit-index rebuilds inside it00, a ceilingscroll.translation.hit_rebuilds
One window resize step7.36 ms10.25kitchen.resize

The two count ceilings are the sharper claims. A scroll changes no computed style and no size, so a frame that relays anything out or rebuilds the index has lost a fast path, and that reads the same on a slow machine and a fast one.

Per-box slopes, from docs/perf/glide-split.md:

Part of a glide frameCost
The rigid-move walk73.1 ns per moved box
…of which the box tree being faulted back into cache39.3 ns
HitIndex::settle, once after the walk35.5 ns per moved box
The whole slope108.6 ns per moved box

The settle is the largest single attributable cost in a glide frame, larger than either duty inside the walk.

The wall-clock budget for repeated measurement, from crates/zgui-layout/tests/wall_clock.rs: twenty widths asked of a two-hundred-line paragraph measure 0.79 ms to 0.85 ms by the fastest of fifteen rounds, against a budget of 1.8 ms. Before a paragraph's flattened form was kept beside its box the same loop measured 2.07 ms by the same method, which is why the budget stops where it does — set any higher, it could not fail on the regression it was written for.

The counters this crate moves are BoxesRebuilt, NodesRelaidOut, GuttersExamined, LayoutReachedRoot, LayoutsHeld, SizesHeld, SizesMeasured, FragmentsDiffed, FragmentsRebuilt, NodesVisited, HitEntriesUpdated, HitEntriesMovedInPlace, HitEntriesReinserted and HitIndexRebuilds. They are compiled into the release build, because zgui-runtime depends on zgui-profile with the counters feature.

ZGUI_INVARIANTS=1 turns on the level-agreement checks in crates/zgui-layout/src/invariants/. Every link between an element, its boxes and their fragments is stored twice, and two records of one fact drift. The symptom is a click that lands on nothing or a rectangle painted for something that no longer exists — never an error where the mistake was made. The checks run in every test of this crate, and outside tests they are opt-in.

Next

On this page