The frame
What to call each frame, in what order, and why the order is fixed.
Carte does not own a window or a loop. You call it from yours. A frame has five steps, and their order is part of the contract.
Each frame
// 1. Draw what the path says is visible, from committed state only.
for id in router.visible_views() {
match id {
"home" => { /* draw from store.read::<Shared>(Scope::Process) */ }
_ => {}
}
}
// 2. Input becomes actions. Escape goes through the router.
if escape_pressed {
let _outcome = router.escape(model, outbox);
}
// 3. Tick: drain the outbox, apply actions, run producers, commit.
let notice = store.tick(outbox, |actions, barrier| {
for a in actions {
model.apply(a);
}
producers.produce(model, barrier);
});
// 4. Re-derive the path from the model and the committed store.
let moved = router.resolve(model, store);
// 5. If the path moved, tick once more so new scopes are filled
// before anything draws them.
if moved {
store.tick(outbox, |actions, barrier| { /* same as step 3 */ });
}
Before the first frame, run steps 3 to 5 once, so the path and the store are in place for the first draw. The Quick start wraps steps 3 to 5 in a settle function and calls it from new.
Router::update(cx, out, dt) ticks every node on the path, outermost first, if your nodes do per-frame work. Call it before step 3, so anything it pushes lands in this frame's tick.
What each step may touch
| Step | Reads | Writes |
|---|---|---|
| Draw | Committed store, the router's path | The outbox |
| Input | The model (&) | The outbox |
| Tick | Drained actions, working store | The model, store fields through producers |
| Resolve | The model, committed store | The path, node lifecycle, scope mounts |
The draw step and the input step can only ask. They push actions. Nothing reaches into the model or the store while the frame is being drawn.
Why the order is pinned
Inside Store::tick the order is fixed:
outbox drain → producer sets → commit → notify
The signature enforces it. The outbox is drained before your closure runs, and the closure receives the actions. A field can only be set through a Writer, a Writer only comes from the Barrier, and the Barrier only exists inside that closure. So there is no way to write to the store outside the tick, and no way for a producer to run before the actions are applied.
Three things follow.
No torn reads. The Barrier borrows the store mutably for its whole life, so no view can call store.read while producers are running. A view never sees field A from this frame and field B from the last one. This is not checked at runtime. The borrow checker makes it impossible to write.
A click costs zero frames. An action pushed during draw is applied in the same frame's tick, before producers run, so its effect is in this frame's commit and the next draw shows it.
Ten sets are one notice. Commit happens once, on the way out of tick, and returns one Notice. A subscriber watching ten moved fields appears in it once. An idle frame's notice is empty, because sets that change nothing are dropped.
Why resolve comes after the tick
The walk reads committed state. Each node's resolve_child reads the model, and resolve_child_in and every guard read the store's last commit. Running resolve after the tick means the path reflects what this frame asked for.
When the path changes, resolve runs in this order:
- The deepest departing node's
departis asked how it leaves. leaveruns on each departing node, deepest first.- Departing scopes are dropped.
- Arriving scopes are mounted at their declared starting values.
enterruns on each arriving node, outermost first.
A node's leave can still read the facts it stood on. A node's enter finds its scope already standing.
Why tick twice when the path moves
A scope that mounts in step 4 starts at its declared starting value, not at what your producers would write. If you drew it straight away, the editor would flash empty for one frame. The second tick runs producers over the newly mounted scope before the next draw. It costs one extra commit on frames where the path changed, and nothing on the rest.
Using the notice
let watcher = store.subscriber();
store.subscribe(watcher, EDITOR, "dirty")?;
// after a tick:
if notice.woke(watcher) {
// re-render whatever reads `dirty`
}
for c in notice.changed() {
println!("{}.{}", c.scope, c.field);
}
The store never calls you back. A callback would run inside the commit, which is exactly when nothing should be looking. The tick hands back who to wake, and your binding does the waking.
Next
- Nodes and the walk: what
resolveasks each node. - Producers and the tick: writing fields inside the barrier.