How rendering works
Architecture covered the structure; this page follows a widget through the
running system. It traces a
widget from build to pixels, a click from the native event to your closure, and a signal write
back out to the screen. None of this is required reading to use Day, but once you can picture
the mechanism, the framework’s behavior is predictable.
The realized tree
day-core keeps one arena-allocated tree per window, the realized tree. Each node records:
NodeData
├─ kind "label" | "button" | "column" | …
├─ handle Option<toolkit handle> ← None for layout-only nodes
├─ parent/children tree links
├─ layout the node's Layout impl + flex facts (grow, spacer, group)
├─ scope reactive Scope owning this node's bindings & handlers
├─ id, a11y stable identifier + accessibility props
└─ measure cache proposal → size, plus a needs_measure flag
The handle is whatever the backend wants it to be: a retained NSView pointer on AppKit, a
JNI GlobalRef on Android, an opaque C++ pointer on Qt. day-core never looks inside it; it
only hands it back to the toolkit.
The tree has no shadow copy for diffing; it is the only representation of the UI that Day keeps.
Mounting
When a Piece’s build runs, the node is created, and (for native kinds) the backend is asked
to realize it:
cx.leaf("button", &ButtonProps { title: "Save" })
│
├─ insert node into the arena, under the current parent
├─ toolkit.realize("button", props, node_id) → native NSButton, returns Handle
├─ compute native insertion index (skipping layout-only ancestors:
│ a column has no native counterpart,
│ so its children flatten into the
│ nearest real native container)
├─ toolkit.insert(parent_handle, handle, index)
└─ mark ancestors needs_measure
That index computation is the subtle part: Day’s tree has structure (columns, padding wrappers)
that the native view hierarchy doesn’t, so native children of a container are the flattened
in-order native descendants. Keeping those indices right during each reorders is one of the
jobs the mock-toolkit golden tests pin down.
Updates are patches
Nothing ever re-builds a mounted widget. Changes arrive as patches, small enums per kind
(LabelPatch::Text(String), SliderPatch::Value(f64)), produced by the bindings that
reactivity re-runs:
signal write ─► binding re-runs ─► eq-gate ─► tree.patch(node, patch, affects_size)
│
├─ toolkit.update(handle, patch) ← one native setter
└─ if affects_size:
mark needs_measure, bubble to boundary
The affects_size flag is decided by the piece author: a text change might, a color change
doesn’t. Size-affecting patches queue incremental relayout
(how that works); everything else is done after one native
call.
Events flow in through a trampoline
Backends register native callbacks once per widget and translate them to a uniform
(NodeId, Event) stream. On AppKit, for instance, a Rust-defined Objective-C class holds the
node id and is set as the widget’s target; its action method classifies the sender (switch →
ToggleChanged, slider → ValueChanged, button → Pressed) and emits. The contract for every
backend’s sink is enqueue only: no user code runs inside the native callback, which keeps
re-entrancy problems (a handler mutating the tree mid-native-dispatch) structurally impossible.
user clicks NSButton
└─ [DayTarget action:] ─► emit(node 17, Pressed) ─► event queue
(native callback returns)
next: day-core drains the queue as a fresh batch
└─ handler registered via cx.on(node 17, …) runs your closure
└─ count.update(|c| *c += 1) … and we're in the reactivity story
Two-way controls (text fields especially) are more careful than they look: the native widget is the source of truth while focused, writes are origin-tagged so a signal update echoing back doesn’t clobber what the user is typing, and programmatic writes during IME composition are deferred until composition ends. This is the kind of edge you get to not think about because the framework’s controlled-input path owns it.
The turn
Everything above is sequenced by the turn, Day’s unit of “handle things, then settle”:
native event(s)
1. handlers run, signal writes batch
2. reactive drain to fixpoint (bindings re-run, patches applied)
3. one posted main-loop callback:
incremental layout for dirty boundaries
set_frame for frames that actually changed (½-pixel epsilon)
release queue drains (widgets disposed this turn are freed)
One turn, at most one layout pass, and native mutations grouped where the toolkit wants them. There is no per-frame tick: an idle Day app runs no code, which is the runtime-profile claim in concrete form.
Drawing: canvas as a display list
canvas(|d, size| …) doesn’t hand you a native graphics context; the closure records into a
Vec<DrawOp> (fill/stroke shape, text run, transform, clip…), and the backend replays the ops
through the platform’s 2D API: Core Graphics, android.graphics.Canvas, cairo, QPainter.
The closure is itself a binding, so a signal it reads re-records and replays just that node; the
op list’s PartialEq is the equality gate, so an identical recording skips the replay entirely.
One FFI hop carries the whole buffer, which matters on JNI. And because text ops go through the
toolkit’s text engine, canvas text gets native fonts, shaping, and bidi; Day still isn’t
rasterizing anything itself.
The mock toolkit
day-mock implements the full Toolkit trait with no display: handles are plain structs,
measurement is deterministic (fixed metrics per kind), and every call appends to an op log.
Tests mount real Pieces against it inside ordinary cargo test; this is condensed from Day’s
own test suite:
let (mock, probe) = MockToolkit::new();
day_core::launch_with(mock, options, || counter_ui());
probe.clear_log();
probe.emit(button_node, Event::Pressed); // synthesize the click
let muts = probe.mutations();
assert_eq!(muts.len(), 1); // ONE native mutation for the click
assert!(muts[0].contains("update day.label"));
assert!(probe.measure_calls() <= 6); // relayout stayed on the label's path
Those assertions are the interesting part: the framework’s core promises (one click, one native mutation; bounded measure calls per layout pass) are written down as golden tests over the op log, so the fine-grained guarantee is checked in CI. Your own component tests run the same way: no simulator, no display server, milliseconds per test.
Teardown
When structure changes (when flips, an each row leaves), the subtree’s scope is disposed
(bindings and handlers die with it), and the nodes go onto a release queue drained at the turn
boundary, where the backend frees the native widgets (with toolkit-appropriate deferral, like
Qt’s deleteLater). A signal write racing a disposed binding is a checked no-op. The ownership
story is short enough to state completely: the scope owns the reactive machinery, the tree owns
the handles, and both are torn down together, once, at a safe point.
The tree is built once and patched in place, one turn at a time. The reference section documents each subsystem in more depth.