Viewer: touchpad scrolling tracks the finger 1:1 (#759) (#868)

A precision touchpad sends pixel-precise `MouseWheel` events. On Windows
they arrive with no `TouchPhase::Start`, so egui never takes its
no-smoothing "in touch" path and eases any delta of 8 px or more over
several frames, leaving the page a frame behind the finger.

The page view now applies precise (Point-unit) wheel input itself, 1:1,
through the same un-eased scroll path as keyboard and middle-button
auto-scroll, and suppresses egui's eased copy for a short window so its
internal easing tail neither doubles nor trails the motion. A coarse
mouse wheel (Line/Page units) keeps egui's smoothing, which is pleasant
for chunky notches, and single-page paging still owns the vertical axis.

Regression test: one 200 px precise swipe scrolls the full 200 px at
once (an eased path would ramp there over ~0.1 s) and leaves no drift.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Justin John 2026-10-11 01:09:15 +05:30 committed by GitHub
parent 7a1e0bce86
commit 3cd619e937
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 76 additions and 2 deletions

View File

@ -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<usize>,
@ -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<usize> = 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());
}

View File

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