Skip to main content

blockworx_egui/
view.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3use std::time::Duration;
4
5use blockworx_doc::{block_model::Asset, hash::AssetHash};
6use blockworx_geom::{Rect, WorldPx};
7use blockworx_paint::edit::{EDITOR_BORDER, EDITOR_PAD, EDITOR_ROUNDING};
8use blockworx_paint::ground::{GridLine, horizontals, verticals};
9use blockworx_paint::{
10    Button, Color, Cursor, DrawOp, EditColors, EditField, Factor, Input, Keys, Move, Raw, ScrollPx,
11    TextEvent, Tick, Vantage,
12};
13use egui::{PointerButton, Sense, Stroke};
14
15use crate::convert::{IntoEgui as _, IntoGeom};
16use crate::image::ImageRegistry;
17use crate::replay::replay;
18
19pub use blockworx_paint::Camera;
20/// The colours the canvas paints its own ground with, under the canvas's own
21/// name for them.
22pub use blockworx_paint::Ground as CanvasChrome;
23
24fn grid_stroke(line: GridLine, color: Color) -> Stroke {
25    Stroke::new(line.width(), color.egui())
26}
27
28/// Whether the key that turns a left-drag into a pan is held: **space**, the
29/// convention across drawing and diagram editors (Figma, Illustrator, Inkscape,
30/// Miro). A text editor owning the keyboard is typing, not panning.
31fn pan_key_held(ui: &egui::Ui) -> bool {
32    !ui.ctx().egui_wants_keyboard_input() && ui.input(|i| i.key_down(egui::Key::Space))
33}
34
35/// The canvas as egui shows it: the rect it is laid out in, the pointer and
36/// the gestures over it, and the in-place editor drawn into it. Where the
37/// diagram is shown *from* is the session's; this reads what would move it
38/// and reports the move.
39pub struct View {
40    /// The in-place editor shown last frame and what has been typed into it,
41    /// so a freshly-shown editor (different id, or none previously) opens on
42    /// the request's text and grabs keyboard focus exactly once.
43    draft: Option<Draft>,
44    /// Whether the pointer was over the canvas response (not a floating overlay)
45    /// as of the last [`Self::begin`]. Lets the app suppress the tool cursor when
46    /// the pointer sits over the nav bar, toolbar, or selection overlay.
47    hovered: bool,
48    /// Whether the primary-button drag in progress is panning the canvas (it
49    /// began with the [pan key](pan_key_held) held). Latched at drag start so
50    /// releasing the key mid-drag can't hand a half-finished pan to a tool.
51    primary_drag_pans: bool,
52    /// Whether a two-finger gesture is in progress. `MultiTouchInfo` vanishes
53    /// the instant a finger lifts, but the finger still down keeps arriving as
54    /// an ordinary drag — so the latch holds the canvas until the last one
55    /// leaves, rather than handing a half-finished pan to a tool.
56    touch_pans: bool,
57    /// What the last [`Self::begin`] made of the frame's camera input, kept for
58    /// the chrome drawn after the canvas pass.
59    camera: Camera,
60    /// The artwork the display list may name, registered through
61    /// [`Self::register_image`] as each asset's bytes are handed off.
62    images: Rc<RefCell<ImageRegistry>>,
63}
64
65/// The frame clock as egui states it: the time, and how long it predicts
66/// the frame will take, which is what the easing table reads ahead by.
67pub fn tick(ctx: &egui::Context) -> Tick {
68    let (now, predicted_dt) = ctx.input(|i| (i.time, i.predicted_dt));
69    // A clock that ran backwards would be a broken host, not a lost frame:
70    // fall back to zero rather than take the drawing down with it.
71    Tick::predicting(
72        Duration::try_from_secs_f64(now).unwrap_or_default(),
73        Duration::try_from_secs_f32(predicted_dt).unwrap_or_default(),
74    )
75}
76
77impl Default for View {
78    fn default() -> Self {
79        Self {
80            draft: None,
81            hovered: false,
82            primary_drag_pans: false,
83            touch_pans: false,
84            camera: Camera::Settled,
85            images: Rc::new(RefCell::new(ImageRegistry::default())),
86        }
87    }
88}
89
90impl View {
91    /// Hand an asset's bytes to the loader under the hash the display list
92    /// names it by. Idempotent, so a re-sent asset costs nothing.
93    pub fn register_image(&self, ctx: &egui::Context, hash: AssetHash, asset: &Asset) {
94        self.images.borrow_mut().register(ctx, hash, asset);
95    }
96
97    /// Whether the bytes behind `hash` have been registered — what a suite
98    /// above this crate checks a hand-off reached the canvas by.
99    #[cfg(any(test, feature = "test-support"))]
100    pub fn holds_image(&self, hash: AssetHash) -> bool {
101        self.images.borrow().uri(hash).is_some()
102    }
103
104    /// Whether the pointer was over the canvas (not a floating overlay) as of the
105    /// last [`Self::begin`]. The app gates the tool cursor on this so it never
106    /// leaks over the nav bar, toolbar, or selection overlay.
107    pub fn canvas_hovered(&self) -> bool {
108        self.hovered
109    }
110
111    /// Whether the camera is being worked, as of the last [`Self::begin`]. The
112    /// selection overlay is drawn after the canvas pass and reads it there.
113    pub fn camera(&self) -> Camera {
114        self.camera
115    }
116
117    /// Id of the in-place editor rendered last frame. A fallback for surrendering
118    /// editor focus when `Memory::focused` can't be trusted to name it.
119    pub fn focused_edit_id(&self) -> Option<egui::Id> {
120        self.draft.as_ref().map(|draft| draft.id)
121    }
122
123    /// Open the canvas frame: allocate the viewport and read this frame's
124    /// pan/zoom — everything that would move the camera, and nothing that
125    /// paints under it. The [`Canvas`] it answers with is a value, so the
126    /// frame can carry it across whatever runs between reading the pointer
127    /// and painting.
128    pub fn begin(&mut self, ui: &mut egui::Ui) -> Canvas {
129        let (rect, response) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
130        let diagram = ui.painter().add(egui::Shape::Noop);
131        let rect = rect.geom();
132        self.hovered = response.hovered();
133
134        let mut moves = Vec::new();
135        let pan = self.handle_pan_zoom(ui, &response, rect, &mut moves);
136        self.camera = pan;
137
138        Canvas {
139            rect,
140            response,
141            diagram,
142            pan,
143            moves,
144        }
145    }
146
147    /// Paint the frame: the ground and the grid under `diagram`'s camera,
148    /// then its display list replayed over them, clipped to the canvas —
149    /// into the slot [`Self::begin`] reserved, so it lies under whatever the
150    /// frame placed over the canvas in between.
151    /// Answers the cursor the pointer is left with: the diagram's own,
152    /// unless the camera is being worked — an armed or in-progress pan owns
153    /// the pointer.
154    pub fn show(&mut self, ui: &egui::Ui, canvas: &Canvas, diagram: Diagram<'_>) -> Option<Cursor> {
155        let painter = ui.painter().with_clip_rect(canvas.rect.egui());
156        let mut shapes = View::grid(diagram.vantage, canvas.rect, diagram.ground);
157        shapes.extend(replay(diagram.draw_list, &painter, &self.images.borrow()));
158        painter.set(canvas.diagram, egui::Shape::Vec(shapes));
159        if canvas.pan == Camera::Moving {
160            Some(Cursor::Grabbing)
161        } else if self.hovered && pan_key_held(ui) {
162            Some(Cursor::Grab)
163        } else {
164            diagram.cursor
165        }
166    }
167
168    /// Wheel zoom (centered on the cursor), two-finger pan/pinch, and drag pan —
169    /// right/middle button, or the primary button with the [pan key](pan_key_held)
170    /// held — each reported as a [`Move`] for the session to apply.
171    fn handle_pan_zoom(
172        &mut self,
173        ui: &egui::Ui,
174        response: &egui::Response,
175        rect: Rect,
176        moves: &mut Vec<Move>,
177    ) -> Camera {
178        if let Some(pan) = self.handle_touch_gesture(ui, response, moves) {
179            return pan;
180        }
181        // `hovered()` is false when a floating overlay (the nav tree, toolbar, …)
182        // sits under the pointer, so scrolling there scrolls that overlay instead
183        // of also zooming the canvas.
184        if response.hovered() {
185            let cursor = ui
186                .ctx()
187                .pointer_hover_pos()
188                .map_or(rect.center(), IntoGeom::geom);
189            // A bare wheel zooms — this canvas has no document flow to scroll
190            // past. egui diverts ctrl/⌘+wheel and trackpad pinch into
191            // `zoom_delta` instead (leaving `smooth_scroll_delta` at zero), so
192            // both channels are read: the modifier is optional, not a different
193            // gesture.
194            let (scroll, pinch) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta()));
195            let factor = Factor::of_scroll(ScrollPx::up(scroll)).get() * pinch;
196            if factor != 1.0 {
197                moves.push(Move::Zoom {
198                    factor: Factor::new(factor),
199                    anchor: cursor,
200                });
201            }
202        }
203
204        // A left-drag pans while the pan key is held, for pointing devices where
205        // the other buttons are awkward. Shift+left-drag stays the selection
206        // marquee (handled by the tools), so it is not a pan gesture.
207        if response.drag_started() {
208            self.primary_drag_pans = pan_key_held(ui);
209        }
210        // The latch also covers the frame the drag stops on, so the gesture's
211        // closing event is consumed by the pan rather than reaching a tool.
212        let primary_pan = self.primary_drag_pans
213            && (response.dragged_by(PointerButton::Primary) || response.drag_stopped());
214        if response.drag_stopped() {
215            self.primary_drag_pans = false;
216        }
217        if primary_pan
218            || response.dragged_by(PointerButton::Secondary)
219            || response.dragged_by(PointerButton::Middle)
220        {
221            moves.push(Move::Pan(response.drag_delta().geom()));
222            Camera::Moving
223        } else {
224            Camera::Settled
225        }
226    }
227
228    /// Two fingers pan and pinch the canvas together, about the gesture's own
229    /// center. `Some` means the gesture owns the frame — the primary finger also
230    /// arrives as an ordinary drag, which a tool would otherwise read as a
231    /// marquee or a wire.
232    ///
233    /// This is the only pan a touch screen has: it has no wheel, no middle
234    /// button, and no space bar to hold.
235    fn handle_touch_gesture(
236        &mut self,
237        ui: &egui::Ui,
238        response: &egui::Response,
239        moves: &mut Vec<Move>,
240    ) -> Option<Camera> {
241        let (gesture, touching) = ui.input(|i| (i.multi_touch(), i.any_touches()));
242        if !touching {
243            self.touch_pans = false;
244            return None;
245        }
246        // A gesture that began on the canvas keeps it even if a finger strays
247        // over an overlay; one that began on an overlay never takes it.
248        let ours = self.touch_pans || response.hovered() || response.is_pointer_button_down_on();
249        let Some(gesture) = gesture.filter(|_| ours) else {
250            // The latch outlives `MultiTouchInfo`, which is gone the moment the
251            // gesture drops back to one finger.
252            return self.touch_pans.then_some(Camera::Moving);
253        };
254        self.touch_pans = true;
255        moves.push(Move::Pan(gesture.translation_delta.geom()));
256        if gesture.zoom_delta != 1.0 {
257            moves.push(Move::Zoom {
258                factor: Factor::new(gesture.zoom_delta),
259                anchor: gesture.center_pos.geom(),
260            });
261        }
262        Some(Camera::Moving)
263    }
264
265    /// The canvas background and the grid over it, in the chrome colors the
266    /// caller already resolved rather than through the palette-based
267    /// recording.
268    fn grid(vantage: Vantage, rect: Rect, chrome: CanvasChrome) -> Vec<egui::Shape> {
269        let down = verticals(vantage, rect).map(|(x, line)| {
270            egui::Shape::vline(x, rect.y_range().egui(), grid_stroke(line, chrome.grid))
271        });
272        let across = horizontals(vantage, rect).map(|(y, line)| {
273            egui::Shape::hline(rect.x_range().egui(), y, grid_stroke(line, chrome.grid))
274        });
275        std::iter::once(egui::Shape::rect_filled(
276            rect.egui(),
277            0.0,
278            chrome.background.egui(),
279        ))
280        .chain(down)
281        .chain(across)
282        .collect()
283    }
284
285    /// The editor the last call asked for, run as egui's own text field
286    /// over the diagram: it keeps the draft, the caret and the selection,
287    /// and says only how the edit ended. `angle` is not honoured — egui
288    /// cannot rotate a `TextEdit` — so a vertical label is edited upright.
289    pub fn capture(
290        &mut self,
291        ui: &mut egui::Ui,
292        field: Option<&EditField>,
293        vantage: Vantage,
294    ) -> Option<TextEvent> {
295        let Some(edit) = field else {
296            self.draft = None;
297            return None;
298        };
299        let id = edit.id;
300        let edit_id = id.egui();
301        let (draft, opening) = match &mut self.draft {
302            Some(draft) if draft.id == edit_id => (draft, false),
303            slot => (
304                slot.insert(Draft {
305                    id: edit_id,
306                    text: edit.text.clone(),
307                }),
308                true,
309            ),
310        };
311        if opening {
312            // Where the edit opens: the whole text selected where the
313            // request asks for that, so typing replaces it, else the caret
314            // after it.
315            let chars = draft.text.chars().count();
316            let range = if edit.tab_cycle || edit.select_all_on_focus {
317                egui::text::CCursorRange::two(
318                    egui::text::CCursor::new(0),
319                    egui::text::CCursor::new(chars),
320                )
321            } else {
322                egui::text::CCursorRange::one(egui::text::CCursor::new(chars))
323            };
324            let mut state =
325                egui::text_edit::TextEditState::load(ui.ctx(), edit_id).unwrap_or_default();
326            state.cursor.set_char_range(Some(range));
327            state.store(ui.ctx(), edit_id);
328        }
329        let screen_font = vantage.remap_font(&edit.font).egui();
330        let ink = edit.colors.text.egui();
331        let layout_font = screen_font.clone();
332        let wrap_px = edit.wrap_width.map(|w| w.get() * vantage.zoom.get());
333        let mut wrap_at = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _avail: f32| {
334            let job = egui::text::LayoutJob::simple(
335                buf.as_str().to_owned(),
336                layout_font.clone(),
337                ink,
338                wrap_px.unwrap_or(f32::INFINITY),
339            );
340            ui.fonts_mut(|f| f.layout_job(job))
341        };
342        let mut widget = if edit.multiline {
343            egui::TextEdit::multiline(&mut draft.text)
344        } else {
345            egui::TextEdit::singleline(&mut draft.text)
346        }
347        .id(edit_id)
348        .desired_width(f32::INFINITY)
349        .font(screen_font)
350        .frame(editor_frame(&edit.colors, vantage))
351        .min_size(edit.rect.size().egui())
352        .desired_rows(1)
353        .horizontal_align(edit.align.x().egui())
354        .vertical_align(edit.align.y().egui())
355        .text_color(ink)
356        .layouter(&mut wrap_at);
357        if let Some(hint) = &edit.hint {
358            widget =
359                widget.hint_text(egui::RichText::new(hint.as_ref()).color(edit.colors.hint.egui()));
360        }
361        if let Some(limit) = edit.char_limit {
362            widget = widget.char_limit(limit);
363        }
364        let resp = ui
365            .scope(|ui| {
366                let visuals = ui.visuals_mut();
367                visuals.selection.bg_fill = edit.colors.selection.egui();
368                visuals.selection.stroke = egui::Stroke::NONE;
369                visuals.text_cursor.stroke.color = edit.colors.caret.egui();
370                ui.place(edit.rect.egui(), widget)
371            })
372            .inner;
373        // Escape is the editor's, and Tab a cycle editor's. egui's focus
374        // system moves focus on Tab and clears it on Escape at the *start* of
375        // the next frame unless the focused widget's filter claims them, and
376        // the field sets a filter of its own as it is placed — so this one is
377        // set after it. (The field never inserts a tab: it reads events with
378        // its own filter, which still excludes Tab.)
379        ui.memory_mut(|m| {
380            m.set_focus_lock_filter(
381                edit_id,
382                egui::EventFilter {
383                    horizontal_arrows: true,
384                    vertical_arrows: true,
385                    tab: edit.tab_cycle,
386                    escape: true,
387                },
388            );
389        });
390        // Auto-focus an editor the first frame it appears so the user can
391        // type immediately (e.g. right after creating a text box) without a
392        // separate click into the field.
393        if opening {
394            resp.request_focus();
395        }
396        if resp.has_focus() {
397            let (tab, escape) = ui.input_mut(|i| {
398                (
399                    edit.tab_cycle && i.consume_key(egui::Modifiers::NONE, egui::Key::Tab),
400                    i.consume_key(egui::Modifiers::NONE, egui::Key::Escape),
401                )
402            });
403            if tab {
404                return Some(TextEvent::TabPressed {
405                    id,
406                    text: draft.text.clone(),
407                });
408            }
409            if escape {
410                return Some(TextEvent::Cancelled { id });
411            }
412        }
413        resp.lost_focus().then(|| TextEvent::Committed {
414            id,
415            text: draft.text.clone(),
416        })
417    }
418}
419
420/// The draft the editor holds between frames: egui's field edits a buffer
421/// the caller keeps.
422struct Draft {
423    id: egui::Id,
424    text: String,
425}
426
427/// The field as an opaque plate ringed in the editor's border, so it reads
428/// as standing over the drawing rather than as more of it. Its geometry is
429/// the one the kernel sized the field by.
430fn editor_frame(colors: &EditColors, vantage: Vantage) -> egui::Frame {
431    let margin = |len: f32| vantage.remap_len(WorldPx::new(len)).round() as i8;
432    egui::Frame::new()
433        .fill(colors.background.egui())
434        .stroke(egui::Stroke::new(
435            vantage.remap_len(EDITOR_BORDER),
436            colors.border.egui(),
437        ))
438        .corner_radius(vantage.remap_len(EDITOR_ROUNDING))
439        .inner_margin(egui::Margin::symmetric(
440            margin(EDITOR_PAD.x),
441            margin(EDITOR_PAD.y),
442        ))
443}
444
445/// What a call answered for the canvas: the display list, the camera it
446/// was painted under, the colours under it, and the cursor it left.
447#[derive(Clone, Copy)]
448pub struct Diagram<'a> {
449    pub draw_list: &'a [DrawOp],
450    pub vantage: Vantage,
451    pub ground: CanvasChrome,
452    pub cursor: Option<Cursor>,
453}
454
455/// One canvas frame as the pointer left it: the rect it was laid out in,
456/// the moves it read for the session to apply, and the raw input the
457/// session resolves. A value rather than a borrow of the view, so the
458/// frame can call the editor between reading its input and painting.
459pub struct Canvas {
460    rect: Rect,
461    response: egui::Response,
462    /// Where the diagram is painted, reserved as the canvas is laid out.
463    diagram: egui::layers::ShapeIdx,
464    pan: Camera,
465    moves: Vec<Move>,
466}
467
468impl Canvas {
469    /// The screen rect this frame paints into.
470    pub fn viewport(&self) -> Rect {
471        self.rect
472    }
473
474    /// What the pointer did to the camera this frame — a drag pan, a wheel
475    /// notch, a pinch — in the order it happened, for the session to apply
476    /// before the pointer is resolved.
477    pub fn moves(&mut self) -> Vec<Move> {
478        std::mem::take(&mut self.moves)
479    }
480
481    /// What the pointer and the keys did this frame, as the core resolves
482    /// them: the pointer's motions and button edges while it is over the
483    /// canvas or holding a press that began there, its leaving otherwise,
484    /// and a pan in progress, which owns the pointer.
485    ///
486    /// Presses that began on a toolbar button never arrive: holding one —
487    /// whose screen point maps to some world position — must not arm a
488    /// canvas affordance. egui keeps that promise for chrome in a layer of
489    /// its own; for chrome the host draws in the canvas's layer it counts
490    /// the canvas as hovered on the press and not on the release, so the
491    /// host names the rects of such chrome as `glass`, and a press on one
492    /// is not taken either.
493    pub fn input(&self, ui: &egui::Ui, glass: &[Rect]) -> Input {
494        let response = &self.response;
495        let over = response.hovered();
496        let held_here = response.is_pointer_button_down_on();
497        let mut raw = Vec::new();
498        if over || held_here {
499            ui.input(|input| {
500                for event in &input.events {
501                    match *event {
502                        egui::Event::PointerMoved(pos) => raw.push(Raw::Moved(pos.geom())),
503                        egui::Event::PointerButton {
504                            pos,
505                            button,
506                            pressed,
507                            ..
508                        } => {
509                            let Some(button) = button_of(button) else {
510                                continue;
511                            };
512                            let pos = pos.geom();
513                            if pressed && glass.iter().any(|rect| rect.contains(pos)) {
514                                continue;
515                            }
516                            raw.push(if pressed {
517                                Raw::Down { pos, button }
518                            } else {
519                                Raw::Up { pos, button }
520                            });
521                        }
522                        egui::Event::PointerGone => raw.push(Raw::Gone),
523                        _ => {}
524                    }
525                }
526            });
527            // A pointer that stood still while the chrome under it went away
528            // is over the canvas now without having moved onto it.
529            if over
530                && !raw.iter().any(|raw| matches!(raw, Raw::Moved(_)))
531                && let Some(pos) = response.hover_pos()
532            {
533                raw.push(Raw::Moved(pos.geom()));
534            }
535        } else {
536            raw.push(Raw::Gone);
537        }
538        // egui abandons a drag on Escape, whatever holds the keyboard.
539        if ui.input(|input| input.key_pressed(egui::Key::Escape)) {
540            raw.push(Raw::Cancelled);
541        }
542        if self.pan == Camera::Moving {
543            raw.push(Raw::Panning);
544        }
545        let keys = keys(response, ui);
546        Input { raw, keys }
547    }
548}
549
550fn button_of(button: PointerButton) -> Option<Button> {
551    match button {
552        PointerButton::Primary => Some(Button::Primary),
553        PointerButton::Secondary => Some(Button::Secondary),
554        PointerButton::Middle => Some(Button::Middle),
555        PointerButton::Extra1 | PointerButton::Extra2 => None,
556    }
557}
558
559/// The keys the frame carries, scoped by who holds the keyboard.
560fn keys(response: &egui::Response, ui: &egui::Ui) -> Keys {
561    // Any other widget owning the keyboard — a rename TextEdit, the nav
562    // dialog's filter box, the command palette — claims these keys, so a
563    // Backspace editing its text must not delete the selected shape and its
564    // Escape must not cancel an in-progress gesture.
565    let other_focus = ui.memory(|m| m.focused().is_some_and(|id| id != response.id));
566    let canvas_keys = !response.has_focus() && !other_focus;
567    Keys {
568        escape: canvas_keys && ui.input(|i| i.key_pressed(egui::Key::Escape)),
569        delete: canvas_keys
570            && ui
571                .input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)),
572        shift: ui.input(|i| i.modifiers.shift),
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use blockworx_geom::{Pos2, Vec2, pos2};
580    use blockworx_paint::{EditId, Font, Zoom};
581
582    /// Nothing painted, under a camera at rest.
583    fn at_rest(ground: CanvasChrome) -> Diagram<'static> {
584        Diagram {
585            draw_list: &[],
586            vantage: Vantage::resting(),
587            ground,
588            cursor: None,
589        }
590    }
591
592    /// One frame of the canvas, fed `events`, returning the raw input the
593    /// core would resolve and the camera moves it would apply.
594    fn frame(
595        ctx: &egui::Context,
596        view: &mut View,
597        events: Vec<egui::Event>,
598    ) -> (Vec<Raw>, Vec<Move>) {
599        let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0));
600        let input = egui::RawInput {
601            screen_rect: Some(screen.egui()),
602            events,
603            ..Default::default()
604        };
605        let mut seen = (Vec::new(), Vec::new());
606        let chrome = CanvasChrome {
607            background: Color::BLACK,
608            grid: Color::GRAY,
609        };
610        ctx.run_ui(input, |ui| {
611            let mut canvas = view.begin(ui);
612            seen = (canvas.input(ui, &[]).raw, canvas.moves());
613            view.show(ui, &canvas, at_rest(chrome));
614        })
615        .drop_without_applying_deltas();
616        seen
617    }
618
619    /// Whether a frame's raw input is a pan's — the core makes no tool
620    /// gesture of a panning frame — or a gesture the tools may read.
621    fn panning(seen: &[Raw]) -> bool {
622        seen.contains(&Raw::Panning)
623    }
624
625    fn key_down(key: egui::Key) -> egui::Event {
626        egui::Event::Key {
627            key,
628            physical_key: None,
629            pressed: true,
630            repeat: false,
631            modifiers: egui::Modifiers::default(),
632        }
633    }
634
635    fn key_up(key: egui::Key) -> egui::Event {
636        egui::Event::Key {
637            key,
638            physical_key: None,
639            pressed: false,
640            repeat: false,
641            modifiers: egui::Modifiers::default(),
642        }
643    }
644
645    /// The primary button going down at `pos` (these tests never release it —
646    /// every gesture under test is judged mid-drag).
647    fn press(pos: Pos2) -> egui::Event {
648        egui::Event::PointerButton {
649            pos: pos.egui(),
650            button: PointerButton::Primary,
651            pressed: true,
652            modifiers: egui::Modifiers::default(),
653        }
654    }
655
656    /// Space+left-drag pans (the drawing-app convention), and the tool must not
657    /// also see the drag — the view consumes it.
658    #[test]
659    fn space_plus_left_drag_pans_instead_of_reaching_the_tool() {
660        let ctx = egui::Context::default();
661        let mut view = View::default();
662        let start = pos2(400.0, 300.0);
663        frame(&ctx, &mut view, vec![key_down(egui::Key::Space)]);
664        frame(
665            &ctx,
666            &mut view,
667            vec![egui::Event::PointerMoved(start.egui())],
668        );
669        frame(&ctx, &mut view, vec![press(start)]);
670        let (seen, moves) = frame(
671            &ctx,
672            &mut view,
673            vec![egui::Event::PointerMoved(
674                (start + Vec2::new(60.0, -25.0)).egui(),
675            )],
676        );
677        assert_eq!(moves, vec![Move::Pan(Vec2::new(60.0, -25.0))]);
678        assert!(panning(&seen), "the pan leaked to the tool as {seen:?}");
679    }
680
681    /// The same drag without the key belongs to the tool: no pan, and the tool
682    /// sees the gesture.
683    #[test]
684    fn a_plain_left_drag_is_left_to_the_tool() {
685        let ctx = egui::Context::default();
686        let mut view = View::default();
687        let start = pos2(400.0, 300.0);
688        frame(
689            &ctx,
690            &mut view,
691            vec![egui::Event::PointerMoved(start.egui())],
692        );
693        frame(&ctx, &mut view, vec![press(start)]);
694        let (seen, moves) = frame(
695            &ctx,
696            &mut view,
697            vec![egui::Event::PointerMoved(
698                (start + Vec2::new(60.0, -25.0)).egui(),
699            )],
700        );
701        assert!(moves.is_empty(), "a plain drag must not pan: {moves:?}");
702        assert!(
703            !panning(&seen) && seen.iter().any(|raw| matches!(raw, Raw::Moved(_))),
704            "the tool never saw the drag ({seen:?})"
705        );
706    }
707
708    /// Releasing the key mid-drag leaves the gesture a pan: it was latched when
709    /// the drag began, so no tool inherits a half-finished pan.
710    #[test]
711    fn releasing_the_key_mid_drag_keeps_panning() {
712        let ctx = egui::Context::default();
713        let mut view = View::default();
714        let start = pos2(400.0, 300.0);
715        frame(&ctx, &mut view, vec![key_down(egui::Key::Space)]);
716        frame(
717            &ctx,
718            &mut view,
719            vec![egui::Event::PointerMoved(start.egui())],
720        );
721        frame(&ctx, &mut view, vec![press(start)]);
722        let mut at = start + Vec2::new(30.0, 0.0);
723        frame(&ctx, &mut view, vec![egui::Event::PointerMoved(at.egui())]);
724        at += Vec2::new(20.0, 10.0);
725        let (seen, moves) = frame(
726            &ctx,
727            &mut view,
728            vec![
729                key_up(egui::Key::Space),
730                egui::Event::PointerMoved(at.egui()),
731            ],
732        );
733        assert_eq!(moves, vec![Move::Pan(Vec2::new(20.0, 10.0))]);
734        assert!(panning(&seen), "{seen:?}");
735    }
736
737    /// One finger's touch event. A touch screen delivers every finger; egui
738    /// derives the pinch and the two-finger translation from the set.
739    fn touch(id: u64, phase: egui::TouchPhase, pos: Pos2) -> egui::Event {
740        egui::Event::Touch {
741            device_id: egui::TouchDeviceId(0),
742            id: egui::TouchId(id),
743            phase,
744            pos: pos.egui(),
745            force: None,
746        }
747    }
748
749    /// Both fingers of a two-finger gesture moving to `at`, plus the primary
750    /// finger's pointer event — which is what the web backend also sends, and
751    /// what a tool would read as a drag if the view didn't consume it.
752    fn two_fingers(phase: egui::TouchPhase, at: [Pos2; 2]) -> Vec<egui::Event> {
753        vec![
754            egui::Event::PointerMoved(at[0].egui()),
755            touch(0, phase, at[0]),
756            touch(1, phase, at[1]),
757        ]
758    }
759
760    /// Put two fingers down on `at` and hold them there for one frame. The
761    /// leading empty frame gives egui the canvas rect to hit-test the press
762    /// against; adding a finger makes the touch averages jump, so egui withholds
763    /// a delta until the frame after that.
764    fn start_two_finger_gesture(ctx: &egui::Context, view: &mut View, at: [Pos2; 2]) {
765        frame(ctx, view, Vec::new());
766        let mut down = two_fingers(egui::TouchPhase::Start, at);
767        down.push(press(at[0]));
768        frame(ctx, view, down);
769        frame(ctx, view, two_fingers(egui::TouchPhase::Move, at));
770    }
771
772    /// Two fingers pan the canvas. This is the only pan a touch screen has —
773    /// there is no wheel, no middle button, and no space bar — and egui reports
774    /// it *only* through `multi_touch`: a touch drag leaves `smooth_scroll_delta`
775    /// at zero, so a view that reads scroll alone never moves.
776    #[test]
777    fn two_fingers_pan_the_canvas() {
778        let ctx = egui::Context::default();
779        let mut view = View::default();
780        let start = [pos2(360.0, 300.0), pos2(440.0, 300.0)];
781        let step = Vec2::new(20.0, -12.0);
782
783        start_two_finger_gesture(&ctx, &mut view, start);
784        frame(
785            &ctx,
786            &mut view,
787            two_fingers(egui::TouchPhase::Move, start.map(|p| p + step)),
788        );
789        let (seen, moves) = frame(
790            &ctx,
791            &mut view,
792            two_fingers(egui::TouchPhase::Move, start.map(|p| p + step * 2.0)),
793        );
794
795        assert_eq!(
796            moves,
797            vec![Move::Pan(step)],
798            "the canvas did not follow the fingers, or zoomed though they held their spacing"
799        );
800        assert!(
801            panning(&seen),
802            "the primary finger reached the tool as a drag ({seen:?})"
803        );
804    }
805
806    /// Spreading the fingers zooms about the gesture's own center, holding the
807    /// world under it — the pinch counterpart of the wheel's zoom-about-cursor.
808    #[test]
809    fn pinching_zooms_about_the_gesture_center() {
810        let ctx = egui::Context::default();
811        let mut view = View::default();
812        let center = pos2(400.0, 300.0);
813        let reach = Vec2::new(40.0, 0.0);
814        let start = [center - reach, center + reach];
815
816        start_two_finger_gesture(&ctx, &mut view, start);
817
818        let (_, moves) = frame(
819            &ctx,
820            &mut view,
821            two_fingers(
822                egui::TouchPhase::Move,
823                [center - reach * 2.0, center + reach * 2.0],
824            ),
825        );
826
827        let Some(Move::Zoom { factor, anchor }) = moves
828            .iter()
829            .find(|moved| matches!(moved, Move::Zoom { .. }))
830        else {
831            panic!("spreading the fingers reported no zoom: {moves:?}");
832        };
833        assert!(
834            factor.get() > 1.5,
835            "spreading the fingers should have doubled the zoom, got {factor:?}"
836        );
837        assert_eq!(*anchor, center, "the pinch zooms about its own centre");
838    }
839
840    /// Lifting one of two fingers must not hand the remaining one to a tool: the
841    /// gesture owns the canvas until the last finger leaves.
842    #[test]
843    fn the_last_finger_of_a_pan_never_becomes_a_tool_drag() {
844        let ctx = egui::Context::default();
845        let mut view = View::default();
846        let start = [pos2(360.0, 300.0), pos2(440.0, 300.0)];
847        let step = Vec2::new(25.0, 0.0);
848
849        start_two_finger_gesture(&ctx, &mut view, start);
850        frame(
851            &ctx,
852            &mut view,
853            two_fingers(egui::TouchPhase::Move, start.map(|p| p + step)),
854        );
855        // Second finger up; the first keeps sliding.
856        frame(
857            &ctx,
858            &mut view,
859            vec![touch(1, egui::TouchPhase::End, start[1] + step)],
860        );
861        let (seen, _) = frame(
862            &ctx,
863            &mut view,
864            vec![
865                egui::Event::PointerMoved((start[0] + step * 2.0).egui()),
866                touch(0, egui::TouchPhase::Move, start[0] + step * 2.0),
867            ],
868        );
869        assert!(
870            panning(&seen),
871            "the trailing finger reached the tool as a drag ({seen:?})"
872        );
873
874        // Once every finger is up the canvas is the tool's again.
875        frame(
876            &ctx,
877            &mut view,
878            vec![touch(0, egui::TouchPhase::End, start[0] + step * 2.0)],
879        );
880        assert!(!view.touch_pans, "the gesture latch outlived the gesture");
881    }
882
883    /// An open in-place editor must not hammer repaints. On the desktop a
884    /// repaint storm is a warm fan; in a browser tab on a tablet it is an app
885    /// that stops answering, and the editor is the one thing there that cannot
886    /// be dismissed without a keyboard.
887    #[test]
888    fn an_open_editor_settles() {
889        let mut view = View::default();
890        let chrome = CanvasChrome {
891            background: Color::BLACK,
892            grid: Color::GRAY,
893        };
894        let field = EditField {
895            id: EditId::of("settle_title_edit"),
896            rect: Rect::from_min_size(pos2(100.0, 100.0), Vec2::new(120.0, 20.0)),
897            angle: blockworx_geom::Angle::ZERO,
898            font: Font::proportional(14.0),
899            align: blockworx_geom::Align2::LEFT_CENTER,
900            wrap_width: None,
901            text: "Amplifier".to_owned(),
902            multiline: false,
903            char_limit: Some(blockworx_geom::grid::MAX_LABEL_CHARS),
904            tab_cycle: true,
905            select_all_on_focus: false,
906            hint: None,
907            colors: blockworx_paint::EditColors {
908                text: Color::WHITE,
909                background: Color::BLACK,
910                caret: Color::WHITE,
911                selection: Color::GRAY,
912                hint: Color::GRAY,
913                border: Color::WHITE,
914            },
915        };
916        let settle = crate::settle::probe(30, |ui| {
917            // The caret's blink is the editor's own timed wake, not the
918            // hammering this is looking for; it would otherwise vary the
919            // shape count.
920            ui.style_mut().visuals.text_cursor.blink = false;
921            let canvas = view.begin(ui);
922            view.show(ui, &canvas, at_rest(chrome));
923            let _ = view.capture(ui, Some(&field), Vantage::resting());
924        });
925        crate::settle::assert_settles(&settle, 8);
926    }
927
928    #[test]
929    fn frame_zoom_clamps_huge_content_to_the_floor() {
930        let viewport = Vec2::new(800.0, 600.0);
931        let huge = Vec2::new(100_000.0, 100_000.0);
932        assert_eq!(
933            Zoom::framing(viewport, huge, Zoom::max_value()),
934            Zoom::min_value()
935        );
936    }
937}