View: the middle button pans instead of drawing with markup tools (#565)

egui's drag responses accept every pointer button, so on Windows and macOS a
middle-button drag reached the page tools: with Draw (ink) selected it drew a
stroke, and other tools cropped, selected or moved. Linux was unaffected
because its middle-click auto-scroll already blocks page input.

Outside Linux a middle-button drag that starts in the viewport (document or
Organize Pages) now pans 1:1 while the button is held, with a Grabbing
cursor, and page input is blocked from the press through the release. Linux
keeps its latched auto-scroll.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
codyvogt 2026-10-09 11:24:18 -07:00 committed by GitHub
parent 86ec14291c
commit a48e1a3ffe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 201 additions and 39 deletions

View File

@ -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<PointerButton>,
block_input: bool,
/// Where the pointer was last frame during a held middle-button pan (not Linux).
drag: Option<Pos2>,
}
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<Event>, 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]

View File

@ -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();

View File

@ -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<String> = 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());

View File

@ -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]

View File

@ -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"