use std::collections::HashMap;
use std::time::Duration;
use crate::anim::{Animatable, Animated, Easing, Motion, SpringParams};
use crate::core::element::{Element, ElementKind};
use crate::core::interact::ElementKey;
use crate::geometry::{Insets, Transform};
use crate::render::Color;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Transition(pub Motion);
impl Transition {
pub const fn tween(duration: Duration, easing: Easing) -> Self {
Self(Motion::Tween {
duration: duration.as_secs_f64(),
easing,
})
}
pub const fn ease(duration: Duration) -> Self {
Self::tween(duration, Easing::EaseOut)
}
pub const fn spring(params: SpringParams) -> Self {
Self(Motion::Spring(params))
}
pub const fn instant() -> Self {
Self(Motion::Instant)
}
}
impl Default for Transition {
fn default() -> Self {
Self::ease(Duration::from_millis(150))
}
}
impl From<Motion> for Transition {
fn from(motion: Motion) -> Self {
Self(motion)
}
}
impl From<SpringParams> for Transition {
fn from(params: SpringParams) -> Self {
Self::spring(params)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum AnimProp {
Fill,
Border,
Radius,
Padding,
Width,
Height,
TextColor,
Opacity,
Transform,
}
impl AnimProp {
pub const ALL: [AnimProp; 9] = [
AnimProp::Fill,
AnimProp::Border,
AnimProp::Radius,
AnimProp::Padding,
AnimProp::Width,
AnimProp::Height,
AnimProp::TextColor,
AnimProp::Opacity,
AnimProp::Transform,
];
}
type Track<T> = Option<Animated<T>>;
fn drive<T: Animatable>(
track: &mut Option<Track<T>>,
target: Option<T>,
absent: Option<T>,
motion: Motion,
now: Option<Duration>,
head: Duration,
) -> Option<T> {
let (Some(now), Some(slot)) = (now, track.as_mut()) else {
*track = Some(target.map(Animated::new));
return target;
};
match (target, slot.as_mut()) {
(Some(v), Some(anim)) => {
anim.animate_to(v, motion, head);
Some(anim.value(now))
}
(Some(v), None) => {
let mut anim = Animated::new(absent.unwrap_or(v));
anim.animate_to(v, motion, head);
let value = anim.value(now);
*slot = Some(anim);
Some(value)
}
(None, Some(anim)) => match absent {
Some(a) => {
anim.animate_to(a, motion, head);
if anim.is_animating(now) {
Some(anim.value(now))
} else {
*slot = None;
None
}
}
None => {
*slot = None;
None
}
},
(None, None) => None,
}
}
fn moving<T: Animatable>(track: &Option<Track<T>>, now: Duration) -> bool {
matches!(track, Some(Some(a)) if a.is_animating(now))
}
#[derive(Clone, Debug, Default)]
struct Tracks {
fill: Option<Track<Color>>,
border: Option<Track<(Color, f64)>>,
radius: Option<Track<f64>>,
padding: Option<Track<Insets>>,
width: Option<Track<f64>>,
height: Option<Track<f64>>,
text_color: Option<Track<Color>>,
opacity: Option<Track<f64>>,
transform: Option<Track<Transform>>,
last: Option<Duration>,
seen: bool,
}
impl Tracks {
fn animate(
&mut self,
el: &mut Element,
motion_of: impl Fn(AnimProp) -> Option<Motion>,
now: Option<Duration>,
head: Duration,
) {
macro_rules! go {
($prop:expr, $field:ident, $target:expr, $absent:expr) => {
match motion_of($prop) {
Some(m) => Some(drive(&mut self.$field, $target, $absent, m, now, head)),
None => {
self.$field = None;
None
}
}
};
}
let fill = el.decoration.fill;
if let Some(v) = go!(AnimProp::Fill, fill, fill, Some(Color::TRANSPARENT)) {
el.decoration.fill = v;
}
let border = el.decoration.border;
let gone = Some((Color::TRANSPARENT, 0.0));
if let Some(v) = go!(AnimProp::Border, border, border, gone) {
el.decoration.border = v.map(|(c, w)| (c, w.max(0.0)));
}
let radius = Some(el.decoration.radius);
if let Some(Some(v)) = go!(AnimProp::Radius, radius, radius, None) {
el.decoration.radius = v.max(0.0);
}
let padding = Some(el.layout.padding);
if let Some(Some(v)) = go!(AnimProp::Padding, padding, padding, None) {
el.layout.padding = v;
}
let width = el.layout.size.width;
if let Some(v) = go!(AnimProp::Width, width, width, None) {
el.layout.size.width = v.map(|w| w.max(0.0));
}
let height = el.layout.size.height;
if let Some(v) = go!(AnimProp::Height, height, height, None) {
el.layout.size.height = v.map(|h| h.max(0.0));
}
let text = match &el.kind {
ElementKind::Text { color, .. } => Some(*color),
_ => None,
};
if let (Some(Some(v)), ElementKind::Text { color, .. }) = (
go!(AnimProp::TextColor, text_color, text, None),
&mut el.kind,
) {
*color = v;
}
let opacity = Some(el.opacity as f64);
if let Some(Some(v)) = go!(AnimProp::Opacity, opacity, opacity, None) {
el.opacity = v.clamp(0.0, 1.0) as f32;
}
let transform = Some(el.transform.unwrap_or(Transform::IDENTITY));
if let Some(Some(t)) = go!(AnimProp::Transform, transform, transform, None) {
el.transform = (!t.is_identity()).then_some(t);
}
}
fn moving(&self, now: Duration) -> bool {
moving(&self.fill, now)
|| moving(&self.border, now)
|| moving(&self.radius, now)
|| moving(&self.padding, now)
|| moving(&self.width, now)
|| moving(&self.height, now)
|| moving(&self.text_color, now)
|| moving(&self.opacity, now)
|| moving(&self.transform, now)
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Transitions {
entries: HashMap<ElementKey, Tracks>,
now: Option<Duration>,
}
impl Transitions {
pub(crate) fn begin(&mut self, now: Option<Duration>) {
self.now = now;
for e in self.entries.values_mut() {
e.seen = false;
}
}
pub(crate) fn end(&mut self) {
self.entries.retain(|_, e| e.seen);
}
pub(crate) fn animate(
&mut self,
id: ElementKey,
el: &mut Element,
specs: &[(Option<AnimProp>, Transition)],
) {
let now = self.now;
let entry = self.entries.entry(id).or_default();
entry.seen = true;
let head = now.map_or(Duration::ZERO, |now| {
let frame = now.saturating_sub(crate::platform::frame::interval());
entry.last.map_or(frame, |last| frame.max(last.min(now)))
});
let motion_of = |prop: AnimProp| {
specs
.iter()
.rev()
.find(|(p, _)| p.is_none_or(|p| p == prop))
.map(|(_, t)| t.0)
};
entry.animate(el, motion_of, now, head);
entry.last = now;
}
pub(crate) fn animating(&self) -> bool {
self.now
.is_some_and(|now| self.entries.values().any(|e| e.moving(now)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::Axis;
const MS: Duration = Duration::from_millis(1);
fn frame() -> Duration {
crate::platform::frame::interval()
}
fn boxed(fill: Color, pad: f64) -> Element {
Element::stack(Axis::Vertical, vec![])
.fill(fill)
.padding(Insets::uniform(pad))
}
const LINEAR: [(Option<AnimProp>, Transition); 1] = [(
None,
Transition::tween(Duration::from_millis(100), Easing::Linear),
)];
fn run(
tr: &mut Transitions,
specs: &[(Option<AnimProp>, Transition)],
mut el: Element,
now: Duration,
) -> Element {
tr.begin(Some(now));
tr.animate(ElementKey(1), &mut el, specs);
tr.end();
el
}
fn step(tr: &mut Transitions, el: Element, now: Duration) -> Element {
run(tr, &LINEAR, el, now)
}
#[test]
fn a_change_tweens_and_settles() {
let mut tr = Transitions::default();
let t0 = Duration::from_secs(1);
let first = step(&mut tr, boxed(Color::rgb(0, 0, 0), 10.0), t0);
assert_eq!(first.layout.padding.top, 10.0, "first sight: no motion");
assert!(!tr.animating(), "at rest: no frames");
let t1 = t0 + 500 * MS;
let a = step(&mut tr, boxed(Color::rgb(0, 0, 0), 20.0), t1);
let one = frame().as_secs_f64() / 0.1;
assert!((a.layout.padding.top - (10.0 + 10.0 * one)).abs() < 1e-9);
assert!(tr.animating());
let mid_t = t1 + 50 * MS - frame();
let mid = step(&mut tr, boxed(Color::rgb(0, 0, 0), 20.0), mid_t);
assert!((mid.layout.padding.top - 15.0).abs() < 1e-9, "{mid:?}");
let end_t = t1 + 100 * MS - frame();
let end = step(&mut tr, boxed(Color::rgb(0, 0, 0), 20.0), end_t);
assert_eq!(end.layout.padding.top, 20.0);
assert!(!tr.animating(), "settled: no more frames");
}
#[test]
fn colors_interpolate() {
let mut tr = Transitions::default();
let t0 = Duration::from_secs(1);
step(&mut tr, boxed(Color::rgb(0, 0, 0), 0.0), t0);
let t1 = t0 + frame();
step(&mut tr, boxed(Color::rgb(200, 100, 0), 0.0), t1);
let mid = step(
&mut tr,
boxed(Color::rgb(200, 100, 0), 0.0),
t1 + 50 * MS - frame(),
);
assert_eq!(mid.decoration.fill, Some(Color::rgb(100, 50, 0)));
}
#[test]
fn retargeting_mid_flight_continues_from_the_current_value() {
let mut tr = Transitions::default();
let t0 = Duration::from_secs(1);
step(&mut tr, boxed(Color::rgb(0, 0, 0), 0.0), t0);
let t1 = t0 + frame();
step(&mut tr, boxed(Color::rgb(0, 0, 0), 100.0), t1);
let t2 = t1 + 50 * MS - frame();
let mid = step(&mut tr, boxed(Color::rgb(0, 0, 0), 100.0), t2);
assert!((mid.layout.padding.top - 50.0).abs() < 1e-9);
let t3 = t2 + frame();
let back = step(&mut tr, boxed(Color::rgb(0, 0, 0), 0.0), t3);
let p = back.layout.padding.top;
assert!((p - 50.0).abs() < 20.0, "{p}");
assert!(tr.animating());
let done = step(&mut tr, boxed(Color::rgb(0, 0, 0), 0.0), t3 + 100 * MS);
assert_eq!(done.layout.padding.top, 0.0);
assert!(!tr.animating());
}
#[test]
fn a_spring_keeps_its_velocity_when_retargeted() {
let spring = [(None, Transition::spring(SpringParams::new(200.0, 0.5)))];
let mut tr = Transitions::default();
let pad = |tr: &mut Transitions, p: f64, now: Duration| {
run(tr, &spring, boxed(Color::rgb(0, 0, 0), p), now)
.layout
.padding
.top
};
let t0 = Duration::from_secs(1);
pad(&mut tr, 0.0, t0);
let mut t = t0 + frame();
let mut last = pad(&mut tr, 10.0, t);
assert!(last > 0.0 && last < 10.0);
let mut peak = last;
for _ in 0..30 {
t += frame();
last = pad(&mut tr, 10.0, t);
peak = peak.max(last);
}
assert!(peak > 10.0, "overshoots: {peak}");
let before = last;
t += frame();
let after = pad(&mut tr, 0.0, t);
assert!((after - before).abs() < 2.0, "{before} -> {after}");
for _ in 0..600 {
t += frame();
last = pad(&mut tr, 0.0, t);
if !tr.animating() {
break;
}
}
assert!(!tr.animating(), "the spring comes to rest");
assert!(last.abs() < 0.01, "{last}");
}
#[test]
fn a_fill_fades_in_and_out() {
let mut tr = Transitions::default();
let t0 = Duration::from_secs(1);
let bare = Element::stack(Axis::Vertical, vec![]);
step(&mut tr, bare.clone(), t0);
let t1 = t0 + frame();
let red = bare.clone().fill(Color::rgb(255, 0, 0));
let a = step(&mut tr, red.clone(), t1);
let fill = a.decoration.fill.expect("fading in");
assert_eq!((fill.r, fill.g, fill.b), (255, 0, 0), "keeps its hue");
assert!(fill.a > 0 && fill.a < 255, "{fill:?}");
step(&mut tr, red, t1 + 200 * MS);
let t2 = t1 + 300 * MS;
let out = step(&mut tr, bare.clone(), t2).decoration.fill;
assert!(out.is_some_and(|c| c.r == 255 && c.a < 255), "{out:?}");
let gone = step(&mut tr, bare, t2 + 200 * MS);
assert_eq!(gone.decoration.fill, None, "faded out, then gone");
assert!(!tr.animating());
}
#[test]
fn opacity_and_transforms_animate() {
let mut tr = Transitions::default();
let t0 = Duration::from_secs(1);
let plain = Element::stack(Axis::Vertical, vec![]);
step(&mut tr, plain.clone(), t0);
let t1 = t0 + frame();
let to = Transform::IDENTITY.scale(2.0).rotate(1.0);
let moved = plain.clone().opacity(0.0).transform(to);
step(&mut tr, moved.clone(), t1);
let mid = step(&mut tr, moved.clone(), t1 + 50 * MS - frame());
assert!((mid.opacity - 0.5).abs() < 1e-6, "{}", mid.opacity);
let want = Transform::lerp(Transform::IDENTITY, to, 0.5);
let got = mid.transform.expect("mid-way");
assert!((got.scale.dx - want.scale.dx).abs() < 1e-9);
assert!((got.rotate - want.rotate).abs() < 1e-9);
step(&mut tr, moved, t1 + 200 * MS);
let t2 = t1 + 300 * MS;
step(&mut tr, plain.clone(), t2);
let back = step(&mut tr, plain, t2 + 200 * MS);
assert_eq!(back.transform, None);
assert_eq!(back.opacity, 1.0);
assert!(!tr.animating());
}
#[test]
fn one_property_can_switch_while_the_rest_animate() {
let specs = [
(None, LINEAR[0].1),
(Some(AnimProp::Fill), Transition::instant()),
];
let mut tr = Transitions::default();
let t0 = Duration::from_secs(1);
run(&mut tr, &specs, boxed(Color::rgb(0, 0, 0), 0.0), t0);
let t1 = t0 + 50 * MS;
let el = run(&mut tr, &specs, boxed(Color::rgb(9, 9, 9), 10.0), t1);
assert_eq!(el.decoration.fill, Some(Color::rgb(9, 9, 9)));
assert!(el.layout.padding.top < 10.0);
}
#[test]
fn without_a_clock_changes_switch() {
let mut tr = Transitions::default();
let mut el = boxed(Color::rgb(0, 0, 0), 0.0);
tr.begin(None);
tr.animate(ElementKey(1), &mut el, &LINEAR);
let mut el = boxed(Color::rgb(0, 0, 0), 30.0);
tr.animate(ElementKey(1), &mut el, &LINEAR);
assert_eq!(el.layout.padding.top, 30.0);
assert!(!tr.animating());
}
#[test]
fn elements_that_go_away_are_forgotten() {
let mut tr = Transitions::default();
step(
&mut tr,
boxed(Color::rgb(0, 0, 0), 0.0),
Duration::from_secs(1),
);
tr.begin(None);
tr.end();
assert!(tr.entries.is_empty());
}
}