Architecture
This document is the bird's-eye view of how ZenitUI is put together: which pieces exist, what each one owns, and how data flows between them during a frame. It's the map you should read before diving into the individual subsystem documents.
If you only read one internals file, read this one.
1. What kind of framework is this?
ZenitUI is a retained-mode UI framework:
- Every widget is a persistent object (
Layoutand subclasses) that lives across frames. - The tree is built once and mutated incrementally (add/remove children, change styles, change state).
- There's no diffing, no virtual tree, no reconciler. What you build is what runs.
This is in contrast to immediate-mode frameworks (like Dear ImGui), where the whole UI is re-declared every frame and thrown away. Retained mode is the right choice when:
- You have many widgets and want to avoid re-declaring them.
- You want real node identity (each widget has a stable
shared_ptr). - You want CSS-like cascade and state transitions.
- You want to build declaratively (ZMarkup) or imperatively (C++) with the same underlying model.
The trade-off is more bookkeeping: a Layout node carries more state
than an immediate-mode "draw call".
2. Layers
ZenitUI is organized in six conceptual layers. Each one depends only on the ones below it, never above.
┌──────────────────────────────────────────────────────────────┐
│ Authoring │
│ C++ widget API · ZMarkup DSL · ZStyle stylesheets │
├──────────────────────────────────────────────────────────────┤
│ Node model │
│ Layout (tree, rect, state flags, callbacks) │
│ TLayout<Derived, Base> (CRTP factory + fluent helpers) │
├──────────────────────────────────────────────────────────────┤
│ Subsystems │
│ StyleResolver · AnimationPlayer · InputController │
│ ScrollController · FilterRegistry │
├──────────────────────────────────────────────────────────────┤
│ Runtime │
│ UIContext (singleton) · Metrics · Theme │
├──────────────────────────────────────────────────────────────┤
│ Backend interfaces │
│ IRenderer · IPlatform · IAssetProvider │
├──────────────────────────────────────────────────────────────┤
│ Backend implementation │
│ RaylibBackend · any other renderer/platform you write │
└──────────────────────────────────────────────────────────────┘
Authoring
Two ways to build a tree, both producing the same Layout objects:
- C++:
auto b = Btn("OK", cb); - ZMarkup:
ZMarkup::build("Button \"OK\"")
Plus a .zstyle stylesheet language parsed into ThemeRules, loaded
into the global Theme.
Node model
Layout is the single node type. It's not pure virtual — it's concrete
and usable directly. Subclasses (widgets) override specific hooks. The
node owns:
- Its type (
Stack/Vertical/Horizontal). - Its
rect(computed duringarrange). - Its
measuredSize(computed duringmeasure). - Its flag groups:
state(hovered,pressed,focused,checked),config(interactive,enabled,blocksRaycast,portal,passThrough, ...),internal_(framework bookkeeping). - Its children (
std::vector<std::shared_ptr<Layout>>). - Its four subsystems (
style_,anim_,input_,scroll_). - Its callbacks (
onClick,onHoverEnter, ...).
TLayout<Derived, Base> is the CRTP helper that adds:
- A
static create(...)factory that callsonBuild()after construction. - Fluent helpers (
cls,id,size,with,passThrough) that returnshared_ptr<Derived>.
The base Base parameter lets you build on top of an existing widget:
Popup : TLayout<Popup, Panel>, Tooltip : TLayout<Tooltip, Panel>.
Subsystems
Each subsystem is a small struct, friend-accessible from Layout.
They don't own a Layout reference; they're called by Layout's
lifecycle methods with *this as a parameter.
| Subsystem | Owns | Depends on |
|---|---|---|
StyleResolver |
Cascade inputs (inline defaults/base/states), computed style, transitions, part states | Theme, Layout state flags |
AnimationPlayer |
Imperative animations, CSS node animations, ::part animations |
Layout, Theme::keyframes |
InputController |
(Nothing) — stateless dispatcher over Layout flags and UIContext |
UIContext, Layout |
ScrollController |
ScrollState (offset, velocity, drag start), scrollbar geometry |
Layout style, UIContext pointer/wheel |
FilterRegistry |
Named filter definitions (built-ins + custom) | IRenderer, IAssetProvider |
The subsystems are not independently ticked from outside. They're called
by Layout::update, Layout::measure, Layout::arrange, and
Layout::draw. This keeps the frame order under one roof.
Runtime
UIContext is a singleton that holds:
- The three backend pointers.
- The current input snapshot (
pointer,inputEvents,modifiers). - Focus state (
focusedNode). - Pointer capture (
pointerCapture). - Portal registries (
activePortals,framePortals). - Frame clock (
dt,time). - Viewport generation counter.
- A RNG (used by
TextInputfor nothing important, but available).
Metrics::viewport holds the current logical viewport size, updated
each beginFrame. It's used by Value::resolveH/V for vw/vh.
Theme is the global stylesheet: a list of ThemeRules, a root
style, and a map of KeyframeAnimations. It's populated by the
.zstyle parser and read by StyleResolver.
Backend interfaces
Three abstract classes define everything the framework needs from the outside world:
IRenderer— drawing primitives, text measurement, clip, transform, render target, shader push/pop,supports(Feature).IPlatform— window size, pointer state, input events, time, DPI, safe area.IAssetProvider— get font/texture/shader by string name.
Nothing in the framework above these interfaces knows about Raylib, SDL, OpenGL, Metal, or any concrete graphics API.
Backend implementation
RaylibBackend.hpp/cpp provides RaylibRenderer, RaylibPlatform,
and RaylibAssetProvider. This is the only place where the codebase
includes <raylib.h>. To port ZenitUI to another backend, you write
three new classes implementing the same interfaces — no core changes.
3. The frame pipeline
Every frame has two phases, called separately by the user's main loop.
// Phase 1: logic (no GPU calls)
root->runFrame(dt);
// Phase 2: draw (no input reads)
BeginDrawing();
root->renderFrame();
EndDrawing();
3.1 runFrame(dt)
void Layout::runFrame(float dt) {
UIContext::get().beginFrame(dt);
updateTree(dt);
measure(Metrics::viewport.x, Metrics::viewport.y);
arrange({0, 0, Metrics::viewport.x, Metrics::viewport.y});
}
Four sub-phases:
beginFrame(dt)
Polls the platform:
- Updates
Metrics::viewport,safeArea,pointer,inputEvents,modifiers,dpiScale. - Increments
viewportGenerationif the viewport or safe area changed. - Clears per-frame flags:
clickConsumed,rightClickConsumed,wheelConsumedThisFrame,topmostConsumer,hoverTarget,releaseTarget. - Swaps
activePortals ← framePortals(portals from last frame become the hit-test set for this frame).
It does not read the pointer or hit-test. That's updateTree's job.
updateTree(dt)
Runs the input pass and then the per-node update:
- Hit-test the pointer to find
topmostConsumer. - Set
hoverTarget = topmostConsumer. - If pointer pressed, set
pressTarget = hoverTarget; if released,releaseTarget = hoverTarget. - Focus handling:
- On press: focus
topmostConsumerif focusable, else release. - On
Tab: cycle through focusables within the current scope. - Call
update(dt, false, false)on the root — which recursively walks the tree, running the per-node update (see §3.2). - Cleanup: if the pointer was released,
pressTarget = nullptr; if the button is up and capture is set, release capture.
measure(w, h)
Recursive, top-down-then-bottom-up. Each node:
- Resolves its style if needed.
- Computes its available inner size (parent size minus padding).
- Calls
computeIntrinsicSize(availW, availH). - Recurses into children to get their measured sizes.
- Combines children sizes according to
LayoutType(max forStack, sum forVertical/Horizontal). - Adds padding, applies
min-*/max-*, producesmeasuredSize.
The result is cached. measure short-circuits if neither the node's
subtree is dirty nor the parent size changed.
arrange(rect)
Recursive, top-down. Each node:
- Sets its
rectfrom the incoming space. - Splits the space into
paddinginner box. - Arranges absolute children and portals into the inner box.
- Arranges flow children (
Vertical/Horizontal/Stack) with flex (grow/shrink/gap/justify/items-*). - Calls
onLayout()for widgets that need to react to final size.
If the node is a scroll container, arrange delegates to
ScrollController, which may expand the arrange space to fit content
and applies the scroll offset via translateSubtree.
3.2 Per-node update(dt)
Called for every node in the tree, depth-first. It's the heart of the framework. In order:
initStyleIfNeeded— on the first frame, resolve the initial style.scroll_.resetIfOverflowChanged— reset scroll ifoverflowchanged.- Viewport generation check — if the viewport changed, force a
internal_.pendingTransition. anim_.tickImperative(*this, dt)— tick imperative animations. Returns whether they block input.- Compute
blockSubtreeandselfBlocked. input_.updateFlags(*this, selfBlocked, scrolling)— updatestate.hovered,state.pressed,state.focused. Skipped ifscrollingis true (hover freeze).- Compute the new
UIState(Idle/Hover/Pressed/Disabled). - If the state changed,
style_.beginStateTransition(...). Else ifinternal_.pendingTransition,style_.resolvePendingTransition(...). style_.tick(*this, dt)— tick the CSS transition.anim_.tickCss(*this, dt)— tick CSS keyframe animations.style_.propagateInheritance(*this)— notify children if inherited props changed.onAnimationsFinishedif the node just became animation-free.- Recurse into children with
update(dt, blockSubtree, scrolling). scroll_.tickInput(*this)— handle wheel, drag, inertia.input_.handleKeyInput(*this)— Enter/Space activation.input_.fireCallbacks(*this, prev, next, changed)— hover, press, release, click, right-click.cullRemovedChildren()— remove children withinternal_.wantsRemoval.recomputeDirty(...)— updateinternal_.subtreeDirty.onUpdate(dt)ifconfig.enabled || config.updateWhenDisabled.
The order matters. Read it carefully before changing anything.
3.3 renderFrame()
void Layout::renderFrame() {
auto* r = UIContext::get().renderer;
r->beginFrame(); // e.g. push DPI matrix
draw();
r->endFrame();
}
draw(opacity) on the root recursively draws the tree. Each node:
- If
config.portal, register itself inframePortalsand return. - Frustum cull: if the node's
rectis far outside the current clip, skip. - Compute
renderStyle = currentStyle + CSS animation overlay. - Resolve the effect (shader) name to a handle if needed.
- Compute
globalOp = renderStyle.opacity * parentOpacity. - Choose
drawInlineordrawLayerbased on whether the node has filters and whether the renderer supports them. - Draw children in z-order.
- Draw the scrollbar if this is a scroll container.
- If this is the root, drain
framePortalsand draw each one.
drawInline:
push transform (if identity is false)
push effect (if node has effect)
push filter effect (if currentEffect_ is valid)
push clip (if overflow is not visible)
renderChrome (background, box-shadow, border)
renderContent (widget-specific)
drawChildren (z-ordered)
pop clip
pop filter effect
pop effect
pop transform
drawLayer:
- Compute the transformed bounding box.
- Intersect with the current clip →
region. - Ensure two render targets are sized to
region. pushTarget(layerTarget_).- Apply the offset transform so the subtree draws at (0, 0) of the target.
- Draw chrome + content + children.
popTarget().- Run the filter chain on the target.
drawTarget(layerTarget_, region, ...).
3.4 hitTest(p, ancestorBlocked)
Recursive, top-down, z-ordered. Returns the topmost node that:
- Is not blocked by an ancestor.
- Is enabled.
- Contains the point in its
rect. - Is
config.interactiveorconfig.blocksRaycast.
Order:
- If this is the root, test portals first (reverse order).
- Apply the current transform inverse to
p. - Partition children into
negZ,normalFlow,posZ(stacking contexts with negative / no / positive z-index). - Test
posZ(reversed), thennormalFlow(reversed), thennegZ. - If no child hit, test
this(if it qualifies).
This is the same z-order as drawChildren, reversed. Topmost pixel
under the cursor is the topmost interactive node.
4. Data flow between subsystems
The subsystems are not independent. They share state through Layout
and UIContext. The main flows:
4.1 Input → state → style
IPlatform → UIContext.pointer, UIContext.inputEvents
↓
Layout::updateTree → hitTest → topmostConsumer
↓
Layout::update → InputController::updateFlags
↓
Layout.state.hovered / state.pressed / state.focused
↓
InputController::computeNextState → UIState
↓
StyleResolver::beginStateTransition / resolvePendingTransition
↓
StyleResolver resolves matching rules using the flags (via ruleMatches)
↓
ComputedStyle.targetStyle
↓
StyleResolver::tick → currentStyle
↓
Layout::draw → renderChrome / renderContent
4.2 Layout → scroll
measure → measuredSize + contentSize (ScrollController::contentSize)
↓
arrange → if isScrollContainer:
ScrollController::computeArrangeSpace (may expand)
ScrollController::applyOffsetDelta → translateSubtree on children
ScrollController::updateMaxScroll
↓
ScrollController::tickInput (wheel, drag, inertia)
↓
draw → ScrollController::drawScrollbar
4.3 Animation → style → render
AnimationPlayer::tickImperative → writes to getInlineBase() → internal_.pendingTransition
↓
AnimationPlayer::tickCss → updates css[] active animations
↓
Layout::draw → Anim::overlayCssComputed(renderStyle, anim_.css)
↓
renderStyle is the effective style used for drawing
The important detail: renderStyle is computed at draw time and
includes the current CSS animation values. The currentStyle stored in
StyleResolver does not include them. This is why getStyle()
returns the transition value, not the animated value.
4.4 Portal registration
Layout::draw (portal node) → UIContext.framePortals.push_back(weak_from_this())
↓
Layout::draw (root, end) → iterates framePortals → draws each
↓
Next frame: beginFrame → activePortals = move(framePortals)
↓
hitTest → iterates activePortals → the portal becomes clickable
The one-frame delay is intentional (see Portals §8).
4.5 Filter resolution
ComputedStyle.filters (vector<FilterRef>)
↓
Layout::drawLayer → for each FilterRef:
FilterRegistry::find(ref->name) → FilterDef
↓
applyFilter(def, FilterContext{...})
↓
pattern-specific pass: SinglePass / Separable / Silhouette / Custom
↓
IRenderer::pushEffect / setEffect* / drawTarget / popEffect
If the shader name isn't registered with IAssetProvider, the effect
handle is invalid and drawTarget is called without a filter (fallback).
5. Ownership and lifetime
5.1 Nodes
- Children are owned by their parent's
childrenvector (shared_ptr<Layout>). - A child holds a
weak_ptr<Layout>to its parent. removeFromParentsetsinternal_.wantsRemoval = 1. The parent erases the child during the nextcullRemovedChildrenpass.
There are no cycles by default. The weak_ptr to parent prevents the
obvious one.
5.2 Callbacks that capture nodes
Callbacks stored on a node often capture other nodes. This can create cycles:
node->addAnimation("a", anim);
anim->onFinished = [node] { /* ... */ }; // cycle: node → anim → lambda → node
Break it with weak_ptr:
std::weak_ptr<Layout> weak = node;
anim->onFinished = [weak] {
if (auto n = weak.lock()) n->playAnimation("b");
};
The framework does this internally where needed (ContextMenu captures
weak_ptr<Popup>, Tooltip::attach captures shared_ptr<Tooltip> which
is fine because the tooltip is owned by the owner).
5.3 Render targets
Each node that uses drawLayer owns two TargetHandles
(layerTarget_, layerScratch_). They're created lazily and destroyed
in ~Layout. They're not shared between nodes.
5.4 Subsystems
All four subsystems are members of Layout, by value. They don't hold
shared_ptr to the node. They're accessed via friend-private methods
and passed *this as a parameter.
AnimationPlayer::imperative stores shared_ptr<UIAnimation>. The
animation is owned by the player. Users create it and pass it via
addAnimation.
StyleResolver::partTransitions stores Style objects and timers, no
pointers.
ScrollController stores ScrollState (POD).
InputController stores nothing.
6. Why separate files?
The src/ folder splits the Layout implementation into focused files:
| File | Concern |
|---|---|
Layout.cpp |
Orchestration: update, updateTree, runFrame, renderFrame, recomputeDirty, UIContext::requestFocus. |
Measure.cpp |
measure, arrange, arrangeInto, arrangeAbsolute, arrangeVerticalFlow, arrangeHorizontalFlow, arrangeStackFlow, hitTest. |
Render.cpp |
draw, drawInline, drawLayer, drawChildren, resolveEffectIfNeeded. |
StyleResolver.cpp |
Cascade resolution, state transitions, part styles, inheritance propagation. |
AnimationPlayer.cpp |
Imperative + CSS ticking, keyframe evaluation. |
Input.cpp |
updateFlags, computeNextState, fireCallbacks, handleKeyInput. |
ScrollController.cpp |
Scroll arrange, input, thumb geometry. |
FilterRegistry.cpp |
Registry, param binding, built-in filters, applyFilter. |
The split is by phase and subsystem, not by class. All these files
implement methods declared on Layout (or on the free functions in
Anim and the FilterRegistry class).
The Layout class itself is a single header with the full API. It's
large (~700 lines) but the implementation is spread out, so each file
stays readable.
7. What's friend and why
Layout declares four friends:
friend struct StyleResolver;
friend struct AnimationPlayer;
friend struct InputController;
friend struct ScrollController;
This is how the subsystems access private members of Layout (flags,
style_, anim_, scroll_, internal_.pendingTransition, internal_.subtreeDirty) and
how they mutate state that's conceptually private but part of the
node's lifecycle.
The alternative — making every field public — would blur the API. The friend approach keeps the public interface small while letting the subsystems run the show.
AnimationPlayer also accesses style_.targetStyle.animations and
style_.currentStyle directly. ScrollController reads
node.style_.currentStyle. StyleResolver reads node.getParent(),
node.children, the flags.
FilterRegistry is not a friend — it's a static singleton accessed
through get(), and applyFilter takes a FilterContext with all the
information it needs. It only interacts with IRenderer and reads the
FilterRef from the context.
8. Backend interface in one paragraph
The three backend interfaces are the contract with the outside world:
IPlatformgives you time, input, and viewport. It's polled once per frame inbeginFrame. It has no drawing responsibility.IRenderergives you drawing primitives, text measurement, and compositing state (clip, transform, effect, target). It's called duringdraw. Every draw call is inside a singlebeginFrame/endFramepair, matching the host's frame boundary.IAssetProvidergives you named resources: fonts, textures, shaders. It's called during style resolution (resolveFont,resolveBgTexture,resolveEffectIfNeeded) and during filter application (FilterRegistry::findreturns a def whoseshaderis resolved viaassets->getEffect(name)).
The interfaces are small (a few dozen methods total) and never expose
backend-specific types. The only enum passed across is Feature
(currently Effects and NestedTargets), so the framework can degrade
gracefully when a backend doesn't support a feature.
See Backend for the full list of methods and how to implement a new one.
9. Testing strategy
The framework is designed to be testable without a window. The
tests/Mocks.hpp provides:
MockRenderer— records calls, provides a deterministicmeasureText(8px per char + spacing), tracks clip intersection.MockPlatform— scriptable pointer, input events, viewport.MockAssetProvider— returns empty handles (so no assets are loaded in tests).Env— a fixture that installs the mocks inUIContext, clearsTheme, and providesframe(root, dt)andclick(root, pos)helpers.
Tests exercise the real Layout code with these mocks. The mock
renderer supports supports(Feature::Effects) == false, so filters are
skipped in tests unless explicitly enabled.
This is why the framework doesn't have a TestingMode flag or a "headless"
build variant: the backend abstraction is already the seam.
10. Design trade-offs
A few decisions worth knowing about:
Single Layout class, no virtual hierarchy for node types.
Widgets are subclasses, but the tree uses shared_ptr<Layout>. There's
no Widget base, no visitor pattern. Type checks are done by tag name
or by static_pointer_cast when a typed handle is needed. This is
simpler than a full class hierarchy but requires discipline (don't
static_pointer_cast to the wrong type).
Subsystems as friend structs, not virtual classes.
StyleResolver, AnimationPlayer, InputController,
ScrollController are concrete. There's no polymorphic dispatch between
them. Adding a new subsystem means adding a member to Layout and a
new friend declaration. This is faster and simpler than an interface
hierarchy, but it means you can't swap implementations.
Style as a flat struct with Opt<T> fields.
Style has ~40 fields, each wrapped in Opt<T>. The macro
BUBBLE_STYLE_PROPS(X) expands to all of them, and every operation
(overlay, lerp, comparison, iteration) uses the same macro. This is
verbose at the macro level but keeps the code consistent: adding a
property means adding one line to the macro and everything else updates.
Custom properties stored as raw strings, resolved lazily.
--x: #FF0000 is stored as "#FF0000". var(--x) is substituted at
cascade time. This matches CSS and allows custom properties to hold
values that aren't parseable until the cascade is resolved. The cost is
a string map per node per frame — measurable but small.
Transitions per-property, not per-node.
Each property in transition: has its own duration and easing. This
matches CSS but is more complex than a single "duration + easing" per
node. The implementation uses TransitionSpec and lerpStyleTimed,
which reads the spec per property.
filters snap, effects push/pop.
Filter chains don't interpolate; they switch at t > 0. This is a
known limitation. The effect: shader is a push/pop around the node's
draw calls, not a transitioned property. Neither is as flexible as
CSS's filter: interpolation, but both are simple and predictable.
Portals are a flag, not a separate tree.
A portal is still a child of its logical parent for lifetime purposes.
Only drawing and hit-testing are rerouted. This means the portal's
position is computed by the portal itself (via onUpdate), not by the
parent's arrange. Simpler than a separate portal tree, but the
positioning responsibility shifts to the widget.
The frame pipeline is not customizable.
You can't override the order of update → measure → arrange →
draw. Widgets hook in via virtual methods, but the phases are fixed.
This is intentional — it's what makes the framework's behavior
predictable across widgets.
11. Where to go next
- Lifecycle — the exact order of operations per frame, with pseudocode.
- Style System — how the cascade resolves, how specificity works, how inheritance propagates.
- Animation System — imperative vs CSS, the overlay mechanism, the dirty system.
- Layout Engine — intrinsic size, flow algorithms, absolute positioning, dirty tracking.
- Render Pipeline — inline vs layer, the filter pass, the transform stack.
- Input System — state FSM, focus, capture, the click semantics.
- Scroll System —
ScrollState, arrange expansion, inertia, scrollbar geometry. - Backend — implementing
IRenderer/IPlatform/IAssetProvider. - Debugging —
Debug::dump*,Logger, the mock fixture.