Skip to main content

sequoia_animation/
animator.rs

1use std::cell::RefCell;
2use std::rc::Rc;
3use std::time::Instant;
4
5use sequoia_core::{Color, Point};
6
7use crate::MAX_FRAME_DT;
8use crate::animation::{Animation, LoopMode};
9use crate::machine::StateMachine;
10use crate::spring_machine::SpringMachine;
11use crate::value::Value;
12
13#[derive(Debug)]
14struct Inner {
15    clip: Option<Animation>,
16    machine: Option<StateMachine>,
17    spring: Option<SpringMachine>,
18    autoplay: bool,
19    playing: bool,
20
21    elapsed: f64,
22
23    last_frame: Option<Instant>,
24
25    offset: Point,
26    width: Option<f32>,
27    height: Option<f32>,
28    background: Option<Color>,
29    opacity: Option<f32>,
30}
31
32#[derive(Clone, Debug)]
33pub struct Animator {
34    inner: Rc<RefCell<Inner>>,
35}
36
37impl Animator {
38    pub fn new() -> Self {
39        Self {
40            inner: Rc::new(RefCell::new(Inner {
41                clip: None,
42                machine: None,
43                spring: None,
44                autoplay: true,
45                playing: true,
46                elapsed: 0.0,
47                last_frame: None,
48                offset: Point::default(),
49                width: None,
50                height: None,
51                background: None,
52                opacity: None,
53            })),
54        }
55    }
56
57    pub fn with_clip(self, clip: Animation) -> Self {
58        self.inner.borrow_mut().clip = Some(clip);
59        self
60    }
61
62    pub fn with_machine(self, machine: StateMachine) -> Self {
63        self.inner.borrow_mut().machine = Some(machine);
64        self
65    }
66
67    pub fn with_spring(self, spring: SpringMachine) -> Self {
68        self.inner.borrow_mut().spring = Some(spring);
69        self
70    }
71
72    pub fn with_autoplay(self, on: bool) -> Self {
73        {
74            let mut inner = self.inner.borrow_mut();
75            inner.autoplay = on;
76            inner.playing = on;
77        }
78        self
79    }
80
81    pub fn set_clip(&mut self, clip: Animation) {
82        let mut inner = self.inner.borrow_mut();
83        inner.clip = Some(clip);
84        inner.elapsed = 0.0;
85    }
86
87    pub fn play(&mut self) {
88        let mut inner = self.inner.borrow_mut();
89        inner.playing = true;
90        inner.elapsed = 0.0;
91        inner.last_frame = Some(Instant::now());
92    }
93
94    pub fn pause(&mut self) {
95        self.inner.borrow_mut().playing = false;
96    }
97
98    pub fn toggle_play(&mut self) {
99        let playing = self.inner.borrow().playing;
100        if playing {
101            self.pause();
102        } else {
103            self.play();
104        }
105    }
106
107    pub fn fire(&mut self, trigger: impl Into<String>) {
108        if let Some(m) = self.inner.borrow_mut().machine.as_mut() {
109            m.fire(trigger);
110        }
111    }
112
113    pub fn set_boolean(&mut self, name: impl Into<String>, value: bool) {
114        if let Some(m) = self.inner.borrow_mut().machine.as_mut() {
115            m.set_boolean(name, value);
116        }
117    }
118
119    pub fn set_number(&mut self, name: impl Into<String>, value: f32) {
120        if let Some(m) = self.inner.borrow_mut().machine.as_mut() {
121            m.set_number(name, value);
122        }
123    }
124
125    pub fn advance(&mut self, dt: f64) -> bool {
126        let dt = dt.max(0.0);
127
128        let clip = self.inner.borrow().clip.clone();
129        let mut inner = self.inner.borrow_mut();
130        inner.offset = Point::default();
131        inner.width = None;
132        inner.height = None;
133        inner.background = None;
134        inner.opacity = None;
135        let mut running = false;
136
137        if let Some(spring) = inner.spring.as_mut() {
138            running |= spring.advance(dt);
139            let p = spring.offset();
140            inner.offset.x += p.x;
141            inner.offset.y += p.y;
142        }
143
144        if let Some(machine) = inner.machine.as_mut() {
145            running |= machine.advance(dt);
146            let frame = machine.frame().clone();
147            apply_frame_additive(&mut inner, &frame);
148        }
149
150        let Some(clip) = clip.as_ref() else {
151            return running;
152        };
153        if inner.playing {
154            inner.elapsed += dt;
155        }
156        let t = inner.elapsed * f64::from(clip.speed());
157        let frame = clip.sample(t);
158        apply_clip_frame(&mut inner, &frame);
159        if !inner.playing {
160            return running;
161        }
162        running
163            || match clip.loop_mode() {
164                LoopMode::Once => !clip.is_finished_at(t),
165
166                LoopMode::Loop | LoopMode::PingPong => true,
167            }
168    }
169
170    /// Jump to the end of the current clip and stop.
171    ///
172    /// Used when the host has reduced motion enabled. State machines and
173    /// springs are paused in place because they have no single final frame.
174    pub fn finish(&mut self) -> bool {
175        let end = {
176            let inner = self.inner.borrow();
177            inner.clip.as_ref().map(|clip| {
178                let speed = f64::from(clip.speed());
179                if speed > 0.0 {
180                    clip.duration() / speed
181                } else {
182                    0.0
183                }
184            })
185        };
186        {
187            let mut inner = self.inner.borrow_mut();
188            if let Some(end) = end {
189                inner.elapsed = end;
190            }
191            inner.playing = false;
192        }
193        let _ = self.advance(0.0);
194        false
195    }
196
197    pub fn advance_frame(&mut self) -> bool {
198        let now = Instant::now();
199        let dt = {
200            let mut inner = self.inner.borrow_mut();
201            let dt = inner
202                .last_frame
203                .map(|last| now.duration_since(last).as_secs_f64())
204                .unwrap_or(0.0)
205                .clamp(0.0, f64::from(MAX_FRAME_DT));
206            inner.last_frame = Some(now);
207            dt
208        };
209        self.advance(dt)
210    }
211
212    pub fn offset(&self) -> Point {
213        self.inner.borrow().offset
214    }
215
216    pub fn width(&self) -> Option<f32> {
217        self.inner.borrow().width
218    }
219
220    pub fn height(&self) -> Option<f32> {
221        self.inner.borrow().height
222    }
223
224    pub fn background(&self) -> Option<Color> {
225        self.inner.borrow().background
226    }
227
228    pub fn opacity(&self) -> Option<f32> {
229        self.inner.borrow().opacity
230    }
231
232    pub fn elapsed(&self) -> f64 {
233        self.inner.borrow().elapsed
234    }
235
236    pub fn is_playing(&self) -> bool {
237        self.inner.borrow().playing
238    }
239
240    pub fn state_name(&self) -> String {
241        self.inner
242            .borrow()
243            .machine
244            .as_ref()
245            .map(|m| m.current_state().to_string())
246            .unwrap_or_default()
247    }
248}
249
250impl Default for Animator {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256fn apply_clip_frame(inner: &mut Inner, frame: &crate::AnimationFrame) {
257    if let Some(v) = frame.get("x").and_then(Value::as_px) {
258        inner.offset.x += v;
259    }
260    if let Some(v) = frame.get("y").and_then(Value::as_px) {
261        inner.offset.y += v;
262    }
263    if let Some(v) = frame.get("width").and_then(Value::as_px) {
264        inner.width = Some(v);
265    }
266    if let Some(v) = frame.get("height").and_then(Value::as_px) {
267        inner.height = Some(v);
268    }
269    if let Some(v) = frame
270        .get("background")
271        .or_else(|| frame.get("color"))
272        .and_then(Value::as_color)
273    {
274        inner.background = Some(v);
275    }
276    if let Some(v) = frame.get("opacity").and_then(Value::as_f32) {
277        inner.opacity = Some(v.clamp(0.0, 1.0));
278    }
279}
280
281fn apply_frame_additive(inner: &mut Inner, frame: &crate::AnimationFrame) {
282    apply_clip_frame(inner, frame);
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::{Keyframe, Track};
289
290    fn slide(dur: f64, to: f32) -> Animation {
291        Animation::new("slide", dur).with_track(
292            "x",
293            Track::try_new(vec![
294                Keyframe::new(0.0, Value::px(0.0)),
295                Keyframe::new(dur, Value::px(to)),
296            ])
297            .unwrap(),
298        )
299    }
300
301    #[test]
302    fn first_frame_starts_at_first_keyframe() {
303        let mut a = Animator::new().with_clip(slide(1.0, 100.0));
304        assert!(a.advance(0.0), "klip Once leci od t = 0");
305        assert_eq!(a.offset(), Point::new(0.0, 0.0));
306    }
307
308    #[test]
309    fn once_clip_travels_then_holds_last_frame() {
310        let mut a = Animator::new().with_clip(slide(0.4, 100.0));
311        assert!(a.advance(0.1));
312        let x1 = a.offset().x;
313        assert!(x1 > 0.0 && x1 < 100.0, "center of flight: {x1}");
314        assert!(a.advance(0.1));
315        let x2 = a.offset().x;
316        assert!(x2 > x1, "leci do przodu ({x1} -> {x2})");
317        assert!(a.advance(0.15));
318        assert!(
319            (a.offset().x - 87.5).abs() < 1e-4,
320            "t=0.35: {}",
321            a.offset().x
322        );
323        assert!(!a.advance(0.1));
324        assert_eq!(a.offset().x, 100.0, "holds the last frame");
325        assert!(!a.advance(0.016), "at the end of Once stands");
326    }
327
328    #[test]
329    fn loop_clip_wraps_and_never_settles() {
330        let clip = Animation::new("pulse", 0.2)
331            .with_loop(LoopMode::Loop)
332            .with_track(
333                "x",
334                Track::try_new(vec![
335                    Keyframe::new(0.0, Value::px(0.0)),
336                    Keyframe::new(0.2, Value::px(50.0)),
337                ])
338                .unwrap(),
339            );
340        let mut a = Animator::new().with_clip(clip);
341        assert!(a.advance(0.25));
342        assert!((a.offset().x - 12.5).abs() < 1e-4, "wrap: {}", a.offset().x);
343        assert!(a.advance(10.0), "Loop nigdy nie staje");
344    }
345
346    #[test]
347    fn pingpong_comes_back() {
348        let clip = Animation::new("pp", 1.0)
349            .with_loop(LoopMode::PingPong)
350            .with_track(
351                "x",
352                Track::try_new(vec![
353                    Keyframe::new(0.0, Value::px(0.0)),
354                    Keyframe::new(1.0, Value::px(100.0)),
355                ])
356                .unwrap(),
357            );
358        let mut a = Animator::new().with_clip(clip);
359        a.advance(0.25);
360        assert!((a.offset().x - 25.0).abs() < 1e-4);
361        a.advance(1.25);
362        assert!(
363            (a.offset().x - 50.0).abs() < 1e-4,
364            "ping-pong: {}",
365            a.offset().x
366        );
367    }
368
369    #[test]
370    fn autoplay_false_freezes_until_play() {
371        let mut a = Animator::new()
372            .with_clip(slide(0.4, 100.0))
373            .with_autoplay(false);
374        assert!(!a.is_playing());
375        assert!(!a.advance(0.1), "zapauzowany nie gra");
376        assert_eq!(a.offset().x, 0.0);
377        a.play();
378        assert!(a.is_playing());
379        assert!(a.advance(0.1));
380        assert!(a.offset().x > 0.0, "po play leci");
381        a.pause();
382        let frozen = a.offset().x;
383        assert!(!a.advance(0.1));
384        assert_eq!(a.offset().x, frozen, "pause freezes sample");
385    }
386
387    #[test]
388    fn clones_share_playback_state() {
389        let player = Animator::new().with_clip(slide(1.0, 100.0));
390        let mut view = player.clone();
391        assert!(view.advance(0.25));
392        assert!((player.offset().x - 25.0).abs() < 1e-4, "klon dzieli stan");
393        let mut other = player.clone();
394        other.pause();
395        assert!(!player.is_playing(), "pauza widoczna we wszystkich klonach");
396    }
397
398    #[test]
399    fn width_height_background_opacity_tracks() {
400        let clip = Animation::new("grow", 1.0)
401            .with_track(
402                "width",
403                Track::try_new(vec![Keyframe::new(0.0, Value::px(10.0))]).unwrap(),
404            )
405            .with_track(
406                "background",
407                Track::try_new(vec![Keyframe::new(0.0, Value::color(Color::GREEN))]).unwrap(),
408            )
409            .with_track(
410                "opacity",
411                Track::try_new(vec![
412                    Keyframe::new(0.0, Value::f32(1.0)),
413                    Keyframe::new(1.0, Value::f32(0.0)),
414                ])
415                .unwrap(),
416            );
417        let mut a = Animator::new().with_clip(clip);
418        a.advance(0.0);
419        assert_eq!(a.width(), Some(10.0));
420        assert_eq!(a.background(), Some(Color::GREEN));
421        assert_eq!(a.opacity(), Some(1.0));
422        a.advance(0.5);
423        assert_eq!(a.opacity(), Some(0.5));
424    }
425
426    #[test]
427    fn opacity_is_clamped() {
428        let clip = Animation::new("o", 1.0).with_track(
429            "opacity",
430            Track::try_new(vec![Keyframe::new(0.0, Value::f32(42.0))]).unwrap(),
431        );
432        let mut a = Animator::new().with_clip(clip);
433        a.advance(0.0);
434        assert_eq!(a.opacity(), Some(1.0));
435    }
436
437    #[test]
438    fn color_alias_track_works() {
439        let clip = Animation::new("c", 1.0).with_track(
440            "color",
441            Track::try_new(vec![Keyframe::new(0.0, Value::color(Color::RED))]).unwrap(),
442        );
443        let mut a = Animator::new().with_clip(clip);
444        a.advance(0.0);
445        assert_eq!(a.background(), Some(Color::RED));
446    }
447
448    #[test]
449    fn spring_offset_adds_to_clip() {
450        use crate::{AnimValue, SpringMachine, StateDef};
451        let spring = SpringMachine::new(
452            vec![
453                StateDef::new("a", AnimValue::static_px(10.0), AnimValue::static_px(0.0)),
454                StateDef::new("b", AnimValue::static_px(10.0), AnimValue::static_px(0.0)),
455            ],
456            vec![],
457        );
458        let mut a = Animator::new()
459            .with_clip(slide(1.0, 100.0))
460            .with_spring(spring);
461        a.advance(0.5);
462        assert!((a.offset().x - 60.0).abs() < 1e-4, "suma: {}", a.offset().x);
463    }
464
465    #[test]
466    fn full_machine_trigger_drives_offset() {
467        use crate::{Condition, FromState, MachineState, ToState, Transition};
468        use std::collections::HashMap;
469        let idle = Animation::new("idle", 1.0).with_track(
470            "x",
471            Track::try_new(vec![
472                Keyframe::new(0.0, Value::px(0.0)),
473                Keyframe::new(1.0, Value::px(0.0)),
474            ])
475            .unwrap(),
476        );
477        let wave = Animation::new("wave", 1.0).with_track(
478            "x",
479            Track::try_new(vec![
480                Keyframe::new(0.0, Value::px(0.0)),
481                Keyframe::new(1.0, Value::px(100.0)),
482            ])
483            .unwrap(),
484        );
485        let sm = StateMachine::try_new(
486            vec![
487                MachineState::entry(),
488                MachineState::animation("idle", "idle"),
489                MachineState::animation("wave", "wave"),
490            ],
491            vec![
492                Transition::new(FromState::Entry, ToState::State("idle".into()), vec![]),
493                Transition::new(
494                    FromState::State("idle".into()),
495                    ToState::State("wave".into()),
496                    vec![Condition::Trigger("go".into())],
497                )
498                .with_mix(0.5),
499            ],
500            HashMap::from([("idle".into(), idle), ("wave".into(), wave)]),
501        )
502        .unwrap();
503        let mut a = Animator::new().with_machine(sm);
504        a.advance(0.0);
505        assert_eq!(a.state_name(), "idle");
506        a.fire("go");
507        a.advance(0.0);
508        assert!(a.advance(0.25));
509        let x = a.offset().x;
510        assert!(x > 0.0 && x < 100.0, "blend w locie: {x}");
511    }
512
513    #[test]
514    fn empty_animator_is_idle() {
515        let mut a = Animator::new();
516        assert!(!a.advance(0.016));
517        assert_eq!(a.offset(), Point::ZERO);
518        assert_eq!(a.width(), None);
519        assert_eq!(a.opacity(), None);
520    }
521
522    #[test]
523    fn negative_dt_is_clamped() {
524        let mut a = Animator::new().with_clip(slide(1.0, 100.0));
525        assert!(a.advance(-5.0));
526        assert_eq!(a.offset().x, 0.0);
527    }
528
529    #[test]
530    fn set_clip_restarts_time() {
531        let mut a = Animator::new().with_clip(slide(1.0, 100.0));
532        a.advance(0.75);
533        assert!((a.offset().x - 75.0).abs() < 1e-4);
534        a.set_clip(slide(1.0, 200.0));
535        assert_eq!(a.elapsed(), 0.0);
536        a.advance(0.5);
537        assert!((a.offset().x - 100.0).abs() < 1e-4);
538    }
539
540    #[test]
541    fn advance_frame_uses_wall_clock_without_teleport() {
542        let mut a = Animator::new().with_clip(slide(60.0, 100.0));
543        assert!(a.advance_frame());
544        assert!(a.offset().x < 1.0, "the first frame barely moved");
545    }
546}