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) { }
}| Callback | What Runtime does (crates/zgui-runtime/src/app.rs) |
|---|---|
surfaces_available | Installs the clock and waker, then opens every requested window that has no surface. On resume, this rebuilds all suspended window views. |
surfaces_lost | Drops every current frame pipeline and surface while retaining the requested window specifications. |
surface_event | RedrawRequested 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. |
wake | Turns surface-scoped reasons into redraw requests, or drains application window commands for AppWork. |
idle | Computes the park. Returns an IdlePolicy. |
deadline_reached | Turns "a deadline arrived" into a redraw request on the surfaces that were parked on it. |
shutting_down | Closes 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) -> FrameReportWindow::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.
| Mark | Step | Owner | Reads | Writes | Skipped when |
|---|---|---|---|---|---|
f.drain | Dispatch queued events | zgui-input, zgui-runtime | last frame's fragments and hit index; the queued events | interaction state, whatever the listeners write, the command queue | nothing was queued |
f.timers | Fire due callbacks | zgui-runtime | the timer heap, now | whatever the callbacks write | nothing is due |
f.scroll | Advance running scroll | zgui-scroll | offsets, elapsed time | offsets, and the containers it moved | nothing is scrolling, or no time passed |
f.gestures | Advance held contacts | zgui-input | pointer contacts, now | gesture readings, the long-press moment | no contact is held |
f.reconfigure | Point the renderer at the surface | zgui-render | the surface's extent, never the event's | the render target, full damage | no reconfiguration is owed |
f.device | Rebuild the media device | zgui-style | the viewport, scale and colour scheme | the device the cascade matches against | the device did not move |
f.animate | Move every running animation on | zgui-anim, zgui-style | the frame's timestamp | animated values, restyle marks, lifecycle events | nothing is running |
f.flush | Run posted UI closures, then reactive tasks | zgui-reactive | the UI queue and ready task set | signals and the document, through the Dom seam | called re-entrantly |
f.commands | Carry out what listeners asked for | zgui-runtime | the command queue | focus, selection, pointer capture, sheets | the queue is empty |
f.restyle | Cascade the elements that owe one | zgui-style | the document and the sheets | computed styles, and damage bits | no element owes a restyle |
f.brushes | Rewrite moved text colours | zgui-text, zgui-runtime | the cascade's paint updates | the brush slots a cached paragraph resolves through | no colour moved |
f.boxes | Bring the box tree back into agreement | zgui-layout | structural obligations | spliced subtrees, rewritten text runs | nothing structural is owed |
f.layout | Measure, arrange, compose fragments | zgui-layout, zgui-text | the box tree, the scroll offsets | sizes, positions, fragments, hit regions, damage | the measuring half only: the composing half always runs |
f.enter | Enter owed focus traps | zgui-input | the traps that asked to be entered | focus | nothing owes an entry |
f.dispatch_scroll | Announce containers that moved | zgui-runtime | this frame's scroll deltas | scroll events, then a flush of what they wrote | nothing moved |
f.observe | Deliver settled geometry | zgui-runtime, zgui-view | fragments, the observation registry | observed signals, then a flush and a relayout | nothing is observing |
f.rehit | Re-test under a stationary pointer | zgui-input | the fragments this frame produced | hover state, crossing events for the next frame | the pointer is over nothing new |
f.publish_brushes | Copy the brush table into the display list | zgui-runtime | the text engine's table | scene.text_paints, in zgui-scene | no slot was claimed and no colour moved |
f.caret | Plan the caret rectangle | zgui-runtime | the lines this frame produced, now | a highlight, and the damage for it | nothing editable has focus |
f.paint | Grow the damage, emit against it | zgui-paint | fragments, styles, the content caches | the display list, batches, draw order | nothing to emit |
p.draw | Draw and present | zgui-render + backend | the display list and the damage | the persistent target, then the surface | the surface is occluded |
f.a11y | Publish the accessibility tree | zgui-a11y | the document and this frame's geometry | a tree update | nothing is listening and nothing is owed |
f.recycle | Recycle document and box arenas, decide the park | zgui-runtime | retired document nodes and boxes, the frame gate, the document's change flag | reusable 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:
| Mark | Work opened by the mark |
|---|---|
r.tables | Consume the scene-table journals and flatten changed clip, paint, stop and coordinate-system slots. |
r.vectors | Encode the planned vector paths. |
r.plan | Plan render passes, targets and per-draw parameter blocks. |
r.encoder | Create the frame command encoder. |
r.shift | Copy the valid pixels of an accepted scroll shift through the scratch texture. Present only when a composed scrollport is shifted. |
r.buffers | Copy changed table ranges and this frame's instances through reusable staging chunks. |
r.record | Record render passes and draw commands. If the upload belt grew, this mark names how many chunks were allocated. |
acq.in | Acquire the surface after all scene work has been recorded. |
sub.out | Finish submission to the device queue. |
pres.out | Present 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.
| Site | Why | Bound |
|---|---|---|
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_frame | The 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:
- it is set aside, and its waker is kept;
- the cycle is reported once, at
errorlevel throughtracing, naming the spawn site when#[track_caller]recorded one; FlushOutcome::budget_exhaustedis set;- the kept wakers are woken after
flush()returns, which re-queues the tasks in the pool and setsFlushOutcome::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:
| Condition | Policy | Effect |
|---|---|---|
| No window has a merged deadline | IdlePolicy::Block | Block on the windowing system alone |
| At least one deadline is strictly in the future | IdlePolicy::BlockUntil(min) | Block until the earliest of them |
| A deadline has already passed | The 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_reachedis 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
elsebranch 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 + 1One 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.
| Source | Where it comes from | Dropped while occluded |
|---|---|---|
| The next animation tick | AnimationCadence::due() | yes |
| The earliest scheduled callback | Timers::peek_for(document, now) | no |
| The moment a held contact becomes a long press | Window::gesture_deadline() | no |
| The moment a deferred configure becomes worth answering | ResizePace::due(interval) | no |
| The next caret blink | Carets::next_flip(now) | yes |
| The release time of a presentation-paced frame | PresentPace::due() | no |
| The renderer-requested retry time | retry_after | yes |
| Idle-memory maintenance | maintenance_due | no |
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.
| Asker | Route | Type involved |
|---|---|---|
| A change to the document | The reactive write that caused it, or the frame's own end-of-frame flag | Document::end_frame, FrameGate |
| Input | Runtime::surface_event calls Window::queue, then Window::request_frame | SurfaceEvent |
| Work finishing somewhere else | RuntimeWaker::wake to Waker::wake to Runtime::wake | WakeReason::ReactiveWork |
| A deadline arriving | The platform reports it; Runtime::deadline_reached turns it into a request | IdlePolicy::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.
peekdrops 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.
advancesteps 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):
- an animation gets exactly one frame per refresh at 60, 75 and 240 hertz;
- an unrelated wake does not move the moment the next tick is owed at;
- 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 bottomFrameProbe
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 inWindowOptions::probe. One seam, one occupant: the field is anOption, not a list. - Nothing in the framework implements it.
zgui-devtoolsis 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:
| Hook | Called at | Why there |
|---|---|---|
before_dispatch | Inside f.drain, once per event, before the first listener | An engine intercepting an event for its own dispatcher |
checkpoint | Immediately after zgui_reactive::flush() | Drain queued engine work here. Direct document changes reach this frame's restyle. Signal writes need a later flush. |
before_paint | Immediately before paint_and_draw | Run 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_down | First line of Window::close | — |
NoBinding implements every default, so a window with no script engine costs three calls that
compile to nothing.
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. RuntimeWakerowns the openSurfaceIdvalues and removes one when its surface closes.idlemerges one deadline per window.deadline_reachedkeeps 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
The document
The node arena the frame reads and writes, and the seams around it.
Invalidation
The obligation bits every step of the frame retires, and how damage merges.
Reactive internals
How the graph is stored, and what the flush actually walks.
The platform layer
Surface, Clock, Waker, and what a second backend must not do.