name: gum-layout-engine description: Deep internals of Gum's layout engine — UpdateLayout call chain, UpdateChildren ordering, stacking pipeline, dirty state, perf. Triggers: debugging/optimizing UpdateLayout/UpdateChildren, RefreshParentRowColumnDimensionForThis, GetWhatToStackAfter, MakeDirty, ResumeLayoutUpdateIfDirtyRecursive, _cachedSiblingIndex. trigger_phrase: UpdateLayout internals|UpdateChildren|GetWhatToStackAfter|RefreshParentRowColumnDimensionForThis|MakeDirty|ResumeLayoutUpdateIfDirtyRecursive|_cachedSiblingIndex|layout performance|ChildrenUpdateDepth|GetIfShouldCallUpdateOnParent|UseFixedStackChildrenSize|UpdateLayoutCallCount|isFontDirty|SuppressLayoutFromFontChange|font during layout
Gum Layout Engine Internals
For user-facing layout concepts (units, stacking, wrapping, Anchor/Dock), see the gum-layout skill. This skill is for people debugging, optimizing, or extending the engine itself.
All layout logic lives in GumRuntime/GraphicalUiElement.cs.
UpdateLayout Call Chain
Entry point: UpdateLayout(ParentUpdateType, int childrenUpdateDepth, XOrY?)
Flow (in order)
Resolve
updateParent— evaluateParentUpdateTypeflags against actual parent state (stacks? depends on children? has ratio children?).Early out — suspended or invisible — if layout is suspended (
mIsLayoutSuspendedorIsAllLayoutSuspended) OR the element is invisible and not needed for parent update, callMakeDirty()and return. Invisible elements also exit if parent is invisible (unless render target).Propagate to parent (originating call only) — if
updateParentANDGetIfShouldCallUpdateOnParent(), the originating call (the element that changed) hands off toparent.UpdateLayout()withchildrenUpdateDepth + 1and returns; the parent lays this element out as a child. A propagated climb (already size-gated — see Upward Propagation) instead falls through and re-evaluates whether to keep climbing at the end of the method.Clear dirty state —
currentDirtyState = null. This is critical: it prevents double-updates duringResumeLayoutUpdateIfDirtyRecursive(see below).Pre-children dimensions — update dimensions that do NOT depend on children (Absolute, PercentageOfParent, etc.) so children have correct parent sizes when they lay out.
First children pass (if dimensions depend on children) — update children with absolute layout types so the parent can measure them. With
UseFixedStackChildrenSize, only the first child is updated here (O(1) instead of O(n)).Post-children dimensions — update RelativeToChildren / RelativeToMaxParentOrChildren dimensions now that children have been measured.
Wrapped children pass — if
WrapsChildren, updateStackedWrappedchildren and re-measure dimensions with wrapping considered.UpdatePosition — calculate X/Y based on units, origin, parent stacking. For stacked children, this calls
GetWhatToStackAfterto find position relative to the previous sibling.RefreshParentRowColumnDimensionForThis — if parent stacks, update the per-row/column max dimension.
Full children pass —
UpdateChildren(depth, ChildType.All, ...)updates all children. Children already updated in step 6 are skipped viaalreadyUpdatedset.Post-layout dimension check + gated climb — re-update dimensions if a child change could have altered them; then, for a propagated climb, continue up to the parent only if this element's own measured size or position actually changed (see Upward Propagation).
Deferred font realization (step 4.5)
Right after the parent-delegate early-out, before measuring, a node loads any font deferred while
layout was suspended (isFontDirty, set under IsAllLayoutSuspended). This is what makes a bare
UpdateLayout() realize deferred fonts. The font assignment normally calls UpdateLayout again
for RelativeToChildren text; that call is suppressed here (SuppressLayoutFromFontChange) because
this pass already sizes the element. See the gum-property-assignment skill for the full cascade.
UpdateChildren Internals
Two-pass ordering for Ratio dependencies
When some children use Ratio width/height and siblings use complex units
(RelativeToChildren, PercentageOfOtherDimension, MaintainFileAspectRatio,
ScreenPixel, RelativeToMaxParentOrChildren), those complex-unit siblings must be
updated first. Ratio children need sibling sizes to compute remaining space.
Pass 1 (conditional): update children with DoesDimensionNeedUpdateFirstForRatio
units. Pass 2: update all remaining children.
shouldFlagAsUpdated
For Regular layout, children are flagged as updated to avoid redundant work.
For stacked layouts, children are not flagged — they need a second pass to
update positions in order (stacking depends on sibling order).
_cachedSiblingIndex
Set on each child (child._cachedSiblingIndex = i) in the iteration loop
before calling UpdateLayout. Used by GetWhatToStackAfter to avoid an
O(n) IndexOf call. Falls back to IndexOf if the cache is stale (element
not at expected position).
Stacking Position Pipeline
UpdatePosition → TryAdjustOffsetsByParentLayoutType → GetWhatToStackAfter
GetWhatToStackAfter
Finds the previous visible sibling and computes the offset to stack after it.
Find sibling index — uses
_cachedSiblingIndex(O(1)) withIndexOffallback (O(n)). The cache is valid duringUpdateChildrenbut may be stale for individual property-change-triggered layouts.Find previous visible sibling — walks backward from
thisIndexskipping invisible elements.Determine wrap — if wrapping, increments
StackedRowOrColumnIndexand sumsStackedRowOrColumnDimensionsfor all previous rows/columns.Compute offset — for non-wrapping: previous sibling's position + size +
StackSpacing. For wrapping: row/column dimension sum.
RefreshParentRowColumnDimensionForThis
Maintains parent.StackedRowOrColumnDimensions[rowOrColumnIndex] — the max
cross-axis dimension for each row (LeftToRight) or column (TopToBottom).
O(1) fast path: if this child's dimension >= stored max, just set it. This is the common case during sequential layout (e.g., populating a ListBox).
O(n) fallback: if this child's dimension < stored max, it may have been the max-holder and shrunk. Must rescan all siblings in the same row/column to find the true max.
Dirty State and Suspension
MakeDirty
Called when UpdateLayout is invoked on a suspended or invisible element.
Accumulates into currentDirtyState:
ParentUpdateType— OR'd together across multiple callsChildrenUpdateDepth— max of all callsXOrY— set to null if different axes were dirtied (means update both)
ResumeLayoutUpdateIfDirtyRecursive
Called when layout is resumed after suspension. Walks the tree:
- Clear
mIsLayoutSuspended - If
currentDirtyState != null, callUpdateLayoutwith accumulated state - Recurse into children
No double-update: the parent's UpdateLayout (step 2) calls
UpdateChildren, which calls each child's UpdateLayout, which clears that
child's currentDirtyState. When recursion (step 3) reaches that child, its
dirty state is already null — it skips the UpdateLayout call.
EffectiveDirtyStateParentUpdateType
Combines currentDirtyState.ParentUpdateType with runtime checks:
GetIfParentHasRatioChildren() and GetIfParentStacks(). These are checked
at resume time, not when dirtied, so they reflect current state.
Upward Propagation
GetIfShouldCallUpdateOnParent
Returns true if:
- Parent dimensions depend on children (
GetIfDimensionsDependOnChildren) - Parent stacks children (any non-Regular
ChildrenLayout) - Any sibling uses
Ratiowidth/height
When true, the originating call delegates to the parent (flow step 3); propagated climbs above it are additionally size-gated (see below).
Upward propagation is incremental and size-gated
A change climbs one level so the parent can re-measure, then continues to the grandparent and beyond only while each level's own measured size or position keeps changing. When a level re-measures unchanged, propagation stops there — its siblings and ancestors are not relaid out. That is what keeps a change inside one item of an N-item stack from costing O(N-siblings).
The element that actually changed (the originating call) always notifies its
parent unconditionally, so the parent can re-measure; only the propagated
climbs above it are size-gated. The gateClimbOnSizeChange flag distinguishes
the two. Why the source can't gate itself:
An element's own size delta is not the same as its contribution to a content-sized or stacking parent. The clearest case is visibility: size is unchanged when an element is hidden or shown, but its contribution flips (full extent ↔ 0). Only the parent, by re-measuring, can tell whether the change matters. Gating at the source on the source's own size would silently drop visibility-driven (and add/remove) changes, because the parent would never re-measure.
RelativeToMaxParentOrChildren always propagates regardless of its own delta:
its size is the max of its content and its parent, so a content change can be
masked by a (stale) parent size while it still feeds the parent's size.
Visibility changes propagate through TWO paths
A visibility toggle is not one code path:
- Becoming invisible: the
Visiblesetter updates the parent directly (when the parent stacks / auto-grids / depends on children), separately fromUpdateLayout's climb. - Becoming visible: flows through
UpdateLayout's normal (originating) climb.
Both rely on the parent re-measuring — see the contribution-vs-size trap above for why the parent, not the toggled element, is the source of truth.
Performance Patterns
| Optimization | What it avoids | Where |
|---|---|---|
_cachedSiblingIndex |
O(n) IndexOf per child → O(n²) total |
GetWhatToStackAfter |
RefreshParentRowColumn fast path |
O(n) rescan per child → O(n²) total | RefreshParentRowColumnDimensionForThis |
UseFixedStackChildrenSize |
Iterating all children in GetMaxCellHeight |
UpdateLayout step 6, UpdateHeight |
xOrY parameter |
Recalculating unchanged axis | Throughout |
childrenUpdateDepth |
Unbounded recursion | UpdateChildren decrements per level |
alreadyUpdated set |
Re-updating children measured in pre-pass | UpdateChildren |
Diagnostic Counters
UpdateLayoutCallCount— total layout calls (incremented after parent propagation check)ChildrenUpdatingParentLayoutCalls— times a child triggered parent relayout
Verifying propagation (and a test-isolation caution)
UpdateLayoutCallCount deltas are how you assert propagation didn't over-fire:
build an N-item stack, make a change that shouldn't affect siblings, and assert
the delta stays flat as N grows rather than scaling. See the layout-call-count
tests in LayoutUnitTests.
If an engine change instead makes the RaylibGum draw-call-count tests fail,
suspect pre-existing test isolation before suspecting your change. Those tests
assert exact draw-call deltas and are sensitive to renderables leaked onto the
shared layer (an AddToManagers with no matching RemoveFromManagers); combined
with run-to-run test ordering that is flaky on a clean tree too. A layout change
can shift render batching just enough to change how often the latent flake trips —
confirm by running the suite repeatedly on the base branch before assuming you
caused it.