diff --git a/contributors/people.toml b/contributors/people.toml index 15ea7507..f42dcc57 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b6c1fffe..e6a66f04 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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. diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 180dc85e..95aaaf95 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -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) { + 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); + } +}