sequoia-animation 0.1.0

Sequoia GUI framework — Rive-style animation (tween, keyframes, mixer, state machines)
Documentation
use crate::animation::{Animation, AnimationFrame, LoopMode};
use crate::mixer::Mixer;

#[derive(Clone, Debug)]
struct Entry {
    animation: Animation,
    at: f64,
    weight: f32,
}

#[derive(Clone, Debug, Default)]
pub struct Choreography {
    entries: Vec<Entry>,
    loop_mode: LoopMode,
}

impl Choreography {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_loop(mut self, mode: LoopMode) -> Self {
        self.loop_mode = mode;
        self
    }

    pub fn add(mut self, animation: Animation, at_secs: f64) -> Self {
        self.entries.push(Entry {
            animation,
            at: at_secs.max(0.0),
            weight: 1.0,
        });
        self
    }

    pub fn add_weighted(mut self, animation: Animation, at_secs: f64, weight: f32) -> Self {
        self.entries.push(Entry {
            animation,
            at: at_secs.max(0.0),
            weight,
        });
        self
    }

    pub fn stagger(animations: impl IntoIterator<Item = Animation>, step: f64) -> Self {
        Self::new().add_staggered(animations, step)
    }

    pub fn add_staggered(
        mut self,
        animations: impl IntoIterator<Item = Animation>,
        step: f64,
    ) -> Self {
        let mut at = self.duration();
        for animation in animations {
            self.entries.push(Entry {
                animation,
                at,
                weight: 1.0,
            });
            at += step.max(0.0);
        }
        self
    }

    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    pub fn loop_mode(&self) -> LoopMode {
        self.loop_mode
    }

    pub fn duration(&self) -> f64 {
        self.entries
            .iter()
            .map(|e| {
                let len = match e.animation.loop_mode() {
                    LoopMode::Once => e.animation.duration(),

                    LoopMode::Loop | LoopMode::PingPong => e.animation.duration(),
                };
                e.at + len.max(0.0)
            })
            .fold(0.0, f64::max)
    }

    pub fn is_finished_at(&self, t: f64) -> bool {
        self.loop_mode == LoopMode::Once && t >= self.duration()
    }

    pub fn sample(&self, t: f64) -> AnimationFrame {
        let t = self.wrap_time(t);
        let samples: Vec<(AnimationFrame, f32)> = self
            .entries
            .iter()
            .filter_map(|e| {
                let local = t - e.at;
                if local < 0.0 || e.weight <= 0.0 {
                    return None;
                }
                Some((e.animation.sample(local), e.weight))
            })
            .collect();
        if samples.is_empty() {
            return AnimationFrame::new();
        }
        Mixer::mix_frames(samples.iter().map(|(f, w)| (f, *w)))
    }

    fn wrap_time(&self, t: f64) -> f64 {
        let duration = self.duration();
        if duration <= 0.0 {
            return 0.0;
        }
        match self.loop_mode {
            LoopMode::Once => t.max(0.0).min(duration),
            LoopMode::Loop => t.max(0.0) % duration,
            LoopMode::PingPong => {
                let period = duration * 2.0;
                let t = t.max(0.0) % period;
                if t <= duration { t } else { period - t }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::animation::{Keyframe, Track};
    use crate::value::Value;

    fn fade(dur: f64) -> Animation {
        Animation::new("fade", dur).with_track(
            "opacity",
            Track::try_new(vec![
                Keyframe::new(0.0, Value::f32(0.0)),
                Keyframe::new(dur, Value::f32(1.0)),
            ])
            .unwrap(),
        )
    }

    fn slide(name: &str, dur: f64, to: f32) -> Animation {
        Animation::new(name, dur).with_track(
            "x",
            Track::try_new(vec![
                Keyframe::new(0.0, Value::px(0.0)),
                Keyframe::new(dur, Value::px(to)),
            ])
            .unwrap(),
        )
    }

    #[test]
    fn empty_choreography_is_inert() {
        let c = Choreography::new();
        assert!(c.is_empty());
        assert_eq!(c.duration(), 0.0);
        assert!(c.sample(1.0).is_empty());
        assert!(c.is_finished_at(0.0));
    }

    #[test]
    fn duration_is_latest_clip_end() {
        let c = Choreography::new()
            .add(slide("a", 0.4, 10.0), 0.0)
            .add(slide("b", 0.4, 10.0), 0.25);
        assert!((c.duration() - 0.65).abs() < 1e-9);
        assert_eq!(c.len(), 2);
    }

    #[test]
    fn clips_before_their_offset_contribute_nothing() {
        let c = Choreography::new()
            .add(slide("a", 0.2, 100.0), 0.0)
            .add(slide("b", 0.2, 100.0), 0.5);

        let f0 = c.sample(0.0);
        assert_eq!(f0.get("x"), Some(&Value::px(0.0)));

        let f1 = c.sample(0.1);
        assert_eq!(f1.get("x"), Some(&Value::px(50.0)));
    }

    #[test]
    fn stagger_offsets_each_clip() {
        let c = Choreography::stagger([fade(0.2), fade(0.2), fade(0.2)], 0.1);

        assert!((c.duration() - 0.4).abs() < 1e-9);

        assert_eq!(c.sample(0.0).get("opacity"), Some(&Value::f32(0.0)));

        let mixed = c.sample(0.1).get("opacity").copied().unwrap();
        assert!(matches!(mixed, Value::F32(v) if (v - 0.25).abs() < 1e-6));

        assert_eq!(c.sample(10.0).get("opacity"), Some(&Value::f32(1.0)));
    }

    #[test]
    fn weighted_entries_blend() {
        let a = slide("a", 1.0, 100.0);
        let b = slide("b", 1.0, -100.0);
        let c = Choreography::new()
            .add_weighted(a, 0.0, 0.75)
            .add_weighted(b, 0.0, 0.25);
        let v = c.sample(1.0).get("x").copied().unwrap();
        assert_eq!(v, Value::px(50.0));
    }

    #[test]
    fn once_holds_last_frame_and_reports_finished() {
        let c = Choreography::new().add(fade(0.2), 0.0);
        assert!(!c.is_finished_at(0.1));
        assert!(c.is_finished_at(0.2));
        assert_eq!(c.sample(5.0).get("opacity"), Some(&Value::f32(1.0)));
    }

    #[test]
    fn looping_timeline_wraps() {
        let c = Choreography::new()
            .add(fade(0.2), 0.0)
            .with_loop(LoopMode::Loop);
        assert!(!c.is_finished_at(100.0));

        let v = c.sample(0.25).get("opacity").copied().unwrap();
        assert!(matches!(v, Value::F32(x) if (x - 0.25).abs() < 1e-5));
    }

    #[test]
    fn pingpong_reverses_at_the_end() {
        let c = Choreography::new()
            .add(fade(0.2), 0.0)
            .with_loop(LoopMode::PingPong);

        let v = c.sample(0.3).get("opacity").copied().unwrap();
        assert!(matches!(v, Value::F32(x) if (x - 0.5).abs() < 1e-5));
    }

    #[test]
    fn add_staggered_chains_from_the_end() {
        let c = Choreography::new()
            .add(fade(0.3), 0.0)
            .add_staggered([fade(0.2), fade(0.2)], 0.05);

        assert!((c.duration() - 0.55).abs() < 1e-9);
    }
}