Improve PDF Navigation with Cursor-Anchored Zoom and Mouse Panning (#504)

* M3: add cursor zoom and temporary mouse-button navigation on Windows

Anchor Ctrl-wheel zoom to the cursor; share bounded, immediate ScrollArea pan between the middle button and Space + primary drag. Preserve ordinary scrolling and editing, cancel capture on focus/document changes, and initialize numeric zoom before the viewport exists.

Validation on Windows x64: formatting, application check and workspace Clippy pass; full workspace tests pass (928 passed, 4 ignored), including 53 UI unit tests and 27 control integration tests. No branding changes, new dependencies, machine-specific build configuration or local development notes.

* M3: add contributor display name for mhadidi06

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mustafa Hadidi 2026-10-11 22:49:39 +03:00 committed by GitHub
parent 513a1ecc3a
commit 0f7ff1ce30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 110 additions and 8 deletions

View File

@ -93,6 +93,9 @@ real_name = "Atos Lins"
display_name = "Atos Lins"
sync_github_name = false
[people.mhadidi06]
display_name = "Mustafa Hadidi"
[people.LucasT-dev]
real_name = "Lucas"
display_name = "Lucas"

View File

@ -367,7 +367,7 @@ pub struct DocView {
screen_xforms: Vec<(usize, PageXform)>,
/// The scroll viewport on screen last frame.
viewport_screen: Rect,
/// Pending zoom anchor: page, position within it (0..1), and offset from the viewport corner.
/// Pending zoom anchor: page, fractional position (also in gutters), and viewport offset.
zoom_anchor: Option<(usize, f32, f32, Vec2)>,
/// The page view has been shown, so its scroll offset in egui's memory is this document's.
shown: bool,
@ -1214,17 +1214,31 @@ impl DocView {
/// Zoom keeping the centre of the view still.
pub fn set_zoom(&mut self, zoom: f32) {
let centre = self.viewport_screen.center();
// CLI/view options can set zoom before the first layout. Rect::NOTHING has no
// finite centre yet, but an initial zoom still needs to leave the default fit mode.
let centre = if self.viewport_screen.is_positive() { self.viewport_screen.center() } else { Pos2::ZERO };
self.zoom_at(zoom, centre);
}
/// Zoom keeping the document point under `screen_pos` still (pinch, ⌘-scroll).
pub fn zoom_at(&mut self, zoom: f32, screen_pos: Pos2) {
let anchor = self.screen_rects.iter().find(|(_, r)| r.expand(GAP).contains(screen_pos)).map(|(p, r)| {
let f = (screen_pos - r.min) / r.size();
(*p, f.x.clamp(0.0, 1.0), f.y.clamp(0.0, 1.0), screen_pos - self.viewport_screen.min)
});
self.zoom = zoom.clamp(0.08, 64.0);
if !zoom.is_finite() || !screen_pos.is_finite() {
return;
}
let zoom = zoom.clamp(0.08, 64.0);
if zoom == self.zoom && self.fit == Fit::None {
return;
}
// In gutters/gaps, anchor relative to the nearest page rather than jumping to its top.
// Fractions outside 0..1 preserve the pointer's position outside the page as well.
let anchor =
self.screen_rects.iter().min_by(|(_, a), (_, b)| a.distance_sq_to_pos(screen_pos).total_cmp(&b.distance_sq_to_pos(screen_pos))).map(
|(p, r)| {
let f = (screen_pos - r.min) / r.size();
(*p, f.x, f.y, screen_pos - self.viewport_screen.min)
},
);
self.zoom = zoom;
self.fit = Fit::None;
match anchor {
Some(a) => self.zoom_anchor = Some(a),
@ -1997,7 +2011,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// Pinch / ctrl+scroll zoom, anchored on the current page.
let zoom_delta = ui.input(|i| i.zoom_delta());
if (zoom_delta - 1.0).abs() > 0.001 && ui.rect_contains_pointer(avail) {
// Exclude the previous frame's scrollbar tracks from gesture activation.
let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail };
let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused);
if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) {
let z = view.zoom * zoom_delta;
match ui.input(|i| i.pointer.hover_pos()) {
Some(p) => view.zoom_at(z, p),
@ -2880,6 +2897,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
(page, out.state.offset.y <= MARGIN - GAP + 1.0, out.state.offset.y >= max_y - 1.0)
});
view.viewport_screen = out.inner_rect;
view.auto_scroll.paint(ui, avail);
// Collect all main-page demand, including cached surfaces, for byte admission later.

View File

@ -1427,3 +1427,84 @@ fn read_mode_retains_existing_rail_click_to_close_policy() {
assert_eq!(h.state().right, None);
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read);
}
fn navigation_frame(h: &mut Harness<'static, PdfCraftApp>, events: Vec<egui::Event>) {
h.input_mut().events.extend(events);
h.step();
}
fn navigation_wheel(h: &mut Harness<'static, PdfCraftApp>, pos: egui::Pos2, delta: f32, ctrl: bool) {
let modifiers = egui::Modifiers { ctrl, command: ctrl, ..egui::Modifiers::NONE };
navigation_frame(
h,
vec![
egui::Event::ModifiersChanged(modifiers),
egui::Event::PointerMoved(pos),
egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, delta), modifiers, phase: egui::TouchPhase::Move },
],
);
h.run_steps(20);
navigation_frame(h, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]);
}
#[test]
fn ctrl_wheel_anchors_visible_page_and_plain_wheel_only_scrolls() {
for percent in [100, 200, 400] {
for fraction in [0.1, 0.5, 0.9] {
let (mut h, c) = harness_pages(20);
ok(&mut h, &c, "ui.set", json!({"key":"zoom", "value":percent.to_string()}));
h.run_steps(4);
let viewport = h.state().views[0].viewport_rect();
let before = h.state().views[0].page_screen_rect(0).unwrap();
let visible = before.intersect(viewport).shrink(15.0);
let p = egui::pos2(visible.left() + fraction * visible.width(), visible.top() + fraction * visible.height());
let normalised = (p - before.min) / before.size();
let old_zoom = h.state().views[0].zoom;
navigation_wheel(&mut h, p, 20.0, true);
let enlarged = h.state().views[0].page_screen_rect(0).unwrap();
let anchored = enlarged.min + normalised * enlarged.size();
assert!(h.state().views[0].zoom > old_zoom);
assert!((anchored.y - p.y).abs() < 2.0, "percent={percent}, fraction={fraction}");
if before.width() > viewport.width() {
assert!((anchored.x - p.x).abs() < 2.0);
}
navigation_wheel(&mut h, p, -20.0, true);
assert!((h.state().views[0].zoom - old_zoom).abs() < 0.01);
let zoom = h.state().views[0].zoom;
let top = h.state().views[0].page_screen_rect(0).unwrap().top();
navigation_wheel(&mut h, p, -80.0, false);
assert_eq!(h.state().views[0].zoom, zoom);
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < top - 20.0);
}
}
}
#[test]
fn wheel_zoom_is_scoped_to_the_viewport_and_respects_existing_limits() {
let (mut h, c) = harness();
let outside = egui::pos2(5.0, 5.0);
let zoom = h.state().views[0].zoom;
navigation_wheel(&mut h, outside, 40.0, true);
assert_eq!(h.state().views[0].zoom, zoom);
for (percent, delta, expected) in [(6400, 80.0, 64.0), (8, -80.0, 0.08)] {
ok(&mut h, &c, "ui.set", json!({"key":"zoom", "value":percent.to_string()}));
h.run_steps(4);
let p = h.state().views[0].viewport_rect().center();
navigation_wheel(&mut h, p, delta, true);
assert_eq!(h.state().views[0].zoom, expected);
}
}
#[test]
fn initial_zoom_option_is_applied_before_the_first_viewport_layout() {
for percent in [50, 100, 200, 400] {
let h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("initial-zoom.pdf", None, fixture(3)).unwrap();
app.set_option("zoom", &percent.to_string()).unwrap();
assert_eq!(app.views[0].zoom, percent as f32 / 100.0, "initial options must work before the viewport exists");
app
});
assert_eq!(h.state().views[0].zoom, percent as f32 / 100.0);
}
}