diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5b68f657..54e32bd8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2779,7 +2779,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // zooms nor slides the image towards the pointer. let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size); if nz != view.zoom { - zoom_about(&mut view, &xf, p, nz, false, ppp); + // Over the pasteboard the anchor is the image's nearest point (#3720). + let at = wheel_anchor(&xf, p, view.doc_size); + zoom_about(&mut view, &xf, at, nz, false, ppp); } } (Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => { @@ -3557,6 +3559,20 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: /// Zoom to `new_zoom` about the pointer `p`. By default the document point under it stays under /// it; with Preferences › Tools › Zoom Clicked Point to Center (`center_on_point`) it moves to the /// centre of the view instead (#204). +/// Where a wheel or pinch zoom anchors for a pointer at `p`: the pointer itself over the image, +/// otherwise the nearest point of the image (#3720). Anchoring on a pointer out on the gray +/// pasteboard slid the image away from it, out of the window after a few notches; Photoshop keeps +/// the image in view. Clamped in document space, so a rotated or flipped view behaves the same. +fn wheel_anchor(xf: &ViewXform, p: Pos2, doc_size: [u32; 2]) -> Pos2 { + let [w, h] = doc_size; + let d = xf.to_doc(p); + if w == 0 || h == 0 || !(d[0].is_finite() && d[1].is_finite()) { + return p; + } + let c = [d[0].clamp(0.0, f64::from(w)), d[1].clamp(0.0, f64::from(h))]; + if c == d { p } else { xf.to_screen(c[0] as f32, c[1] as f32) } +} + fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool, ppp: f32) { let before = xf.to_doc(p); view.zoom = new_zoom; @@ -6687,6 +6703,23 @@ mod tests { } } + #[test] + fn wheel_anchor_is_the_pointer_over_the_image_and_the_nearest_image_point_off_it() { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + for (rotation, flip) in [(0.0, false), (30.0, false), (90.0, true)] { + let xf = ViewXform { rect, zoom: 0.5, center: [400.0, 300.0], flip, rotation, aspect: 1.0 }; + let inside = xf.to_screen(100.0, 200.0); + assert_eq!(wheel_anchor(&xf, inside, [800, 600]), inside); + for (p, want) in [((-300.0, -200.0), [0.0, 0.0]), ((1200.0, 300.0), [800.0, 300.0]), ((400.0, 900.0), [400.0, 600.0])] { + let a = xf.to_doc(wheel_anchor(&xf, xf.to_screen(p.0, p.1), [800, 600])); + assert!((a[0] - want[0]).abs() < 0.01 && (a[1] - want[1]).abs() < 0.01, "rot {rotation}: {p:?} -> {a:?}"); + } + } + let p = pos2(5.0, 5.0); + let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip: false, rotation: 0.0, aspect: 1.0 }; + assert_eq!(wheel_anchor(&xf, p, [0, 0]), p, "no document: the pointer"); + } + #[test] fn zoom_about_centres_the_clicked_point_with_the_preference() { let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index d78dbedb..bb87d477 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -707,3 +707,62 @@ fn escape_with_targa_options_open_does_not_deselect() { h.run_steps(2); assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to Targa Options"); } + +/// How much of the 400 × 300 document is on screen: the visible document span on each axis, +/// clipped to the image (0 when the image has left the view on that axis). +fn visible_image(h: &Harness<'static, PhotocraftApp>) -> [f32; 2] { + let r = h.state().last_canvas_rect; + let (a, b) = (doc_at(h, r.min), doc_at(h, r.max)); + [(b[0].min(400.0) - a[0].max(0.0)).max(0.0), (b[1].min(300.0) - a[1].max(0.0)).max(0.0)] +} + +#[test] +fn wheel_zoom_over_the_pasteboard_keeps_the_image_in_view() { + // #3720: zooming in with the pointer on the gray pasteboard anchored on the pointer, so a few + // notches pushed the image out of the window. The anchor is now the nearest point of the image. + for (zoom_with_wheel, m) in [(true, Modifiers::NONE), (false, Modifiers::ALT)] { + let mut h = harness(); + h.state_mut().session.edit_prefs(|p| p.general.zoom_with_scroll_wheel = zoom_with_wheel); + let r = h.state().last_canvas_rect; + let p = pos2(r.left() + 20.0, r.top() + 20.0); + let d = doc_at(&h, p); + assert!(d[0] < 0.0 && d[1] < 0.0, "the pointer starts on the pasteboard: {d:?}"); + h.hover_at(p); + h.run_steps(2); + let z0 = zoom(&h); + for _ in 0..20 { + wheel(&h, 1.0, m); + h.run_steps(3); + } + h.run_steps(20); + assert!(zoom(&h) > z0 * 5.0, "the notches zoomed in: {z0} -> {}", zoom(&h)); + let v = visible_image(&h); + assert!(v[0] > 0.0 && v[1] > 0.0, "zoom_with_wheel {zoom_with_wheel}: the image left the view {v:?}"); + // The image's corner nearest the pointer is the anchor: it stays where it was on screen. + let c = doc_at(&h, pos2(r.left() + 20.0, r.top() + 20.0)); + assert!(c[0] < 0.0 && c[1] < 0.0, "still pasteboard under the pointer, the corner held: {c:?}"); + // A pinch from there behaves the same. + h.event(egui::Event::Zoom(4.0)); + h.run_steps(3); + let v = visible_image(&h); + assert!(v[0] > 0.0 && v[1] > 0.0, "pinch: the image left the view {v:?}"); + } +} + +#[test] +fn wheel_zoom_over_the_image_still_anchors_on_the_pointer() { + let mut h = harness(); + let r = h.state().last_canvas_rect; + let p = pos2(r.center().x - 150.0, r.center().y - 100.0); + h.hover_at(p); + h.run_steps(2); + let d0 = doc_at(&h, p); + assert!(d0[0] > 0.0 && d0[1] > 0.0); + for _ in 0..5 { + wheel(&h, 1.0, Modifiers::ALT); + h.run_steps(3); + } + h.run_steps(20); + let d1 = doc_at(&h, p); + assert!((d1[0] - d0[0]).abs() < 0.1 && (d1[1] - d0[1]).abs() < 0.1, "{d0:?} -> {d1:?}"); +}