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}