Skip to main content

blockworx_canvas2d/
replay.rs

1//! A display list, drawn.
2//!
3//! [`DrawOp`]s arrive in screen space with every swatch resolved and every
4//! font at the size it is drawn, so replaying one is a translation into
5//! `Canvas2D` calls and nothing more. Text arrives as rows the recorder laid
6//! out; the browser sets each row, in the bundled face the page was handed
7//! ([`Faces`]). What else a mark needs from the host is its images, which the
8//! registry was handed the bytes for when they arrived in a hand-off.
9
10use blockworx_geom::{Angle, Pos2, Rect};
11use blockworx_paint::text::TextRow;
12use blockworx_paint::{Color, DrawOp, FontChoice, record::Stroke};
13use web_sys::CanvasRenderingContext2d;
14
15use crate::color::Swatches;
16use crate::faces::{self, Faces, Ready};
17use crate::images::Images;
18
19/// Whether the frame just drawn is the whole diagram, or whether something it
20/// named was not ready and another frame is owed once it is.
21#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
22pub enum Repaint {
23    #[default]
24    Settled,
25    Owed,
26}
27
28impl Repaint {
29    /// Both answers over one frame: a single mark that owes a frame owes it
30    /// for the whole list.
31    #[must_use]
32    pub fn and(self, other: Self) -> Self {
33        if self == Self::Owed || other == Self::Owed {
34            Self::Owed
35        } else {
36            Self::Settled
37        }
38    }
39}
40
41/// Draw `draw_list` onto `ctx`, in order, in CSS pixels.
42pub fn replay(
43    draw_list: &[DrawOp],
44    ctx: &CanvasRenderingContext2d,
45    images: &Images,
46    faces: &Faces,
47    typeface: FontChoice,
48) -> Repaint {
49    let swatches = Swatches::default();
50    let ready = faces.ready(typeface);
51    // Wires and polylines are drawn as open paths; the app's own joins are
52    // round everywhere, and this is the only place the context is told so.
53    ctx.set_line_join("round");
54    ctx.set_line_cap("round");
55    let mut owed = Repaint::Settled;
56    for op in draw_list {
57        match op {
58            DrawOp::Rect {
59                rect,
60                rounding,
61                fill,
62                stroke,
63            } => {
64                ctx.begin_path();
65                outline_rect(ctx, *rect, *rounding);
66                paint(ctx, &swatches, *fill, *stroke);
67            }
68            DrawOp::LineSegment { points, stroke } => {
69                ctx.begin_path();
70                ctx.move_to(points[0].x.into(), points[0].y.into());
71                ctx.line_to(points[1].x.into(), points[1].y.into());
72                paint(ctx, &swatches, Color::TRANSPARENT, *stroke);
73            }
74            DrawOp::Line { points, stroke } => {
75                ctx.begin_path();
76                polyline(ctx, points);
77                paint(ctx, &swatches, Color::TRANSPARENT, *stroke);
78            }
79            DrawOp::Circle {
80                center,
81                radius,
82                fill,
83                stroke,
84            } => {
85                ctx.begin_path();
86                let _ = ctx.arc(
87                    center.x.into(),
88                    center.y.into(),
89                    f64::from(radius.max(0.0)),
90                    0.0,
91                    std::f64::consts::TAU,
92                );
93                paint(ctx, &swatches, *fill, *stroke);
94            }
95            DrawOp::ConvexPolygon {
96                points,
97                fill,
98                stroke,
99            } => {
100                ctx.begin_path();
101                polyline(ctx, points);
102                ctx.close_path();
103                paint(ctx, &swatches, *fill, *stroke);
104            }
105            DrawOp::Text {
106                rows,
107                font,
108                color,
109                angle,
110                ..
111            } => match ready {
112                Ready::Yes => {
113                    if color.a() > 0 {
114                        ctx.set_font(&faces::css(typeface, font.size));
115                        ctx.set_fill_style_str(&swatches.css(*color));
116                        for row in rows {
117                            text_row(ctx, row, *angle);
118                        }
119                    }
120                }
121                // A face loaded from bytes arrives a moment after the page
122                // does; a row set before then would be set in another face.
123                Ready::NotYet => owed = Repaint::Owed,
124                Ready::Never => {}
125            },
126            DrawOp::Image { rect, hash } => owed = owed.and(images.draw(ctx, *rect, *hash)),
127        }
128    }
129    owed
130}
131
132/// Fill then outline the path already built on `ctx`. A transparent swatch is
133/// no fill, and a stroke with no width or no colour is no outline — the same
134/// two nothings the egui backend's strokes collapse to.
135fn paint(ctx: &CanvasRenderingContext2d, swatches: &Swatches, fill: Color, stroke: Stroke) {
136    if fill.a() > 0 {
137        ctx.set_fill_style_str(&swatches.css(fill));
138        ctx.fill();
139    }
140    if stroke.width > 0.0 && stroke.color.a() > 0 {
141        ctx.set_line_width(stroke.width.into());
142        ctx.set_stroke_style_str(&swatches.css(stroke.color));
143        ctx.stroke();
144    }
145}
146
147/// A rect, rounded where the display list asks for it. `roundRect` is a
148/// recent addition to the API, so a browser without it falls back to arcs.
149fn outline_rect(ctx: &CanvasRenderingContext2d, rect: Rect, rounding: f32) {
150    let (x, y, w, h) = (
151        f64::from(rect.min.x),
152        f64::from(rect.min.y),
153        f64::from(rect.width()),
154        f64::from(rect.height()),
155    );
156    let radius = f64::from(rounding.max(0.0))
157        .min(w.abs() / 2.0)
158        .min(h.abs() / 2.0);
159    if radius <= 0.0 {
160        ctx.rect(x, y, w, h);
161    } else if ctx.round_rect_with_f64(x, y, w, h, radius).is_err() {
162        ctx.move_to(x + radius, y);
163        let _ = ctx.arc_to(x + w, y, x + w, y + h, radius);
164        let _ = ctx.arc_to(x + w, y + h, x, y + h, radius);
165        let _ = ctx.arc_to(x, y + h, x, y, radius);
166        let _ = ctx.arc_to(x, y, x + w, y, radius);
167        ctx.close_path();
168    }
169}
170
171fn polyline(ctx: &CanvasRenderingContext2d, points: &[Pos2]) {
172    let mut points = points.iter();
173    let Some(first) = points.next() else {
174        return;
175    };
176    ctx.move_to(first.x.into(), first.y.into());
177    for point in points {
178        ctx.line_to(point.x.into(), point.y.into());
179    }
180}
181
182/// One row set by the browser in the face, from its pen along its baseline
183/// and turned by `angle` about the pen. The row is the recorder's; only the
184/// glyphs within it are the browser's to place.
185fn text_row(ctx: &CanvasRenderingContext2d, row: &TextRow, angle: Angle) {
186    ctx.save();
187    // `translate` and `rotate` multiply rather than replace: the context
188    // already carries the device-pixel-ratio scale `fit` put on it.
189    let _ = ctx.translate(row.pen.x.into(), row.pen.y.into());
190    if angle != Angle::ZERO {
191        let _ = ctx.rotate(f32::from(angle).into());
192    }
193    let _ = ctx.fill_text(&row.text, 0.0, 0.0);
194    ctx.restore();
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn one_mark_that_is_not_ready_owes_the_whole_frame_a_repaint() {
203        assert_eq!(Repaint::Settled.and(Repaint::Settled), Repaint::Settled);
204        assert_eq!(Repaint::Settled.and(Repaint::Owed), Repaint::Owed);
205        assert_eq!(Repaint::Owed.and(Repaint::Settled), Repaint::Owed);
206        assert_eq!(Repaint::default(), Repaint::Settled);
207    }
208}