use std::time::Duration;
use frust_core::Curve;
use frust_core::anim::Lerp;
use kurbo::{Point, Rect, Size};
const FADE_THROUGH_SPLIT: f64 = 0.30;
const FADE_THROUGH_OUT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
const FADE_THROUGH_IN_CURVE: Curve = Curve::Cubic(0.0, 0.0, 0.2, 1.0);
const FADE_THROUGH_SCALE_START: f64 = 0.92;
const SHARED_AXIS_SLIDE_DP: f64 = 30.0;
const SHARED_AXIS_SCALE_IN_START: f64 = 0.80;
const SHARED_AXIS_SCALE_OUT_END: f64 = 1.10;
const FADE_SCALE_FADE_END: f64 = 0.30;
const FADE_SCALE_SCALE_START: f64 = 0.80;
const FADE_SCALE_SCALE_CURVE: Curve = Curve::Cubic(0.0, 0.0, 0.2, 1.0);
pub const FADE_SCALE_FORWARD: Duration = Duration::from_millis(150);
pub const FADE_SCALE_REVERSE: Duration = Duration::from_millis(75);
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PatternLayer {
pub dx: f64,
pub dy: f64,
pub alpha: f32,
pub scale: f64,
}
impl PatternLayer {
pub const IDENTITY: PatternLayer = PatternLayer {
dx: 0.0,
dy: 0.0,
alpha: 1.0,
scale: 1.0,
};
}
pub trait TransitionPattern {
fn resolve(&self, p: f64, reverse: bool, size: Size) -> (PatternLayer, PatternLayer);
}
const _: fn() = || {
let _: Option<&dyn TransitionPattern> = None;
};
fn fade_through_progress(pc: f64) -> (f64, f64) {
let out = FADE_THROUGH_OUT_CURVE
.interval(0.0, FADE_THROUGH_SPLIT)
.transform(pc);
let inc = FADE_THROUGH_IN_CURVE
.interval(FADE_THROUGH_SPLIT, 1.0)
.transform(pc);
(inc, out)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FadeThrough;
impl TransitionPattern for FadeThrough {
fn resolve(&self, p: f64, _reverse: bool, _size: Size) -> (PatternLayer, PatternLayer) {
let pc = p.clamp(0.0, 1.0);
let (inc, out) = fade_through_progress(pc);
let incoming = PatternLayer {
dx: 0.0,
dy: 0.0,
alpha: inc as f32,
scale: FADE_THROUGH_SCALE_START + (1.0 - FADE_THROUGH_SCALE_START) * inc,
};
let exiting = PatternLayer {
dx: 0.0,
dy: 0.0,
alpha: (1.0 - out) as f32,
scale: 1.0,
};
(incoming, exiting)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SharedAxis {
X,
Y,
Scaled,
}
impl TransitionPattern for SharedAxis {
fn resolve(&self, p: f64, reverse: bool, _size: Size) -> (PatternLayer, PatternLayer) {
let pc = p.clamp(0.0, 1.0);
let (inc, out) = fade_through_progress(pc);
let dir = if reverse { -1.0 } else { 1.0 };
let slide_in = dir * (1.0 - p) * SHARED_AXIS_SLIDE_DP;
let slide_out = -dir * p * SHARED_AXIS_SLIDE_DP;
let inc_alpha = inc as f32;
let out_alpha = (1.0 - out) as f32;
match self {
SharedAxis::X => (
PatternLayer {
dx: slide_in,
dy: 0.0,
alpha: inc_alpha,
scale: 1.0,
},
PatternLayer {
dx: slide_out,
dy: 0.0,
alpha: out_alpha,
scale: 1.0,
},
),
SharedAxis::Y => (
PatternLayer {
dx: 0.0,
dy: slide_in,
alpha: inc_alpha,
scale: 1.0,
},
PatternLayer {
dx: 0.0,
dy: slide_out,
alpha: out_alpha,
scale: 1.0,
},
),
SharedAxis::Scaled => (
PatternLayer {
dx: 0.0,
dy: 0.0,
alpha: inc_alpha,
scale: SHARED_AXIS_SCALE_IN_START + (1.0 - SHARED_AXIS_SCALE_IN_START) * inc,
},
PatternLayer {
dx: 0.0,
dy: 0.0,
alpha: out_alpha,
scale: 1.0 + (SHARED_AXIS_SCALE_OUT_END - 1.0) * out,
},
),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FadeScale;
impl TransitionPattern for FadeScale {
fn resolve(&self, p: f64, _reverse: bool, _size: Size) -> (PatternLayer, PatternLayer) {
let pc = p.clamp(0.0, 1.0);
let fade = Curve::Linear
.interval(0.0, FADE_SCALE_FADE_END)
.transform(pc);
let scale_prog = FADE_SCALE_SCALE_CURVE.transform(pc);
let incoming = PatternLayer {
dx: 0.0,
dy: 0.0,
alpha: fade as f32,
scale: FADE_SCALE_SCALE_START + (1.0 - FADE_SCALE_SCALE_START) * scale_prog,
};
let exiting = PatternLayer {
dx: 0.0,
dy: 0.0,
alpha: (1.0 - fade) as f32,
scale: 1.0,
};
(incoming, exiting)
}
}
pub const CONTAINER_TRANSFORM_DURATION: Duration = Duration::from_millis(300);
const CONTAINER_TRANSFORM_MORPH_CURVE: Curve = Curve::Cubic(0.4, 0.0, 0.2, 1.0);
const CONTAINER_TRANSFORM_FADE_CURVE: Curve = Curve::Cubic(0.0, 0.0, 0.2, 1.0);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ContainerFade {
#[default]
FadeThrough,
Fade,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ContainerTransform {
pub source: Rect,
pub target: Rect,
pub fade: ContainerFade,
}
impl ContainerTransform {
pub const fn new(source: Rect, target: Rect) -> Self {
Self {
source,
target,
fade: ContainerFade::FadeThrough,
}
}
pub const fn from_source(source: Rect) -> Self {
Self::new(source, Rect::ZERO)
}
pub const fn with_fade(mut self, fade: ContainerFade) -> Self {
self.fade = fade;
self
}
pub fn morph_rect(&self, p: f64, reverse: bool) -> Rect {
morph_between(self.source, self.target, p, reverse)
}
fn fade_alphas(&self, pc: f64) -> (f64, f64) {
match self.fade {
ContainerFade::FadeThrough => {
let (inc, out) = fade_through_progress(pc);
(inc, 1.0 - out)
}
ContainerFade::Fade => {
let f = CONTAINER_TRANSFORM_FADE_CURVE.transform(pc);
(f, 1.0 - f)
}
}
}
}
fn morph_between(source: Rect, target: Rect, p: f64, reverse: bool) -> Rect {
let pe = CONTAINER_TRANSFORM_MORPH_CURVE.transform(p);
let (from, to) = if reverse {
(target, source)
} else {
(source, target)
};
from.lerp(&to, pe)
}
impl TransitionPattern for ContainerTransform {
fn resolve(&self, p: f64, reverse: bool, size: Size) -> (PatternLayer, PatternLayer) {
let pc = p.clamp(0.0, 1.0);
let target = if self.target.width() > 0.0 && self.target.height() > 0.0 {
self.target
} else {
Rect::from_origin_size(Point::ZERO, size)
};
let morph = morph_between(self.source, target, p, reverse);
let scale = if target.width() > 0.0 {
morph.width() / target.width()
} else {
1.0
};
let dx = morph.center().x - target.center().x;
let dy = morph.center().y - target.center().y;
let (in_a, out_a) = self.fade_alphas(pc);
let incoming = PatternLayer {
dx,
dy,
alpha: in_a as f32,
scale,
};
let exiting = PatternLayer {
alpha: out_a as f32,
..PatternLayer::IDENTITY
};
(incoming, exiting)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SIZE: Size = Size::new(400.0, 800.0);
#[test]
fn fade_through_staged_opacity_and_scale_table() {
let ft = FadeThrough;
let (inc, out) = ft.resolve(0.0, false, SIZE);
assert_eq!(out.alpha, 1.0);
assert_eq!(out.scale, 1.0);
assert_eq!(inc.alpha, 0.0);
assert!((inc.scale - 0.92).abs() < 1e-9);
let (inc, out) = ft.resolve(0.30, false, SIZE);
assert!(out.alpha.abs() < 1e-6, "outgoing gone by the split");
assert_eq!(inc.alpha, 0.0, "incoming fade-in opens at the split");
assert!((inc.scale - 0.92).abs() < 1e-9);
let (inc, out) = ft.resolve(0.50, false, SIZE);
assert_eq!(out.alpha, 0.0);
let eased = Curve::Cubic(0.0, 0.0, 0.2, 1.0).transform((0.50 - 0.30) / 0.70);
assert!((inc.alpha as f64 - eased).abs() < 1e-6);
assert!((inc.scale - (0.92 + 0.08 * eased)).abs() < 1e-9);
let (inc, out) = ft.resolve(1.0, false, SIZE);
assert_eq!(inc.alpha, 1.0);
assert!((inc.scale - 1.0).abs() < 1e-9);
assert_eq!(out.alpha, 0.0);
assert_eq!(out.scale, 1.0);
}
#[test]
fn fade_through_is_non_directional_reverse_equals_forward() {
for t in [0.0, 0.3, 0.5, 1.0] {
assert_eq!(
FadeThrough.resolve(t, false, SIZE),
FadeThrough.resolve(t, true, SIZE),
"fade-through has no direction: reverse must match forward at t={t}"
);
}
}
#[test]
fn shared_axis_x_slides_and_crossfades_forward_and_reverse() {
let sx = SharedAxis::X;
let (inc, out) = sx.resolve(0.0, false, SIZE);
assert_eq!(inc.dx, SHARED_AXIS_SLIDE_DP);
assert_eq!(inc.alpha, 0.0);
assert_eq!(out.dx, 0.0);
assert_eq!(out.alpha, 1.0);
let (inc, out) = sx.resolve(1.0, false, SIZE);
assert_eq!(inc.dx, 0.0);
assert_eq!(inc.alpha, 1.0);
assert_eq!(out.dx, -SHARED_AXIS_SLIDE_DP);
assert_eq!(out.alpha, 0.0);
let (inc, _out) = sx.resolve(0.0, true, SIZE);
assert_eq!(inc.dx, -SHARED_AXIS_SLIDE_DP);
let (inc, out) = sx.resolve(0.30, false, SIZE);
assert!(out.alpha.abs() < 1e-6);
assert_eq!(inc.alpha, 0.0);
let (fi, _fo) = SharedAxis::X.resolve(0.5, false, SIZE);
let (fti, _fto) = FadeThrough.resolve(0.5, false, SIZE);
assert!((fi.alpha - fti.alpha).abs() < 1e-9);
}
#[test]
fn shared_axis_y_slides_vertically_only() {
let (inc, out) = SharedAxis::Y.resolve(0.0, false, SIZE);
assert_eq!(inc.dx, 0.0);
assert_eq!(inc.dy, SHARED_AXIS_SLIDE_DP);
assert_eq!(out.dy, 0.0);
let (inc, out) = SharedAxis::Y.resolve(1.0, false, SIZE);
assert_eq!(inc.dy, 0.0);
assert_eq!(out.dy, -SHARED_AXIS_SLIDE_DP);
}
#[test]
fn shared_axis_scaled_scales_without_sliding() {
let s = SharedAxis::Scaled;
let (inc, out) = s.resolve(0.0, false, SIZE);
assert_eq!(inc.dx, 0.0);
assert_eq!(inc.dy, 0.0);
assert!((inc.scale - SHARED_AXIS_SCALE_IN_START).abs() < 1e-9);
assert_eq!(inc.alpha, 0.0);
assert_eq!(out.scale, 1.0);
assert_eq!(out.alpha, 1.0);
let (inc, out) = s.resolve(1.0, false, SIZE);
assert!((inc.scale - 1.0).abs() < 1e-9);
assert_eq!(inc.alpha, 1.0);
assert!((out.scale - SHARED_AXIS_SCALE_OUT_END).abs() < 1e-9);
assert_eq!(out.alpha, 0.0);
}
#[test]
fn fade_scale_incoming_fades_early_scales_full_exiting_fades_only() {
let fs = FadeScale;
let (inc, out) = fs.resolve(0.0, false, SIZE);
assert_eq!(inc.alpha, 0.0);
assert!((inc.scale - FADE_SCALE_SCALE_START).abs() < 1e-9);
assert_eq!(out.alpha, 1.0);
assert_eq!(out.scale, 1.0);
let (inc, out) = fs.resolve(0.30, false, SIZE);
assert!((inc.alpha - 1.0).abs() < 1e-6, "fade completes by 0.30");
assert!(out.alpha.abs() < 1e-6);
let scale_at_30 = FADE_SCALE_SCALE_START
+ (1.0 - FADE_SCALE_SCALE_START) * FADE_SCALE_SCALE_CURVE.transform(0.30);
assert!((inc.scale - scale_at_30).abs() < 1e-9);
assert!(inc.scale < 1.0, "scale is still animating at t=0.30");
let (inc, _out) = fs.resolve(0.50, false, SIZE);
assert!((inc.alpha - 1.0).abs() < 1e-6);
let scale_at_50 = FADE_SCALE_SCALE_START
+ (1.0 - FADE_SCALE_SCALE_START) * FADE_SCALE_SCALE_CURVE.transform(0.50);
assert!((inc.scale - scale_at_50).abs() < 1e-9);
let (inc, out) = fs.resolve(1.0, false, SIZE);
assert!((inc.alpha - 1.0).abs() < 1e-6);
assert!((inc.scale - 1.0).abs() < 1e-9);
assert_eq!(out.alpha, 0.0);
assert_eq!(
out.scale, 1.0,
"the exiting child never scales (fades only)"
);
}
#[test]
fn fade_scale_reverse_is_geometrically_identical() {
for t in [0.0, 0.3, 0.5, 1.0] {
assert_eq!(
FadeScale.resolve(t, false, SIZE),
FadeScale.resolve(t, true, SIZE),
"fade-scale geometry is direction-independent at t={t}"
);
}
}
#[test]
fn forward_default_durations_encode_exits_faster_than_entrances() {
assert_eq!(FADE_SCALE_FORWARD, Duration::from_millis(150));
assert_eq!(FADE_SCALE_REVERSE, Duration::from_millis(75));
assert!(
FADE_SCALE_REVERSE < FADE_SCALE_FORWARD,
"exits always faster than entrances"
);
}
const SRC: Rect = Rect::new(100.0, 200.0, 200.0, 400.0);
#[test]
fn container_transform_rect_interpolation_at_fixed_t() {
let ct = ContainerTransform::new(SRC, Rect::from_origin_size(Point::ZERO, SIZE));
let target = Rect::from_origin_size(Point::ZERO, SIZE);
let r0 = ct.morph_rect(0.0, false);
assert!((r0.x0 - SRC.x0).abs() < 1e-9 && (r0.y0 - SRC.y0).abs() < 1e-9);
assert!((r0.width() - SRC.width()).abs() < 1e-9);
let r1 = ct.morph_rect(1.0, false);
assert!((r1.width() - SIZE.width).abs() < 1e-9);
assert!((r1.height() - SIZE.height).abs() < 1e-9);
let pe = CONTAINER_TRANSFORM_MORPH_CURVE.transform(0.5);
let r5 = ct.morph_rect(0.5, false);
let expect = SRC.lerp(&target, pe);
assert!((r5.x0 - expect.x0).abs() < 1e-9);
assert!((r5.width() - expect.width()).abs() < 1e-9);
let rr = ct.morph_rect(0.0, true);
assert!(
(rr.width() - SIZE.width).abs() < 1e-9,
"reverse starts at target"
);
}
#[test]
fn container_transform_zero_area_target_resolves_to_full_container() {
let ct = ContainerTransform::from_source(SRC);
let (inc, _out) = ct.resolve(1.0, false, SIZE);
assert!((inc.scale - 1.0).abs() < 1e-9);
assert!(inc.dx.abs() < 1e-9 && inc.dy.abs() < 1e-9);
let (inc0, _out0) = ct.resolve(0.0, false, SIZE);
assert!((inc0.scale - SRC.width() / SIZE.width).abs() < 1e-9);
let full = Rect::from_origin_size(Point::ZERO, SIZE);
assert!((inc0.dx - (SRC.center().x - full.center().x)).abs() < 1e-9);
assert!((inc0.dy - (SRC.center().y - full.center().y)).abs() < 1e-9);
}
#[test]
fn container_transform_fade_through_opacity_staging() {
let ct = ContainerTransform::from_source(SRC); let (inc, out) = ct.resolve(0.0, false, SIZE);
assert_eq!(out.alpha, 1.0);
assert_eq!(inc.alpha, 0.0);
let (inc, out) = ct.resolve(FADE_THROUGH_SPLIT, false, SIZE);
assert!(out.alpha.abs() < 1e-6);
assert_eq!(inc.alpha, 0.0);
let (inc, out) = ct.resolve(1.0, false, SIZE);
assert!((inc.alpha - 1.0).abs() < 1e-6);
assert_eq!(out.alpha, 0.0);
}
#[test]
fn container_transform_plain_fade_variant_cross_fades_simultaneously() {
let ct = ContainerTransform::from_source(SRC).with_fade(ContainerFade::Fade);
for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
let (inc, out) = ct.resolve(t, false, SIZE);
assert!(
(inc.alpha + out.alpha - 1.0).abs() < 1e-6,
"plain fade cross-fades (alphas sum to 1) at t={t}"
);
}
}
#[test]
fn transition_patterns_reduce_motion_collapse_target_removes_motion() {
let (ft_in, _) = FadeThrough.resolve(0.5, false, SIZE);
assert_eq!(ft_in.dx, 0.0);
assert_eq!(ft_in.dy, 0.0);
let (axis_in, _) = SharedAxis::X.resolve(0.5, false, SIZE);
assert!(
axis_in.dx.abs() > 0.0,
"SharedAxis offsets where the collapse does not"
);
let (ct_in, _) = ContainerTransform::from_source(SRC).resolve(0.5, false, SIZE);
assert!(
(ct_in.scale - 1.0).abs() > 1e-6 || ct_in.dx.abs() > 0.0 || ct_in.dy.abs() > 0.0,
"ContainerTransform morphs where the collapse does not"
);
}
}