use ratatui::layout::Rect;
use ratatui::style::Color;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
#[derive(Clone, Copy, Debug)]
pub struct Anim {
start: Instant,
dur: Duration,
}
impl Anim {
pub fn new(now: Instant, dur: Duration) -> Self {
Anim { start: now, dur }
}
pub fn progress(&self, now: Instant) -> f32 {
let dur = self.dur.as_secs_f32();
if dur <= 0.0 {
return 1.0;
}
let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
(elapsed / dur).clamp(0.0, 1.0)
}
pub fn done(&self, now: Instant) -> bool {
self.progress(now) >= 1.0
}
pub fn elapsed(&self, now: Instant) -> Duration {
now.saturating_duration_since(self.start)
}
}
pub fn ease_out_cubic(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
1.0 - (1.0 - t).powi(3)
}
const ZOOM_START: f32 = 0.15;
pub fn zoom_rect(full: Rect, t: f32) -> Rect {
let scale = ZOOM_START + (1.0 - ZOOM_START) * t.clamp(0.0, 1.0);
let w = (full.width as f32 * scale).round() as u16;
let h = (full.height as f32 * scale).round() as u16;
let w = w.clamp(1, full.width.max(1));
let h = h.clamp(1, full.height.max(1));
Rect {
x: full.x + full.width.saturating_sub(w) / 2,
y: full.y + full.height.saturating_sub(h) / 2,
width: w,
height: h,
}
}
pub fn lerp_color(from: Color, to: Color, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
match (from, to) {
(Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) => {
Color::Rgb(lerp_u8(fr, tr, t), lerp_u8(fg, tg, t), lerp_u8(fb, tb, t))
}
_ => to,
}
}
fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
let a = a as f32;
let b = b as f32;
(a + (b - a) * t).round().clamp(0.0, 255.0) as u8
}
static ENABLED: OnceLock<bool> = OnceLock::new();
pub fn set_enabled(b: bool) {
let _ = ENABLED.set(b);
}
pub fn enabled() -> bool {
*ENABLED.get_or_init(|| true)
}
type Stat = (&'static str, u32, f32, f32);
static STATS: OnceLock<Mutex<Vec<Stat>>> = OnceLock::new();
fn stats_on() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| std::env::var_os("SUCHER_ANIM_STATS").is_some())
}
pub fn record(kind: &'static str, frames: u32, elapsed: Duration) {
if !stats_on() {
return;
}
let ms = elapsed.as_secs_f32() * 1000.0;
let fps = if ms > 0.0 {
frames as f32 / (ms / 1000.0)
} else {
0.0
};
if let Ok(mut v) = STATS.get_or_init(|| Mutex::new(Vec::new())).lock() {
v.push((kind, frames, ms, fps));
}
}
pub fn dump_stats() {
if !stats_on() {
return;
}
let Some(m) = STATS.get() else { return };
let Ok(v) = m.lock() else { return };
for (kind, frames, ms, fps) in v.iter() {
eprintln!("sucher anim: {kind}: {frames} frames in {ms:.1} ms → {fps:.0} fps");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Instant {
Instant::now()
}
#[test]
fn progress_endpoints_and_clamping() {
let now = base();
let a = Anim::new(now, Duration::from_millis(100));
assert_eq!(a.progress(now), 0.0);
assert_eq!(a.progress(now + Duration::from_millis(100)), 1.0);
assert_eq!(a.progress(now + Duration::from_millis(500)), 1.0);
let mid = a.progress(now + Duration::from_millis(50));
assert!((mid - 0.5).abs() < 1e-4, "mid was {mid}");
}
#[test]
fn zero_duration_is_immediately_complete() {
let now = base();
let a = Anim::new(now, Duration::from_millis(0));
assert_eq!(a.progress(now), 1.0);
assert!(a.done(now));
}
#[test]
fn done_transitions_at_the_end() {
let now = base();
let a = Anim::new(now, Duration::from_millis(100));
assert!(!a.done(now));
assert!(!a.done(now + Duration::from_millis(99)));
assert!(a.done(now + Duration::from_millis(100)));
assert!(a.done(now + Duration::from_millis(200)));
}
#[test]
fn ease_out_cubic_endpoints_monotonic_and_above_linear() {
assert_eq!(ease_out_cubic(0.0), 0.0);
assert_eq!(ease_out_cubic(1.0), 1.0);
assert_eq!(ease_out_cubic(-1.0), 0.0);
assert_eq!(ease_out_cubic(2.0), 1.0);
let mut prev = 0.0;
for i in 0..=20 {
let t = i as f32 / 20.0;
let e = ease_out_cubic(t);
assert!(e >= prev - 1e-6, "not monotonic at {t}");
assert!(e >= t - 1e-6, "ease below linear at {t}: {e} < {t}");
prev = e;
}
assert!((ease_out_cubic(0.5) - 0.875).abs() < 1e-6);
}
#[test]
fn zoom_rect_full_at_t_one() {
let full = Rect::new(3, 5, 80, 24);
assert_eq!(zoom_rect(full, 1.0), full);
assert_eq!(zoom_rect(full, 2.0), full);
}
#[test]
fn zoom_rect_small_and_centred_at_t_zero() {
let full = Rect::new(0, 0, 100, 40);
let r = zoom_rect(full, 0.0);
assert_eq!(r.width, 15);
assert_eq!(r.height, 6);
assert_eq!(r.x, (100 - 15) / 2);
assert_eq!(r.y, (40 - 6) / 2);
assert_eq!(zoom_rect(full, -1.0), r);
}
#[test]
fn zoom_rect_always_inside_and_symmetric() {
let full = Rect::new(7, 2, 81, 25); let mut prev_w = 0;
for i in 0..=20 {
let t = i as f32 / 20.0;
let r = zoom_rect(full, t);
assert!(r.width >= 1 && r.height >= 1, "empty at t={t}");
assert!(r.x >= full.x && r.y >= full.y, "escapes top-left at t={t}");
assert!(r.right() <= full.right(), "escapes right at t={t}: {r:?}");
assert!(
r.bottom() <= full.bottom(),
"escapes bottom at t={t}: {r:?}"
);
let left = r.x - full.x;
let right = full.right() - r.right();
assert!(left.abs_diff(right) <= 1, "off-centre x at t={t}");
let top = r.y - full.y;
let bottom = full.bottom() - r.bottom();
assert!(top.abs_diff(bottom) <= 1, "off-centre y at t={t}");
assert!(r.width >= prev_w, "width shrank at t={t}");
prev_w = r.width;
}
}
#[test]
fn zoom_rect_degenerate_full_does_not_panic() {
let r = zoom_rect(Rect::new(0, 0, 0, 0), 0.0);
assert_eq!((r.width, r.height), (1, 1));
assert_eq!((r.x, r.y), (0, 0));
}
#[test]
fn lerp_endpoints_and_midpoint() {
let from = Color::Rgb(16, 16, 20);
let to = Color::Rgb(96, 165, 250);
assert_eq!(lerp_color(from, to, 0.0), from);
assert_eq!(lerp_color(from, to, 1.0), to);
assert_eq!(lerp_color(from, to, 0.5), Color::Rgb(56, 91, 135));
}
#[test]
fn lerp_bg_to_color_at_one_is_identity() {
let bg = Color::Rgb(16, 16, 20);
for c in [
Color::Rgb(96, 165, 250),
Color::Rgb(0, 0, 0),
Color::Rgb(255, 255, 255),
Color::Rgb(120, 120, 132),
] {
assert_eq!(lerp_color(bg, c, 1.0), c);
}
}
#[test]
fn lerp_clamps_and_falls_back_for_non_rgb() {
let from = Color::Rgb(0, 0, 0);
let to = Color::Rgb(200, 200, 200);
assert_eq!(lerp_color(from, to, -1.0), from);
assert_eq!(lerp_color(from, to, 5.0), to);
assert_eq!(lerp_color(Color::Reset, to, 0.3), to);
}
}