Skip to main content

a3s_tui/
animation.rs

1//! Animation system for smooth transitions and timed effects.
2//!
3//! Provides [`Transition`] for animating between states with easing functions.
4
5use std::time::{Duration, Instant};
6
7/// Easing function type.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum Easing {
10    /// Constant speed.
11    Linear,
12    /// Slow start, fast end.
13    EaseIn,
14    /// Fast start, slow end.
15    EaseOut,
16    /// Slow start and end, fast middle.
17    EaseInOut,
18}
19
20impl Easing {
21    fn apply(&self, t: f64) -> f64 {
22        match self {
23            Easing::Linear => t,
24            Easing::EaseIn => t * t,
25            Easing::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
26            Easing::EaseInOut => {
27                if t < 0.5 {
28                    2.0 * t * t
29                } else {
30                    1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
31                }
32            }
33        }
34    }
35}
36
37/// A value that transitions smoothly from one state to another.
38///
39/// ```rust
40/// use a3s_tui::animation::{Transition, Easing};
41/// use std::time::Duration;
42///
43/// let mut t = Transition::new(0.0);
44/// t.animate_to(100.0, Duration::from_millis(300), Easing::EaseOut);
45/// // Call t.tick() each frame to advance the animation
46/// ```
47#[derive(Debug, Clone)]
48pub struct Transition {
49    from: f64,
50    to: f64,
51    current: f64,
52    duration: Duration,
53    easing: Easing,
54    started_at: Option<Instant>,
55}
56
57impl Transition {
58    /// Create a new transition at the given initial value.
59    pub fn new(value: f64) -> Self {
60        Self {
61            from: value,
62            to: value,
63            current: value,
64            duration: Duration::from_millis(200),
65            easing: Easing::Linear,
66            started_at: None,
67        }
68    }
69
70    /// Start animating to a new target value.
71    pub fn animate_to(&mut self, target: f64, duration: Duration, easing: Easing) {
72        self.from = self.current;
73        self.to = target;
74        self.duration = duration;
75        self.easing = easing;
76        self.started_at = Some(Instant::now());
77    }
78
79    /// Advance the animation. Call once per frame.
80    pub fn tick(&mut self) {
81        let Some(started) = self.started_at else {
82            return;
83        };
84        let elapsed = started.elapsed();
85        if elapsed >= self.duration {
86            self.current = self.to;
87            self.started_at = None;
88        } else {
89            let t = elapsed.as_secs_f64() / self.duration.as_secs_f64();
90            let eased = self.easing.apply(t);
91            self.current = self.from + (self.to - self.from) * eased;
92        }
93    }
94
95    /// Get the current interpolated value.
96    pub fn value(&self) -> f64 {
97        self.current
98    }
99
100    /// Whether the animation is still running.
101    pub fn is_animating(&self) -> bool {
102        self.started_at.is_some()
103    }
104
105    /// Jump immediately to a value without animation.
106    pub fn set(&mut self, value: f64) {
107        self.from = value;
108        self.to = value;
109        self.current = value;
110        self.started_at = None;
111    }
112}
113
114/// A looping frame-based animation (e.g., for spinners, progress indicators).
115///
116/// ```rust
117/// use a3s_tui::animation::FrameAnimation;
118/// use std::time::Duration;
119///
120/// let anim = FrameAnimation::new(
121///     vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
122///     Duration::from_millis(80),
123/// );
124/// ```
125#[derive(Debug, Clone)]
126pub struct FrameAnimation {
127    frames: Vec<String>,
128    interval: Duration,
129    current: usize,
130    last_tick: Option<Instant>,
131    active: bool,
132}
133
134impl FrameAnimation {
135    /// Create a new frame animation with the given frames and interval.
136    pub fn new(frames: Vec<impl Into<String>>, interval: Duration) -> Self {
137        Self {
138            frames: frames.into_iter().map(|f| f.into()).collect(),
139            interval,
140            current: 0,
141            last_tick: None,
142            active: true,
143        }
144    }
145
146    /// Advance to the next frame if enough time has passed.
147    pub fn tick(&mut self) {
148        if !self.active || self.frames.is_empty() {
149            return;
150        }
151        let now = Instant::now();
152        match self.last_tick {
153            None => {
154                self.last_tick = Some(now);
155            }
156            Some(last) if now.duration_since(last) >= self.interval => {
157                let current = self.normalized_current();
158                self.current = if current + 1 == self.frames.len() {
159                    0
160                } else {
161                    current + 1
162                };
163                self.last_tick = Some(now);
164            }
165            _ => {}
166        }
167    }
168
169    /// Get the current frame content.
170    pub fn frame(&self) -> &str {
171        self.frames
172            .get(self.normalized_current())
173            .map(String::as_str)
174            .unwrap_or("")
175    }
176
177    /// Get the current frame index.
178    pub fn index(&self) -> usize {
179        self.normalized_current()
180    }
181
182    pub fn start(&mut self) {
183        self.active = true;
184        self.last_tick = None;
185    }
186
187    pub fn stop(&mut self) {
188        self.active = false;
189    }
190
191    pub fn is_active(&self) -> bool {
192        self.active
193    }
194
195    pub fn reset(&mut self) {
196        self.current = 0;
197        self.last_tick = None;
198    }
199
200    fn normalized_current(&self) -> usize {
201        if self.frames.is_empty() {
202            0
203        } else {
204            self.current % self.frames.len()
205        }
206    }
207}
208
209/// Predefined spinner frame sets.
210pub mod spinners {
211    use super::FrameAnimation;
212    use std::time::Duration;
213
214    pub fn dots() -> FrameAnimation {
215        FrameAnimation::new(
216            vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
217            Duration::from_millis(80),
218        )
219    }
220
221    pub fn line() -> FrameAnimation {
222        FrameAnimation::new(vec!["-", "\\", "|", "/"], Duration::from_millis(130))
223    }
224
225    pub fn bounce() -> FrameAnimation {
226        FrameAnimation::new(
227            vec!["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
228            Duration::from_millis(120),
229        )
230    }
231
232    pub fn pulse() -> FrameAnimation {
233        FrameAnimation::new(
234            vec!["█", "▓", "▒", "░", "▒", "▓"],
235            Duration::from_millis(150),
236        )
237    }
238
239    pub fn arrow() -> FrameAnimation {
240        FrameAnimation::new(
241            vec!["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
242            Duration::from_millis(100),
243        )
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn easing_linear() {
253        assert_eq!(Easing::Linear.apply(0.0), 0.0);
254        assert_eq!(Easing::Linear.apply(0.5), 0.5);
255        assert_eq!(Easing::Linear.apply(1.0), 1.0);
256    }
257
258    #[test]
259    fn easing_ease_in() {
260        assert_eq!(Easing::EaseIn.apply(0.0), 0.0);
261        assert_eq!(Easing::EaseIn.apply(1.0), 1.0);
262        assert!(Easing::EaseIn.apply(0.5) < 0.5);
263    }
264
265    #[test]
266    fn easing_ease_out() {
267        assert_eq!(Easing::EaseOut.apply(0.0), 0.0);
268        assert_eq!(Easing::EaseOut.apply(1.0), 1.0);
269        assert!(Easing::EaseOut.apply(0.5) > 0.5);
270    }
271
272    #[test]
273    fn transition_immediate_set() {
274        let mut t = Transition::new(10.0);
275        assert_eq!(t.value(), 10.0);
276        assert!(!t.is_animating());
277        t.set(50.0);
278        assert_eq!(t.value(), 50.0);
279        assert!(!t.is_animating());
280    }
281
282    #[test]
283    fn transition_animate_starts() {
284        let mut t = Transition::new(0.0);
285        t.animate_to(100.0, Duration::from_millis(100), Easing::Linear);
286        assert!(t.is_animating());
287    }
288
289    #[test]
290    fn transition_completes_after_duration() {
291        let mut t = Transition::new(0.0);
292        t.animate_to(100.0, Duration::from_millis(1), Easing::Linear);
293        std::thread::sleep(Duration::from_millis(5));
294        t.tick();
295        assert_eq!(t.value(), 100.0);
296        assert!(!t.is_animating());
297    }
298
299    #[test]
300    fn frame_animation_cycles() {
301        let mut anim = FrameAnimation::new(vec!["a", "b", "c"], Duration::from_millis(1));
302        assert_eq!(anim.frame(), "a");
303        anim.tick(); // initializes last_tick
304        std::thread::sleep(Duration::from_millis(5));
305        anim.tick();
306        assert_eq!(anim.frame(), "b");
307        std::thread::sleep(Duration::from_millis(5));
308        anim.tick();
309        assert_eq!(anim.frame(), "c");
310        std::thread::sleep(Duration::from_millis(5));
311        anim.tick();
312        assert_eq!(anim.frame(), "a"); // wraps
313    }
314
315    #[test]
316    fn frame_animation_stop_start() {
317        let mut anim = FrameAnimation::new(vec!["x", "y"], Duration::from_millis(1));
318        anim.stop();
319        assert!(!anim.is_active());
320        anim.tick();
321        std::thread::sleep(Duration::from_millis(5));
322        anim.tick();
323        assert_eq!(anim.frame(), "x"); // didn't advance
324        anim.start();
325        anim.tick();
326        std::thread::sleep(Duration::from_millis(5));
327        anim.tick();
328        assert_eq!(anim.frame(), "y");
329    }
330
331    #[test]
332    fn frame_animation_empty_frames_do_not_panic() {
333        let mut anim = FrameAnimation::new(Vec::<&str>::new(), Duration::from_millis(1));
334        assert_eq!(anim.frame(), "");
335        assert_eq!(anim.index(), 0);
336        assert!(anim.is_active());
337
338        anim.tick();
339        anim.stop();
340        anim.start();
341        anim.reset();
342
343        assert_eq!(anim.frame(), "");
344        assert_eq!(anim.index(), 0);
345    }
346
347    #[test]
348    fn frame_animation_normalizes_stale_index_for_reads() {
349        let mut anim = FrameAnimation::new(vec!["a", "b", "c"], Duration::from_millis(1));
350        anim.current = usize::MAX;
351
352        assert_eq!(anim.index(), usize::MAX % 3);
353        assert_eq!(anim.frame(), anim.frames[usize::MAX % 3]);
354    }
355
356    #[test]
357    fn frame_animation_tick_normalizes_stale_index() {
358        let mut anim = FrameAnimation::new(vec!["a", "b", "c"], Duration::from_millis(1));
359        anim.current = usize::MAX;
360        anim.last_tick = Some(Instant::now() - Duration::from_millis(5));
361
362        anim.tick();
363
364        assert!(anim.current < anim.frames.len());
365        assert_eq!(anim.index(), (usize::MAX % 3 + 1) % 3);
366    }
367
368    #[test]
369    fn spinners_create_valid_animations() {
370        let dots = spinners::dots();
371        assert_eq!(dots.frame(), "⠋");
372        let line = spinners::line();
373        assert_eq!(line.frame(), "-");
374    }
375}