Skip to main content

tess/
anim.rs

1//! Pure animation-playback state. No terminal, no clock — advanced by explicit
2//! `Duration` deltas the caller computes from an `Instant`, so the transport
3//! logic (frame advance, loop-count termination, pause/step/restart) is fully
4//! unit-testable. Mirrors the project's pure-kernel discipline.
5
6use image::RgbaImage;
7use std::time::Duration;
8
9/// Frame delays are clamped into a sane range: a raw delay below
10/// MIN_HONORED_DELAY (e.g. 0, as some GIFs encode) is treated as
11/// MIN_FRAME_DELAY, matching common viewer/browser behavior so playback can't
12/// busy-loop on absurdly fast frames.
13const MIN_FRAME_DELAY: Duration = Duration::from_millis(100);
14const MIN_HONORED_DELAY: Duration = Duration::from_millis(10);
15
16pub struct AnimationState {
17    frames: Vec<(RgbaImage, Duration)>,
18    loop_count: Option<u32>, // None = infinite
19    current: usize,
20    elapsed_in_frame: Duration,
21    playing: bool,
22    loops_done: u32,
23    finished: bool,
24}
25
26impl AnimationState {
27    /// Build from decoded frames + loop count. Starts playing at frame 0.
28    pub fn new(frames: Vec<(RgbaImage, Duration)>, loop_count: Option<u32>) -> Self {
29        Self {
30            frames, loop_count, current: 0, elapsed_in_frame: Duration::ZERO,
31            playing: true, loops_done: 0, finished: false,
32        }
33    }
34
35    pub fn current_frame(&self) -> &RgbaImage { &self.frames[self.current].0 }
36    pub fn frame_index(&self) -> usize { self.current }
37    pub fn frame_count(&self) -> usize { self.frames.len() }
38    pub fn is_playing(&self) -> bool { self.playing }
39    pub fn is_finished(&self) -> bool { self.finished }
40
41    fn frame_delay(&self, i: usize) -> Duration {
42        let d = self.frames[i].1;
43        if d < MIN_HONORED_DELAY { MIN_FRAME_DELAY } else { d }
44    }
45
46    /// Accumulate `dt`, flipping to later frames as each delay elapses (a large
47    /// dt may cross several frames). On wrapping past the last frame, count a
48    /// loop; if a finite loop count is then reached, finish and rest on the last
49    /// frame. No-op when paused, finished, or single-frame. Returns whether the
50    /// displayed frame changed.
51    pub fn advance(&mut self, dt: Duration) -> bool {
52        if !self.playing || self.finished || self.frames.len() <= 1 { return false; }
53        let start = self.current;
54        self.elapsed_in_frame += dt;
55        loop {
56            let delay = self.frame_delay(self.current);
57            if self.elapsed_in_frame < delay { break; }
58            self.elapsed_in_frame -= delay;
59            if self.current + 1 < self.frames.len() {
60                self.current += 1;
61            } else {
62                self.loops_done += 1;
63                if let Some(n) = self.loop_count {
64                    if self.loops_done >= n {
65                        self.finished = true;
66                        self.playing = false;
67                        self.current = self.frames.len() - 1;
68                        self.elapsed_in_frame = Duration::ZERO;
69                        break;
70                    }
71                }
72                self.current = 0;
73            }
74        }
75        self.current != start
76    }
77
78    /// Time until the next frame flip, or None when paused/finished/single-frame.
79    pub fn next_deadline(&self) -> Option<Duration> {
80        if !self.playing || self.finished || self.frames.len() <= 1 { return None; }
81        Some(self.frame_delay(self.current).saturating_sub(self.elapsed_in_frame))
82    }
83
84    /// Play/pause toggle. Reviving a finished animation restarts it (so the
85    /// play key brings a stopped GIF back to life).
86    pub fn toggle_pause(&mut self) {
87        if self.finished { self.restart(); return; }
88        self.playing = !self.playing;
89    }
90
91    /// Pause and move ±1 frame (wrapping). Clears the finished state.
92    pub fn step(&mut self, delta: i32) {
93        self.playing = false;
94        self.finished = false;
95        self.elapsed_in_frame = Duration::ZERO;
96        let n = self.frames.len() as i64;
97        if n == 0 { return; }
98        let next = ((self.current as i64 + delta as i64) % n + n) % n;
99        self.current = next as usize;
100    }
101
102    /// Restart from frame 0, playing, loop counter reset.
103    pub fn restart(&mut self) {
104        self.current = 0;
105        self.elapsed_in_frame = Duration::ZERO;
106        self.loops_done = 0;
107        self.finished = false;
108        self.playing = true;
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use image::{Rgba, RgbaImage};
116
117    fn frames(n: usize, delay_ms: u64) -> Vec<(RgbaImage, Duration)> {
118        (0..n).map(|i| (RgbaImage::from_pixel(1, 1, Rgba([i as u8, 0, 0, 255])),
119                        Duration::from_millis(delay_ms))).collect()
120    }
121
122    #[test]
123    fn advances_at_delay_boundary() {
124        let mut a = AnimationState::new(frames(3, 100), None);
125        assert_eq!(a.frame_index(), 0);
126        assert!(!a.advance(Duration::from_millis(50)));
127        assert_eq!(a.frame_index(), 0);
128        assert!(a.advance(Duration::from_millis(60)));
129        assert_eq!(a.frame_index(), 1);
130    }
131
132    #[test]
133    fn large_dt_crosses_multiple_frames() {
134        let mut a = AnimationState::new(frames(4, 100), None);
135        assert!(a.advance(Duration::from_millis(250)));
136        assert_eq!(a.frame_index(), 2);
137    }
138
139    #[test]
140    fn infinite_loop_never_finishes() {
141        let mut a = AnimationState::new(frames(2, 100), None);
142        a.advance(Duration::from_millis(1000));
143        assert!(!a.is_finished());
144        assert!(a.is_playing());
145    }
146
147    #[test]
148    fn finite_loop_finishes_on_last_frame() {
149        let mut a = AnimationState::new(frames(3, 100), Some(1));
150        a.advance(Duration::from_millis(300));
151        assert!(a.is_finished());
152        assert!(!a.is_playing());
153        assert_eq!(a.frame_index(), 2);
154        assert_eq!(a.next_deadline(), None);
155        assert!(!a.advance(Duration::from_millis(500)));
156    }
157
158    #[test]
159    fn pause_blocks_advance_and_resume_restores() {
160        let mut a = AnimationState::new(frames(3, 100), None);
161        a.toggle_pause();
162        assert!(!a.is_playing());
163        assert!(!a.advance(Duration::from_millis(500)));
164        assert_eq!(a.frame_index(), 0);
165        a.toggle_pause();
166        assert!(a.is_playing());
167    }
168
169    #[test]
170    fn step_wraps_and_pauses() {
171        let mut a = AnimationState::new(frames(3, 100), None);
172        a.step(-1);
173        assert_eq!(a.frame_index(), 2);
174        assert!(!a.is_playing());
175        a.step(1);
176        assert_eq!(a.frame_index(), 0);
177    }
178
179    #[test]
180    fn restart_resets() {
181        let mut a = AnimationState::new(frames(3, 100), Some(1));
182        a.advance(Duration::from_millis(300));
183        a.restart();
184        assert_eq!(a.frame_index(), 0);
185        assert!(a.is_playing());
186        assert!(!a.is_finished());
187    }
188
189    #[test]
190    fn toggle_pause_revives_finished() {
191        let mut a = AnimationState::new(frames(2, 100), Some(1));
192        a.advance(Duration::from_millis(200));
193        assert!(a.is_finished());
194        a.toggle_pause();
195        assert!(a.is_playing() && !a.is_finished() && a.frame_index() == 0);
196    }
197
198    #[test]
199    fn zero_delay_floored_not_busy_loop() {
200        let mut a = AnimationState::new(frames(2, 0), None);
201        assert!(!a.advance(Duration::from_millis(50)));
202        assert!(a.advance(Duration::from_millis(60)));
203    }
204
205    #[test]
206    fn zero_delay_floor_applies_to_next_deadline() {
207        let a = AnimationState::new(frames(2, 0), None);
208        // A 0ms raw delay is floored, so the deadline reflects MIN_FRAME_DELAY (100ms).
209        assert_eq!(a.next_deadline(), Some(Duration::from_millis(100)));
210    }
211
212    #[test]
213    fn per_frame_delays_are_respected() {
214        use image::{Rgba, RgbaImage};
215        let mk = |c: u8, ms: u64| (RgbaImage::from_pixel(1, 1, Rgba([c, 0, 0, 255])), Duration::from_millis(ms));
216        // frame 0: 50ms, frame 1: 200ms, frame 2: 50ms
217        let mut a = AnimationState::new(vec![mk(0, 50), mk(1, 200), mk(2, 50)], None);
218        assert_eq!(a.next_deadline(), Some(Duration::from_millis(50)), "frame 0 delay is 50ms");
219        assert!(a.advance(Duration::from_millis(50)));       // 0 -> 1
220        assert_eq!(a.frame_index(), 1);
221        assert_eq!(a.next_deadline(), Some(Duration::from_millis(200)), "frame 1 delay is 200ms");
222        assert!(!a.advance(Duration::from_millis(100)), "100ms < frame 1's 200ms, no flip");
223        assert_eq!(a.frame_index(), 1);
224        assert!(a.advance(Duration::from_millis(120)), "now crosses frame 1's 200ms");
225        assert_eq!(a.frame_index(), 2);
226    }
227}