diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d267f76c..f77738ca 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -62,19 +62,31 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } return; } - let (content_rect, scale) = layout(app, avail); // Scroll when zoomed beyond the window. let scroll_id = ui.id().with("canvas_scroll"); + let last_id = scroll_id.with("last"); let mut offset: egui::Vec2 = ui.data_mut(|d| d.get_temp(scroll_id).unwrap_or_default()); - let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0)); + let last: Option<(Rect, f32)> = ui.data_mut(|d| d.get_temp(last_id)); let resp = ui.allocate_rect(avail, Sense::click_and_drag()); - let (wheel, zoom_delta, pinch_pos) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos())); - if resp.hovered() { - if (zoom_delta - 1.0).abs() > 1e-3 { - let cur = app.ui.zoom.unwrap_or(scale); - app.ui.zoom = Some((cur * zoom_delta).clamp(0.1, 4.0)); - let _ = pinch_pos; - } else if overflow != egui::Vec2::ZERO { + let (wheel, zoom_delta, hover) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos())); + let zooming = resp.hovered() && (zoom_delta - 1.0).abs() > 1e-3; + if zooming { + let cur = app.ui.zoom.unwrap_or_else(|| layout(app, avail).1); + app.ui.zoom = Some((cur * zoom_delta).clamp(0.1, 4.0)); + } + let (content_rect, scale) = layout(app, avail); + // A new zoom keeps the slide point under the pointer (wheel, pinch) or under the middle of the + // canvas (Zoom In/Out, the slider) where it was, instead of growing from the corner (#106). + if let Some((last_rect, last_scale)) = last + && (scale - last_scale).abs() > 1e-6 + { + let anchor = if zooming { hover.unwrap_or(avail.center()) } else { avail.center() }; + let at = (anchor - last_rect.min) / last_scale; + offset = content_rect.min + at * scale - anchor; + } + let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0)); + if resp.hovered() && !zooming { + if overflow != egui::Vec2::ZERO { offset -= wheel; } else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { // Wheel over a fitted slide pages through slides (like PowerPoint). @@ -90,8 +102,11 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } offset.x = offset.x.clamp(0.0, overflow.x); offset.y = offset.y.clamp(0.0, overflow.y); - ui.data_mut(|d| d.insert_temp(scroll_id, offset)); let slide_rect = content_rect.translate(-offset); + ui.data_mut(|d| { + d.insert_temp(scroll_id, offset); + d.insert_temp(last_id, (slide_rect, scale)); + }); app.slide_rect = Some(slide_rect); app.canvas_scale = scale; let xf = Xf { origin: slide_rect.min, scale }; @@ -608,3 +623,96 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } }); } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use deckcraft_engine::Session; + use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; + use egui_kittest::Harness; + + use crate::{Services, SlideApp}; + + fn harness() -> Harness<'static, SlideApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + h.run_steps(3); + h + } + + /// The slide point (in slide points) under screen position `p`. + fn slide_point(h: &Harness<'_, SlideApp>, p: Pos2) -> Pos2 { + let (r, s) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s) + } + + fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) { + let wheel = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: TouchPhase::Move, modifiers: Modifiers::COMMAND }; + h.event(wheel); + // egui spreads a wheel notch over several frames. + h.run_steps(30); + } + + fn assert_near(a: Pos2, b: Pos2, what: &str) { + assert!((a - b).length() < 0.5, "{what}: {a:?} moved to {b:?}"); + } + + /// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer. + #[test] + fn wheel_and_pinch_zoom_keep_the_point_under_the_pointer() { + let mut h = harness(); + let fit = h.state().canvas_scale; + let r = h.state().slide_rect.unwrap(); + let p = r.min + vec2(r.width() * 0.8, r.height() * 0.7); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + let under = slide_point(&h, p); + + h.event(Event::Zoom(2.0)); + h.run_steps(2); + assert!((h.state().canvas_scale - fit * 2.0).abs() < 1e-3); + assert_near(under, slide_point(&h, p), "pinch in"); + + ctrl_wheel(&mut h, 1.0); + assert!(h.state().canvas_scale > fit * 2.1, "Ctrl/Cmd + wheel up zooms in"); + assert_near(under, slide_point(&h, p), "wheel in"); + + ctrl_wheel(&mut h, -1.0); + ctrl_wheel(&mut h, -1.0); + assert!(h.state().canvas_scale < fit * 2.0, "Ctrl/Cmd + wheel down zooms out"); + assert_near(under, slide_point(&h, p), "wheel out"); + + h.event(Event::Zoom(0.8)); + h.run_steps(2); + assert_near(under, slide_point(&h, p), "pinch out"); + + // Zoomed out below the window size, the slide is centred again. + h.event(Event::Zoom(0.25)); + h.run_steps(2); + assert!(h.state().canvas_scale < fit); + let (canvas, slide) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap()); + assert!((canvas.center() - slide.center()).length() < 1.0, "{canvas:?} {slide:?}"); + } + + /// Zoom In/Out (keys, buttons, slider) keeps the middle of the view where it is. + #[test] + fn zoom_commands_keep_the_middle_of_the_view() { + let mut h = harness(); + let c = h.state().canvas_rect.unwrap().center(); + let mid = slide_point(&h, c); + for _ in 0..4 { + h.state_mut().run("view.zoomIn", serde_json::json!({})).unwrap(); + h.run_steps(2); + assert_near(mid, slide_point(&h, c), "zoom in"); + } + h.state_mut().run("view.zoomOut", serde_json::json!({})).unwrap(); + h.run_steps(2); + assert_near(mid, slide_point(&h, c), "zoom out"); + } +}