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 pieceComposite piece
What it wrapsa new native control (NSComboBox, WKWebView, …)existing Day pieces
Per-toolkit codeone renderer per backend (Obj-C, C++, Java…)none
Cargo featuresappkit / gtk / qt / uikit / mdc / xamlnone
Extra build assetsbuild.rs, shims, Gradle/SwiftPM entriesnone
Referencethe native-piece tutorial · day-piece-searchfieldthis 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, ...) with Alignment: 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| …) with Shape: a native drawing surface for anything custom.
  • the Modifier trait + .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 Modifier via the blanket impl: my_content.modifier(|c: AnyPiece| c.padding(16.0).corner_radius(12.0)). The shipped day-piece-rating crate includes a ready-made Card of 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-rating ships one as badge(count, over).

  • A chip is a labeled .background(...).corner_radius(...) capsule; a pill button is a ButtonStyle (FilledButtonStyle is the shipped example) applied with Button::style.

  • Themed subtrees flow through with_environment(value, || …) and are read back with environment::<T>(), so a value set once reaches every descendant without being passed through each builder.