mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 02:21:45 +00:00
Scroll: smooth wheel bursts and preserve precise movement
This commit is contained in:
1 parent
ecdd1e658c
commit
292f9c75ff
9 files changed
+1067
-127
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user