diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 49d2c44b03..9d371fce5d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -35,14 +35,13 @@ mod selection; pub(super) mod signature_drag; pub(super) mod wheel_boost; pub(super) mod wheel_pager; +pub(super) mod wheel_reset; mod xform; mod zoom; pub(super) mod zoom_snap; pub(crate) use area::signature_badges; -use area::{ - SUPPRESS_SECS, SUPPRESS_TAIL_SECS, drawing_edge_pointer, glide_interrupted, page_scroll, presentation_click, run_button, set_layers, zoom_anchor, -}; +use area::{drawing_edge_pointer, glide_interrupted, page_scroll, presentation_click, run_button, set_layers, zoom_anchor}; #[cfg(test)] use bars::in_size_hint_zone; use bars::{Notice, notices, page_size_hint, quick_bar}; @@ -582,12 +581,10 @@ pub struct DocView { pub(crate) auto_ocr_skipped: std::collections::HashMap, /// 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, + /// The document owns the full coarse-wheel distance, including its acceleration. + wheel_motion: crate::wheel_boost::Motion, + /// Raw input and retained motion advance once per frame, including discarded UI passes. + wheel_frame: Option, /// Per-axis line-wheel state; horizontal input cannot extend a vertical burst. wheel_boost_x: crate::wheel_boost::WheelBoost, wheel_boost_y: crate::wheel_boost::WheelBoost, @@ -961,7 +958,8 @@ impl DocView { auto_ocr_tried: Default::default(), auto_ocr_skipped: Default::default(), wheel: Default::default(), - precise_scroll_until: 0.0, + wheel_motion: Default::default(), + wheel_frame: None, wheel_boost_x: Default::default(), wheel_boost_y: Default::default(), fling: Default::default(), @@ -1107,6 +1105,7 @@ impl DocView { self.wheel.clear_motion(); self.wheel_boost_x.reset(); self.wheel_boost_y.reset(); + self.wheel_motion.reset(); } } @@ -1121,7 +1120,7 @@ struct PageEnv<'a> { author: &'a String, auto_delta: Vec2, avail: Rect, - boost_extra: Vec2, + coarse_scroll: Vec2, by_line: &'a String, calc_order: bool, can_copy: bool, @@ -2355,7 +2354,7 @@ fn draw_pages( allowed, auto_delta, avail, - boost_extra, + coarse_scroll, calc_order, can_copy, can_modify, @@ -2393,12 +2392,11 @@ fn draw_pages( // Only interactions pause; the document must keep its original colours. ui.set_opacity(opacity); } - // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide - // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y - // moves the content down. All scroll without easing, unlike egui's wheel default. + // These deltas already contain their motion policy. Positive y moves the content down; + // applying another ScrollArea animation would ease the wheel twice. // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution // until after the text widgets when a selection owns that drag. - let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; + let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + coarse_scroll }; let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); @@ -2546,11 +2544,10 @@ fn draw_pages( // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the // pointer leaves the selection's page for the gap or the next page; over the whole - // document area, like #759. Combine direct and smoothed motion in one request so mixed - // Point and Line events retain their full contribution. + // document area. The viewer owns both precise and coarse wheel motion, so selection uses + // the same distance as ordinary document scrolling. if selection_wheel { - let smooth = if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; - let delta = precise_scroll + boost_extra + smooth; + let delta = precise_scroll + coarse_scroll; if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } @@ -2832,89 +2829,91 @@ fn single_page_paging(ui: &egui::Ui, view: &mut DocView, cx: &PagingCx<'_>) -> ( (single_paging, edge_turned) } -/// This frame's wheel motion over the page view, after paging and point/line suppression. +/// This frame's wheel motion over the page view, after paging and motion integration. struct Wheel { precise_scroll: Vec2, line_base: Vec2, - boost_extra: Vec2, + coarse_scroll: Vec2, has_page: bool, /// The pager owns the vertical axis (single-page view, or the rest of a gesture that turned the page). paging_owns_vertical: bool, } -/// Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, smoothed -/// base and adds only the accelerated surplus. While point suppression is active, line and page -/// input are reconstructed so current coarse events survive without exposing an older eased tail. -fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: bool, edge_turned: bool) -> Wheel { +/// Cancels older motion before the pager reads new input, without discarding that input. +fn reset_interrupted_wheel(ui: &egui::Ui, view: &mut DocView, other_motion: bool) { + let frame = ui.ctx().cumulative_frame_nr(); + let resumed = view.wheel_frame.is_some_and(|last| last != frame && last.checked_add(1) != Some(frame)); + if resumed || glide_interrupted(ui) || other_motion { + view.reset_coarse_state(); + } +} + +/// Integrates the whole coarse-wheel distance once, independently of egui's wheel easing. +/// Precise input remains direct, and interrupts clear retained motion before accepting new input. +fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: bool, edge_turned: bool, enabled: bool, animate: bool) -> Wheel { let now = ui.input(|i| i.time); - let input = if ui.rect_contains_pointer(avail) { + let hovered = ui.rect_contains_pointer(avail); + let frame = ui.ctx().cumulative_frame_nr(); + let repeated = view.wheel_frame == Some(frame); + view.wheel_frame = Some(frame); + // ScrollArea must not add egui's independently eased copy to the viewer's own motion. + // Keep draining that copy on later frames and discarded passes too. + if hovered { + ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); + } + let paging_owns_vertical = single_paging || (!view.continuous && view.wheel.gesture_turned()); + if repeated || !hovered || !enabled { + if !hovered || !enabled { + view.reset_coarse_state(); + } + return Wheel { precise_scroll: Vec2::ZERO, line_base: Vec2::ZERO, coarse_scroll: Vec2::ZERO, has_page: false, paging_owns_vertical }; + } + let consumed = ui.input(|i| { + i.events.iter().any(|event| { + matches!(event, egui::Event::MouseWheel { delta, modifiers, .. } if *delta != Vec2::ZERO && delta.is_finite() && !modifiers.command && !modifiers.ctrl) + }) + }); + if consumed { + wheel_reset::consumed(ui.ctx()); + } + let input = { // Match the line distance used by egui's other scroll areas. let live = ui.ctx().options(|o| o.input_options.line_scroll_speed); let line_points = if live.is_finite() && live > 0.0 { live } else { crate::scroll_speed::EGUI_LINE_POINTS }; ui.input(|i| crate::wheel_boost::accumulate(&i.events, line_points, single_paging)) - } else { - crate::wheel_boost::FrameInput::default() }; let mut precise_scroll = input.precise; let mut line_base = input.line; - let mut boost_extra = crate::wheel_boost::boost_extra(input, &mut view.wheel_boost_x, &mut view.wheel_boost_y, now); - let has_page = input.page != Vec2::ZERO; - if now.is_finite() && precise_scroll != Vec2::ZERO && !has_page { - view.precise_scroll_until = now + SUPPRESS_SECS; + if precise_scroll.x != 0.0 { + view.wheel_boost_x.reset(); } + if precise_scroll.y != 0.0 { + view.wheel_boost_y.reset(); + } + let mut coarse_input = line_base + crate::wheel_boost::boost_extra(input, &mut view.wheel_boost_x, &mut view.wheel_boost_y, now); + let has_page = input.page != Vec2::ZERO; if edge_turned { - // The turn leaves egui's eased copy of the notch to decay for a few frames; it must not - // scroll the page just shown away from its top (or bottom). - view.precise_scroll_until = now + 0.25; view.fling.stop(); } + let page_scale = ui.input(|i| i.viewport_rect().height()); + if page_scale.is_finite() && page_scale > 0.0 { + coarse_input += input.page * page_scale; + } // Paging owns the vertical axis in single-page view; leave it to the pager. // The rest of a gesture that turned the page is swallowed too, or it would pan the new page // away from the edge it was shown at. - let paging_owns_vertical = single_paging || (!view.continuous && view.wheel.gesture_turned()); if paging_owns_vertical { precise_scroll.y = 0.0; line_base.y = 0.0; - boost_extra.y = 0.0; + coarse_input.y = 0.0; + view.wheel_motion.cancel_axes(vec2(0.0, 1.0)); } - // egui smooths a Point event that moved at least 8 points, keeping part of it for later - // passes. Suppression must outlive that whole action: once it expired after a fixed 0.25 s, - // the last few eased points leaked through the ScrollArea after a precise 1:1 scroll (held - // point input measured 33 points for a 30-point event). Keep hiding the eased copy until - // egui says the action is over, then forget the suppression. - let scrolling = ui.input(|i| i.is_scrolling()); - if now < view.precise_scroll_until || (view.precise_scroll_until != 0.0 && scrolling) { - if scrolling { - // Keep hiding egui's unprocessed copy until it has drained. The short rolling grace - // avoids a delayed duplicate without prolonging suppression after the tail is gone. - view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS); - } - ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO); - // The point path suppresses every eased wheel delta. Restore the line base through the - // same un-eased path as its surplus; paging-owned axes were cleared above. - boost_extra = crate::wheel_boost::cap_frame(boost_extra + line_base); - if has_page { - // Page units normally stay on egui's viewport-sized path. During suppression only, - // reproduce the current event directly so an older hidden tail stays hidden. - let page_scale = ui.input(|i| i.viewport_rect().height()); - let mut page_delta = - if page_scale.is_finite() && page_scale > 0.0 { crate::wheel_boost::cap_frame(input.page * page_scale) } else { Vec2::ZERO }; - if paging_owns_vertical { - page_delta.y = 0.0; - } - boost_extra = crate::wheel_boost::cap_frame(boost_extra + page_delta); - } - } else { - precise_scroll = Vec2::ZERO; - // Selection handling below uses zero to distinguish an inactive window. Clearing a past - // deadline also keeps a later line/page action from being mistaken for the old tail. - view.precise_scroll_until = 0.0; + view.wheel_motion.cancel_axes(precise_scroll); + let coarse_scroll = view.wheel_motion.step(coarse_input, now, ui.input(|i| i.stable_dt), animate); + if view.wheel_motion.has_pending() { + ui.ctx().request_repaint(); } - if has_page && paging_owns_vertical { - // Page input belongs to egui on the free axis; the pager still owns vertical motion. - ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); - } - Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } + Wheel { precise_scroll, line_base, coarse_scroll, has_page, paging_owns_vertical } } /// What decides which renders a frame asks for. @@ -3596,21 +3595,20 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // drawn, and the content height (#752). Empty for a document with no pages. let row = shown_row(view, &rects); let middle_gesture = view.auto_scroll.blocks_input(); + reset_interrupted_wheel(ui, view, zoom_delta != 1.0 || key_scroll != 0.0 || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO); // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on // (#1924, `single_page_wheel_edge`). 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 (single_paging, edge_turned) = single_page_paging(ui, view, &PagingCx { row: &row, rects: &rects, avail, middle_gesture, unobstructed }); - // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, - // smoothed base and adds only the accelerated surplus. While point suppression is active, - // reconstruct line and page input here so current coarse events survive without exposing an - // older eased tail. let now = ui.input(|i| i.time); - let Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } = read_wheel(ui, view, avail, single_paging, edge_turned); let animates = app.animation_effects.animates(app.motion.reduced); + let wheel_enabled = navigation_enabled && unobstructed && !middle_gesture; + let Wheel { precise_scroll, line_base, coarse_scroll, has_page, paging_owns_vertical } = + read_wheel(ui, view, avail, single_paging, edge_turned, wheel_enabled, animates); let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; - let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; + let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || coarse_scroll != Vec2::ZERO || has_page; let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; let glide = step_glide(ui, view, avail, &motion); // The wheel may have turned the page above: draw the row it turned to. Measured from the old @@ -3695,7 +3693,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { author: &author, auto_delta, avail, - boost_extra, + coarse_scroll, by_line: &by_line, calc_order, can_copy, diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index 0d934dc8c9..d8891e7901 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -24,14 +24,6 @@ pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], pa .collect() } -/// Suppression window for egui's eased copy of point input. -/// -/// egui 0.36.2 drains `WheelState::after_events` tail deltas by 90% per 0.1 s. -pub(super) const SUPPRESS_SECS: f64 = 0.25; - -/// Short rolling extension while egui still has an unprocessed wheel tail. -pub(super) const SUPPRESS_TAIL_SECS: f64 = 0.05; - // Keep the viewer's scroll identity tied to the document instead of its parent UI. Without // `fades` (Show Page Edge Shadows off) the viewport-edge gradients are off for this area only. pub(super) fn page_scroll( diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4761584735..3623ceb604 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -234,6 +234,100 @@ fn view(pages: usize, layout: PageLayout, continuous: bool) -> DocView { DocView::new(DocId(1), &info, ViewDefaults { layout, continuous, ..Default::default() }) } +/// Delivers a raw move event to the same viewport path used by native input. +fn viewport_wheel_event(unit: egui::MouseWheelUnit, delta: Vec2) -> egui::Event { + egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, source: egui::MouseWheelSource::Unknown } +} + +/// Runs actual UI passes while explicitly discarding the unused texture output. +fn viewport_wheel_frame(ctx: &egui::Context, view: &mut DocView, time: f64, events: Vec, passes: usize, animate: bool) -> Vec { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 800.0)); + let mut events = events; + events.insert(0, egui::Event::PointerMoved(screen.center())); + let input = egui::RawInput { screen_rect: Some(screen), time: Some(time), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + let mut deltas = Vec::new(); + ctx.run_ui(input, |ui| { + let avail = ui.max_rect(); + assert!(ui.rect_contains_pointer(avail), "the synthetic pointer owns the viewport"); + let wheel = read_wheel(ui, view, avail, false, false, true, animate); + deltas.push(wheel.precise_scroll + wheel.coarse_scroll); + assert_eq!(ui.input(|i| i.smooth_scroll_delta), Vec2::ZERO, "egui's wheel copy is drained on every owned pass"); + if deltas.len() < passes { + ui.ctx().request_discard("exercise viewport wheel ownership across passes"); + } + }) + .drop_without_applying_deltas(); + assert_eq!(deltas.len(), passes); + deltas +} + +/// Fixes line distance and discard allowance independently of host preferences. +fn viewport_wheel_context() -> egui::Context { + let ctx = egui::Context::default(); + ctx.options_mut(|options| { + options.max_passes = std::num::NonZeroUsize::new(3).unwrap(); + options.input_options.line_scroll_speed = 100.0; + }); + ctx +} + +/// A frame's admitted distance is unchanged by additional layout passes. +#[test] +fn viewport_wheel_discard_passes_neither_repeat_nor_advance_motion() { + for animate in [false, true] { + let (one, three) = (viewport_wheel_context(), viewport_wheel_context()); + let (mut single_view, mut multi_view) = (view(2, PageLayout::Single, true), view(2, PageLayout::Single, true)); + let mut total = Vec2::ZERO; + for frame in 0..90 { + let events = if frame == 0 { + vec![ + viewport_wheel_event(egui::MouseWheelUnit::Line, vec2(0.0, -1.0)), + viewport_wheel_event(egui::MouseWheelUnit::Point, vec2(0.0, -7.0)), + ] + } else { + Vec::new() + }; + let time = 1.0 + f64::from(frame) / 60.0; + let expected = viewport_wheel_frame(&one, &mut single_view, time, events.clone(), 1, animate); + let actual = viewport_wheel_frame(&three, &mut multi_view, time, events, 3, animate); + assert!((actual[0] - expected[0]).length() < 0.001, "frame {frame}, animations {animate}: {actual:?} versus {expected:?}"); + assert_eq!(&actual[1..], &[Vec2::ZERO, Vec2::ZERO], "discarded passes do not spend the retained queue"); + if frame == 0 { + if animate { + assert!(actual[0].y < -7.0 && actual[0].y > -107.0, "fine input is direct and the whole coarse amount is eased"); + } else { + assert_eq!(actual[0], vec2(0.0, -107.0)); + } + } + total += actual[0]; + } + assert!((total.y + 107.0).abs() < 0.002, "one admitted mixed bucket preserves distance: {total:?}"); + assert!(!multi_view.wheel_motion.has_pending()); + } +} + +/// Mixed current input survives an older same-axis queue being interrupted. +#[test] +fn viewport_precise_input_cancels_only_older_motion_on_its_axis() { + let (actual_ctx, reference_ctx) = (viewport_wheel_context(), viewport_wheel_context()); + let (mut actual, mut reference) = (view(2, PageLayout::Single, true), view(2, PageLayout::Single, true)); + viewport_wheel_frame(&actual_ctx, &mut actual, 1.0, vec![viewport_wheel_event(egui::MouseWheelUnit::Line, vec2(1.0, -1.0))], 1, true); + viewport_wheel_frame(&reference_ctx, &mut reference, 1.0, vec![viewport_wheel_event(egui::MouseWheelUnit::Line, vec2(1.0, 0.0))], 1, true); + let mixed = + vec![viewport_wheel_event(egui::MouseWheelUnit::Line, vec2(0.0, -1.0)), viewport_wheel_event(egui::MouseWheelUnit::Point, vec2(0.0, 2.0))]; + let mut total = Vec2::ZERO; + for frame in 1..90 { + let events = if frame == 1 { mixed.clone() } else { Vec::new() }; + let time = 1.0 + f64::from(frame) / 60.0; + let output = viewport_wheel_frame(&actual_ctx, &mut actual, time, events.clone(), 3, true)[0]; + let expected = viewport_wheel_frame(&reference_ctx, &mut reference, time, events, 1, true)[0]; + assert!((output - expected).length() < 0.001, "older y motion is canceled, x motion and new coarse input survive"); + total += output; + } + assert!(total.x > 0.0, "the untouched x tail continues"); + assert!((total.y + 98.0).abs() < 0.002, "only the new y bucket is admitted: {total:?}"); +} + fn history_search_view() -> DocView { let mut v = view(5, PageLayout::Single, true); v.texts.insert( diff --git a/crates/ui-egui/src/canvas/wheel_boost.rs b/crates/ui-egui/src/canvas/wheel_boost.rs index 10ccbbbaa7..981940bcb0 100644 --- a/crates/ui-egui/src/canvas/wheel_boost.rs +++ b/crates/ui-egui/src/canvas/wheel_boost.rs @@ -1,9 +1,10 @@ //! Bounded acceleration for coarse wheel input in the document viewport. //! -//! Line-unit events describe notches, not motion over time. A fixed distance per -//! notch makes a fast spin feel slow, so this module derives a bounded multiplier -//! from arrival time and direction. The state is constant-size and leaves isolated -//! notches unchanged. +//! Coarse line events use a bounded arrival-rate multiplier and one motion curve +//! for their whole distance. Isolated notches retain their distance. Fractional line +//! components stay direct on their own axes; explicitly continuous sources stay direct +//! on both axes. Fractional values alone do not identify the physical device. +//! Retained motion uses constant-size state. use egui::Vec2; @@ -20,9 +21,12 @@ const MAX_TICKS: u32 = 13; /// Extra ticks retained beyond the ceiling. They absorb a few slow events /// before the visible multiplier starts to fall. const HYSTERESIS_TICKS: u32 = 4; -/// Maximum scroll applied from one frame of coarse input, in points per axis. -/// Each event is clamped first; this caps the sum. +/// Per-axis bound for a normalized bucket, accelerated surplus or emitted motion step. pub(crate) const MAX_FRAME_POINTS: f32 = 8192.0; +/// Per-axis motion input limit after combining base, surplus and Page distance. +const MAX_MOTION_INPUT_POINTS: f32 = 2.0 * MAX_FRAME_POINTS; +/// Retained motion permits a transient burst beyond one output frame. +const MAX_PENDING_POINTS: f32 = 2.0 * MAX_FRAME_POINTS; /// Per-event clamp, in native units (lines or pages). Keeps one hostile event /// bounded before points conversion. pub(crate) const MAX_EVENT_UNITS: f32 = 10.0; @@ -74,15 +78,95 @@ impl WheelBoost { } } -/// Caps one frame of accumulated coarse scroll, per axis. A non-finite axis +/// Caps a normalized bucket or output step, per axis. A non-finite axis /// maps to zero; the other axis is preserved. pub(crate) fn cap_frame(delta: Vec2) -> Vec2 { + cap_points(delta, MAX_FRAME_POINTS) +} + +/// Caps finite point distances independently; invalid axes contribute no distance. +fn cap_points(delta: Vec2, limit: f32) -> Vec2 { Vec2::new( - if delta.x.is_finite() { delta.x.clamp(-MAX_FRAME_POINTS, MAX_FRAME_POINTS) } else { 0.0 }, - if delta.y.is_finite() { delta.y.clamp(-MAX_FRAME_POINTS, MAX_FRAME_POINTS) } else { 0.0 }, + if delta.x.is_finite() { delta.x.clamp(-limit, limit) } else { 0.0 }, + if delta.y.is_finite() { delta.y.clamp(-limit, limit) } else { 0.0 }, ) } +/// Eases the whole coarse-wheel distance, including its unaccelerated part. +/// +/// The caller owns input once per egui frame, cancels old motion on direct-input +/// axes before stepping, and requests another frame while motion is pending. +/// Each axis admits up to 16384 input points and retains up to 16384 pending +/// points, emitting at most 8192 per frame. Input beyond those finite capacities +/// is dropped; isolated base-plus-surplus bursts are preserved when there is room. +#[derive(Debug, Default)] +pub(crate) struct Motion { + pending: Vec2, + last: Option, +} + +impl Motion { + /// Forgets owed distance and its clock when input ownership or navigation changes. + pub(crate) fn reset(&mut self) { + *self = Self::default(); + } + + /// Drops older coarse motion on axes carrying direct input. This must happen + /// before adding this frame's coarse input, so mixed input is retained. + pub(crate) fn cancel_axes(&mut self, direct: Vec2) { + if direct.x != 0.0 { + self.pending.x = 0.0; + } + if direct.y != 0.0 { + self.pending.y = 0.0; + } + } + + /// Whether another frame is needed to deliver admitted coarse movement. + pub(crate) fn has_pending(&self) -> bool { + self.pending != Vec2::ZERO + } + + /// Consumes a bounded frame bucket. Reversal cancels the older direction on + /// that axis. Disabling animation drains existing and new input immediately, + /// except for overflow beyond the frame cap. A stale clock cancels the old + /// tail; an invalid clock also limits fresh input to one output frame without + /// retaining it. Pending plus fresh input cannot exceed queue and output capacity. + pub(crate) fn step(&mut self, input: Vec2, now: f64, dt: f32, animate: bool) -> Vec2 { + let input = cap_points(input, MAX_MOTION_INPUT_POINTS); + let stale = !now.is_finite() || self.last.is_some_and(|last| now < last || now - last > RESET_GAP_SECS); + if stale { + self.pending = Vec2::ZERO; + } + self.last = now.is_finite().then_some(now); + if !now.is_finite() { + self.pending = Vec2::ZERO; + return cap_frame(input); + } + // Use a valid elapsed frame interval rather than letting invalid or very + // large intervals erase the distinction between animated and direct input. + let dt = if dt.is_finite() && dt > 0.0 { dt.min(0.1) } else { 1.0 / 60.0 }; + let fraction = if animate { 1.0 - 0.1_f32.powf(dt / 0.1) } else { 1.0 }; + Vec2::new(Self::step_axis(&mut self.pending.x, input.x, fraction), Self::step_axis(&mut self.pending.y, input.y, fraction)) + } + + /// Delivers one axis while bounding both the returned step and remaining distance. + fn step_axis(pending: &mut f32, input: f32, fraction: f32) -> f32 { + if input != 0.0 && input.signum() != pending.signum() { + *pending = 0.0; + } + let capacity = MAX_PENDING_POINTS + MAX_FRAME_POINTS; + let total = (*pending + input).clamp(-capacity, capacity); + // Emit enough overflow to keep both output and pending bounded, without + // discarding distance after the finite queue's admission boundary. + let magnitude = total.abs(); + let emitted = if magnitude <= 0.01 { magnitude } else { (magnitude * fraction).max(magnitude - MAX_PENDING_POINTS).min(MAX_FRAME_POINTS) }; + let output = total.signum() * emitted; + *pending = total - output; + output + } +} + /// One frame of wheel input, normalized in a single pass. Line motion is in /// points; page motion remains in native units until the viewport is known. #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -98,38 +182,39 @@ pub(crate) fn accumulate(events: &[egui::Event], line_points: f32, single_paging let line_points = sanitize_scale(line_points); let mut out = FrameInput::default(); for event in events { - let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event else { continue }; + let egui::Event::MouseWheel { unit, delta, modifiers, source, .. } = event else { continue }; if modifiers.command || modifiers.ctrl { continue; // Zooming, not scrolling. } if !delta.is_finite() { continue; } + // Fractional native line components use a distance-preserving routing policy for + // high-resolution input, not evidence of a particular physical device. + // Inspect before clamping or axis folding, which can hide fractions. + let continuous = matches!(source, egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum); + let direct_x = continuous || delta.x.fract() != 0.0 || (modifiers.shift && delta.y.fract() != 0.0); + let direct_y = continuous || delta.y.fract() != 0.0; + let delta = if modifiers.shift { Vec2::new(delta.x + delta.y, 0.0) } else { *delta }; + if !delta.is_finite() { + continue; + } match unit { - egui::MouseWheelUnit::Point if modifiers.shift => { - // Shift scrolls sideways, matching egui's own wheel handling. - let sum = delta.x + delta.y; - if sum.is_finite() { - out.precise.x += sum; - } - } - egui::MouseWheelUnit::Point => out.precise += *delta, - egui::MouseWheelUnit::Line if modifiers.shift => { - let sum = delta.x + delta.y; - if sum.is_finite() { - out.line.x += sum.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points; - } - } + egui::MouseWheelUnit::Point => out.precise += delta, egui::MouseWheelUnit::Line => { - out.line.x += delta.x.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points; - if !single_paging { - out.line.y += delta.y.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS) * line_points; + let mut points = delta.clamp(Vec2::splat(-MAX_EVENT_UNITS), Vec2::splat(MAX_EVENT_UNITS)) * line_points; + if single_paging { + points.y = 0.0; } - } - egui::MouseWheelUnit::Page if modifiers.shift => { - let sum = delta.x + delta.y; - if sum.is_finite() { - out.page.x += sum.clamp(-MAX_EVENT_UNITS, MAX_EVENT_UNITS); + if direct_x { + out.precise.x += points.x; + } else { + out.line.x += points.x; + } + if direct_y { + out.precise.y += points.y; + } else { + out.line.y += points.y; } } egui::MouseWheelUnit::Page => { @@ -145,10 +230,10 @@ pub(crate) fn accumulate(events: &[egui::Event], line_points: f32, single_paging out } -/// Converts the frame total to the extra scroll delta beyond egui's share. +/// Converts the frame total to the accelerated surplus. /// -/// egui applies the base distance through its own smoothing. This function -/// returns only the accelerated surplus, so an isolated notch adds nothing. +/// The caller adds this to the base distance before passing the whole coarse +/// bucket to `Motion`, so an isolated notch adds no surplus. /// Each axis advances once per frame; events batched into one frame cannot /// accelerate one another. Output is frame-capped. pub(crate) fn boost_extra(input: FrameInput, boost_x: &mut WheelBoost, boost_y: &mut WheelBoost, now: f64) -> Vec2 { @@ -171,6 +256,283 @@ fn sanitize_scale(points: f32) -> f32 { mod tests { use super::*; + /// Easing preserves distance and terminates at each tested refresh rate. + #[test] + fn whole_distance_eases_and_settles_at_each_frame_rate() { + for hz in [30, 60, 144] { + let dt = 1.0 / hz as f32; + let mut motion = Motion::default(); + let first = motion.step(egui::vec2(0.0, -600.0), 1.0, dt, true); + assert!(first.y < 0.0 && first.y > -600.0); + let mut total = first.y; + for frame in 1..=hz * 2 { + let delta = motion.step(Vec2::ZERO, 1.0 + f64::from(frame) / f64::from(hz), dt, true); + assert!(delta.y <= 0.0); + total += delta.y; + } + assert!((total + 600.0).abs() < 0.002, "{hz} Hz: {total}"); + assert!(!motion.has_pending()); + } + } + + /// Timing follows elapsed seconds rather than a fixed number of frames. + #[test] + fn equal_elapsed_time_consumes_the_same_share_at_different_cadences() { + let expected = 600.0 * (1.0 - 0.1_f32.powf(5.0 / 3.0)); + for hz in [30, 60, 144] { + let mut motion = Motion::default(); + let mut total = 0.0; + for frame in 0..hz / 6 { + let input = if frame == 0 { egui::vec2(600.0, 0.0) } else { Vec2::ZERO }; + total += motion.step(input, 1.0 + f64::from(frame) / f64::from(hz), 1.0 / hz as f32, true).x; + } + assert!((total - expected).abs() < 0.01, "{hz} Hz: {total} versus {expected}"); + } + } + + /// Reversing one axis preserves the other axis's admitted distance. + #[test] + fn motion_reversal_cancels_only_the_reversed_axis() { + let mut motion = Motion::default(); + motion.step(egui::vec2(120.0, -600.0), 1.0, 0.01, true); + let retained_x = motion.pending.x; + let delta = motion.step(egui::vec2(0.0, 100.0), 1.01, 0.01, true); + assert!(delta.x > 0.0 && delta.y > 0.0); + assert!((delta.x + motion.pending.x - retained_x).abs() < 0.001); + assert!((delta.y + motion.pending.y - 100.0).abs() < 0.001); + } + + /// An isolated base-plus-surplus burst can settle without losing its requested distance. + #[test] + fn transient_burst_above_one_output_frame_preserves_distance() { + for distance in [9600.0, 2.0 * MAX_FRAME_POINTS] { + let mut motion = Motion::default(); + let mut total = 0.0; + for frame in 0..120 { + let input = if frame == 0 { egui::vec2(0.0, -distance) } else { Vec2::ZERO }; + let output = motion.step(input, 1.0 + f64::from(frame) / 60.0, 1.0 / 60.0, true); + assert!(output.is_finite() && output.y.abs() <= MAX_FRAME_POINTS); + total += output.y; + } + assert!((total + distance).abs() < 0.02, "requested {distance}, delivered {total}"); + assert!(!motion.has_pending()); + } + } + + /// Direct input interrupts older motion, while a current mixed event still contributes. + #[test] + fn precise_axes_cancel_old_tail_without_dropping_current_coarse_input() { + let mut motion = Motion::default(); + motion.step(egui::vec2(200.0, -500.0), 1.0, 0.01, true); + let retained_x = motion.pending.x; + motion.cancel_axes(egui::vec2(0.0, 0.25)); + let delta = motion.step(egui::vec2(0.0, -80.0), 1.01, 0.01, true); + assert!((delta.x + motion.pending.x - retained_x).abs() < 0.001); + assert!((delta.y + motion.pending.y + 80.0).abs() < 0.001); + } + + /// Saturation limits per-frame work without silently losing admitted movement. + #[test] + fn saturated_motion_bounds_output_and_queue_without_losing_distance() { + let mut motion = Motion::default(); + let mut admitted = 0.0; + let mut emitted = 0.0; + for frame in 0..80 { + let input = if frame < 20 { MAX_FRAME_POINTS } else { 0.0 }; + admitted += input; + let delta = motion.step(egui::vec2(input, 0.0), 1.0 + f64::from(frame) * 0.01, 0.01, true); + assert!((0.0..=MAX_FRAME_POINTS).contains(&delta.x)); + assert!((0.0..=MAX_PENDING_POINTS).contains(&motion.pending.x)); + emitted += delta.x; + assert!((admitted - emitted - motion.pending.x).abs() < 0.1); + } + } + + /// Sustained arrival above service capacity drops excess only at the finite admission boundary. + #[test] + fn sustained_double_frame_input_keeps_a_finite_queue_and_output() { + let mut motion = Motion::default(); + let mut requested = 0.0; + let mut admitted = 0.0; + let mut emitted = 0.0; + for frame in 0..160 { + let input = if frame < 20 { MAX_MOTION_INPUT_POINTS } else { 0.0 }; + let previous = motion.pending.x; + let total = (previous + input).min(MAX_PENDING_POINTS + MAX_FRAME_POINTS); + requested += input; + admitted += total - previous; + let output = motion.step(egui::vec2(input, -input), 1.0 + f64::from(frame) * 0.01, 0.01, true); + emitted += output.x; + assert!((0.0..=MAX_FRAME_POINTS).contains(&output.x)); + assert!((0.0..=MAX_PENDING_POINTS).contains(&motion.pending.x)); + assert_eq!(output.y, -output.x); + assert_eq!(motion.pending.y, -motion.pending.x); + assert!((output.x + motion.pending.x - total).abs() < 0.003); + assert!((admitted - emitted - motion.pending.x).abs() < 0.15); + } + assert!(requested > admitted, "a sustained overload must not create an unlimited queue"); + assert!((emitted - admitted).abs() < 0.15); + assert!(!motion.has_pending()); + } + + /// Input admission permits two output frames, while each emitted step stays independently bounded. + #[test] + fn oversized_motion_input_is_limited_before_entering_the_queue() { + for animate in [false, true] { + let mut motion = Motion::default(); + let mut delivered = 0.0; + for frame in 0..120 { + let input = if frame == 0 { egui::vec2(4.0 * MAX_FRAME_POINTS, 0.0) } else { Vec2::ZERO }; + let output = motion.step(input, 1.0 + f64::from(frame) / 60.0, 1.0 / 60.0, animate); + assert!(output.x <= MAX_FRAME_POINTS); + assert!(motion.pending.x <= MAX_PENDING_POINTS); + delivered += output.x; + } + assert!((delivered - MAX_MOTION_INPUT_POINTS).abs() < 0.02, "animations {animate}: {delivered}"); + assert!(!motion.has_pending()); + } + } + + /// A non-finite clock neither resumes old movement nor emits an oversized fresh step. + #[test] + fn invalid_clock_limits_a_fresh_burst_without_retaining_it() { + for now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut motion = Motion::default(); + motion.step(egui::vec2(100.0, 100.0), 1.0, 0.01, true); + let output = motion.step(egui::vec2(MAX_MOTION_INPUT_POINTS, -MAX_MOTION_INPUT_POINTS), now, 0.01, true); + assert_eq!(output, egui::vec2(MAX_FRAME_POINTS, -MAX_FRAME_POINTS)); + assert!(!motion.has_pending()); + assert_eq!(motion.step(Vec2::ZERO, 2.0, 0.01, true), Vec2::ZERO); + } + } + + /// Turning animation off drains owed movement under the same output cap. + #[test] + fn disabling_animation_preserves_owed_distance_with_a_bounded_flush() { + let mut motion = Motion::default(); + let first = motion.step(egui::vec2(MAX_FRAME_POINTS, 0.0), 1.0, 0.01, true); + let second = motion.step(egui::vec2(MAX_FRAME_POINTS, 0.0), 1.01, 0.01, false); + assert_eq!(second.x, MAX_FRAME_POINTS); + let last = motion.step(Vec2::ZERO, 1.02, 0.01, false); + assert!((first.x + second.x + last.x - 2.0 * MAX_FRAME_POINTS).abs() < 0.002); + assert!(!motion.has_pending()); + assert_eq!(motion.step(egui::vec2(0.0, -100.0), 1.03, 0.01, false), egui::vec2(0.0, -100.0)); + assert!(!motion.has_pending()); + } + + /// Invalid input or a discontinuous clock cannot retain an unbounded or stale tail. + #[test] + fn reset_clock_gaps_and_invalid_values_cannot_reanimate_old_motion() { + for next in [0.5, 2.0, f64::NAN, f64::INFINITY] { + let mut motion = Motion::default(); + motion.step(egui::vec2(100.0, -200.0), 1.0, 0.01, true); + assert_eq!(motion.step(Vec2::ZERO, next, 0.01, true), Vec2::ZERO); + assert!(!motion.has_pending()); + } + for dt in [f32::NAN, f32::INFINITY, -1.0, 0.0, 1e9] { + let mut motion = Motion::default(); + let delta = motion.step(egui::vec2(100.0, f32::NAN), 1.0, dt, true); + assert!(delta.x.is_finite() && delta.x > 0.0 && delta.x < 100.0); + assert_eq!(delta.y, 0.0); + motion.reset(); + assert!(!motion.has_pending()); + } + } + + /// Normalization cannot hide fractional motion by clamping or combining its axes. + #[test] + fn fractional_lines_are_direct_before_clamping_or_axis_folding() { + use egui::MouseWheelUnit::Line; + let input = accumulate(&[wheel(Line, 0.0, -10.5), shifted(Line, 0.5, 0.5)], 100.0, false); + assert_eq!(input.precise, egui::vec2(100.0, -1000.0)); + assert_eq!(input.line, Vec2::ZERO); + let events: [egui::Event; 4] = core::array::from_fn(|_| wheel(Line, 0.0, -0.25)); + let input = accumulate(&events, 100.0, false); + assert_eq!(input.precise.y, -100.0); + } + + /// Source hints and partial-notch distances each independently select direct movement. + #[test] + fn known_continuous_sources_are_direct_even_for_integral_lines() { + for source in [egui::MouseWheelSource::Trackpad, egui::MouseWheelSource::Momentum] { + let mut event = wheel(egui::MouseWheelUnit::Line, 0.0, -1.0); + if let egui::Event::MouseWheel { source: slot, .. } = &mut event { + *slot = source; + } + let input = accumulate(&[event], 100.0, false); + assert_eq!(input.precise, egui::vec2(0.0, -100.0)); + assert_eq!(input.line, Vec2::ZERO); + let diagonal = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.25, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source, + }; + let input = accumulate(&[diagonal], 100.0, false); + assert_eq!(input.precise, egui::vec2(25.0, -100.0)); + assert_eq!(input.line, Vec2::ZERO); + } + let mut event = wheel(egui::MouseWheelUnit::Line, 0.0, -0.25); + if let egui::Event::MouseWheel { source, .. } = &mut event { + *source = egui::MouseWheelSource::Wheel; + } + assert_eq!(accumulate(&[event], 100.0, false).precise, egui::vec2(0.0, -25.0)); + } + + /// A fractional horizontal component does not change a vertical notch's distance policy. + #[test] + fn diagonal_fraction_keeps_the_other_axis_coarse() { + use egui::MouseWheelUnit::Line; + for source in [egui::MouseWheelSource::Unknown, egui::MouseWheelSource::Wheel] { + let mut diagonal = wheel(Line, 0.25, -1.0); + if let egui::Event::MouseWheel { source: slot, .. } = &mut diagonal { + *slot = source; + } + let mut bx = WheelBoost::default(); + let mut by = WheelBoost::default(); + let mut reference_bx = WheelBoost::default(); + let mut reference_by = WheelBoost::default(); + let mut motion = Motion::default(); + let mut reference_motion = Motion::default(); + for frame in 0..90 { + let (input, reference) = if frame < 12 { + let input = accumulate(std::slice::from_ref(&diagonal), 100.0, false); + assert_eq!(input.precise, egui::vec2(25.0, 0.0), "{source:?}: only the fractional axis is direct"); + assert_eq!(input.line, egui::vec2(0.0, -100.0)); + (input, accumulate(&[wheel(Line, 0.0, -1.0)], 100.0, false)) + } else { + (FrameInput::default(), FrameInput::default()) + }; + let now = 1.0 + f64::from(frame) / 60.0; + let coarse = input.line + boost_extra(input, &mut bx, &mut by, now); + let reference_coarse = reference.line + boost_extra(reference, &mut reference_bx, &mut reference_by, now); + motion.cancel_axes(input.precise); + reference_motion.cancel_axes(reference.precise); + assert_eq!(coarse, reference_coarse, "{source:?}: the vertical burst remains unchanged"); + assert_eq!(motion.step(coarse, now, 1.0 / 60.0, true), reference_motion.step(reference_coarse, now, 1.0 / 60.0, true)); + } + if let egui::Event::MouseWheel { delta, .. } = &mut diagonal { + *delta = egui::vec2(1.0, -0.25); + } + let transposed = accumulate(&[diagonal], 100.0, false); + assert_eq!(transposed.precise, egui::vec2(0.0, -25.0)); + assert_eq!(transposed.line, egui::vec2(100.0, 0.0)); + } + } + + /// Coarse and direct events in one frame keep their respective distance policies. + #[test] + fn mixed_coarse_and_direct_events_retain_both_buckets() { + use egui::MouseWheelUnit::{Line, Point}; + let input = accumulate(&[wheel(Line, 0.0, -1.0), wheel(Line, 0.0, -0.25), wheel(Point, 2.0, -3.0)], 100.0, false); + assert_eq!(input.line, egui::vec2(0.0, -100.0)); + assert_eq!(input.precise, egui::vec2(2.0, -28.0)); + let input = accumulate(&[wheel(Line, 2.0, -0.25)], 100.0, true); + assert_eq!(input.precise, Vec2::ZERO); + assert_eq!(input.line, egui::vec2(200.0, 0.0)); + } + #[test] fn single_and_slow_notches_stay_unboosted() { let mut b = WheelBoost::default(); diff --git a/crates/ui-egui/src/canvas/wheel_reset.rs b/crates/ui-egui/src/canvas/wheel_reset.rs new file mode 100644 index 0000000000..5174dbfef3 --- /dev/null +++ b/crates/ui-egui/src/canvas/wheel_reset.rs @@ -0,0 +1,311 @@ +//! Clear egui's retained copy of wheel input already owned by the document canvas. +//! +//! The reset precedes fresh events and all panels. Its synthetic event is hidden from app +//! motion routing; genuine input stays in its original order. No input is deferred or replayed. + +use egui::{Context, Event, Id, Modifiers, MouseWheelSource, MouseWheelUnit, Plugin, RawInput, TouchPhase, Ui, Vec2, ViewportId}; + +/// Schedule a reset of this viewport's hidden egui wheel state before its next frame. +pub(crate) fn consumed(ctx: &Context) { + let _ = ctx.plugin_or_default::(); + let key = pending(ctx.viewport_id()); + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|data| data.insert_temp(key, frame)); +} + +/// Whether this viewport's old egui wheel state was reset at the start of the current pass. +pub(crate) fn reset_this_pass(ctx: &Context) -> bool { + let viewport = ctx.viewport_id(); + let pass = ctx.cumulative_pass_nr_for(viewport); + ctx.data(|data| data.get_temp::(applied(viewport)) == Some(pass)) +} + +/// Separates reset requests belonging to different native viewports. +fn pending(viewport: ViewportId) -> Id { + Id::unique(("pdfcraft-wheel-reset-pending", viewport)) +} + +/// Records whether this pass contains an internal reset that must be hidden from app routing. +fn injected(viewport: ViewportId) -> Id { + Id::unique(("pdfcraft-wheel-reset-injected", viewport)) +} + +/// Shares reset ownership with app wheel readers only during the pass that applied it. +fn applied(viewport: ViewportId) -> Id { + Id::unique(("pdfcraft-wheel-reset-applied", viewport)) +} + +/// A zero-distance cancellation clears egui's private wheel queue before fresh input arrives. +fn reset_event() -> Event { + Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: Vec2::ZERO, + phase: TouchPhase::Cancel, + modifiers: Modifiers::NONE, + source: MouseWheelSource::Unknown, + } +} + +/// Stateless plugin: per-viewport flags live in the context without retaining the context. +#[derive(Default)] +struct WheelReset; + +impl Plugin for WheelReset { + /// Identifies the input ownership hook in egui diagnostics. + fn debug_name(&self) -> &'static str { + "pdfcraft-wheel-reset" + } + + /// Cancel only the old wheel copy before egui processes this pass's fresh input. + fn input_hook(&mut self, ctx: &Context, raw: &mut RawInput) { + ctx.data_mut(|data| data.remove::(applied(raw.viewport_id))); + let key = pending(raw.viewport_id); + let Some(consumed_frame) = ctx.data(|data| data.get_temp::(key)) else { + return; + }; + // A flag can outlive a closed viewport. Create its replacement before querying the + // counter, and discard stale ownership if this viewport has not received input yet. + let initialized = ctx.viewport_for(raw.viewport_id, |viewport| viewport.input.raw.viewport_id == raw.viewport_id); + if !initialized { + ctx.data_mut(|data| data.remove::(key)); + return; + } + // run_pass snapshots begin-pass plugins before the frame, but refreshes input hooks + // on each discard pass. A lazily registered plugin must wait for the next frame. + if ctx.cumulative_frame_nr_for(raw.viewport_id) == consumed_frame { + return; + } + if raw.events.try_reserve(1).is_err() { + // Keep the pending flag so a later pass can retry without dropping fresh input. + return; + } + // Index zero is always valid; the fallible reservation above admits the extra event. + raw.events.insert(0, reset_event()); + ctx.data_mut(|data| { + data.remove::(key); + data.insert_temp(injected(raw.viewport_id), true); + }); + } + + /// Hide exactly our prepended reset before app motion or any panel inspects input events. + fn on_begin_pass(&mut self, ui: &mut Ui) { + let ctx = ui.ctx(); + let key = injected(ctx.viewport_id()); + let ours = ctx.data_mut(|data| data.remove_temp::(key).unwrap_or(false)); + if ours { + let removed = ctx.input_mut(|input| { + if input.events.first() == Some(&reset_event()) { + // The first-event check proves index zero exists and belongs to this reset. + input.events.remove(0); + true + } else { + false + } + }); + if removed { + let key = applied(ctx.viewport_id()); + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|data| data.insert_temp(key, pass)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Pos2, Rect, vec2}; + + #[derive(Clone)] + struct Observed { + scroll: Vec2, + zoom: f32, + events: Vec, + reset: bool, + } + + fn context() -> Context { + let ctx = Context::default(); + ctx.options_mut(|options| { + options.max_passes = 3.try_into().unwrap(); + options.input_options.line_scroll_speed = 100.0; + }); + ctx + } + + fn wheel(unit: MouseWheelUnit, amount: f32, modifiers: Modifiers) -> Event { + Event::MouseWheel { unit, delta: vec2(0.0, amount), phase: TouchPhase::Move, source: MouseWheelSource::Unknown, modifiers } + } + + /// Run the actual hooks and input pipeline without an app raw-input hook. + fn frame(ctx: &Context, viewport: ViewportId, time: f64, events: Vec, consume: bool, passes: usize) -> Vec { + let mut raw = RawInput { + viewport_id: viewport, + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 800.0))), + time: Some(time), + predicted_dt: 1.0 / 60.0, + events, + ..Default::default() + }; + raw.viewports.entry(viewport).or_default(); + let mut observed = Vec::new(); + let output = ctx.run_ui(raw, |ui| { + let reset = reset_this_pass(ui.ctx()); + observed.push(ui.input(|input| Observed { + scroll: input.smooth_scroll_delta, + zoom: input.zoom_delta(), + events: input.events.clone(), + reset, + })); + if consume { + consumed(ui.ctx()); + // The canvas drains the public value after taking ownership of the raw wheel. + ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); + } + if observed.len() < passes { + ui.ctx().request_discard("wheel reset multipass regression"); + } + }); + output.drop_without_applying_deltas(); + assert_eq!(observed.len(), passes); + observed + } + + /// Draining the public delta alone leaves a hidden tail; the plugin clears it before UI. + #[test] + fn consumed_wheel_does_not_leak_into_the_next_frame_elsewhere() { + let baseline = context(); + frame(&baseline, ViewportId::ROOT, 0.0, vec![wheel(MouseWheelUnit::Point, 200.0, Modifiers::NONE)], false, 1); + baseline.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); + let elsewhere = vec![Event::PointerMoved(Pos2::new(10.0, 10.0))]; + assert!(frame(&baseline, ViewportId::ROOT, 1.0 / 60.0, elsewhere.clone(), false, 1)[0].scroll.y > 0.0); + + let reset = context(); + frame(&reset, ViewportId::ROOT, 0.0, vec![wheel(MouseWheelUnit::Point, 200.0, Modifiers::NONE)], true, 1); + let next = frame(&reset, ViewportId::ROOT, 1.0 / 60.0, elsewhere.clone(), false, 1); + assert_eq!(next[0].scroll, Vec2::ZERO); + assert_eq!(next[0].events, elsewhere); + } + + /// Resetting old motion preserves the complete distance of fresh Point and Line events. + #[test] + fn fresh_wheel_distance_is_preserved_after_the_reset() { + for (unit, amount, expected) in [(MouseWheelUnit::Point, 60.0, 60.0), (MouseWheelUnit::Line, 1.0, 100.0)] { + let ctx = context(); + frame(&ctx, ViewportId::ROOT, 0.0, vec![wheel(MouseWheelUnit::Point, 200.0, Modifiers::NONE)], true, 1); + let fresh = wheel(unit, amount, Modifiers::NONE); + let first = frame(&ctx, ViewportId::ROOT, 1.0 / 60.0, vec![fresh.clone()], false, 1); + assert_eq!(first[0].events, vec![fresh]); + let mut distance = first[0].scroll.y; + for index in 2..120 { + distance += frame(&ctx, ViewportId::ROOT, f64::from(index) / 60.0, Vec::new(), false, 1)[0].scroll.y; + } + assert!((distance - expected).abs() < 0.005, "{unit:?}: {distance}, expected {expected}"); + } + } + + /// A fresh Ctrl-wheel remains a zoom input after old non-zoom wheel state is cleared. + #[test] + fn fresh_ctrl_wheel_zoom_is_preserved() { + let ctx = context(); + frame(&ctx, ViewportId::ROOT, 0.0, vec![wheel(MouseWheelUnit::Point, 200.0, Modifiers::NONE)], true, 1); + let zoom = wheel(MouseWheelUnit::Point, 4.0, Modifiers::CTRL); + let next = frame(&ctx, ViewportId::ROOT, 1.0 / 60.0, vec![zoom.clone()], false, 1); + assert!(next[0].zoom > 1.0); + assert_eq!(next[0].scroll, Vec2::ZERO); + assert_eq!(next[0].events, vec![zoom]); + } + + /// A genuine zero-delta Cancel is retained, both with and without our preceding marker. + #[test] + fn genuine_cancel_events_are_not_removed() { + for schedule in [false, true] { + let ctx = context(); + let _ = ctx.plugin_or_default::(); + if schedule { + frame(&ctx, ViewportId::ROOT, 0.0, vec![wheel(MouseWheelUnit::Point, 200.0, Modifiers::NONE)], true, 1); + } + let events = vec![reset_event(), wheel(MouseWheelUnit::Point, 3.0, Modifiers::NONE)]; + let observed = frame(&ctx, ViewportId::ROOT, 1.0 / 60.0, events.clone(), false, 1); + assert_eq!(observed[0].events, events); + assert_eq!(observed[0].scroll.y, 3.0); + assert_eq!(observed[0].reset, schedule); + } + } + + /// Discard passes see no replay of wheel events or synthetic cancellation markers. + #[test] + fn discard_passes_do_not_replay_events_or_old_wheel_motion() { + let ctx = context(); + frame(&ctx, ViewportId::ROOT, 0.0, vec![wheel(MouseWheelUnit::Point, 200.0, Modifiers::NONE)], true, 1); + let fresh = wheel(MouseWheelUnit::Point, 4.0, Modifiers::NONE); + let passes = frame(&ctx, ViewportId::ROOT, 1.0 / 60.0, vec![fresh.clone()], false, 3); + assert_eq!(passes[0].events, vec![fresh]); + assert_eq!(passes[0].scroll.y, 4.0); + for pass in passes.iter().skip(1) { + assert!(pass.events.is_empty()); + assert_eq!(pass.scroll, Vec2::ZERO); + } + } + + /// Reset flags belong to a viewport, even when another viewport processes input first. + #[test] + fn another_viewport_does_not_consume_the_pending_reset() { + let ctx = context(); + let other = ViewportId::from_hash_of("wheel-reset-other"); + frame(&ctx, ViewportId::ROOT, 0.0, vec![wheel(MouseWheelUnit::Point, 200.0, Modifiers::NONE)], true, 1); + let other_events = vec![wheel(MouseWheelUnit::Point, 4.0, Modifiers::NONE)]; + let observed = frame(&ctx, other, 1.0 / 60.0, other_events.clone(), false, 1); + assert_eq!(observed[0].events, other_events); + assert_eq!(observed[0].scroll.y, 4.0); + assert!(ctx.data(|data| data.get_temp::(pending(ViewportId::ROOT)).is_some())); + let root = frame(&ctx, ViewportId::ROOT, 2.0 / 60.0, Vec::new(), false, 1); + assert_eq!(root[0].scroll, Vec2::ZERO); + assert!(root[0].events.is_empty()); + } + + /// First-use registration during a discarded pass must not expose the reset to later passes. + #[test] + fn first_consumed_frame_can_discard_without_exposing_the_reset() { + let ctx = context(); + let fresh = wheel(MouseWheelUnit::Point, 200.0, Modifiers::NONE); + let passes = frame(&ctx, ViewportId::ROOT, 0.0, vec![fresh.clone()], true, 3); + assert_eq!(passes[0].events, vec![fresh]); + for pass in passes.iter().skip(1) { + assert!(pass.events.is_empty(), "the newly registered input hook must not expose its reset before the begin-pass hook is active"); + } + let next = frame(&ctx, ViewportId::ROOT, 1.0 / 60.0, Vec::new(), false, 1); + assert_eq!(next[0].scroll, Vec2::ZERO); + assert!(next[0].events.is_empty()); + } + + /// App readers observe reset ownership only in the viewport and pass that applied it. + #[test] + fn reset_notification_is_scoped_to_the_pass_and_viewport() { + let ctx = context(); + let first = frame(&ctx, ViewportId::ROOT, 0.0, vec![wheel(MouseWheelUnit::Point, 200.0, Modifiers::NONE)], true, 1); + assert!(!first[0].reset); + let other = ViewportId::from_hash_of("wheel-reset-notification-other"); + assert!(!frame(&ctx, other, 1.0 / 60.0, Vec::new(), false, 1)[0].reset); + let passes = frame(&ctx, ViewportId::ROOT, 2.0 / 60.0, Vec::new(), false, 3); + assert!(passes[0].reset); + assert!(passes.iter().skip(1).all(|pass| !pass.reset)); + assert!(!frame(&ctx, ViewportId::ROOT, 3.0 / 60.0, Vec::new(), false, 1)[0].reset); + } + + /// An old flag for a removed viewport must neither panic nor cancel a replacement's input. + #[test] + fn stale_reset_for_a_recreated_viewport_does_not_cancel_fresh_motion() { + let ctx = context(); + let other = ViewportId::from_hash_of("wheel-reset-recreated"); + let _ = ctx.plugin_or_default::(); + // Model the temporary flag surviving egui's removal of the old ViewportState. + ctx.data_mut(|data| data.insert_temp(pending(other), 0u64)); + let first = frame(&ctx, other, 0.0, vec![wheel(MouseWheelUnit::Point, 200.0, Modifiers::NONE)], false, 1); + assert!(!first[0].reset); + assert!(ctx.data(|data| data.get_temp::(pending(other)).is_none())); + let next = frame(&ctx, other, 1.0 / 60.0, Vec::new(), false, 1); + assert!(next[0].scroll.y > 0.0, "fresh unconsumed wheel motion still belongs to the replacement viewport"); + assert!(!next[0].reset); + } +} diff --git a/crates/ui-egui/src/platform/motion.rs b/crates/ui-egui/src/platform/motion.rs index 92ca469da3..b1d615df14 100644 --- a/crates/ui-egui/src/platform/motion.rs +++ b/crates/ui-egui/src/platform/motion.rs @@ -125,6 +125,11 @@ impl Motion { self.frame = None; return; } + if crate::canvas::wheel_reset::reset_this_pass(ctx) { + // The document's hidden cancel resets egui before fresh input. Its app reader + // must forget the same older gesture modifiers before reading those events. + self.wheel = WheelReader::default(); + } let options = ctx.options(|o| o.input_options); // One frame may run several passes (`request_discard`). Only the first pass has this // frame's events, and only it may hand the wheel on: a scroll area applies the delta to diff --git a/crates/ui-egui/src/platform/motion/tests.rs b/crates/ui-egui/src/platform/motion/tests.rs index e2d44f2438..2db565df6a 100644 --- a/crates/ui-egui/src/platform/motion/tests.rs +++ b/crates/ui-egui/src/platform/motion/tests.rs @@ -166,6 +166,53 @@ fn reduced_motion_hands_egui_the_whole_step_and_stops_its_animations() { assert_eq!((style.animation_time, style.scroll_animation), (default.animation_time, default.scroll_animation)); } +#[test] +fn consumed_document_gesture_does_not_keep_shift_for_another_scroll_area() { + let ctx = egui::Context::default(); + let mut motion = Motion::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 300.0)); + let pointer = Event::PointerMoved(screen.center()); + let raw = egui::RawInput { + screen_rect: Some(screen), + time: Some(0.1), + events: vec![ + pointer.clone(), + wheel(MouseWheelUnit::Point, Vec2::ZERO, TouchPhase::Start, Modifiers::SHIFT), + wheel(MouseWheelUnit::Point, vec2(0.0, -30.0), TouchPhase::Move, Modifiers::SHIFT), + ], + ..Default::default() + }; + ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), AnimationEffects::Off); + assert_eq!(ui.input(|input| input.smooth_scroll_delta), vec2(-30.0, 0.0)); + // The document owns the raw gesture and schedules its hidden-wheel reset. + crate::canvas::wheel_reset::consumed(ui.ctx()); + ui.input_mut(|input| input.smooth_scroll_delta = Vec2::ZERO); + }) + .drop_without_applying_deltas(); + + let fresh = wheel(MouseWheelUnit::Point, vec2(0.0, -18.0), TouchPhase::Move, Modifiers::NONE); + let events = vec![pointer, fresh]; + let raw = egui::RawInput { screen_rect: Some(screen), time: Some(0.1 + 1.0 / 60.0), events: events.clone(), ..Default::default() }; + let mut offset = Vec2::ZERO; + ctx.run_ui(raw, |ui| { + // The real plugin already cleared egui's old gesture and hid its own Cancel. + let eguis = ui.input(|input| input.smooth_scroll_delta); + assert_eq!(eguis.x, 0.0); + assert!(eguis.y < 0.0); + assert_eq!(ui.input(|input| input.events.clone()), events); + motion.begin_pass(ui.ctx(), AnimationEffects::Off); + assert_eq!(ui.input(|input| input.smooth_scroll_delta), vec2(0.0, -18.0), "fresh motion follows the released modifier"); + let out = + egui::ScrollArea::both().id_salt("motion-after-document").scroll_offset(vec2(200.0, 200.0)).auto_shrink([false, false]).show(ui, |ui| { + ui.set_min_size(vec2(1000.0, 3000.0)); + }); + offset = out.state.clamped_offset(); + }) + .drop_without_applying_deltas(); + assert_eq!(offset, vec2(200.0, 218.0), "the other area receives the complete fresh vertical step"); +} + #[test] fn the_preference_overrides_the_system_for_every_animation() { use AnimationEffects::{Off, On, System}; diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 0e9443bf51..59503bba30 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -760,6 +760,99 @@ fn changing_continuous_layout_resets_wheel_acceleration() { assert!((moved - base).abs() < 1.0, "a layout jump clears the old burst: moved {moved}, base {base}"); } +/// A steady wheel burst must not alternate a large accelerated jump with nearly still frames. +#[test] +fn scroll_regression_regular_notches_have_regular_frame_motion() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "3000"), ("animation_effects", "on")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + let mut settled = Vec::new(); + for frame in 0..66 { + if frame % 3 == 0 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + } + let moved = scroll_over(&mut h, 1); + if frame >= 48 { + settled.push(moved); + } + } + let low = settled.iter().copied().fold(f32::INFINITY, f32::min); + let high = settled.iter().copied().fold(0.0, f32::max); + assert!(low > 0.0 && high < low * 3.0, "regular input has bounded frame variation: {settled:?}"); +} + +/// Windows and X11 can report continuous device movement as fractions of a line. +#[test] +fn scroll_regression_fractional_lines_preserve_distance_without_an_idle_tail() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "3000"), ("animation_effects", "on")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + let line = pdfcraft_ui_egui::scroll_speed::ScrollSpeed::default().line_points(); + let mut distance = 0.0; + for _ in 0..24 { + h.event(wheel(MouseWheelUnit::Line, -0.25, TouchPhase::Move)); + distance += scroll_over(&mut h, 1); + } + // Page rectangles report the previous pass's scroll request on the following draw. + distance += scroll_over(&mut h, 1); + assert!((distance - line * 6.0).abs() < 1.0, "continuous motion retains its input distance: {distance}"); + let tail = scroll_over(&mut h, 40); + assert!(tail.abs() < 1.0, "continuous motion stops with the input: {tail}"); +} + +/// Input ownership changes discard an old queue even when the view is hidden very briefly. +#[test] +fn scroll_regression_returning_to_a_tab_does_not_resume_old_motion() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "3000"), ("animation_effects", "on")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + for _ in 0..5 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + scroll_over(&mut h, 1); + } + h.state_mut().open_bytes("second.pdf", None, PAGES.to_vec()).unwrap(); + h.run_steps(2); + h.state_mut().active = Some(0); + h.run_steps(2); + assert!(scroll_over(&mut h, 12).abs() < 0.5, "returning to a tab must not continue its old wheel burst"); +} + +/// Direct input ends an old coarse burst, so a later isolated notch has its base distance. +#[test] +fn scroll_regression_precise_input_resets_coarse_acceleration() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "3000"), ("animation_effects", "off")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + for _ in 0..5 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + scroll_over(&mut h, 1); + } + h.event(wheel(MouseWheelUnit::Point, -10.0, TouchPhase::Move)); + scroll_over(&mut h, 2); + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + let distance = scroll_over(&mut h, 2); + let base = pdfcraft_ui_egui::scroll_speed::ScrollSpeed::default().line_points(); + assert!((distance - base).abs() < 0.5, "a new coarse burst begins at the base distance: {distance}"); +} + +/// A key cancels earlier movement without consuming a new wheel event in the same frame. +#[test] +fn scroll_regression_key_interrupt_retains_current_wheel_input() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "3000"), ("animation_effects", "off")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(2); + h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + let distance = scroll_over(&mut h, 2); + let base = pdfcraft_ui_egui::scroll_speed::ScrollSpeed::default().line_points(); + assert!((distance - base).abs() < 0.5, "new wheel input survives a simultaneous key: {distance}"); +} + /// Steps `frames` frames and returns how far the document scrolled down over them, from the /// pages on screen in each pair of consecutive frames (a page can leave the screen on the way). /// Used by wheel and non-macOS glide tests below. @@ -2050,8 +2143,9 @@ fn final_spread_at_scroll_end(heights: &[u32], cover: bool) -> (Vec<(usize, egui let at = h.state().views[0].viewport_rect().center(); h.hover_at(at); h.run_steps(2); - for _ in 0..4 { - h.event(wheel(MouseWheelUnit::Line, -1000.0, TouchPhase::Move)); + // Reach the end through admitted wheel steps; the assertion concerns spread geometry. + for _ in 0..8 { + h.event(wheel(MouseWheelUnit::Line, -10.0, TouchPhase::Move)); h.run_steps(45); } let view = &h.state().views[0]; diff --git a/crates/ui-egui/tests/perf.rs b/crates/ui-egui/tests/perf.rs index 892f7b766a..6200686553 100644 --- a/crates/ui-egui/tests/perf.rs +++ b/crates/ui-egui/tests/perf.rs @@ -168,6 +168,43 @@ fn scrolling_a_500_page_document_stays_within_the_frame_budget() { assert!(avg < BUDGET, "average frame {avg:?}"); } +/// Measures real wheel events and their idle frames, separately from page-jump scheduling. +#[test] +fn regular_wheel_input_on_500_pages_stays_within_the_frame_budget() { + if overloaded() { + return; + } + let _turn = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let bytes = big(500); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("wheel-budget.pdf", None, bytes).expect("opens"); + for (key, value) in [("left", "closed"), ("panel", "none"), ("layout", "continuous"), ("zoom", "200"), ("animation_effects", "on")] { + app.set_option(key, value).unwrap(); + } + app + }); + h.run_steps(8); + h.hover_at(h.state().views[0].viewport_rect().center()); + h.run_steps(2); + let start = h.state().views[0].current; + let average = best_average("wheel input", 200, |frame| { + if frame % 3 == 0 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + } + h.step(); + }); + assert!(h.state().views[0].current > start, "wheel events must actually move through the document"); + assert!(average < BUDGET, "wheel input: average frame {average:?}"); +} + #[test] fn panels_with_hundreds_of_items_stay_within_the_frame_budget() { if overloaded() {