fix(ui-egui): stop held middle-button auto-scroll on release (#3795) (#6544)

This commit is contained in:
jonathanwxh-cell authored and GitHub committed 2026-11-09 08:56:30 +08:00
1 parent 3e437b7bd4
commit bb8fbd6b67
3 files changed
+131 -12

No files matched your search

+117 -3
View File
@@ -17,6 +17,9 @@ const MAX_DISPLACEMENT: f32 = 1_000_000.0;
pub(crate) struct AutoScroll {
anchor: Option<Pos2>,
organize: bool,
/// Consecutive or total frames spent past [`DEAD_ZONE`] while the initiating middle press
/// remained held (`>= 2` distinguishes a held scroll from a quick click).
held_frames_past_dead_zone: u8,
/// Own a cancelling click through its release, so it cannot also edit page content.
cancel_button: Option<PointerButton>,
block_input: bool,
@@ -36,6 +39,7 @@ impl AutoScroll {
pub(crate) fn cancel(&mut self) {
self.anchor = None;
self.held_frames_past_dead_zone = 0;
self.drag = None;
self.cancel_button = None;
self.block_input = false;
@@ -59,7 +63,7 @@ impl AutoScroll {
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 (pointer, middle_press, middle_down, middle_released, has_release_event, cancel, cancel_button, dt) = ctx.input(|i| {
let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2]
.into_iter()
.find(|button| i.pointer.button_pressed(*button));
@@ -72,6 +76,7 @@ impl AutoScroll {
}),
i.pointer.button_down(PointerButton::Middle),
i.pointer.button_released(PointerButton::Middle),
i.events.iter().any(|e| matches!(e, Event::PointerButton { button: PointerButton::Middle, pressed: false, .. })),
!i.focused
|| i.key_pressed(Key::Escape)
|| cancel_button.is_some()
@@ -80,7 +85,8 @@ impl AutoScroll {
i.stable_dt,
)
});
self.block_input = self.active() || self.cancel_button.is_some() || middle_press.is_some() || middle_down || middle_released;
self.block_input =
self.active() || self.cancel_button.is_some() || middle_press.is_some() || middle_down || middle_released || has_release_event;
if let Some(button) = self.cancel_button {
if !ctx.input(|i| i.pointer.button_down(button)) {
self.cancel_button = None;
@@ -106,13 +112,23 @@ impl AutoScroll {
{
self.anchor = Some(pressed_at);
self.organize = organize;
self.held_frames_past_dead_zone = 0;
}
}
let (Some(anchor), Some(pointer)) = (self.anchor, pointer) else { return Vec2::ZERO };
let displacement = pointer.y - anchor.y;
let past_dead_zone = displacement.is_finite() && displacement.abs() > DEAD_ZONE;
if middle_down && !has_release_event && past_dead_zone {
self.held_frames_past_dead_zone = self.held_frames_past_dead_zone.saturating_add(1);
}
if (middle_released || has_release_event) && self.held_frames_past_dead_zone >= 2 {
self.cancel();
self.block_input = true;
return Vec2::ZERO;
}
// Cap the elapsed time too: returning from an idle/hidden window must never jump pages.
let delta = scroll_delta(displacement, dt);
if displacement.is_finite() && displacement.abs() > DEAD_ZONE {
if past_dead_zone {
// Continuous redraws let stable_dt use measured frame time. Delayed redraws
// instead use predicted_dt, which can make speed depend on the actual frame rate.
ctx.request_repaint();
@@ -124,6 +140,7 @@ impl AutoScroll {
/// 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.held_frames_past_dead_zone = 0;
self.cancel_button = None;
let ctx = ui.ctx();
let (pointer, middle_press, middle_down, middle_released, focused) = ctx.input(|i| {
@@ -319,6 +336,103 @@ mod tests {
assert_eq!(latched_by(10.0, true), Vec2::ZERO, "the dead zone still holds still");
}
#[test]
fn holding_middle_button_to_auto_scroll_turns_off_on_release() {
let ctx = Context::default();
let mut scroll = AutoScroll::default();
let p = egui::pos2(100.0, 100.0);
let far = p + vec2(0.0, 60.0);
frame(&ctx, &mut scroll, vec![], true, (true, false), |_, _| {});
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, d| {
assert!(s.active() && s.blocks_input());
assert_eq!(d, Vec2::ZERO);
});
for _ in 0..2 {
frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| {
assert!(s.active() && s.blocks_input());
assert!(d.y < 0.0, "holding and moving past the dead zone scrolls: {d:?}");
});
}
// Primary case: releasing while still past the dead zone stops auto-scroll immediately.
frame(&ctx, &mut scroll, vec![middle(far, false)], true, (true, false), |s, d| {
assert!(!s.active(), "releasing at +60pt after holding must turn auto-scroll off");
assert!(s.blocks_input(), "the release frame itself still blocks page tools");
assert_eq!(d, Vec2::ZERO);
});
frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| {
assert!(!s.active() && !s.blocks_input());
assert_eq!(d, Vec2::ZERO);
});
// Returning inside the dead zone before releasing still counts as a held scroll gesture.
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, _| assert!(s.active()));
for _ in 0..2 {
frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| assert!(s.active() && d.y < 0.0));
}
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + vec2(0.0, 5.0)), middle(p + vec2(0.0, 5.0), false)], true, (true, false), |s, d| {
assert!(!s.active(), "releasing after scrolling while held must turn auto-scroll off");
assert!(s.blocks_input());
assert_eq!(d, Vec2::ZERO);
});
}
#[test]
fn middle_click_released_within_dead_zone_stays_latched() {
let ctx = Context::default();
let mut scroll = AutoScroll::default();
let p = egui::pos2(100.0, 100.0);
let far = p + vec2(0.0, 60.0);
frame(&ctx, &mut scroll, vec![], true, (true, false), |_, _| {});
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, _| assert!(s.active()));
frame(
&ctx,
&mut scroll,
vec![Event::PointerMoved(p + vec2(0.0, DEAD_ZONE)), middle(p + vec2(0.0, DEAD_ZONE), false)],
true,
(true, false),
|s, d| {
assert!(s.active(), "releasing within the dead zone keeps auto-scroll latched");
assert_eq!(d, Vec2::ZERO);
},
);
frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| {
assert!(s.active());
assert!(d.y < 0.0);
});
scroll.cancel();
// Pointer movement after middle-button release in the same frame does not count as held movement.
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, _| assert!(s.active()));
frame(&ctx, &mut scroll, vec![middle(p, false), Event::PointerMoved(far)], true, (true, false), |s, d| {
assert!(s.active(), "movement after release in the same frame keeps auto-scroll latched");
assert!(d.y < 0.0);
});
scroll.cancel();
// A press, move, and release arriving in a single frame is a click at the initial press position,
// and stays latched across subsequent frames when egui delivers its deferred button_released state
// or runs multiple layout passes within the activation frame.
frame(
&ctx,
&mut scroll,
vec![Event::PointerMoved(p), middle(p, true), Event::PointerMoved(far), middle(far, false)],
true,
(true, false),
|s, d| {
assert!(s.active(), "single-frame click and move latches auto-scroll");
assert!(d.y < 0.0);
},
);
frame(&ctx, &mut scroll, vec![], true, (true, false), |s, d| {
assert!(s.active(), "deferred button_released in the next frame must keep auto-scroll latched");
assert!(d.y < 0.0);
});
frame(&ctx, &mut scroll, vec![], true, (true, false), |s, d| {
assert!(s.active(), "subsequent frames after deferred release must keep auto-scroll latched");
assert!(d.y < 0.0);
});
}
#[test]
fn middle_drag_pans_one_to_one_and_owns_the_button_until_release() {
let ctx = Context::default();
+2 -6
View File
@@ -2790,16 +2790,12 @@ fn a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing() {
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());
// Linux auto-scrolls while the wheel is held and stops on release.
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"]);
+12 -3
View File
@@ -665,13 +665,21 @@ fn moving_before_middle_button_release_keeps_scrolling_without_starting_page_too
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);
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < top - 20.0);
assert!(h.state().views[0].auto_scrolling(), "release keeps scrolling toggled on even after movement");
assert!(!h.state().views[0].auto_scrolling(), "releasing after holding and scrolling turns auto-scroll off");
let released = h.state().views[0].page_screen_rect(0).unwrap().top();
h.run_steps(8);
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < released - 30.0, "scrolling continues with no button held");
assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().top(), released, "no drift after releasing a held middle drag");
assert!(h.state().views[0].crop_drag.is_none(), "the Crop tool must not receive a wheel drag");
assert!(h.state().dialog.is_none());
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
// Small movement within the dead zone before release still latches auto-scroll on.
ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y + 10.0], "steps": 4, "button": "middle" }));
assert!(h.state().views[0].auto_scrolling(), "release within the dead zone keeps scrolling toggled on");
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 60.0 }));
h.run_steps(8);
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < released - 30.0, "scrolling continues with no button held");
assert!(h.state().views[0].crop_drag.is_none(), "the Crop tool must not receive a wheel drag");
ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y + 60.0, "button": "middle" }));
assert!(!h.state().views[0].auto_scrolling(), "the next middle click toggles scrolling off");
}
@@ -886,7 +894,8 @@ fn organize_pages_supports_autoscroll_without_selecting_or_reordering_pages() {
assert!(h.state().views[0].selected.is_empty());
assert!(h.state().views[0].org_drag.is_none());
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
assert!(h.state().views[0].auto_scrolling(), "releasing the wheel leaves the grid scrolling on");
assert!(!h.state().views[0].auto_scrolling(), "releasing a held wheel stops the grid scrolling");
start_autoscroll(&mut h, &c);
h.state_mut().views[0].organize = false;
h.run_steps(2);
assert!(!h.state().views[0].auto_scrolling(), "changing canvas mode cancels the gesture");