Skip to main content

blockworx/canvas/
view.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3
4use blockworx_geom::{Pos2, Rangef, Rect, Vec2, pos2};
5use blockworx_paint::{Canvas as _, Color, Cursor, Event, Interaction, Palette, Zoom, ZoomStep};
6use blockworx_store::record::Camera as Recorded;
7use egui::{PointerButton, Sense, Stroke};
8
9use super::egui_compat::{IntoEgui as _, IntoGeom};
10use super::interaction::compute_interaction;
11use super::painter::Painter;
12use crate::canvas::image::ImageRegistry;
13use crate::grid::GRID_SIZE;
14use crate::icons::Icons;
15/// Largest zoom a *framing* will apply — the navigator focusing one block, or a
16/// double-click fitting the level's contents. Small content would otherwise
17/// balloon to fill the view, which reads as a jump rather than a fit. Explicit
18/// camera rects are not capped: a caller asking for a ten-cell window means
19/// it.
20const FRAME_MAX_ZOOM: f32 = 2.0;
21
22/// The gutter a double-click fit leaves around the content, in grid cells, so
23/// the outermost blocks don't sit against the viewport edge.
24const FIT_BORDER_CELLS: f32 = 4.0;
25
26/// The slack the camera framings leave around their rect, as a fraction of the
27/// content's size per side. A quarter of the content overall — the same framing
28/// they have always had, expressed as padding rather than as a fudge factor on
29/// the computed zoom.
30const CAMERA_SLACK: f32 = 0.125;
31
32/// The band the scroll wheel zooms within — deliberately narrower than [`Zoom`]'s
33/// own bounds, which the framing helpers are allowed to use in full.
34const WHEEL_ZOOM_MIN: Zoom = Zoom::new(0.25);
35const WHEEL_ZOOM_MAX: Zoom = Zoom::new(4.0);
36
37/// Zoom factor exponent per pixel of scroll delta.
38const SCROLL_ZOOM_RATE: f32 = 0.002;
39
40/// Screen-space spacing below which the minor grid lines are dropped, as they
41/// read as a wash of color rather than a grid.
42const MIN_MINOR_GRID_SPACING: f32 = 5.0;
43
44/// Every fourth grid line is a major one.
45const MAJOR_GRID_STEP: i32 = 4;
46
47/// The two weights the grid is drawn in.
48#[derive(Clone, Copy, PartialEq, Eq)]
49enum GridLine {
50    Major,
51    Minor,
52}
53
54impl GridLine {
55    fn at(index: i32) -> Self {
56        if index % MAJOR_GRID_STEP == 0 {
57            Self::Major
58        } else {
59            Self::Minor
60        }
61    }
62
63    fn stroke(self, color: Color) -> Stroke {
64        match self {
65            Self::Major => Stroke::new(1.0, color.egui()),
66            Self::Minor => Stroke::new(0.5, color.egui()),
67        }
68    }
69}
70
71/// Zoom that fits a `content`-sized box within a `viewport`-sized box, clamped
72/// to `[0.1, max_zoom]`. Padding is the caller's: it pads `content` before
73/// framing it. Pure so it can be unit-tested without a live viewport.
74fn frame_zoom(viewport: Vec2, content: Vec2, max_zoom: Zoom) -> Zoom {
75    let zoom = (viewport.x / content.x).min(viewport.y / content.y);
76    Zoom::new(zoom.min(max_zoom.get()))
77}
78
79/// The two raw colors the canvas paints its own chrome with (the background fill
80/// and the grid lines). The app resolves these from its theme and hands them to
81/// [`View::begin`], so the canvas never needs to know about roles or a theme.
82#[derive(Clone, Copy)]
83pub struct CanvasChrome {
84    pub background: Color,
85    pub grid: Color,
86}
87
88/// Focus and key results from the in-place text editor, which egui can only
89/// report after the canvas closure has already run. They are held for one frame
90/// and merged into the next [`Interaction`], so a tool sees them alongside the
91/// keys the canvas itself reports.
92#[derive(Clone, Copy, Default)]
93struct EditorFeedback {
94    lost_focus: bool,
95    enter: bool,
96    tab: bool,
97    escape: bool,
98}
99
100impl EditorFeedback {
101    fn merge_into(self, interaction: &mut Interaction) {
102        interaction.lost_focus |= self.lost_focus;
103        interaction.enter_pressed |= self.enter;
104        interaction.tab_pressed |= self.tab;
105        interaction.escape_pressed |= self.escape;
106    }
107}
108
109/// Where the camera stands: the whole of what a `view`-kind undo entry
110/// restores (spec §7.1).
111///
112/// The two numbers the view holds, not a world rect — a rect would have to be
113/// re-framed against a viewport that may have resized since, which would land
114/// the camera somewhere the user never was.
115#[derive(Clone, Copy, PartialEq, Debug)]
116pub struct Vantage {
117    pub zoom: Zoom,
118    pub translation: Vec2,
119}
120
121impl Vantage {
122    /// How a manifest row records this view (§10.1 of
123    /// `docs/log-vs-snapshot.md`): the world point at the centre of
124    /// `viewport`, and the zoom.
125    ///
126    /// Centre-and-zoom rather than the translation, which is measured
127    /// against a window size the reader may no longer have.
128    pub fn recorded(self, viewport: Vec2) -> Recorded {
129        let centre = (viewport / 2.0 - self.translation) / self.zoom.get();
130        Recorded {
131            x: centre.x,
132            y: centre.y,
133            zoom: self.zoom.get(),
134        }
135    }
136
137    /// And back, against the viewport the reader has now.
138    pub fn of_recorded(camera: Recorded, viewport: Vec2) -> Self {
139        let zoom = Zoom::new(camera.zoom);
140        Self {
141            zoom,
142            translation: viewport / 2.0 - Vec2::new(camera.x, camera.y) * zoom.get(),
143        }
144    }
145}
146
147/// Whether the camera is being worked this frame — a drag pan, or a two-finger
148/// pan/pinch in flight.
149///
150/// One answer, two readers, because they are the same question asked twice: the
151/// gesture is consumed by the view, so the active tool must not also read it as
152/// a selection or a move; and the selection overlay hides while it lasts rather
153/// than jittering along behind the object (§3.4).
154#[derive(Clone, Copy, PartialEq, Eq, Debug)]
155pub enum Camera {
156    Moving,
157    Settled,
158}
159
160/// Whether the key that turns a left-drag into a pan is held: **space**, the
161/// convention across drawing and diagram editors (Figma, Illustrator, Inkscape,
162/// Miro). A text editor owning the keyboard is typing, not panning.
163fn pan_key_held(ui: &egui::Ui) -> bool {
164    !ui.ctx().egui_wants_keyboard_input() && ui.input(|i| i.key_down(egui::Key::Space))
165}
166
167pub struct View {
168    pub zoom: Zoom,
169    pub translation: Vec2,
170    editor_feedback: EditorFeedback,
171    /// Id of the in-place editor rendered last frame, so a freshly-shown editor
172    /// (different id, or none previously) can grab keyboard focus exactly once.
173    last_edit_id: Option<egui::Id>,
174    last_mouse_down: Option<Pos2>,
175    /// Screen-space rect the canvas was drawn into last frame. Used by
176    /// `frame_rect` to compute zoom/pan against the actual viewport size.
177    viewport: Rect,
178    /// The part of the viewport the floating chrome leaves clear, measured
179    /// this frame (spec §2.1). Every framing centres in *this* rather than in
180    /// the raw viewport, so nothing lands under a pill. `Rect::NOTHING` until
181    /// a shell measures its chrome — which is what a headless caller leaves it
182    /// at, and it has no chrome to clear.
183    safe: Rect,
184    /// Whether the pointer was over the canvas response (not a floating overlay)
185    /// as of the last [`Self::begin`]. Lets the app suppress the tool cursor when
186    /// the pointer sits over the nav bar, toolbar, or selection overlay.
187    hovered: bool,
188    /// When set, the view is easing its `(zoom, translation)` toward this target
189    /// (a framing requested by `focus_on`), so navigation glides instead of
190    /// snapping. Cleared once reached, or when the user zooms/pans.
191    target_view: Option<(Zoom, Vec2)>,
192    /// Whether the primary-button drag in progress is panning the canvas (it
193    /// began with the [pan key](pan_key_held) held). Latched at drag start so
194    /// releasing the key mid-drag can't hand a half-finished pan to a tool.
195    primary_drag_pans: bool,
196    /// Whether a two-finger gesture is in progress. `MultiTouchInfo` vanishes
197    /// the instant a finger lifts, but the finger still down keeps arriving as
198    /// an ordinary drag — so the latch holds the canvas until the last one
199    /// leaves, rather than handing a half-finished pan to a tool.
200    touch_pans: bool,
201    /// What the last [`Self::begin`] made of the frame's camera input, kept for
202    /// the chrome drawn after the canvas pass (§3.4).
203    camera: Camera,
204    /// Whether the user worked the camera themselves this frame — a wheel, a
205    /// pinch, a drag pan, a zoom step. The undo stack names an entry by the
206    /// move that made it (§7.2), and a fit is not a pan.
207    worked: bool,
208    /// Registered images, keyed by content. Shared with the per-frame `Painter`
209    /// so the image/icon tools can register through it.
210    images: Rc<RefCell<ImageRegistry>>,
211    /// Handles for the embedded UI icons, registered once via
212    /// [`Self::register_icons`] and handed to each frame's `Painter`.
213    icons: Icons,
214}
215
216/// Whether a view change eases into place or jumps there.
217#[derive(Clone, Copy, Debug, PartialEq, Eq)]
218pub enum Framing {
219    Animated,
220    Immediate,
221}
222
223/// Exponential approach rate for an animated view change; higher is snappier.
224const VIEW_ANIMATION_RATE: f32 = 16.0;
225
226/// How far a span reaching from `lo` to `hi` must move along one axis to sit
227/// inside `visible_lo..visible_hi`. Zero when it already does; assumes it
228/// fits, so it never has to choose which end to leave outside.
229fn overshoot(lo: f32, hi: f32, visible_lo: f32, visible_hi: f32) -> f32 {
230    if lo < visible_lo {
231        lo - visible_lo
232    } else if hi > visible_hi {
233        hi - visible_hi
234    } else {
235        0.0
236    }
237}
238
239impl View {
240    pub fn new() -> Self {
241        Self {
242            zoom: Zoom::unity(),
243            translation: Vec2::ZERO,
244            editor_feedback: EditorFeedback::default(),
245            last_edit_id: None,
246            last_mouse_down: None,
247            viewport: Rect::ZERO,
248            safe: Rect::NOTHING,
249            hovered: false,
250            target_view: None,
251            primary_drag_pans: false,
252            touch_pans: false,
253            camera: Camera::Settled,
254            worked: false,
255            images: Rc::new(RefCell::new(ImageRegistry::default())),
256            icons: Icons::default(),
257        }
258    }
259
260    /// Register the embedded UI icons. Call once at startup (see `App::update`).
261    pub fn register_icons(&mut self, ctx: &egui::Context) {
262        self.icons = Icons::register(ctx, &mut self.images.borrow_mut());
263    }
264
265    /// Screen-space rect the canvas occupied as of the last [`Self::begin`]. Used
266    /// to place screen-space overlays (e.g. the selection bar) within the canvas.
267    pub fn viewport(&self) -> Rect {
268        self.viewport
269    }
270
271    /// Tell the view which part of its viewport the floating chrome leaves
272    /// clear (spec §2.1). Called every frame with what the chrome actually
273    /// measured, so opening or closing a piece reframes from the frame it
274    /// happens; nothing is cached across the change.
275    pub fn set_safe_region(&mut self, region: Rect) {
276        self.safe = region;
277    }
278
279    /// Where a framing centres and sizes itself: the region the chrome left,
280    /// or the whole viewport where no chrome has reported any.
281    fn framing_rect(&self) -> Rect {
282        if self.safe.is_positive() {
283            self.safe
284        } else {
285            self.viewport
286        }
287    }
288
289    /// Whether the pointer was over the canvas (not a floating overlay) as of the
290    /// last [`Self::begin`]. The app gates the tool cursor on this so it never
291    /// leaks over the nav bar, toolbar, or selection overlay.
292    pub fn canvas_hovered(&self) -> bool {
293        self.hovered
294    }
295
296    /// The viewport as it was last laid out — what a recorded camera is
297    /// measured against, in both directions.
298    pub fn viewport_size(&self) -> Vec2 {
299        self.viewport.size()
300    }
301
302    /// Current world→screen scale. Lets screen-space overlays size a world-space
303    /// gap (e.g. the selection bar's distance from its object) in pixels.
304    pub fn zoom(&self) -> Zoom {
305        self.zoom
306    }
307
308    /// Whether the camera is being worked, as of the last [`Self::begin`]. The
309    /// selection overlay is drawn after the canvas pass and reads it there.
310    pub fn camera(&self) -> Camera {
311        self.camera
312    }
313
314    /// Where the camera stands — the whole of what a `view` undo entry holds.
315    pub fn vantage(&self) -> Vantage {
316        Vantage {
317            zoom: self.zoom,
318            translation: self.translation,
319        }
320    }
321
322    /// Put the camera back where an undo entry says it stood. Any framing
323    /// still easing is abandoned: the entry *is* the framing now, and letting
324    /// the old one finish would walk the camera off the state the stack
325    /// believes it landed on.
326    pub fn stand_at(&mut self, vantage: Vantage) {
327        self.zoom = vantage.zoom;
328        self.translation = vantage.translation;
329        self.target_view = None;
330    }
331
332    /// Whether the user moved the camera themselves since the last frame,
333    /// rather than a fit or a focus moving it for them.
334    pub fn worked_camera(&self) -> bool {
335        self.worked
336    }
337
338    /// Id of the in-place editor rendered last frame. A fallback for surrendering
339    /// editor focus when `Memory::focused` can't be trusted to name it.
340    pub fn focused_edit_id(&self) -> Option<egui::Id> {
341        self.last_edit_id
342    }
343
344    /// Reset zoom and pan so `content` (world space) fits within the viewport,
345    /// centered, with a small margin around it.
346    /// Frame `content` to fill the view, snapping immediately instead of easing.
347    /// Used for layer changes (drilling into / out of a block): the block path
348    /// changed underneath, so animating a pan/zoom across unrelated content is
349    /// jarring rather than helpful.
350    pub fn fit_to_rect_instant(&mut self, content: Rect) {
351        self.frame_rect(
352            content.expand2(content.size() * CAMERA_SLACK),
353            Zoom::max_value(),
354            Framing::Immediate,
355        );
356    }
357
358    /// Fit the level's whole contents in view — the double-click gesture. Unlike
359    /// an authored camera rect this leaves a fixed [`FIT_BORDER_CELLS`] gutter
360    /// (so nothing sits against the edge) and is capped at [`FRAME_MAX_ZOOM`]
361    /// (so a nearly empty level doesn't leap to a huge magnification).
362    pub fn fit_content(&mut self, content: Rect) {
363        self.frame_rect(
364            content.expand(FIT_BORDER_CELLS * GRID_SIZE),
365            Zoom::new(FRAME_MAX_ZOOM),
366            Framing::Immediate,
367        );
368    }
369
370    /// Frame a single subject — a block picked from the navigation dialog,
371    /// the region an undo just changed — capping the zoom so a small one
372    /// doesn't blow up to fill the whole view. A framing that crosses a
373    /// scope snaps ([`Framing::Immediate`]) for the same reason a navigation
374    /// does: easing across content the camera was never on is jarring.
375    pub fn focus_on(&mut self, content: Rect, framing: Framing) {
376        self.frame_rect(
377            content.expand2(content.size() * CAMERA_SLACK),
378            Zoom::new(FRAME_MAX_ZOOM),
379            framing,
380        );
381    }
382
383    /// Bring `content` into view with the gentlest move that reaches it:
384    /// nothing at all when it is already visible, otherwise a pan — the zoom
385    /// is only touched for content that cannot fit at this one. What a paste
386    /// lands is content the viewer has never seen, so the alternative to this
387    /// is content that arrives off-screen and reads as having not arrived.
388    ///
389    /// A history step frames [`Framing::Immediate`]: the undo stack pins the
390    /// camera the step ended at, and a camera still easing toward its target
391    /// would leave the pin and the view disagreeing for every frame of the
392    /// ease.
393    pub fn bring_into_view(&mut self, content: Rect, framing: Framing) {
394        if !content.is_positive() || !self.viewport.is_positive() {
395            return;
396        }
397        let visible = self.visible_world_rect();
398        if visible.contains_rect(content) {
399            return;
400        }
401        let fits = |r: Rect| r.width() <= visible.width() && r.height() <= visible.height();
402        let gutter = content.expand(FIT_BORDER_CELLS * GRID_SIZE);
403        let target = if fits(gutter) { gutter } else { content };
404        if !fits(target) {
405            self.focus_on(content, framing);
406            return;
407        }
408        let shift = Vec2::new(
409            overshoot(target.min.x, target.max.x, visible.min.x, visible.max.x),
410            overshoot(target.min.y, target.max.y, visible.min.y, visible.max.y),
411        );
412        let panned = self.translation - shift * self.zoom.get();
413        match framing {
414            Framing::Animated => self.target_view = Some((self.zoom, panned)),
415            Framing::Immediate => {
416                self.translation = panned;
417                self.target_view = None;
418            }
419        }
420    }
421
422    fn frame_rect(&mut self, content: Rect, max_zoom: Zoom, framing: Framing) {
423        if !content.is_positive() || !self.viewport.is_positive() {
424            return;
425        }
426        // Not the viewport: the chrome floats over it, and framing against
427        // the raw rect lands the model under the tool cluster (§2.1).
428        let within = self.framing_rect();
429        let zoom = frame_zoom(within.size(), content.size(), max_zoom);
430        // world_to_screen maps `world` to `viewport.min + translation + world * zoom`.
431        // Pick translation so content.center() lands on the safe region's center.
432        let translation =
433            (within.center() - self.viewport.min) - content.center().to_vec2() * zoom.get();
434        if framing == Framing::Animated {
435            // Ease toward the framing rather than snapping (see `animate_view`).
436            self.target_view = Some((zoom, translation));
437        } else {
438            self.zoom = zoom;
439            self.translation = translation;
440            self.target_view = None;
441        }
442    }
443
444    /// Advance any in-progress framing animation one frame, easing `zoom` and
445    /// `translation` toward the target with a frame-rate-independent exponential
446    /// approach. Requests a repaint until the target is reached. Returns nothing;
447    /// the user zooming or panning clears the target so manual control wins.
448    fn animate_view(&mut self, ctx: &egui::Context) {
449        let Some((zoom, translation)) = self.target_view else {
450            return;
451        };
452        let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
453        let t = 1.0 - (-VIEW_ANIMATION_RATE * dt).exp();
454        self.zoom = Zoom::new(self.zoom.get() + (zoom.get() - self.zoom.get()) * t);
455        self.translation += (translation - self.translation) * t;
456        if (self.zoom.get() - zoom.get()).abs() < 1e-3
457            && (self.translation - translation).length() < 0.5
458        {
459            self.zoom = zoom;
460            self.translation = translation;
461            self.target_view = None;
462        } else {
463            ctx.request_repaint();
464        }
465    }
466    pub fn world_to_screen(&self, origin: Pos2, world: Pos2) -> Pos2 {
467        origin + self.translation + world.to_vec2() * self.zoom.get()
468    }
469
470    /// Screen position → world position under the current transform (origin =
471    /// the viewport min) — where a tool carried off the cluster was dropped.
472    pub fn screen_to_world_pos(&self, screen: Pos2) -> Pos2 {
473        self.screen_to_world(self.viewport.min, screen)
474    }
475
476    fn screen_to_world(&self, origin: Pos2, screen: Pos2) -> Pos2 {
477        ((screen - origin - self.translation) / self.zoom.get()).to_pos2()
478    }
479
480    /// The world rect the viewport currently shows.
481    pub fn visible_world_rect(&self) -> Rect {
482        Rect::from_min_max(
483            self.screen_to_world(self.viewport.min, self.viewport.min),
484            self.screen_to_world(self.viewport.min, self.viewport.max),
485        )
486    }
487
488    /// World-space point at the center of the current viewport, mapped with the
489    /// same transform as [`Self::screen_to_world`] (`origin` = `viewport.min`).
490    /// Falls back to the translation-based origin when the viewport hasn't been
491    /// laid out yet (no positive area), so a paste before the first frame still
492    /// lands somewhere sensible.
493    pub fn visible_world_center(&self) -> Pos2 {
494        if !self.viewport.is_positive() {
495            return (-self.translation / self.zoom.get()).to_pos2();
496        }
497        self.screen_to_world(self.viewport.min, self.viewport.center())
498    }
499
500    /// Open the canvas frame: allocate the viewport, advance any framing
501    /// animation, and take this frame's pan/zoom — everything that decides
502    /// the camera, and nothing that paints under it. The returned
503    /// [`Canvas`] is where a framing can still be changed; `palette` backs
504    /// the `Painter`'s `Swatch → Color` resolution and `chrome` supplies
505    /// the background/grid colors the caller resolved from its theme.
506    pub fn begin<'a>(
507        &'a mut self,
508        ui: &'a mut egui::Ui,
509        palette: Palette,
510        chrome: CanvasChrome,
511    ) -> Canvas<'a> {
512        let (rect, response) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
513        let rect = rect.geom();
514        self.viewport = rect;
515        self.hovered = response.hovered();
516
517        // Ease toward a pending framing (nav/fit) before reading manual input.
518        self.animate_view(ui.ctx());
519        self.worked = false;
520        let pan = self.handle_pan_zoom(ui, &response, rect);
521        self.camera = pan;
522
523        if let Some(pos) = response.interact_pointer_pos() {
524            if self.last_mouse_down.is_none() {
525                self.last_mouse_down = Some(pos.geom());
526            }
527        } else {
528            self.last_mouse_down = None;
529        }
530
531        Canvas {
532            view: self,
533            ui,
534            palette,
535            chrome,
536            rect,
537            response,
538            pan,
539        }
540    }
541
542    /// Wheel zoom (centered on the cursor), two-finger pan/pinch, and drag pan —
543    /// right/middle button, or the primary button with the [pan key](pan_key_held)
544    /// held. All are manual control, so any of them cancels a framing animation
545    /// in progress.
546    fn handle_pan_zoom(&mut self, ui: &egui::Ui, response: &egui::Response, rect: Rect) -> Camera {
547        if let Some(pan) = self.handle_touch_gesture(ui, response, rect) {
548            return pan;
549        }
550        // `hovered()` is false when a floating overlay (the nav tree, toolbar, …)
551        // sits under the pointer, so scrolling there scrolls that overlay instead
552        // of also zooming the canvas.
553        if response.hovered() {
554            let cursor = ui
555                .ctx()
556                .pointer_hover_pos()
557                .map_or(rect.center(), IntoGeom::geom);
558            // A bare wheel zooms — this canvas has no document flow to scroll
559            // past. egui diverts ctrl/⌘+wheel and trackpad pinch into
560            // `zoom_delta` instead (leaving `smooth_scroll_delta` at zero), so
561            // both channels are read: the modifier is optional, not a different
562            // gesture.
563            let (scroll, pinch) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta()));
564            let factor = (scroll * SCROLL_ZOOM_RATE).exp() * pinch;
565            if factor != 1.0 {
566                self.zoom_about(factor, cursor, rect);
567            }
568        }
569
570        // A left-drag pans while the pan key is held, for pointing devices where
571        // the other buttons are awkward. Shift+left-drag stays the selection
572        // marquee (handled by the tools), so it is not a pan gesture.
573        if response.drag_started() {
574            self.primary_drag_pans = pan_key_held(ui);
575        }
576        // The latch also covers the frame the drag stops on, so the gesture's
577        // closing event is consumed by the pan rather than reaching a tool.
578        let primary_pan = self.primary_drag_pans
579            && (response.dragged_by(PointerButton::Primary) || response.drag_stopped());
580        if response.drag_stopped() {
581            self.primary_drag_pans = false;
582        }
583        if primary_pan
584            || response.dragged_by(PointerButton::Secondary)
585            || response.dragged_by(PointerButton::Middle)
586        {
587            self.target_view = None;
588            self.translation += response.drag_delta().geom();
589            self.worked = true;
590            Camera::Moving
591        } else {
592            Camera::Settled
593        }
594    }
595
596    /// Two fingers pan and pinch the canvas together, about the gesture's own
597    /// center. `Some` means the gesture owns the frame — the primary finger also
598    /// arrives as an ordinary drag, which a tool would otherwise read as a
599    /// marquee or a wire.
600    ///
601    /// This is the only pan a touch screen has: it has no wheel, no middle
602    /// button, and no space bar to hold.
603    fn handle_touch_gesture(
604        &mut self,
605        ui: &egui::Ui,
606        response: &egui::Response,
607        rect: Rect,
608    ) -> Option<Camera> {
609        let (gesture, touching) = ui.input(|i| (i.multi_touch(), i.any_touches()));
610        if !touching {
611            self.touch_pans = false;
612            return None;
613        }
614        // A gesture that began on the canvas keeps it even if a finger strays
615        // over an overlay; one that began on an overlay never takes it.
616        let ours = self.touch_pans || response.hovered() || response.is_pointer_button_down_on();
617        let Some(gesture) = gesture.filter(|_| ours) else {
618            // The latch outlives `MultiTouchInfo`, which is gone the moment the
619            // gesture drops back to one finger.
620            return self.touch_pans.then_some(Camera::Moving);
621        };
622        self.touch_pans = true;
623        self.target_view = None;
624        self.worked = true;
625        self.translation += gesture.translation_delta.geom();
626        if gesture.zoom_delta != 1.0 {
627            self.zoom_about(gesture.zoom_delta, gesture.center_pos.geom(), rect);
628        }
629        Some(Camera::Moving)
630    }
631
632    /// Scale the zoom by `factor`, keeping the world point under `anchor` (a
633    /// screen position) where it is — the "zoom about the cursor" every zoom
634    /// input wants. `rect` is the canvas viewport, whose corner the translation
635    /// is measured from. Manual control, so it cancels a framing animation.
636    fn zoom_about(&mut self, factor: f32, anchor: Pos2, rect: Rect) {
637        self.target_view = None;
638        self.worked = true;
639        let new_zoom = Zoom::new(self.zoom.get() * factor).clamp(WHEEL_ZOOM_MIN, WHEEL_ZOOM_MAX);
640        let from_corner = anchor.to_vec2() - rect.min.to_vec2();
641        let world_offset = from_corner - self.translation;
642        self.translation = from_corner - world_offset * (new_zoom.get() / self.zoom.get());
643        self.zoom = new_zoom;
644    }
645
646    /// One keyboard zoom step, about `anchor` (the pointer) or the viewport
647    /// center when the pointer is elsewhere.
648    pub fn zoom_step(&mut self, step: ZoomStep, anchor: Option<Pos2>) {
649        if !self.viewport.is_positive() {
650            return;
651        }
652        let anchor = anchor
653            .filter(|p| self.viewport.contains(*p))
654            .unwrap_or_else(|| self.viewport.center());
655        self.zoom_about(step.factor(), anchor, self.viewport);
656    }
657
658    /// Paint the canvas background and the grid over it, drawn with the raw egui
659    /// painter (outside the palette-based [`Painter`]) so they use the chrome
660    /// colors the caller already resolved.
661    fn draw_grid(&self, painter: &egui::Painter, rect: Rect, chrome: CanvasChrome) {
662        painter.rect_filled(rect.egui(), 0.0, chrome.background.egui());
663
664        let origin = rect.min;
665        let world = Rect::from_min_max(
666            self.screen_to_world(origin, rect.min),
667            self.screen_to_world(origin, rect.max),
668        );
669        for (x, line) in self.grid_lines(world.x_range()) {
670            let sx = self.world_to_screen(origin, pos2(x, 0.0)).x;
671            painter.vline(sx, rect.y_range().egui(), line.stroke(chrome.grid));
672        }
673        for (y, line) in self.grid_lines(world.y_range()) {
674            let sy = self.world_to_screen(origin, pos2(0.0, y)).y;
675            painter.hline(rect.x_range().egui(), sy, line.stroke(chrome.grid));
676        }
677    }
678
679    /// World coordinates of the grid lines crossing `span` on one axis, each with
680    /// its weight. Minor lines are skipped once they crowd together on screen.
681    fn grid_lines(&self, span: Rangef) -> impl Iterator<Item = (f32, GridLine)> {
682        let draw_minor = GRID_SIZE * self.zoom.get() >= MIN_MINOR_GRID_SPACING;
683        ((span.min / GRID_SIZE).floor() as i32..=(span.max / GRID_SIZE).ceil() as i32)
684            .map(|i| (i as f32 * GRID_SIZE, GridLine::at(i)))
685            .filter(move |&(_, line)| line == GridLine::Major || draw_minor)
686    }
687
688    /// Render the in-place `TextEdit` the closure requested, if any, remapping it
689    /// from world space to screen space. Its focus and key results only exist
690    /// after the widget has run, so they are returned for the next frame.
691    fn render_pending_edit(&mut self, ui: &mut egui::Ui, painter: &mut Painter) -> EditorFeedback {
692        let Some(edit) = painter.take_edit_text() else {
693            self.last_edit_id = None;
694            return EditorFeedback::default();
695        };
696        let edit_id = edit.id.egui();
697        let screen_rect = painter.remap_rect(edit.position);
698        let screen_font = painter.remap_font(&edit.font).egui();
699        let mut buffer = edit.buffer.borrow_mut();
700        // A wrapping editor (the text box) must wrap at exactly the width its
701        // rendered box wraps at — egui's default wraps to the widget's inner
702        // width, which differs by egui's margin and shifts with zoom. Pin the
703        // wrap width explicitly (world units → screen) via a custom layouter so
704        // the editor and the committed box break lines identically.
705        let layout_font = screen_font.clone();
706        let hint_font = screen_font.clone();
707        let text_color = edit
708            .colors
709            .map(|c| c.text.egui())
710            .or_else(|| ui.visuals().override_text_color)
711            .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
712        let wrap_px = edit.wrap_width.map(|w| w.get() * self.zoom.get());
713        let mut wrap_at = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _avail: f32| {
714            let job = egui::text::LayoutJob::simple(
715                buf.as_str().to_owned(),
716                layout_font.clone(),
717                text_color,
718                wrap_px.unwrap_or(f32::INFINITY),
719            );
720            ui.fonts_mut(|f| f.layout_job(job))
721        };
722        let mut widget = if edit.multiline {
723            egui::TextEdit::multiline(&mut *buffer)
724        } else {
725            egui::TextEdit::singleline(&mut *buffer)
726        }
727        .id(edit_id)
728        .desired_width(f32::INFINITY)
729        .font(screen_font);
730        if edit.wrap_width.is_some() {
731            widget = widget.layouter(&mut wrap_at);
732        }
733        if let Some(limit) = edit.char_limit {
734            widget = widget.char_limit(limit);
735        }
736        if let Some(hint) = edit.hint {
737            // Render the hint at the editor's own zoom-scaled font, not egui's
738            // default body font, so it tracks the text the user will type
739            // instead of shrinking to an illegible size when zoomed in.
740            widget = widget.hint_text(egui::RichText::new(hint).font(hint_font));
741        }
742        if let Some(c) = edit.colors {
743            // Blend an in-body editor with the label it replaces.
744            widget = widget
745                .text_color(c.text.egui())
746                .background_color(c.background.egui());
747        }
748        let resp = ui.place(screen_rect.egui(), widget);
749        // Block-edit cycle editors keep Tab and Escape for themselves. egui's
750        // focus system moves focus on Tab (and clears it on Escape) at the
751        // *start* of the frame unless the focused widget's filter claims them
752        // — so consuming them after the fact is too late. Claim both here so
753        // the cycle drives them. (The editor never inserts a tab: it reads
754        // events with its own default filter, which still excludes Tab.)
755        if edit.tab_cycle {
756            ui.memory_mut(|m| {
757                m.set_focus_lock_filter(
758                    edit_id,
759                    egui::EventFilter {
760                        tab: true,
761                        escape: true,
762                        ..Default::default()
763                    },
764                );
765            });
766        }
767        // Auto-focus an editor the first frame it appears so the user can
768        // type immediately (e.g. right after creating a text box) without a
769        // separate click into the field.
770        if self.last_edit_id != Some(edit_id) {
771            resp.request_focus();
772            if edit.tab_cycle || edit.select_all_on_focus {
773                // Highlight the whole field so typing replaces it (and each
774                // tab-cycle step lands selected).
775                let mut state =
776                    egui::text_edit::TextEditState::load(ui.ctx(), edit_id).unwrap_or_default();
777                let chars = buffer.chars().count();
778                state
779                    .cursor
780                    .set_char_range(Some(egui::text::CCursorRange::two(
781                        egui::text::CCursor::new(0),
782                        egui::text::CCursor::new(chars),
783                    )));
784                state.store(ui.ctx(), edit_id);
785            }
786            self.last_edit_id = Some(edit_id);
787        }
788        // Read the Tab/Escape the cycle editor claimed above (consume so they
789        // don't leak to other widgets), to feed the tool next frame.
790        let (tab, escape) = if edit.tab_cycle && resp.has_focus() {
791            ui.input_mut(|i| {
792                (
793                    i.consume_key(egui::Modifiers::NONE, egui::Key::Tab),
794                    i.consume_key(egui::Modifiers::NONE, egui::Key::Escape),
795                )
796            })
797        } else {
798            (false, false)
799        };
800        EditorFeedback {
801            lost_focus: resp.lost_focus(),
802            enter: resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)),
803            tab,
804            escape,
805        }
806    }
807}
808
809/// The canvas frame between deciding the camera and painting under it.
810///
811/// A fit-to-content framing has to be measured from a render, and the
812/// measuring recorder ([`Extent`](blockworx_paint::Extent)) draws nothing — so the
813/// measurement belongs *in* the frame it reframes, ahead of the grid, the
814/// interaction, and the tool's passes. [`Self::paint`] consumes the handle,
815/// so a framing can never arrive after the frame it would have moved.
816pub struct Canvas<'a> {
817    view: &'a mut View,
818    ui: &'a mut egui::Ui,
819    palette: Palette,
820    chrome: CanvasChrome,
821    rect: Rect,
822    response: egui::Response,
823    pan: Camera,
824}
825
826impl Canvas<'_> {
827    /// Frame the view on what `measure` reports through a painter carrying
828    /// this frame's transform. Its clip rect holds nothing, so a pass that
829    /// draws by mistake still cannot mark the frame.
830    pub fn fit_to(&mut self, measure: impl FnOnce(&mut Painter) -> Option<Rect>) {
831        let mut painter = Painter::new(
832            self.ui.painter().with_clip_rect(Rect::NOTHING.egui()),
833            self.rect.min,
834            self.view.zoom,
835            self.view.translation,
836            self.palette.clone(),
837            self.view.images.clone(),
838            self.view.icons.clone(),
839        );
840        if let Some(content) = measure(&mut painter) {
841            self.view.fit_content(content);
842        }
843    }
844
845    /// Paint the frame: the grid, then `f` with this frame's interaction
846    /// (positions in world space) and a world-space-aware painter.
847    pub fn paint<F>(self, f: F)
848    where
849        F: FnOnce(Interaction, &mut Painter),
850    {
851        let Canvas {
852            view,
853            ui,
854            palette,
855            chrome,
856            rect,
857            response,
858            pan,
859        } = self;
860        let origin = rect.min;
861        let egui_painter = ui.painter().with_clip_rect(rect.egui());
862        view.draw_grid(&egui_painter, rect, chrome);
863
864        // Bundle mouse event + keyboard flags into a single Interaction value
865        let mut interaction = compute_interaction(
866            &response,
867            |p| view.screen_to_world(origin, p),
868            view.zoom,
869            ui,
870        );
871        if let Some(Event::DragStarted { pos }) = interaction.event.as_mut()
872            && let Some(last) = view.last_mouse_down
873        {
874            *pos = view.screen_to_world(origin, last);
875        }
876
877        if pan == Camera::Moving {
878            if matches!(
879                interaction.event,
880                Some(
881                    Event::DragStarted { .. } | Event::Dragging { .. } | Event::DragStopped { .. }
882                ),
883            ) {
884                interaction.event = None;
885            }
886            // A key-pan holds the primary button down, which would otherwise arm
887            // the press-and-hold affordances (route starts, new-pin markers).
888            interaction.press = None;
889        }
890
891        std::mem::take(&mut view.editor_feedback).merge_into(&mut interaction);
892
893        // Hand off to the caller's drawing closure
894        let mut painter = Painter::new(
895            egui_painter,
896            origin,
897            view.zoom,
898            view.translation,
899            palette,
900            view.images.clone(),
901            view.icons.clone(),
902        );
903        f(interaction, &mut painter);
904
905        // An armed or in-progress pan owns the cursor, so it is set after the
906        // tool's own choice — the app applies the painter's last word.
907        if pan == Camera::Moving {
908            painter.set_cursor(Cursor::Grabbing);
909        } else if view.hovered && pan_key_held(ui) {
910            painter.set_cursor(Cursor::Grab);
911        }
912
913        view.editor_feedback = view.render_pending_edit(ui, &mut painter);
914    }
915}
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920    use blockworx_paint::{EditId, EditText, Font};
921
922    /// Zooming about a point leaves the world under it where it was — that is
923    /// what "zoom about the cursor" means, and what the wheel, pinch, and the
924    /// keyboard steps all share.
925    #[test]
926    fn a_zoom_step_holds_the_world_under_its_anchor() {
927        let mut view = View::new();
928        view.viewport = Rect::from_min_size(pos2(30.0, 20.0), Vec2::new(800.0, 600.0));
929        view.translation = Vec2::new(17.0, -9.0);
930        let anchor = pos2(240.0, 380.0);
931        let origin = view.viewport.min;
932        let before = view.screen_to_world(origin, anchor);
933
934        view.zoom_step(ZoomStep::In, Some(anchor));
935        assert!(
936            view.zoom > Zoom::unity(),
937            "zooming in should raise the zoom"
938        );
939        let after = view.screen_to_world(origin, anchor);
940        assert!(
941            before.distance(after) < 0.01,
942            "the world under the cursor moved: {before:?} -> {after:?}"
943        );
944
945        // Out again returns to where it started (the step factors are inverses).
946        view.zoom_step(ZoomStep::Out, Some(anchor));
947        assert!((view.zoom.get() - 1.0).abs() < 1e-5, "{:?}", view.zoom);
948    }
949
950    /// With the pointer off the canvas the step falls back to the viewport
951    /// center, so a keyboard zoom always has an anchor.
952    #[test]
953    fn a_zoom_step_without_a_pointer_uses_the_viewport_center() {
954        let mut view = View::new();
955        view.viewport = Rect::from_min_size(pos2(0.0, 0.0), Vec2::new(800.0, 600.0));
956        let origin = view.viewport.min;
957        let center = view.viewport.center();
958        let before = view.screen_to_world(origin, center);
959        // A pointer outside the canvas is ignored, like no pointer at all.
960        view.zoom_step(ZoomStep::In, Some(pos2(-50.0, -50.0)));
961        assert!(before.distance(view.screen_to_world(origin, center)) < 0.01);
962    }
963
964    /// The three answers [`View::bring_into_view`] gives, in the order it
965    /// prefers them: nothing at all, a pan, and — only for content that
966    /// cannot fit at this zoom — a reframing.
967    #[test]
968    fn bringing_content_into_view_moves_as_little_as_it_can() {
969        let settled = |view: &mut View| {
970            if let Some((zoom, translation)) = view.target_view.take() {
971                view.zoom = zoom;
972                view.translation = translation;
973            }
974            view.visible_world_rect()
975        };
976        let mut view = View::new();
977        view.viewport = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
978        let visible = view.visible_world_rect();
979        assert!(
980            visible.width() > 0.0 && visible.height() > 0.0,
981            "precondition: the view shows something to be outside of",
982        );
983
984        let inside = Rect::from_min_size(pos2(100.0, 100.0), Vec2::splat(50.0));
985        assert!(visible.contains_rect(inside), "precondition: already shown");
986        view.bring_into_view(inside, Framing::Animated);
987        assert!(
988            view.target_view.is_none(),
989            "content already in view moved the camera",
990        );
991
992        let beside = Rect::from_min_size(pos2(900.0, 100.0), Vec2::splat(50.0));
993        assert!(
994            !visible.contains_rect(beside) && beside.width() < visible.width(),
995            "precondition: outside the view, and small enough to pan to",
996        );
997        let zoom_before = view.zoom.get();
998        view.bring_into_view(beside, Framing::Animated);
999        assert_eq!(
1000            view.target_view.expect("the camera moves").0.get(),
1001            zoom_before,
1002            "a pan must not touch the zoom",
1003        );
1004        assert!(settled(&mut view).contains_rect(beside));
1005
1006        let mut view = View::new();
1007        view.viewport = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
1008        let huge = Rect::from_min_size(pos2(400.0, 0.0), Vec2::new(2000.0, 300.0));
1009        assert!(
1010            huge.width() > view.visible_world_rect().width(),
1011            "precondition: no pan can hold this at the current zoom",
1012        );
1013        view.bring_into_view(huge, Framing::Animated);
1014        assert!(
1015            view.target_view.expect("the camera moves").0.get() < view.zoom.get(),
1016            "content too wide for the view must be zoomed out to",
1017        );
1018        assert!(settled(&mut view).contains_rect(huge));
1019    }
1020
1021    /// A double-click fit keeps a [`FIT_BORDER_CELLS`] gutter around the
1022    /// content and never magnifies past [`FRAME_MAX_ZOOM`].
1023    #[test]
1024    fn a_content_fit_leaves_a_border_and_caps_the_zoom() {
1025        let mut view = View::new();
1026        view.viewport = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
1027        // Wide content: the fit is bound by the content, not the cap, so the
1028        // gutter is what decides where its edges land.
1029        let content = Rect::from_min_size(pos2(100.0, 100.0), Vec2::new(1600.0, 400.0));
1030        view.fit_content(content);
1031        assert!(
1032            view.zoom.get() < FRAME_MAX_ZOOM,
1033            "the cap should not bind here"
1034        );
1035        let origin = view.viewport.min;
1036        let left = view.world_to_screen(origin, content.min).x;
1037        let right = view.world_to_screen(origin, content.max).x;
1038        let gutter = FIT_BORDER_CELLS * GRID_SIZE * view.zoom.get();
1039        assert!(
1040            left - view.viewport.left() >= gutter - 0.5,
1041            "content starts {left} with only {} of gutter",
1042            left - view.viewport.left()
1043        );
1044        assert!(view.viewport.right() - right >= gutter - 0.5);
1045
1046        // A nearly empty level would fit at a huge zoom; the cap holds it.
1047        view.fit_content(Rect::from_min_size(pos2(0.0, 0.0), Vec2::new(10.0, 10.0)));
1048        assert_eq!(view.zoom, Zoom::new(FRAME_MAX_ZOOM));
1049    }
1050
1051    #[test]
1052    fn focus_zoom_caps_small_content_but_fit_zooms_in_further() {
1053        let viewport = Vec2::new(800.0, 600.0);
1054        let tiny = Vec2::new(10.0, 10.0);
1055        let focus_cap = Zoom::new(FRAME_MAX_ZOOM);
1056        // A tiny block would fit at a huge zoom; focus_on caps it.
1057        assert_eq!(frame_zoom(viewport, tiny, focus_cap), focus_cap);
1058        // fit_to_rect's higher cap lets the same block zoom in further.
1059        assert!(frame_zoom(viewport, tiny, Zoom::max_value()) > focus_cap);
1060    }
1061
1062    /// One frame of the canvas, fed `events`, returning the interaction the
1063    /// drawing closure saw.
1064    fn frame(ctx: &egui::Context, view: &mut View, events: Vec<egui::Event>) -> Option<Event> {
1065        let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
1066        let input = egui::RawInput {
1067            screen_rect: Some(screen.egui()),
1068            events,
1069            ..Default::default()
1070        };
1071        let mut seen = None;
1072        let chrome = CanvasChrome {
1073            background: Color::BLACK,
1074            grid: Color::GRAY,
1075        };
1076        ctx.run_ui(input, |ui| {
1077            view.begin(ui, Palette::tokyo_night_moon(), chrome)
1078                .paint(|interaction, _painter| {
1079                    seen = interaction.event;
1080                });
1081        })
1082        .drop_without_applying_deltas();
1083        seen
1084    }
1085
1086    fn key_down(key: egui::Key) -> egui::Event {
1087        egui::Event::Key {
1088            key,
1089            physical_key: None,
1090            pressed: true,
1091            repeat: false,
1092            modifiers: egui::Modifiers::default(),
1093        }
1094    }
1095
1096    fn key_up(key: egui::Key) -> egui::Event {
1097        egui::Event::Key {
1098            key,
1099            physical_key: None,
1100            pressed: false,
1101            repeat: false,
1102            modifiers: egui::Modifiers::default(),
1103        }
1104    }
1105
1106    /// The primary button going down at `pos` (these tests never release it —
1107    /// every gesture under test is judged mid-drag).
1108    fn press(pos: Pos2) -> egui::Event {
1109        egui::Event::PointerButton {
1110            pos: pos.egui(),
1111            button: PointerButton::Primary,
1112            pressed: true,
1113            modifiers: egui::Modifiers::default(),
1114        }
1115    }
1116
1117    /// Space+left-drag pans (the drawing-app convention), and the tool must not
1118    /// also see the drag — the view consumes it.
1119    #[test]
1120    fn space_plus_left_drag_pans_instead_of_reaching_the_tool() {
1121        let ctx = egui::Context::default();
1122        let mut view = View::new();
1123        let start = pos2(400.0, 300.0);
1124        frame(&ctx, &mut view, vec![key_down(egui::Key::Space)]);
1125        frame(
1126            &ctx,
1127            &mut view,
1128            vec![egui::Event::PointerMoved(start.egui())],
1129        );
1130        frame(&ctx, &mut view, vec![press(start)]);
1131        let before = view.translation;
1132        let seen = frame(
1133            &ctx,
1134            &mut view,
1135            vec![egui::Event::PointerMoved(
1136                (start + Vec2::new(60.0, -25.0)).egui(),
1137            )],
1138        );
1139        assert_eq!(view.translation - before, Vec2::new(60.0, -25.0));
1140        assert!(
1141            !matches!(
1142                seen,
1143                Some(Event::DragStarted { .. } | Event::Dragging { .. })
1144            ),
1145            "the pan leaked to the tool as {seen:?}"
1146        );
1147    }
1148
1149    /// The same drag without the key belongs to the tool: no pan, and the tool
1150    /// sees the gesture.
1151    #[test]
1152    fn a_plain_left_drag_is_left_to_the_tool() {
1153        let ctx = egui::Context::default();
1154        let mut view = View::new();
1155        let start = pos2(400.0, 300.0);
1156        frame(
1157            &ctx,
1158            &mut view,
1159            vec![egui::Event::PointerMoved(start.egui())],
1160        );
1161        frame(&ctx, &mut view, vec![press(start)]);
1162        let before = view.translation;
1163        let seen = frame(
1164            &ctx,
1165            &mut view,
1166            vec![egui::Event::PointerMoved(
1167                (start + Vec2::new(60.0, -25.0)).egui(),
1168            )],
1169        );
1170        assert_eq!(view.translation, before, "a plain drag must not pan");
1171        assert!(
1172            matches!(
1173                seen,
1174                Some(Event::DragStarted { .. } | Event::Dragging { .. })
1175            ),
1176            "the tool never saw the drag ({seen:?})"
1177        );
1178    }
1179
1180    /// Releasing the key mid-drag leaves the gesture a pan: it was latched when
1181    /// the drag began, so no tool inherits a half-finished pan.
1182    #[test]
1183    fn releasing_the_key_mid_drag_keeps_panning() {
1184        let ctx = egui::Context::default();
1185        let mut view = View::new();
1186        let start = pos2(400.0, 300.0);
1187        frame(&ctx, &mut view, vec![key_down(egui::Key::Space)]);
1188        frame(
1189            &ctx,
1190            &mut view,
1191            vec![egui::Event::PointerMoved(start.egui())],
1192        );
1193        frame(&ctx, &mut view, vec![press(start)]);
1194        let mut at = start + Vec2::new(30.0, 0.0);
1195        frame(&ctx, &mut view, vec![egui::Event::PointerMoved(at.egui())]);
1196        let before = view.translation;
1197        at += Vec2::new(20.0, 10.0);
1198        let seen = frame(
1199            &ctx,
1200            &mut view,
1201            vec![
1202                key_up(egui::Key::Space),
1203                egui::Event::PointerMoved(at.egui()),
1204            ],
1205        );
1206        assert_eq!(view.translation - before, Vec2::new(20.0, 10.0));
1207        assert!(!matches!(seen, Some(Event::Dragging { .. })), "{seen:?}");
1208    }
1209
1210    /// One finger's touch event. A touch screen delivers every finger; egui
1211    /// derives the pinch and the two-finger translation from the set.
1212    fn touch(id: u64, phase: egui::TouchPhase, pos: Pos2) -> egui::Event {
1213        egui::Event::Touch {
1214            device_id: egui::TouchDeviceId(0),
1215            id: egui::TouchId(id),
1216            phase,
1217            pos: pos.egui(),
1218            force: None,
1219        }
1220    }
1221
1222    /// Both fingers of a two-finger gesture moving to `at`, plus the primary
1223    /// finger's pointer event — which is what the web backend also sends, and
1224    /// what a tool would read as a drag if the view didn't consume it.
1225    fn two_fingers(phase: egui::TouchPhase, at: [Pos2; 2]) -> Vec<egui::Event> {
1226        vec![
1227            egui::Event::PointerMoved(at[0].egui()),
1228            touch(0, phase, at[0]),
1229            touch(1, phase, at[1]),
1230        ]
1231    }
1232
1233    /// Put two fingers down on `at` and hold them there for one frame. The
1234    /// leading empty frame gives egui the canvas rect to hit-test the press
1235    /// against; adding a finger makes the touch averages jump, so egui withholds
1236    /// a delta until the frame after that.
1237    fn start_two_finger_gesture(ctx: &egui::Context, view: &mut View, at: [Pos2; 2]) {
1238        frame(ctx, view, Vec::new());
1239        let mut down = two_fingers(egui::TouchPhase::Start, at);
1240        down.push(press(at[0]));
1241        frame(ctx, view, down);
1242        frame(ctx, view, two_fingers(egui::TouchPhase::Move, at));
1243    }
1244
1245    /// Two fingers pan the canvas. This is the only pan a touch screen has —
1246    /// there is no wheel, no middle button, and no space bar — and egui reports
1247    /// it *only* through `multi_touch`: a touch drag leaves `smooth_scroll_delta`
1248    /// at zero, so a view that reads scroll alone never moves.
1249    #[test]
1250    fn two_fingers_pan_the_canvas() {
1251        let ctx = egui::Context::default();
1252        let mut view = View::new();
1253        let start = [pos2(360.0, 300.0), pos2(440.0, 300.0)];
1254        let step = Vec2::new(20.0, -12.0);
1255
1256        start_two_finger_gesture(&ctx, &mut view, start);
1257        frame(
1258            &ctx,
1259            &mut view,
1260            two_fingers(egui::TouchPhase::Move, start.map(|p| p + step)),
1261        );
1262        let before = view.translation;
1263        let zoom_before = view.zoom;
1264        let seen = frame(
1265            &ctx,
1266            &mut view,
1267            two_fingers(egui::TouchPhase::Move, start.map(|p| p + step * 2.0)),
1268        );
1269
1270        assert_eq!(
1271            view.translation - before,
1272            step,
1273            "the canvas did not follow the fingers"
1274        );
1275        assert_eq!(
1276            view.zoom, zoom_before,
1277            "the fingers held their spacing, so nothing should have zoomed"
1278        );
1279        assert!(
1280            !matches!(
1281                seen,
1282                Some(Event::DragStarted { .. } | Event::Dragging { .. })
1283            ),
1284            "the primary finger reached the tool as a drag ({seen:?})"
1285        );
1286    }
1287
1288    /// Spreading the fingers zooms about the gesture's own center, holding the
1289    /// world under it — the pinch counterpart of the wheel's zoom-about-cursor.
1290    #[test]
1291    fn pinching_zooms_about_the_gesture_center() {
1292        let ctx = egui::Context::default();
1293        let mut view = View::new();
1294        let center = pos2(400.0, 300.0);
1295        let reach = Vec2::new(40.0, 0.0);
1296        let start = [center - reach, center + reach];
1297
1298        start_two_finger_gesture(&ctx, &mut view, start);
1299        let origin = view.viewport.min;
1300        let world_before = view.screen_to_world(origin, center);
1301
1302        frame(
1303            &ctx,
1304            &mut view,
1305            two_fingers(
1306                egui::TouchPhase::Move,
1307                [center - reach * 2.0, center + reach * 2.0],
1308            ),
1309        );
1310
1311        assert!(
1312            view.zoom.get() > 1.5,
1313            "spreading the fingers should have doubled the zoom, got {:?}",
1314            view.zoom
1315        );
1316        let world_after = view.screen_to_world(origin, center);
1317        assert!(
1318            world_before.distance(world_after) < 0.01,
1319            "the world under the pinch moved: {world_before:?} -> {world_after:?}"
1320        );
1321    }
1322
1323    /// Lifting one of two fingers must not hand the remaining one to a tool: the
1324    /// gesture owns the canvas until the last finger leaves.
1325    #[test]
1326    fn the_last_finger_of_a_pan_never_becomes_a_tool_drag() {
1327        let ctx = egui::Context::default();
1328        let mut view = View::new();
1329        let start = [pos2(360.0, 300.0), pos2(440.0, 300.0)];
1330        let step = Vec2::new(25.0, 0.0);
1331
1332        start_two_finger_gesture(&ctx, &mut view, start);
1333        frame(
1334            &ctx,
1335            &mut view,
1336            two_fingers(egui::TouchPhase::Move, start.map(|p| p + step)),
1337        );
1338        // Second finger up; the first keeps sliding.
1339        frame(
1340            &ctx,
1341            &mut view,
1342            vec![touch(1, egui::TouchPhase::End, start[1] + step)],
1343        );
1344        let seen = frame(
1345            &ctx,
1346            &mut view,
1347            vec![
1348                egui::Event::PointerMoved((start[0] + step * 2.0).egui()),
1349                touch(0, egui::TouchPhase::Move, start[0] + step * 2.0),
1350            ],
1351        );
1352        assert!(
1353            !matches!(
1354                seen,
1355                Some(Event::DragStarted { .. } | Event::Dragging { .. })
1356            ),
1357            "the trailing finger reached the tool as a drag ({seen:?})"
1358        );
1359
1360        // Once every finger is up the canvas is the tool's again.
1361        frame(
1362            &ctx,
1363            &mut view,
1364            vec![touch(0, egui::TouchPhase::End, start[0] + step * 2.0)],
1365        );
1366        assert!(!view.touch_pans, "the gesture latch outlived the gesture");
1367    }
1368
1369    /// An open in-place editor must not hammer repaints. On the desktop a
1370    /// repaint storm is a warm fan; in a browser tab on a tablet it is an app
1371    /// that stops answering, and the editor is the one thing there that cannot
1372    /// be dismissed without a keyboard.
1373    #[test]
1374    fn an_open_editor_settles() {
1375        let buffer = Rc::new(RefCell::new(String::from("Amplifier")));
1376        let mut view = View::new();
1377        let chrome = CanvasChrome {
1378            background: Color::BLACK,
1379            grid: Color::GRAY,
1380        };
1381        let settle = crate::tools::settle::probe(30, |ui| {
1382            // The caret's own blink would otherwise vary the shape count; it is
1383            // a timed wake, not the hammering this is looking for.
1384            ui.style_mut().visuals.text_cursor.blink = false;
1385            view.begin(ui, Palette::tokyo_night_moon(), chrome)
1386                .paint(|_, painter| {
1387                    painter.set_edit_text(EditText {
1388                        position: Rect::from_min_size(pos2(100.0, 100.0), Vec2::new(120.0, 20.0)),
1389                        buffer: Rc::clone(&buffer),
1390                        font: Font::proportional(14.0),
1391                        id: EditId::of("settle_title_edit"),
1392                        multiline: false,
1393                        char_limit: Some(crate::grid::MAX_LABEL_CHARS),
1394                        tab_cycle: true,
1395                        select_all_on_focus: false,
1396                        hint: None,
1397                        colors: None,
1398                        wrap_width: None,
1399                    });
1400                });
1401        });
1402        crate::tools::settle::assert_settles(&settle, 8);
1403    }
1404
1405    #[test]
1406    fn frame_zoom_clamps_huge_content_to_the_floor() {
1407        let viewport = Vec2::new(800.0, 600.0);
1408        let huge = Vec2::new(100_000.0, 100_000.0);
1409        assert_eq!(
1410            frame_zoom(viewport, huge, Zoom::max_value()),
1411            Zoom::min_value()
1412        );
1413    }
1414}