diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 737cad34..4a84bec9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -306,6 +306,12 @@ pub struct DocView { shown: bool, /// Turns wheel input into page turns in single-page view. wheel: crate::wheel_pager::WheelPager, + /// Until this time, apply precise (touchpad) wheel input 1:1 and suppress egui's eased copy. + /// A precision touchpad sends pixel-precise `MouseWheel` events; on Windows they arrive with + /// no `TouchPhase::Start`, so egui eases deltas of 8 px or more and the page lags the finger + /// (#759). The window outlasts a single frame so egui's internal easing tail is suppressed as + /// it decays, instead of adding on top of the 1:1 motion. + precise_scroll_until: f64, pub(crate) auto_scroll: crate::autoscroll::AutoScroll, /// Pages selected in the organize grid or the Pages panel (0-based). Empty means "the current page". pub selected: BTreeSet, @@ -503,6 +509,7 @@ impl DocView { zoom_anchor: None, shown: false, wheel: Default::default(), + precise_scroll_until: 0.0, auto_scroll: Default::default(), selected: BTreeSet::new(), select_anchor: None, @@ -1772,10 +1779,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // instead; zoomed in far enough to pan, it pans. Touch drags are untouched: with nothing // to pan, touch users turn pages with the rail buttons, the page box and the arrow keys. // Runs before `visible_pages` so the frame that turns the page draws it. + let mut single_paging = false; if view.layout == PageLayout::Single { // Within a point, so layout rounding can't stop a fitting page from turning. let fits = rects.get(view.current.min(rects.len().saturating_sub(1))).is_some_and(|r| r.height() + 2.0 * MARGIN <= avail.height() + 1.0); let can_turn = fits && !middle_gesture && unobstructed && ui.rect_contains_pointer(avail); + single_paging = can_turn; // Every wheel event goes to the pager, so it follows each trackpad touch to its end // even while the page can't turn. ui.input(|i| { @@ -1793,6 +1802,46 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } } + // Touchpad scrolling 1:1 (#759). A precision touchpad sends pixel-precise `MouseWheel` + // events; on Windows they carry no `TouchPhase::Start`, so egui never takes its no-smoothing + // path and eases deltas of 8 px or more, leaving the page a frame behind the finger. Apply + // the precise delta ourselves this frame through the un-animated scroll path, and suppress + // egui's eased copy while it decays so it neither doubles nor trails the motion. A coarse + // mouse wheel keeps egui's easing (pleasant for chunky notches). + let now = ui.input(|i| i.time); + let mut precise_scroll = Vec2::ZERO; + if ui.rect_contains_pointer(avail) { + let mut coarse_wheel = false; + ui.input(|i| { + for e in &i.events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = e { + if modifiers.command || modifiers.ctrl { + continue; // Zooming, not scrolling. + } + match unit { + // Shift scrolls sideways, matching egui's own wheel handling. + egui::MouseWheelUnit::Point if modifiers.shift => precise_scroll.x += delta.x + delta.y, + egui::MouseWheelUnit::Point => precise_scroll += *delta, + egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => coarse_wheel = true, + } + } + } + }); + if coarse_wheel { + view.precise_scroll_until = 0.0; + } else if precise_scroll != Vec2::ZERO { + view.precise_scroll_until = now + 0.25; + } + } + // Paging owns the vertical axis in single-page view; leave it to the pager. + if single_paging { + precise_scroll.y = 0.0; + } + if now < view.precise_scroll_until { + ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); + } else { + precise_scroll = Vec2::ZERO; + } let visible_pages: Vec = match view.layout { PageLayout::Single => vec![view.current.min(rects.len() - 1)], _ => (0..rects.len()).collect(), @@ -1922,8 +1971,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Only interactions pause; the document must keep its original colours. ui.set_opacity(opacity); } - // Middle-button auto-scroll and the keyboard (positive y moves the content down). - let delta = auto_delta - vec2(0.0, key_scroll); + // Middle-button auto-scroll, the keyboard and precise touchpad input (#759); positive y + // moves the content down. All three scroll without easing, unlike egui's wheel default. + let delta = auto_delta - vec2(0.0, key_scroll) + precise_scroll; if delta != Vec2::ZERO { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 222df48a..bc216c23 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -214,6 +214,30 @@ fn wheel_turns_one_page_per_notch_and_per_trackpad_swipe() { assert_eq!(h.state().views[0].current, 1, "one swipe back turns one page back"); } +#[test] +fn precise_touchpad_wheel_scrolls_one_to_one() { + // A precision touchpad's pixel delta scrolls the page by that amount in the same frame, + // instead of egui easing it over several frames so the page lags the finger (#759). A single + // 200 px precise event is well over egui's 8 px smoothing threshold, so without the fix only + // a fraction would land on the first frame. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "100")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + let before = rect(&h, 0).expect("page 1 on screen").min.y; + h.event(wheel(MouseWheelUnit::Point, -200.0, TouchPhase::Move)); + // A few frames for the event to reach the ScrollArea and the new offset to be drawn. The + // page jumps the full 200 px at once; an eased path would ramp there over ~0.1 s (6 frames). + h.run_steps(4); + let moved = before - rect(&h, 0).expect("page 1 still laid out").min.y; + assert!(moved > 190.0, "a 200 px precise swipe scrolls ~200 px at once, not an eased fraction: moved {moved}"); + // And it stops with the finger: idle frames must not drift on a leftover easing tail. + let settled = rect(&h, 0).unwrap().min.y; + h.run_steps(20); + let drift = settled - rect(&h, 0).unwrap().min.y; + assert!(drift.abs() < 2.0, "no easing tail after the swipe: drifted {drift}"); +} + #[test] fn page_display_commands_switch_layouts() { let mut h = harness(&[]);