1use std::time::{Duration, Instant};
6
7#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum Easing {
10 Linear,
12 EaseIn,
14 EaseOut,
16 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#[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 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 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 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 pub fn value(&self) -> f64 {
97 self.current
98 }
99
100 pub fn is_animating(&self) -> bool {
102 self.started_at.is_some()
103 }
104
105 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#[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 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 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 pub fn frame(&self) -> &str {
171 self.frames
172 .get(self.normalized_current())
173 .map(String::as_str)
174 .unwrap_or("")
175 }
176
177 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
209pub 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(); 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"); }
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"); 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}