Issue #106: zooming keeps the point under the pointer

Ctrl/Cmd + wheel and pinch changed the zoom but left the scroll offset
alone, so the slide grew from the canvas corner (from its top-left once it
was larger than the window) and the spot under the pointer slid away. The
pointer position was read but never used. Zoom In/Out, the status-bar
slider and the Zoom dialog had the same effect.

The canvas now remembers last frame's slide rect and scale. When the scale
changes it sets the offset so the slide point under the anchor stays put:
the pointer for wheel and pinch zoom, the middle of the canvas for the
commands. Once the slide fits the window it is centred as before. Wheel
and pinch zoom take effect in the same frame instead of the next.

Fixes #106

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lloyd
2026-10-11 16:57:53 +02:00
co-authored by Claude Opus 5.5
parent a98df49b21
commit e999424c79
+118 -10
View File
@@ -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");
}
}