Tutorial: A composite piece (no native code)
A composite piece combines existing Day pieces into a reusable control. It is a Rust library
crate and needs no additional native backend. This tutorial builds a star-rating control bound to
a Signal<usize>, with configurable star count and size.
1. What a composite piece is (and why it needs no backend code)
Day has two kinds of pieces:
| Native piece | Composite piece | |
|---|---|---|
| What it wraps | a new native control (NSComboBox, WKWebView, …) | existing Day pieces |
| Per-toolkit code | one renderer per backend (Obj-C, C++, Java…) | none |
| Cargo features | appkit / gtk / qt / uikit / mdc / xaml | none |
| Extra build assets | build.rs, shims, Gradle/SwiftPM entries | none |
| Reference | the native-piece tutorial · day-piece-searchfield | this tutorial · day-piece-rating |
A native piece exists to introduce a native widget Day does not already have. A star rating is a
row of small drawings that react to taps, and Day already gives you row, canvas (a native 2D
surface on every platform), Shape, Signal, and .on_tap. Compose those and the existing leaves
do the platform work: the AppKit build draws the star with Core Graphics, the Android build with
android.graphics.Canvas, and the GTK build with Cairo, all from the one canvas closure.
Most widgets should be composite. Reach for a native piece only when you need a control the toolkits provide and Day does not yet wrap.
The composition toolkit lives in the prelude:
column/row/zstack: stack children (vertical, horizontal, layered)..overlay(...)/.overlay_aligned(align, ...)withAlignment: draw an annotation on top without changing layout size (badges, corner dots)..background(color)/.corner_radius(r)/.padding(...)/.frame(w, h): surface + inset.canvas(|d, size| …)withShape: a native drawing surface for anything custom.- the
Modifiertrait +.modifier(m): a reusable, by-value view transform (a card, a chip). ButtonStyle/FilledButtonStyle+Button::style: a pluggable button appearance.with_environment(...)/environment::<T>(): pass ambient values down a subtree.
2. Scaffold the crate
Start with the scaffolder. day new piece generates a ready-to-build crate (Cargo.toml,
.gitignore, README.md, and a sample src/lib.rs) and a demo/ app that shows it:
day new piece day-piece-rating # no --toolkits ⇒ a composite piece
The generated crate builds immediately (cargo build) and depends on a remote Day release, so it
works as a standalone repo outside the Day workspace. Pass --id dev.acme.rating to set the
reverse-DNS id (defaults to dev.example.<name>), or --local <path-to-day-checkout> if you are
developing against a local Day clone rather than the published crates. --composite forces a
composite piece even when --toolkits is present, and --no-input skips the interactive prompts
(scripts and CI). From demo/, day launch -p <target> --script dayscript/demo.yaml runs a
one-page app that shows the piece on any target, and --no-demo skips it. The rest of this
tutorial walks through what the scaffolder emits and how to flesh it out.
A composite piece is an ordinary library crate. It depends on three Day crates and nothing platform-specific.
# day-piece-rating/Cargo.toml
[package]
name = "day-piece-rating"
version = "0.1.0"
edition = "2024"
[dependencies]
# The framework crates come from git until they're on crates.io (`day new piece` writes this).
day-pieces = { git = "https://github.com/daybrite/day.git" } # primitives + prelude (row, canvas, Decorate, …)
day-core = { git = "https://github.com/daybrite/day.git" } # Piece / BuildCx / RNode / AnyPiece
day-reactive = { git = "https://github.com/daybrite/day.git" } # Signal (also re-exported through day-pieces' prelude)
# Note what is not here: no [features], no dep:day-appkit / day-gtk / day-android,
# no build.rs. A composed piece needs no per-toolkit code at all.
# The scaffolder appends this so the crate is its own cargo workspace and builds standalone.
[workspace]
Now src/lib.rs. Everything comes from the pieces prelude; RNode also comes from day-core,
because it is the return type of Piece::build:
use day_core::RNode;
use day_pieces::prelude::*;
3. Design the builder
Day pieces follow a config-struct + chainable-setter pattern (the same shape as slider(...),
button(...), or the combo_box piece). A free function creates the piece with defaults; methods
return Self so calls chain. The two-way value is a Signal passed in by the caller: the control
reads it to draw and writes it back on tap.
/// The default filled-star tint: a warm gold/amber.
const GOLD: Color = Color::rgb(1.0, 0.72, 0.0);
/// A star-rating control bound to `value` (the number of filled stars).
pub struct Rating {
value: Signal<usize>,
max: u32,
star_size: f64,
editable: bool,
color: Color,
}
/// Create a rating bound to `value`. Defaults: 5 stars, 28pt, tappable, gold.
pub fn rating(value: Signal<usize>) -> Rating {
Rating {
value,
max: 5,
star_size: 28.0,
editable: true,
color: GOLD,
}
}
impl Rating {
/// How many stars to show (clamped to at least 1; default 5).
pub fn max(mut self, n: u32) -> Self {
self.max = n.max(1);
self
}
/// Edge length of each star, in points (default 28).
pub fn star_size(mut self, pt: f64) -> Self {
self.star_size = pt;
self
}
/// Whether taps change the value (default `true`; pass `false` for a read-only display).
pub fn editable(mut self, yes: bool) -> Self {
self.editable = yes;
self
}
/// The star tint (default gold).
pub fn color(mut self, c: Color) -> Self {
self.color = c;
self
}
}
The shipped crate also has an inherent .id(prefix) setter. A rating is several tappable nodes
rather than one, so a plain Decorate::id would tag only the row and leave the stars
unaddressable; .id(prefix) sets the row’s id to prefix and each star’s to prefix:N
(1-based), so a dayscript walkthrough can tap a specific star.
4. Compose the body
A piece becomes usable by implementing the Piece trait: a single build method that returns the
node it created. Once Rating implements Piece, it automatically gains .id(…), .padding(…),
.frame(…), .any() and the rest, from the blanket Decorate impl.
The star, as a Shape::Polygon
A five-pointed star is ten points on two alternating radii: a tip on the outer radius, a valley on the inner one. This helper computes them, centered in whatever size layout hands the canvas:
/// Vertices of an `points`-pointed star centered in `size`, first tip pointing up.
fn star_points(size: Size, points: usize, outer: f64, inner: f64) -> Vec<Point> {
let cx = size.width / 2.0;
let cy = size.height / 2.0;
let step = std::f64::consts::PI / points as f64; // half-sector: tip → valley
let mut angle = -std::f64::consts::FRAC_PI_2; // start at the top
let mut out = Vec::with_capacity(points * 2);
for i in 0..points * 2 {
let r = if i % 2 == 0 { outer } else { inner };
out.push(Point::new(cx + r * angle.cos(), cy + r * angle.sin()));
angle += step;
}
out
}
One star = one reactive canvas
Each star is a fixed-size canvas that draws the polygon: filled if its index is within the
current value, outlined otherwise. The draw closure reads value.get(), a tracked read, so
the canvas re-records exactly when the signal changes. When editable, an .on_tap writes this
star’s 1-based position back into the signal.
fn star(i: usize, value: Signal<usize>, size_pt: f64, editable: bool, color: Color) -> AnyPiece {
let star = canvas(move |d, size| {
let radius = size.width.min(size.height) / 2.0 - 1.0; // 1pt margin for the stroke
let shape = Shape::Polygon(star_points(size, 5, radius, radius * 0.42));
if i < value.get() {
d.fill(shape, color); // selected → solid
} else {
d.stroke(shape, color, 1.5); // empty → outline
}
})
.frame(size_pt, size_pt);
if editable {
star.on_tap(move || value.set(i + 1))
} else {
star
}
}
The body: a row of stars
build lays the stars in a row. Because the count is dynamic, collect them into a PieceVec
(the runtime-heterogeneous child sequence) and hand that to row:
impl Piece for Rating {
fn build(self, cx: &mut BuildCx) -> RNode {
let Rating { value, max, star_size, editable, color } = self;
let stars: Vec<AnyPiece> =
(0..max as usize).map(|i| star(i, value, star_size, editable, color)).collect();
row(PieceVec(stars)).spacing(4.0).build(cx)
}
}
That completes the piece. The row and its max canvases are built once. Tapping the third star
calls value.set(3); only the star canvases that read value re-record (the first three fill, the
last two outline), and build never runs again. This is Day’s build-once reactive model; the
tracked read in the canvas closure is the only binding.
5. Use it in an app
The app depends on day-piece-rating like any crate, with one line in Cargo.toml:
# the app's Cargo.toml (the framework crates come from git until they're on crates.io —
# `day new app` writes this form for you)
[dependencies]
day = { git = "https://github.com/daybrite/day.git" }
day-piece-rating = { path = "../day-piece-rating" } # a plain dependency, nothing else to wire
Then use it like a built-in piece, bound to your own Signal:
use day::prelude::*;
use day_piece_rating::rating;
fn review_form() -> impl Piece {
let stars = Signal::new(3usize);
column((
label("How was it?").font(Font::Title),
rating(stars).max(5).star_size(32.0),
// reacts live: reads `stars`, re-renders only this label when a star is tapped
label(move || format!("{} / 5", stars.get())),
))
.spacing(12.0)
.padding(20.0)
}
Run day launch -p macos-appkit, then -p android-mdc, then -p linux-gtk. The same rating
renders natively on each, drawn by that platform’s 2D API from the one canvas closure.
6. Going further
The same approach covers most of a design system:
-
A card is a
Modifier, a reusable transform you apply with.modifier(m):pub struct Card { pub tint: Color } impl Modifier for Card { fn apply(self, content: AnyPiece) -> AnyPiece { content.padding(16.0).background(self.tint).corner_radius(12.0) } } // usage: my_content.modifier(Card { tint: Color::hex(0xF2F2F7) })For a one-off you do not even need the type; a plain closure is a
Modifiervia the blanket impl:my_content.modifier(|c: AnyPiece| c.padding(16.0).corner_radius(12.0)). The shippedday-piece-ratingcrate includes a ready-madeCardof exactly this shape. -
A badge is
.overlay_aligned(Alignment::TopTrailing, dot), an annotation layered on top of an avatar or icon without disturbing its layout size.day-piece-ratingships one asbadge(count, over). -
A chip is a labeled
.background(...).corner_radius(...)capsule; a pill button is aButtonStyle(FilledButtonStyleis the shipped example) applied withButton::style. -
Themed subtrees flow through
with_environment(value, || …)and are read back withenvironment::<T>(), so a value set once reaches every descendant without being passed through each builder.