Canvas

The canvas piece records a display list (a Vec<DrawOp>) that each backend replays with its own native 2-D API: CoreGraphics on Apple, cairo on GTK, QPainter on Qt, android.graphics on Android, OH_Drawing on HarmonyOS, XAML shapes on Windows, and Canvas2D on the web. The closure re-records on any tracked read and on FrameChanged, and replay is equality-gated, so a canvas that draws the same list twice costs nothing the second time.

canvas(|d, size| {
    d.fill(Shape::Rect(Rect::new(0.0, 0.0, size.width, size.height)), SKY);
    d.stroke(Shape::Line(a, b), INK, 2.0);
})

The vocabulary

OpWhat it does
fill(shape, paint)Fill with a color or a linear/radial gradient
stroke(shape, color, width)Stroke at a width, everything else default
stroke_styled(shape, paint, style)Stroke with dash, cap, join, and any paint
stamp(shape, at, paint)Fill ONE shape at many positions, as one op (Stamping)
stamp_styled(shape, at, paint, style)The same, stroking each copy
clip(shape) / clipped(shape, f)Confine what follows to a shape
text(text, at, style)One line of text at a point, in a size, color and font
image(&bitmap, rect) / image_with_opacity(…)A decoded image scaled into a rect — the handle, never bytes
save / restore / concat(affine)Transform and clip state

Shape covers Rect, RoundedRect, Ellipse, Arc, Line, Polygon, and Path.

Paths

Shape::Path is any number of contours, straight or curved, with a fill rule. Build one with PathBuilder:

let ring = PathBuilder::new()
    .rule(FillRule::EvenOdd)      // the inner circle cuts a hole
    .circle(center, 40.0)
    .circle(center, 24.0)
    .build();
d.fill(ring, TEAL);

FillRule::NonZero is the default and what glyph outlines assume: a hole needs its contour wound the opposite way. FillRule::EvenOdd makes any contour inside another a hole regardless of winding, which is what PDF’s f* and SVG’s fill-rule: evenodd mean.

arc_to(center, radius, start_deg, sweep_deg) appends a circular arc — degrees, 0 = the +x axis, positive sweeping clockwise, the same convention Shape::Arc and circle use. It is a segment, not a shape, which is what lets a figure made of arcs be one closed contour:

// A donut wedge: out along the far edge, back along the near one.
let wedge = PathBuilder::new()
    .arc_to(center, 60.0, 20.0, 100.0)     // outer edge, clockwise
    .arc_to(center, 34.0, 120.0, -100.0)   // inner edge, back the other way
    .close()
    .build();

An arc joins whatever came before it — reached by a line when the subpath has a current point, opening a new one when it does not (including straight after a close). That is the difference that matters: Shape::Arc draws an arc, arc_to builds a figure out of arcs, and a donut hole, an angular inset and a corner radius can only compose in the second form. day-piece-charts drew every pie and donut wedge from forty lines of hand-rolled cubics until this existed.

It emits cubics rather than an arc op on the wire. Every rasterizer under Day has its own rule for joining an arc to the line before it and its own flattening tolerance, so the same beziers everywhere means the same pixels everywhere. A cubic cannot be a circle: the radial error peaks at 2.7 × 10⁻⁴ of the radius on the quarter turns this splits into — a quarter of a pixel on a circle a thousand points across — which is the same tradeoff Core Graphics, cairo and every SVG renderer make.

smooth_polyline(&points, tension) fits a Catmull-Rom spline through points and emits it as cubics. It passes through every point, so it is a drawing of the data rather than a fit to it. A spline still implies values between the samples, which is why Day Trader smooths its sparklines and not the chart someone reads prices off.

From SVG

build_path! parses SVG path data at compile time and emits the PathBuilder chain, so a path costs the same at runtime as writing the chain by hand and there is no string left in the binary:

let heart = build_path!("M12,21 C5.5,15.5 2,12 2,8.5 C2,5.4 4.4,3 7.5,3 …").build();

The whole SVG 1.1 grammar is accepted: relative commands, H/V, the smooth forms S/T, elliptical arcs, implicit command repetition, and SVG’s number syntax (1e2, .5.5, 10-5). Malformed data is a compile error naming the offending character. Arcs are converted to cubics by the macro, because an arc is the one SVG command with no counterpart in the 2-D APIs Day draws through; converting once at build time is cheaper than converting in nine backends at draw time.

Strokes

StrokeStyle carries width, cap, join, miter limit, and a dash pattern. StrokeStyle::width(w), ::dashed(w, pattern) and ::round(w) cover the common cases; the rest is struct-update syntax.

d.stroke_styled(path, SLATE, StrokeStyle::dashed(1.0, vec![5.0, 5.0]));
d.stroke_styled(path, LinearGradient::horizontal(RUST, SKY), StrokeStyle::round(6.0));

The default cap is Butt and the default join is Miter, matching PDF, SVG and every native 2-D API. AppKit, Qt and Android used to force round caps on every canvas stroke; they now honor the style, so a line that wants round ends has to ask for it.

Clipping

clip intersects the current clip, and the only way to widen it again is restore. Every native 2-D context works this way, so there is no “unclip”. clipped(shape, f) wraps the save/clip/restore for you.

What each backend can and cannot do

Everything above works on every backend except where noted.

BackendLimitation
web-domA gradient stroke paints the gradient across the path’s interior rather than only the stroked band. Canvas2D has no “convert stroke to path”, so there is no region to clip to. It looks correct for thin lines and diverges as the width grows.
xamlClipping is rectangular: UIElement.Clip accepts only a RectangleGeometry, so a path, ellipse or polygon clip degrades to its bounding box, and content is still confined, just less tightly. Escaping this means moving the canvas to Windows.UI.Composition, whose CompositionGeometricClip does take a path.
appkitQuadratic segments are elevated to cubics, exactly (NSBezierPath’s own quadratic API is macOS 14+). No visual difference.
gtk, arkuiSame quadratic elevation, for the same reason: cairo and OH_Drawing have cubics.
web-domfont_families() is the CSS generic families plus the bundled fonts, not the machine’s: a browser lists local fonts only through queryLocalFonts(), which is Chromium-only, asynchronous, and behind a permission prompt. Cap::FontList answers Emulated.
androidfont_families() is read from /system/etc/fonts.xml, the configuration Typeface.create resolves names from, so the system’s alias families (sans-serif-condensed, sans-serif-medium, …) appear as families of their own.
arkuiCanvas fonts and the font list compile against the SDK’s OH_Drawing_FontMgr API and are exercised by CI on a device, not by a local emulator run.
allA weight or slant the family does not ship is synthesized where the platform does that (Skia, DirectWrite, CSS) and rounded to the nearest face elsewhere (Pango, CoreText). face_for on the family’s FontFamilyInfo says which face will actually draw.
qt, android, xamlDash patterns are specified in pixels by Day and converted to those APIs’ stroke-width units on the way in. A zero-width stroke falls back to a width of 1 for the conversion.

Gradient strokes on Apple work by converting the stroke to the region it covers (CGContextReplacePathWithStrokedPath) and drawing the gradient through that clip, which is exact.

Text

d.text("Aa", Point::new(8.0, 8.0), TextStyle {
    size: 24.0,
    color: ink,
    anchor: TextAnchor::LEADING,
    font: CanvasFont { family: Some("Pacifico".into()), weight: Some(FontWeight::Bold), italic: false },
});
d.text("40", center, TextStyle { size: 22.0, color: accent, anchor: TextAnchor::CENTERED, ..Default::default() });

TextStyle is a size in absolute canvas points, a color, an anchor and a CanvasFont: a family (a platform family or a bundled one, None for the platform’s face), a weight and a slant. Fill what you set and take the rest from ..Default::default(). One line: a newline is drawn as the engine draws it, not as a line break.

Canvas text takes a size, not a FontSpec: it is for labels and type inside a drawing, and it carries neither the reader’s font-scale setting nor RTL mirroring. Anything a user reads as content belongs in a label piece, which does.

Anchors

TextAnchor is one placement per axis, and the backend does the alignment:

TextAnchor { h: TextAlign::Trailing, v: TextVAlign::Middle }   // an axis label, right of its tick
TextAnchor::LEADING                                            // top-leading corner (the default)
TextAnchor::CENTERED                                           // the box's center, both ways
TextAnchor::TRAILING                                           // top-trailing corner
TextAlignat is
Leadingthe leading edge — left of LTR text, right of RTL
Centerthe horizontal middle of the advance width
Trailingthe trailing edge
TextVAlignat is
Topthe top of the line box
Middleits vertical middle
Baselinethe typographic baseline itself
Bottomthe bottom of the line box

Top, Middle and Bottom are edges of the line box — the ascent-plus-descent box day::measure_text reports for the same text, size and font — so a drawing that frames its text from measure_text gets a frame that hugs what is drawn on every backend, and TextVAlign::Baseline is at.y + metrics.ascent below Top.

Let the anchor do it rather than measuring. Right-aligning a label by measuring it and subtracting the width costs a measure_text call per label, and the backend is about to lay the same text out anyway. day-piece-charts drew every y-axis label that way until Trailing existed; the measurement per label is now gone. (Measuring is memoized — docs/fonts.md — so the repeat is cheap; it is still work that the anchor does for free and more accurately.)

(Before fonts arrived, gtk, qt, android, arkui and web-dom put a Leading anchor on the baseline instead; a caller that compensated for that with an offset can drop it.)

The platform’s font families, with the faces each ships, come from day::font_families(); the font menu of a drawing app is that list, and docs/fonts.md covers it and the measurement API.

Interaction

A canvas is a real native view, so it takes the ordinary gestures, and two of them report where the press landed:

canvas(draw)
    .on_tap_at(move |p| pick(p))                      // Event::Tap's point
    .on_drag(move |drag| pick(drag.location))         // and every phase of a drag
    .frame(width, height)

Both points are in the canvas’s own coordinate space, origin at its top-leading corner, which lets a drawn control (a color wheel, a map, a waveform scrubber) turn “the user pressed here” into a value. on_tap (no location) stays for the common case.

Wire both when a press should count as a pick: a press that never moves is a tap on some backends and a zero-length drag on others, and since both handlers write the same value, a backend that reports both costs nothing. (Day-Sketch, whose tap TOGGLES a selection, cannot be idempotent that way and carries a 30 ms cross-recognizer guard instead — src/canvas.rs’s handle_click.)

Hover

canvas(draw).on_hover(move |at| pointer.set(at))     // Some(point) inside, None on leaving

Some(point) in the canvas’s own coordinates while the pointer is over it, None when it leaves. Pointer input only. Touch-only devices do not produce hover events. Actions available on hover must also be accessible by touch. For chart selection, handle on_tap_at, on_drag, and on_hover.

BackendHoverUnderneath
appkitan NSTrackingArea + mouseEntered:/mouseMoved:/mouseExited:. Like Pan, only DayCanvas overrides those, so a hover enabled on another view reports nothing
gtkGtkEventControllerMotion — enter, motion, leave
qtsetMouseTracking(true) plus Enter/MouseMove/Leave on the event filter
xamlPointerEntered/Moved/Exited, gated to a Mouse or Pen device
dompointerenter/move/leave, gated on pointerType !== 'touch'
uikit✓ (pointer)UIHoverGestureRecognizer — an iPad with a trackpad, a mouse, or a hovering Pencil. A finger-only device reports nothing
android✓ (pointer)setOnHoverListener — a mouse or a stylus. A finger generates no HOVER_* action at all
arkuithe C node API’s NODE_ON_HOVER carries no coordinates, and the contract is a point

The three backends that gate on device type all do it for the same reason: the platform reports a touch contact through the same enter/move/exit events, and passing those through would make every tap look like a hover.

On exit, location is the last point seen INSIDE the node — none of these platforms reports a useful coordinate with the leave event itself, and “where the pointer went out” is what a handler wants. Put them on the canvas before any wrapping decorator, because .frame and .corner_radius build layout nodes of their own, and a point in a wrapper’s space is not a point in the canvas’s.

The reference use is day-piece-colorpicker’s composed panel (docs/colorpicker.md): its saturation/brightness field, hue strip and opacity strip are three canvases that read their value straight out of the press location.

Zoom and pan

Two continuous gestures serve a canvas that is a viewport onto something larger (a drawing, a map, a timeline):

canvas(draw)
    .on_pinch(move |g| zoom_about(g.location, g.scale, g.phase))
    .on_pan(move |g| scroll_by(g.delta))

Pinch.scale is cumulative (the total magnification since the gesture began, with 1.0 meaning unchanged), so a handler applies it to the zoom it captured at DragPhase::Began rather than multiplying every event in. Pan.delta is incremental (each event carries only the movement since the previous one, as a content displacement: pan by += delta and content follows the fingers), because desktop wheels produce lone Changed events with no began/ended bracket to accumulate across. Both carry a location in canvas coordinates for anchoring the zoom under the fingers; a backend that cannot know it (GTK’s scroll controller) reports Point::ZERO.

Where they come from: trackpad magnify and two-finger scroll on macOS (a plain mouse wheel also pans), GtkGestureZoom and the scroll controller on GTK, native zoom gestures and wheel events on Qt, and pinch plus a two-finger pan recognizer on iOS; one-finger drags still go to .on_drag, so selection and panning coexist. The remaining backends do not deliver these events yet; apps that offer zoom controls in a toolbar or menu (as Day-Sketch does) lose no capability there, only the gesture shortcut.

Stamping

One shape at many positions is one op:

let dot = Shape::Ellipse(Rect::new(-3.0, -3.0, 6.0, 6.0));   // authored around the ORIGIN
d.stamp(dot, positions, color);                              // …translated to each point
d.stamp_styled(cross, positions, color, StrokeStyle::width(1.5));   // stroked instead of filled

Each copy is the template translated by one point, and every copy shares the shape, the size and the paint — so anything that varies means another stamp. For a chart that is one per series, which is what day-piece-charts does: it groups its point marks by symbol, size, color and stroke width and emits a stamp per group.

Why it exists. A DrawOp is 168 bytes, and a canvas re-records its whole op list on any tracked read. Fifty thousand points drawn one fill at a time is 8.4 MB of ops to build, compare against the previous frame and clone into the tree — about 3.6 ms per frame before a backend draws anything. As one stamp it is 800 KB and 1.2 ms: the equality check is 9× faster, the clone 23×, and the whole per-frame overhead 3.1×. On the wire to a serializing backend it is a quarter of the numbers. Day-Viz’s scatter records 120,037 marks as 40 ops.

Backends draw a batch as a single native path — one NSBezierPath, one Path2D, one QPainter transform per copy into one geometry — so the rasterizer is entered once however many copies there are, rather than once per mark.

Performance

CanvasProps holds the whole op list and a change replaces it, so a canvas is cheapest when its op count is stable and small. Two ways to keep it that way:

  • One path over many segments. Day Trader’s chart line went from one Shape::Line per sample (about 250 ops for a year of daily closes, every corner unjoined) to a single path op.
  • One stamp over many identical marks — see Stamping above.

Geometry is decoded once, not once a frame

A path and a polygon are the only shapes whose geometry does not fit the record’s own slots: they ride a side channel, as text on the serializing backends and as a length-prefixed run on the web. Decoding that — parsing every segment into an android.graphics.Path, a QPainterPath, a Path2D — used to happen on every frame, for drawings that had not moved.

Each of those records now carries a content key, and backends keep the geometry they decoded under it. The key is a hash of the geometry itself, never of the op’s position or identity, which is what makes it work for the way canvases are actually written: a draw closure that rebuilds its PathBuilder chain from scratch every frame produces an equal path, so it keys the same and the decoded geometry is reused. Nothing is asked of the caller — there is no cached path type to hold on to, and no handle to thread through a draw closure.

Backends are free to ignore it: a key of 0 means “none offered”, and a decoder that does not know the slot decodes the payload exactly as it did before.