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}