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.
This commit is contained in:
1 parent
5ee6aa1e50
commit
5fd4113081
2 files changed
+328
-5
No files matched your search
@@ -112,6 +112,24 @@ const MIN_ZOOM: f32 = 0.1;
|
|||||||
const MAX_ZOOM: f32 = 20.0;
|
const MAX_ZOOM: f32 = 20.0;
|
||||||
const ZOOM_STEP: f32 = 1.1;
|
const ZOOM_STEP: f32 = 1.1;
|
||||||
const SCROLL_LINE_MULTIPLIER: f32 = 20.0;
|
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 BASE_SQUARE_SIZE: f32 = 32.0;
|
||||||
const ZOOM_EDITOR_MIN_DIGITS: usize = 3; // Reserve room for common values like 100%.
|
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%.
|
const ZOOM_EDITOR_MAX_DIGITS: usize = 4; // MAX_ZOOM is 2000%.
|
||||||
@@ -270,6 +288,8 @@ pub struct ImageView {
|
|||||||
text_editor: Option<Entity<Editor>>,
|
text_editor: Option<Entity<Editor>>,
|
||||||
pdf_selection: Option<PdfSelection>,
|
pdf_selection: Option<PdfSelection>,
|
||||||
pdf_mouse_down: bool,
|
pdf_mouse_down: bool,
|
||||||
|
/// Middle-drag pan in progress: (cursor position, scroll offset) captured at press.
|
||||||
|
pdf_middle_pan: Option<(Point<Pixels>, Point<Pixels>)>,
|
||||||
pdf_drag_position: Option<Point<Pixels>>,
|
pdf_drag_position: Option<Point<Pixels>>,
|
||||||
pdf_autoscroll_task: Option<Task<()>>,
|
pdf_autoscroll_task: Option<Task<()>>,
|
||||||
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
|
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
|
||||||
@@ -433,6 +453,7 @@ impl ImageView {
|
|||||||
text_editor: None,
|
text_editor: None,
|
||||||
pdf_selection: None,
|
pdf_selection: None,
|
||||||
pdf_mouse_down: false,
|
pdf_mouse_down: false,
|
||||||
|
pdf_middle_pan: None,
|
||||||
pdf_drag_position: None,
|
pdf_drag_position: None,
|
||||||
pdf_autoscroll_task: None,
|
pdf_autoscroll_task: None,
|
||||||
context_menu: None,
|
context_menu: None,
|
||||||
@@ -529,14 +550,26 @@ impl ImageView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn zoom_in(&mut self, _: &ZoomIn, _window: &mut Window, cx: &mut Context<Self>) {
|
fn zoom_in(&mut self, _: &ZoomIn, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
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>) {
|
fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
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<Self>) {
|
fn reset_zoom(&mut self, _: &ResetZoom, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.image_item.read(cx).is_pdf() {
|
||||||
|
self.zoom_pdf_view(1.0, None, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.zoom_level = 1.0;
|
self.zoom_level = 1.0;
|
||||||
self.pan_offset = Point::default();
|
self.pan_offset = Point::default();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -570,6 +603,10 @@ impl ImageView {
|
|||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
if self.image_item.read(cx).is_pdf() {
|
||||||
|
self.zoom_pdf_view(1.0, None, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.zoom_level = 1.0;
|
self.zoom_level = 1.0;
|
||||||
self.pan_offset = Point::default();
|
self.pan_offset = Point::default();
|
||||||
cx.notify();
|
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<Point<Pixels>>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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<Self>,
|
||||||
|
) {
|
||||||
|
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>,
|
||||||
|
) {
|
||||||
|
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>) {
|
||||||
|
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<Self>) {
|
||||||
|
if self.pdf_middle_pan.take().is_some() {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles key events for the PDF viewport before they reach focused children.
|
/// 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.
|
/// Space only scrolls while the find bar is not focused, so it stays typeable.
|
||||||
fn handle_pdf_key_down(
|
fn handle_pdf_key_down(
|
||||||
@@ -3222,6 +3351,7 @@ impl Item for ImageView {
|
|||||||
text_editor: None,
|
text_editor: None,
|
||||||
pdf_selection: self.pdf_selection.clone(),
|
pdf_selection: self.pdf_selection.clone(),
|
||||||
pdf_mouse_down: false,
|
pdf_mouse_down: false,
|
||||||
|
pdf_middle_pan: None,
|
||||||
pdf_drag_position: None,
|
pdf_drag_position: None,
|
||||||
pdf_autoscroll_task: None,
|
pdf_autoscroll_task: None,
|
||||||
context_menu: None,
|
context_menu: None,
|
||||||
@@ -3436,10 +3566,28 @@ impl Render for ImageView {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| {
|
.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 {
|
if this.pdf_mouse_down {
|
||||||
this.handle_pdf_general_mouse_move(event.position, cx);
|
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(
|
.on_mouse_up(
|
||||||
MouseButton::Left,
|
MouseButton::Left,
|
||||||
cx.listener(|this, _event: &MouseUpEvent, _window, cx| {
|
cx.listener(|this, _event: &MouseUpEvent, _window, cx| {
|
||||||
@@ -3474,9 +3622,18 @@ impl Render for ImageView {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id("pdf-scroll-container")
|
.id("pdf-scroll-container")
|
||||||
|
.debug_selector(|| "pdf-scroll-container".into())
|
||||||
.track_scroll(&self.pdf_scroll_handle)
|
.track_scroll(&self.pdf_scroll_handle)
|
||||||
.overflow_y_scroll()
|
.overflow_y_scroll()
|
||||||
.size_full()
|
.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(
|
.on_mouse_down(
|
||||||
MouseButton::Left,
|
MouseButton::Left,
|
||||||
cx.listener(|this, _event: &MouseDownEvent, window, cx| {
|
cx.listener(|this, _event: &MouseDownEvent, window, cx| {
|
||||||
@@ -4670,7 +4827,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use fs::{FakeFs, Fs as _};
|
use fs::{FakeFs, Fs as _};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, TestAppContext, VisualTestContext,
|
Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, TestAppContext, TouchPhase,
|
||||||
|
VisualTestContext,
|
||||||
};
|
};
|
||||||
use settings::SettingsStore;
|
use settings::SettingsStore;
|
||||||
use util::rel_path::rel_path;
|
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]
|
#[test]
|
||||||
fn test_pdf_selection_endpoints() {
|
fn test_pdf_selection_endpoints() {
|
||||||
let forward = PdfSelection {
|
let forward = PdfSelection {
|
||||||
@@ -5466,9 +5643,137 @@ mod tests {
|
|||||||
out
|
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]
|
#[gpui::test]
|
||||||
async fn test_annotation_tools_apply_via_mouse(cx: &mut TestAppContext) {
|
async fn test_annotation_tools_apply_via_mouse(cx: &mut TestAppContext) {
|
||||||
init_test(cx);
|
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 pdf_bytes = build_annotation_fixture();
|
||||||
let fs = FakeFs::new(cx.executor());
|
let fs = FakeFs::new(cx.executor());
|
||||||
fs.create_dir(Path::new("/root"))
|
fs.create_dir(Path::new("/root"))
|
||||||
|
|||||||
@@ -1106,7 +1106,20 @@ impl PdfiumEngine {
|
|||||||
pages: pages_details,
|
pages: pages_details,
|
||||||
})
|
})
|
||||||
} else {
|
} 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);
|
let details = engine.extract_document_details(dummy_pdf);
|
||||||
assert!(details.is_ok());
|
assert!(details.is_ok());
|
||||||
let doc = details.unwrap();
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user