use std::time::Duration;
use crate::frame::{Color, Style};
pub const FRAME: Duration = Duration::from_millis(33);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Easing {
#[default]
Linear,
EaseIn,
EaseOut,
EaseInOut,
}
impl Easing {
#[must_use]
pub fn apply(self, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
match self {
Self::Linear => t,
Self::EaseIn => t * t * t,
Self::EaseOut => {
let inv = 1.0 - t;
inv.mul_add(-inv * inv, 1.0)
},
Self::EaseInOut if t < 0.5 => 4.0 * t * t * t,
Self::EaseInOut => {
let inv = (-2.0f32).mul_add(t, 2.0);
inv.mul_add(-inv * inv / 2.0, 1.0)
},
}
}
}
pub trait Lerp: Copy {
#[must_use]
fn lerp(self, to: Self, t: f32) -> Self;
}
impl Lerp for f32 {
fn lerp(self, to: Self, t: f32) -> Self {
(to - self).mul_add(t, self)
}
}
impl Lerp for u8 {
fn lerp(self, to: Self, t: f32) -> Self {
f32::from(self).lerp(f32::from(to), t).round() as Self
}
}
impl Lerp for u16 {
fn lerp(self, to: Self, t: f32) -> Self {
f32::from(self).lerp(f32::from(to), t).round() as Self
}
}
impl Lerp for Color {
fn lerp(self, to: Self, t: f32) -> Self {
match (self, to) {
(Self::Rgb(r0, g0, b0), Self::Rgb(r1, g1, b1)) => {
Self::Rgb(r0.lerp(r1, t), g0.lerp(g1, t), b0.lerp(b1, t))
},
_ if t < 0.5 => self,
_ => to,
}
}
}
impl<A: Lerp, B: Lerp> Lerp for (A, B) {
fn lerp(self, to: Self, t: f32) -> Self {
(self.0.lerp(to.0, t), self.1.lerp(to.1, t))
}
}
#[derive(Clone, Copy, Debug)]
pub struct Tween<T: Lerp> {
from: T,
to: T,
start: Duration,
duration: Duration,
easing: Easing,
}
impl<T: Lerp> Tween<T> {
pub const fn settled(value: T) -> Self {
Self {
from: value,
to: value,
start: Duration::ZERO,
duration: Duration::ZERO,
easing: Easing::Linear,
}
}
pub fn sample(&self, now: Duration) -> T {
if self.duration.is_zero() {
return self.to;
}
let t = now
.saturating_sub(self.start)
.div_duration_f32(self.duration);
self.from.lerp(self.to, self.easing.apply(t))
}
pub const fn target(&self) -> T {
self.to
}
pub fn is_settled(&self, now: Duration) -> bool {
now >= self.settles_at()
}
pub const fn settles_at(&self) -> Duration {
self.start.saturating_add(self.duration)
}
pub fn retarget(&mut self, now: Duration, to: T, duration: Duration, easing: Easing)
where
T: PartialEq,
{
if self.to == to {
return;
}
self.from = self.sample(now);
self.to = to;
self.start = now;
self.duration = duration;
self.easing = easing;
}
}
#[derive(Clone, Copy, Debug)]
pub struct Frames {
frames: &'static [&'static str],
interval: Duration,
}
impl Frames {
pub const SPINNER: Self =
Self::new(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], Duration::from_millis(80));
pub const SPINNER_ASCII: Self = Self::new(&["|", "/", "-", "\\"], Duration::from_millis(120));
pub const fn new(frames: &'static [&'static str], interval: Duration) -> Self {
assert!(!frames.is_empty(), "a frame cycle needs at least one frame");
assert!(!interval.is_zero(), "a frame cycle needs a nonzero interval");
Self { frames, interval }
}
pub const fn at(&self, now: Duration) -> &'static str {
let step = now.as_nanos() / self.interval.as_nanos();
self.frames[(step % self.frames.len() as u128) as usize]
}
pub const fn next_change(&self, now: Duration) -> Duration {
let interval = self.interval.as_nanos();
let remaining = interval - now.as_nanos() % interval;
now.saturating_add(Duration::from_nanos(remaining as u64))
}
}
#[derive(Clone, Copy, Debug)]
pub struct Shimmer {
position: f32,
}
impl Shimmer {
const HALF_WIDTH: f32 = 6.0;
const HIGH: f32 = 0.65;
const MID: f32 = 0.22;
const PADDING: f32 = 10.0;
pub fn new(now: Duration, period: Duration, length: u16) -> Self {
let track = Self::PADDING.mul_add(2.0, f32::from(length));
let period = period.as_secs_f32().max(f32::EPSILON);
let phase = (now.as_secs_f32() / period).fract();
Self { position: phase * track }
}
pub fn pick<T>(&self, cell: u16, low: T, mid: T, high: T) -> T {
let distance = (f32::from(cell) + Self::PADDING - self.position).abs();
if distance >= Self::HALF_WIDTH {
return low;
}
let angle = std::f32::consts::PI * distance / Self::HALF_WIDTH;
let intensity = f32::midpoint(1.0, angle.cos());
if intensity >= Self::HIGH {
high
} else if intensity >= Self::MID {
mid
} else {
low
}
}
pub fn style_at(&self, cell: u16, base: Style) -> Style {
let Color::Rgb(red, green, blue) = base.foreground_color() else {
return self.pick(cell, base, base, base.bold());
};
let lift = |channel: u8, fifths: u16| {
(u16::from(channel) + (255 - u16::from(channel)) * fifths / 5) as u8
};
let toward_white =
|fifths: u16| Color::Rgb(lift(red, fifths), lift(green, fifths), lift(blue, fifths));
self.pick(cell, base, base.fg(toward_white(1)), base.fg(toward_white(2)).bold())
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Reveal {
shown: f32,
last: Option<Duration>,
}
impl Reveal {
pub const MIN_RATE: f32 = 90.0;
pub const fn new() -> Self {
Self { shown: 0.0, last: None }
}
pub fn advance(&mut self, now: Duration, total: usize, horizon: Duration) -> usize {
let target = total as f32;
if self.shown >= target {
self.shown = target;
self.last = None;
return total;
}
let elapsed = self
.last
.map_or(Duration::ZERO, |prev| now.saturating_sub(prev).min(FRAME));
self.last = Some(now);
let horizon = horizon.as_secs_f32();
if horizon <= 0.0 {
self.shown = target;
self.last = None;
return total;
}
let mut backlog = target - self.shown;
let mut dt = elapsed.as_secs_f32();
let floor = Self::MIN_RATE * horizon;
if backlog > floor {
let cross = horizon * (backlog / floor).ln();
if dt < cross {
backlog *= (-dt / horizon).exp();
dt = 0.0;
} else {
backlog = floor;
dt -= cross;
}
}
backlog = Self::MIN_RATE.mul_add(-dt, backlog).max(0.0);
if backlog <= 0.0 {
self.shown = target;
self.last = None;
return total;
}
self.shown = target - backlog;
(self.shown as usize).min(total)
}
pub const fn reset(&mut self) {
self.shown = 0.0;
self.last = None;
}
pub fn is_settled(&self, total: usize) -> bool {
self.shown >= total as f32
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn easing_curves_hit_both_endpoints_and_stay_ordered() {
for easing in [Easing::Linear, Easing::EaseIn, Easing::EaseOut, Easing::EaseInOut] {
assert_eq!(easing.apply(0.0), 0.0, "{easing:?} must start at rest");
assert!((easing.apply(1.0) - 1.0).abs() < 1e-6, "{easing:?} must land on the target");
assert!(easing.apply(-1.0) == 0.0 && (easing.apply(2.0) - 1.0).abs() < 1e-6);
}
assert!(Easing::EaseIn.apply(0.25) < 0.25 && Easing::EaseOut.apply(0.25) > 0.25);
}
#[test]
fn color_lerp_blends_rgb_and_snaps_unblendable_endpoints() {
let midpoint = Color::Rgb(0, 100, 200).lerp(Color::Rgb(100, 200, 0), 0.5);
assert_eq!(midpoint, Color::Rgb(50, 150, 100));
assert_eq!(Color::Indexed(1).lerp(Color::Rgb(9, 9, 9), 0.4), Color::Indexed(1));
assert_eq!(Color::Indexed(1).lerp(Color::Rgb(9, 9, 9), 0.6), Color::Rgb(9, 9, 9));
}
#[test]
fn retarget_resumes_from_the_current_sample_without_jumping() {
let mut fade = Tween::settled(Color::Rgb(0, 0, 0));
fade.retarget(
Duration::ZERO,
Color::Rgb(200, 200, 200),
Duration::from_millis(400),
Easing::Linear,
);
let now = Duration::from_millis(200);
let midway = fade.sample(now);
assert_eq!(midway, Color::Rgb(100, 100, 100));
fade.retarget(now, Color::Rgb(0, 0, 0), Duration::from_millis(400), Easing::Linear);
assert_eq!(fade.sample(now), midway);
assert!(!fade.is_settled(Duration::from_millis(599)));
assert_eq!(fade.sample(Duration::from_millis(600)), Color::Rgb(0, 0, 0));
assert!(fade.is_settled(Duration::from_millis(600)));
}
#[test]
fn retargeting_the_same_target_keeps_the_running_tween() {
let mut fade = Tween::settled(0.0f32);
fade.retarget(Duration::ZERO, 1.0, Duration::from_millis(100), Easing::Linear);
fade.retarget(Duration::from_millis(50), 1.0, Duration::from_millis(100), Easing::Linear);
assert_eq!(fade.sample(Duration::from_millis(50)), 0.5);
}
#[test]
fn frame_cycles_wrap_and_predict_the_next_change() {
let cycle = Frames::new(&["a", "b", "c"], Duration::from_millis(10));
assert_eq!(cycle.at(Duration::ZERO), "a");
assert_eq!(cycle.at(Duration::from_millis(19)), "b");
assert_eq!(cycle.at(Duration::from_millis(35)), "a");
assert_eq!(cycle.next_change(Duration::from_millis(19)), Duration::from_millis(20));
assert_eq!(cycle.next_change(Duration::from_millis(20)), Duration::from_millis(30));
}
fn drain(reveal: &mut Reveal, from: Duration, total: usize, horizon: Duration) -> u32 {
let mut frames = 0;
while !reveal.is_settled(total) {
frames += 1;
assert!(frames < 1000, "reveal never settled");
reveal.advance(from + FRAME * frames, total, horizon);
}
frames
}
#[test]
fn reveal_arms_on_first_sample_then_drains_at_the_floor_rate() {
let mut reveal = Reveal::new();
let horizon = Duration::from_millis(250);
assert_eq!(reveal.advance(Duration::ZERO, 18, horizon), 0, "first sample only arms");
assert_eq!(reveal.advance(FRAME, 18, horizon), 2);
assert_eq!(reveal.advance(FRAME * 2, 18, horizon), 5);
assert_eq!(reveal.advance(FRAME * 3, 18, horizon), 8);
assert_eq!(drain(&mut reveal, FRAME * 3, 18, horizon), 4);
assert!(reveal.is_settled(18));
}
#[test]
fn reveal_catches_up_exponentially_then_settles_on_the_floor() {
let mut reveal = Reveal::new();
let horizon = Duration::from_millis(250);
reveal.advance(Duration::ZERO, 1000, horizon);
let shown = reveal.advance(FRAME, 1000, horizon);
assert!((110..=135).contains(&shown), "one frame reveals ~123 units, got {shown}");
let frames = drain(&mut reveal, FRAME, 1000, horizon);
assert!((30..=45).contains(&frames), "settled after {frames} more frames");
}
#[test]
fn reveal_never_earns_more_than_one_frame_per_sample() {
let mut reveal = Reveal::new();
let horizon = Duration::from_millis(250);
reveal.advance(Duration::ZERO, 20, horizon);
assert_eq!(reveal.advance(Duration::from_millis(400), 20, horizon), 2);
let mut idle = Reveal::new();
idle.advance(Duration::ZERO, 3, horizon);
idle.advance(FRAME, 3, horizon);
assert_eq!(idle.advance(FRAME * 2, 3, horizon), 3);
assert!(idle.is_settled(3));
assert_eq!(idle.advance(Duration::from_secs(60), 40, horizon), 3, "resume only arms");
let resumed = idle.advance(Duration::from_secs(60) + FRAME, 40, horizon);
assert!((4..=12).contains(&resumed), "one catch-up frame, not a jump: {resumed}");
}
#[test]
fn reveal_zero_horizon_snaps_and_a_smaller_total_clamps() {
let mut reveal = Reveal::new();
assert_eq!(reveal.advance(Duration::ZERO, 12, Duration::ZERO), 12);
assert_eq!(reveal.advance(Duration::from_secs(1), 5, Duration::from_millis(250)), 5);
assert!(reveal.is_settled(5));
reveal.reset();
assert_eq!(reveal.advance(Duration::from_secs(5), 12, Duration::from_millis(250)), 0);
}
#[test]
fn shimmer_bands_derive_from_an_rgb_foreground() {
use crate::frame::Style;
let shimmer = Shimmer::new(Duration::from_millis(200), Duration::from_secs(1), 30);
let base = Style::new().fg(Color::Rgb(120, 120, 120));
let peak = shimmer.style_at(0, base);
assert_eq!(peak.foreground_color(), Color::Rgb(174, 174, 174));
assert!(peak.bold && !peak.dim);
let shoulder = shimmer.style_at(3, base);
assert_eq!(shoulder.foreground_color(), Color::Rgb(147, 147, 147));
assert!(!shoulder.bold && !shoulder.dim);
assert_eq!(shimmer.style_at(29, base), base);
let fallback = Style::new();
assert!(shimmer.style_at(0, fallback).bold);
assert_eq!(shimmer.style_at(29, fallback), fallback);
}
}