use std::time::Duration;
use frust_core::{AnimationController, Curve, FrameTime, Spring, SpringDesc};
use frust_theme::{MotionScheme, MotionSpring};
use kurbo::{Affine, Point, Rect, Size};
const M3_SHARED_AXIS_SLIDE_DP: f64 = 30.0;
const M3_FADE_SPLIT: f64 = 0.35;
const M3_FADE_THROUGH_SCALE_START: f64 = 0.92;
const M3_FADE_THROUGH_SPLIT: f64 = 0.30;
const M3_FADE_THROUGH_OUT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
const M3_FADE_THROUGH_IN_CURVE: Curve = Curve::Cubic(0.0, 0.0, 0.2, 1.0);
const GLYPH_SLIDE_DP: f64 = 16.0;
const GLYPH_ENTER: Duration = Duration::from_millis(340);
const GLYPH_EXIT: Duration = Duration::from_millis(150);
const GLYPH_SPATIAL_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);
const GLYPH_EXIT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
const GLYPH_FADE_SPLIT: f64 = 0.44;
const REDUCE_MOTION_DURATION: Duration = Duration::from_millis(120);
const IOS_PARALLAX_FRACTION: f64 = 1.0 / 3.0;
const IOS_DIM_MAX: f32 = 0.08;
const IOS_DEFAULT_DURATION: Duration = Duration::from_millis(350);
const M3_DEFAULT_DURATION: Duration = Duration::from_millis(300);
const DEFAULT_SETTLE_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 700.0,
damping_ratio: 0.9,
};
#[allow(unpredictable_function_pointer_comparisons)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PageTransition {
#[default]
None,
M3SharedAxisX,
M3FadeThrough,
IosPush,
SlideUp,
Glyph,
ReducedCrossfade,
Custom(fn(progress: f64, is_pop: bool, size: Size) -> (Layer, Layer)),
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Timing {
Duration(Duration, Curve),
Spring(MotionSpring),
ThemeDefault,
}
impl Default for Timing {
fn default() -> Self {
Timing::Duration(M3_DEFAULT_DURATION, Curve::EaseInOut)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TransitionSpec {
pub preset: PageTransition,
pub timing: Timing,
}
impl Default for TransitionSpec {
fn default() -> Self {
Self::NONE
}
}
impl TransitionSpec {
pub const NONE: Self = TransitionSpec {
preset: PageTransition::None,
timing: Timing::Duration(M3_DEFAULT_DURATION, Curve::EaseInOut),
};
pub const fn new(preset: PageTransition, timing: Timing) -> Self {
TransitionSpec { preset, timing }
}
pub fn duration(preset: PageTransition) -> Self {
let d = match preset {
PageTransition::IosPush => IOS_DEFAULT_DURATION,
_ => M3_DEFAULT_DURATION,
};
TransitionSpec {
preset,
timing: Timing::Duration(d, Curve::EaseInOut),
}
}
pub fn spring(preset: PageTransition, spring: MotionSpring) -> Self {
TransitionSpec {
preset,
timing: Timing::Spring(spring),
}
}
pub const fn themed(preset: PageTransition) -> Self {
TransitionSpec {
preset,
timing: Timing::ThemeDefault,
}
}
pub const fn glyph() -> Self {
Self::themed(PageTransition::Glyph)
}
pub fn is_animated(&self) -> bool {
self.preset != PageTransition::None
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TransitionState {
pub active: bool,
pub progress: f64,
pub is_pop: bool,
pub interactive: bool,
pub from_depth: usize,
pub to_depth: usize,
pub generation: u32,
}
impl TransitionState {
pub const fn settled(depth: usize, generation: u32) -> Self {
TransitionState {
active: false,
progress: 1.0,
is_pop: false,
interactive: false,
from_depth: depth,
to_depth: depth,
generation,
}
}
pub fn clamped(&self) -> f64 {
self.progress.clamp(0.0, 1.0)
}
}
impl Default for TransitionState {
fn default() -> Self {
Self::settled(0, 0)
}
}
const _: fn() = || {
fn assert_send_sync<T: Send + Sync + 'static>() {}
assert_send_sync::<TransitionState>();
};
#[derive(Clone, Copy, Debug)]
pub struct Advance {
pub value: f64,
pub animating: bool,
pub done: bool,
}
#[derive(Clone, Copy, Debug)]
pub enum TransitionDriver {
Auto(AnimationController),
Held { value: f64 },
Settle {
spring: Spring,
target: f64,
elapsed: f64,
last: Option<FrameTime>,
},
}
impl TransitionDriver {
pub fn value(&self) -> f64 {
match self {
TransitionDriver::Auto(c) => c.value(),
TransitionDriver::Held { value } => *value,
TransitionDriver::Settle {
spring,
target,
elapsed,
..
} => target + spring.position(*elapsed),
}
}
pub fn advance(&mut self, now: FrameTime) -> Advance {
match self {
TransitionDriver::Auto(c) => {
let animating = c.advance(now);
Advance {
value: c.value(),
animating,
done: !animating,
}
}
TransitionDriver::Held { value } => Advance {
value: *value,
animating: false,
done: false,
},
TransitionDriver::Settle {
spring,
target,
elapsed,
last,
} => {
let dt = match *last {
Some(prev) => now.saturating_sub(prev).as_secs_f64(),
None => 0.0,
};
*last = Some(now);
*elapsed += dt.max(0.0);
let e = *elapsed;
if spring.is_at_rest(e, 1e-3) {
Advance {
value: *target,
animating: false,
done: true,
}
} else {
Advance {
value: *target + spring.position(e),
animating: true,
done: false,
}
}
}
}
}
}
pub fn make_driver(timing: Timing) -> (TransitionDriver, SpringDesc) {
match timing {
Timing::Duration(d, curve) => {
let mut c = AnimationController::new(d).with_curve(curve);
c.forward();
(TransitionDriver::Auto(c), DEFAULT_SETTLE_SPRING)
}
Timing::Spring(spring) => {
let desc: SpringDesc = spring.into();
let mut c = AnimationController::new(M3_DEFAULT_DURATION);
c.fling(0.0, desc);
(TransitionDriver::Auto(c), desc)
}
Timing::ThemeDefault => {
make_driver(Timing::Duration(M3_DEFAULT_DURATION, Curve::Emphasized))
}
}
}
pub fn preset_enter_exit(
preset: PageTransition,
scheme: Option<&MotionScheme>,
) -> (Timing, Timing) {
match preset {
PageTransition::Glyph => match scheme {
Some(s) => (
Timing::Duration(
Duration::from_secs_f64(s.durations.slow / 1000.0),
s.easing.spatial,
),
Timing::Duration(
Duration::from_secs_f64(s.durations.fast / 1000.0),
s.easing.exit,
),
),
None => (
Timing::Duration(GLYPH_ENTER, GLYPH_SPATIAL_CURVE),
Timing::Duration(GLYPH_EXIT, GLYPH_EXIT_CURVE),
),
},
PageTransition::Custom(_) => {
let d = Timing::Duration(M3_DEFAULT_DURATION, Curve::Emphasized);
(d, d)
}
other => {
let d = TransitionSpec::duration(other).timing;
(d, d)
}
}
}
pub fn resolve_timing(
timing: Timing,
preset: PageTransition,
scheme: Option<&MotionScheme>,
) -> Timing {
match timing {
Timing::ThemeDefault => preset_enter_exit(preset, scheme).0,
other => other,
}
}
pub fn resolve_spec(spec: TransitionSpec, scheme: Option<&MotionScheme>) -> TransitionSpec {
if spec.is_animated() && scheme.map(|s| s.reduce_motion).unwrap_or(false) {
return TransitionSpec {
preset: PageTransition::ReducedCrossfade,
timing: Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear),
};
}
TransitionSpec {
preset: spec.preset,
timing: resolve_timing(spec.timing, spec.preset, scheme),
}
}
pub fn settle_driver(
spring: SpringDesc,
from: f64,
velocity: f64,
target: f64,
) -> TransitionDriver {
TransitionDriver::Settle {
spring: Spring::new(spring, from - target, velocity),
target,
elapsed: 0.0,
last: None,
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Layer {
pub dx: f64,
pub dy: f64,
pub alpha: f32,
pub scale: f64,
}
impl Layer {
pub const IDENTITY: Layer = Layer {
dx: 0.0,
dy: 0.0,
alpha: 1.0,
scale: 1.0,
};
}
pub fn resolve_layers(
preset: PageTransition,
value: f64,
is_pop: bool,
size: Size,
) -> (Layer, Layer) {
let p = value; let pc = value.clamp(0.0, 1.0); let w = size.width;
match preset {
PageTransition::None => (Layer::IDENTITY, Layer::IDENTITY),
PageTransition::ReducedCrossfade => {
let entering = Layer {
dx: 0.0,
dy: 0.0,
alpha: pc as f32,
scale: 1.0,
};
let leaving = Layer {
dx: 0.0,
dy: 0.0,
alpha: (1.0 - pc) as f32,
scale: 1.0,
};
(entering, leaving)
}
PageTransition::M3SharedAxisX => {
let slide = M3_SHARED_AXIS_SLIDE_DP;
let dir = if is_pop { -1.0 } else { 1.0 };
let entering = Layer {
dx: dir * (1.0 - p) * slide,
dy: 0.0,
alpha: ramp(pc, M3_FADE_SPLIT, 1.0),
scale: 1.0,
};
let leaving = Layer {
dx: -dir * p * slide,
dy: 0.0,
alpha: 1.0 - ramp(pc, 0.0, M3_FADE_SPLIT),
scale: 1.0,
};
(entering, leaving)
}
PageTransition::M3FadeThrough => {
let out = M3_FADE_THROUGH_OUT_CURVE.interval(0.0, M3_FADE_THROUGH_SPLIT);
let inc = M3_FADE_THROUGH_IN_CURVE.interval(M3_FADE_THROUGH_SPLIT, 1.0);
let in_progress = inc.transform(pc);
let entering = Layer {
dx: 0.0,
dy: 0.0,
alpha: in_progress as f32,
scale: M3_FADE_THROUGH_SCALE_START
+ (1.0 - M3_FADE_THROUGH_SCALE_START) * in_progress,
};
let leaving = Layer {
dx: 0.0,
dy: 0.0,
alpha: (1.0 - out.transform(pc)) as f32,
scale: 1.0,
};
(entering, leaving)
}
PageTransition::Glyph => {
let slide = GLYPH_SLIDE_DP;
let dir = if is_pop { -1.0 } else { 1.0 };
let entering = Layer {
dx: dir * (1.0 - p) * slide,
dy: 0.0,
alpha: ramp(pc, GLYPH_FADE_SPLIT, 1.0),
scale: 1.0,
};
let leaving = Layer {
dx: -dir * p * slide,
dy: 0.0,
alpha: 1.0 - ramp(pc, 0.0, GLYPH_FADE_SPLIT),
scale: 1.0,
};
(entering, leaving)
}
PageTransition::IosPush => {
if is_pop {
let entering = Layer {
dx: -(1.0 - p) * w * IOS_PARALLAX_FRACTION,
dy: 0.0,
alpha: 1.0,
scale: 1.0,
};
let leaving = Layer {
dx: p * w,
dy: 0.0,
alpha: 1.0,
scale: 1.0,
};
(entering, leaving)
} else {
let entering = Layer {
dx: (1.0 - p) * w,
dy: 0.0,
alpha: 1.0,
scale: 1.0,
};
let leaving = Layer {
dx: -p * w * IOS_PARALLAX_FRACTION,
dy: 0.0,
alpha: 1.0 - pc as f32 * IOS_DIM_MAX,
scale: 1.0,
};
(entering, leaving)
}
}
PageTransition::SlideUp => {
let h = size.height;
if is_pop {
let entering = Layer {
dx: 0.0,
dy: 0.0,
alpha: 1.0,
scale: 1.0,
};
let leaving = Layer {
dx: 0.0,
dy: p * h,
alpha: 1.0,
scale: 1.0,
};
(entering, leaving)
} else {
let entering = Layer {
dx: 0.0,
dy: (1.0 - p) * h,
alpha: 1.0,
scale: 1.0,
};
let leaving = Layer {
dx: 0.0,
dy: 0.0,
alpha: 1.0,
scale: 1.0,
};
(entering, leaving)
}
}
PageTransition::Custom(f) => f(p, is_pop, size),
}
}
pub fn lerp_rect(from: Rect, to: Rect, t: f64) -> Rect {
let lerp = |a: f64, b: f64| a + (b - a) * t;
Rect::from_origin_size(
Point::new(lerp(from.x0, to.x0), lerp(from.y0, to.y0)),
Size::new(
lerp(from.width(), to.width()),
lerp(from.height(), to.height()),
),
)
}
pub fn rect_to_rect(from: Rect, to: Rect) -> Affine {
let sx = if from.width().abs() > f64::EPSILON {
to.width() / from.width()
} else {
1.0
};
let sy = if from.height().abs() > f64::EPSILON {
to.height() / from.height()
} else {
1.0
};
Affine::translate((to.x0, to.y0))
* Affine::scale_non_uniform(sx, sy)
* Affine::translate((-from.x0, -from.y0))
}
fn ramp(t: f64, start: f64, end: f64) -> f32 {
if end <= start {
return if t >= start { 1.0 } else { 0.0 };
}
(((t - start) / (end - start)).clamp(0.0, 1.0)) as f32
}
#[cfg(test)]
mod tests {
use super::*;
fn ft_secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
const SIZE: Size = Size::new(400.0, 800.0);
#[test]
fn transition_state_settled_is_at_rest_on_one_depth() {
let s = TransitionState::settled(3, 7);
assert!(!s.active);
assert!(!s.is_pop);
assert!(!s.interactive);
assert_eq!((s.from_depth, s.to_depth), (3, 3));
assert_eq!(s.generation, 7);
assert_eq!(s.progress, 1.0, "settled means fully arrived");
assert_eq!(
TransitionState::default(),
TransitionState::settled(0, 0),
"the default is an at-rest empty stack (no navigator attached)"
);
}
#[test]
fn transition_state_clamped_bounds_a_spring_overshoot() {
let mut s = TransitionState::settled(1, 0);
s.progress = 1.08;
assert_eq!(s.clamped(), 1.0);
s.progress = -0.04;
assert_eq!(s.clamped(), 0.0);
s.progress = 0.42;
assert_eq!(s.clamped(), 0.42);
}
#[test]
fn ramp_is_clamped_linear() {
assert_eq!(ramp(0.0, 0.35, 1.0), 0.0);
assert_eq!(ramp(0.35, 0.35, 1.0), 0.0);
assert_eq!(ramp(1.0, 0.35, 1.0), 1.0);
assert!((ramp(0.675, 0.35, 1.0) - 0.5).abs() < 1e-6);
assert_eq!(ramp(0.1, 0.5, 0.5), 0.0);
assert_eq!(ramp(0.9, 0.5, 0.5), 1.0);
}
#[test]
fn shared_axis_slides_and_crossfades_forward() {
let (enter, leave) = resolve_layers(PageTransition::M3SharedAxisX, 0.0, false, SIZE);
assert_eq!(enter.dx, M3_SHARED_AXIS_SLIDE_DP);
assert_eq!(enter.alpha, 0.0);
assert_eq!(leave.dx, 0.0);
assert_eq!(leave.alpha, 1.0);
let (enter, leave) = resolve_layers(PageTransition::M3SharedAxisX, 1.0, false, SIZE);
assert_eq!(enter.dx, 0.0);
assert_eq!(enter.alpha, 1.0);
assert_eq!(leave.dx, -M3_SHARED_AXIS_SLIDE_DP);
assert_eq!(leave.alpha, 0.0);
}
#[test]
fn shared_axis_pop_mirrors_direction() {
let (enter, _leave) = resolve_layers(PageTransition::M3SharedAxisX, 0.0, true, SIZE);
assert_eq!(enter.dx, -M3_SHARED_AXIS_SLIDE_DP);
}
#[test]
fn ios_push_incoming_full_width_and_outgoing_parallax() {
let (enter, leave) = resolve_layers(PageTransition::IosPush, 0.0, false, SIZE);
assert_eq!(enter.dx, SIZE.width);
assert_eq!(leave.dx, 0.0);
let (enter, leave) = resolve_layers(PageTransition::IosPush, 1.0, false, SIZE);
assert_eq!(enter.dx, 0.0);
assert!((leave.dx + SIZE.width * IOS_PARALLAX_FRACTION).abs() < 1e-9);
assert!(leave.alpha < 1.0);
}
#[test]
fn fade_through_has_no_horizontal_slide() {
let (enter, leave) = resolve_layers(PageTransition::M3FadeThrough, 0.5, false, SIZE);
assert_eq!(enter.dx, 0.0);
assert_eq!(leave.dx, 0.0);
}
#[test]
fn fade_through_staged_opacity_and_scale_table() {
let ft = PageTransition::M3FadeThrough;
let (enter, leave) = resolve_layers(ft, 0.0, false, SIZE);
assert_eq!(leave.alpha, 1.0);
assert_eq!(leave.scale, 1.0);
assert_eq!(enter.alpha, 0.0);
assert!((enter.scale - 0.92).abs() < 1e-9);
let (enter, leave) = resolve_layers(ft, 0.30, false, SIZE);
assert!(leave.alpha.abs() < 1e-6, "outgoing gone by the split");
assert_eq!(enter.alpha, 0.0, "incoming fade-in opens at the split");
assert!((enter.scale - 0.92).abs() < 1e-9);
let (enter, leave) = resolve_layers(ft, 0.65, false, SIZE);
assert_eq!(leave.alpha, 0.0);
let eased = Curve::Cubic(0.0, 0.0, 0.2, 1.0).transform(0.5);
assert!((enter.alpha as f64 - eased).abs() < 1e-6);
assert!((enter.scale - (0.92 + 0.08 * eased)).abs() < 1e-9);
assert!((enter.alpha as f64 - 0.839).abs() < 2e-2);
assert!((enter.scale - 0.987).abs() < 2e-2);
let (enter, leave) = resolve_layers(ft, 1.0, false, SIZE);
assert_eq!(enter.alpha, 1.0);
assert!((enter.scale - 1.0).abs() < 1e-9);
assert_eq!(leave.alpha, 0.0);
assert_eq!(leave.scale, 1.0);
}
#[test]
fn glyph_slides_directionally_and_crossfades() {
let g = PageTransition::Glyph;
let (enter, leave) = resolve_layers(g, 0.0, false, SIZE);
assert_eq!(enter.dx, GLYPH_SLIDE_DP);
assert_eq!(enter.alpha, 0.0);
assert_eq!(enter.scale, 1.0);
assert_eq!(leave.dx, 0.0);
assert_eq!(leave.alpha, 1.0);
let (enter, leave) = resolve_layers(g, 1.0, false, SIZE);
assert_eq!(enter.dx, 0.0);
assert_eq!(enter.alpha, 1.0);
assert_eq!(leave.dx, -GLYPH_SLIDE_DP);
assert_eq!(leave.alpha, 0.0);
}
#[test]
fn glyph_pop_mirrors_push_direction() {
let (enter, _leave) = resolve_layers(PageTransition::Glyph, 0.0, true, SIZE);
assert_eq!(enter.dx, -GLYPH_SLIDE_DP);
let (_enter, leave) = resolve_layers(PageTransition::Glyph, 1.0, true, SIZE);
assert_eq!(leave.dx, GLYPH_SLIDE_DP);
}
#[test]
fn glyph_enter_exit_durations_unthemed_fallback() {
let (enter, exit) = preset_enter_exit(PageTransition::Glyph, None);
assert_eq!(enter, Timing::Duration(GLYPH_ENTER, GLYPH_SPATIAL_CURVE));
assert_eq!(exit, Timing::Duration(GLYPH_EXIT, GLYPH_EXIT_CURVE));
let (Timing::Duration(de, _), Timing::Duration(dx, _)) = (enter, exit) else {
panic!("expected duration timings");
};
assert_eq!(de, Duration::from_millis(340));
assert_eq!(dx, Duration::from_millis(150));
assert!(dx < de, "exits always faster than entrances");
}
#[test]
fn glyph_enter_exit_durations_from_theme_scheme() {
let m = MotionScheme::neutral();
let (enter, exit) = preset_enter_exit(PageTransition::Glyph, Some(&m));
assert_eq!(
enter,
Timing::Duration(
Duration::from_secs_f64(m.durations.slow / 1000.0),
m.easing.spatial
)
);
assert_eq!(
exit,
Timing::Duration(
Duration::from_secs_f64(m.durations.fast / 1000.0),
m.easing.exit
)
);
}
#[test]
fn theme_default_timing_resolves_to_enter_and_passes_explicit_through() {
let resolved = resolve_timing(Timing::ThemeDefault, PageTransition::Glyph, None);
assert_eq!(resolved, Timing::Duration(GLYPH_ENTER, GLYPH_SPATIAL_CURVE));
let explicit = Timing::Duration(Duration::from_millis(200), Curve::Linear);
assert_eq!(
resolve_timing(explicit, PageTransition::Glyph, None),
explicit
);
}
#[test]
fn resolve_spec_resolves_theme_default_when_motion_enabled() {
let m = MotionScheme::neutral(); let resolved = resolve_spec(TransitionSpec::glyph(), Some(&m));
assert_eq!(resolved.preset, PageTransition::Glyph);
assert_eq!(
resolved.timing,
Timing::Duration(
Duration::from_secs_f64(m.durations.slow / 1000.0),
m.easing.spatial
)
);
}
#[test]
fn reduce_motion_collapses_every_preset_to_crossfade() {
let mut m = MotionScheme::neutral();
m.reduce_motion = true;
for preset in [
PageTransition::Glyph,
PageTransition::M3SharedAxisX,
PageTransition::IosPush,
PageTransition::SlideUp,
PageTransition::M3FadeThrough,
] {
let resolved = resolve_spec(TransitionSpec::themed(preset), Some(&m));
assert_eq!(
resolved.preset,
PageTransition::ReducedCrossfade,
"{preset:?} must collapse to the pure alpha crossfade"
);
let Timing::Duration(d, curve) = resolved.timing else {
panic!("reduced-motion must be a duration crossfade");
};
assert!(d <= Duration::from_millis(120), "{preset:?} not ≤120ms");
assert_eq!(curve, Curve::Linear, "{preset:?}");
for p in [0.0, 0.25, 0.5, 0.75, 1.0] {
let (entering, leaving) =
resolve_layers(resolved.preset, p, false, Size::new(100.0, 100.0));
for (label, l) in [("entering", entering), ("leaving", leaving)] {
assert_eq!((l.dx, l.dy), (0.0, 0.0), "{label} slid at p={p}");
assert_eq!(l.scale, 1.0, "{label} scaled at p={p}");
}
}
}
let none = resolve_spec(TransitionSpec::NONE, Some(&m));
assert_eq!(none.preset, PageTransition::None);
}
fn custom_vertical_slide(p: f64, is_pop: bool, size: Size) -> (Layer, Layer) {
let dir = if is_pop { -1.0 } else { 1.0 };
let entering = Layer {
dx: 0.0,
dy: dir * (1.0 - p) * size.height,
alpha: 1.0,
scale: 1.0,
};
let leaving = Layer::IDENTITY;
(entering, leaving)
}
#[test]
fn custom_preset_drives_caller_supplied_layers() {
let (enter, leave) = resolve_layers(
PageTransition::Custom(custom_vertical_slide),
0.5,
false,
SIZE,
);
let (expected_enter, expected_leave) = custom_vertical_slide(0.5, false, SIZE);
assert_eq!(enter, expected_enter);
assert_eq!(leave, expected_leave);
assert_eq!(enter.dy, 0.5 * SIZE.height);
assert_eq!(leave, Layer::IDENTITY);
let (enter, _leave) = resolve_layers(
PageTransition::Custom(custom_vertical_slide),
1.2,
false,
SIZE,
);
assert!((enter.dy - (-0.2 * SIZE.height)).abs() < 1e-9);
}
#[test]
fn custom_preset_falls_back_to_m3_timing_under_theme_default() {
let expected = Timing::Duration(M3_DEFAULT_DURATION, Curve::Emphasized);
let (enter, exit) = preset_enter_exit(PageTransition::Custom(custom_vertical_slide), None);
assert_eq!(enter, expected);
assert_eq!(exit, expected);
let m = MotionScheme::neutral();
let (enter, exit) =
preset_enter_exit(PageTransition::Custom(custom_vertical_slide), Some(&m));
assert_eq!(enter, expected);
assert_eq!(exit, expected);
let resolved = resolve_timing(
Timing::ThemeDefault,
PageTransition::Custom(custom_vertical_slide),
Some(&m),
);
assert_eq!(resolved, expected);
}
#[test]
fn custom_preset_collapses_under_reduce_motion_on_the_resolve_spec_path() {
fn panics_if_called(_p: f64, _is_pop: bool, _size: Size) -> (Layer, Layer) {
panic!("Custom's function must not be invoked on the resolve_spec path");
}
let mut m = MotionScheme::neutral();
m.reduce_motion = true;
let resolved = resolve_spec(
TransitionSpec::themed(PageTransition::Custom(panics_if_called)),
Some(&m),
);
assert_eq!(resolved.preset, PageTransition::ReducedCrossfade);
let Timing::Duration(d, curve) = resolved.timing else {
panic!("reduced-motion must be a duration crossfade");
};
assert!(d <= Duration::from_millis(120));
assert_eq!(curve, Curve::Linear);
let (entering, leaving) = resolve_layers(resolved.preset, 0.5, false, SIZE);
assert_eq!((entering.dx, entering.dy), (0.0, 0.0));
assert_eq!((leaving.dx, leaving.dy), (0.0, 0.0));
}
#[test]
fn make_driver_theme_default_falls_back_when_unresolved() {
let (mut driver, _) = make_driver(Timing::ThemeDefault);
let a = driver.advance(ft_secs(0.0));
assert!(a.animating && !a.done);
let a = driver.advance(ft_secs(1.0));
assert!(a.done);
assert_eq!(a.value, 1.0);
}
#[test]
fn slide_up_enters_from_bottom_and_settles() {
let (enter, leave) = resolve_layers(PageTransition::SlideUp, 0.0, false, SIZE);
assert_eq!(enter.dx, 0.0);
assert_eq!(enter.dy, SIZE.height);
assert_eq!(enter.alpha, 1.0);
assert_eq!(leave.dx, 0.0);
assert_eq!(leave.dy, 0.0);
assert_eq!(leave.alpha, 1.0);
let (enter, leave) = resolve_layers(PageTransition::SlideUp, 1.0, false, SIZE);
assert_eq!(enter.dy, 0.0);
assert_eq!(enter.alpha, 1.0);
assert_eq!(leave.dy, 0.0);
assert_eq!(leave.alpha, 1.0);
}
#[test]
fn slide_up_pop_reverses_and_never_moves_below_page() {
let (enter, leave) = resolve_layers(PageTransition::SlideUp, 0.0, true, SIZE);
assert_eq!(enter.dy, 0.0);
assert_eq!(leave.dy, 0.0);
let (enter, leave) = resolve_layers(PageTransition::SlideUp, 1.0, true, SIZE);
assert_eq!(enter.dy, 0.0, "revealed page never moves");
assert_eq!(
leave.dy, SIZE.height,
"the sheet slides fully off the bottom"
);
assert_eq!(enter.alpha, 1.0);
assert_eq!(leave.alpha, 1.0, "SlideUp never fades a page's own layer");
}
#[test]
fn duration_driver_runs_zero_to_one_and_settles() {
let (mut driver, _) =
make_driver(Timing::Duration(Duration::from_millis(100), Curve::Linear));
let a = driver.advance(ft_secs(0.0));
assert!(a.animating && !a.done);
assert!((a.value - 0.0).abs() < 1e-9);
let a = driver.advance(ft_secs(0.05));
assert!((a.value - 0.5).abs() < 1e-6);
let a = driver.advance(ft_secs(0.2));
assert!(!a.animating && a.done);
assert_eq!(a.value, 1.0);
}
#[test]
fn spring_spatial_driver_overshoots_past_one() {
let spring = MotionSpring {
damping_ratio: 0.9,
stiffness: 700.0,
};
let (mut driver, _) = make_driver(Timing::Spring(spring));
let mut max = f64::MIN;
let mut t = 0.0;
for _ in 0..100_000 {
let a = driver.advance(ft_secs(t));
max = max.max(a.value);
if a.done {
break;
}
t += 1.0 / 120.0;
}
assert!(
max > 1.0 + 1e-3,
"spatial spring should overshoot past 1.0, got {max}"
);
}
#[test]
fn spring_effects_driver_never_overshoots() {
let spring = MotionSpring {
damping_ratio: 1.0,
stiffness: 1600.0,
};
let (mut driver, _) = make_driver(Timing::Spring(spring));
let mut t = 0.0;
for _ in 0..100_000 {
let a = driver.advance(ft_secs(t));
assert!(
a.value <= 1.0 + 1e-9,
"effects spring overshot: {}",
a.value
);
if a.done {
break;
}
t += 1.0 / 120.0;
}
}
#[test]
fn held_driver_pauses_without_finishing() {
let mut driver = TransitionDriver::Held { value: 0.4 };
let a = driver.advance(ft_secs(1.0));
assert_eq!(a.value, 0.4);
assert!(!a.animating);
assert!(!a.done, "a held driver never reports done (drag paused)");
}
#[test]
fn lerp_rect_interpolates_origin_and_size_independently() {
let from = Rect::from_origin_size(Point::new(0.0, 0.0), Size::new(20.0, 20.0));
let to = Rect::from_origin_size(Point::new(100.0, 40.0), Size::new(200.0, 80.0));
assert_eq!(lerp_rect(from, to, 0.0), from);
assert_eq!(lerp_rect(from, to, 1.0), to);
let mid = lerp_rect(from, to, 0.5);
assert_eq!(mid.origin(), Point::new(50.0, 20.0));
assert_eq!(mid.size(), Size::new(110.0, 50.0));
}
#[test]
fn rect_to_rect_maps_corners_exactly() {
let from = Rect::from_origin_size(Point::new(10.0, 20.0), Size::new(20.0, 20.0));
let to = Rect::from_origin_size(Point::new(100.0, 200.0), Size::new(80.0, 40.0));
let t = rect_to_rect(from, to);
let tl = t * Point::new(from.x0, from.y0);
let br = t * Point::new(from.x1, from.y1);
assert!((tl.x - to.x0).abs() < 1e-9 && (tl.y - to.y0).abs() < 1e-9);
assert!((br.x - to.x1).abs() < 1e-9 && (br.y - to.y1).abs() < 1e-9);
}
#[test]
fn rect_to_rect_zero_extent_source_is_identity_scale() {
let from = Rect::from_origin_size(Point::new(5.0, 5.0), Size::new(0.0, 0.0));
let to = Rect::from_origin_size(Point::new(9.0, 12.0), Size::new(0.0, 0.0));
let t = rect_to_rect(from, to);
let mapped = t * Point::new(5.0, 5.0);
assert!((mapped.x - 9.0).abs() < 1e-9 && (mapped.y - 12.0).abs() < 1e-9);
assert!(t.as_coeffs()[0].is_finite() && t.as_coeffs()[3].is_finite());
}
#[test]
fn settle_driver_springs_to_target() {
let mut driver = settle_driver(DEFAULT_SETTLE_SPRING, 0.6, 0.0, 1.0);
let mut t = 0.0;
let mut done = false;
for _ in 0..100_000 {
let a = driver.advance(ft_secs(t));
if a.done {
done = true;
assert_eq!(a.value, 1.0);
break;
}
t += 1.0 / 120.0;
}
assert!(done, "settle driver failed to reach its target");
}
}