zgui
Architecture

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.

zgui-runtime is the crate that runs the pipeline. This page is one frame in the order it is actually implemented, the conditions under which the loop stops, and what starts it again.

It assumes the architecture overview. Nothing here is needed to write an application.

The loop from outside

A platform backend owns the event loop and drives an object that implements zgui_platform::AppHandler. zgui_runtime::Runtime is the only implementation the framework ships.

pub trait AppHandler: 'static {
    fn surfaces_available(&mut self, cx: &dyn PlatformCx);
    fn surfaces_lost(&mut self, cx: &dyn PlatformCx) { }
    fn surface_event(&mut self, cx: &dyn PlatformCx, surface: SurfaceId, event: SurfaceEvent);
    fn wake(&mut self, cx: &dyn PlatformCx, reason: WakeReason);
    fn idle(&mut self, cx: &dyn PlatformCx) -> IdlePolicy { IdlePolicy::Block }
    fn deadline_reached(&mut self, cx: &dyn PlatformCx) { }
    fn shutting_down(&mut self, cx: &dyn PlatformCx) { }
}
CallbackWhat Runtime does (crates/zgui-runtime/src/app.rs)
surfaces_availableInstalls the clock and waker, then opens every requested window that has no surface. On resume, this rebuilds all suspended window views.
surfaces_lostDrops every current frame pipeline and surface while retaining the requested window specifications.
surface_eventRedrawRequested runs a frame if the named window wants one and presentation pacing does not hold it. CloseRequested consults close callbacks; an accepted close and Destroyed remove that window, then apply the exit policy. Other events are queued only on the named window.
wakeTurns surface-scoped reasons into redraw requests, or drains application window commands for AppWork.
idleComputes the park. Returns an IdlePolicy.
deadline_reachedTurns "a deadline arrived" into a redraw request on the surfaces that were parked on it.
shutting_downCloses every window.

A frame runs from exactly one place: SurfaceEvent::RedrawRequested, and only when Window::wants_a_frame(now) answers true and Window::holds_a_frame(now) answers false. Everything else — input, a wake, a reached deadline — asks for a redraw and returns.

One frame, in order

pub fn frame(&mut self, clock: &dyn Clock) -> FrameReport

Window::frame runs the whole body inside the window's own reactive owner (owner.with(|| self.run_frame(clock)), crates/zgui-runtime/src/window/frame.rs). That is what makes the free functions a component is written with — set_timeout, focused_node, observation registration — resolve to the right window from a listener body, from an effect, and from a timer callback, not only while a view is being built.

The frame reads the clock once, at the top: clock.now() for deadlines and clock.timestamp() for every stamp the frame hands out. No later stage reads it again.

The names in the first column are the profiling marks the frame emits, which is what the ZGUI_LATENCY trace is labelled with.

MarkStepOwnerReadsWritesSkipped when
f.drainDispatch queued eventszgui-input, zgui-runtimelast frame's fragments and hit index; the queued eventsinteraction state, whatever the listeners write, the command queuenothing was queued
f.timersFire due callbackszgui-runtimethe timer heap, nowwhatever the callbacks writenothing is due
f.scrollAdvance running scrollzgui-scrolloffsets, elapsed timeoffsets, and the containers it movednothing is scrolling, or no time passed
f.gesturesAdvance held contactszgui-inputpointer contacts, nowgesture readings, the long-press momentno contact is held
f.reconfigurePoint the renderer at the surfacezgui-renderthe surface's extent, never the event'sthe render target, full damageno reconfiguration is owed
f.deviceRebuild the media devicezgui-stylethe viewport, scale and colour schemethe device the cascade matches againstthe device did not move
f.animateMove every running animation onzgui-anim, zgui-stylethe frame's timestampanimated values, restyle marks, lifecycle eventsnothing is running
f.flushRun posted UI closures, then reactive taskszgui-reactivethe UI queue and ready task setsignals and the document, through the Dom seamcalled re-entrantly
f.commandsCarry out what listeners asked forzgui-runtimethe command queuefocus, selection, pointer capture, sheetsthe queue is empty
f.restyleCascade the elements that owe onezgui-stylethe document and the sheetscomputed styles, and damage bitsno element owes a restyle
f.brushesRewrite moved text colourszgui-text, zgui-runtimethe cascade's paint updatesthe brush slots a cached paragraph resolves throughno colour moved
f.boxesBring the box tree back into agreementzgui-layoutstructural obligationsspliced subtrees, rewritten text runsnothing structural is owed
f.layoutMeasure, arrange, compose fragmentszgui-layout, zgui-textthe box tree, the scroll offsetssizes, positions, fragments, hit regions, damagethe measuring half only: the composing half always runs
f.enterEnter owed focus trapszgui-inputthe traps that asked to be enteredfocusnothing owes an entry
f.dispatch_scrollAnnounce containers that movedzgui-runtimethis frame's scroll deltasscroll events, then a flush of what they wrotenothing moved
f.observeDeliver settled geometryzgui-runtime, zgui-viewfragments, the observation registryobserved signals, then a flush and a relayoutnothing is observing
f.rehitRe-test under a stationary pointerzgui-inputthe fragments this frame producedhover state, crossing events for the next framethe pointer is over nothing new
f.publish_brushesCopy the brush table into the display listzgui-runtimethe text engine's tablescene.text_paints, in zgui-sceneno slot was claimed and no colour moved
f.caretPlan the caret rectanglezgui-runtimethe lines this frame produced, nowa highlight, and the damage for itnothing editable has focus
f.paintGrow the damage, emit against itzgui-paintfragments, styles, the content cachesthe display list, batches, draw ordernothing to emit
p.drawDraw and presentzgui-render + backendthe display list and the damagethe persistent target, then the surfacethe surface is occluded
f.a11yPublish the accessibility treezgui-a11ythe document and this frame's geometrya tree updatenothing is listening and nothing is owed
f.recycleRecycle document and box arenas, decide the parkzgui-runtimeretired document nodes and boxes, the frame gate, the document's change flagreusable arena slots and one redraw request, at most

p.draw contains the renderer's own marks. The Timeline tab of the inspector shows each of these names in parentheses, after the words for the stage:

MarkWork opened by the mark
r.tablesConsume the scene-table journals and flatten changed clip, paint, stop and coordinate-system slots.
r.vectorsEncode the planned vector paths.
r.planPlan render passes, targets and per-draw parameter blocks.
r.encoderCreate the frame command encoder.
r.shiftCopy the valid pixels of an accepted scroll shift through the scratch texture. Present only when a composed scrollport is shifted.
r.buffersCopy changed table ranges and this frame's instances through reusable staging chunks.
r.recordRecord render passes and draw commands. If the upload belt grew, this mark names how many chunks were allocated.
acq.inAcquire the surface after all scene work has been recorded.
sub.outFinish submission to the device queue.
pres.outPresent the acquired surface.

A long r.tables interval is CPU preparation. A long r.buffers interval with upload_chunks_allocated == 0 contains copying and possible destination-buffer growth, but no staging allocation. A non-zero allocation count identifies a larger staging working set or GPU backlog that prevented reuse.

Two of those steps exist for one defect each, and the source says so (crates/zgui-runtime/src/window/frame.rs):

Delivering geometry before painting is what stops a menu appearing for one frame in the wrong corner. Re-hit testing under a stationary pointer is what stops a dropdown that opens under the cursor from never being hovered.

The frame returns what it did:

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameReport {
    pub outcome: FrameOutcome,
    pub needs_another_frame: bool,
    pub restyled: usize,
    pub timers_fired: usize,
    pub animated: usize,
}

FrameOutcome comes from zgui-render: Presented(FrameStats), Skipped(SkipReason) or Recovered. outcome.retires_damage() is false for SkipReason::Unconfigured and SkipReason::DeviceUnavailable, because neither records work. A frame that composed everything and then failed to acquire a surface has still written the target it draws into, so its damage is retired.

Where the flush sits, and why

The flush is the reactive layer's only chance to run. zgui_reactive::flush() first drains the closures sent through Ui::post. It then polls every ready task to a stall. A posted closure can write signals, and those writes settle during the task poll that follows. Writing a signal outside the flush only marks observers.

The order around it is three lines of run_frame and each is load-bearing.

self.gate.requests_serviced(), immediately before the flush. It clears "another frame is owed". Anything that asked for a frame up to this line — a listener that ran in this frame's own dispatch, a timer callback, a task that finished while the frame was starting — is answered by the flush that follows and owes nothing more. Placing it after the flush would lose whatever arrived in between.

let flush = zgui_reactive::flush(); Effects run here, and this is where they mutate the document through the same Dom seam a view builds through.

self.document.borrow().changes_serviced(); — the produce and consume line. Everything above it produces changes: events dispatched, timers fired, the graph flushed, commands carried out. Everything below it consumes them. Without this line the change a listener made during a frame's own dispatch would still be flagged at the end of that frame and buy a second frame that damages nothing.

The restyle is below that line, so a listener's write is styled, laid out and painted by the frame that dispatched the event. That is what makes an interaction cost one frame rather than two, and it is asserted:

a click delivers three events — move, press, release — and each is shown by one frame

(a_click_runs_one_frame_for_each_event_and_not_two, crates/zgui/tests/wall_clock.rs).

The four places a frame flushes

The main flush is not the only one. Each of the others exists because a listener's writes must settle before something later in the same frame reads the document.

SiteWhyBound
settle_between_events (window/input.rs)A press that opens a dialog must have built it before the Escape in the same batch is routed.Once per queued event except the last
run_frameThe frame's own reactive work.Once
dispatch_scroll (window/scroll/mod.rs)A virtualised list must not render the rows it had before the scroll.Once
deliver_observations (window/observe.rs)A popover measures itself, then moves; the move must be painted in the frame it opened in.MAX_PASSES: u8 = 2, then a warning

A flush called from inside an effect is a no-op returning FlushOutcome::default(), so none of these can nest.

The iteration budget

Polling "until nothing is ready" does not terminate when two effects write each other's sources. Each write makes the other ready again, inside one poll, with no frame presented.

// crates/zgui-reactive/src/executor/budget.rs
pub(crate) const BUDGET: u32 = if cfg!(debug_assertions) { 32 } else { 8 };

The budget caps how many times one task may be polled within one flush. Each flush increments a generation counter, and a task's poll count resets when it sees a new generation.

When a task reaches the cap:

  1. it is set aside, and its waker is kept;
  2. the cycle is reported once, at error level through tracing, naming the spawn site when #[track_caller] recorded one;
  3. FlushOutcome::budget_exhausted is set;
  4. the kept wakers are woken after flush() returns, which re-queues the tasks in the pool and sets FlushOutcome::needs_another_frame.

So a dependency cycle costs a logged error and a presented frame, repeated every frame, rather than a hang. Debug builds allow more polls because a chatty dependency chain should be diagnosed rather than truncated while it is being investigated; release builds cut sooner because the frame still has to present. An ordinary task is polled once or twice per flush.

When the loop parks

Parking means the loop blocks in the windowing system and consumes nothing. The whole decision is Runtime::idle:

fn idle(&mut self, cx: &dyn PlatformCx) -> IdlePolicy {
    let now = cx.clock().now();
    let mut policy = IdlePolicy::Block;
    self.parked.clear();
    for window in &self.windows {
        let Some(deadline) = window.merged_deadline(now) else { continue };
        if deadline > now {
            self.parked.push((window.surface().id(), deadline));
            policy = policy.merge(IdlePolicy::BlockUntil(deadline));
        } else {
            window.request_frame();
        }
    }
    policy
}

Three outcomes, and no fourth:

ConditionPolicyEffect
No window has a merged deadlineIdlePolicy::BlockBlock on the windowing system alone
At least one deadline is strictly in the futureIdlePolicy::BlockUntil(min)Block until the earliest of them
A deadline has already passedThe frame is asked for now, and the park is on nothing

IdlePolicy::Spin exists in the contract and the runtime never returns it.

The two failure modes

The module doc of crates/zgui-runtime/src/app.rs names both, because they look identical from outside — nothing happens.

  • The stall. A deadline is installed, the loop is woken when it arrives, and nothing turns "the deadline arrived" into a request to draw. Runtime::deadline_reached is the edge that closes it.
  • The spin. A deadline that has already passed is installed anyway. The platform recomputes the time remaining on every turn, finds none, reports it reached again, and the loop runs no frames while burning a core. The else branch above closes it.

deadline_reached reads self.parked and never recomputes a deadline. Recomputing would find the next moment each window owes and conclude that nothing had been reached.

The headless backend asserts the difference after every turn:

pub fn assert_park_invariant(&self);   // resumes <= frames + 1

One resume per frame, plus the one whose frame has not run yet, is the whole budget (crates/zgui-platform-headless/src/harness.rs).

What a window is parked on

pub fn merged_deadline(&self, now: Instant) -> Option<Instant>

Eight sources are merged, and the earliest wins. Maintenance remains a distinct deadline kind because it must not request a normal frame.

SourceWhere it comes fromDropped while occluded
The next animation tickAnimationCadence::due()yes
The earliest scheduled callbackTimers::peek_for(document, now)no
The moment a held contact becomes a long pressWindow::gesture_deadline()no
The moment a deferred configure becomes worth answeringResizePace::due(interval)no
The next caret blinkCarets::next_flip(now)yes
The release time of a presentation-paced framePresentPace::due()no
The renderer-requested retry timeretry_afteryes
Idle-memory maintenancemaintenance_dueno

The seven render-producing deadlines are stable moments. merged_deadline is asked on every turn of the loop, so a source that answered "an interval from now" would be pushed forward by every unrelated wake. The animation's moment comes from the phase the last frame left, the resize's from the frame that last answered a configure, and the blink's from the caret's own origin. The maintenance moment is set once, at the end of a normal frame, to two seconds after that frame. A later normal frame replaces it.

When maintenance wins, the runtime releases reproducible renderer and embedded-surface working memory without layout, paint, drawing, or presentation. It clears the maintenance deadline when the work is complete.

The resize and presentation gates then floor the answer. While a reconfiguration is owed and could not yet be seen, Window::wants_a_frame refuses every frame whatever asked for it. While presentation pacing holds a frame, Window::holds_a_frame does the same. A moment before either gate is not an early frame but no frame at all: wake, ask, be refused, recompute, wake again.

What wakes the loop

Four things, and the list in crates/zgui-runtime/src/lib.rs is exhaustive on purpose, because a missing entry is not a crash but a window that quietly stops answering one class of event.

AskerRouteType involved
A change to the documentThe reactive write that caused it, or the frame's own end-of-frame flagDocument::end_frame, FrameGate
InputRuntime::surface_event calls Window::queue, then Window::request_frameSurfaceEvent
Work finishing somewhere elseRuntimeWaker::wake to Waker::wake to Runtime::wakeWakeReason::ReactiveWork
A deadline arrivingThe platform reports it; Runtime::deadline_reached turns it into a requestIdlePolicy::BlockUntil

The gate

pub struct FrameGate { /* AtomicBool in_frame, AtomicBool another_frame */ }

impl FrameGate {
    pub fn in_frame(&self) -> bool;
    pub fn needs_another_frame(&self) -> bool;
    pub fn request(&self) -> bool;      // true means deferred rather than forwarded
    pub fn requests_serviced(&self);
    pub fn begin_frame(&self);
    pub fn end_frame(&self) -> bool;    // swaps `another_frame` out
}

Two atomics, read from arbitrary threads, because a wake arrives from wherever the work finished. A wake raised from outside a frame goes to the platform. A wake raised from inside one sets another_frame and returns. A unit test asserts that a thousand in-frame wakes become exactly one owed frame (crates/zgui-runtime/src/wake.rs).

The waker

impl FrameWaker for RuntimeWaker {
    fn wake(&self) {
        if self.gate.request() { return; }
        let surfaces = /* the surfaces this waker owns */;
        if surfaces.is_empty() { return; }
        self.platform.wake(WakeReason::ReactiveWork { surfaces: surfaces.into_boxed_slice() });
    }
}

RuntimeWaker holds an Arc<dyn zgui_platform::Waker>, the shared FrameGate and the set of SurfaceId it speaks for. The surfaces are named rather than left implicit, because an image decoding for one window is not a reason to redraw another. Runtime::open_live_window calls waker.owns(surface.id()) before the view is built; close_window calls disowns, so a later wake naming a closed window goes nowhere.

zgui_platform::Waker is the only object in the platform contract that crosses threads. A signal written on a worker thread reaches it synchronously, inside the write, on the writing thread.

The other wake reasons

#[non_exhaustive]
pub enum WakeReason {
    ReactiveWork { surfaces: Box<[SurfaceId]> },
    A11yAction(accesskit::ActionRequest),
    A11yTreeRequested(SurfaceId),
    ClipboardRead { serial: ClipboardSerial, result: Result<ClipboardData, ClipboardError> },
    AppWork,
    DeviceLost,
    ColorSchemeChanged,
}

AppWork tells the runtime to process deferred open, close, or quit commands. It does not name a surface because a command can create or remove the surface itself.

ColorSchemeChanged is the one that is more than a redraw. Runtime::wake reads cx.color_scheme() and pushes it into every window before requesting a frame: a frame alone changes nothing, because the device the cascade is matched against is rebuilt from the window's own state. ClipboardRead is not handled by the runtime today.

Requesting a frame is idempotent

pub fn request_frame(&self) {
    if !self.awaiting_frame.replace(true) {
        zgui_profile::latency::mark("req.redraw");
        self.surface.request_redraw();
    }
}

Asking twice for the same frame is not wrong, but it is indistinguishable from two things each having a reason to ask. The flag is cleared by frame_started() at the top of a frame, and by declined_a_frame() when one is refused — refusing a frame and forgetting that one was asked for have to be the same act, or the request left standing is one nothing can renew and the window stops redrawing.

A frame that ends occluded is excluded from the end-of-frame request: if needs_another_frame && outcome != FrameOutcome::Skipped(SkipReason::Occluded). Honouring it there is a hidden window running the whole pipeline at full rate for ever.

Timers, deadlines and the clock

There is no timer thread. A single-threaded loop that already computes when to wake up gets scheduled callbacks for nothing.

pub struct Timers { /* BinaryHeap<Reverse<Entry>>, next: u64, cancelled: FxHashSet<TimerId> */ }

impl Timers {
    pub fn schedule(&mut self, document: DocumentId, now: Instant, after: Duration,
                    repeat: Repeat, callback: Rc<dyn Fn()>) -> TimerId;
    pub fn cancel(&mut self, id: TimerId);
    pub fn peek(&mut self) -> Option<(Instant, DocumentId)>;
    pub fn peek_for(&self, document: DocumentId, now: Instant) -> Option<Instant>;
    pub fn due(&mut self, document: DocumentId, now: Instant) -> Vec<Rc<dyn Fn()>>;
    pub fn forget(&mut self, document: DocumentId);
}
  • One heap is shared by every window, and each entry carries its DocumentId. A window's frame takes only that document's due callbacks. Due callbacks for another window remain scheduled for its own frame and reactive scope.
  • Ordering is deadline, then TimerId. Two entries due at the same instant fire in registration order.
  • Cancellation is lazy. A binary heap has no cheap removal, and a tooltip scheduled and cancelled on every pointer move would otherwise cost a rebuild each time. peek drops cancelled entries from the front.
  • A repeating entry re-arms from now, not from the deadline it missed. A loop blocked for a second fires a 16 ms interval once, not sixty times.
  • A pending entry marks no invalidation on any node. It is a deadline the loop owes, not work the document owes, so a callback that writes nothing costs a frame that skips every stage from the restyle onwards.

Callbacks fire at f.timers, before the flush, each inside zgui_reactive::enter_non_reactive_zone() — a callback that reads a signal is reading it, not subscribing whatever scope happens to be current.

The Clock seam

pub trait Clock: Send + Sync + 'static {
    fn now(&self) -> Instant;
    fn origin(&self) -> Instant;
    fn timestamp(&self) -> Timestamp {
        Timestamp::from_origin(self.now().saturating_duration_since(self.origin()))
    }
}

Nothing in the framework calls the system clock directly, and that is the whole reason the trait exists. Timers, animations and the park all ask the platform what time it is, so a test backend can hand them a clock it moves by hand: a five-second animation is exercisable in a microsecond, with no sleeping and no flakiness. zgui_platform::VirtualClock is the one manual implementation, and both the headless backend and zgui-testkit-scene re-export it rather than writing a second one.

PlatformCx::clock() returns Arc<dyn Clock> rather than a borrow, because a held reading is a frozen clock and deadlines computed against it never arrive.

Animation, pacing and the refresh rate

An animating window asks for no frame of its own. Doing so would be a spin at whatever rate the machine can manage. What brings the loop back is the deadline the frame leaves behind.

pub struct AnimationCadence { /* due: Option<Instant> */ }

impl AnimationCadence {
    pub const fn parked() -> Self;
    pub const fn due(&self) -> Option<Instant>;
    pub const fn park(&mut self);
    pub fn advance(&mut self, now: Instant, interval: Duration);
}

The moment is a phase, not a delay. The next frame is owed one refresh interval after the moment the last one was due, not one interval after whatever else last happened to the window. A deadline recomputed as "now plus an interval" is pushed a full interval into the future by every unrelated wake — a compositor re-stating a size, a pointer sample, a task finishing elsewhere. Two of those per interval halve the frame rate, and nothing about the animation looks wrong while it happens: the values are interpolated against the clock, so every frame that does run holds the right value, and the only symptom is a motion made of half as many steps.

Two degradations are part of the definition:

  • A late frame catches up without bursting. advance steps the phase by whole intervals until it is in the future again. One deadline, on the original phase, however many were missed.
  • A window that fell far behind starts again. const MAX_CATCH_UP: u128 = 8; — past eight missed intervals the phase describes a rate nothing was running at, and the window is anchored where it is.

pace_animations(now) runs at the end of the frame, against the moment the frame was for rather than the moment it ended, and after the cascade — because the cascade is what starts a keyframe animation. A window that is occluded or no longer animating calls park() and leaves no deadline at all.

The interval itself is read from the surface, not assumed:

// crates/zgui-platform/src/monitor.rs
pub fn refresh_interval(millihertz: Option<u32>) -> std::time::Duration {
    const FALLBACK_MILLIHERTZ: u32 = 60_000;
    let rate = millihertz.filter(|rate| *rate > 0).unwrap_or(FALLBACK_MILLIHERTZ);
    std::time::Duration::from_secs_f64(1_000.0 / f64::from(rate))
}

A rate of zero is treated as no rate at all rather than as an infinitely fast display. A window dragged onto another output is paced by that output from the next frame onwards.

What the cadence gate proves

cargo xtask cadence is one of the project's standing gates. It runs two headless targets in crates/zgui-runtime/tests/: anim_cadence and scroll_cadence. Both mount a real window over the headless platform, put its surface on an output with a stated refresh rate, and drive a virtual clock in exact refresh intervals — so the rate is a real input to the runtime's pacing rather than a constant the test also chose, and 240 Hz needs no 240 Hz display.

The anim_cadence half proves three things (xtask/src/cadence/subject.rs):

  1. an animation gets exactly one frame per refresh at 60, 75 and 240 hertz;
  2. an unrelated wake does not move the moment the next tick is owed at;
  3. an animation that finishes leaves no deadline and draws nothing for ten seconds.

Seventy-five hertz is named in the gate's own subject list on purpose: a cadence held by rounding a refresh interval to something convenient holds at 60 and at 240 and is lost at 75.

What the gate cannot prove is that those frames reached the device. A frame whose picture is identical to the last one damages nothing, and the renderer refuses an undamaged frame rather than spending a swap-chain image on pixels the surface already holds. scroll_cadence closes most of that hole by counting composed positions that differ rather than frames. The rest needs a real display server, and stays a probe run by hand:

cargo run --release -p zgui-bench --bin anim-cadence   -- dev.zgui.anim   10
cargo run --release -p zgui-bench --bin scroll-cadence -- dev.zgui.scroll bottom

FrameProbe

A window's interesting state exists only between the frame that produced it and the next frame that overwrites it. A tool cannot ask for it later, and cannot ask for it from inside a view either, because a view sees the document and not the frame that painted it.

pub trait FrameProbe {
    /// A frame has finished on `window`.
    fn frame_ended(&self, window: &Window);

    /// What to call this probe in a diagnostic rendering of the window's options.
    fn describe(&self) -> &str { "a frame probe" }
}
  • Called once, last, on every frame, with the window exactly as the frame left it: the scene it emitted, the damage it answered, the layout it computed, the renderer's report.
  • &self, not &mut self, because the window is borrowed for the call. A probe that keeps something writes it through a cell or a signal, which is what lets one probe serve several windows.
  • Installed with App::with_probe(probe: Rc<dyn FrameProbe>), stored in WindowOptions::probe. One seam, one occupant: the field is an Option, not a list.
  • Nothing in the framework implements it. zgui-devtools is the canonical consumer.

The seam is deliberately the narrowest possible: one method, no return value, nothing it can change. A probe that mutated the window would change the thing it was measuring, and a probe that could refuse a frame would be a second frame loop.

use std::cell::Cell;
use std::rc::Rc;
use zgui::prelude::*;
use zgui::runtime::{FrameProbe, Window};

/// Counts the frames the window ran.
#[derive(Default)]
struct Frames(Cell<u64>);

impl FrameProbe for Frames {
    fn frame_ended(&self, _window: &Window) {
        self.0.set(self.0.get() + 1);
    }
}

fn main() -> Result<(), zgui::Error> {
    app()
        .with_probe(Rc::new(Frames::default()))
        .run(|| view! { column() })
}

HostBinding

The document carries no scripting language, and the hooks a scripting language needs are frame-loop concepts rather than document ones. All three are questions about when, and only the loop knows when.

pub trait HostBinding {
    /// An event is about to be dispatched, before the first listener on its path.
    /// Answering `false` means the event is not dispatched at all.
    fn before_dispatch(&mut self, target: Option<NodeId>, event: EventKind) -> bool { true }

    /// The frame's reactive work has settled.
    fn checkpoint(&mut self) { }

    /// Layout has settled and nothing has been emitted yet.
    fn before_paint(&mut self, timestamp: zgui_vocab::Timestamp) { }

    /// The window this binding was installed on is going away.
    fn shutting_down(&mut self) { }
}

The three in-frame hooks are called in a fixed order, and the fourth is the teardown:

HookCalled atWhy there
before_dispatchInside f.drain, once per event, before the first listenerAn engine intercepting an event for its own dispatcher
checkpointImmediately after zgui_reactive::flush()Drain queued engine work here. Direct document changes reach this frame's restyle. Signal writes need a later flush.
before_paintImmediately before paint_and_drawRun work that belongs after layout and observation delivery. Document and signal changes are processed in the next frame. The hook receives the frame's timestamp.
shutting_downFirst line of Window::close

NoBinding implements every default, so a window with no script engine costs three calls that compile to nothing.

Partial· Installed, and not surfaced through the umbrella crate.
pub fn install_binding(&mut self, binding: Box<dyn HostBinding>);

Window::install_binding is the only entry point. There is no App::with_binding, and zgui::app::Handler exposes no Runtime, so reaching it means building through zgui_runtime::App::into_handler and driving the Runtime yourself. Nothing in the repository implements the trait except NoBinding and one unit-test type.

Cold start

The very first frame of a window is the only one that does several things, and every one of them is a cache that has nothing in it yet.

The surface is created hidden. That is a rule, not a default: an accessibility adapter has to be attached before a surface is first shown, and there is no second chance.

The renderer is built from the factory, against a RenderTarget made from the surface's extent and scale factor. No adapter means AppError::GpuUnavailable and no silent fallback to drawing nowhere.

The waker is told it owns this surface, before the view is built, because a view that asks for anything while it is being built asks through that waker.

Window::open runs. The document, the layout store, the scroller and the style engine are created; the application style sheet is parsed and installed at SheetOrigin::Author; a zgui_reactive::Mounted scope is opened; zgui_view::provide_host is called inside it; the view factory runs and the built anchor is mounted. The window starts with damage = DamageSet::full(), reconfigure = true, first_frame = true.

The desktop's colour scheme is pushed in before the first frame, so the document is styled dark from the first pixel rather than laid out light and cascaded again.

The first frame runs. f.reconfigure configures the renderer and sets full damage. Window::restyle skips writing the cascade's result onto boxes, because the document has no box tree at all yet and every box is about to be built from this same cascade. build_boxes takes the full-rebuild path, since layout.root() is None. Every measurement misses, every paragraph is shaped for the first time, and every glyph is rasterised into the atlas.

The surface is shown, and only now: set_visible(true) runs on the first FrameOutcome::Presented. Showing an unpainted surface is what produces a flash of empty window at launch.

Measured, on the 1 851-box gallery: 103.31 ms to the first painted frame, against a band of 140.00 and a budget of 250.00 (cold.first_frame, docs/performance.md). The rationale shipped with the number reads "measured at 102-111 ms headless; on screen with a device it is 216-247 ms" — the difference is the graphics device, which is deliberately outside the measurement: what a driver takes to compile a pipeline varies by more than everything else measured put together, and a band around it would fire on a driver update (crates/zgui-bench/src/scenario/cold.rs).

That first frame emits 844 primitives and culls 4 575 (docs/performance.md). It is measured once and never repeated: the second start in a process is not cold.

For contrast, a settled application costs idle.frames = 0.00 and idle.turn = 0.07 us from the same file. The regression test is a_settled_application_runs_no_frames_at_all, whose comment reads "The park policy, which has been broken four times".

More than one window

Runtime keeps two sets. live: Vec<LiveWindow> holds every window the application still wants, including one that temporarily has no surface. windows: Vec<Window> holds the frame pipelines for the surfaces that exist now. A platform suspension clears the second set and keeps the first. Resume builds each live window again from its retained FnMut view factory.

Opening and closing are deferred commands. A listener or effect runs inside a borrowed frame, where no platform context is available to create a surface. use_windows().open therefore mints a stable WindowId, returns its pending WindowHandle, queues the specification, and wakes the loop with WakeReason::AppWork. The next handler turn creates the surface and document. A programmatic close uses the same queue and destroys the named surface.

Every path is scoped to the correct window:

  • Each window receives a distinct DocumentId.
  • RuntimeWaker owns the open SurfaceId values and removes one when its surface closes.
  • idle merges one deadline per window.
  • deadline_reached keeps the deadlines that have not arrived.
  • Timers key entries by DocumentId.
  • Accessibility actions are offered to windows until one claims the node.

The reactive ready queue is thread-wide. A frame in one window can run an effect that changes the document of another. After each flush, sweep_other_windows checks the other document revisions and requests frames only for those that were written. This closes the wake that the first frame already serviced without redrawing every window.

The public lifecycle and state-sharing rules are in Multiple windows.

A click, traced

A click is three platform events — move, press, release — and three frames. Take one element:

use zgui::prelude::*;

#[component]
fn Swatch() -> impl IntoView {
    let (picked, set_picked) = signal(false);

    view! {
        control(
            class = "swatch",
            class:picked = move || picked.get(),
            on:click = move |_| set_picked.update(|on| *on = !*on),
        ) {
            "pick"
        }
    }
}

const SHEET: &str = css!(
    ".swatch { padding: 8px 20px; border-radius: 8px; background: #232733 }
     .swatch.picked { background: #2b6cff }"
);

Here is the release frame, which is the one that does the work.

The platform reports a pointer release. Runtime::surface_event falls into the other arm and calls window.queue(event). Pointer events are input, so the event is pushed onto self.queued and queue answers true, which asks for a frame. Window::request_frame finds awaiting_frame clear, sets it, and calls surface.request_redraw().

The loop delivers RedrawRequested. wants_a_frame(now) is true, because no reconfiguration is owed. window.frame(clock) runs, inside the window's own reactive owner.

f.drain. The router hit-tests the release position against the previous frame's fragments and hit index, and builds the capture, target and bubble path. HostBinding::before_dispatch is consulted. No listener is registered for a pointer release here, so nothing runs, default_allowed stays true, and the framework's own default for the event — FrameworkDefault::Activate(node) — is carried out. That calls Window::synthesize, which resolves the path again and dispatches a synthetic Click down the same capture, target and bubble path a real one would take. The on:click body runs set_picked.update(..), which marks the observers of picked and runs none of them. The wake this write raises is folded by FrameGate::request, because a frame is in flight.

f.timers, f.scroll, f.gestures, f.reconfigure, f.device, f.animate. Each returns on an emptiness test. Nothing is due, nothing is moving, no configure is owed, nothing is animating.

gate.requests_serviced(), then f.flush. The render effect that reads picked is polled once. It writes the class through the Dom seam, which opens a change batch, adds the class, marks the element as owing a restyle, and tells its ancestors there is work below them.

f.commands, then changes_serviced(). No command was issued. The change the flush just made is declared answered by this frame.

f.restyle. The style engine visits exactly the one element that owes a restyle, cascades it, and translates what moved into damage bits. A background colour is repaint damage, not relayout. The new computed style is written onto the box the element already has, because a box holds a clone of the style it was built with and every stage after layout reads the box's copy.

f.brushes, f.boxes. No text colour moved. No structural obligation is owed, so the splice does nothing and the text rewrite finds no run changed.

f.layout. The measuring half is gated and does not run: nothing changed a size. The composing half always runs, and the fragment diff puts the swatch's rectangle into the damage set. Skipping it here would paint nothing at all for every change that does not move a box.

f.enter through f.caret. Five emptiness tests: no trap owes an entry, nothing scrolled, nothing is observing, the pointer is over nothing new, no slot was claimed, nothing editable has focus.

f.paint. The damage is clipped to the viewport, then grown by zgui_paint::expand over anything that reads pixels outside what it writes — a shadow, a blur. The emit walk replays the per-fragment recording for everything the damage does not reach and re-emits only what it does. The scene is finished against the damage, the atlas uploads are flushed to the device, and renderer.draw(&scene, &damage) scissors to the same rectangles. Outside them the last frame's pixels are still correct and are not touched.

f.a11y, f.recycle. Nothing is listening to the accessibility tree and no node moved, so nothing is published. document.end_frame() answers false, gate.end_frame() answers false, the flush owed nothing, and the drain settled nothing between events.

The park. needs_another_frame is false, so no request is made. Nothing is animating, no timer is scheduled, no configure is owed, no caret is blinking — merged_deadline answers None, and Runtime::idle returns IdlePolicy::Block. The loop consumes nothing until the next event.

Measured cost of the whole interaction on a 1 851-box document: 11.34 µs at the median (kitchen.click, docs/performance.md). The click places 0 glyphs and rasterises 0, because the damage reaches one swatch and no text is inside one (a_click_rasterises_no_glyph_and_stays_far_inside_one_frame, crates/zgui/tests/wall_clock.rs).

Next

On this page