Writing fast interfaces
The practical rules that follow from the cost model, each named against the measured behaviour it follows from.
The cost model says what each kind of change costs. This page says what to do about it. Every rule below names the measurement or the mechanism it follows from, and nothing is here because it sounds prudent.
Lists
Key every list, and never by index
use zgui::prelude::*;
#[derive(Clone, Debug)]
struct Todo {
id: u64,
label: String,
}
view! {
column(class = "list") {
for item in move || items.get(), key = |item: &Todo| item.id {
row(class = "todo") { text {{item.label.clone()}} }
}
}
}When the collection changes, the list compares the new key list with the old one and produces one step per position: keep, move, or create. A kept row is placed again and is not rebuilt. It keeps its nodes, its signals, its timers and the data it was built with.
That last clause is why an index is the wrong key. Insert one item at the front of a three-row list:
| Key function | Old keys | New keys | Plan | What is drawn |
|---|---|---|---|---|
item.id | 7, 8, 9 | 6, 7, 8, 9 | create, keep, keep, keep | the new row, then the three old ones |
| the index | 0, 1, 2 | 0, 1, 2, 3 | keep, keep, keep, create | the three old rows unchanged, then the last one twice |
The second row is not a slow list. It is a wrong list: the new item never appears, and the old last item is drawn at two positions. An index is a position, and a position is not an identity.
A good key also decides the work. Inserting at the front of a thousand-row list is one node insertion, not a thousand rewrites.
Two rows that produce the same key make "which row is this" unanswerable, so the list panics and says so. The check runs in every profile, release included, because a check compiled out of the build you ship is a check for the one case that never happens.
Give each row its own signal
The list's only reactive dependency is the closure after in. Every write to that closure's
sources re-runs it, and one re-run reads the whole collection, clones every key, builds a map of
every key and produces a plan — work proportional to the whole list, before anything on screen has
changed.
So keep what changes inside a row out of the collection:
#[derive(Clone, Debug)]
struct Todo {
id: u64,
label: String,
done: RwSignal<bool>, // the row's own state, not the list's
}
view! {
for item in move || items.get(), key = |item: &Todo| item.id {
row(
class = "todo",
class:done = move || item.done.get(),
on:click = move |_| item.done.update(|done| *done = !*done)
) {
text {{item.label.clone()}}
}
}
}Ticking one item writes that item's done. The collection signal is untouched, so the list does
not compare keys at all, and one class binding runs.
What that buys, measured: one class toggled on one element of an 1 851-box document is 11.34 µs
(kitchen.click, docs/performance.md). Over a document of ten thousand controls, the same change
restyles one element, lays out none, diffs six fragments, emits one primitive and
takes one draw-order insertion — at every size from 1 250 controls to 10 000
(static-slope, docs/perf/reference-workloads.md).
A row that reads the collection to find itself has subscribed to the collection again. Give the row
its value, or a Field handle, and let it read that.
Use a Selector for "which one is selected"
Selection written as a plain signal makes every row read it, so every row subscribes to it, and
moving the selection wakes all of them. A Selector keeps one small signal per watched key.
use zgui::reactive::Selector;
let selected = RwSignal::new(0_u64);
let selector = Selector::new(move || selected.get());
view! {
for item in move || items.get(), key = |item: &Todo| item.id {
row(
class = "todo",
class:selected = move || selector.is_selected(&item.id),
on:click = move |_| selected.set(item.id)
) {
text {{item.label.clone()}}
}
}
}Measured directly in the repository: eight rows produce eight first runs, and moving the selection
produces exactly two more — the row that lost it and the row that gained it
(only_the_two_affected_rows_re_run, crates/zgui-reactive/src/reexport/selector.rs). See
Stores and selectors.
Read the narrow thing
An observer of a store's root, or of any ancestor field, re-runs on any descendant write. The isolation a store gives you is only there if you read at the depth the write happens at.
| Hand a hole | Wakes on |
|---|---|
Store<Settings> | every write to every field. Never do this. |
store.rows() | every write to every row |
store.rows().at_key(id).value() | that one field of that one row |
Field<u32> | whatever field the handle names |
The same rule applies to a plain signal holding a struct: a wide value read by many holes wakes them all, and each one re-runs to produce the same answer.
Branching
Prefer if to a closure hole
// Compares the answer. Adding a row to a list that already had rows swaps nothing.
if move || items.get().is_empty() {
label(class = "empty") {"Nothing to do yet."}
} else {
column(class = "list") { … }
}if reads its condition inside one effect and compares the boolean. A closure hole compares
nothing: it re-runs whenever any signal it read changed, and rebuilds what it produced.
The difference is not only work. Rebuilding a subtree runs its cleanups, cancels and restarts its timers, and rebinds every handle inside it — so a subtree rebuilt on an unrelated write is a subtree that loses whatever it was keeping, including a scroll position and a half-typed field.
| Branches | Use | Compares |
|---|---|---|
| two, known in advance | if | the boolean |
| many, of different view types | Dynamic | the view type; the same type rebuilds in place |
| none — the branch produces a value, not a subtree | a closure hole | nothing, but a text node compares the string it writes |
The last row is worth keeping in mind: a hole that produces the same string writes nothing to the document, because the text node remembers its last value. That hole costs the closure and no more.
Where a memo pays for itself
A memo does two things: it runs its closure once per flush however many readers it has, and it stops the notification when the new value compares equal to the old one.
| Situation | Memo? | Why |
|---|---|---|
| several holes, or several rows, read one derived value | yes | one computation instead of one each |
| the answer is narrower than its inputs and the reader rebuilds a subtree | yes | the reader is woken only when the answer moves |
| one reader, and it is a text hole | no | the text node already compares the string it writes |
in front of an if condition | no | if already compares the answer |
a.get() + b.get() | no | the memo's own bookkeeping costs more than the addition |
the value is a large Vec or String that is cheap to rebuild | no | every run pays a full PartialEq over it |
A memo is not a way to coalesce writes and does not need to be. The notification behind each task
holds one slot, so a signal written a thousand times between two flushes costs one re-run, not a
thousand (crates/zgui-reactive/src/executor/frame.rs).
Style
Toggle the class on the element, not on an ancestor
The repository measures exactly this, with one declaration reached two ways:
.cell.hot { background-color: rgb(47, 107, 255) } /* a class on one control */
.warm .cell { background-color: rgb(47, 107, 255) } /* a class on the root, above every control */One property, two reaches. The ratio of the local change to the ancestor change is gated at
≤ 0.09 and measured at 0.0679–0.0702
(STATIC-locality, crates/zgui-bench/src/bin/static-slope/criteria.rs). In the advisory numbers
beside it, the local change costs 70.7–72.8 ns per control and the ancestor change
1 031–1 041 ns per control (docs/perf/reference-workloads.md).
Two mechanisms sit under that.
- A class no selector mentions costs nothing at all. Before any style work is scheduled, the framework asks whether any selector mentions this class name, whether any mentions this attribute name, and which state bits could matter for this element. A change nothing depends on needs no record, no marking and no traversal.
- A class an ancestor rule sits under reaches every descendant those rules could match. The
dependency index is built from the rule set's invalidation map rather than from the matching map,
precisely so that
.warmis known to matter (crates/zgui-style/src/deps/class_set.rs).
For a theme, prefer a custom property on a container. It inherits, so one declaration re-themes the subtree with no rule matching anywhere (Styling). Prefer either to swapping a sheet: the frame in which the installed sheet set changes disables the filters above, so every mutation in that one frame takes the full path.
Prefer a colour change to a size change
Where the design accepts either, change the colour.
| Change | Reaches |
|---|---|
color, background-color, a border colour, a shadow | restyle, paint, draw |
width, padding, gap, font-size | restyle, layout, paint, draw |
A repaint cost yields empty layout damage, so the box tree, the layout, the fragment geometry and the shaped text are all still valid and are all kept. A geometry cost relays out the box, its descendants, and its ancestors up to the first whose size does not depend on it. Both rows are measured in the cost model.
The same choice appears in animation: a transition on opacity or transform does not enter
layout, while one on width does on every frame of it.
Damage is at most four disjoint rectangles, and adding a fifth merges the pair whose union
wastes the least area (MAX_DAMAGE, crates/zgui-bits/src/damage_set/mod.rs). A frame that changes
five unrelated places therefore redraws the bounding box of two of them. Changes that happen
together are cheaper when they are near each other.
Avoid intrinsic sizing you do not need
fit-content, min-content, max-content and an auto flex basis all ask the content how big it
wants to be, and for text that means shaping it. A fixed size, a percentage or a flex share asks
nothing.
For the content keywords, zgui keeps intrinsic answers across frames. A new layout pass does not
measure an unchanged fit-content box again. A content, style, scale, or gutter change invalidates
the affected answer. Avoid unnecessary intrinsic sizing because the first measurement and each
valid invalidation still lay out the complete subtree at min-content and max-content.
The case to watch is a grid. Track sizing measures every item at min-content and again at max-content, once per pass of an algorithm that runs several passes per axis, with an area estimate that moves between the passes. One item is asked ten questions of the same shape and different numbers. The framework holds sixteen complete size-only answers per box for this case.
The instrument is sizes_measured against sizes_held. A grid whose items are sized intrinsically
and whose measurements are missing shows up there first.
Know which ancestors composite their whole subtree
A stacking context is a subtree that is painted as one unit, at one place in its parent's paint order. An offscreen target is a texture that subtree is drawn into before it is placed. They are different lists, and only the second one costs a frame.
Establishing a stacking context is nearly free and changes the paint order. Anything that changes whether one is established owes a restack, a re-hit and a repaint, and moves the whole subtree in front of or behind content it used to sit beside. Layout has the list.
An offscreen target is the expensive one:
| On an ancestor | Target? |
|---|---|
opacity below 1, subtree ink that does not overlap itself, nothing blending below | no — the alpha is multiplied into each primitive's own paint |
opacity below 1, subtree ink that overlaps, or any blending descendant | yes |
opacity: 0 | no — the group contributes no pixel |
a two-dimensional transform, translate, rotate or scale | no — the draw call applies it |
perspective, transform-style: preserve-3d, backface-visibility: hidden | yes |
any filter or backdrop-filter | yes |
mix-blend-mode other than normal | yes |
isolation: isolate | yes |
a clip-path shape or box keyword | yes |
All of it is decided in crates/zgui-paint/src/emit/group.rs and
crates/zgui-paint/src/lower/. The counter is group_targets.
So a fade written as opacity on a panel whose children do not overlap costs no target, and the
same fade over overlapping content costs one. A filter: blur(…) on an ancestor costs one
unconditionally, on every frame that reaches it.
Long lists
Virtualise
Build the rows the scrollport can show, and let the container's own padding stand in for the rest.
const ROW: f32 = 24.0;
const OVERSCAN: usize = 4;
const SHEET: &str = css!(
".list__pane {
display: flex;
flex-direction: column;
padding-top: var(--lead, 0px);
padding-bottom: var(--trail, 0px);
}
.list__pane > * { height: 24px; flex: none }"
);
#[component]
fn Rows(rows: Signal<Vec<Todo>>) -> impl IntoView {
let offset = RwSignal::new(0.0_f32);
let port = RwSignal::new(0.0_f32);
let first = move || ((offset.get() / ROW) as usize).saturating_sub(OVERSCAN);
let count = move || (port.get() / ROW).ceil() as usize + OVERSCAN * 2;
let shown = move || {
let all = rows.get();
let start = first().min(all.len());
let end = (start + count()).min(all.len());
all[start..end].to_vec()
};
view! {
scroll(
class = "list",
on:scroll = move |ev| {
offset.set(ev.offset.y.0);
port.set(ev.scrollport.height.0);
}
) {
column(
class = "list__pane",
var:--lead = move || Some(format!("{}px", first() as f32 * ROW)),
var:--trail = move || {
let below = rows.get().len().saturating_sub(first() + count());
Some(format!("{}px", below as f32 * ROW))
}
) {
for item in move || shown(), key = |item: &Todo| item.id {
row(class = "todo") { text {{item.label.clone()}} }
}
}
}
}
}Three details in that sketch are deliberate.
- The space above and below is padding on the container, not two empty elements. A box that exists only to be empty is a box the style engine, layout and the painter each visit for nothing, twice a frame, for ever.
- The window slides, so the keys of the rows that stayed do not change. Moving down by one row destroys one row, builds one row, and leaves the rest exactly where they are. That is the recycle frame measured below.
- A virtualiser that has only a row count keys by the model index. That is an identity while the model does not move under the window, and stops being one the moment the model gains, loses or reorders rows. When the rows have identities of their own, use them.
The scrollport height arrives with the scroll event, so it is not known before the first scroll. Scrolling has the complete version — measuring the container, the glide and the scrollbars.
What it is worth
| Measurement | Value | Source |
|---|---|---|
| one translation frame, 10 000-row virtual list | 44.18 µs, with 0 restyles, 0 relayouts and 0 hit-index rebuilds | scroll.translation, docs/performance.md |
| one recycle frame — rows entering and leaving | 1021.42 µs | scroll.recycle, docs/performance.md |
| what one row shift actually does | 8 boxes rebuilt, 29 nodes relaid out, 404 primitives emitted | docs/performance.md |
| a wheel over 100 000 rows ÷ the same over 12 500 | 0.9991–1.0123, gated at ≤ 1.05 | LIST-virtualisation-wheel |
| fragments rebuilt per drawn glide frame | 7.52 | LIST-rebuilds-wheel |
| frames of a scroll that declared the whole surface damaged | 0, gated at ≤ 0 | LIST-full-frames |
The 8 and the 29 are the row that arrived. The 404 are the viewport, and they are what the rest of a recycle frame costs. Eight times the data changes the wheel cost by an amount indistinguishable from zero.
Without virtualisation the same shape is a cliff rather than a slope. Over a document with every row
mounted, a glide frame costs 172 ns per box at 17 507 boxes, 287 at 70 007, and 5 660 at
140 007 — about 792 ms a frame at twenty thousand rows
(unvirtualised-probe, docs/perf/reference-workloads.md). A rigid scroller crosses a 16.6 ms
budget at roughly 160 000 boxes (docs/perf/glide-split.md).
Carrying rows past a port is not free: one glide frame costs about nine tenths of repainting the
same realised rows from their styles (LIST-glide = 0.92). Virtualisation wins by making the number
of realised rows a property of the window instead of a property of the data.
Resize
A resize is the one interaction that genuinely invalidates everything, and there is nothing to keep. The cost model has the measured frame and the pacing table.
Do not debounce it yourself. Two mitigations are already in the frame loop. A configure arriving
less than one frame of the output after the last resize frame is recorded and answered at the merged
deadline, and a resize that repeats the extent runs no frame at all — the repository asserts the
second by name (a_resize_that_repeats_the_extent_runs_no_frame_at_all,
crates/zgui/tests/wall_clock.rs).
What is left for you is the size of the document and what a size change drags in with it.
| Lever | Why |
|---|---|
| Fewer boxes in the window | a resize costs about 1.00 µs per box (resize-slope advisory, docs/perf/reference-workloads.md). Virtualising a list is the largest single reduction available. |
Percentages and flex shares instead of vw and vh | a viewport unit resolves at computed-value time, so every size change makes the frame scan every element to find the ones that read one (crates/zgui-style/src/device/viewport.rs). A document with none marks nothing. |
| Few media-query boundaries, far apart | a resize that crosses no boundary disturbs no rule at all. One that crosses a boundary re-collects that origin's rules and restyles every element. |
| No intrinsic sizing you do not need | the first measurement and valid invalidations are nested subtree layouts; unchanged intrinsic answers are reused during resize. |
A scale-factor change is worse than a resize and you cannot avoid it: it throws away every box's layout and misses both caches keyed on device size. Do not treat a display change as a resize you can smooth over.
Finding out which one is your problem
Do this in order. Each step is cheaper than the one after it.
Count the frames. Install a FrameProbe and see whether frames are being drawn at all. A still
document must draw zero. A window that draws when nothing happened is a different bug from a
window whose frames are expensive.
Read the counters. They say what a frame did, in numbers rather than durations, so an answer survives being measured on a slow machine.
Set ZGUI_FULL_DAMAGE=1 and restart. If the artefact goes away, it is a damage-tracking
problem and not a drawing one. This is the first thing to try when something looks wrong on screen.
Set ZGUI_LATENCY=/tmp/trace.jsonl and reproduce the interaction. The desktop backend starts
the recording for you, and the frame loop marks every stage. What is recorded is instants, so
every gap between two consecutive marks is visible whether or not anyone named the thing that filled
it.
Open the inspector with F12 in a program that has one wired in, to see what one element computed to and why it is the size it is.
The probe
use std::cell::Cell;
use std::rc::Rc;
use zgui::runtime::{FrameProbe, Window};
#[derive(Default)]
struct Frames(Cell<u64>);
impl FrameProbe for Frames {
fn frame_ended(&self, window: &Window) {
self.0.set(self.0.get() + 1);
// The scene, the damage, the layout and the caches this frame left behind.
let _ = (window.scene(), window.damage());
}
}
fn main() -> Result<(), zgui::Error> {
let probe: Rc<dyn FrameProbe> = Rc::new(Frames::default());
app().with_probe(probe).run(|| view! { Shell() })
}The probe is called once at the end of every frame and handed the window as it stands. It takes
&self, so anything it keeps goes through a cell or a signal. One seam, one occupant: installing a
second replaces the first.
The counters
Add the crate that owns them. The framework already compiles them in — zgui-runtime names the
feature — but the umbrella does not re-export them, so an application that wants to read them names
the crate itself.
[dependencies]
zgui-profile = { path = "../zgui/crates/zgui-profile", features = ["counters"] }use zgui_profile::{COUNTERS_ENABLED, Counter, Counters, counter};
assert!(COUNTERS_ENABLED, "this build has no counters compiled in");
let before: Counters = counter::snapshot();
// … the interaction …
let after = counter::snapshot();
let frame = before.delta(&after);
println!("{frame:?}"); // only the non-zero fields
println!("{}", frame.get(Counter::ElementsRestyled));Counters accumulate until counter::reset() is called; nothing resets them per frame. Take two
snapshots and subtract.
| The question | The counter | What a healthy answer looks like |
|---|---|---|
| Did one change restyle more than one thing? | elements_restyled | the elements you actually changed |
| Is a stage walking the whole document? | nodes_visited | this is the counter that notices a traversal touching six thousand clean nodes to service one |
| Was the box tree rebuilt? | boxes_rebuilt | zero for a colour, a class or a hover |
| Did layout run at all? | layouts_held against nodes_relaid_out | held, for any frame that moved no box |
| Did layout start from the root? | layout_reached_root | rarely; a resize, a scale change, a first frame |
| Are intrinsic measurements missing? | sizes_held against sizes_measured | mostly held once the document has settled |
| Is text being reshaped? | text_shaped, text_rebroken | shaping is more expensive than breaking |
| Are glyphs being rasterised again? | glyphs_rasterised against glyphs_placed | zero rasterised on a repaint of unchanged text |
| Is the display list being replayed? | chunks_translated against chunks_reencoded | translated, for anything that only moved |
| Is anything being culled? | primitives_culled against primitives_emitted | over the hover-storm scenario, 96 % of primitives considered are culled |
| Are ancestors forcing offscreen targets? | group_targets | one per genuinely isolated subtree, and no more |
| Is the hit index being rebuilt? | hit_index_rebuilds | zero while scrolling |
| Is the loop spinning? | wakes | one per thing that asked for one |
Read the pairs as pairs. A counter of avoided work reads zero when everything is broken and zero when everything is perfect, so it means nothing without the counter of work done beside it.
The checklist
- Every list has a key, and no key is an index into the rendered list.
- Row state lives in the row, not in the collection signal.
- "Which one is selected" is a
Selector, not a signal every row reads. - Holes read a store field or a
Fieldhandle, never the store root. - A two-way branch is
if; many branches areDynamic; a value is a closure hole. - Memos sit where several readers share one answer, and nowhere else.
- A class that changes often is on the element it affects, not on an ancestor every rule sits under.
- A theme is custom properties on a container, not a sheet swap.
- Where the design accepts either, the change is a colour and not a size.
- Intrinsic sizes are used where they are needed and not by default, especially inside a grid.
- No ancestor carries a filter, a blend mode or an isolation it does not need.
- Any list that can hold thousands of rows is virtualised.
- No
vworvhwhere a percentage or a flex share would do. - The application has a
FrameProbe, and you have read its counters at least once.
Next
How performance is measured
The band, budget and ratchet system, the five scenarios, the standing gates, the reference workloads, and the counters an application can read itself.
The component library
The four optional crates that ship beside zgui, how they relate, what an application gives up by leaving them out, and the whole component inventory.