feat(ui-egui): Preferences can invert the middle-button drag (#5790) (#5817)

Add a saved preference, invert_middle_drag (off by default), that
reverses the direction of the middle-button pan and of the latched
auto-scroll. It changes only the direction, not which of the two a
platform uses, so it composes with the Windows auto-scroll work.

Reachable as Preferences > Invert middle-button drag and headlessly
through ui.set invert_middle_drag. Parity "Automatic scrolling" notes
updated; status unchanged.

Tests: a unit test of both paths with the switch on and off (dead zone
kept), a settings round-trip test, and an end-to-end control-channel
drag. Each fails with its fix reverted.


Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9

Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-03 00:33:18 -05:00
1 parent bf0f3c9f77
commit 5cb65ff0ee
11 files changed
+117 -15

No files matched your search

+1
View File
@@ -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(),
+3
View File
@@ -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::<bool>().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")?;
}
+4
View File
@@ -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::<scroll_speed::ScrollSpeed>(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::<motion::AnimationEffects>(v["animation_effects"].clone()) {
self.animation_effects = effects;
}
+5 -4
View File
@@ -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());
+55 -8
View File
@@ -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<Event>, 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<Event>, 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();
+2 -1
View File
@@ -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
@@ -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
+3
View File
@@ -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.
+19
View File
@@ -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();
+16
View File
@@ -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))
+2 -2
View File
@@ -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"