Skip to main content

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 &regions {
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}