diff --git a/crates/ui-egui/src/autoscroll.rs b/crates/ui-egui/src/autoscroll.rs index e025733f..139bdaed 100644 --- a/crates/ui-egui/src/autoscroll.rs +++ b/crates/ui-egui/src/autoscroll.rs @@ -1,4 +1,6 @@ -//! Linux middle-button scrolling for the document viewport. +//! Middle-button scrolling for the document viewport: Linux latches auto-scroll, other +//! platforms pan while the button is held. Either way the page tools never see the button, +//! because egui's drag responses accept every pointer button. use egui::{Context, CursorIcon, Event, Key, PointerButton, Pos2, Stroke, Vec2, vec2}; @@ -17,6 +19,8 @@ pub(crate) struct AutoScroll { /// Own a cancelling click through its release, so it cannot also edit page content. cancel_button: Option, block_input: bool, + /// Where the pointer was last frame during a held middle-button pan (not Linux). + drag: Option, } impl AutoScroll { @@ -24,8 +28,14 @@ impl AutoScroll { self.anchor.is_some() } + /// Whether a held middle-button pan is under way (platforms without auto-scroll). + pub(crate) fn panning(&self) -> bool { + self.drag.is_some() + } + pub(crate) fn cancel(&mut self) { self.anchor = None; + self.drag = None; self.cancel_button = None; self.block_input = false; } @@ -41,10 +51,8 @@ impl AutoScroll { } fn update_for_platform(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, supported: bool) -> Vec2 { - // Leave middle-button events and widget interactions to the existing platform behavior. if !supported { - self.cancel(); - return Vec2::ZERO; + return self.update_drag(ui, viewport); } let ctx = ui.ctx(); let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| { @@ -108,8 +116,51 @@ impl AutoScroll { vec2(0.0, delta) } + /// Without auto-scroll, a middle-button drag pans the view 1:1, like the Hand tool. Input is + /// blocked from the press through the release, so the drag cannot also draw, select or move. + fn update_drag(&mut self, ui: &egui::Ui, viewport: egui::Rect) -> Vec2 { + self.anchor = None; + self.cancel_button = None; + let ctx = ui.ctx(); + let (pointer, middle_press, middle_down, middle_released, focused) = ctx.input(|i| { + ( + i.pointer.latest_pos().filter(|p| p.is_finite()), + // Read the press event itself: later movement in this frame is part of the pan. + i.events.iter().find_map(|event| match event { + Event::PointerButton { pos, button: PointerButton::Middle, pressed: true, .. } if pos.is_finite() => Some(*pos), + _ => None, + }), + i.pointer.button_down(PointerButton::Middle), + i.pointer.button_released(PointerButton::Middle), + i.focused, + ) + }); + self.block_input = self.drag.is_some() || middle_press.is_some() || middle_down || middle_released; + if !focused { + self.drag = None; + return Vec2::ZERO; + } + if let Some(pressed_at) = middle_press + && self.drag.is_none() + && !ctx.egui_wants_keyboard_input() + && viewport.intersect(ui.clip_rect()).contains(pressed_at) + && ctx.layer_id_at(pressed_at) == Some(ui.layer_id()) + { + self.drag = Some(pressed_at); + } + let (Some(last), Some(pointer)) = (self.drag, pointer) else { return Vec2::ZERO }; + self.drag = middle_down.then_some(pointer); + // egui's delta moves the content, so the page follows the pointer. + let delta = pointer - last; + if delta.is_finite() { delta } else { Vec2::ZERO } + } + /// Draw an original geometric marker at the activation point, above page content. pub(crate) fn paint(&self, ui: &egui::Ui, viewport: egui::Rect) { + if self.drag.is_some() { + ui.ctx().set_cursor_icon(CursorIcon::Grabbing); + return; + } let Some(anchor) = self.anchor else { return }; let painter = ui.painter().with_clip_rect(viewport); let ink = ui.visuals().text_color(); @@ -154,38 +205,80 @@ fn scroll_delta(displacement: f32, dt: f32) -> f32 { mod tests { use super::*; + fn middle(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE } + } + + /// Run one frame of the non-Linux path; `check` sees the pan delta inside the frame. + fn drag_frame(ctx: &Context, scroll: &mut AutoScroll, events: Vec, focused: bool, check: impl Fn(&AutoScroll, Vec2)) { + let mut output = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, focused, ..Default::default() }, + |ui| { + let delta = scroll.update_for_platform(ui, ui.max_rect(), false, false); + check(scroll, delta); + }, + ); + // This input-only test has no renderer to apply the generated font texture. + output.textures_delta.clear(); + } + #[test] - fn unsupported_platform_leaves_middle_button_and_escape_input_untouched() { - for previously_active in [false, true] { - let ctx = Context::default(); - let pos = egui::pos2(100.0, 100.0); - let mut scroll = AutoScroll { anchor: previously_active.then_some(pos), block_input: previously_active, ..Default::default() }; - let mut output = ctx.run_ui( - egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), - events: vec![ - Event::PointerMoved(pos), - Event::PointerButton { pos, button: PointerButton::Middle, pressed: true, modifiers: egui::Modifiers::NONE }, - Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, - ], - ..Default::default() - }, - |ui| { - assert_eq!(scroll.update_for_platform(ui, ui.max_rect(), false, false), Vec2::ZERO); - assert!(!scroll.active(), "unsupported platforms must not start or retain custom scrolling"); - assert!(!scroll.blocks_input(), "middle-button input must reach the existing widgets"); - scroll.paint(ui, ui.max_rect()); - assert_eq!(ctx.output(|o| o.cursor_icon), CursorIcon::Default, "no custom marker or cursor"); - assert!(!scroll.escape(&ctx)); - ctx.input(|i| { - assert!(i.pointer.button_pressed(PointerButton::Middle)); - assert!(i.key_pressed(Key::Escape), "Escape must remain available to the existing shortcuts"); - }); - }, - ); - // This input-only test has no renderer to apply the generated font texture. - output.textures_delta.clear(); - } + fn middle_drag_pans_one_to_one_and_owns_the_button_until_release() { + let ctx = Context::default(); + let mut scroll = AutoScroll::default(); + let p = egui::pos2(100.0, 100.0); + // The first frame lays out the layer that later presses are tested against. + drag_frame(&ctx, &mut scroll, vec![], true, |_, _| {}); + drag_frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, |s, d| { + assert_eq!(d, Vec2::ZERO); + assert!(s.panning() && s.blocks_input(), "the press itself must not reach the page tools"); + assert!(!s.active(), "no auto-scroll latch outside Linux"); + }); + drag_frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + vec2(-30.0, 40.0))], true, |s, d| { + assert_eq!(d, vec2(-30.0, 40.0), "the page follows the pointer on both axes"); + assert!(s.blocks_input()); + }); + drag_frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + vec2(-30.0, 50.0)), middle(p + vec2(-30.0, 50.0), false)], true, |s, d| { + assert_eq!(d, vec2(0.0, 10.0), "movement in the release frame still counts"); + assert!(!s.panning()); + assert!(s.blocks_input(), "the release must not finish a drawing drag"); + }); + drag_frame(&ctx, &mut scroll, vec![Event::PointerMoved(p)], true, |s, d| { + assert_eq!(d, Vec2::ZERO, "nothing moves once the button is up"); + assert!(!s.blocks_input()); + }); + } + + #[test] + fn middle_drag_ignores_presses_outside_the_viewport_and_stops_when_focus_is_lost() { + let ctx = Context::default(); + let mut scroll = AutoScroll::default(); + let outside = egui::pos2(500.0, 100.0); + drag_frame(&ctx, &mut scroll, vec![], true, |_, _| {}); + drag_frame(&ctx, &mut scroll, vec![Event::PointerMoved(outside), middle(outside, true)], true, |s, _| assert!(!s.panning())); + drag_frame(&ctx, &mut scroll, vec![Event::PointerMoved(outside + vec2(0.0, 50.0))], true, |_, d| assert_eq!(d, Vec2::ZERO)); + drag_frame(&ctx, &mut scroll, vec![middle(outside, false)], true, |_, _| {}); + + let p = egui::pos2(100.0, 100.0); + drag_frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, |s, _| assert!(s.panning())); + drag_frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + vec2(0.0, 50.0))], false, |s, d| { + assert_eq!(d, Vec2::ZERO); + assert!(!s.panning(), "losing focus ends the pan"); + }); + } + + #[test] + fn middle_drag_leaves_escape_to_the_existing_shortcuts() { + let ctx = Context::default(); + let mut scroll = AutoScroll::default(); + let p = egui::pos2(100.0, 100.0); + drag_frame(&ctx, &mut scroll, vec![], true, |_, _| {}); + let escape = Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + drag_frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true), escape], true, |s, _| { + assert!(s.panning()); + assert!(ctx.input(|i| i.key_pressed(Key::Escape)), "Escape must remain available to the existing shortcuts"); + }); + assert!(!scroll.escape(&ctx)); } #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 11bc1cb1..3084f623 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -337,6 +337,11 @@ impl DocView { self.auto_scroll.active() } + /// Whether a held middle-button pan is active, outside Linux (tests and automation). + pub fn middle_panning(&self) -> bool { + self.auto_scroll.panning() + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 3bade2c7..2544eeb3 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -594,6 +594,52 @@ fn attaching_a_file_as_a_comment() { assert_eq!(s.quick_tool, QuickTool::Select); } +/// Drag with the middle button (the mouse wheel) in small steps. +fn middle_drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE }; + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(button(from, true)); + h.run_steps(1); + for k in 1..=4 { + h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))); + h.run_steps(1); + } + h.event(button(to, false)); + h.run_steps(3); +} + +#[test] +fn a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing() { + // egui's drag responses accept any button, so the drawing tools used to draw with the wheel. + let mut h = harness(|app| { + app.set_option("zoom", "400").unwrap(); + app.set_option("quick", "ink").unwrap(); + }); + let from = at(&h, 150.0, 100.0); + let top = h.state().views[0].page_screen_rect(0).expect("page 1 on screen").top(); + middle_drag(&mut h, from, from - egui::vec2(0.0, 120.0)); + assert!(comments(&h).is_empty(), "{:?}", comments(&h)); + let s = h.state(); + assert!(!s.session.get(s.views[0].id).unwrap().dirty, "scrolling never edits the PDF"); + assert!(!s.views[0].middle_panning(), "releasing the wheel ends the pan"); + let moved = s.views[0].page_screen_rect(0).expect("page 1 on screen").top(); + if cfg!(target_os = "linux") { + // Linux latches auto-scroll instead of panning with the drag. + assert!(s.views[0].auto_scrolling()); + } else { + assert!((moved - (top - 120.0)).abs() < 1.0, "the page follows the pointer: {top} -> {moved}"); + } + // The tool still draws with the primary button afterwards. + if cfg!(target_os = "linux") { + h.key_press(egui::Key::Escape); + h.run_steps(2); + } + drag_pt(&mut h, (60.0, 120.0), (200.0, 80.0)); + let kinds: Vec = comments(&h).into_iter().map(|a| a.subtype).collect(); + assert_eq!(kinds, ["Ink"]); +} + #[test] fn erasing_part_of_a_drawing() { let mut h = harness(|app| app.set_option("quick", "ink").unwrap()); diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index f21aee6d..28f62419 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -446,7 +446,7 @@ fn organize_pages_supports_autoscroll_without_selecting_or_reordering_pages() { #[cfg(not(target_os = "linux"))] #[test] -fn middle_button_input_does_not_start_custom_scrolling_outside_linux() { +fn middle_button_pans_while_held_outside_linux() { for organize in [false, true] { let (mut h, c) = harness_pages(40); h.state_mut().views[0].organize = organize; @@ -455,8 +455,26 @@ fn middle_button_input_does_not_start_custom_scrolling_outside_linux() { ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y, "button": "middle" })); ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 50.0 })); h.run_steps(8); - assert!(!h.state().views[0].auto_scrolling(), "custom scrolling must be Linux-only: organize={organize}"); + assert!(!h.state().views[0].auto_scrolling(), "a click does not latch auto-scroll outside Linux: organize={organize}"); + assert!(!h.state().views[0].middle_panning(), "a released click leaves nothing to pan: organize={organize}"); } + // A drag pans while the button is held, and the Crop tool never sees it. + let (mut h, c) = harness(); + h.state_mut().set_option("zoom", "400").unwrap(); + h.run_steps(3); + h.state_mut().views[0].go_to_page(1); + h.run_steps(2); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Crop; + let p = h.state().views[0].viewport_rect().center(); + let top = h.state().views[0].page_screen_rect(1).unwrap().top(); + ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y - 60.0], "steps": 12, "button": "middle" })); + h.run_steps(2); + let moved = h.state().views[0].page_screen_rect(1).unwrap().top(); + assert!((moved - (top - 60.0)).abs() < 1.0, "the page follows the pointer 1:1: {top} -> {moved}"); + h.run_steps(8); + assert_eq!(h.state().views[0].page_screen_rect(1).unwrap().top(), moved, "no drift after release"); + assert!(h.state().views[0].crop_drag.is_none(), "the Crop tool must not receive a wheel drag"); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index aba11b3c..08b790f0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -902,8 +902,8 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views"] -notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; other targets retain their existing middle-button input behavior. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented." +evidence = ["crates/ui-egui/tests/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing"] +notes = "Linux-only middle-button vertical autoscroll in the document and Organize Pages; Windows and macOS pan 1:1 while the middle button is held (Grabbing cursor, no latch). On every target the middle button never reaches the page tools, so it scrolls instead of drawing, selecting, cropping or moving. Click to toggle, release keeps scrolling on, and a Chromium-style nonlinear curve increases speed with vertical screen distance from the initial click. A 15-point dead zone pauses immediately. Continuous redraws use elapsed frame time and preserve fractional movement, tested at 30/60/120/144 fps. Cancels on another middle click, Escape, other pointer/wheel input, focus loss, tab changes and dialogs. Driven headlessly through the opt-in UI control channel (ui.click/ui.move/ui.drag). Keyboard-started timed scrolling is not implemented." [[feature]] id = "view.zoom-in-out"