Shapes: design & implementation
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 gesture events, 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 parameterized by kind rather than one node kind per shape.
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 hooks:
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): shipped 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; the frame-relative model
recovers 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 simpler 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.
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 precise 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 parameter) 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 centered 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 & centered), … */ }
}
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 and Polygon semantics differ 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 extend past its box. - 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
generalizes 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; the five built-in kinds with solid fill/stroke need no
day-spec, day-core, or backend changes. .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>) -> impl Piece;
/// 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) -> impl Piece;
// 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. Groups have two 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,
because it is one leaf.
As a rule of density, a backdrop is a shape, a glyph is a group, and hundreds of shapes are 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 is needed. Shapes participate in dayscript locators and the a11y tree like any leaf.
5. Animation
Shapes animate by interpolating parameters and re-recording per frame, on the canvas path of
§8.4 rather than the native-widget AnimSpec path:
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.
Realization (§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 event channel delivers
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. A kind with no cheap
containment test falls back to its bounding box. This 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 and add no 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 optimization 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.
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.