Skip to main content

blockworx_paint/
record.rs

1//! The [`Canvas`] that keeps the diagram instead of showing it.
2//!
3//! A second implementation of the live-canvas traits that names no toolkit:
4//! every draw call becomes a [`DrawOp`] in a display list, and everything a
5//! tool asks the host for — the cursor, an in-place editor, a keyed easing, a
6//! repaint — is recorded as an answer the caller reads back off [`Recorded`].
7//!
8//! It is not a stub. Colours resolve through the same [`Palette`] the on-screen
9//! painter and the SVG writer resolve through, geometry goes through the same
10//! [`Vantage`] transform, and text is measured by the host's own [`TextLayout`](crate::TextLayout)
11//! — so what the recorder measures is what the backend would have drawn.
12
13use core::time::Duration;
14use std::{
15    cell::{Cell, RefCell},
16    collections::BTreeMap,
17    rc::Rc,
18};
19
20use blockworx_doc::{block_model::Asset, hash::AssetHash};
21use blockworx_geom::{Align2, Angle, Pos2, Rect, Vec2, WorldPx};
22
23use crate::{
24    AnimKey, Animator, Canvas, Color, Cursor, Easing, EditText, Font, Palette, PaletteStroke,
25    PointerKind, Renderer, Swatch, Tick, Vantage, text::Layout,
26};
27
28/// A palette stroke as a recorded mark carries it: resolved to a colour, and
29/// scaled to the screen width the backend would have drawn.
30#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
31pub struct Stroke {
32    pub width: f32,
33    pub color: Color,
34}
35
36impl Stroke {
37    /// No outline at all.
38    pub const NONE: Self = Self {
39        width: 0.0,
40        color: Color::TRANSPARENT,
41    };
42}
43
44/// One mark in a recorded display list, in screen space with every swatch
45/// resolved and every font at the size it is drawn — the drawing as the host
46/// would receive it, not as the render path stated it.
47#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
48pub enum DrawOp {
49    Rect {
50        rect: Rect,
51        rounding: f32,
52        fill: Color,
53        stroke: Stroke,
54    },
55    LineSegment {
56        points: [Pos2; 2],
57        stroke: Stroke,
58    },
59    Line {
60        points: Vec<Pos2>,
61        stroke: Stroke,
62    },
63    Circle {
64        center: Pos2,
65        radius: f32,
66        fill: Color,
67        stroke: Stroke,
68    },
69    ConvexPolygon {
70        points: Vec<Pos2>,
71        fill: Color,
72        stroke: Stroke,
73    },
74    Text {
75        rect: Rect,
76        anchor: Align2,
77        text: String,
78        font: Font,
79        color: Color,
80    },
81    RotatedText {
82        pos: Pos2,
83        anchor: Align2,
84        text: String,
85        font: Font,
86        color: Color,
87        angle: Angle,
88    },
89    TextWrapped {
90        rect: Rect,
91        anchor: Align2,
92        text: String,
93        font: Font,
94        color: Color,
95        max_width: f32,
96    },
97    /// Artwork by the hash of its bytes: the [`Recorded::assets`] of the
98    /// frame that first drew it carry the payload, and every replay after
99    /// that finds it in the host's own table.
100    Image {
101        rect: Rect,
102        hash: AssetHash,
103    },
104}
105
106/// A frame's marks, in the order they are drawn.
107pub type DrawList = Vec<DrawOp>;
108
109/// Everything one recorded frame answered.
110pub struct Recorded {
111    pub draw_list: DrawList,
112    /// The artwork the display list names, keyed as it names it. Cloned out
113    /// of the drawing by `Arc`, so a frame that draws the same bytes twice
114    /// holds them once.
115    pub assets: BTreeMap<AssetHash, Asset>,
116    pub cursor: Option<Cursor>,
117    pub edit_text: Option<EditText>,
118    /// The soonest another frame was asked for, or `None` if none was.
119    pub repaint: Option<Duration>,
120}
121
122/// A [`Canvas`] that records. `layout` is the host's text engine, so the
123/// recorder measures the run the backend would draw; `easing` is the
124/// session's table, so an animation that started on one frame is read back on
125/// the next.
126pub struct Recording<L: crate::TextLayout> {
127    palette: Palette,
128    layout: L,
129    vantage: Vantage,
130    viewport: Rect,
131    easing: Rc<RefCell<Easing>>,
132    tick: Tick,
133    pointer: Option<Pos2>,
134    pointer_kind: PointerKind,
135    draw_list: RefCell<DrawList>,
136    assets: RefCell<BTreeMap<AssetHash, Asset>>,
137    cursor: Option<Cursor>,
138    edit_text: Option<EditText>,
139    repaint: Cell<Option<Duration>>,
140}
141
142/// What a [`Recording`] is opened with. The frame's clock, camera and pointer
143/// arrive together because they describe one frame; passing them separately
144/// would let a call site pair a camera with another frame's tick.
145pub struct Frame<L: crate::TextLayout> {
146    pub palette: Palette,
147    pub layout: L,
148    pub vantage: Vantage,
149    pub viewport: Rect,
150    pub easing: Rc<RefCell<Easing>>,
151    pub tick: Tick,
152    /// The pointer in world space, if it is over the canvas.
153    pub pointer: Option<Pos2>,
154    pub pointer_kind: PointerKind,
155}
156
157impl<L: crate::TextLayout> Recording<L> {
158    pub fn new(frame: Frame<L>) -> Self {
159        let Frame {
160            palette,
161            layout,
162            vantage,
163            viewport,
164            easing,
165            tick,
166            pointer,
167            pointer_kind,
168        } = frame;
169        Self {
170            palette,
171            layout,
172            vantage,
173            viewport,
174            easing,
175            tick,
176            pointer,
177            pointer_kind,
178            draw_list: RefCell::new(DrawList::new()),
179            assets: RefCell::new(BTreeMap::new()),
180            cursor: None,
181            edit_text: None,
182            repaint: Cell::new(None),
183        }
184    }
185
186    pub fn finish(self) -> Recorded {
187        Recorded {
188            draw_list: self.draw_list.into_inner(),
189            assets: self.assets.into_inner(),
190            cursor: self.cursor,
191            edit_text: self.edit_text,
192            repaint: self.repaint.get(),
193        }
194    }
195
196    /// The editor a tool asked for so far this frame, taken off the recording
197    /// — for a driver that reads it mid-frame, where [`Self::finish`] would
198    /// end the frame.
199    pub fn take_edit_text(&mut self) -> Option<EditText> {
200        self.edit_text.take()
201    }
202
203    fn record(&self, op: DrawOp) {
204        self.draw_list.borrow_mut().push(op);
205    }
206
207    fn w2s(&self, world: Pos2) -> Pos2 {
208        self.vantage.world_to_screen(self.viewport.min, world)
209    }
210
211    fn color(&self, swatch: impl Into<Swatch>) -> Color {
212        self.palette.resolve(swatch.into())
213    }
214
215    fn ink(&self, stroke: impl Into<PaletteStroke>) -> Stroke {
216        let stroke = stroke.into();
217        Stroke {
218            width: self.vantage.remap_len(stroke.width),
219            color: self.color(stroke.color),
220        }
221    }
222
223    /// The run's size in world units, laid out at the size it is drawn — the
224    /// backend rasterizes at `font.size * zoom`, so the recorder measures
225    /// there too and divides back.
226    fn measure(&self, text: &str, font: &Font, wrap: WorldPx) -> Vec2 {
227        self.laid(text, font, wrap) / self.vantage.zoom.get()
228    }
229
230    /// The run's screen-space size.
231    fn laid(&self, text: &str, font: &Font, wrap: WorldPx) -> Vec2 {
232        self.laid_out(text, font, wrap).size
233    }
234
235    /// The run laid out in screen space.
236    fn laid_out(&self, text: &str, font: &Font, wrap: WorldPx) -> Layout {
237        let scaled = self.vantage.remap_font(font);
238        let wrap = WorldPx::new(self.vantage.remap_len(wrap));
239        self.layout.layout(text, &scaled, wrap)
240    }
241
242    /// Record one run and answer the screen-space rect it occupies — the same
243    /// answer the on-screen painter gives, since it is the same layout.
244    // The parameters a `Renderer::text*` call carries, one for one: bundling
245    // them into a struct here would leave the trait's signature and this
246    // recorder's stating the same thing two ways.
247    #[expect(clippy::too_many_arguments)]
248    fn write(
249        &self,
250        pos: Pos2,
251        anchor: Align2,
252        text: &str,
253        font: &Font,
254        color: Color,
255        max_width: WorldPx,
256    ) -> Rect {
257        let rect = anchor.anchor_size(self.w2s(pos), self.laid(text, font, max_width));
258        let font = self.vantage.remap_font(font);
259        self.record(if max_width == WorldPx::UNBOUNDED {
260            DrawOp::Text {
261                rect,
262                anchor,
263                text: text.to_owned(),
264                font,
265                color,
266            }
267        } else {
268            DrawOp::TextWrapped {
269                rect,
270                anchor,
271                text: text.to_owned(),
272                font,
273                color,
274                max_width: self.vantage.remap_len(max_width),
275            }
276        });
277        rect
278    }
279}
280
281impl<L: crate::TextLayout> Renderer for Recording<L> {
282    fn rect(
283        &self,
284        rect: Rect,
285        rounding: WorldPx,
286        fill: impl Into<Swatch>,
287        stroke: impl Into<PaletteStroke>,
288    ) {
289        self.record(DrawOp::Rect {
290            rect: self.vantage.remap_rect(self.viewport.min, rect),
291            rounding: self.vantage.remap_len(rounding),
292            fill: self.color(fill),
293            stroke: self.ink(stroke),
294        });
295    }
296
297    fn line_segment(&self, points: [Pos2; 2], stroke: impl Into<PaletteStroke>) {
298        self.record(DrawOp::LineSegment {
299            points: [self.w2s(points[0]), self.w2s(points[1])],
300            stroke: self.ink(stroke),
301        });
302    }
303
304    fn line(&self, points: Vec<Pos2>, stroke: impl Into<PaletteStroke>) {
305        self.record(DrawOp::Line {
306            points: points.into_iter().map(|p| self.w2s(p)).collect(),
307            stroke: self.ink(stroke),
308        });
309    }
310
311    fn circle(
312        &self,
313        center: Pos2,
314        radius: WorldPx,
315        fill: impl Into<Swatch>,
316        stroke: impl Into<PaletteStroke>,
317    ) {
318        self.record(DrawOp::Circle {
319            center: self.w2s(center),
320            radius: self.vantage.remap_len(radius),
321            fill: self.color(fill),
322            stroke: self.ink(stroke),
323        });
324    }
325
326    fn add_convex_polygon(
327        &self,
328        points: Vec<Pos2>,
329        fill: impl Into<Swatch>,
330        stroke: impl Into<PaletteStroke>,
331    ) {
332        self.record(DrawOp::ConvexPolygon {
333            points: points.into_iter().map(|p| self.w2s(p)).collect(),
334            fill: self.color(fill),
335            stroke: self.ink(stroke),
336        });
337    }
338
339    fn text(
340        &self,
341        pos: Pos2,
342        anchor: Align2,
343        text: impl ToString,
344        font: &Font,
345        color: impl Into<Swatch>,
346    ) -> Rect {
347        let color = self.color(color);
348        self.write(
349            pos,
350            anchor,
351            &text.to_string(),
352            font,
353            color,
354            WorldPx::UNBOUNDED,
355        )
356    }
357
358    fn text_wrapped(
359        &self,
360        pos: Pos2,
361        anchor: Align2,
362        text: impl ToString,
363        font: &Font,
364        color: impl Into<Swatch>,
365        max_width: WorldPx,
366    ) -> Rect {
367        let color = self.color(color);
368        self.write(pos, anchor, &text.to_string(), font, color, max_width)
369    }
370
371    fn rotated_text(
372        &self,
373        pos: Pos2,
374        anchor: Align2,
375        text: impl ToString,
376        font: &Font,
377        color: impl Into<Swatch>,
378        angle: Angle,
379    ) {
380        let color = self.color(color);
381        self.record(DrawOp::RotatedText {
382            pos: self.w2s(pos),
383            anchor,
384            text: text.to_string(),
385            font: self.vantage.remap_font(font),
386            color,
387            angle,
388        });
389    }
390
391    fn text_size(&self, text: impl ToString, font: &Font) -> Vec2 {
392        self.measure(&text.to_string(), font, WorldPx::UNBOUNDED)
393    }
394
395    fn text_size_wrapped(&self, text: impl ToString, font: &Font, max_width: WorldPx) -> Vec2 {
396        self.measure(&text.to_string(), font, max_width)
397    }
398
399    fn text_layout(&self, text: &str, font: &Font, max_width: WorldPx) -> Layout {
400        self.laid_out(text, font, max_width)
401            .scaled_down(self.vantage.zoom)
402    }
403
404    fn draw_image(&self, rect: Rect, image: &Asset) {
405        let hash = image.hash();
406        self.assets
407            .borrow_mut()
408            .entry(hash)
409            .or_insert_with(|| image.clone());
410        self.record(DrawOp::Image {
411            rect: self.vantage.remap_rect(self.viewport.min, rect),
412            hash,
413        });
414    }
415
416    fn image_intrinsic_size(&self, image: &Asset) -> Option<Vec2> {
417        crate::image::image_intrinsic_size(image).ok()
418    }
419
420    fn animator(&self) -> Option<&dyn Animator> {
421        Some(self)
422    }
423
424    fn visible_world_bounds(&self) -> Option<Rect> {
425        Some(self.vantage.visible(self.viewport))
426    }
427}
428
429impl<L: crate::TextLayout> Animator for Recording<L> {
430    fn animate(&self, key: AnimKey, goal: f32, over: Duration) -> f32 {
431        let animated = self.easing.borrow_mut().animate(self.tick, key, goal, over);
432        if animated.in_progress {
433            self.request_repaint();
434        }
435        animated.value
436    }
437
438    fn pointer_world(&self) -> Option<Pos2> {
439        self.pointer
440    }
441}
442
443impl<L: crate::TextLayout> Canvas for Recording<L> {
444    fn set_cursor(&mut self, cursor: Cursor) {
445        self.cursor = Some(cursor);
446    }
447
448    fn cursor(&self) -> Option<Cursor> {
449        self.cursor
450    }
451
452    fn set_edit_text(&mut self, edit: EditText) {
453        self.edit_text = Some(edit.fitted(&*self));
454    }
455
456    fn remap_rect(&self, world: Rect) -> Rect {
457        self.vantage.remap_rect(self.viewport.min, world)
458    }
459
460    fn request_repaint(&self) {
461        self.request_repaint_after(Duration::ZERO);
462    }
463
464    fn request_repaint_after(&self, after: Duration) {
465        self.repaint.set(Some(
466            self.repaint
467                .get()
468                .map_or(after, |soonest| soonest.min(after)),
469        ));
470    }
471
472    fn now(&self) -> Duration {
473        self.tick.now()
474    }
475
476    fn pointer_kind(&self) -> PointerKind {
477        self.pointer_kind
478    }
479}