How rendering works
Day’s renderer connects the piece tree to native widgets. This page follows widget creation, event handling, and signal updates through that process. It complements the architecture guide and is intended for developers working on the framework.
The realized tree
day-core keeps one arena-allocated tree per window, the realized tree. Each node records:
NodeData
├─ kind "day.label" | "day.button" | "day.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("day.button", &ButtonProps { title: "Save" })
│
├─ insert node into the arena, under the current parent
├─ toolkit.realize("day.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
The index computation is subtle because 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.
Event delivery
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. Every backend’s sink
only enqueues; user code runs after the native callback returns, which makes re-entrancy
problems (a handler mutating the tree mid-native-dispatch) 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) take extra care: while the field is focused, the native widget holds the authoritative value, writes are origin-tagged so a signal update echoing back doesn’t overwrite what the user is typing, and programmatic writes during IME composition are deferred until composition ends. The framework’s controlled-input path handles all of this, so your code never sees 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)
Each turn runs at most one layout pass and groups native mutations where the toolkit wants them. There is no per-frame tick, so an idle Day app runs no code.
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
CI runs those assertions as golden tests over the op log, so a click that causes two native
mutations, or an unbounded measure count, fails the build. Your own component tests run the same
way, in an ordinary cargo test process that takes 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 scope owns the
reactive machinery, the tree owns the handles, and both are torn down
together at the turn boundary.
The reference section documents each subsystem in more depth.