Tutorial: A composite piece (no native code)

Most Day widgets you build won’t need a single line of platform code. They are composite pieces: new widgets assembled from primitives Day already ships. A composite piece is pure Rust, with no cargo features, no build.rs, and no per-toolkit source files. You add it to an app as a plain dependency and it runs on every Day target, because every leaf it composes is already a native control on each one.

In this tutorial you will build one end to end: a star rating control, a row of tappable stars bound to a Signal<usize>. By the end you will have a day-piece-rating crate you can .max(5), .star_size(32.0), and drop next to a label, just like a built-in.

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. But a star rating is just 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 there is nothing left for a backend to do: the AppKit build draws the star with Core Graphics, the Android build with android.graphics.Canvas, and the GTK build with Cairo. You wrote none of that.

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.

Every one of those is pure composition, with no per-backend work, and they are what the rest of this tutorial is built from.

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) so you never assemble the boilerplate by hand:

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. 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: no toolkit crates, no feature table.

# day-piece-rating/Cargo.toml
[package]
name = "day-piece-rating"
version = "0.1.0"
edition = "2021"

[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.

In the Day workspace these are { workspace = true } instead of a version. Either way, contrast this with a native piece’s Cargo.toml, which carries a [features] block (one feature per backend) and often a build.rs. See the native-piece tutorial.

Now src/lib.rs. Everything comes from the pieces prelude; we additionally name RNode 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 sensible 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.

/// A star-rating control bound to `value` (the number of filled stars).
pub struct Rating {
    value: Signal<usize>,
    max: usize,
    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: Color::hex(0xF5A623),
    }
}

impl Rating {
    /// How many stars to show (default 5).
    pub fn max(mut self, n: usize) -> Self {
        self.max = n;
        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
    }
}

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. We never touch a backend.

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).map(|i| star(i, value, star_size, editable, color)).collect();
        row(PieceVec(stars)).spacing(4.0).build(cx)
    }
}

That is the entire piece. Note the reactivity model: 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), with no tree diff and no re-execution of build. This is Day’s build-once reactive model, and you got it without writing a binding by hand: canvas’s tracked read wired it for you.

5. Use it in an app

The app depends on day-piece-rating like any crate. There is no feature to enable, no platform block, and no conditional compilation:

# 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() -> AnyPiece {
    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)
    .any()
}

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 own 2D API. You wrote zero platform code.

6. Going further

The star rating is one instance of a general pattern: build widgets by composing primitives, and let the native leaves do the platform work. The same idea 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)).

  • A badge is .overlay_aligned(Alignment::TopTrailing, dot), an annotation layered on top of an avatar or icon without disturbing its layout size.

  • A chip is a labelled .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>(), with no prop drilling and no backend code.

Every one of these is composite: pure Rust with no cargo features, working on every target the day you publish it. Reach for the native-piece tutorial only when you need a native control Day does not already wrap.