From 5fd4113081a14925754ace1e7db76838cdbae6cd Mon Sep 17 00:00:00 2001 From: yenru0 Date: Sat, 3 Oct 2026 20:33:08 +0900 Subject: [PATCH] image_viewer: Add anchored zoom and pan to the PDF viewer The PDF viewport lacked the interaction set the image view already had: ctrl/cmd+wheel and pinch zoomed nothing (plain wheel just scrolled), middle-drag did not pan, and the zoom actions only applied image-mode pan math, which does nothing for the scroll-based PDF layout. - Ctrl/cmd+wheel zooms around the cursor: the native scroll handler on the same element runs first in the bubble phase, so the applied wheel delta is undone before anchoring the zoom. - Pinch zooms around the touch point; zoom actions anchor the viewport center. - Middle-drag pans, keeping left-drag for text selection and pen input. - extract_document_details now falls back to a single mock US-Letter page when no native libpdfium is present, mirroring the mock rasterizer, so PDFs open on machines without the engine instead of failing with an image-format error. - The annotation mouse test gates on native pdfium like the channel order test, since mock pages carry no text. Anchor math lives in pdf_anchored_scroll_y with unit tests, and a gpui integration test drives real wheel/drag events to assert the document point under the cursor stays put while zooming. --- crates/image_viewer/src/image_viewer.rs | 311 +++++++++++++++++++++++- crates/pdf_viewer/src/pdfium.rs | 22 +- 2 files changed, 328 insertions(+), 5 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 87084982ff..a683ff1986 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -112,6 +112,24 @@ const MIN_ZOOM: f32 = 0.1; const MAX_ZOOM: f32 = 20.0; const ZOOM_STEP: f32 = 1.1; const SCROLL_LINE_MULTIPLIER: f32 = 20.0; + +/// Scroll offset that keeps the document point under `anchor_rel_y` (offset from the +/// viewport top) stationary while the zoom scales by `zoom_ratio`. `max_offset_y` and +/// `viewport_height` describe the pre-zoom layout; the result is clamped to the scaled +/// scrollable range. +fn pdf_anchored_scroll_y( + offset_y: Pixels, + anchor_rel_y: Pixels, + zoom_ratio: f32, + max_offset_y: Pixels, + viewport_height: Pixels, +) -> Pixels { + // Zooming out below the fit height leaves the content shorter than the viewport, + // where the scrollable range collapses to zero rather than going negative. + let max_y = (((max_offset_y + viewport_height) * zoom_ratio) - viewport_height).max(px(0.0)); + (anchor_rel_y * (1.0 - zoom_ratio) + offset_y * zoom_ratio).clamp(-max_y, px(0.0)) +} + const BASE_SQUARE_SIZE: f32 = 32.0; const ZOOM_EDITOR_MIN_DIGITS: usize = 3; // Reserve room for common values like 100%. const ZOOM_EDITOR_MAX_DIGITS: usize = 4; // MAX_ZOOM is 2000%. @@ -270,6 +288,8 @@ pub struct ImageView { text_editor: Option>, pdf_selection: Option, pdf_mouse_down: bool, + /// Middle-drag pan in progress: (cursor position, scroll offset) captured at press. + pdf_middle_pan: Option<(Point, Point)>, pdf_drag_position: Option>, pdf_autoscroll_task: Option>, context_menu: Option<(Entity, Point, Subscription)>, @@ -433,6 +453,7 @@ impl ImageView { text_editor: None, pdf_selection: None, pdf_mouse_down: false, + pdf_middle_pan: None, pdf_drag_position: None, pdf_autoscroll_task: None, context_menu: None, @@ -529,14 +550,26 @@ impl ImageView { } fn zoom_in(&mut self, _: &ZoomIn, _window: &mut Window, cx: &mut Context) { - self.set_zoom(self.zoom_level * ZOOM_STEP, None, cx); + if self.image_item.read(cx).is_pdf() { + self.zoom_pdf_view(self.zoom_level * ZOOM_STEP, None, cx); + } else { + self.set_zoom(self.zoom_level * ZOOM_STEP, None, cx); + } } fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context) { - self.set_zoom(self.zoom_level / ZOOM_STEP, None, cx); + if self.image_item.read(cx).is_pdf() { + self.zoom_pdf_view(self.zoom_level / ZOOM_STEP, None, cx); + } else { + self.set_zoom(self.zoom_level / ZOOM_STEP, None, cx); + } } fn reset_zoom(&mut self, _: &ResetZoom, _window: &mut Window, cx: &mut Context) { + if self.image_item.read(cx).is_pdf() { + self.zoom_pdf_view(1.0, None, cx); + return; + } self.zoom_level = 1.0; self.pan_offset = Point::default(); cx.notify(); @@ -570,6 +603,10 @@ impl ImageView { _window: &mut Window, cx: &mut Context, ) { + if self.image_item.read(cx).is_pdf() { + self.zoom_pdf_view(1.0, None, cx); + return; + } self.zoom_level = 1.0; self.pan_offset = Point::default(); cx.notify(); @@ -1621,6 +1658,98 @@ impl ImageView { } } + /// Zooms the PDF layout around `anchor` (a window position), adjusting the scroll + /// offset so the document point under the anchor stays put. `None` anchors the + /// viewport center. + /// + /// `max_offset` still reflects the pre-zoom layout until the next paint, so the + /// scrollable range is scaled manually here. + fn zoom_pdf_view( + &mut self, + new_zoom: f32, + anchor: Option>, + cx: &mut Context, + ) { + let old_zoom = self.zoom_level; + let new_zoom = new_zoom.clamp(MIN_ZOOM, MAX_ZOOM); + let zoom_ratio = new_zoom / old_zoom; + if (zoom_ratio - 1.0).abs() < f32::EPSILON { + return; + } + self.zoom_level = new_zoom; + self.pdf_middle_pan = None; + + let viewport = self.pdf_scroll_handle.bounds(); + if viewport.size.width <= px(0.0) || viewport.size.height <= px(0.0) { + cx.notify(); + return; + } + + let anchor_rel = match anchor { + Some(position) => position - viewport.origin, + None => point(viewport.size.width / 2.0, viewport.size.height / 2.0), + }; + let new_y = pdf_anchored_scroll_y( + self.pdf_scroll_handle.offset().y, + anchor_rel.y, + zoom_ratio, + self.pdf_scroll_handle.max_offset().y, + viewport.size.height, + ); + // The container only scrolls vertically; horizontal placement is layout-driven. + self.pdf_scroll_handle.set_offset(point(px(0.0), new_y)); + cx.notify(); + } + + /// Ctrl/cmd+wheel zoom for the PDF viewport. The native scroll handler on the same + /// element is registered after this listener, so it runs first in the bubble phase + /// and has already applied the wheel delta; undo it before zooming around the cursor. + fn handle_pdf_scroll_wheel( + &mut self, + event: &ScrollWheelEvent, + window: &mut Window, + cx: &mut Context, + ) { + if !event.modifiers.control && !event.modifiers.platform { + return; + } + cx.stop_propagation(); + + let delta_y = event.delta.pixel_delta(window.line_height()).y; + let offset = self.pdf_scroll_handle.offset(); + self.pdf_scroll_handle + .set_offset(point(offset.x, offset.y - delta_y)); + + let magnitude: f32 = delta_y.abs().into(); + let zoom_factor = if delta_y > px(0.0) { + 1.0 + magnitude * 0.01 + } else { + 1.0 / (1.0 + magnitude * 0.01) + }; + self.zoom_pdf_view(self.zoom_level * zoom_factor, Some(event.position), cx); + } + + fn handle_pdf_pinch( + &mut self, + event: &PinchEvent, + _window: &mut Window, + cx: &mut Context, + ) { + self.zoom_pdf_view(self.zoom_level * (1.0 + event.delta), Some(event.position), cx); + } + + fn handle_pdf_middle_mouse_down(&mut self, event: &MouseDownEvent, cx: &mut Context) { + self.pdf_middle_pan = Some((event.position, self.pdf_scroll_handle.offset())); + cx.stop_propagation(); + cx.notify(); + } + + fn handle_pdf_middle_mouse_up(&mut self, cx: &mut Context) { + if self.pdf_middle_pan.take().is_some() { + cx.notify(); + } + } + /// Handles key events for the PDF viewport before they reach focused children. /// Space only scrolls while the find bar is not focused, so it stays typeable. fn handle_pdf_key_down( @@ -3222,6 +3351,7 @@ impl Item for ImageView { text_editor: None, pdf_selection: self.pdf_selection.clone(), pdf_mouse_down: false, + pdf_middle_pan: None, pdf_drag_position: None, pdf_autoscroll_task: None, context_menu: None, @@ -3436,10 +3566,28 @@ impl Render for ImageView { }), ) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { + if let Some((origin, origin_offset)) = this.pdf_middle_pan { + let delta = event.position - origin; + this.pdf_scroll_handle.set_offset(origin_offset - delta); + cx.notify(); + return; + } if this.pdf_mouse_down { this.handle_pdf_general_mouse_move(event.position, cx); } })) + .on_mouse_up( + MouseButton::Middle, + cx.listener(|this, _: &MouseUpEvent, _window, cx| { + this.handle_pdf_middle_mouse_up(cx); + }), + ) + .on_mouse_up_out( + MouseButton::Middle, + cx.listener(|this, _: &MouseUpEvent, _window, cx| { + this.handle_pdf_middle_mouse_up(cx); + }), + ) .on_mouse_up( MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { @@ -3474,9 +3622,18 @@ impl Render for ImageView { .child( div() .id("pdf-scroll-container") + .debug_selector(|| "pdf-scroll-container".into()) .track_scroll(&self.pdf_scroll_handle) .overflow_y_scroll() .size_full() + .on_scroll_wheel(cx.listener(Self::handle_pdf_scroll_wheel)) + .on_pinch(cx.listener(Self::handle_pdf_pinch)) + .on_mouse_down( + MouseButton::Middle, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + this.handle_pdf_middle_mouse_down(event, cx); + }), + ) .on_mouse_down( MouseButton::Left, cx.listener(|this, _event: &MouseDownEvent, window, cx| { @@ -4670,7 +4827,8 @@ mod tests { use super::*; use fs::{FakeFs, Fs as _}; use gpui::{ - Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, TestAppContext, VisualTestContext, + Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, TestAppContext, TouchPhase, + VisualTestContext, }; use settings::SettingsStore; use util::rel_path::rel_path; @@ -4931,6 +5089,25 @@ mod tests { }); } + #[test] + fn test_pdf_anchored_scroll_keeps_anchor_stationary() { + // Zooming in 2x with the anchor 100px below the viewport top must keep the + // document point at that screen position: content coord 1100px (100 - (-1000)) + // maps back to 1100 * 2 - 2100 = 100. + let scrolled = pdf_anchored_scroll_y(px(-1000.0), px(100.0), 2.0, px(2000.0), px(500.0)); + assert_eq!(scrolled, px(-2100.0)); + + // Zooming out above the top edge clamps to the top instead of overscrolling. + let scrolled = pdf_anchored_scroll_y(px(-100.0), px(300.0), 0.5, px(100.0), px(500.0)); + assert_eq!(scrolled, px(0.0)); + + // Zooming out with the viewport at the bottom edge clamps to the scaled bottom + // boundary instead of following the anchor: raw target 100*0.5 + (-2000)*0.5 = + // -950 exceeds the scaled max of (2000+500)*0.5-500 = 750. + let scrolled = pdf_anchored_scroll_y(px(-2000.0), px(100.0), 0.5, px(2000.0), px(500.0)); + assert_eq!(scrolled, px(-750.0)); + } + #[test] fn test_pdf_selection_endpoints() { let forward = PdfSelection { @@ -5466,9 +5643,137 @@ mod tests { out } + #[gpui::test] + async fn test_pdf_wheel_zoom_and_middle_drag_pan(cx: &mut TestAppContext) { + init_test(cx); + let pdf_bytes = build_annotation_fixture(); + let fs = FakeFs::new(cx.executor()); + fs.create_dir(Path::new("/root")) + .await + .expect("test root should be created"); + fs.insert_file("/root/fixture.pdf", pdf_bytes).await; + + let project = Project::test(fs, [Path::new("/root")], cx).await; + let worktree_id = cx.update(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .expect("test project should contain a worktree") + .read(cx) + .id() + }); + let image_item = project + .update(cx, |project, cx| { + project.open_image( + ProjectPath { + worktree_id, + path: rel_path("fixture.pdf").into(), + }, + cx, + ) + }) + .await + .expect("test pdf should open"); + + let (image_view, cx) = cx.add_window_view(|window, cx| { + ImageView::new(image_item.clone(), project.clone(), window, cx) + }); + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + + let pdf_pages = cx.read(|cx| { + image_item + .read(cx) + .pdf_info + .as_ref() + .map(|info| info.total_pages) + }); + let container = cx.debug_bounds("pdf-scroll-container").unwrap_or_else(|| { + panic!("pdf viewport should be laid out; pdf_info pages = {pdf_pages:?}") + }); + let anchor = point( + container.origin.x + container.size.width * 0.5, + container.origin.y + container.size.height * 0.4, + ); + + let (zoom_before, offset_before) = cx.read(|cx| { + let view = image_view.read(cx); + (view.zoom_level, view.pdf_scroll_handle.offset()) + }); + + cx.simulate_event(ScrollWheelEvent { + position: anchor, + delta: ScrollDelta::Lines(point(0.0, 3.0)), + modifiers: Modifiers { + control: true, + ..Modifiers::default() + }, + touch_phase: TouchPhase::Moved, + }); + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + + let (zoom_after, offset_after) = cx.read(|cx| { + let view = image_view.read(cx); + (view.zoom_level, view.pdf_scroll_handle.offset()) + }); + assert!( + zoom_after > zoom_before, + "ctrl+wheel should zoom in, got {zoom_before} -> {zoom_after}" + ); + let zoom_ratio = zoom_after / zoom_before; + let anchor_rel_y = anchor.y - container.origin.y; + let content_y = anchor_rel_y - offset_before.y; + let anchored_screen_y = content_y * zoom_ratio + offset_after.y; + assert!( + (anchored_screen_y - anchor_rel_y).abs() < px(1.0), + "the document point under the cursor should stay put: \ + {anchored_screen_y} vs {anchor_rel_y}" + ); + + // Middle-drag pans the viewport: dragging down scrolls the content up. + let drag_from = point(container.origin.x + px(40.0), container.origin.y + px(40.0)); + cx.simulate_event(MouseDownEvent { + position: drag_from, + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + cx.run_until_parked(); + cx.simulate_event(MouseMoveEvent { + position: drag_from + point(px(0.0), px(80.0)), + pressed_button: Some(MouseButton::Middle), + modifiers: Modifiers::default(), + }); + cx.run_until_parked(); + cx.simulate_event(MouseUpEvent { + position: drag_from + point(px(0.0), px(80.0)), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + }); + cx.run_until_parked(); + + let offset_pan = cx.read(|cx| image_view.read(cx).pdf_scroll_handle.offset()); + assert!( + offset_pan.y < offset_after.y, + "dragging down should scroll the viewport up, got {offset_pan:?} vs {offset_after:?}" + ); + } + #[gpui::test] async fn test_annotation_tools_apply_via_mouse(cx: &mut TestAppContext) { init_test(cx); + // Highlighting requires real text extraction; the mock fallback pages carry + // no character boxes, so only a native engine can exercise this flow. + if !PdfiumEngine::new().is_native_available() { + eprintln!("skipping: native libpdfium not available on this machine"); + return; + } let pdf_bytes = build_annotation_fixture(); let fs = FakeFs::new(cx.executor()); fs.create_dir(Path::new("/root")) diff --git a/crates/pdf_viewer/src/pdfium.rs b/crates/pdf_viewer/src/pdfium.rs index 9c23a1d81f..f073f36325 100644 --- a/crates/pdf_viewer/src/pdfium.rs +++ b/crates/pdf_viewer/src/pdfium.rs @@ -1106,7 +1106,20 @@ impl PdfiumEngine { pages: pages_details, }) } else { - Ok(PdfDocumentDetails::default()) + // Fallback mock document when the native engine binary is unavailable: one + // US-Letter page so PDFs still open and render via the mock rasterizer. + Ok(PdfDocumentDetails { + total_pages: 1, + full_text: String::new(), + pages: vec![PdfPageDetails { + page_index: 0, + width_pt: 612.0, + height_pt: 792.0, + text: String::new(), + links: Vec::new(), + text_segments: Vec::new(), + }], + }) } } @@ -2610,7 +2623,12 @@ mod tests { let details = engine.extract_document_details(dummy_pdf); assert!(details.is_ok()); let doc = details.unwrap(); - assert_eq!(doc.total_pages, 0); + // Without the native library the engine still yields a single mock US-Letter + // page so callers can open the document instead of failing outright. + assert_eq!(doc.total_pages, 1); + let page = doc.pages.first().expect("mock page should exist"); + assert_eq!(page.width_pt, 612.0); + assert_eq!(page.height_pt, 792.0); } #[test]