Layout
Day measures and positions the UI through a shared layout engine. Native toolkits still measure text and controls; Day uses those measurements to calculate frames and place widgets in their containers. This gives the app a consistent layout API while respecting native control sizes.
Arrange controls
Use column for vertical content and row for horizontal content. Their .spacing() sets
the gap between children; .padding() adds space around a group.
column((
label("Account").font(Font::Title),
row((label("Notifications"), spacer(), toggle(enabled))),
))
.spacing(12.0)
.padding(16.0)
Here, enabled is a Signal<bool>. The spacer takes the row’s remaining horizontal space,
leaving the label and toggle at opposite ends. Use scroll(content) when the content can be
taller than its window.
Containers fit their content by default. Add .grow() when a pane should take the available
space in its parent’s direction. Use a fixed .frame() only when the design calls for a fixed
size; text needs room to wrap and to grow in translation.
The modifier vocabulary
Day’s layout modifiers are few and compose left to right:
label("Total")
.padding(Insets::symmetric(12.0, 6.0)) // or .padding(8.0) for all edges
.frame(200.0, 44.0) // fixed size (or .width / .height for one axis)
.grow() // take flexible space in the parent's axis
column((a, b, c)).spacing(8.0).align(HAlign::Leading)
row((x, spacer(), y)) // spacer pushes x and y apart
zstack((photo, badge)).align(Alignment::TopTrailing)
scroll(long_column)
padding, frame, and friends are layout-only wrapper nodes: they exist in Day’s tree but
create no native widget, so nesting them is cheap.
Parent proposes, child chooses
Day uses the SwiftUI-style negotiation protocol. A parent offers a child a proposal (an optional width and optional height), and the child answers with the size it wants:
pub struct Proposal { pub width: Option<f64>, pub height: Option<f64> }
pub trait Layout {
fn measure(&self, cx: &mut dyn LayoutOps, children: &[RNode], p: Proposal) -> Size;
fn place(&self, cx: &mut dyn LayoutOps, children: &[RNode], bounds: Rect);
}
The two-phase pass looks like this for a simple form row:
measure place
─────── ─────
row gets Proposal { w: 400, h: None } row gets Rect { 0,0 400×32 }
├─ measures label → 88×20 ├─ places label at (0,6) 88×20
├─ measures spacer → flexible ├─ (spacer takes the slack)
└─ measures toggle → 52×32 └─ places toggle at (348,0) 52×32
row answers 400×32
Containers like row and column measure rigid children first, then divide the remaining space
among flexible ones (spacer, anything marked .grow()). A child is never forced: if you propose
100 points to a label that needs 120, it answers 120, and the parent decides what to do about the
overflow (usually: let scroll handle it).
The Layout trait is public and has no private privileges; column is implemented with the
same trait a custom masonry or flow container would use.
Containers don’t stretch children by default. A
column is as wide as its widest child; a pane you want to fill available space needs .grow().
Forgetting this shows up as a view collapsing to its content size, or to nothing when it has no
content.
Native measurement, especially text
Leaf Pieces answer measure by asking the real widget. This matters most for text, which is
height-for-width: propose a width, and the toolkit’s text engine (Core Text, Pango,
minikin, QFontMetrics) reports the wrapped height. Day never guesses at glyph metrics, so a
label wraps exactly where the platform would wrap it, in every script and locale.
The cost is that measurement is a real call into the toolkit, and on Android it’s a JNI round-trip. Negotiation multiplies these probes, so Day carries a measure cache per node, keyed by the quantized proposal, and bounds the distinct proposals a parent may probe per child per pass. The design assumes the cache: the mock toolkit’s tests assert measure-call counts, so a caching regression fails tests.
Incremental relayout
When a binding changes something size-affecting (a label’s text grows, a font changes), the node
is marked dirty and the change propagates to the nearest layout boundary: a node whose size is
externally fixed, like the window root, a scroll, or a node with an explicit two-axis
.frame(w, h). At the turn boundary, layout re-enters there, not at the root:
window root (boundary)
└─ column
├─ header unchanged: answers from measure cache
└─ scroll (boundary) ◄──── relayout re-enters here
└─ column
├─ row re-measured: contains the dirty label
│ └─ label* ← text changed
└─ row unchanged: pruned (same proposal, size, origin)
Frames are diffed with a half-pixel epsilon before touching the toolkit, so a text change that
doesn’t move anything costs one native set_text and zero frame updates.
Relayout needs boundaries because, inside a negotiated stack, one child’s new size changes its siblings’ proposals, so pruning is only safe from a node whose own proposal is stable. Mock-toolkit golden tests pin this behavior down.
Windows, safe areas, and direction
- Window sizing: the minimum window size is the one the app declares
(
WindowOptions::min_size, anOption<Size>that defaults to none), applied verbatim; Day doesn’t derive a minimum from content measurement. The window relayouts on native resize and never shrinks on its own. - Safe areas and keyboards (mobile): the root applies safe-area insets as padding by default;
a root-level
scrollconverts them to content insets and slides the focused field above the keyboard. Backends with an edge-to-edge mode (Android’s immersive opt-in) stop clamping the top and report the insets throughday::safe_area()instead. Paint a background unpadded and pad the content by those insets to run it under the system bars. - Right-to-left: since Day owns placement, RTL is a single x-mirror applied at place time.
Layoutimplementations are written using leading/trailing coordinates, and the backends set the native per-view direction so text, cursors, and assistive technology agree with the mirrored layout.
Tradeoffs
Owning layout gives Day the same negotiation on every platform, testable on the mock toolkit without a display, and it is why per-locale reflow and RTL are features of the framework that every backend shares. It also has costs:
- Native layout idioms don’t apply: Auto Layout constraints, Compose modifiers, and GTK size groups have no effect inside a Day window.
- Measurement crosses the FFI. The cache keeps this off the hot path, but a pathological
layout (thousands of unique text leaves invalidating at once) pays real per-leaf costs,
especially over JNI. The native
listexists so that long scrolling content doesn’t become that case. - Deep negotiation is O(children) per level. SwiftUI has the same cost; it is rarely a
problem, but it becomes visible when you build a custom
Layout.
Next: Styling, what you can restyle and what stays native.