Shapes: design & implementation
Status: implemented (Proposal A, the canvas-backed unified
shapepiece). Theshapepiece (day_pieces), reactiveShapeKind/fill/stroke/inset/rotate/scale/offset, canvas CTM transform ops (Save/Restore/Concat), and.on_tap/.on_draggestures ship on all five backends (AppKit, GTK, Qt, UIKit, Android) and are demonstrated by the showcase “Shapes” playground. ArkUI also delivers.on_tap(NODE_ON_CLICK) and.on_drag(a native pan recognizer on the shared kind-11 gesture wire); long-press is still unwired there. Since then:ShapeKind::Line/ShapeKind::Polygon(unit-point geometry over the already-replayedShape::Line/Shape::Polygonops, §3.1), fractional placement with.at(fx, fy, fw, fh), and theshape_group/shape_group_fncomposites that flatten many shapes into one canvas leaf (§3.6). This document is the SwiftUICircle/Rectangle/Path/Shapeanalogue for Day; conventions follow DESIGN.md.
1. Goal & constraints
Give Day SwiftUI’s shape ergonomics (Circle, Rectangle, RoundedRectangle, Capsule,
Ellipse, arbitrary Path, and custom shapes) as regular Pieces that:
- are bound to signals (geometry and style),
- are identified (
.id) and accessible (.a11y), - are interactive through the (eventual) gesture seam, with path-precise hit testing,
- are animatable,
- are built atop the internal canvas API (so they work on all six backends day one),
- read naturally for Day and Rust: a single data-oriented
shapepiece parameterised by kind rather than a zoo of node types.
What Day already gives us (the substrate)
canvas(|d: &mut Draw, size: Size| …): a reactive leaf (kinds::CANVAS) whose closure re-runs on any tracked signal read (and onFrameChanged), records aVec<DrawOp>, diffs it (DrawOp: PartialEq), and replays only on change. Backends turnDrawOps into native drawing (CoreGraphics / cairo / QPainter / Canvas.draw* / DirectWrite), one FFI hop per redraw.Shape { Rect(Rect), RoundedRect(Rect, f64), Ellipse(Rect), Arc{rect,start_deg,sweep_deg}, Line(Point,Point), Polygon(Vec<Point>) }: the geometry enum (day-spec).Draw::fill(Shape, Color),Draw::stroke(Shape, Color, f64),Draw::text(...).Color { r,g,b,a }(rgba/hex), aLinearGradientvia.fill_linear(...), or aRadialGradientvia.fill_radial(...)(angular is a later phase).Decorateblanket impl →.id/.id_keyed/.a11y/.frame/.padding/.anyfor every Piece.- Reserved seams:
AnimSpec { duration_ms }threaded throughupdate/set_frame; and §8.4’s “day-driven frame-clock ticker for canvas only”, the exact hook shape animation needs.
Prior art consulted
- SwiftUI:
Shape: path(in rect) -> Path; shapes are frame-relative views that fill their proposed size;.fill(ShapeStyle),.stroke(_, lineWidth:),.trim(from:to:),.rotation();Pathfor arbitrary geometry;Canvasfor dense immediate-mode drawing. - floem: shapes are drawn in the render pass; no separate node per shape (dense-draw model).
- hop/ (this workspace): proved a native
ShapeSpecpipeline withShape.fill(gradient)andLinearGradient/RadialGradient/AngularGradientnative on all four toolkits, and painted-path transforms (rotate/scale/offset are path-only, matching SwiftCrossUI). Directly informs §7–§8. - pane/ DESIGN2.md (this workspace): “style-as-data struct args” over the SwiftUI modifier
tower; informs the
Paint/StrokeStylevalue types below.
2. Design decisions (with rationale)
D1: Shapes are frame-relative, not absolute. A Circle inscribes the rect the layout
engine assigns; a Rectangle fills it; RoundedRectangle’s corner is the only extra parameter.
You size a shape with .frame(w, h) (or a bounded parent), exactly like SwiftUI. This is more
composable than “circle of radius r”: shapes drop into stacks, grow/shrink with layout, and animate
their frame for free. (The user’s radius = 1.0 sketch is absolute; we deliberately choose the
frame-relative model and recover absolute sizing via .frame(d, d).)
D2: One piece, parameterised by a data ShapeKind. There is no separate node kind per shape.
Rust’s enum-with-fields is the “params bag”: type-safe and cleaner than a mutable params closure.
Convenience free functions (circle(), rounded_rectangle(12.0)) give SwiftUI ergonomics; the
unified shape(kind) gives the data-oriented form. Both construct the same ShapePiece.
D3: Render atop canvas; the renderer is an implementation detail behind a stable API. v1
lowers a ShapePiece to the existing canvas display-list (zero backend work, works everywhere,
free reactivity, free unit tests). The same API can later lower hot or native-fidelity shapes to a
native SHAPE leaf (Proposal B, §9), a capability-gated choice with no API change. Lead with
canvas.
D4: Reactivity is free. Because the canvas closure re-runs on tracked reads, every shape
parameter and style may be a value, a Signal, or a closure with no per-prop binding code, a
strict simplification over native leaves. Shape params are stored as a small Reactive<T> source.
D5: Interaction is path-precise and day-side. The shape knows its path, so on a Tap(point)
from its canvas leaf, Day tests the point against the path in Rust before firing .on_tap. That
needs no backend change and is more correct than bounding-box hit testing.
D6: Two animation paths; shapes use the canvas one. Day has (i) backend-executed animation for
native widgets (the AnimSpec seam) and (ii) a day-driven canvas frame-clock (§8.4). Shapes
animate by interpolating params CPU-side and re-recording per frame, the same way SwiftUI renders
shape animation. The shape API is animation-ready now; the frame-clock engine lands via §8.4.
3. Proposal A (recommended): the canvas-backed shape piece
3.1 Geometry: ShapeKind (data)
/// A shape's geometry, resolved against the rect the layout engine assigns (frame-relative).
#[derive(Clone)]
pub enum ShapeKind {
Rectangle,
RoundedRectangle { corner: Corner },
Circle, // inscribed centred circle (min(w,h))
Ellipse, // fills the rect
Capsule, // RoundedRectangle with corner = min(w,h)/2
Arc { start_deg: f64, sweep_deg: f64 }, // stroked arc of the inscribed ellipse
Line { from: UnitPoint, to: UnitPoint }, // stroke-only segment between unit points
Polygon { points: Rc<[UnitPoint]> }, // point-list polygon in unit space
// ── phase 2 (each adds one canvas op + geometry, no new node) ──
// Path(PathData), // arbitrary (see §8)
// Custom(Rc<dyn Fn(Rect) -> PathData>), // the `Shape` protocol analogue
}
/// A corner radius as absolute points or a fraction (0..1) of min(w,h).
#[derive(Clone, Copy)]
pub enum Corner { Fixed(f64), Fraction(f64) }
impl From<f64> for Corner { fn from(v: f64) -> Self { Corner::Fixed(v) } }
impl ShapeKind {
/// Lower to a drawable path within `rect` (v1: the existing `Shape` enum; phase 2: `PathData`).
fn resolve(&self, rect: Rect) -> Geometry { /* Circle → Ellipse(square inset & centred), … */ }
}
resolve maps each kind to the existing day_spec::Shape, so no day-spec change is required,
including for Line and Polygon, whose Shape::Line/Shape::Polygon ops every backend already
replays. line((fx, fy), (fx, fy)) and polygon([(fx, fy), …]) are the tuple-friendly sugar.
Line/Polygon semantics, deliberately different from the closed kinds:
- Unit-point geometry, unclamped: Points resolve as fractions of the rect (like gradient
UnitPoints) and may sit outside 0..1; a glyph’s flourish can poke past its box on purpose. - No stroke-half inset: closed kinds inset by
stroke/2so a centered stroke stays inside the frame (SwiftUIstrokeBorderbehavior); Line and Polygon resolve exactly at their authored points, and a stroked segment touching the frame edge may clip on the clipping backends (Qt/Android/XAML), exactly as raw canvas does. - Line is stroke-only (
.fillrecords nothing; a segment has no interior), and its rect may be degenerate (a horizontal line in a zero-height sub-rect), so it skips the empty-rect bail. - Polygon hit-testing is path-precise (even-odd ray cast), keeping the D5 promise.
A regular n-gon (Polygon { sides, rotation } in earlier drafts) is future sugar that computes
inscribed unit points; the point-list form subsumes it.
Placement: .at(fx, fy, fw, fh) resolves the shape inside that fractional sub-rect of its
bounds (applied before .inset). It exists for composition: hand-drawn glyph code full of
Rect::new(ox + fx * s, oy + fy * s, fw * s, fh * s) translates 1:1 into .at(fx, fy, fw, fh)
children of a group (§3.6). It works on a standalone shape too.
3.2 Style: Paint and Stroke (data, growable)
/// A fill source (day-spec). `Solid` and `Linear` are IMPLEMENTED: `DrawOp::Fill(Shape, Paint)`
/// replays native gradients on every backend (NSGradient, CGGradient, cairo pattern,
/// QLinearGradient, Android LinearGradient shader, XAML LinearGradientBrush, OH_Drawing shader
/// effect); gradient unit points resolve against the filled shape's bounding box, and the packed
/// encoding carries stops on the texts channel (kind 14 — see day-spec::encode_ops).
#[derive(Clone, Debug, PartialEq)]
pub enum Paint {
Solid(Color),
Linear(LinearGradient), // { start: UnitPoint, end: UnitPoint, stops: Vec<(f64, Color)> }
Radial(RadialGradient), // { center: UnitPoint, radius: f64 (unit, elliptical-to-bounds), stops }
// ── later phases ──
// Angular { stops: Vec<(f64, Color)>, center: UnitPoint, start_deg: f64 } — native on
// Apple (CGContextDrawConicGradient) / Qt (QConicalGradient) / Android (SweepGradient) /
// OH_Drawing (sweep); needs a wedge-fan fallback on cairo + XAML XAML (the hop recipe).
// Token(SemanticColor), // §6 theme tokens, late-bound
}
impl From<Color> for Paint { /* … */ }
impl From<LinearGradient> for Paint { /* … */ }
impl From<RadialGradient> for Paint { /* … */ }
#[derive(Clone)]
pub struct Stroke {
pub paint: Reactive<Paint>,
pub width: Reactive<f64>,
// phase 2: pub cap: LineCap, pub join: LineJoin, pub dash: Vec<f64>,
}
3.3 The piece + builder
pub struct ShapePiece {
kind: Reactive<ShapeKind>,
fill: Option<Reactive<Paint>>,
stroke: Option<Stroke>,
inset: Reactive<f64>, // uniform inset before resolving (stroke-safe by default)
// reserved seams (design now, wire later):
on_tap: Option<Rc<dyn Fn()>>, // path-precise (§6)
anim: Option<Animation>, // implicit-animate reads (§5)
// phase 2: trim: Option<(Reactive<f64>, Reactive<f64>)>, rotation: Reactive<f64>, …
}
/// The unified, data-oriented constructor (the user's `shape(type:, params:)`).
pub fn shape(kind: impl IntoReactive<ShapeKind>) -> ShapePiece { /* … */ }
/// SwiftUI-ergonomic sugar; all construct the same `ShapePiece`.
pub fn rectangle() -> ShapePiece { shape(ShapeKind::Rectangle) }
pub fn circle() -> ShapePiece { shape(ShapeKind::Circle) }
pub fn ellipse() -> ShapePiece { shape(ShapeKind::Ellipse) }
pub fn capsule() -> ShapePiece { shape(ShapeKind::Capsule) }
pub fn rounded_rectangle(c: impl Into<Corner>) -> ShapePiece {
shape(ShapeKind::RoundedRectangle { corner: c.into() })
}
impl ShapePiece {
pub fn fill(mut self, p: impl IntoReactive<Paint>) -> Self { /* … */ self }
pub fn stroke(mut self, p: impl IntoReactive<Paint>, w: impl IntoReactive<f64>) -> Self { /* … */ self }
pub fn inset(mut self, v: impl IntoReactive<f64>) -> Self { /* … */ self }
pub fn on_tap(mut self, f: impl Fn() + 'static) -> Self { /* … */ self } // reserved
pub fn animation(mut self, a: Animation) -> Self { /* … */ self } // reserved
}
IntoReactive<T> is a marker-trait conversion accepting T, Signal<T>, or Fn() -> T. It
generalises today’s IntoText/IntoFraction into one reusable source type
Reactive<T> = Const(T) | Dyn(Rc<dyn Fn() -> T>). (Adopting it also lets us collapse the existing
per-type *Source enums, an optional cleanup.)
3.4 Lowering to canvas (the whole implementation, v1)
impl Piece for ShapePiece {
fn build(self, cx: &mut BuildCx) -> RNode {
// A shape greedily fills its proposed size (SwiftUI semantics): a canvas leaf with grow.
let node = cx.leaf(kinds::CANVAS, &CanvasProps::default(),
Flex { grow_w: true, grow_h: true, ..Default::default() });
// Reactive redraw: identical to `canvas()`, but recording fill+stroke of the resolved kind.
// Every `.get()` below is a tracked read, so any signal change re-records + diffs + replays.
let (kind, fill, stroke, inset) = (self.kind, self.fill, self.stroke, self.inset);
redraw(node, move |d, size| {
let rect = Rect::from_size(size).inset(inset.get());
let geom = kind.get().resolve(rect);
if let Some(fill) = &fill { d.fill(geom.clone(), fill.get().solid()); }
if let Some(st) = &stroke { d.stroke(geom, st.paint.get().solid(), st.width.get()); }
});
// Path-precise interaction: test the tap against the resolved path in Rust (§6).
if let Some(on_tap) = self.on_tap {
let (kind, inset) = (kind.clone(), inset.clone());
cx.on(node, move |ev| if let Event::Tap(p) = ev {
let rect = with_tree(|t| t.node_frame(node)).map(|f| Rect::from_size(f.size)).unwrap();
if kind.get().resolve(rect.inset(inset.get())).contains(*p) { on_tap(); }
});
}
node
}
}
redraw(node, closure) is the canvas() recording harness (a Trigger on FrameChanged + a
bind that records → replay) factored out so both canvas() and shape() share it.
That is the entire v1 renderer. No day-spec, day-core, or backend changes for the five
built-in kinds with solid fill/stroke. .id, .a11y, .frame, .padding come from Decorate
unchanged. Reactivity, diffing, and native replay come from the canvas machinery unchanged.
3.5 Reference example: re-express the gauge
// Today (raw canvas):
canvas(move |d, size| {
let r = Rect::from_size(size).inset(8.0);
d.stroke(Shape::Arc { rect: r, start_deg: 135.0, sweep_deg: 270.0 }, TRACK, 6.0);
d.stroke(Shape::Arc { rect: r, start_deg: 135.0, sweep_deg: 270.0 * frac() }, ACCENT, 6.0);
})
// With shapes (phase 2 arcs + trim):
zstack((
circle().stroke(TRACK, 6.0),
circle().trim(0.0, move || value.get() / 100.0).stroke(ACCENT, 6.0).rotation(-90.0),
text(move || format!("{:.0}", value.get())),
))
.frame(120.0, 120.0)
.a11y(|a| a.role(Role::Meter))
The result is a composable, identified, animatable progress ring, the same idiom SwiftUI uses.
3.6 Shape groups: many shapes, one canvas leaf
A ShapePiece is one native view. That is right for a backdrop or a swatch and wrong for a
composed glyph: a 10-row forecast whose weather icons average 8–12 shapes each would create ~200
native views (each with a trigger, an event route, and a per-view replay hop) where the raw-canvas
version had ~30. The composites close that gap:
/// Flatten shape descriptions into ONE canvas leaf, drawn in order.
pub fn shape_group(shapes: impl IntoIterator<Item = ShapePiece>) -> AnyPiece;
/// Size-aware variant: children derive from the laid-out size, re-run on FrameChanged —
/// for geometry that maps data along the final width/height.
pub fn shape_group_fn(shapes: impl Fn(Size) -> Vec<ShapePiece> + 'static) -> AnyPiece;
// A storm glyph — five shapes, one native view:
shape_group([
rounded_rectangle(4.0).fill(CLOUD).at(0.08, 0.54, 0.84, 0.36),
ellipse().fill(CLOUD).at(0.06, 0.36, 0.42, 0.42),
ellipse().fill(CLOUD).at(0.30, 0.20, 0.46, 0.46),
line((0.34, 0.78), (0.29, 0.98)).stroke(RAIN, 1.5),
polygon([(0.52, 0.66), (0.40, 0.90), /* … */]).fill(BOLT),
])
.frame(30.0, 30.0)
Groups reuse the whole shape pipeline: each child is the same ShapePiece description
(fill/stroke/inset/rotate/scale/offset/.at, all reactive; a tracked read in any child
re-records the group), and both composites lower through the same canvas_leaf as canvas() and
standalone shapes. Two intentional limits: child .on_tap/.on_drag are not wired inside a
group (put gestures on the group via Decorate::on_tap), and a group has no per-child identity;
it is one leaf.
Density guidance: a backdrop is a shape; a glyph is a group; hundreds of shapes is a
canvas() (the §4 escape hatch, unchanged).
4. Reactivity, identity, accessibility
- Reactivity: free (D4).
circle().fill(move || if on.get() { RED } else { GRAY })re-records only the fill op whenonflips; the diff replays one op. This is coarse-grained per shape, which is correct: a shape is a handful of ops. (For hundreds of shapes, drop to a singlecanvas(), one view with one op-list. That’s the documented escape hatch, mirroring SwiftUIShapevsCanvas.) - Identity / a11y:
Decoratealready appliesset_id/set_a11yto the shape’s canvas node. Nothing new. Shapes participate in dayscript locators and the a11y tree like any leaf.
5. Animation (the headline seam)
Shapes animate by interpolating parameters and re-recording per frame (the canvas path of
§8.4, not the native-widget AnimSpec path). Design:
pub struct Animation { pub curve: Curve, pub duration_ms: u32, pub delay_ms: u32 }
pub enum Curve { Linear, EaseIn, EaseOut, EaseInOut, Spring { response: f64, damping: f64 } }
/// Interpolable shape data. Implemented for f64, Color, Point, Rect, Corner, Paint, PathData.
pub trait Animatable { fn lerp(&self, to: &Self, t: f64) -> Self; }
Two entry points, matching SwiftUI:
- Implicit:
circle().fill(color_sig).animation(Animation::ease_in_out(300))makes the shape’s reactive reads animated. Whencolor_sigchanges, the shape capturesfrom → to, and a frame-clock Trigger ticks while the transition is live; each tick recomputeslerp(from, to, eased(t))and re-records. - Explicit:
with_animation(Animation::spring(...), || path.set(newValue))animates any signal writes in the closure through their dependent animated shapes.
Realisation (§8.4). Day owns one per-window frame clock (CVDisplayLink / Choreographer /
GdkFrameClock / DispatchSourceTimer), started only while ≥1 animation is live and stopped when the
pool drains (no idle wakeups). Each tick advances active Animatable transitions and notify()s a
Trigger that the animated shapes’ canvas binds track, so the existing record→diff→replay path
redraws them. This is additive: shapes ship and work without animation; .animation is a no-op
until the clock lands, and explicit signal-timer animation (a task writing a signal each frame)
already works today.
6. Interaction & gestures
The shape’s canvas leaf is a real native view; when the gesture seam emits Event::Tap/LongPress
for it, the shape does path-precise hit testing in Rust (D5) via Geometry::contains(point)
before firing .on_tap/.on_long_press. Bounding-box fallback if a kind has no cheap containment.
This is more correct than SwiftUI’s default (which needs .contentShape to get precise
hit testing) and needs no backend work beyond the gesture events Day already plans.
7. Gradients & ShapeStyle (linear + radial: DONE; angular: later)
Linear and radial gradients are implemented end-to-end: Paint::Linear/Paint::Radial on the
Fill op, Draw::fill(shape, gradient) on raw canvas, .fill_linear(...)/.fill_radial(...)
on shape pieces (reactive), and native replay on every backend (radial: NSGradient center-draw,
CGContextDrawRadialGradient, cairo radial pattern, QRadialGradient in ObjectMode, Android
RadialGradient shader + local matrix, XAML RadialGradientBrush (OS 1903+, now the msix
MinVersion), and OH_Drawing radial shader with local matrix). Radial geometry is unit-space and
stretches elliptically to non-square bounds on every backend. Paint still grows to Angular +
semantic Tokens later.
This is the one place the
canvas layer must grow: a Fill(Shape, Paint)/Stroke(Shape, Paint, StrokeStyle) op (Paint, not
just Color) + per-backend gradient replay (CGGradient, cairo pattern, QGradient/QConicalGradient,
Android shaders, XAML brushes). hop already implemented this on four toolkits (linear and radial
native everywhere; angular native on Qt, hand-rendered as a wedge fan elsewhere), so the recipe is
known. It benefits raw canvas() too. Gradients are a canvas-layer feature, still not a new node
kind.
8. Arbitrary paths & custom shapes (phase 2)
pub enum PathSeg { Move(Point), Line(Point), Quad(Point,Point), Cubic(Point,Point,Point),
ArcTo{ rect: Rect, start_deg: f64, sweep_deg: f64 }, Close }
pub struct PathData(pub Vec<PathSeg>);
pub fn path(build: impl Fn(&mut PathBuilder) + 'static) -> ShapePiece; // arbitrary
// The `Shape` protocol analogue: a custom kind is a closure Rect -> PathData.
pub fn shape_fn(f: impl Fn(Rect) -> PathData + 'static) -> ShapePiece;
Requires PathData + a FillPath/StrokePath canvas op + backend replay (NSBezierPath /
cairo path / QPainterPath / android.graphics.Path all support arbitrary paths). This closes SwiftUI
Path/custom-Shape parity. .trim(from, to) (progress rings, draw-on animations) also lives here
(trim a resolved path by arc-length).
9. Proposal B (alternative): a native SHAPE leaf
Instead of lowering to canvas, add kinds::SHAPE + ShapeProps { kind, fill, stroke } and render
natively per backend (CAShapeLayer / GtkSnapshot / QGraphics / android.graphics / XAML Path).
- Pros: native gradients/shadows/materials, native path animation, native precise hit testing, potentially cheaper for many shapes (one layer vs one canvas view each).
- Cons: six backend implementations; more
day-specsurface; diverges from “atop canvas”; reactivity needs explicitbind_seededper prop (loses D4’s freebie); duplicates what canvas does.
Recommendation: A now, B as a transparent optimisation later. Because the public API
(shape/ShapeKind/Paint) is renderer-agnostic (D3), a future ShapePiece::build may lower to a
native SHAPE leaf for shapes that need native fidelity (drop shadows, Material, smooth native
morphs), chosen by capability or a .native() hint, with no change to user code. Ship A;
keep B in the pocket.
10. Phasing
- Phase 1 (small, no backend work):
Reactive<T>/IntoReactive;ShapeKind{Rectangle, RoundedRectangle, Circle, Ellipse, Capsule};Paint::Solid;shape()+ sugar;.fill/.stroke/.inset; canvas lowering;.id/.a11y/.frameviaDecorate; mock-tested op-lists; ashapesshowcase playground. Works on all six backends immediately. - Phase 1.5 (shipped; still no backend work):
Arc;Line/Polygonunit-point kinds;.atfractional placement;shape_group/shape_group_fn(§3.6). Day Skies’ weather glyphs and range bars are the reference consumers. - Phase 2 (canvas-layer growth): gradients (
Paint+ gradient ops + backend replay, per hop);PathData+ path ops +path()/shape_fn(); arc modes (Sector/Chord),.trim. - Phase 3 (pillars): the §8.4 frame clock +
Animatable/Animation→.animation/with_animation; gesture wiring →.on_tapwith path-precise hit testing. - Phase 4 (optional): native
SHAPEleaf (Proposal B) as a transparent lowering for fidelity.
11. Open questions
- Frame-relative vs. absolute as the default (D1): proposed frame-relative; confirm.
- Unify the
IntoXsources intoReactive<T>/IntoReactive<T,M>now, or keep per-type and add just the shape ones? zstack: the gauge example wants a Z overlay piece. Is azstack/overlayin scope alongside shapes, or do shapes ship first and compose later?Drawop growth for gradients/paths: extend theDrawOpenum (breaks the packed 9-float encoding’s assumptions) vs. a parallel richer op stream. Prefer extending with a versioned encoder..background(shape)/.clip(shape): SwiftUI uses shapes as backgrounds/clips. Out of scope here, but theShapeKindvalue is exactly what those modifiers would consume later.