Skip to main content

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