Skip to main content

blockworx_egui/
replay.rs

1//! A display list, drawn.
2//!
3//! [`DrawOp`]s arrive in screen space with every swatch resolved and every font
4//! at the size it is drawn, so replaying one is a translation into epaint
5//! shapes and nothing more. What a mark still needs from the host is its text
6//! engine — a run is drawn row by row from a fresh layout, so the glyphs are
7//! the context's own — and its image loader, which the registry handed the
8//! bytes to when they arrived in a hand-off.
9
10use blockworx_doc::hash::AssetHash;
11use blockworx_geom::{Align2, Angle, Pos2, Rect, Vec2};
12use blockworx_paint::{Color, DrawOp, Font, record::Stroke};
13use egui::{
14    CornerRadius, Shape, StrokeKind,
15    epaint::{CircleShape, RectShape, TextShape},
16};
17
18use crate::{convert::IntoEgui as _, image::ImageRegistry};
19
20/// Cap on the pixel size an SVG/PNG is rasterized to, so a hugely zoomed image
21/// can't ask the loader for an enormous texture.
22const MAX_RASTER: u32 = 2048;
23
24/// The shapes that draw `draw_list`, in order, laid out through `painter`'s
25/// fonts. `images` holds the loader URI of every asset the front end has
26/// registered from a hand-off.
27pub fn replay(draw_list: &[DrawOp], painter: &egui::Painter, images: &ImageRegistry) -> Vec<Shape> {
28    let mut shapes = Vec::with_capacity(draw_list.len());
29    for op in draw_list {
30        match op {
31            DrawOp::Rect {
32                rect,
33                rounding,
34                fill,
35                stroke,
36            } => shapes.push(Shape::Rect(RectShape::new(
37                rect.egui(),
38                CornerRadius::same(rounding.round().min(255.0) as u8),
39                fill.egui(),
40                stroke_of(*stroke),
41                StrokeKind::Middle,
42            ))),
43            DrawOp::LineSegment { points, stroke } => shapes.push(Shape::line_segment(
44                [points[0].egui(), points[1].egui()],
45                stroke_of(*stroke),
46            )),
47            DrawOp::Line { points, stroke } => shapes.push(Shape::line(
48                points.iter().map(|p| p.egui()).collect(),
49                stroke_of(*stroke),
50            )),
51            DrawOp::Circle {
52                center,
53                radius,
54                fill,
55                stroke,
56            } => shapes.push(Shape::Circle(CircleShape {
57                center: center.egui(),
58                radius: *radius,
59                fill: fill.egui(),
60                stroke: stroke_of(*stroke),
61            })),
62            DrawOp::ConvexPolygon {
63                points,
64                fill,
65                stroke,
66            } => shapes.push(Shape::convex_polygon(
67                points.iter().map(|p| p.egui()).collect(),
68                fill.egui(),
69                stroke_of(*stroke),
70            )),
71            DrawOp::Text {
72                rect,
73                text,
74                font,
75                color,
76                ..
77            } => rows(
78                painter,
79                *rect,
80                Run {
81                    text,
82                    font,
83                    color: *color,
84                },
85                f32::INFINITY,
86                &mut shapes,
87            ),
88            DrawOp::TextWrapped {
89                rect,
90                text,
91                font,
92                color,
93                max_width,
94                ..
95            } => rows(
96                painter,
97                *rect,
98                Run {
99                    text,
100                    font,
101                    color: *color,
102                },
103                *max_width,
104                &mut shapes,
105            ),
106            DrawOp::RotatedText {
107                pos,
108                anchor,
109                text,
110                font,
111                color,
112                angle,
113            } => shapes.push(rotated(
114                painter,
115                *pos,
116                *anchor,
117                Run {
118                    text,
119                    font,
120                    color: *color,
121                },
122                *angle,
123            )),
124            DrawOp::Image { rect, hash } => shapes.extend(image(painter, images, *rect, *hash)),
125        }
126    }
127    shapes
128}
129
130fn stroke_of(stroke: Stroke) -> egui::Stroke {
131    egui::Stroke::new(stroke.width, stroke.color.egui())
132}
133
134/// What every text mark carries: the run, its face at screen size, its ink.
135#[derive(Clone, Copy)]
136struct Run<'a> {
137    text: &'a str,
138    font: &'a Font,
139    color: Color,
140}
141
142/// Draw a run row by row inside the rect the recorder measured for it.
143///
144/// epaint snaps each row's baseline to a whole physical pixel for crisp text,
145/// which makes a line "jiggle" vertically as zoom sweeps through pixel
146/// boundaries. The snap is applied per row and accumulates downward, so the
147/// lower lines of a multi-line block jiggle the most. To keep every line
148/// gliding smoothly each row is laid out and drawn as its own galley, its
149/// baseline placed at a continuous `ascent + row * line_height` offset — font
150/// metrics that are rounded only to 1/32 pt, never to whole pixels.
151fn rows(painter: &egui::Painter, rect: Rect, run: Run<'_>, wrap: f32, shapes: &mut Vec<Shape>) {
152    let font = run.font.egui();
153    let color = run.color.egui();
154    // The whole block once, for the row split the recorder measured against.
155    let galley = painter.layout(run.text.to_owned(), font.clone(), color, wrap);
156    // Continuous (non-pixel-snapped) line metrics, read from any glyph —
157    // ascent and line height are font properties shared by every glyph of
158    // the same format. No glyphs means nothing visible to draw.
159    let Some(metrics) = galley.rows.iter().flat_map(|r| r.glyphs.iter()).next() else {
160        return;
161    };
162    let (ascent, line_height) = (metrics.font_ascent, metrics.line_height);
163    for (row, placed) in galley.rows.iter().enumerate() {
164        // The row's text from its glyphs (the `\n` is omitted and starts a
165        // fresh row), so the split matches egui exactly.
166        let line: String = placed.glyphs.iter().map(|g| g.chr).collect();
167        if line.is_empty() {
168            continue; // blank line: nothing to draw, but it still spaces
169        }
170        let line_galley = painter.layout_no_wrap(line, font.clone(), color);
171        // This row galley's own pixel-snapped baseline; subtracting it makes
172        // the drawn baseline land exactly on the continuous target.
173        let snapped = line_galley
174            .rows
175            .first()
176            .and_then(|r| r.glyphs.first().map(|g| r.pos.y + g.pos.y))
177            .unwrap_or(0.0);
178        let baseline = rect.min.y + ascent + row as f32 * line_height;
179        shapes.push(Shape::galley(
180            Pos2::new(rect.min.x, baseline - snapped).egui(),
181            line_galley,
182            color,
183        ));
184    }
185}
186
187/// A run rotated by `angle` radians about `pos` via `anchor`, nudged by the
188/// residual between epaint's pixel-snapped baseline and the font's unrounded
189/// ascent so the run moves continuously as zoom sweeps.
190fn rotated(
191    painter: &egui::Painter,
192    pos: Pos2,
193    anchor: Align2,
194    run: Run<'_>,
195    angle: Angle,
196) -> Shape {
197    let color = run.color.egui();
198    let galley = painter.layout_no_wrap(run.text.to_owned(), run.font.egui(), color);
199    let correction = galley
200        .rows
201        .first()
202        .and_then(|r| {
203            r.glyphs
204                .first()
205                .map(|g| g.font_ascent - (r.pos.y + g.pos.y))
206        })
207        .unwrap_or(0.0);
208    TextShape::new((pos + Vec2::new(0.0, correction)).egui(), galley, color)
209        .with_angle_and_anchor(angle.into(), anchor.egui())
210        .into()
211}
212
213/// An image filling `rect`. The texture is pulled from egui's loader by
214/// the URI registered under `hash`; on the first frames the loader may still
215/// be rasterizing, so a repaint is asked for until it is `Ready`. A hash the
216/// registry does not hold is a hand-off the front end dropped: nothing is
217/// drawn, and the frame says so.
218fn image(
219    painter: &egui::Painter,
220    images: &ImageRegistry,
221    rect: Rect,
222    hash: AssetHash,
223) -> Option<Shape> {
224    let ctx = painter.ctx();
225    let Some(uri) = images.uri(hash) else {
226        tracing::warn!("image {hash} was painted before its bytes were registered");
227        return None;
228    };
229    if rect.width() <= 0.0 || rect.height() <= 0.0 {
230        return None;
231    }
232    // Rasterize at the on-screen pixel size (capped) so the image stays
233    // crisp; `maintain_aspect_ratio` keeps the image undistorted.
234    let hint = egui::load::SizeHint::Size {
235        width: (rect.width().ceil() as u32).clamp(1, MAX_RASTER),
236        height: (rect.height().ceil() as u32).clamp(1, MAX_RASTER),
237        maintain_aspect_ratio: true,
238    };
239    match ctx.try_load_texture(uri, egui::TextureOptions::LINEAR, hint) {
240        Ok(egui::load::TexturePoll::Ready { texture }) => {
241            let uv = Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0));
242            // palette-exempt: egui's image tint is a multiplier, and
243            // white is its identity — an untinted image, not a color.
244            let untinted = Color::WHITE.egui();
245            Some(Shape::image(texture.id, rect.egui(), uv.egui(), untinted))
246        }
247        // Still decoding: ask for another frame so it appears once ready.
248        Ok(egui::load::TexturePoll::Pending { .. }) => {
249            ctx.request_repaint();
250            None
251        }
252        // Unloadable image (bad bytes, missing loader): draw nothing.
253        Err(_) => None,
254    }
255}