1use std::time::Duration;
31
32use crate::frame::{Color, Style};
33
34pub const FRAME: Duration = Duration::from_millis(33);
38
39#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
41pub enum Easing {
42 #[default]
44 Linear,
45 EaseIn,
47 EaseOut,
49 EaseInOut,
51}
52
53impl Easing {
54 #[must_use]
56 pub fn apply(self, t: f32) -> f32 {
57 let t = t.clamp(0.0, 1.0);
58 match self {
59 Self::Linear => t,
60 Self::EaseIn => t * t * t,
61 Self::EaseOut => {
62 let inv = 1.0 - t;
63 inv.mul_add(-inv * inv, 1.0)
64 },
65 Self::EaseInOut if t < 0.5 => 4.0 * t * t * t,
66 Self::EaseInOut => {
67 let inv = (-2.0f32).mul_add(t, 2.0);
68 inv.mul_add(-inv * inv / 2.0, 1.0)
69 },
70 }
71 }
72}
73
74pub trait Lerp: Copy {
76 #[must_use]
78 fn lerp(self, to: Self, t: f32) -> Self;
79}
80
81impl Lerp for f32 {
82 fn lerp(self, to: Self, t: f32) -> Self {
83 (to - self).mul_add(t, self)
84 }
85}
86
87impl Lerp for u8 {
88 fn lerp(self, to: Self, t: f32) -> Self {
89 f32::from(self).lerp(f32::from(to), t).round() as Self
90 }
91}
92
93impl Lerp for u16 {
94 fn lerp(self, to: Self, t: f32) -> Self {
95 f32::from(self).lerp(f32::from(to), t).round() as Self
96 }
97}
98
99impl Lerp for Color {
102 fn lerp(self, to: Self, t: f32) -> Self {
103 match (self, to) {
104 (Self::Rgb(r0, g0, b0), Self::Rgb(r1, g1, b1)) => {
105 Self::Rgb(r0.lerp(r1, t), g0.lerp(g1, t), b0.lerp(b1, t))
106 },
107 _ if t < 0.5 => self,
108 _ => to,
109 }
110 }
111}
112
113impl<A: Lerp, B: Lerp> Lerp for (A, B) {
116 fn lerp(self, to: Self, t: f32) -> Self {
117 (self.0.lerp(to.0, t), self.1.lerp(to.1, t))
118 }
119}
120
121#[derive(Clone, Copy, Debug)]
141pub struct Tween<T: Lerp> {
142 from: T,
143 to: T,
144 start: Duration,
145 duration: Duration,
146 easing: Easing,
147}
148
149impl<T: Lerp> Tween<T> {
150 pub const fn settled(value: T) -> Self {
152 Self {
153 from: value,
154 to: value,
155 start: Duration::ZERO,
156 duration: Duration::ZERO,
157 easing: Easing::Linear,
158 }
159 }
160
161 pub fn sample(&self, now: Duration) -> T {
163 if self.duration.is_zero() {
164 return self.to;
165 }
166 let t = now
167 .saturating_sub(self.start)
168 .div_duration_f32(self.duration);
169 self.from.lerp(self.to, self.easing.apply(t))
170 }
171
172 pub const fn target(&self) -> T {
174 self.to
175 }
176
177 pub fn is_settled(&self, now: Duration) -> bool {
179 now >= self.settles_at()
180 }
181
182 pub const fn settles_at(&self) -> Duration {
184 self.start.saturating_add(self.duration)
185 }
186
187 pub fn retarget(&mut self, now: Duration, to: T, duration: Duration, easing: Easing)
191 where
192 T: PartialEq,
193 {
194 if self.to == to {
195 return;
196 }
197 self.from = self.sample(now);
198 self.to = to;
199 self.start = now;
200 self.duration = duration;
201 self.easing = easing;
202 }
203}
204
205#[derive(Clone, Copy, Debug)]
211pub struct Frames {
212 frames: &'static [&'static str],
213 interval: Duration,
214}
215
216impl Frames {
217 pub const SPINNER: Self =
219 Self::new(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], Duration::from_millis(80));
220 pub const SPINNER_ASCII: Self = Self::new(&["|", "/", "-", "\\"], Duration::from_millis(120));
222
223 pub const fn new(frames: &'static [&'static str], interval: Duration) -> Self {
228 assert!(!frames.is_empty(), "a frame cycle needs at least one frame");
229 assert!(!interval.is_zero(), "a frame cycle needs a nonzero interval");
230 Self { frames, interval }
231 }
232
233 pub const fn at(&self, now: Duration) -> &'static str {
235 let step = now.as_nanos() / self.interval.as_nanos();
236 self.frames[(step % self.frames.len() as u128) as usize]
237 }
238
239 pub const fn next_change(&self, now: Duration) -> Duration {
242 let interval = self.interval.as_nanos();
243 let remaining = interval - now.as_nanos() % interval;
244 now.saturating_add(Duration::from_nanos(remaining as u64))
245 }
246}
247
248#[derive(Clone, Copy, Debug)]
258pub struct Shimmer {
259 position: f32,
261}
262
263impl Shimmer {
264 const HALF_WIDTH: f32 = 6.0;
266 const HIGH: f32 = 0.65;
268 const MID: f32 = 0.22;
270 const PADDING: f32 = 10.0;
272
273 pub fn new(now: Duration, period: Duration, length: u16) -> Self {
276 let track = Self::PADDING.mul_add(2.0, f32::from(length));
277 let period = period.as_secs_f32().max(f32::EPSILON);
278 let phase = (now.as_secs_f32() / period).fract();
279 Self { position: phase * track }
280 }
281
282 pub fn pick<T>(&self, cell: u16, low: T, mid: T, high: T) -> T {
287 let distance = (f32::from(cell) + Self::PADDING - self.position).abs();
288 if distance >= Self::HALF_WIDTH {
289 return low;
290 }
291 let angle = std::f32::consts::PI * distance / Self::HALF_WIDTH;
292 let intensity = f32::midpoint(1.0, angle.cos());
293 if intensity >= Self::HIGH {
294 high
295 } else if intensity >= Self::MID {
296 mid
297 } else {
298 low
299 }
300 }
301
302 pub fn style_at(&self, cell: u16, base: Style) -> Style {
309 let Color::Rgb(red, green, blue) = base.foreground_color() else {
310 return self.pick(cell, base, base, base.bold());
311 };
312 let lift = |channel: u8, fifths: u16| {
313 (u16::from(channel) + (255 - u16::from(channel)) * fifths / 5) as u8
314 };
315 let toward_white =
316 |fifths: u16| Color::Rgb(lift(red, fifths), lift(green, fifths), lift(blue, fifths));
317 self.pick(cell, base, base.fg(toward_white(1)), base.fg(toward_white(2)).bold())
318 }
319}
320
321#[derive(Clone, Copy, Debug, Default)]
340pub struct Reveal {
341 shown: f32,
343 last: Option<Duration>,
345}
346
347impl Reveal {
348 pub const MIN_RATE: f32 = 90.0;
350
351 pub const fn new() -> Self {
353 Self { shown: 0.0, last: None }
354 }
355
356 pub fn advance(&mut self, now: Duration, total: usize, horizon: Duration) -> usize {
360 let target = total as f32;
361 if self.shown >= target {
362 self.shown = target;
363 self.last = None;
364 return total;
365 }
366 let elapsed = self
369 .last
370 .map_or(Duration::ZERO, |prev| now.saturating_sub(prev).min(FRAME));
371 self.last = Some(now);
372 let horizon = horizon.as_secs_f32();
373 if horizon <= 0.0 {
374 self.shown = target;
375 self.last = None;
376 return total;
377 }
378 let mut backlog = target - self.shown;
379 let mut dt = elapsed.as_secs_f32();
380 let floor = Self::MIN_RATE * horizon;
383 if backlog > floor {
384 let cross = horizon * (backlog / floor).ln();
385 if dt < cross {
386 backlog *= (-dt / horizon).exp();
387 dt = 0.0;
388 } else {
389 backlog = floor;
390 dt -= cross;
391 }
392 }
393 backlog = Self::MIN_RATE.mul_add(-dt, backlog).max(0.0);
394 if backlog <= 0.0 {
395 self.shown = target;
396 self.last = None;
397 return total;
398 }
399 self.shown = target - backlog;
400 (self.shown as usize).min(total)
401 }
402
403 pub const fn reset(&mut self) {
405 self.shown = 0.0;
406 self.last = None;
407 }
408
409 pub fn is_settled(&self, total: usize) -> bool {
411 self.shown >= total as f32
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn easing_curves_hit_both_endpoints_and_stay_ordered() {
421 for easing in [Easing::Linear, Easing::EaseIn, Easing::EaseOut, Easing::EaseInOut] {
422 assert_eq!(easing.apply(0.0), 0.0, "{easing:?} must start at rest");
423 assert!((easing.apply(1.0) - 1.0).abs() < 1e-6, "{easing:?} must land on the target");
424 assert!(easing.apply(-1.0) == 0.0 && (easing.apply(2.0) - 1.0).abs() < 1e-6);
425 }
426 assert!(Easing::EaseIn.apply(0.25) < 0.25 && Easing::EaseOut.apply(0.25) > 0.25);
427 }
428
429 #[test]
430 fn color_lerp_blends_rgb_and_snaps_unblendable_endpoints() {
431 let midpoint = Color::Rgb(0, 100, 200).lerp(Color::Rgb(100, 200, 0), 0.5);
432 assert_eq!(midpoint, Color::Rgb(50, 150, 100));
433 assert_eq!(Color::Indexed(1).lerp(Color::Rgb(9, 9, 9), 0.4), Color::Indexed(1));
434 assert_eq!(Color::Indexed(1).lerp(Color::Rgb(9, 9, 9), 0.6), Color::Rgb(9, 9, 9));
435 }
436
437 #[test]
438 fn retarget_resumes_from_the_current_sample_without_jumping() {
439 let mut fade = Tween::settled(Color::Rgb(0, 0, 0));
440 fade.retarget(
441 Duration::ZERO,
442 Color::Rgb(200, 200, 200),
443 Duration::from_millis(400),
444 Easing::Linear,
445 );
446 let now = Duration::from_millis(200);
447 let midway = fade.sample(now);
448 assert_eq!(midway, Color::Rgb(100, 100, 100));
449
450 fade.retarget(now, Color::Rgb(0, 0, 0), Duration::from_millis(400), Easing::Linear);
452 assert_eq!(fade.sample(now), midway);
453 assert!(!fade.is_settled(Duration::from_millis(599)));
454 assert_eq!(fade.sample(Duration::from_millis(600)), Color::Rgb(0, 0, 0));
455 assert!(fade.is_settled(Duration::from_millis(600)));
456 }
457
458 #[test]
459 fn retargeting_the_same_target_keeps_the_running_tween() {
460 let mut fade = Tween::settled(0.0f32);
461 fade.retarget(Duration::ZERO, 1.0, Duration::from_millis(100), Easing::Linear);
462 fade.retarget(Duration::from_millis(50), 1.0, Duration::from_millis(100), Easing::Linear);
463 assert_eq!(fade.sample(Duration::from_millis(50)), 0.5);
464 }
465
466 #[test]
467 fn frame_cycles_wrap_and_predict_the_next_change() {
468 let cycle = Frames::new(&["a", "b", "c"], Duration::from_millis(10));
469 assert_eq!(cycle.at(Duration::ZERO), "a");
470 assert_eq!(cycle.at(Duration::from_millis(19)), "b");
471 assert_eq!(cycle.at(Duration::from_millis(35)), "a");
472 assert_eq!(cycle.next_change(Duration::from_millis(19)), Duration::from_millis(20));
473 assert_eq!(cycle.next_change(Duration::from_millis(20)), Duration::from_millis(30));
474 }
475
476 fn drain(reveal: &mut Reveal, from: Duration, total: usize, horizon: Duration) -> u32 {
478 let mut frames = 0;
479 while !reveal.is_settled(total) {
480 frames += 1;
481 assert!(frames < 1000, "reveal never settled");
482 reveal.advance(from + FRAME * frames, total, horizon);
483 }
484 frames
485 }
486
487 #[test]
488 fn reveal_arms_on_first_sample_then_drains_at_the_floor_rate() {
489 let mut reveal = Reveal::new();
490 let horizon = Duration::from_millis(250);
491 assert_eq!(reveal.advance(Duration::ZERO, 18, horizon), 0, "first sample only arms");
492 assert_eq!(reveal.advance(FRAME, 18, horizon), 2);
494 assert_eq!(reveal.advance(FRAME * 2, 18, horizon), 5);
495 assert_eq!(reveal.advance(FRAME * 3, 18, horizon), 8);
496 assert_eq!(drain(&mut reveal, FRAME * 3, 18, horizon), 4);
497 assert!(reveal.is_settled(18));
498 }
499
500 #[test]
501 fn reveal_catches_up_exponentially_then_settles_on_the_floor() {
502 let mut reveal = Reveal::new();
503 let horizon = Duration::from_millis(250);
504 reveal.advance(Duration::ZERO, 1000, horizon);
505 let shown = reveal.advance(FRAME, 1000, horizon);
507 assert!((110..=135).contains(&shown), "one frame reveals ~123 units, got {shown}");
508 let frames = drain(&mut reveal, FRAME, 1000, horizon);
511 assert!((30..=45).contains(&frames), "settled after {frames} more frames");
512 }
513
514 #[test]
515 fn reveal_never_earns_more_than_one_frame_per_sample() {
516 let mut reveal = Reveal::new();
517 let horizon = Duration::from_millis(250);
518 reveal.advance(Duration::ZERO, 20, horizon);
521 assert_eq!(reveal.advance(Duration::from_millis(400), 20, horizon), 2);
522
523 let mut idle = Reveal::new();
526 idle.advance(Duration::ZERO, 3, horizon);
527 idle.advance(FRAME, 3, horizon);
528 assert_eq!(idle.advance(FRAME * 2, 3, horizon), 3);
529 assert!(idle.is_settled(3));
530 assert_eq!(idle.advance(Duration::from_secs(60), 40, horizon), 3, "resume only arms");
531 let resumed = idle.advance(Duration::from_secs(60) + FRAME, 40, horizon);
532 assert!((4..=12).contains(&resumed), "one catch-up frame, not a jump: {resumed}");
533 }
534
535 #[test]
536 fn reveal_zero_horizon_snaps_and_a_smaller_total_clamps() {
537 let mut reveal = Reveal::new();
538 assert_eq!(reveal.advance(Duration::ZERO, 12, Duration::ZERO), 12);
539 assert_eq!(reveal.advance(Duration::from_secs(1), 5, Duration::from_millis(250)), 5);
540 assert!(reveal.is_settled(5));
541 reveal.reset();
542 assert_eq!(reveal.advance(Duration::from_secs(5), 12, Duration::from_millis(250)), 0);
543 }
544
545 #[test]
546 fn shimmer_bands_derive_from_an_rgb_foreground() {
547 use crate::frame::Style;
548 let shimmer = Shimmer::new(Duration::from_millis(200), Duration::from_secs(1), 30);
550 let base = Style::new().fg(Color::Rgb(120, 120, 120));
551
552 let peak = shimmer.style_at(0, base);
554 assert_eq!(peak.foreground_color(), Color::Rgb(174, 174, 174));
555 assert!(peak.bold && !peak.dim);
556 let shoulder = shimmer.style_at(3, base);
558 assert_eq!(shoulder.foreground_color(), Color::Rgb(147, 147, 147));
559 assert!(!shoulder.bold && !shoulder.dim);
560 assert_eq!(shimmer.style_at(29, base), base);
562
563 let fallback = Style::new();
565 assert!(shimmer.style_at(0, fallback).bold);
566 assert_eq!(shimmer.style_at(29, fallback), fallback);
567 }
568}