From 8ddb977733eb56e3230898b29ea3ec7b960b3448 Mon Sep 17 00:00:00 2001 From: kinower Date: Thu, 22 Oct 2026 01:01:48 +0200 Subject: [PATCH] UI: accumulate fractional mouse-wheel lines into whole slide steps High-resolution and free-spin wheels on Windows send fractions of a line (e.g. 0.125 per event); rounding each one up turned a single notch into several slides. The fractional line delta now accumulates in ui.data temp state, resets when the direction changes, and each whole line is one slide. slide.go is skipped when the target is the current slide. Test: eight 0.125-line events turn exactly one slide, and a reversal drops the leftover fraction. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 29 ++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f2efc09d..e29046ff 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -97,7 +97,11 @@ pub(crate) fn page_flick(wheel: egui::Vec2) -> Option { /// Discrete wheel input must not share the trackpad's gesture cooldown. Keep all the steps, /// including several notches delivered in one frame, and clamp at each end of the deck. -fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize) -> Option { +/// +/// High-resolution and free-spin wheels send fractions of a line (0.125 per event on Windows), so +/// the delta is accumulated in `carry` and each whole line is one slide; a change of direction +/// drops what was left over. Returns the target, `None` when no wheel event was read. +fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize, carry: &mut f32) -> Option { let last = slides.checked_sub(1)?; let mut target = current.min(last); let mut handled = false; @@ -111,7 +115,14 @@ fn wheel_slide_target(events: &[egui::Event], current: usize, slides: usize) -> && delta.y.abs() > 0.0 && delta.y.abs() >= delta.x.abs() { - let steps = delta.y.abs().ceil().min(slides as f32) as usize; + if *carry * delta.y < 0.0 { + *carry = 0.0; + } + *carry += delta.y; + // The small slack keeps sums such as 10 × 0.1 from falling just short of a line. + let whole = (carry.abs() + 1e-4).floor(); + *carry -= whole.copysign(*carry); + let steps = whole.min(slides as f32) as usize; target = if delta.y < 0.0 { target.saturating_add(steps).min(last) } else { target.saturating_sub(steps) }; handled = true; } @@ -166,9 +177,17 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { if overflow != egui::Vec2::ZERO { offset -= wheel; } else if !app.anim_preview_active() && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { - let target = app.session.active().and_then(|d| ui.input(|i| wheel_slide_target(&i.events, d.selection.slide, d.doc.slides.len()))); - if let Some(index) = target { - let _ = app.run("slide.go", json!({"index": index})); + let carry_id = ui.id().with("wheel_carry"); + let mut carry: f32 = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default()); + let wheel_step = app.session.active().and_then(|d| { + let current = d.selection.slide; + ui.input(|i| wheel_slide_target(&i.events, current, d.doc.slides.len(), &mut carry)).map(|target| (current, target)) + }); + ui.data_mut(|d| d.insert_temp(carry_id, carry)); + if let Some((current, index)) = wheel_step { + if index != current { + let _ = app.run("slide.go", json!({"index": index})); + } } else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) { // Point-based trackpad gestures keep their cooldown. Raw input avoids reading // the smoothed tail of a discrete mouse notch as another page turn. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0ccb7a65..0c6e532c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3357,6 +3357,32 @@ mod tests { assert_eq!(slide_at(h.state()), 8, "stop at the last slide"); } + /// A high-resolution wheel on Windows sends one notch as eight 0.125-line events. + #[test] + fn mouse_wheel_fractional_lines_accumulate_into_one_slide_per_line() { + let mut h = flick_harness(); + for _ in 0..4 { + h.state_mut().run("slide.new", json!({})).unwrap(); + } + h.state_mut().run("slide.go", json!({"index": 0})).unwrap(); + h.run_steps(1); + + for _ in 0..7 { + mouse_wheel(&mut h, &[-0.125]); + assert_eq!(slide_at(h.state()), 0, "a fraction of a line does not turn the page"); + } + mouse_wheel(&mut h, &[-0.125]); + assert_eq!(slide_at(h.state()), 1, "eight eighths make exactly one slide"); + h.run_steps(30); + assert_eq!(slide_at(h.state()), 1, "nothing is left over to turn another page"); + + mouse_wheel(&mut h, &[-0.5]); + mouse_wheel(&mut h, &[0.5, 0.25]); + assert_eq!(slide_at(h.state()), 1, "a change of direction drops the leftover fraction"); + mouse_wheel(&mut h, &[0.25]); + assert_eq!(slide_at(h.state()), 0, "a whole line back is one slide back"); + } + #[test] fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { let mut h = flick_harness();