diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c326b479fe..6ac8ea8783 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -99,6 +99,7 @@ impl PdfCraftApp { #[cfg(target_os = "linux")] desktop_theme_enabled: false, scroll_speed: scroll_speed::ScrollSpeed::default(), + invert_middle_drag: false, animation_effects: motion::AnimationEffects::default(), motion: motion::Motion::default(), language: i18n::AUTO.to_string(), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index e4078efd13..0309042bf1 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -50,6 +50,9 @@ impl PdfCraftApp { ("scroll_speed", _) => { self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?; } + ("invert_middle_drag", _) => { + self.invert_middle_drag = value.parse::().map_err(|_| "invert_middle_drag must be true or false")?; + } ("animation_effects", _) => { self.animation_effects = motion::AnimationEffects::parse(value).ok_or("animation_effects must be system, on, or off")?; } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 7eddee0af0..d3db335d83 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -89,6 +89,7 @@ impl PdfCraftApp { "pinned_folders": self.pinned.folders, "theme": self.theme_preference, "scroll_speed": self.scroll_speed, + "invert_middle_drag": self.invert_middle_drag, "animation_effects": self.animation_effects, "default_mode": self.default_mode, "default_layout": canvas::display_token(self.view_defaults.layout, self.view_defaults.continuous), @@ -237,6 +238,9 @@ impl PdfCraftApp { if let Ok(speed) = serde_json::from_value::(v["scroll_speed"].clone()) { self.scroll_speed = speed; } + if let Some(invert) = v["invert_middle_drag"].as_bool() { + self.invert_middle_drag = invert; + } if let Ok(effects) = serde_json::from_value::(v["animation_effects"].clone()) { self.animation_effects = effects; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56d2674213..3879cd9344 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3338,9 +3338,9 @@ fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rec /// The middle-button auto-scroll's delta, and the edge scroll a drawing in progress asks for: the /// pointer in a band along any edge (or just past it) scrolls the view that way, so a point /// beyond what is shown can be reached without letting go of the tool. -fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool) -> (Vec2, Vec2) { +fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool, invert: bool) -> (Vec2, Vec2) { let auto_delta = if unobstructed { - view.auto_scroll.update(ui, avail, false) + view.auto_scroll.update(ui, avail, false, invert) } else { view.auto_scroll.cancel(); Vec2::ZERO @@ -3517,11 +3517,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let modal = app.modal_open(); let unobstructed = !modal && !app.palette_open; let organize_zoom = app.organize_zoom; + let invert_middle = app.invert_middle_drag; let view = &mut app.views[index]; if view.organize { // One remembered grid size for every document, kept in the settings. view.set_grid_zoom(organize_zoom); - organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, ui, &t); + organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, invert_middle, ui, &t); app.organize_zoom = view.grid_zoom(); return; } @@ -3541,7 +3542,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let present = presentation_click_state(ui, view, navigation_rect, navigation_enabled && app.quick_tool == QuickTool::Select); pinch_zoom(ui, view, zoom_delta, navigation_rect, navigation_enabled); view.viewport_screen = avail; - let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed); + let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed, invert_middle); let content_w = view.content_width(info, avail.width()); let rects = view.layout(info, content_w, ui.ctx().pixels_per_point()); diff --git a/crates/ui-egui/src/canvas/autoscroll.rs b/crates/ui-egui/src/canvas/autoscroll.rs index 14751ffc5c..1f4cd62c5d 100644 --- a/crates/ui-egui/src/canvas/autoscroll.rs +++ b/crates/ui-egui/src/canvas/autoscroll.rs @@ -46,15 +46,18 @@ impl AutoScroll { } /// Run before the document's widgets. Starting is restricted to the unobstructed viewport; - /// once started, moving outside that viewport still controls the speed. - pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 { - self.update_for_platform(ui, viewport, organize, cfg!(target_os = "linux")) + /// once started, moving outside that viewport still controls the speed. `invert` is + /// Preferences ▸ Invert middle-button drag: the returned delta scrolls the other way. + pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, invert: bool) -> Vec2 { + self.update_for_platform(ui, viewport, organize, cfg!(target_os = "linux"), invert) } - fn update_for_platform(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, supported: bool) -> Vec2 { - if !supported { - return self.update_drag(ui, viewport); - } + fn update_for_platform(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, supported: bool, invert: bool) -> Vec2 { + let delta = if supported { self.update_latched(ui, viewport, organize) } else { self.update_drag(ui, viewport) }; + oriented(delta, invert) + } + + fn update_latched(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 { let ctx = ui.ctx(); let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| { let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2] @@ -237,6 +240,11 @@ pub(crate) fn edge_delta(y: f32, viewport: egui::Rect, dt: f32) -> f32 { -distance.clamp(-MAX_DISPLACEMENT, MAX_DISPLACEMENT) * EDGE_SPEED * dt.clamp(0.0, 0.05) } +/// The middle button's delta, reversed when the user inverted it. +fn oriented(delta: Vec2, invert: bool) -> Vec2 { + if invert { -delta } else { delta } +} + fn scroll_delta(displacement: f32, dt: f32) -> f32 { if !displacement.is_finite() || !dt.is_finite() { return 0.0; @@ -261,10 +269,15 @@ mod tests { /// 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)) { + frame(ctx, scroll, events, focused, (false, false), check); + } + + /// Run one frame; `mode` is (latched auto-scroll supported, inverted). + fn frame(ctx: &Context, scroll: &mut AutoScroll, events: Vec, focused: bool, mode: (bool, 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); + let delta = scroll.update_for_platform(ui, ui.max_rect(), false, mode.0, mode.1); check(scroll, delta); }, ); @@ -272,6 +285,40 @@ mod tests { output.textures_delta.clear(); } + /// The pan delta after pressing at `p` and moving the pointer by `by`. + fn pan_by(by: Vec2, invert: bool) -> Vec2 { + let (ctx, mut scroll, p) = (Context::default(), AutoScroll::default(), egui::pos2(100.0, 100.0)); + frame(&ctx, &mut scroll, vec![], true, (false, invert), |_, _| {}); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (false, invert), |_, _| {}); + let moved = std::cell::Cell::new(Vec2::ZERO); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + by)], true, (false, invert), |_, d| moved.set(d)); + moved.get() + } + + /// The auto-scroll delta in the frame after latching at `p`, with the pointer `dy` below it. + fn latched_by(dy: f32, invert: bool) -> Vec2 { + let (ctx, mut scroll, p) = (Context::default(), AutoScroll::default(), egui::pos2(100.0, 100.0)); + frame(&ctx, &mut scroll, vec![], true, (true, invert), |_, _| {}); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true), middle(p, false)], true, (true, invert), |_, _| {}); + let moved = std::cell::Cell::new(Vec2::ZERO); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + vec2(0.0, dy))], true, (true, invert), |s, d| { + assert!(s.active(), "the click latched auto-scroll"); + moved.set(d); + }); + moved.get() + } + + #[test] + fn inverting_the_middle_button_reverses_the_pan_and_the_auto_scroll() { + let by = vec2(-30.0, 40.0); + assert_eq!(pan_by(by, false), by, "by default the page follows the pointer"); + assert_eq!(pan_by(by, true), -by, "inverted, the view moves the way the pointer goes"); + let (normal, inverted) = (latched_by(60.0, false), latched_by(60.0, true)); + assert!(normal.y < 0.0, "by default, below the anchor scrolls down: {normal:?}"); + assert_eq!(inverted, -normal, "inverted, the same distance scrolls the other way"); + assert_eq!(latched_by(10.0, true), Vec2::ZERO, "the dead zone still holds still"); + } + #[test] fn middle_drag_pans_one_to_one_and_owns_the_button_until_release() { let ctx = Context::default(); diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs index 48c7e82d8f..25c99543e4 100644 --- a/crates/ui-egui/src/canvas/organize.rs +++ b/crates/ui-egui/src/canvas/organize.rs @@ -272,6 +272,7 @@ pub(super) fn organize_grid( doc: &pdfcraft_engine::Document, unobstructed: bool, show_page_edge_shadows: bool, + invert_middle: bool, ui: &mut egui::Ui, t: &Tokens, ) { @@ -288,7 +289,7 @@ pub(super) fn organize_grid( let viewport = ui.available_rect_before_wrap(); view.viewport_screen = viewport; let auto_delta = if unobstructed { - view.auto_scroll.update(ui, viewport, true) + view.auto_scroll.update(ui, viewport, true, invert_middle) } else { view.auto_scroll.cancel(); Vec2::ZERO diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 49609461d8..38cce3274b 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -759,7 +759,11 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, }); ctx.label_secondary(ui, tl!("For page sizes and margins."), t); ui.add_space(6.0); + scrolling(ui, app, t, ctx); +} +/// The scroll speed and the middle button's direction, in Card 3. +fn scrolling(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { ui.horizontal(|ui| { ctx.label_primary(ui, tl!("Scroll speed (mouse wheel and arrow keys)"), t); ctx.pill_choice( @@ -773,6 +777,9 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ], ); }); + ui.add_space(6.0); + ctx.checkbox(ui, &mut app.invert_middle_drag, tl!("Invert middle-button drag"), t); + ctx.label_secondary(ui, tl!("Panning or auto-scrolling with the middle (wheel) button moves the page the other way."), t); } /// Content for Card 4: Identity diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c25dae98e3..643d5e90ff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -311,6 +311,9 @@ pub struct PdfCraftApp { desktop_theme_enabled: bool, /// How far a mouse-wheel notch scrolls. pub scroll_speed: scroll_speed::ScrollSpeed, + /// Preferences ▸ Invert middle-button drag: the middle button's pan or auto-scroll moves the + /// view the other way. Off by default. + pub invert_middle_drag: bool, /// Preferences ▸ Animation effects: on, off, or as the system's animation setting says. pub animation_effects: motion::AnimationEffects, /// Whether the system asked for no animations, and how the desktop app asks it. diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 6a0abd749b..c1465b233d 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -916,6 +916,25 @@ fn middle_button_pans_while_held_outside_linux() { assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); } +/// With Invert middle-button drag on, a held middle drag moves the view the way the pointer goes. +#[cfg(not(target_os = "linux"))] +#[test] +fn inverted_middle_drag_moves_the_view_the_way_the_pointer_goes() { + let (mut h, c) = harness(); + h.state_mut().set_option("zoom", "400").unwrap(); + ok(&mut h, &c, "ui.set", json!({ "key": "invert_middle_drag", "value": "true" })); + h.run_steps(3); + h.state_mut().views[0].go_to_page(1); + h.run_steps(2); + 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, "dragging up scrolls up, so the page moves down: {top} -> {moved}"); + assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); +} + #[test] fn state_and_view_options() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 3d824b9ea1..6f7d3d3cc8 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1838,6 +1838,22 @@ fn the_scroll_speed_preference_scales_mouse_wheel_notches() { assert_eq!(again.scroll_speed, ScrollSpeed::Fast); } +#[test] +fn the_invert_middle_drag_preference_is_off_by_default_and_kept_in_the_settings() { + let mut app = PdfCraftApp::new(); + assert!(!app.invert_middle_drag, "the middle button keeps its direction unless asked"); + app.set_option("invert_middle_drag", "true").unwrap(); + assert!(app.set_option("invert_middle_drag", "yes").unwrap_err().contains("true or false")); + let mut again = PdfCraftApp::new(); + again.restore(&app.persist()); + assert!(again.invert_middle_drag, "turned on, it survives a restart"); + again.set_option("invert_middle_drag", "false").unwrap(); + let mut off = PdfCraftApp::new(); + off.invert_middle_drag = true; + off.restore(&again.persist()); + assert!(!off.invert_middle_drag, "turned off, that is saved too"); +} + /// A 40 × 30 picture (one red corner) and a harness showing a PDF of it. fn picture_harness() -> (image::RgbaImage, Harness<'static, PdfCraftApp>) { picture_harness_sized(egui::vec2(1400.0, 900.0)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2fe849a6dc..ab8f750190 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -938,8 +938,8 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/it/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/it/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/it/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/it/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/it/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." +evidence = ["crates/ui-egui/tests/it/control.rs::middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions", "crates/ui-egui/tests/it/control.rs::organize_pages_supports_autoscroll_without_selecting_or_reordering_pages", "crates/ui-egui/tests/it/control.rs::moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools", "crates/ui-egui/tests/it/control.rs::farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame", "crates/ui-egui/tests/it/control.rs::autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views", "crates/ui-egui/tests/it/control.rs::middle_button_pans_while_held_outside_linux", "crates/ui-egui/tests/it/comments.rs::a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing", "crates/ui-egui/tests/it/control.rs::inverted_middle_drag_moves_the_view_the_way_the_pointer_goes", "crates/ui-egui/tests/it/viewer.rs::the_invert_middle_drag_preference_is_off_by_default_and_kept_in_the_settings"] +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. Preferences ▸ Invert middle-button drag (off by default, saved; ui.set invert_middle_drag) reverses the direction of both the pan and the auto-scroll (#5790); it changes direction only, not which of the two a platform uses. 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"