collective

Browse / github.com/storytold/pdfcraft / patch

fix(ui): prevent mouse wheel scrolling from triggering kinetic fling jumps (#4917)

[Bug Report] [PdfCraft] Strange Animation Jumping When Scrolling Pages V0.14.0 ### Description When scrolling, the PDF can jump rather harshly and be quite disorienting. The behavior seems to be some breakdown of the scrolling animation. Scrolling in the preferences panel or elsewhere is not an issue, only when scrolling through the PDF pages. When I turn the scrolling speed down it doesn't resolve the issue. It appears to happen when I scroll quickly with about 4-5+ lines scrolled in a quick movement. If I turn the animation setting to System default, or on, the jumping occurs, and turning it off disables the animation and it even looks kind of like what I'm seeing whe

Status
open
Score
0.00
Adopters
0 other operators
Author
YusufMntm (imported from pull request #5056, open)
Base
e9fc890a862ea2d8c895ea32ddecda98580483cd
Size
+213 −8 in 4 files
Review
pending
Shared
2026-10-28
Id
5f84b5c316bf666a6f9f216a

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/fling.rs crates/ui-egui/tests/it/canvas.rs crates/ui-egui/tests/it/main.rs

diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -805,6 +805,8 @@ impl DocView {         self.wheel.clear_motion();         self.wheel_boost_x.reset();         self.wheel_boost_y.reset();+        self.fling.stop();+        self.fling.reset_gesture();     } } @@ -1104,14 +1106,17 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {     // Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a     // stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or     // dialog has the input, or animations are off; any click, key, zoom or other scrolling ends it.+    // Discrete mouse wheel scrolling stops any active fling and never glides (#4917).     // Asked of the preference, not of egui's animation time, which test harnesses zero.+    let has_discrete_wheel = line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || input.line != Vec2::ZERO || has_page;     let animates = app.animation_effects.animates(app.motion.reduced);-    let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates;+    let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates && !has_discrete_wheel;     let over_page = ui.rect_contains_pointer(avail);     ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page));     let interrupted = glide_interrupted(ui);-    if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 {+    if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 || has_discrete_wheel {         view.fling.stop();+        view.fling.reset_gesture();     }     let glide = view.fling.step(now);     if view.fling.gliding() {diff --git a/crates/ui-egui/src/canvas/fling.rs b/crates/ui-egui/src/canvas/fling.rs--- a/crates/ui-egui/src/canvas/fling.rs+++ b/crates/ui-egui/src/canvas/fling.rs@@ -29,6 +29,8 @@ const STALE: f64 = 0.1; pub(crate) struct Fling {     /// Whether a lift glides on this platform (see the module docs).     pub(crate) enabled: bool,+    /// Whether an active touchpad gesture is currently in progress (#4917).+    touch_active: bool,     /// The gesture in progress: when each pass's motion arrived, and how far it went.     samples: Vec<(f64, Vec2)>,     /// The glide's speed in points per second (positive moves the content down), or zero.@@ -46,19 +48,28 @@ impl Default for Fling { }  impl Fling {+    /// Creates a new fling momentum tracker, enabled where supported by the platform.     pub(crate) fn new(enabled: bool) -> Self {-        Self { enabled, samples: Vec::new(), velocity: Vec2::ZERO, at: 0.0, stalled: 0 }+        Self { enabled, touch_active: false, samples: Vec::new(), velocity: Vec2::ZERO, at: 0.0, stalled: 0 }     } +    /// Whether the view is currently gliding on fling momentum.     pub(crate) fn gliding(&self) -> bool {         self.velocity != Vec2::ZERO     } +    /// Stops any ongoing glide and resets stalled counter.     pub(crate) fn stop(&mut self) {         self.velocity = Vec2::ZERO;         self.stalled = 0;     } +    /// Resets active touchpad gesture tracking and clears accumulated samples (#4917).+    pub(crate) fn reset_gesture(&mut self) {+        self.touch_active = false;+        self.samples.clear();+    }+     /// Reads one pass's wheel events, at time `now`. Any wheel input stops a glide; a touchpad     /// gesture's `End` may start one. `glide` is false where the view must not glide this pass     /// (animations off, single-page paging, a zoom, a dialog).@@ -68,27 +79,33 @@ impl Fling {             self.stop();             // Zooming, a mouse wheel and sideways Shift scrolling never glide.             if *unit != MouseWheelUnit::Point || modifiers.command || modifiers.ctrl || modifiers.shift {-                self.samples.clear();+                self.reset_gesture();                 continue;             }             match phase {                 TouchPhase::Start => {+                    self.touch_active = true;                     self.samples.clear();                     self.push(now, *delta);                 }-                TouchPhase::Move => self.push(now, *delta),+                TouchPhase::Move => {+                    if self.touch_active {+                        self.push(now, *delta);+                    }+                }                 TouchPhase::End => {-                    if glide && self.enabled {+                    if self.touch_active && glide && self.enabled {                         self.velocity = self.lift_velocity(now);                         self.at = now;                     }-                    self.samples.clear();+                    self.reset_gesture();                 }-                TouchPhase::Cancel => self.samples.clear(),+                TouchPhase::Cancel => self.reset_gesture(),             }         }     } +    /// Records one sample of gesture delta at time `now`.     fn push(&mut self, now: f64, delta: Vec2) {         if !(delta.x.is_finite() && delta.y.is_finite()) {             return;@@ -159,6 +176,7 @@ mod tests {     use super::*;     use egui::{Modifiers, vec2}; +    /// Helper creating a wheel point event with the given phase.     fn wheel(dy: f32, phase: TouchPhase) -> Event {         Event::MouseWheel {             unit: MouseWheelUnit::Point,@@ -293,4 +311,48 @@ mod tests {         swipe(&mut fling, f32::NAN, 6);         assert!(!fling.gliding(), "a speed that isn't a number");     }++    /// Discrete wheel events (Line deltas) must never trigger a kinetic glide (#4917).+    #[test]+    fn discrete_wheel_events_never_trigger_glide() {+        let mut fling = Fling::new(true);+        let dt = 1.0 / 60.0;+        let mut now = 0.0;+        for _ in 0..10 {+            now += dt;+            let notch = Event::MouseWheel {+                unit: MouseWheelUnit::Line,+                delta: vec2(0.0, -1.0),+                phase: TouchPhase::Move,+                modifiers: Modifiers::NONE,+                source: egui::MouseWheelSource::Unknown,+            };+            fling.read(&[notch], now, true);+        }+        now += dt;+        let end_notch = Event::MouseWheel {+            unit: MouseWheelUnit::Line,+            delta: vec2(0.0, 0.0),+            phase: TouchPhase::End,+            modifiers: Modifiers::NONE,+            source: egui::MouseWheelSource::Unknown,+        };+        fling.read(&[end_notch], now, true);+        assert!(!fling.gliding(), "discrete wheel steps never trigger glide");+    }++    /// Movement without a preceding TouchPhase::Start must never trigger a kinetic glide (#4917).+    #[test]+    fn move_without_touch_start_never_triggers_glide() {+        let mut fling = Fling::new(true);+        let dt = 1.0 / 60.0;+        let mut now = 0.0;+        for _ in 0..6 {+            now += dt;+            fling.read(&[wheel(-25.0, TouchPhase::Move)], now, true);+        }+        now += dt;+        fling.read(&[wheel(0.0, TouchPhase::End)], now, true);+        assert!(!fling.gliding(), "no TouchPhase::Start means not a touchpad gesture");+    } }diff --git a/crates/ui-egui/tests/it/canvas.rs b/crates/ui-egui/tests/it/canvas.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/canvas.rs@@ -0,0 +1,137 @@+//! Canvas scrolling tests, verifying that mouse wheel events do not trigger kinetic fling jumps (#4917).++use egui::{Modifiers, MouseWheelUnit, TouchPhase, vec2};+use egui_kittest::Harness;+use pdfcraft_ui_egui::PdfCraftApp;++/// Three 300x400 pt pages in a continuous scroll view.+const PAGES: &[u8] = b"%PDF-1.7+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj+2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj+3 0 obj << /Type /Page /Parent 2 0 R >> endobj+4 0 obj << /Type /Page /Parent 2 0 R >> endobj+5 0 obj << /Type /Page /Parent 2 0 R >> endobj+trailer << /Root 1 0 R >>+%%EOF";++/// Creates a test harness with stepped frame time and standard continuous layout.+fn harness(step_dt: f32) -> Harness<'static, PdfCraftApp> {+    let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(step_dt).build_eframe(move |_cc| {+        let mut app = PdfCraftApp::new();+        app.set_option("language", "en").unwrap();+        app.open_bytes("pages.pdf", None, PAGES.to_vec()).expect("opens");+        app.set_option("left", "closed").unwrap();+        app.set_option("panel", "none").unwrap();+        app.set_option("layout", "continuous").unwrap();+        app.set_option("zoom", "300").unwrap();+        app+    });+    h.run_steps(8);+    h+}++/// Helper getting the screen rect of a page.+fn rect(h: &Harness<'static, PdfCraftApp>, page: usize) -> Option<egui::Rect> {+    h.state().views[0].page_screen_rect(page)+}++/// Measures total vertical scroll movement over `frames` steps.+fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 {+    let tops = |h: &Harness<'static, PdfCraftApp>| (0..3).map(|p| rect(h, p).map(|r| r.min.y)).collect::<Vec<_>>();+    let mut moved = 0.0;+    for _ in 0..frames {+        let before = tops(h);+        h.step();+        let after = tops(h);+        let step = before.iter().zip(&after).find_map(|(b, a)| Some(b.as_ref()? - a.as_ref()?));+        moved += step.expect("a page stays on screen from one frame to the next");+    }+    moved+}++/// Rapid discrete mouse wheel steps must never trigger kinetic momentum (#4917).+#[test]+fn fast_mouse_wheel_scrolling_does_not_trigger_kinetic_fling_jumps() {+    let mut h = harness(1.0 / 60.0);+    let at = h.state().views[0].viewport_rect().center();+    h.hover_at(at);+    h.run_steps(2);++    // Send rapid discrete mouse wheel notches (line delta).+    for _ in 0..10 {+        h.event(egui::Event::MouseWheel {+            unit: MouseWheelUnit::Line,+            delta: vec2(0.0, -1.0),+            phase: TouchPhase::Move,+            modifiers: Modifiers::NONE,+            source: egui::MouseWheelSource::Unknown,+        });+        h.step();+    }++    // Step 20 frames to allow egui's internal ~0.25s notch easing to finish settling.+    h.run_steps(20);++    // After mouse wheel scrolling ceases, the view must not exhibit any fling glide or drift.+    let drift = scroll_over(&mut h, 20);+    assert!(drift.abs() < 0.5, "mouse wheel must not trigger kinetic fling drift: drifted {drift}");+}++/// A mouse wheel notch immediately stops an active touchpad fling glide (#4917).+#[cfg(not(target_os = "macos"))]+#[test]+fn mouse_wheel_notch_interrupts_active_touchpad_glide() {+    let mut h = harness(1.0 / 60.0);+    let at = h.state().views[0].viewport_rect().center();+    h.hover_at(at);+    h.run_steps(2);++    // Perform a touchpad swipe to enter a gliding state.+    h.event(egui::Event::MouseWheel {+        unit: MouseWheelUnit::Point,+        delta: vec2(0.0, -25.0),+        phase: TouchPhase::Start,+        modifiers: Modifiers::NONE,+        source: egui::MouseWheelSource::Unknown,+    });+    h.step();++    for _ in 0..6 {+        h.event(egui::Event::MouseWheel {+            unit: MouseWheelUnit::Point,+            delta: vec2(0.0, -25.0),+            phase: TouchPhase::Move,+            modifiers: Modifiers::NONE,+            source: egui::MouseWheelSource::Unknown,+        });+        h.step();+    }++    h.event(egui::Event::MouseWheel {+        unit: MouseWheelUnit::Point,+        delta: vec2(0.0, 0.0),+        phase: TouchPhase::End,+        modifiers: Modifiers::NONE,+        source: egui::MouseWheelSource::Unknown,+    });+    h.step();++    // Verify the page is gliding forward.+    let gliding_delta = scroll_over(&mut h, 3);+    assert!(gliding_delta > 10.0, "touchpad swipe must glide after lift: moved {gliding_delta}");++    // Deliver a discrete wheel notch.+    h.event(egui::Event::MouseWheel {+        unit: MouseWheelUnit::Line,+        delta: vec2(0.0, -1.0),+        phase: TouchPhase::Move,+        modifiers: Modifiers::NONE,+        source: egui::MouseWheelSource::Unknown,+    });+    h.step();+    h.run_steps(2);++    // After the wheel notch, the prior fling glide must be completely cancelled.+    let drift = scroll_over(&mut h, 20);+    assert!(drift.abs() < 0.5, "mouse wheel notch must stop kinetic glide: drifted {drift}");+}diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -27,6 +27,7 @@ mod bookmarks; mod bulk_fields; mod buttons; mod camera;+mod canvas; mod column_select; mod combine_grid; mod commands;