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:
parent
513a1ecc3a
commit
0f7ff1ce30
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue