day_pieces/canvas.rs
1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! The `canvas` immediate-mode drawing surface (record a `Draw` display list that backends
5//! replay natively, with `frame_clock` for per-frame animation), plus the general `Reactive<T>`
6//! (value / `Signal` / closure) abstraction that pieces accept for animatable inputs.
7
8use std::cell::RefCell;
9use std::rc::Rc;
10
11use day_core::*;
12use day_reactive::{Scope, Signal};
13use day_spec::props::*;
14use day_spec::{
15 Color, CornerRadii, DrawOp, Event, ExportError, FillRule, Paint, PathSeg, Point, Rect, Shape,
16 Size, StrokeStyle, kinds,
17};
18
19use crate::*;
20
21// ---------------------------------------------------------------------------
22// Canvas (§11): record a display list reactively; backends replay natively.
23// ---------------------------------------------------------------------------
24
25#[derive(Default)]
26pub struct Draw {
27 ops: Vec<DrawOp>,
28}
29
30impl Draw {
31 /// An empty recorder, for a test that calls a draw function directly and asserts on what
32 /// it records: the same list the canvas replays.
33 pub fn new() -> Self {
34 Draw::default()
35 }
36 /// What has been recorded so far, in order.
37 pub fn ops(&self) -> &[DrawOp] {
38 &self.ops
39 }
40}
41
42/// Build a [`Shape::Path`]: several contours, straight or curved, with a fill rule.
43///
44/// ```ignore
45/// let ring = PathBuilder::new()
46/// .rule(FillRule::EvenOdd) // the inner circle cuts a hole
47/// .circle(center, 40.0)
48/// .circle(center, 24.0)
49/// .build();
50/// d.fill(ring, Color::BLUE);
51/// ```
52#[derive(Clone, Debug, Default)]
53pub struct PathBuilder {
54 segs: Vec<PathSeg>,
55 rule: FillRule,
56}
57
58impl PathBuilder {
59 pub fn new() -> Self {
60 Self::default()
61 }
62 /// Which points count as inside where contours overlap (default [`FillRule::NonZero`]).
63 pub fn rule(mut self, rule: FillRule) -> Self {
64 self.rule = rule;
65 self
66 }
67 /// Start a new contour.
68 pub fn move_to(mut self, p: Point) -> Self {
69 self.segs.push(PathSeg::Move(p));
70 self
71 }
72 pub fn line_to(mut self, p: Point) -> Self {
73 self.segs.push(PathSeg::Line(p));
74 self
75 }
76 /// Quadratic bezier through control point `c` to `p`.
77 pub fn quad_to(mut self, c: Point, p: Point) -> Self {
78 self.segs.push(PathSeg::Quad(c, p));
79 self
80 }
81 /// Cubic bezier with control points `c1`, `c2` to `p`.
82 pub fn cubic_to(mut self, c1: Point, c2: Point, p: Point) -> Self {
83 self.segs.push(PathSeg::Cubic(c1, c2, p));
84 self
85 }
86 /// Close the current contour back to its start.
87 pub fn close(mut self) -> Self {
88 self.segs.push(PathSeg::Close);
89 self
90 }
91 /// A whole circular contour, as four cubics.
92 ///
93 /// 0.5523 is the standard circle-from-beziers constant (`4/3·tan(π/8)`); the error against a
94 /// true circle is under a thousandth of the radius, which is well inside a pixel at any size
95 /// a UI draws.
96 /// Append a circular arc: `sweep_deg` of a circle of `radius` about `center`, starting at
97 /// `start_deg`. Degrees, `0` = the +x axis, positive sweeping clockwise, the same convention
98 /// [`Shape::Arc`] and [`PathBuilder::circle`] already use, so the crate has one.
99 ///
100 /// A *segment*, not a shape: the arc joins whatever came before it, which is what lets a
101 /// donut wedge, a rounded gauge or a pie slice with a hole be one closed path. Without it
102 /// every such figure is hand-rolled from cubics; `day-piece-charts` carried forty lines of
103 /// exactly this to draw a wedge.
104 ///
105 /// Emitted as cubics rather than as an arc op on the wire. Every rasterizer under Day has its
106 /// own rule for joining an arc to the line before it, and its own flattening tolerance; the
107 /// same beziers everywhere means the same pixels everywhere.
108 ///
109 /// A cubic cannot be a circle, so this is an approximation, and the quarter turns it splits
110 /// into are where that is cheapest: the radial error of a cubic quarter-circle peaks at
111 /// 2.7 × 10⁻⁴ of the radius, a quarter of a pixel on a circle a thousand points across, and
112 /// proportionally less on anything smaller. Splitting finer would halve nothing anyone can
113 /// see and double the segment count. It is the same tradeoff Core Graphics, cairo and every
114 /// SVG renderer make.
115 ///
116 /// Continues the current subpath when there is one, and starts a new one otherwise.
117 pub fn arc_to(mut self, center: Point, radius: f64, start_deg: f64, sweep_deg: f64) -> Self {
118 // Reach the arc's start: a line from wherever the path is, or a move if it is nowhere.
119 // `Close` ends a subpath, so a path that just closed is "nowhere" too.
120 let fresh = self.segs.last().is_none_or(|s| matches!(s, PathSeg::Close));
121 let first = day_spec::arc_point(center, radius, start_deg);
122 self.segs.push(if fresh {
123 PathSeg::Move(first)
124 } else {
125 PathSeg::Line(first)
126 });
127 self.segs
128 .extend(day_spec::arc_cubics(center, radius, start_deg, sweep_deg));
129 self
130 }
131
132 pub fn circle(self, center: Point, radius: f64) -> Self {
133 const K: f64 = 0.552_284_749_8;
134 let (cx, cy, r, k) = (center.x, center.y, radius, radius * K);
135 self.move_to(Point::new(cx + r, cy))
136 .cubic_to(
137 Point::new(cx + r, cy + k),
138 Point::new(cx + k, cy + r),
139 Point::new(cx, cy + r),
140 )
141 .cubic_to(
142 Point::new(cx - k, cy + r),
143 Point::new(cx - r, cy + k),
144 Point::new(cx - r, cy),
145 )
146 .cubic_to(
147 Point::new(cx - r, cy - k),
148 Point::new(cx - k, cy - r),
149 Point::new(cx, cy - r),
150 )
151 .cubic_to(
152 Point::new(cx + k, cy - r),
153 Point::new(cx + r, cy - k),
154 Point::new(cx + r, cy),
155 )
156 .close()
157 }
158 /// A contour through `pts` as a Catmull-Rom spline converted to cubics: the smooth line a
159 /// chart wants through its data points, without the caller doing bezier arithmetic.
160 ///
161 /// The curve passes through every point (unlike a plain bezier fit), and the tangent at each
162 /// point follows its neighbors. `tension` 0.0 is a straight polyline and 1.0 is the
163 /// standard Catmull-Rom; values above about 1.2 overshoot visibly.
164 pub fn smooth_polyline(mut self, pts: &[Point], tension: f64) -> Self {
165 if pts.len() < 2 {
166 return match pts.first() {
167 Some(p) => self.move_to(*p),
168 None => self,
169 };
170 }
171 self.segs.push(PathSeg::Move(pts[0]));
172 let t = tension / 6.0;
173 for i in 0..pts.len() - 1 {
174 // The neighbors on each side, clamped at the ends so the first and last segments
175 // keep the same construction as the middle ones.
176 let p0 = pts[i.saturating_sub(1)];
177 let (p1, p2) = (pts[i], pts[i + 1]);
178 let p3 = pts[(i + 2).min(pts.len() - 1)];
179 self.segs.push(PathSeg::Cubic(
180 Point::new(p1.x + (p2.x - p0.x) * t, p1.y + (p2.y - p0.y) * t),
181 Point::new(p2.x - (p3.x - p1.x) * t, p2.y - (p3.y - p1.y) * t),
182 p2,
183 ));
184 }
185 self
186 }
187 /// Append a rectangle with its own radius on each corner, as one closed contour: the path
188 /// [`Shape::rounded_rect`] draws when its corners differ, for a figure that needs it as one
189 /// contour among several (a rounded frame with a hole cut out of it).
190 pub fn rounded_rect(mut self, rect: Rect, radii: CornerRadii) -> Self {
191 self.segs.extend(day_spec::rounded_rect_segs(rect, radii));
192 self
193 }
194
195 /// Finish, as a [`Shape`] ready for `fill`, `stroke` or `clip`.
196 pub fn build(self) -> Shape {
197 Shape::Path(day_spec::Path {
198 segs: self.segs,
199 rule: self.rule,
200 })
201 }
202}
203
204/// Canvas text styling (named fields per the API style rule, docs/api-style.md). Fill what you
205/// set and take the rest from `..Default::default()`: 12 points, black, top-leading, the
206/// platform's own face.
207#[derive(Clone, Debug, PartialEq)]
208pub struct TextStyle {
209 /// Absolute canvas points, with no accessibility scale (docs/canvas.md "Text").
210 pub size: f64,
211 pub color: Color,
212 pub anchor: day_spec::TextAnchor,
213 /// Family, weight and slant (docs/fonts.md); the default is the platform's UI face.
214 pub font: day_spec::CanvasFont,
215}
216
217impl Default for TextStyle {
218 fn default() -> Self {
219 TextStyle {
220 size: 12.0,
221 color: Color::BLACK,
222 anchor: day_spec::TextAnchor::LEADING,
223 font: day_spec::CanvasFont::default(),
224 }
225 }
226}
227
228impl Draw {
229 /// Fill a shape with a solid color or a [`day_spec::LinearGradient`] (both convert to [`Paint`];
230 /// gradient unit points resolve against the shape's bounding box, docs/shapes.md §3.2).
231 pub fn fill(&mut self, shape: Shape, paint: impl Into<Paint>) {
232 self.ops.push(DrawOp::Fill(shape, paint.into()));
233 }
234 /// Stroke a shape with a solid color at `width`, the everyday case.
235 pub fn stroke(&mut self, shape: Shape, color: Color, width: f64) {
236 self.ops.push(DrawOp::Stroke(
237 shape,
238 Paint::Solid(color),
239 StrokeStyle::width(width),
240 ));
241 }
242 /// Stroke with a full [`StrokeStyle`] (dash, cap, join) and any paint, gradients included.
243 pub fn stroke_styled(&mut self, shape: Shape, paint: impl Into<Paint>, style: StrokeStyle) {
244 self.ops
245 .push(DrawOp::Stroke(shape, paint.into(), style.clone()));
246 }
247 /// Draw `shape` once at every position in `at`: one op for the whole batch.
248 ///
249 /// The template is authored around the origin and each copy is it translated by one point, so
250 /// a 6-point dot is `Shape::Ellipse(Rect::new(-3.0, -3.0, 6.0, 6.0))`. Order is drawing order.
251 ///
252 /// Reach for this the moment a drawing has more marks than it has kinds of mark. A scatter of
253 /// fifty thousand points drawn one `fill` at a time is fifty thousand ops to build, compare
254 /// and clone on every frame that re-records; as one stamp it is one op and a flat array of
255 /// coordinates, and a backend can put every copy into a single path (docs/canvas.md
256 /// "Stamping"). Every copy shares the shape, size and paint, so group by whatever varies.
257 pub fn stamp(&mut self, shape: Shape, at: Vec<Point>, paint: impl Into<Paint>) {
258 self.ops.push(DrawOp::Stamp(Box::new(day_spec::Stamp {
259 shape,
260 at,
261 paint: paint.into(),
262 stroke: None,
263 })));
264 }
265
266 /// [`Draw::stamp`], stroking each copy instead of filling it.
267 pub fn stamp_styled(
268 &mut self,
269 shape: Shape,
270 at: Vec<Point>,
271 paint: impl Into<Paint>,
272 style: StrokeStyle,
273 ) {
274 self.ops.push(DrawOp::Stamp(Box::new(day_spec::Stamp {
275 shape,
276 at,
277 paint: paint.into(),
278 stroke: Some(style),
279 })));
280 }
281
282 /// Draw a decoded image into `rect` (docs/images.md).
283 ///
284 /// Takes a decoded [`Bitmap`](day_core::Bitmap), not bytes: the draw closure re-records on
285 /// every tracked read, and a buffer here would be compared (and re-decoded) every frame.
286 /// Decode once with `day::decode_image`, hold the handle, and draw it as often as you like.
287 ///
288 /// ```ignore
289 /// let photo = day::decode_image(bytes).await?; // once
290 /// canvas(move |d, size| d.image(&photo, Rect::from_size(size)))
291 /// ```
292 ///
293 /// Needs [`Cap::ImageDecode`](day_spec::Cap::ImageDecode); a backend without it draws
294 /// nothing here, the same as for any op it does not know.
295 pub fn image(&mut self, image: &day_core::Bitmap, rect: day_spec::Rect) {
296 self.image_with_opacity(image, rect, 1.0);
297 }
298
299 /// [`Draw::image`] with an opacity in `0.0..=1.0` multiplying the image's own alpha.
300 pub fn image_with_opacity(
301 &mut self,
302 image: &day_core::Bitmap,
303 rect: day_spec::Rect,
304 opacity: f64,
305 ) {
306 self.ops.push(DrawOp::Image {
307 image: image.id(),
308 rect,
309 opacity: opacity.clamp(0.0, 1.0),
310 });
311 }
312
313 /// Confine everything drawn afterwards to `shape`.
314 ///
315 /// The clip lasts until the enclosing [`Draw::restore`], so the usual shape is
316 /// `save` → `clip` → draw → `restore`; [`Draw::clipped`] does exactly that for you.
317 pub fn clip(&mut self, shape: Shape) {
318 self.ops.push(DrawOp::Clip(shape));
319 }
320 /// Draw `f` clipped to `shape`, restoring the previous clip afterwards.
321 pub fn clipped(&mut self, shape: Shape, f: impl FnOnce(&mut Draw)) {
322 self.save();
323 self.clip(shape);
324 f(self);
325 self.restore();
326 }
327 /// One line of text hung on `at` per the style's anchor (docs/canvas.md "Text"); measure it
328 /// first with `day::measure_text` when the drawing needs its extent.
329 pub fn text(&mut self, text: &str, at: Point, style: TextStyle) {
330 self.ops.push(DrawOp::Text {
331 text: text.to_owned(),
332 at,
333 size: style.size,
334 color: style.color,
335 anchor: style.anchor,
336 font: style.font,
337 });
338 }
339 /// Save the current transform/clip; pair with [`Draw::restore`].
340 pub fn save(&mut self) {
341 self.ops.push(DrawOp::Save);
342 }
343 /// Restore the transform/clip saved by the matching [`Draw::save`].
344 pub fn restore(&mut self) {
345 self.ops.push(DrawOp::Restore);
346 }
347 /// Multiply an affine onto the current transform (shape rotate/scale/offset, §11).
348 pub fn concat(&mut self, m: day_geometry::Affine) {
349 self.ops.push(DrawOp::Concat(m));
350 }
351 /// Draw within `m` applied to the CTM, restoring afterwards.
352 pub fn transformed(&mut self, m: day_geometry::Affine, f: impl FnOnce(&mut Draw)) {
353 self.save();
354 self.concat(m);
355 f(self);
356 self.restore();
357 }
358}
359
360/// Create + wire a reactive canvas leaf with a given flex: the draw closure re-records on any
361/// tracked read and on `FrameChanged`; replay is equality-gated by `DrawOp: PartialEq` (§4.2).
362/// Shared by [`canvas`] (intrinsic) and [`shape`] (grows to fill, §shapes).
363pub(crate) fn canvas_leaf(
364 cx: &mut BuildCx,
365 flex: Flex,
366 draw: impl Fn(&mut Draw, Size) + 'static,
367) -> RNode {
368 use day_reactive::{Trigger, bind};
369 let node = cx.leaf(kinds::CANVAS, &CanvasProps::default(), flex);
370 let trig = Trigger::new();
371 cx.on(node, move |ev| {
372 if matches!(ev, Event::FrameChanged(_)) {
373 trig.notify();
374 }
375 });
376 let draw = std::rc::Rc::new(draw);
377 let d2 = draw.clone();
378 bind(
379 move || {
380 trig.track();
381 let size = with_tree(|t| t.node_frame(node))
382 .map(|f| f.size)
383 .unwrap_or(Size::new(0.0, 0.0));
384 let mut d = Draw { ops: Vec::new() };
385 (d2)(&mut d, size);
386 d.ops
387 },
388 move |ops: &Vec<DrawOp>| {
389 with_tree(|t| t.replay(node, ops.clone()));
390 },
391 );
392 node
393}
394
395/// The drawing closure is a binding: signal reads re-record; layout size changes re-record
396/// (via FrameChanged); replay is equality-gated by DrawOp's PartialEq (§4.2).
397pub fn canvas(draw: impl Fn(&mut Draw, Size) + 'static) -> impl Piece {
398 piece_fn(move |cx| canvas_leaf(cx, Flex::default(), draw))
399}
400
401// ---------------------------------------------------------------------------
402// Export (docs/canvas.md "Exporting"): the same draw function, replayed offscreen.
403// ---------------------------------------------------------------------------
404
405/// A recorded drawing at a fixed size: what a [`canvas`] would replay, kept for export.
406///
407/// Write the drawing once as a plain function and call it from the canvas and from export:
408///
409/// ```ignore
410/// fn draw_chart(d: &mut Draw, size: Size, series: &[f64]) { /* … */ }
411///
412/// canvas(move |d, size| draw_chart(d, size, &series.get())) // on screen
413/// let drawing = Drawing::record(Size::new(600.0, 400.0), |d, size| {
414/// draw_chart(d, size, &series.get_untracked())
415/// });
416/// day::task(async move {
417/// if let Ok(png) = drawing.to_png(2.0).await { /* save it */ }
418/// });
419/// ```
420///
421/// Rendering goes through the toolkit's own canvas painter (Core Graphics, cairo, `QPainter`,
422/// an android `Canvas`, …), so an export matches the screen. Probe [`canvas_exports`] before
423/// offering a command.
424#[derive(Clone, Debug, PartialEq)]
425pub struct Drawing {
426 size: Size,
427 ops: Vec<DrawOp>,
428}
429
430impl Drawing {
431 /// Run `draw` once, untracked, at `size` points. Signal reads inside it subscribe nothing,
432 /// so recording from a binding or a menu action is safe.
433 pub fn record(size: Size, draw: impl FnOnce(&mut Draw, Size)) -> Drawing {
434 let mut d = Draw::new();
435 day_reactive::untrack(|| draw(&mut d, size));
436 Drawing { size, ops: d.ops }
437 }
438
439 /// A drawing of ops recorded elsewhere, at `size` points.
440 pub fn from_ops(size: Size, ops: Vec<DrawOp>) -> Drawing {
441 Drawing { size, ops }
442 }
443
444 /// The size the drawing was recorded at, in points.
445 pub fn size(&self) -> Size {
446 self.size
447 }
448
449 /// The recorded display list.
450 pub fn ops(&self) -> &[DrawOp] {
451 &self.ops
452 }
453
454 /// Render to a bitmap of `size × scale` pixels whose `info().scale` is `scale`, so
455 /// `image(&bitmap)` shows it at the drawing's point size. Await it inside `day::task`.
456 ///
457 /// `Err(ExportError::Unsupported)` where the toolkit has no offscreen painter
458 /// ([`Cap::CanvasBitmap`](day_spec::Cap::CanvasBitmap)); `Err(ExportError::TooLarge)` above
459 /// 16,384 pixels a side or 64 megapixels.
460 pub fn to_bitmap(&self, scale: f64) -> day_core::RenderBitmap {
461 day_core::render_bitmap(self.ops.clone(), self.size, scale)
462 }
463
464 /// Render to a bitmap and encode it as PNG: [`Drawing::to_bitmap`] then
465 /// [`Bitmap::encode`]. `Err(ExportError::Unsupported)` where either half is missing
466 /// ([`CanvasExports::png`] answers that up front).
467 pub fn to_png(
468 &self,
469 scale: f64,
470 ) -> impl std::future::Future<Output = Result<Vec<u8>, ExportError>> + 'static {
471 let render = self.to_bitmap(scale);
472 async move {
473 let bitmap = render.await?;
474 bitmap
475 .encode(day_spec::EncodeSpec::default())
476 .await
477 .map_err(|e| match e {
478 day_spec::ImageError::Unsupported | day_spec::ImageError::Encode => {
479 ExportError::Unsupported
480 }
481 other => ExportError::Failed(other.to_string()),
482 })
483 }
484 }
485
486 /// A one-page PDF the size of the drawing. `Err(ExportError::Unsupported)` where the
487 /// platform has no PDF writer ([`Cap::CanvasPdf`](day_spec::Cap::CanvasPdf)).
488 pub fn to_pdf(&self) -> day_core::RenderPdf {
489 PdfDocument::new().page(self.clone()).render()
490 }
491}
492
493/// A PDF of one or more drawings, one per page; each page takes its drawing's size in PDF
494/// points (1/72 inch, the same unit as canvas points: US Letter is 612 × 792, A4 595 × 842).
495///
496/// ```ignore
497/// let mut doc = PdfDocument::new().title("Quarterly report");
498/// for region in ®ions {
499/// doc = doc.page(Drawing::record(LETTER, |d, size| draw_region(d, size, region)));
500/// }
501/// let pdf = doc.render().await?;
502/// ```
503#[derive(Clone, Debug, Default, PartialEq)]
504pub struct PdfDocument {
505 pages: Vec<Drawing>,
506 info: day_spec::PdfInfo,
507}
508
509impl PdfDocument {
510 /// An empty document; rendering it before adding a page answers `ExportError::Invalid`.
511 pub fn new() -> Self {
512 Self::default()
513 }
514
515 /// Append `drawing` as the next page, at the drawing's size.
516 pub fn page(mut self, drawing: Drawing) -> Self {
517 self.pages.push(drawing);
518 self
519 }
520
521 /// The document title. Android's writer has no metadata API and ignores it.
522 pub fn title(mut self, title: impl Into<String>) -> Self {
523 self.info.title = Some(title.into());
524 self
525 }
526
527 /// The document author. Android's writer ignores it, as it does the title.
528 pub fn author(mut self, author: impl Into<String>) -> Self {
529 self.info.author = Some(author.into());
530 self
531 }
532
533 /// How many pages have been added.
534 pub fn len(&self) -> usize {
535 self.pages.len()
536 }
537
538 /// Whether no page has been added yet.
539 pub fn is_empty(&self) -> bool {
540 self.pages.is_empty()
541 }
542
543 /// Render the document through the platform's PDF writer. Await it inside `day::task`.
544 pub fn render(self) -> day_core::RenderPdf {
545 let pages = self
546 .pages
547 .into_iter()
548 .map(|d| day_spec::PdfPage {
549 size: d.size,
550 ops: d.ops,
551 })
552 .collect();
553 day_core::render_pdf(pages, self.info)
554 }
555}
556
557/// A frame clock (§8.4): an invisible, zero-size piece that calls `tick` every animation frame with
558/// the wall-clock delta since the previous frame, for as long as it is mounted. Drop it into the
559/// tree (e.g. behind a `canvas` in a `zstack`) to drive a game loop or self-driven animation: the
560/// tick mutates state `Signal`s, and a `canvas` reading them re-records that frame.
561///
562/// Shares `day::frame`'s per-window native display scheduler (docs/frames.md); Day has no
563/// timer fallback. Requests stop when the last consumer unmounts. This legacy piece retains
564/// a first delta of 1/60 s and a <=100 ms clamp; the general frame API exposes raw elapsed time
565/// and pause/resume handles. Inert on the mock backend.
566///
567/// ```ignore
568/// zstack((
569/// canvas(move |d, sz| draw(d, sz, state)).grow(),
570/// frame_clock(move |dt| step(dt, state)),
571/// ))
572/// ```
573pub fn frame_clock(tick: impl FnMut(std::time::Duration) + 'static) -> impl Piece {
574 type TickSlot = Rc<RefCell<Option<Box<dyn FnMut(std::time::Duration)>>>>;
575 // Registered on first build (in the mounting scope) and removed when that scope is disposed.
576 let slot: TickSlot = Rc::new(RefCell::new(Some(Box::new(tick))));
577 piece_fn(move |cx| {
578 if let Some(cb) = slot.borrow_mut().take() {
579 let id = day_core::add_frame_consumer(cb);
580 Scope::current().on_cleanup(move || day_core::remove_frame_consumer(id));
581 }
582 label("").frame(0.0, 0.0).build(cx)
583 })
584}
585
586// ---------------------------------------------------------------------------
587// Reactive<T>: a value, a Signal, or a closure; the generalization of IntoText/IntoFraction.
588// ---------------------------------------------------------------------------
589
590/// A parameter that is either a constant or a reactive source. `get()` is a tracked read, so any
591/// `Reactive` used inside a canvas draw closure makes that shape re-record when the source changes.
592pub enum Reactive<T: Clone + 'static> {
593 Const(T),
594 Dyn(Rc<dyn Fn() -> T>),
595}
596impl<T: Clone + 'static> Clone for Reactive<T> {
597 fn clone(&self) -> Self {
598 match self {
599 Reactive::Const(v) => Reactive::Const(v.clone()),
600 Reactive::Dyn(f) => Reactive::Dyn(f.clone()),
601 }
602 }
603}
604impl<T: Clone + 'static> Reactive<T> {
605 pub fn get(&self) -> T {
606 match self {
607 Reactive::Const(v) => v.clone(),
608 Reactive::Dyn(f) => f(),
609 }
610 }
611 pub fn get_untracked(&self) -> T {
612 match self {
613 Reactive::Const(v) => v.clone(),
614 Reactive::Dyn(f) => day_reactive::untrack(|| f()),
615 }
616 }
617}
618/// Disjoint-marker conversion (like [`IntoText`]): accepts `T`, `Signal<T>`, or `Fn() -> T`.
619pub trait IntoReactive<T: Clone + 'static, M> {
620 fn into_reactive(self) -> Reactive<T>;
621}
622impl<T: Clone + 'static> IntoReactive<T, StaticMark> for T {
623 fn into_reactive(self) -> Reactive<T> {
624 Reactive::Const(self)
625 }
626}
627impl<T: Clone + 'static> IntoReactive<T, SignalMark> for Signal<T> {
628 fn into_reactive(self) -> Reactive<T> {
629 Reactive::Dyn(Rc::new(move || self.get()))
630 }
631}
632impl<T: Clone + 'static, F: Fn() -> T + 'static> IntoReactive<T, FnMark> for F {
633 fn into_reactive(self) -> Reactive<T> {
634 Reactive::Dyn(Rc::new(self))
635 }
636}
637
638// ---------------------------------------------------------------------------
639// Conformance cases (docs/testing.md)
640// ---------------------------------------------------------------------------
641
642/// The canvas's `#[day::test]` cases, next to its constructor.
643#[cfg(feature = "conformance")]
644pub(crate) mod conformance {
645 use day_core::conformance::{Case, Drive};
646 use day_reactive::Signal;
647 use day_spec::{Cap, Color, Role, Shape, kinds};
648
649 use crate::*;
650
651 /// A canvas with an explicit role, label and a value that follows a signal reports all
652 /// three natively, after the value changes too (the showcase gauge's shape). Its value is
653 /// also its text, so a drawn readout is checked with `assert_text` like a label.
654 #[day_macros::test(day_core)]
655 fn canvas_a11y_value() -> Case {
656 let level = Signal::new(40.0f64);
657 Case::new()
658 .proves(kinds::CANVAS)
659 .proves_modifier("a11y")
660 .page(move || {
661 column((
662 canvas(move |d, size| {
663 let w = size.width * level.get() / 100.0;
664 d.fill(
665 Shape::Rect(day_spec::Rect::new(0.0, 0.0, w, size.height)),
666 Color::rgb(0.2, 0.5, 0.9),
667 );
668 })
669 .a11y(move |a| {
670 a.role(Role::Meter)
671 .label("Level")
672 .value(move || format!("{:.0}", level.get()))
673 })
674 .id(day_core::id!(meter))
675 .frame(160.0, 24.0),
676 button("Raise")
677 .action(move || level.set(80.0))
678 .id(day_core::id!(raise)),
679 ))
680 .spacing(8.0)
681 })
682 .drive(|d: Drive| async move {
683 d.a11y_audit(Some("meter")).await?;
684 d.assert_text("meter", "40").await?;
685 d.tap("raise").await?;
686 d.a11y_audit(Some("meter")).await?;
687 d.assert_text("meter", "80").await
688 })
689 }
690
691 /// Native layout must not turn a canvas into a focus scope with no focusable children.
692 #[day_macros::test(day_core)]
693 fn canvas_focus_after_layout() -> Case {
694 Case::new()
695 .proves(kinds::CANVAS)
696 .proves_duty("focus")
697 .requires(Cap::Snapshot)
698 .page(|| {
699 column((
700 canvas(|d, size| {
701 d.fill(
702 Shape::Rect(day_spec::Rect::from_size(size)),
703 Color::hex(0xff0000),
704 );
705 })
706 .frame(80.0, 60.0)
707 .id(day_core::id!(painted)),
708 canvas(|_, _| {}).frame(40.0, 40.0).id(day_core::id!(empty)),
709 ))
710 })
711 .drive(|d: Drive| async move {
712 // A real rendered frame, rather than a timing delay: requesting focus before
713 // ArkUI's first native layout hid its canvas host's loss of focusability.
714 d.shot("before-focus").await?;
715 d.sample_pixel("painted", 0.5, 0.5, "#ff0000").await?;
716 d.focus("painted").await?;
717 d.assert_focused("painted", true).await?;
718 d.sample_pixel("painted", 0.5, 0.5, "#ff0000").await?;
719 d.focus("empty").await?;
720 d.assert_focused("empty", true).await
721 })
722 }
723
724 /// Bitmap origins participate in the whole canvas transform, just like shape coordinates.
725 /// Reading the recorded rectangle alone cannot detect a native replay placing it elsewhere.
726 #[day_macros::test(day_core)]
727 fn canvas_image_transform_and_opacity() -> Case {
728 Case::new()
729 .proves(kinds::CANVAS)
730 .proves_cap(Cap::ImageDecode)
731 .requires(Cap::ImageDecode)
732 .requires(Cap::Snapshot)
733 .page(|| {
734 let bitmap = Signal::new(None::<day_core::Bitmap>);
735 day_core::decode_image_async(
736 std::sync::Arc::new(crate::image::conformance::RED_BLUE.to_vec()),
737 move |result| bitmap.set(Some(result.expect("decode the fixture PNG"))),
738 );
739 column((
740 canvas(move |d, size| {
741 d.fill(Shape::Rect(day_spec::Rect::from_size(size)), Color::WHITE);
742 if let Some(image) = bitmap.get() {
743 d.transformed(
744 day_spec::Affine::scale(2.0, 2.0)
745 .then(day_spec::Affine::rotate(std::f64::consts::FRAC_PI_2))
746 .then(day_spec::Affine::translate(160.0, 20.0)),
747 |d| {
748 d.image_with_opacity(
749 &image,
750 day_spec::Rect::new(20.0, 10.0, 40.0, 20.0),
751 0.5,
752 );
753 },
754 );
755 }
756 })
757 .frame(200.0, 200.0)
758 .id(day_core::id!(canvas_image::painted)),
759 label(move || {
760 if bitmap.get().is_some() {
761 "ready"
762 } else {
763 "loading"
764 }
765 })
766 .id(day_core::id!(canvas_image::state)),
767 ))
768 })
769 .drive(|d: Drive| async move {
770 d.assert_text("canvas_image::state", "ready").await?;
771 // Rotated and doubled: x=100..140, red y=60..100, blue y=100..140.
772 d.sample_pixel("canvas_image::painted", 0.6, 0.4, "#ff8080")
773 .await?;
774 d.sample_pixel("canvas_image::painted", 0.6, 0.6, "#8080ff")
775 .await?;
776 d.sample_pixel("canvas_image::painted", 0.4, 0.5, "#ffffff")
777 .await?;
778 d.sample_pixel("canvas_image::painted", 0.6, 0.2, "#ffffff")
779 .await
780 })
781 }
782
783 /// The drawing the export cases render: red left half, blue right half, a white disc in
784 /// the middle, and a dark bar across the bottom fifth. Solid colors, so a sample has one
785 /// right answer on every toolkit.
786 fn export_swatch(d: &mut Draw, size: day_spec::Size) {
787 let (w, h) = (size.width, size.height);
788 d.fill(
789 Shape::Rect(day_spec::Rect::new(0.0, 0.0, w / 2.0, h)),
790 Color::hex(0xff0000),
791 );
792 d.fill(
793 Shape::Rect(day_spec::Rect::new(w / 2.0, 0.0, w / 2.0, h)),
794 Color::hex(0x0000ff),
795 );
796 d.fill(
797 Shape::Ellipse(day_spec::Rect::new(
798 w / 2.0 - 15.0,
799 h / 2.0 - 15.0,
800 30.0,
801 30.0,
802 )),
803 Color::hex(0xffffff),
804 );
805 d.fill(
806 Shape::Rect(day_spec::Rect::new(0.0, h * 0.8, w, h * 0.2)),
807 Color::hex(0x202020),
808 );
809 }
810
811 /// An exported bitmap shown beside the live canvas reads the same colors in the same places:
812 /// the export path and the on-screen path paint with one painter, with the same origin and
813 /// orientation (docs/canvas.md "Exporting").
814 #[day_macros::test(day_core)]
815 fn canvas_export_matches_screen() -> Case {
816 let size = day_spec::Size::new(120.0, 80.0);
817 Case::new()
818 .proves(kinds::CANVAS)
819 .proves_cap(Cap::CanvasBitmap)
820 .requires(Cap::CanvasBitmap)
821 .requires(Cap::Snapshot)
822 .page(move || {
823 let shown = Signal::new(None::<day_core::Bitmap>);
824 day_core::render_bitmap_async(
825 Drawing::record(size, export_swatch).ops().to_vec(),
826 size,
827 2.0,
828 move |result| shown.set(result.ok()),
829 );
830 column((
831 canvas(export_swatch)
832 .frame(size.width, size.height)
833 .id(day_core::id!(export::live)),
834 keyed(
835 move || shown.get(),
836 move |bitmap| {
837 image(bitmap)
838 .stretch()
839 .frame(size.width, size.height)
840 .id(day_core::id!(export::rendered))
841 },
842 ),
843 label(move || match shown.get() {
844 Some(b) => {
845 let info = b.info();
846 format!(
847 "{}x{}@{}",
848 info.pixels.width, info.pixels.height, info.scale
849 )
850 }
851 None => "rendering".to_string(),
852 })
853 .id(day_core::id!(export::state)),
854 ))
855 .spacing(8.0)
856 })
857 .shot("default")
858 .drive(|d: Drive| async move {
859 // 120 × 80 points at 2× is 240 × 160 pixels that draw back at 120 × 80 points.
860 d.assert_text("export::state", "240x160@2").await?;
861 d.assert_size("export::rendered", 120.0, 80.0).await?;
862 for id in ["export::live", "export::rendered"] {
863 d.sample_pixel(id, 0.15, 0.3, "#ff0000").await?;
864 d.sample_pixel(id, 0.85, 0.3, "#0000ff").await?;
865 d.sample_pixel(id, 0.5, 0.5, "#ffffff").await?;
866 // The bar is at the bottom in both: a flipped export would put it on top.
867 d.sample_pixel(id, 0.5, 0.92, "#202020").await?;
868 d.sample_pixel(id, 0.15, 0.08, "#ff0000").await?;
869 }
870 Ok(())
871 })
872 }
873
874 /// Curved/path clips must retain their geometry and the transform at clipping time.
875 /// The PNG round trip must preserve transparent pixels outside that geometry too.
876 #[day_macros::test(day_core)]
877 fn canvas_clip_transform_png() -> Case {
878 fn swatch(d: &mut Draw, _: day_spec::Size) {
879 use day_spec::{Point, Rect};
880 // An empty data series must leave the transparent corners untouched.
881 d.stamp(
882 Shape::Rect(Rect::new(0.0, 0.0, 120.0, 100.0)),
883 vec![],
884 Color::BLACK,
885 );
886 d.transformed(day_geometry::Affine::translate(20.0, 10.0), |d| {
887 d.clipped(Shape::Ellipse(Rect::new(0.0, 0.0, 80.0, 80.0)), |d| {
888 d.fill(
889 Shape::Rect(Rect::new(-20.0, -10.0, 160.0, 120.0)),
890 Color::hex(0xff0000),
891 );
892 let triangle = PathBuilder::new()
893 .move_to(Point::new(40.0, 0.0))
894 .line_to(Point::new(80.0, 80.0))
895 .line_to(Point::new(0.0, 80.0))
896 .close()
897 .build();
898 d.clipped(triangle, |d| {
899 d.transformed(day_geometry::Affine::translate(-20.0, -10.0), |d| {
900 d.fill(
901 Shape::Rect(Rect::new(0.0, 0.0, 120.0, 100.0)),
902 Color::hex(0x0000ff),
903 );
904 });
905 });
906 });
907 });
908 // Restoring must remove both clips and transforms.
909 d.fill(
910 Shape::Rect(day_spec::Rect::new(0.0, 90.0, 120.0, 10.0)),
911 Color::hex(0x00ff00),
912 );
913 }
914 Case::new()
915 .proves(kinds::CANVAS)
916 .proves_cap(Cap::ImageEncode)
917 .requires(Cap::CanvasBitmap)
918 .requires(Cap::ImageEncode)
919 .requires(Cap::Snapshot)
920 .page(|| {
921 let decoded = Signal::new(None::<day_core::Bitmap>);
922 let status = Signal::new("rendering".to_string());
923 day_core::task(async move {
924 let result = Drawing::record(day_spec::Size::new(120.0, 100.0), swatch)
925 .to_png(2.0)
926 .await;
927 match result {
928 Ok(bytes) => match day_core::decode_image(std::sync::Arc::new(bytes)).await
929 {
930 Ok(bitmap) => {
931 decoded.set(Some(bitmap));
932 status.set("ready".into());
933 }
934 Err(e) => status.set(format!("decode: {e}")),
935 },
936 Err(e) => status.set(format!("export: {e}")),
937 }
938 });
939 column((
940 canvas(swatch)
941 .frame(120.0, 100.0)
942 .id(day_core::id!(clip_png::live)),
943 keyed(
944 move || decoded.get(),
945 |bitmap| {
946 image(bitmap)
947 .stretch()
948 .frame(120.0, 100.0)
949 .id(day_core::id!(clip_png::decoded))
950 },
951 ),
952 label(move || status.get()).id(day_core::id!(clip_png::state)),
953 ))
954 .background(Color::WHITE)
955 })
956 .drive(|d: Drive| async move {
957 d.assert_text("clip_png::state", "ready").await?;
958 for id in ["clip_png::live", "clip_png::decoded"] {
959 d.sample_pixel(id, 0.2, 0.14, "#ffffff").await?;
960 d.sample_pixel(id, 0.24, 0.5, "#ff0000").await?;
961 d.sample_pixel(id, 0.5, 0.6, "#0000ff").await?;
962 d.sample_pixel(id, 0.1, 0.95, "#00ff00").await?;
963 }
964 Ok(())
965 })
966 }
967
968 /// A PDF where the platform has a writer, and `Unsupported` where it has none: the bytes are
969 /// a PDF file, and a toolkit without the cap never pretends otherwise.
970 #[day_macros::test(day_core)]
971 fn canvas_pdf_document() -> Case {
972 Case::headless().run(|t: Drive| async move {
973 let page = |w, h| Drawing::record(day_spec::Size::new(w, h), export_swatch);
974 let result = PdfDocument::new()
975 .title("Conformance")
976 .author("Day")
977 .page(page(612.0, 792.0))
978 .page(page(300.0, 200.0))
979 .render()
980 .await;
981 if !canvas_exports().pdf {
982 return t.check_eq(result.err(), Some(day_spec::ExportError::Unsupported));
983 }
984 let pdf = match result {
985 Ok(pdf) => pdf,
986 Err(e) => return t.check(false, &format!("the PDF did not render: {e}")),
987 };
988 t.check(
989 pdf.starts_with(b"%PDF-"),
990 "the bytes do not start with %PDF-",
991 )?;
992 let tail = String::from_utf8_lossy(&pdf[pdf.len().saturating_sub(64)..]).to_string();
993 t.check(tail.contains("%%EOF"), "the PDF has no %%EOF trailer")?;
994 // An empty document is not a PDF of two pages: each page carries its own content.
995 t.check(
996 pdf.len() > 400,
997 &format!("a {} byte PDF is too small", pdf.len()),
998 )
999 })
1000 }
1001
1002 /// A rendered bitmap is a `Bitmap` like any decode: it reports the export scale and the
1003 /// rounded-up pixel size, and encodes to PNG where the toolkit encodes PNG.
1004 #[day_macros::test(day_core)]
1005 fn canvas_export_scale_and_png() -> Case {
1006 Case::headless().run(|t: Drive| async move {
1007 let drawing = Drawing::record(day_spec::Size::new(50.5, 20.0), export_swatch);
1008 let result = drawing.to_bitmap(3.0).await;
1009 if !canvas_exports().bitmap {
1010 return t.check_eq(result.err(), Some(day_spec::ExportError::Unsupported));
1011 }
1012 let bitmap = match result {
1013 Ok(b) => b,
1014 Err(e) => return t.check(false, &format!("the bitmap did not render: {e}")),
1015 };
1016 let info = bitmap.info();
1017 t.check_eq(info.pixels, day_spec::Size::new(152.0, 60.0))?;
1018 t.check_eq(info.scale, 3.0)?;
1019 if !canvas_exports().png() {
1020 return Ok(());
1021 }
1022 match drawing.to_png(1.0).await {
1023 Ok(png) => t.check_eq(
1024 day_spec::ImageFormat::sniff(&png),
1025 Some(day_spec::ImageFormat::Png),
1026 ),
1027 Err(e) => t.check(false, &format!("the PNG did not encode: {e}")),
1028 }
1029 })
1030 }
1031}
1032
1033#[cfg(test)]
1034mod arc_tests {
1035 use super::PathBuilder;
1036 use day_spec::{PathSeg, Point};
1037
1038 /// Walk a built path, sampling every cubic densely, and hand back the points.
1039 fn sample(segs: &[PathSeg], per_seg: usize) -> Vec<Point> {
1040 let mut out = Vec::new();
1041 let mut cur = Point::ZERO;
1042 for seg in segs {
1043 match seg {
1044 PathSeg::Move(p) | PathSeg::Line(p) => {
1045 cur = *p;
1046 out.push(cur);
1047 }
1048 PathSeg::Cubic(c1, c2, p) => {
1049 for i in 1..=per_seg {
1050 let t = i as f64 / per_seg as f64;
1051 let u = 1.0 - t;
1052 // de Casteljau, written out: the cubic Bernstein basis.
1053 out.push(Point::new(
1054 u * u * u * cur.x
1055 + 3.0 * u * u * t * c1.x
1056 + 3.0 * u * t * t * c2.x
1057 + t * t * t * p.x,
1058 u * u * u * cur.y
1059 + 3.0 * u * u * t * c1.y
1060 + 3.0 * u * t * t * c2.y
1061 + t * t * t * p.y,
1062 ));
1063 }
1064 cur = *p;
1065 }
1066 PathSeg::Quad(..) | PathSeg::Close => {}
1067 }
1068 }
1069 out
1070 }
1071
1072 /// The property that matters: every point of the emitted curve is on the circle. Pinning the
1073 /// control points instead would pin the construction rather than the result, and it is the
1074 /// result a rasterizer draws.
1075 #[test]
1076 fn an_arc_stays_on_its_circle() {
1077 let c = Point::new(100.0, 50.0);
1078 let r = 40.0;
1079 for sweep in [15.0, 90.0, 180.0, 359.0, -90.0, -270.0] {
1080 let path = PathBuilder::new().arc_to(c, r, 30.0, sweep).build();
1081 let day_spec::Shape::Path(p) = path else {
1082 panic!("arc_to builds a path")
1083 };
1084 let worst = sample(&p.segs, 24)
1085 .iter()
1086 .map(|q| ((q.x - c.x).powi(2) + (q.y - c.y).powi(2)).sqrt() - r)
1087 .fold(0.0f64, |m, e| m.max(e.abs()));
1088 // The known peak for a cubic quarter-circle, with a little headroom. The assertion
1089 // uses the real figure rather than a loose one so that a future change that split the
1090 // sweep differently or got a handle length wrong is noticed here.
1091 assert!(worst < r * 2.8e-4, "sweep {sweep}: off by {worst}");
1092 // ...and it is that good rather than accidentally better: a construction that
1093 // silently started flattening to line segments would pass a one-sided bound.
1094 if sweep.abs() >= 90.0 {
1095 assert!(
1096 worst > r * 1e-6,
1097 "sweep {sweep}: suspiciously exact ({worst})"
1098 );
1099 }
1100 }
1101 }
1102
1103 /// Degrees, 0 = +x, positive clockwise, the same convention `Shape::Arc` and `circle` use.
1104 /// A wrong sign here is the kind of thing that only shows up as a donut drawn inside out.
1105 #[test]
1106 fn arc_endpoints_follow_the_clockwise_degree_convention() {
1107 let c = Point::new(0.0, 0.0);
1108 let near = |a: Point, b: Point| (a.x - b.x).abs() < 1e-9 && (a.y - b.y).abs() < 1e-9;
1109 let ends = |start: f64, sweep: f64| {
1110 let day_spec::Shape::Path(p) = PathBuilder::new().arc_to(c, 10.0, start, sweep).build()
1111 else {
1112 panic!()
1113 };
1114 let pts = sample(&p.segs, 1);
1115 (*pts.first().unwrap(), *pts.last().unwrap())
1116 };
1117 // 0° is +x; a quarter turn clockwise lands on +y, which is down in canvas space.
1118 let (a, b) = ends(0.0, 90.0);
1119 assert!(near(a, Point::new(10.0, 0.0)), "{a:?}");
1120 assert!(near(b, Point::new(0.0, 10.0)), "{b:?}");
1121 // …and the same quarter counter-clockwise lands on -y.
1122 let (_, b) = ends(0.0, -90.0);
1123 assert!(near(b, Point::new(0.0, -10.0)), "{b:?}");
1124 }
1125
1126 /// A rounded rectangle is a contour of its own: a frame with a rounded hole is two of them,
1127 /// the inner one cut out by the even-odd rule.
1128 #[test]
1129 fn a_rounded_rect_contour_opens_and_closes_its_own_subpath() {
1130 use day_spec::{CornerRadii, FillRule, Rect};
1131 let day_spec::Shape::Path(p) = PathBuilder::new()
1132 .rule(FillRule::EvenOdd)
1133 .rounded_rect(Rect::new(0.0, 0.0, 40.0, 40.0), CornerRadii::uniform(8.0))
1134 .rounded_rect(Rect::new(10.0, 10.0, 20.0, 20.0), CornerRadii::top(4.0))
1135 .build()
1136 else {
1137 panic!("a path")
1138 };
1139 let moves = p
1140 .segs
1141 .iter()
1142 .filter(|s| matches!(s, PathSeg::Move(_)))
1143 .count();
1144 let closes = p
1145 .segs
1146 .iter()
1147 .filter(|s| matches!(s, PathSeg::Close))
1148 .count();
1149 assert_eq!((moves, closes), (2, 2));
1150 assert_eq!(p.rule, FillRule::EvenOdd);
1151 }
1152
1153 /// An arc joins what came before, which is why it is a segment and not a shape.
1154 #[test]
1155 fn an_arc_joins_the_current_subpath_but_starts_a_fresh_one_after_close() {
1156 let c = Point::new(0.0, 0.0);
1157 let segs = |b: PathBuilder| {
1158 let day_spec::Shape::Path(p) = b.build() else {
1159 panic!()
1160 };
1161 p.segs
1162 };
1163 // Nothing before it: the arc opens the subpath.
1164 let s = segs(PathBuilder::new().arc_to(c, 5.0, 0.0, 90.0));
1165 assert!(matches!(s[0], PathSeg::Move(_)), "{:?}", s[0]);
1166 // Something before it: the arc is reached by a line, so the figure is one contour.
1167 let s = segs(
1168 PathBuilder::new()
1169 .move_to(Point::new(9.0, 9.0))
1170 .arc_to(c, 5.0, 0.0, 90.0),
1171 );
1172 assert!(matches!(s[1], PathSeg::Line(_)), "{:?}", s[1]);
1173 // After a close there is no current point, so it opens a new subpath rather than drawing
1174 // a stray line back from wherever the last contour ended.
1175 let s = segs(
1176 PathBuilder::new()
1177 .move_to(Point::new(9.0, 9.0))
1178 .line_to(Point::new(1.0, 1.0))
1179 .close()
1180 .arc_to(c, 5.0, 0.0, 90.0),
1181 );
1182 assert!(matches!(s[3], PathSeg::Move(_)), "{:?}", s[3]);
1183 // A degenerate arc still reaches its start point and emits no curve.
1184 let s = segs(PathBuilder::new().arc_to(c, 5.0, 0.0, 0.0));
1185 assert_eq!(s.len(), 1);
1186 let s = segs(PathBuilder::new().arc_to(c, 0.0, 0.0, 90.0));
1187 assert_eq!(s.len(), 1);
1188 }
1189}