From 7d54ae2be3022d4563928a8b685d766eab4589fe Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:18:23 +0000 Subject: [PATCH] refactor(ui-egui): move motion.rs's tests to motion/tests.rs --- crates/ui-egui/src/motion.rs | 388 +---------------------------- crates/ui-egui/src/motion/tests.rs | 385 ++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+), 387 deletions(-) create mode 100644 crates/ui-egui/src/motion/tests.rs diff --git a/crates/ui-egui/src/motion.rs b/crates/ui-egui/src/motion.rs index d95d205b0f..92ca469da3 100644 --- a/crates/ui-egui/src/motion.rs +++ b/crates/ui-egui/src/motion.rs @@ -282,390 +282,4 @@ impl WheelReader { } #[cfg(test)] -mod tests { - use super::*; - use std::cell::Cell; - use std::rc::Rc; - - fn wheel(unit: MouseWheelUnit, delta: Vec2, phase: TouchPhase, modifiers: Modifiers) -> Event { - Event::MouseWheel { unit, delta, phase, modifiers, source: egui::MouseWheelSource::Unknown } - } - - fn notch(dy: f32, modifiers: Modifiers) -> Event { - wheel(MouseWheelUnit::Line, vec2(0.0, dy), TouchPhase::Move, modifiers) - } - - fn read(events: &[Event]) -> WheelMotion { - WheelReader::default().read(events, 600.0, &egui::InputOptions::default()) - } - - #[test] - fn a_step_is_read_whole_in_its_units() { - let line = egui::InputOptions::default().line_scroll_speed; - assert_eq!(read(&[notch(-1.0, Modifiers::NONE)]), WheelMotion { scroll: vec2(0.0, -line), zoom: 1.0 }); - assert_eq!(read(&[notch(-1.0, Modifiers::NONE), notch(-2.0, Modifiers::NONE)]).scroll, vec2(0.0, -3.0 * line), "steps of one pass add up"); - let points = wheel(MouseWheelUnit::Point, vec2(3.0, -250.0), TouchPhase::Move, Modifiers::NONE); - assert_eq!(read(&[points]).scroll, vec2(3.0, -250.0)); - let page = wheel(MouseWheelUnit::Page, vec2(0.0, -1.0), TouchPhase::Move, Modifiers::NONE); - assert_eq!(read(&[page]).scroll, vec2(0.0, -600.0)); - assert_eq!(read(&[]), WheelMotion { scroll: Vec2::ZERO, zoom: 1.0 }, "and nothing is left for a later pass"); - } - - #[test] - fn modifiers_turn_the_wheel_sideways_or_into_zoom() { - let line = egui::InputOptions::default().line_scroll_speed; - assert_eq!(read(&[notch(-1.0, Modifiers::SHIFT)]).scroll, vec2(-line, 0.0)); - let sideways = wheel(MouseWheelUnit::Point, vec2(-30.0, 0.0), TouchPhase::Move, Modifiers::ALT); - assert_eq!(read(&[sideways]).scroll, vec2(0.0, -30.0)); - let zoomed = read(&[notch(1.0, Modifiers::COMMAND)]); - assert_eq!(zoomed.scroll, Vec2::ZERO, "a zooming wheel doesn't scroll"); - let step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); - assert!((zoomed.zoom - step).abs() < 1e-6, "{zoomed:?}"); - assert!((read(&[notch(-1.0, Modifiers::COMMAND)]).zoom - 1.0 / step).abs() < 1e-6); - assert!((read(&[Event::Zoom(1.5), Event::Zoom(2.0)]).zoom - 3.0).abs() < 1e-6, "pinch events multiply"); - } - - #[test] - fn a_touch_gesture_keeps_its_modifiers_and_ends_clean() { - let mut reader = WheelReader::default(); - let options = egui::InputOptions::default(); - let point = |dy: f32, phase, modifiers| wheel(MouseWheelUnit::Point, vec2(0.0, dy), phase, modifiers); - // ⌘ held as the fingers land, let go before the momentum: still zooming. - let began = - reader.read(&[point(0.0, TouchPhase::Start, Modifiers::COMMAND), point(20.0, TouchPhase::Move, Modifiers::COMMAND)], 600.0, &options); - assert!(began.zoom > 1.0 && began.scroll == Vec2::ZERO, "{began:?}"); - let momentum = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE)], 600.0, &options); - assert!(momentum.zoom > 1.0 && momentum.scroll == Vec2::ZERO, "{momentum:?}"); - // The pass it ends in moves nothing, and the next gesture starts without the modifier. - let ended = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE), point(0.0, TouchPhase::End, Modifiers::NONE)], 600.0, &options); - assert_eq!(ended, WheelMotion { scroll: Vec2::ZERO, zoom: 1.0 }); - assert_eq!(reader.read(&[point(-20.0, TouchPhase::Move, Modifiers::NONE)], 600.0, &options).scroll, vec2(0.0, -20.0)); - } - - #[test] - fn numbers_that_are_not_finite_move_nothing() { - for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { - let scrolled = read(&[notch(bad, Modifiers::NONE), notch(-1.0, Modifiers::NONE)]); - assert!(scrolled.scroll.x.is_finite() && scrolled.scroll.y.is_finite(), "{bad}: {scrolled:?}"); - assert_eq!(read(&[notch(bad, Modifiers::COMMAND)]).zoom, 1.0, "{bad}"); - assert_eq!(read(&[Event::Zoom(bad)]).zoom, 1.0, "{bad}"); - } - assert_eq!(read(&[Event::Zoom(0.0)]).zoom, 1.0); - assert_eq!(read(&[Event::Zoom(-2.0)]).zoom, 1.0); - // Too far to be a factor at all. - assert_eq!(read(&[notch(1.0e9, Modifiers::COMMAND)]).zoom, 1.0); - } - - /// One pass of a bare egui context, a sixtieth of a second after the last. - fn pass(ctx: &egui::Context, time: &mut f64, events: Vec, mut seen: impl FnMut(&egui::Context)) { - *time += 1.0 / 60.0; - let raw = egui::RawInput { time: Some(*time), events, ..Default::default() }; - // A bare context's first frame uploads the font atlas; nothing paints it here. - ctx.run_ui(raw, |ui| seen(ui.ctx())).textures_delta.clear(); - } - - /// The reader must stay egui's own reading minus the easing: over the passes egui takes to - /// ease a step, it hands out as much scroll (and as much zoom) as the reader does at once. - #[test] - fn reading_at_once_goes_as_far_as_egui_eases() { - let options = egui::InputOptions::default(); - for (modifiers, unit, step) in [ - (Modifiers::NONE, MouseWheelUnit::Line, vec2(0.0, -3.0)), - (Modifiers::NONE, MouseWheelUnit::Point, vec2(40.0, -250.0)), - (Modifiers::SHIFT, MouseWheelUnit::Line, vec2(0.0, 2.0)), - (Modifiers::COMMAND, MouseWheelUnit::Line, vec2(0.0, 1.0)), - (Modifiers::COMMAND, MouseWheelUnit::Point, vec2(0.0, -90.0)), - ] { - let event = wheel(unit, step, TouchPhase::Move, modifiers); - let ctx = egui::Context::default(); - let (mut time, mut scrolled, mut zoomed, mut first) = (0.0, Vec2::ZERO, 1.0_f32, None); - let mut events = vec![event.clone()]; - for _ in 0..120 { - pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { - let (scroll, zoom) = ctx.input(|i| (i.smooth_scroll_delta, i.zoom_delta())); - first.get_or_insert((scroll, zoom)); - scrolled += scroll; - zoomed *= zoom; - }); - } - let page_height = ctx.input(|i| i.viewport_rect().height()); - let at_once = WheelReader::default().read(&[event], page_height, &options); - assert!((scrolled - at_once.scroll).length() < 0.01, "{modifiers:?} {unit:?}: egui scrolled {scrolled:?}, read {:?}", at_once.scroll); - assert!((zoomed / at_once.zoom - 1.0).abs() < 1e-3, "{modifiers:?} {unit:?}: egui zoomed {zoomed}, read {}", at_once.zoom); - // What makes the reader necessary: egui's first pass holds part of the step back. - let first = first.expect("passes ran"); - assert!( - first != (at_once.scroll, at_once.zoom), - "{modifiers:?} {unit:?}: egui no longer eases this step ({first:?}); the reader may not be needed" - ); - } - } - - #[test] - fn reduced_motion_hands_egui_the_whole_step_and_stops_its_animations() { - let ctx = egui::Context::default(); - let mut motion = Motion { reduced: true, ..Default::default() }; - let line = egui::InputOptions::default().line_scroll_speed; - let mut time = 0.0; - let mut seen = Vec::new(); - let mut events = vec![notch(-1.0, Modifiers::NONE)]; - for _ in 0..30 { - pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { - motion.begin_pass(ctx, AnimationEffects::System); - seen.push(ctx.input(|i| i.smooth_scroll_delta)); - }); - } - assert_eq!(seen.first(), Some(&vec2(0.0, -line)), "the whole notch in the pass it arrives"); - assert!(seen.iter().skip(1).all(|d| *d == Vec2::ZERO), "and none of egui's easing after it: {seen:?}"); - let style = ctx.global_style(); - assert_eq!((style.animation_time, style.scroll_animation), (0.0, egui::style::ScrollAnimation::none())); - for theme in [egui::Theme::Light, egui::Theme::Dark] { - assert_eq!(ctx.style_of(theme).animation_time, 0.0, "{theme:?}"); - } - - // ⌘/Ctrl with the wheel: the whole zoom step at once, then nothing. - let step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); - let mut zooms = Vec::new(); - let mut events = vec![notch(1.0, Modifiers::COMMAND)]; - for _ in 0..30 { - pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { - motion.begin_pass(ctx, AnimationEffects::System); - zooms.push(zoom_delta(ctx)); - }); - } - assert!((zooms[0] - step).abs() < 1e-6, "{zooms:?}"); - assert!(zooms.iter().skip(1).all(|z| *z == 1.0), "{zooms:?}"); - - // Animations allowed again: egui's own values and its own times. - motion.reduced = false; - let mut eased = Vec::new(); - let mut events = vec![notch(1.0, Modifiers::COMMAND)]; - for _ in 0..3 { - pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { - motion.begin_pass(ctx, AnimationEffects::System); - eased.push((zoom_delta(ctx), ctx.input(|i| i.zoom_delta()))); - }); - } - assert!(eased.iter().all(|(ours, eguis)| ours == eguis), "{eased:?}"); - assert!(eased[0].0 > 1.0 && eased[0].0 < step, "egui eases the step: {eased:?}"); - let (style, default) = (ctx.global_style(), egui::Style::default()); - assert_eq!((style.animation_time, style.scroll_animation), (default.animation_time, default.scroll_animation)); - } - - #[test] - fn the_preference_overrides_the_system_for_every_animation() { - use AnimationEffects::{Off, On, System}; - assert_eq!(AnimationEffects::default(), System, "the system's setting is followed unless the user chooses"); - for choice in AnimationEffects::ALL { - assert_eq!(AnimationEffects::parse(choice.name()), Some(choice)); - } - assert_eq!((AnimationEffects::parse("OFF"), AnimationEffects::parse("smooth")), (Some(Off), None)); - let (line, egui_default) = (egui::InputOptions::default().line_scroll_speed, egui::Style::default()); - let egui_times = (egui_default.animation_time, egui_default.scroll_animation); - // The system's answer, the choice, and whether the window then animates. - for (reduced, effects, animates) in - [(false, System, true), (true, System, false), (false, On, true), (true, On, true), (false, Off, false), (true, Off, false)] - { - assert_eq!(effects.animates(reduced), animates, "{effects:?}, system reduced: {reduced}"); - let ctx = egui::Context::default(); - let mut motion = Motion { reduced, ..Default::default() }; - let (mut time, mut first) = (0.0, None); - pass(&ctx, &mut time, vec![notch(-1.0, Modifiers::NONE)], |ctx| { - motion.begin_pass(ctx, effects); - first = Some((ctx.input(|i| i.smooth_scroll_delta), animated(ctx))); - }); - // The wheel, egui's animations and the app's own fades all go the same way. - let whole_notch = first.map(|(scroll, _)| scroll) == Some(vec2(0.0, -line)); - assert_eq!( - (whole_notch, first.map(|(_, animated)| animated)), - (!animates, Some(animates)), - "{effects:?}, system reduced: {reduced}: {first:?}" - ); - let style = ctx.global_style(); - let times = if animates { egui_times } else { (0.0, ScrollAnimation::none()) }; - assert_eq!((style.animation_time, style.scroll_animation), times, "{effects:?}, system reduced: {reduced}"); - } - // A choice made while running applies from the next pass, and choosing back undoes it. - let ctx = egui::Context::default(); - let mut motion = Motion::default(); - let mut time = 0.0; - for (effects, times) in [(Off, (0.0, ScrollAnimation::none())), (System, egui_times), (Off, (0.0, ScrollAnimation::none())), (On, egui_times)] - { - pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, effects)); - let style = ctx.global_style(); - assert_eq!((style.animation_time, style.scroll_animation), times, "{effects:?}"); - } - } - - /// The chrome's hover wash has a duration of its own (`theme::Press`): it eases in with - /// animation effects on and is simply there with them off. - #[test] - fn the_hover_wash_eases_only_with_animation_effects_on() { - for (effects, eased) in [(AnimationEffects::On, true), (AnimationEffects::Off, false)] { - let ctx = egui::Context::default(); - let mut motion = Motion::default(); - let (mut time, mut hover) = (0.0, Vec::new()); - for _ in 0..12 { - time += 1.0 / 60.0; - let raw = egui::RawInput { time: Some(time), events: vec![Event::PointerMoved(egui::pos2(20.0, 20.0))], ..Default::default() }; - ctx.run_ui(raw, |ui| { - motion.begin_pass(ui.ctx(), effects); - let (_, response) = ui.allocate_exact_size(vec2(100.0, 40.0), egui::Sense::click()); - hover.push(crate::theme::Press::track(ui, &response).hover); - }) - .textures_delta - .clear(); - } - // The first frame the pointer is found over the control. - let first = hover.iter().copied().find(|amount| *amount > 0.0).expect("the pointer is over the control"); - assert_eq!(first < 1.0, eased, "{effects:?}: {hover:?}"); - assert_eq!(hover.last(), Some(&1.0), "{effects:?}: the wash is whole in the end either way: {hover:?}"); - } - } - - #[test] - fn a_zoom_read_in_an_earlier_pass_is_never_used_again() { - let ctx = egui::Context::default(); - let mut motion = Motion { reduced: true, ..Default::default() }; - let mut time = 0.0; - pass(&ctx, &mut time, vec![notch(1.0, Modifiers::COMMAND)], |ctx| { - motion.begin_pass(ctx, AnimationEffects::System); - assert!(zoom_delta(ctx) > 1.0); - }); - // A pass that didn't begin here (a host that draws without `logic`): egui's own answer. - pass(&ctx, &mut time, Vec::new(), |ctx| { - let eguis = ctx.input(|i| i.zoom_delta()); - assert_eq!(zoom_delta(ctx), eguis); - }); - } - - /// With animation effects off, a real scroll area moves by one wheel step whether the frame - /// runs one pass or two (a `request_discard` from a sizing pass): the scroll area already - /// applied and stored the step in the first pass, so a later pass must not apply it again, - /// and must not replay the frame's raw wheel events either. - #[test] - fn two_pass_scroll_area_applies_one_wheel_step_like_a_single_pass() { - fn run(two_passes: bool) -> (Vec, Vec) { - let ctx = egui::Context::default(); - let mut motion = Motion { reduced: true, ..Default::default() }; - let mut offsets = Vec::new(); - let mut wheel_events = Vec::new(); - let raw = egui::RawInput { - time: Some(0.1), - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))), - events: vec![egui::Event::PointerMoved(egui::pos2(50.0, 50.0)), notch(-1.0, Modifiers::NONE)], - ..Default::default() - }; - let output = ctx.run_ui(raw, |ui| { - motion.begin_pass(ui.ctx(), AnimationEffects::System); - wheel_events.push(ui.input(|i| i.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count())); - // Taller than the 300-point viewport, so one step stays far from the scroll - // limit and a doubled step could not be hidden by clamping. - let out = egui::ScrollArea::vertical().id_salt("motion-two-pass-scroll").auto_shrink([false, false]).show(ui, |ui| { - ui.set_min_size(egui::vec2(100.0, 3000.0)); - }); - offsets.push(out.state.unclamped_offset()); - if two_passes && ui.ctx().current_pass_index() == 0 { - ui.ctx().request_discard("motion two-pass scroll regression"); - } - }); - output.drop_without_applying_deltas(); - (offsets, wheel_events) - } - - let (single, single_events) = run(false); - let (two, two_events) = run(true); - - assert_eq!(single.len(), 1, "one frame, one pass"); - assert_eq!(two.len(), 2, "the frame really ran two passes"); - assert_eq!(single_events, vec![1], "one wheel event reaches the first pass"); - assert_eq!(two_events, vec![1, 0], "raw wheel events are not replayed in the later pass"); - - let step = single[0].y; - assert!(step > 0.0, "the wheel step scrolled: {single:?}"); - assert!((two[0].y - step).abs() < 0.01, "the first pass applies the whole step: single={step}, two-pass={two:?}"); - assert!((two[1].y - step).abs() < 0.01, "the presented pass must not apply the same step again: single={step}, two-pass={two:?}"); - } - - /// With animation effects off, ctrl/⌘-wheel and pinch zoom reach a view's persisted zoom - /// once, whether the frame runs one pass or two (a `request_discard` from a sizing pass): - /// multiplying an accumulator by each pass's factor gives the single step either way. A - /// later pass gets a unit factor explicitly, and a frame with no wheel input resets to it. - #[test] - fn two_pass_zoom_applies_one_step_like_a_single_pass() { - /// One frame's `zoom_delta` readings, one per pass. - fn frame(ctx: &egui::Context, motion: &mut Motion, time: f64, events: Vec, two_passes: bool) -> Vec { - let raw = egui::RawInput { - time: Some(time), - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))), - events, - ..Default::default() - }; - let mut zooms = Vec::new(); - let output = ctx.run_ui(raw, |ui| { - motion.begin_pass(ui.ctx(), AnimationEffects::System); - zooms.push(zoom_delta(ui.ctx())); - if two_passes && ui.ctx().current_pass_index() == 0 { - ui.ctx().request_discard("motion two-pass zoom regression"); - } - }); - output.drop_without_applying_deltas(); - zooms - } - - let line = egui::InputOptions::default().line_scroll_speed; - let ctrl_step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); - for (name, events, step) in [("ctrl-wheel", vec![notch(1.0, Modifiers::COMMAND)], ctrl_step), ("pinch", vec![Event::Zoom(1.5)], 1.5)] { - assert!((step - 1.0).abs() > 0.01, "{name}: the factor is nontrivial: {step}"); - let single = { - let ctx = egui::Context::default(); - let mut motion = Motion { reduced: true, ..Default::default() }; - frame(&ctx, &mut motion, 0.1, events.clone(), false) - }; - let ctx = egui::Context::default(); - let mut motion = Motion { reduced: true, ..Default::default() }; - let two = frame(&ctx, &mut motion, 0.1, events, true); - assert_eq!(single.len(), 1, "{name}: one frame, one pass: {single:?}"); - assert_eq!(two.len(), 2, "{name}: the frame really ran two passes: {two:?}"); - let product = |zooms: &[f32]| zooms.iter().product::(); - assert!((product(&single) - step).abs() < 1e-5, "{name}: one pass applies the step: {single:?}"); - assert!((product(&two) - step).abs() < 1e-5, "{name}: two passes apply it once: {two:?}"); - assert!((two[1] - 1.0).abs() < 1e-6, "{name}: a later pass zooms by nothing: {two:?}"); - // The next frame's wheel reading starts over; the two-pass frame's factor is gone. - let after = frame(&ctx, &mut motion, 0.1 + 1.0 / 60.0, Vec::new(), false); - assert_eq!(after, vec![1.0], "{name}: the cached factor does not survive the frame"); - } - } - - #[test] - fn the_system_is_asked_now_and_then_and_no_answer_changes_nothing() { - let ctx = egui::Context::default(); - let answer = Rc::new(Cell::new(Some(true))); - let asked = Rc::new(Cell::new(0_u32)); - let (source_answer, source_asked) = (Rc::clone(&answer), Rc::clone(&asked)); - let mut motion = Motion { - source: Some(Box::new(move || { - source_asked.set(source_asked.get() + 1); - source_answer.get() - })), - ..Default::default() - }; - let mut time = 0.0; - pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); - assert!(motion.reduced, "asked in the first pass"); - answer.set(Some(false)); - for _ in 0..30 { - pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); - } - assert!(motion.reduced && asked.get() == 1, "not asked again within half a second ({} times)", asked.get()); - // Back at the window later: the first pass asks. - time += ASK_EVERY; - pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); - assert!(!motion.reduced); - answer.set(None); - motion.reduced = true; - time += ASK_EVERY; - pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); - assert!(motion.reduced && asked.get() == 3, "no answer leaves the last one standing"); - } -} +mod tests; diff --git a/crates/ui-egui/src/motion/tests.rs b/crates/ui-egui/src/motion/tests.rs new file mode 100644 index 0000000000..c9c291252c --- /dev/null +++ b/crates/ui-egui/src/motion/tests.rs @@ -0,0 +1,385 @@ +use super::*; +use std::cell::Cell; +use std::rc::Rc; + +fn wheel(unit: MouseWheelUnit, delta: Vec2, phase: TouchPhase, modifiers: Modifiers) -> Event { + Event::MouseWheel { unit, delta, phase, modifiers, source: egui::MouseWheelSource::Unknown } +} + +fn notch(dy: f32, modifiers: Modifiers) -> Event { + wheel(MouseWheelUnit::Line, vec2(0.0, dy), TouchPhase::Move, modifiers) +} + +fn read(events: &[Event]) -> WheelMotion { + WheelReader::default().read(events, 600.0, &egui::InputOptions::default()) +} + +#[test] +fn a_step_is_read_whole_in_its_units() { + let line = egui::InputOptions::default().line_scroll_speed; + assert_eq!(read(&[notch(-1.0, Modifiers::NONE)]), WheelMotion { scroll: vec2(0.0, -line), zoom: 1.0 }); + assert_eq!(read(&[notch(-1.0, Modifiers::NONE), notch(-2.0, Modifiers::NONE)]).scroll, vec2(0.0, -3.0 * line), "steps of one pass add up"); + let points = wheel(MouseWheelUnit::Point, vec2(3.0, -250.0), TouchPhase::Move, Modifiers::NONE); + assert_eq!(read(&[points]).scroll, vec2(3.0, -250.0)); + let page = wheel(MouseWheelUnit::Page, vec2(0.0, -1.0), TouchPhase::Move, Modifiers::NONE); + assert_eq!(read(&[page]).scroll, vec2(0.0, -600.0)); + assert_eq!(read(&[]), WheelMotion { scroll: Vec2::ZERO, zoom: 1.0 }, "and nothing is left for a later pass"); +} + +#[test] +fn modifiers_turn_the_wheel_sideways_or_into_zoom() { + let line = egui::InputOptions::default().line_scroll_speed; + assert_eq!(read(&[notch(-1.0, Modifiers::SHIFT)]).scroll, vec2(-line, 0.0)); + let sideways = wheel(MouseWheelUnit::Point, vec2(-30.0, 0.0), TouchPhase::Move, Modifiers::ALT); + assert_eq!(read(&[sideways]).scroll, vec2(0.0, -30.0)); + let zoomed = read(&[notch(1.0, Modifiers::COMMAND)]); + assert_eq!(zoomed.scroll, Vec2::ZERO, "a zooming wheel doesn't scroll"); + let step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); + assert!((zoomed.zoom - step).abs() < 1e-6, "{zoomed:?}"); + assert!((read(&[notch(-1.0, Modifiers::COMMAND)]).zoom - 1.0 / step).abs() < 1e-6); + assert!((read(&[Event::Zoom(1.5), Event::Zoom(2.0)]).zoom - 3.0).abs() < 1e-6, "pinch events multiply"); +} + +#[test] +fn a_touch_gesture_keeps_its_modifiers_and_ends_clean() { + let mut reader = WheelReader::default(); + let options = egui::InputOptions::default(); + let point = |dy: f32, phase, modifiers| wheel(MouseWheelUnit::Point, vec2(0.0, dy), phase, modifiers); + // ⌘ held as the fingers land, let go before the momentum: still zooming. + let began = + reader.read(&[point(0.0, TouchPhase::Start, Modifiers::COMMAND), point(20.0, TouchPhase::Move, Modifiers::COMMAND)], 600.0, &options); + assert!(began.zoom > 1.0 && began.scroll == Vec2::ZERO, "{began:?}"); + let momentum = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE)], 600.0, &options); + assert!(momentum.zoom > 1.0 && momentum.scroll == Vec2::ZERO, "{momentum:?}"); + // The pass it ends in moves nothing, and the next gesture starts without the modifier. + let ended = reader.read(&[point(20.0, TouchPhase::Move, Modifiers::NONE), point(0.0, TouchPhase::End, Modifiers::NONE)], 600.0, &options); + assert_eq!(ended, WheelMotion { scroll: Vec2::ZERO, zoom: 1.0 }); + assert_eq!(reader.read(&[point(-20.0, TouchPhase::Move, Modifiers::NONE)], 600.0, &options).scroll, vec2(0.0, -20.0)); +} + +#[test] +fn numbers_that_are_not_finite_move_nothing() { + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let scrolled = read(&[notch(bad, Modifiers::NONE), notch(-1.0, Modifiers::NONE)]); + assert!(scrolled.scroll.x.is_finite() && scrolled.scroll.y.is_finite(), "{bad}: {scrolled:?}"); + assert_eq!(read(&[notch(bad, Modifiers::COMMAND)]).zoom, 1.0, "{bad}"); + assert_eq!(read(&[Event::Zoom(bad)]).zoom, 1.0, "{bad}"); + } + assert_eq!(read(&[Event::Zoom(0.0)]).zoom, 1.0); + assert_eq!(read(&[Event::Zoom(-2.0)]).zoom, 1.0); + // Too far to be a factor at all. + assert_eq!(read(&[notch(1.0e9, Modifiers::COMMAND)]).zoom, 1.0); +} + +/// One pass of a bare egui context, a sixtieth of a second after the last. +fn pass(ctx: &egui::Context, time: &mut f64, events: Vec, mut seen: impl FnMut(&egui::Context)) { + *time += 1.0 / 60.0; + let raw = egui::RawInput { time: Some(*time), events, ..Default::default() }; + // A bare context's first frame uploads the font atlas; nothing paints it here. + ctx.run_ui(raw, |ui| seen(ui.ctx())).textures_delta.clear(); +} + +/// The reader must stay egui's own reading minus the easing: over the passes egui takes to +/// ease a step, it hands out as much scroll (and as much zoom) as the reader does at once. +#[test] +fn reading_at_once_goes_as_far_as_egui_eases() { + let options = egui::InputOptions::default(); + for (modifiers, unit, step) in [ + (Modifiers::NONE, MouseWheelUnit::Line, vec2(0.0, -3.0)), + (Modifiers::NONE, MouseWheelUnit::Point, vec2(40.0, -250.0)), + (Modifiers::SHIFT, MouseWheelUnit::Line, vec2(0.0, 2.0)), + (Modifiers::COMMAND, MouseWheelUnit::Line, vec2(0.0, 1.0)), + (Modifiers::COMMAND, MouseWheelUnit::Point, vec2(0.0, -90.0)), + ] { + let event = wheel(unit, step, TouchPhase::Move, modifiers); + let ctx = egui::Context::default(); + let (mut time, mut scrolled, mut zoomed, mut first) = (0.0, Vec2::ZERO, 1.0_f32, None); + let mut events = vec![event.clone()]; + for _ in 0..120 { + pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { + let (scroll, zoom) = ctx.input(|i| (i.smooth_scroll_delta, i.zoom_delta())); + first.get_or_insert((scroll, zoom)); + scrolled += scroll; + zoomed *= zoom; + }); + } + let page_height = ctx.input(|i| i.viewport_rect().height()); + let at_once = WheelReader::default().read(&[event], page_height, &options); + assert!((scrolled - at_once.scroll).length() < 0.01, "{modifiers:?} {unit:?}: egui scrolled {scrolled:?}, read {:?}", at_once.scroll); + assert!((zoomed / at_once.zoom - 1.0).abs() < 1e-3, "{modifiers:?} {unit:?}: egui zoomed {zoomed}, read {}", at_once.zoom); + // What makes the reader necessary: egui's first pass holds part of the step back. + let first = first.expect("passes ran"); + assert!( + first != (at_once.scroll, at_once.zoom), + "{modifiers:?} {unit:?}: egui no longer eases this step ({first:?}); the reader may not be needed" + ); + } +} + +#[test] +fn reduced_motion_hands_egui_the_whole_step_and_stops_its_animations() { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let line = egui::InputOptions::default().line_scroll_speed; + let mut time = 0.0; + let mut seen = Vec::new(); + let mut events = vec![notch(-1.0, Modifiers::NONE)]; + for _ in 0..30 { + pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { + motion.begin_pass(ctx, AnimationEffects::System); + seen.push(ctx.input(|i| i.smooth_scroll_delta)); + }); + } + assert_eq!(seen.first(), Some(&vec2(0.0, -line)), "the whole notch in the pass it arrives"); + assert!(seen.iter().skip(1).all(|d| *d == Vec2::ZERO), "and none of egui's easing after it: {seen:?}"); + let style = ctx.global_style(); + assert_eq!((style.animation_time, style.scroll_animation), (0.0, egui::style::ScrollAnimation::none())); + for theme in [egui::Theme::Light, egui::Theme::Dark] { + assert_eq!(ctx.style_of(theme).animation_time, 0.0, "{theme:?}"); + } + + // ⌘/Ctrl with the wheel: the whole zoom step at once, then nothing. + let step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); + let mut zooms = Vec::new(); + let mut events = vec![notch(1.0, Modifiers::COMMAND)]; + for _ in 0..30 { + pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { + motion.begin_pass(ctx, AnimationEffects::System); + zooms.push(zoom_delta(ctx)); + }); + } + assert!((zooms[0] - step).abs() < 1e-6, "{zooms:?}"); + assert!(zooms.iter().skip(1).all(|z| *z == 1.0), "{zooms:?}"); + + // Animations allowed again: egui's own values and its own times. + motion.reduced = false; + let mut eased = Vec::new(); + let mut events = vec![notch(1.0, Modifiers::COMMAND)]; + for _ in 0..3 { + pass(&ctx, &mut time, std::mem::take(&mut events), |ctx| { + motion.begin_pass(ctx, AnimationEffects::System); + eased.push((zoom_delta(ctx), ctx.input(|i| i.zoom_delta()))); + }); + } + assert!(eased.iter().all(|(ours, eguis)| ours == eguis), "{eased:?}"); + assert!(eased[0].0 > 1.0 && eased[0].0 < step, "egui eases the step: {eased:?}"); + let (style, default) = (ctx.global_style(), egui::Style::default()); + assert_eq!((style.animation_time, style.scroll_animation), (default.animation_time, default.scroll_animation)); +} + +#[test] +fn the_preference_overrides_the_system_for_every_animation() { + use AnimationEffects::{Off, On, System}; + assert_eq!(AnimationEffects::default(), System, "the system's setting is followed unless the user chooses"); + for choice in AnimationEffects::ALL { + assert_eq!(AnimationEffects::parse(choice.name()), Some(choice)); + } + assert_eq!((AnimationEffects::parse("OFF"), AnimationEffects::parse("smooth")), (Some(Off), None)); + let (line, egui_default) = (egui::InputOptions::default().line_scroll_speed, egui::Style::default()); + let egui_times = (egui_default.animation_time, egui_default.scroll_animation); + // The system's answer, the choice, and whether the window then animates. + for (reduced, effects, animates) in + [(false, System, true), (true, System, false), (false, On, true), (true, On, true), (false, Off, false), (true, Off, false)] + { + assert_eq!(effects.animates(reduced), animates, "{effects:?}, system reduced: {reduced}"); + let ctx = egui::Context::default(); + let mut motion = Motion { reduced, ..Default::default() }; + let (mut time, mut first) = (0.0, None); + pass(&ctx, &mut time, vec![notch(-1.0, Modifiers::NONE)], |ctx| { + motion.begin_pass(ctx, effects); + first = Some((ctx.input(|i| i.smooth_scroll_delta), animated(ctx))); + }); + // The wheel, egui's animations and the app's own fades all go the same way. + let whole_notch = first.map(|(scroll, _)| scroll) == Some(vec2(0.0, -line)); + assert_eq!( + (whole_notch, first.map(|(_, animated)| animated)), + (!animates, Some(animates)), + "{effects:?}, system reduced: {reduced}: {first:?}" + ); + let style = ctx.global_style(); + let times = if animates { egui_times } else { (0.0, ScrollAnimation::none()) }; + assert_eq!((style.animation_time, style.scroll_animation), times, "{effects:?}, system reduced: {reduced}"); + } + // A choice made while running applies from the next pass, and choosing back undoes it. + let ctx = egui::Context::default(); + let mut motion = Motion::default(); + let mut time = 0.0; + for (effects, times) in [(Off, (0.0, ScrollAnimation::none())), (System, egui_times), (Off, (0.0, ScrollAnimation::none())), (On, egui_times)] + { + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, effects)); + let style = ctx.global_style(); + assert_eq!((style.animation_time, style.scroll_animation), times, "{effects:?}"); + } +} + +/// The chrome's hover wash has a duration of its own (`theme::Press`): it eases in with +/// animation effects on and is simply there with them off. +#[test] +fn the_hover_wash_eases_only_with_animation_effects_on() { + for (effects, eased) in [(AnimationEffects::On, true), (AnimationEffects::Off, false)] { + let ctx = egui::Context::default(); + let mut motion = Motion::default(); + let (mut time, mut hover) = (0.0, Vec::new()); + for _ in 0..12 { + time += 1.0 / 60.0; + let raw = egui::RawInput { time: Some(time), events: vec![Event::PointerMoved(egui::pos2(20.0, 20.0))], ..Default::default() }; + ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), effects); + let (_, response) = ui.allocate_exact_size(vec2(100.0, 40.0), egui::Sense::click()); + hover.push(crate::theme::Press::track(ui, &response).hover); + }) + .textures_delta + .clear(); + } + // The first frame the pointer is found over the control. + let first = hover.iter().copied().find(|amount| *amount > 0.0).expect("the pointer is over the control"); + assert_eq!(first < 1.0, eased, "{effects:?}: {hover:?}"); + assert_eq!(hover.last(), Some(&1.0), "{effects:?}: the wash is whole in the end either way: {hover:?}"); + } +} + +#[test] +fn a_zoom_read_in_an_earlier_pass_is_never_used_again() { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let mut time = 0.0; + pass(&ctx, &mut time, vec![notch(1.0, Modifiers::COMMAND)], |ctx| { + motion.begin_pass(ctx, AnimationEffects::System); + assert!(zoom_delta(ctx) > 1.0); + }); + // A pass that didn't begin here (a host that draws without `logic`): egui's own answer. + pass(&ctx, &mut time, Vec::new(), |ctx| { + let eguis = ctx.input(|i| i.zoom_delta()); + assert_eq!(zoom_delta(ctx), eguis); + }); +} + +/// With animation effects off, a real scroll area moves by one wheel step whether the frame +/// runs one pass or two (a `request_discard` from a sizing pass): the scroll area already +/// applied and stored the step in the first pass, so a later pass must not apply it again, +/// and must not replay the frame's raw wheel events either. +#[test] +fn two_pass_scroll_area_applies_one_wheel_step_like_a_single_pass() { + fn run(two_passes: bool) -> (Vec, Vec) { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let mut offsets = Vec::new(); + let mut wheel_events = Vec::new(); + let raw = egui::RawInput { + time: Some(0.1), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))), + events: vec![egui::Event::PointerMoved(egui::pos2(50.0, 50.0)), notch(-1.0, Modifiers::NONE)], + ..Default::default() + }; + let output = ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), AnimationEffects::System); + wheel_events.push(ui.input(|i| i.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count())); + // Taller than the 300-point viewport, so one step stays far from the scroll + // limit and a doubled step could not be hidden by clamping. + let out = egui::ScrollArea::vertical().id_salt("motion-two-pass-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.set_min_size(egui::vec2(100.0, 3000.0)); + }); + offsets.push(out.state.unclamped_offset()); + if two_passes && ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("motion two-pass scroll regression"); + } + }); + output.drop_without_applying_deltas(); + (offsets, wheel_events) + } + + let (single, single_events) = run(false); + let (two, two_events) = run(true); + + assert_eq!(single.len(), 1, "one frame, one pass"); + assert_eq!(two.len(), 2, "the frame really ran two passes"); + assert_eq!(single_events, vec![1], "one wheel event reaches the first pass"); + assert_eq!(two_events, vec![1, 0], "raw wheel events are not replayed in the later pass"); + + let step = single[0].y; + assert!(step > 0.0, "the wheel step scrolled: {single:?}"); + assert!((two[0].y - step).abs() < 0.01, "the first pass applies the whole step: single={step}, two-pass={two:?}"); + assert!((two[1].y - step).abs() < 0.01, "the presented pass must not apply the same step again: single={step}, two-pass={two:?}"); +} + +/// With animation effects off, ctrl/⌘-wheel and pinch zoom reach a view's persisted zoom +/// once, whether the frame runs one pass or two (a `request_discard` from a sizing pass): +/// multiplying an accumulator by each pass's factor gives the single step either way. A +/// later pass gets a unit factor explicitly, and a frame with no wheel input resets to it. +#[test] +fn two_pass_zoom_applies_one_step_like_a_single_pass() { + /// One frame's `zoom_delta` readings, one per pass. + fn frame(ctx: &egui::Context, motion: &mut Motion, time: f64, events: Vec, two_passes: bool) -> Vec { + let raw = egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))), + events, + ..Default::default() + }; + let mut zooms = Vec::new(); + let output = ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), AnimationEffects::System); + zooms.push(zoom_delta(ui.ctx())); + if two_passes && ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("motion two-pass zoom regression"); + } + }); + output.drop_without_applying_deltas(); + zooms + } + + let line = egui::InputOptions::default().line_scroll_speed; + let ctrl_step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); + for (name, events, step) in [("ctrl-wheel", vec![notch(1.0, Modifiers::COMMAND)], ctrl_step), ("pinch", vec![Event::Zoom(1.5)], 1.5)] { + assert!((step - 1.0).abs() > 0.01, "{name}: the factor is nontrivial: {step}"); + let single = { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + frame(&ctx, &mut motion, 0.1, events.clone(), false) + }; + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let two = frame(&ctx, &mut motion, 0.1, events, true); + assert_eq!(single.len(), 1, "{name}: one frame, one pass: {single:?}"); + assert_eq!(two.len(), 2, "{name}: the frame really ran two passes: {two:?}"); + let product = |zooms: &[f32]| zooms.iter().product::(); + assert!((product(&single) - step).abs() < 1e-5, "{name}: one pass applies the step: {single:?}"); + assert!((product(&two) - step).abs() < 1e-5, "{name}: two passes apply it once: {two:?}"); + assert!((two[1] - 1.0).abs() < 1e-6, "{name}: a later pass zooms by nothing: {two:?}"); + // The next frame's wheel reading starts over; the two-pass frame's factor is gone. + let after = frame(&ctx, &mut motion, 0.1 + 1.0 / 60.0, Vec::new(), false); + assert_eq!(after, vec![1.0], "{name}: the cached factor does not survive the frame"); + } +} + +#[test] +fn the_system_is_asked_now_and_then_and_no_answer_changes_nothing() { + let ctx = egui::Context::default(); + let answer = Rc::new(Cell::new(Some(true))); + let asked = Rc::new(Cell::new(0_u32)); + let (source_answer, source_asked) = (Rc::clone(&answer), Rc::clone(&asked)); + let mut motion = Motion { + source: Some(Box::new(move || { + source_asked.set(source_asked.get() + 1); + source_answer.get() + })), + ..Default::default() + }; + let mut time = 0.0; + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); + assert!(motion.reduced, "asked in the first pass"); + answer.set(Some(false)); + for _ in 0..30 { + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); + } + assert!(motion.reduced && asked.get() == 1, "not asked again within half a second ({} times)", asked.get()); + // Back at the window later: the first pass asks. + time += ASK_EVERY; + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); + assert!(!motion.reduced); + answer.set(None); + motion.reduced = true; + time += ASK_EVERY; + pass(&ctx, &mut time, Vec::new(), |ctx| motion.begin_pass(ctx, AnimationEffects::System)); + assert!(motion.reduced && asked.get() == 3, "no answer leaves the last one standing"); +}