Skip to main content

blockworx/canvas/
painter.rs

1use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};
2
3use blockworx_doc::block_model::Asset;
4use blockworx_geom::{Align2, Pos2, Rect, Vec2, WorldPx};
5use blockworx_paint::{
6    AnimKey, Animator, Canvas, Color, Cursor, EditText, Font, ImageHandle, Palette, PaletteStroke,
7    PointerKind, Renderer, Swatch, Waker, Zoom,
8};
9use egui::{CornerRadius, Stroke, StrokeKind, epaint::TextShape};
10
11use crate::{
12    canvas::{
13        egui_compat::{IntoEgui as _, IntoGeom as _},
14        image::ImageRegistry,
15    },
16    icons::{Icon, Icons},
17};
18/// Cap on the pixel size an SVG/PNG is rasterized to, so a hugely zoomed image
19/// can't ask the loader for an enormous texture.
20const MAX_RASTER: u32 = 2048;
21
22/// A transform-aware painter that accepts world-space coordinates and converts them
23/// to screen space internally. All sizes — font sizes, stroke widths, radii, rounding —
24/// scale with zoom so the diagram looks consistent at any zoom level. Unlike egui's
25/// Scene (which applies a GPU-level pixel transform), we re-render at the correct
26/// size each frame, so text and edges remain sharp.
27pub struct Painter {
28    inner: egui::Painter,
29    origin: Pos2,
30    zoom: Zoom,
31    translation: Vec2,
32    palette: Palette,
33    cursor: Option<Cursor>,
34    edit_text: Option<EditText>,
35    /// Shared with the Canvas ([`View`](crate::canvas::View)), so an image
36    /// registered once is drawn from the same table on every later frame.
37    images: Rc<RefCell<ImageRegistry>>,
38    /// Handles for the embedded UI icons (registered once at startup), so tools
39    /// can draw them via [`Painter::icon`]. Unused since the tag overlays were
40    /// retired; kept for the planned toolbar icons.
41    #[allow(dead_code)]
42    icons: Icons,
43    /// Whether this painter belongs to a scripted session rather than the
44    /// live canvas, and where that session's synthetic pointer rests (world
45    /// space).
46    scripted: Option<ScriptedInput>,
47}
48
49/// A scripted session's input state, standing in for the real pointer.
50#[derive(Clone, Copy)]
51pub struct ScriptedInput {
52    /// Where the session's synthetic events last put the pointer, in world
53    /// space — what [`Painter::pointer_world`] reports so hover affordances
54    /// light up under the demo cursor.
55    pub pointer: Option<Pos2>,
56}
57
58impl Painter {
59    #[allow(clippy::too_many_arguments)]
60    pub(crate) fn new(
61        inner: egui::Painter,
62        origin: Pos2,
63        zoom: Zoom,
64        translation: Vec2,
65        palette: Palette,
66        images: Rc<RefCell<ImageRegistry>>,
67        icons: Icons,
68    ) -> Self {
69        Self {
70            inner,
71            origin,
72            zoom,
73            translation,
74            palette,
75            cursor: None,
76            edit_text: None,
77            images,
78            icons,
79            scripted: None,
80        }
81    }
82
83    /// A painter with no view transform: world coordinates pass straight through
84    /// to `inner` (origin at zero, unity zoom, no translation) over a private
85    /// image registry and the default icons. This is what the offline callers —
86    /// the headless tool tests — want; only [`View`](super::View) needs the
87    /// full [`Painter::new`].
88    #[cfg(test)]
89    pub(crate) fn headless(inner: egui::Painter, palette: Palette) -> Self {
90        Self::new(
91            inner,
92            Pos2::ZERO,
93            Zoom::unity(),
94            Vec2::ZERO,
95            palette,
96            Rc::new(RefCell::new(ImageRegistry::default())),
97            Icons::default(),
98        )
99    }
100
101    /// Mark this painter as a scripted session's: from here on,
102    /// [`Painter::pointer_world`] reports the session's synthetic pointer
103    /// rather than the user's real one.
104    #[cfg(test)]
105    pub(crate) fn set_scripted(&mut self, input: ScriptedInput) {
106        self.scripted = Some(input);
107    }
108
109    /// The pointer position in world space: the real pointer on the live
110    /// canvas, the synthetic pointer in a scripted session. This is the seam
111    /// hover affordances must read the pointer through — reading
112    /// `ctx.input` directly would track the user's mouse inside the video.
113    pub fn pointer_world(&self) -> Option<Pos2> {
114        match &self.scripted {
115            Some(input) => input.pointer,
116            None => self
117                .inner
118                .ctx()
119                .input(|i| i.pointer.interact_pos())
120                .map(|p| self.screen_to_world(p.geom())),
121        }
122    }
123
124    /// The registered handle for `icon`, or `None` if it failed to register.
125    #[allow(dead_code)]
126    pub fn icon(&self, icon: Icon) -> Option<&ImageHandle> {
127        self.icons.get(icon)
128    }
129
130    pub(crate) fn take_edit_text(&mut self) -> Option<EditText> {
131        self.edit_text.take()
132    }
133
134    /// World-space position → screen-space position.
135    fn w2s(&self, world: Pos2) -> Pos2 {
136        self.origin + self.translation + world.to_vec2() * self.zoom.get()
137    }
138
139    // ── Coordinate remapping (world → screen, no drawing) ──────────────────
140
141    /// Convert a world-space rect to a screen-space rect.
142    pub fn remap_rect(&self, rect: Rect) -> Rect {
143        Rect::from_min_max(self.w2s(rect.min), self.w2s(rect.max))
144    }
145
146    /// Screen-space position → world-space position (the inverse of `w2s`). Lets
147    /// a tool map raw pointer input (which egui reports in screen space) back into
148    /// the world coordinates its geometry uses.
149    pub fn screen_to_world(&self, screen: Pos2) -> Pos2 {
150        ((screen - self.origin - self.translation) / self.zoom.get()).to_pos2()
151    }
152
153    /// Scale a world-space [`Font`] to screen space (i.e. multiply size by zoom).
154    pub fn remap_font(&self, font: &Font) -> Font {
155        Font::new(font.size * self.zoom.get(), font.family.clone())
156    }
157
158    /// Resolve a [`Swatch`] to a concrete color through the palette.
159    fn color(&self, swatch: impl Into<Swatch>) -> Color {
160        self.palette.resolve(swatch.into())
161    }
162
163    /// World-space length → screen-space length. The single place a world
164    /// magnitude (radius, rounding, stroke width, wrap width) loses its unit.
165    fn w2s_len(&self, len: WorldPx) -> f32 {
166        len.get() * self.zoom.get()
167    }
168
169    /// Resolve a palette-based stroke to a concrete egui `Stroke`: width scaled
170    /// by the current zoom, color resolved through the palette.
171    fn scale_stroke(&self, stroke: impl Into<PaletteStroke>) -> Stroke {
172        let s = stroke.into();
173        Stroke::new(self.w2s_len(s.width), self.color(s.color).egui())
174    }
175
176    /// Lay out `text` at `base_font` scaled to the current zoom, returning the
177    /// galley plus the screen-space vertical nudge that cancels epaint's
178    /// per-pixel baseline snapping.
179    ///
180    /// Re-rasterizing at `font.size * zoom` keeps text crisp, but during layout
181    /// epaint rounds each glyph's baseline to a whole physical pixel. As zoom
182    /// sweeps continuously, that rounded baseline jumps a pixel at a time, which
183    /// reads as a vertical "jiggle". The correction is the residual between the
184    /// snapped baseline (`row.pos.y + glyph.pos.y`) and the font's unrounded
185    /// ascent; shifting the draw position by it lands the first baseline at
186    /// `top + ascent`, which moves continuously with zoom. Empty / whitespace
187    /// rows have no glyph, so the correction is zero.
188    fn layout_baseline_corrected(
189        &self,
190        text: String,
191        base_font: &Font,
192        color: Color,
193    ) -> (Arc<egui::Galley>, f32) {
194        let scaled = self.remap_font(base_font).egui();
195        let galley = self.inner.layout_no_wrap(text, scaled, color.egui());
196        let correction = galley
197            .rows
198            .first()
199            .and_then(|r| {
200                r.glyphs
201                    .first()
202                    .map(|g| g.font_ascent - (r.pos.y + g.pos.y))
203            })
204            .unwrap_or(0.0);
205        (galley, correction)
206    }
207
208    /// Draw text at a world-space position, word-wrapped to `max_width`
209    /// ([`WorldPx::UNBOUNDED`] for no wrap). Font size and position scale with
210    /// zoom so text grows and shrinks with the diagram, re-rasterized at the
211    /// correct size each frame so it stays crisp. Returns the screen-space
212    /// bounding rect.
213    ///
214    /// epaint snaps each row's baseline to a whole physical pixel for crisp
215    /// text, which makes a line "jiggle" vertically as zoom sweeps through pixel
216    /// boundaries. The snap is applied per row and accumulates downward, so the
217    /// lower lines of a multi-line block jiggle the most. To keep every line
218    /// gliding smoothly we lay out and draw each row as its own galley, placing
219    /// its baseline at a continuous `ascent + row * line_height` offset — font
220    /// metrics that are rounded only to 1/32 pt, never to whole pixels.
221    // `impl ToString` by value mirrors egui's own `Painter::text`, which this
222    // wraps; taking a reference would make every call site differ from egui's.
223    #[expect(clippy::needless_pass_by_value)]
224    // Mirrors egui's `Painter::text(pos, anchor, text, font, color)`, extended with
225    // the wrap width / rotation this canvas needs. Grouping these into a struct
226    // would make every call site diverge from the API it wraps.
227    #[expect(clippy::too_many_arguments)]
228    fn draw_text_wrapped(
229        &self,
230        pos: Pos2,
231        anchor: Align2,
232        text: impl ToString,
233        font: &Font,
234        color: impl Into<Swatch>,
235        max_width: WorldPx,
236    ) -> Rect {
237        let color = self.color(color).egui();
238        let scaled = self.remap_font(font).egui();
239        let wrap = self.w2s_len(max_width);
240
241        // Lay out the whole block once: gives the anchored bounds (kept
242        // identical to `text_size` so the box frame stays in sync) and the
243        // per-row glyphs to redraw.
244        let galley = self
245            .inner
246            .layout(text.to_string(), scaled.clone(), color, wrap);
247        let rect = anchor.anchor_size(self.w2s(pos), galley.size().geom());
248
249        // Continuous (non-pixel-snapped) line metrics, read from any glyph —
250        // ascent and line height are font properties shared by every glyph of
251        // the same format. No glyphs means nothing visible to draw.
252        let Some(metrics) = galley.rows.iter().flat_map(|r| r.glyphs.iter()).next() else {
253            return rect;
254        };
255        let (ascent, line_height) = (metrics.font_ascent, metrics.line_height);
256
257        for (row, placed) in galley.rows.iter().enumerate() {
258            // Reconstruct this row's text from its glyphs (the `\n` is omitted
259            // and starts a fresh row), so the split matches egui exactly.
260            let line: String = placed.glyphs.iter().map(|g| g.chr).collect();
261            if line.is_empty() {
262                continue; // blank line: nothing to draw, but it still spaces
263            }
264            let line_galley = self.inner.layout_no_wrap(line, scaled.clone(), color);
265            // This row galley's own pixel-snapped baseline; subtracting it makes
266            // the drawn baseline land exactly on the continuous target.
267            let snapped = line_galley
268                .rows
269                .first()
270                .and_then(|r| r.glyphs.first().map(|g| r.pos.y + g.pos.y))
271                .unwrap_or(0.0);
272            let baseline = rect.min.y + ascent + row as f32 * line_height;
273            self.inner.galley(
274                Pos2::new(rect.min.x, baseline - snapped).egui(),
275                line_galley,
276                color,
277            );
278        }
279        rect
280    }
281
282    /// Measure `text` at `font` in world units, word-wrapped to `max_width`.
283    // `impl ToString` by value mirrors egui's own `Painter::text`, which this
284    // wraps; taking a reference would make every call site differ from egui's.
285    #[expect(clippy::needless_pass_by_value)]
286    fn measure_wrapped(&self, text: impl ToString, font: &Font, max_width: WorldPx) -> Vec2 {
287        // Laid out to be measured, never painted, so the ink is the "recolor me"
288        // sentinel rather than a color this module picked.
289        let galley = self.inner.layout(
290            text.to_string(),
291            self.remap_font(font).egui(),
292            Color::PLACEHOLDER.egui(),
293            self.w2s_len(max_width),
294        );
295        galley.size().geom() / self.zoom.get()
296    }
297
298    /// Draw a registered image filling the world-space `rect`. The texture is
299    /// pulled from egui's image loader by `handle.uri`; registration (the
300    /// one-time `include_bytes`) happened on the Canvas, not here. On the first
301    /// frames the loader may still be rasterizing, so request a repaint until it
302    /// is `Ready`.
303    fn draw_handle(&self, rect: Rect, handle: &ImageHandle) {
304        let screen = self.remap_rect(rect);
305        if screen.width() <= 0.0 || screen.height() <= 0.0 {
306            return;
307        }
308        let ctx = self.inner.ctx();
309        // Rasterize at the on-screen pixel size (capped) so the image stays
310        // crisp; `maintain_aspect_ratio` keeps the image undistorted.
311        let hint = egui::load::SizeHint::Size {
312            width: (screen.width().ceil() as u32).clamp(1, MAX_RASTER),
313            height: (screen.height().ceil() as u32).clamp(1, MAX_RASTER),
314            maintain_aspect_ratio: true,
315        };
316        match ctx.try_load_texture(&handle.uri, egui::TextureOptions::LINEAR, hint) {
317            Ok(egui::load::TexturePoll::Ready { texture }) => {
318                let uv = Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0));
319                // palette-exempt: egui's image tint is a multiplier, and
320                // white is its identity — an untinted image, not a color.
321                self.inner
322                    .image(texture.id, screen.egui(), uv.egui(), Color::WHITE.egui());
323            }
324            // Still decoding: ask for another frame so it appears once ready.
325            Ok(egui::load::TexturePoll::Pending { .. }) => ctx.request_repaint(),
326            // Unloadable image (bad bytes, missing loader): draw nothing.
327            Err(_) => {}
328        }
329    }
330}
331
332/// The egui `Painter` is the on-screen [`Renderer`] backend: it converts each
333/// world-space coordinate and magnitude to screen space (`w2s` / `w2s_len`) and
334/// hands the result to egui.
335impl Renderer for Painter {
336    fn rect(
337        &self,
338        rect: Rect,
339        rounding: WorldPx,
340        fill: impl Into<Swatch>,
341        stroke: impl Into<PaletteStroke>,
342    ) {
343        let screen = self.remap_rect(rect);
344        let screen_rounding = CornerRadius::same(self.w2s_len(rounding).round().min(255.0) as u8);
345        self.inner.rect(
346            screen.egui(),
347            screen_rounding,
348            self.color(fill).egui(),
349            self.scale_stroke(stroke),
350            StrokeKind::Middle,
351        );
352    }
353
354    fn line_segment(&self, points: [Pos2; 2], stroke: impl Into<PaletteStroke>) {
355        self.inner.line_segment(
356            [self.w2s(points[0]).egui(), self.w2s(points[1]).egui()],
357            self.scale_stroke(stroke),
358        );
359    }
360
361    fn line(&self, points: Vec<Pos2>, stroke: impl Into<PaletteStroke>) {
362        let screen: Vec<egui::Pos2> = points.into_iter().map(|p| self.w2s(p).egui()).collect();
363        self.inner.line(screen, self.scale_stroke(stroke));
364    }
365
366    fn circle(
367        &self,
368        center: Pos2,
369        radius: WorldPx,
370        fill: impl Into<Swatch>,
371        stroke: impl Into<PaletteStroke>,
372    ) {
373        self.inner.circle(
374            self.w2s(center).egui(),
375            self.w2s_len(radius),
376            self.color(fill).egui(),
377            self.scale_stroke(stroke),
378        );
379    }
380
381    fn add_convex_polygon(
382        &self,
383        points: Vec<Pos2>,
384        fill: impl Into<Swatch>,
385        stroke: impl Into<PaletteStroke>,
386    ) {
387        let screen: Vec<egui::Pos2> = points.into_iter().map(|p| self.w2s(p).egui()).collect();
388        let stroke = self.scale_stroke(stroke);
389        self.inner.add(egui::Shape::convex_polygon(
390            screen,
391            self.color(fill).egui(),
392            stroke,
393        ));
394    }
395
396    fn text(
397        &self,
398        pos: Pos2,
399        anchor: Align2,
400        text: impl ToString,
401        font: &Font,
402        color: impl Into<Swatch>,
403    ) -> Rect {
404        self.draw_text_wrapped(pos, anchor, text, font, color, WorldPx::UNBOUNDED)
405    }
406
407    fn text_wrapped(
408        &self,
409        pos: Pos2,
410        anchor: Align2,
411        text: impl ToString,
412        font: &Font,
413        color: impl Into<Swatch>,
414        max_width: WorldPx,
415    ) -> Rect {
416        self.draw_text_wrapped(pos, anchor, text, font, color, max_width)
417    }
418
419    /// Draw text rotated by `angle` radians, centered on `pos` via `anchor`.
420    /// Font size and position scale with zoom. Use `CENTER_CENTER` for the anchor
421    /// to guarantee the text center stays at `pos` after rotation.
422    fn rotated_text(
423        &self,
424        pos: Pos2,
425        anchor: Align2,
426        text: impl ToString,
427        font: &Font,
428        color: impl Into<Swatch>,
429        angle: f32,
430    ) {
431        let color = self.color(color);
432        let (galley, dy) = self.layout_baseline_corrected(text.to_string(), font, color);
433        let shape = TextShape::new(
434            (self.w2s(pos) + Vec2::new(0.0, dy)).egui(),
435            galley,
436            color.egui(),
437        )
438        .with_angle_and_anchor(angle, anchor.egui());
439        self.inner.add(shape);
440    }
441
442    fn text_size(&self, text: impl ToString, font: &Font) -> Vec2 {
443        self.measure_wrapped(text, font, WorldPx::UNBOUNDED)
444    }
445
446    fn text_size_wrapped(&self, text: impl ToString, font: &Font, max_width: WorldPx) -> Vec2 {
447        self.measure_wrapped(text, font, max_width)
448    }
449
450    /// Draw the image `image` filling the world-space `rect`. The image is
451    /// registered with the painter's shared `hash → handle` registry on first
452    /// use (content-addressed, so repeat calls are cheap lookups), then drawn
453    /// like any other registered image. The caller owns no handle — only the
454    /// asset itself.
455    fn draw_image(&self, rect: Rect, image: &Asset) {
456        let Ok(handle) = self.images.borrow_mut().register(self.inner.ctx(), image) else {
457            return;
458        };
459        self.draw_handle(rect, &handle);
460    }
461
462    /// The intrinsic point size of the image `image`, registering/looking it up
463    /// in the shared registry. `None` if the bytes are not a valid SVG/PNG.
464    fn image_intrinsic_size(&self, image: &Asset) -> Option<Vec2> {
465        self.images
466            .borrow_mut()
467            .register(self.inner.ctx(), image)
468            .ok()
469            .map(|handle| handle.size)
470    }
471
472    fn animator(&self) -> Option<&dyn Animator> {
473        Some(self)
474    }
475
476    fn visible_world_bounds(&self) -> Option<Rect> {
477        let clip = self.inner.clip_rect().geom();
478        Some(Rect::from_min_max(
479            self.screen_to_world(clip.min),
480            self.screen_to_world(clip.max),
481        ))
482    }
483}
484
485impl Animator for Painter {
486    /// egui interpolates a keyed value toward its goal by however long the last
487    /// frame took, and holds it between frames — so polling every frame is what
488    /// drives the easing.
489    fn animate(&self, key: AnimKey, goal: f32, over: Duration) -> f32 {
490        self.inner
491            .ctx()
492            .animate_value_with_time(key.egui(), goal, over.as_secs_f32())
493    }
494
495    fn pointer_world(&self) -> Option<Pos2> {
496        Painter::pointer_world(self)
497    }
498}
499
500impl Canvas for Painter {
501    fn set_cursor(&mut self, cursor: Cursor) {
502        self.cursor = Some(cursor);
503    }
504
505    fn cursor(&self) -> Option<Cursor> {
506        self.cursor
507    }
508
509    fn set_edit_text(&mut self, edit: EditText) {
510        self.edit_text = Some(edit);
511    }
512
513    fn remap_rect(&self, world: Rect) -> Rect {
514        Painter::remap_rect(self, world)
515    }
516
517    fn request_repaint(&self) {
518        self.inner.ctx().request_repaint();
519    }
520
521    fn request_repaint_after(&self, after: Duration) {
522        self.inner.ctx().request_repaint_after(after);
523    }
524
525    fn now(&self) -> Duration {
526        // A clock that ran backwards would be a broken host, not a lost frame:
527        // fall back to zero rather than take the drawing down with it.
528        Duration::try_from_secs_f64(self.inner.ctx().input(|i| i.time)).unwrap_or_default()
529    }
530
531    fn pointer_kind(&self) -> PointerKind {
532        if self.inner.ctx().input(egui::InputState::has_touch_screen) {
533            PointerKind::Touch
534        } else {
535            PointerKind::Mouse
536        }
537    }
538
539    fn waker(&self) -> Waker {
540        super::egui_compat::waker(self.inner.ctx())
541    }
542
543    fn image(&self, rect: Rect, handle: &ImageHandle) {
544        self.draw_handle(rect, handle);
545    }
546}