Scroll: smooth wheel bursts and preserve precise movement

This commit is contained in:
dotCooCoo committed 2026-11-03 12:12:11 -08:00
1 parent ecdd1e658c
commit 292f9c75ff
9 files changed
+1067 -127

No files matched your search

+80 -82
View File
@@ -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<usize, u64>,
/// 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<u64>,
/// 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,
-8
View File
@@ -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<R>(
+94
View File
@@ -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<egui::Event>, passes: usize, animate: bool) -> Vec<Vec2> {
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(
+397 -35
View File
@@ -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<f64>,
}
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();
+311
View File
@@ -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::<WheelReset>();
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::<u64>(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::<u64>(applied(raw.viewport_id)));
let key = pending(raw.viewport_id);
let Some(consumed_frame) = ctx.data(|data| data.get_temp::<u64>(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::<u64>(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::<u64>(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::<bool>(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<Event>,
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<Event>, consume: bool, passes: usize) -> Vec<Observed> {
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::<WheelReset>();
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::<u64>(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::<WheelReset>();
// 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::<u64>(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);
}
}
+5
View File
@@ -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
@@ -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};
+96 -2
View File
@@ -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];
+37
View File
@@ -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() {