use std::time::{Duration, Instant};
use crate::color::{lerp_rgb, Rgb};
#[derive(Debug, Clone, PartialEq)]
pub struct PulseFrame {
pub text: String,
pub color: Rgb,
pub progress: f32,
}
#[derive(Debug, Clone, Copy)]
pub struct PulseOpts {
pub period: Duration,
pub color_dim: Rgb,
pub color_bright: Rgb,
}
impl PulseOpts {
pub const fn cyan_breath() -> Self {
Self {
period: Duration::from_millis(1500),
color_dim: Rgb(40, 60, 80),
color_bright: Rgb(80, 200, 255),
}
}
}
impl Default for PulseOpts {
fn default() -> Self {
Self::cyan_breath()
}
}
#[derive(Debug)]
pub struct PulseHandle {
text: String,
started_at: Instant,
opts: PulseOpts,
}
impl PulseHandle {
pub fn start_at(text: &str, opts: PulseOpts, now: Instant) -> Self {
Self {
text: text.to_string(),
started_at: now,
opts,
}
}
pub fn start(text: &str, opts: PulseOpts) -> Self {
Self::start_at(text, opts, Instant::now())
}
pub fn snapshot(&self, now: Instant) -> PulseFrame {
let elapsed = now.saturating_duration_since(self.started_at);
let progress = pulse_progress(elapsed, self.opts.period);
let color = lerp_rgb(self.opts.color_dim, self.opts.color_bright, progress);
PulseFrame {
text: self.text.clone(),
color,
progress,
}
}
}
fn pulse_progress(elapsed: Duration, period: Duration) -> f32 {
if period.is_zero() {
return 1.0;
}
let t = elapsed.as_secs_f64() / period.as_secs_f64();
let phase = (t * std::f64::consts::TAU) - std::f64::consts::FRAC_PI_2;
(((1.0 + phase.sin()) / 2.0) as f32).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> PulseOpts {
PulseOpts {
period: Duration::from_millis(1000),
color_dim: Rgb(0, 0, 0),
color_bright: Rgb(200, 200, 200),
}
}
fn epoch() -> Instant {
Instant::now()
}
#[test]
fn progress_is_zero_at_t_zero() {
let now = epoch();
let h = PulseHandle::start_at("♪", opts(), now);
let frame = h.snapshot(now);
assert!(frame.progress.abs() < 1e-3);
assert_eq!(frame.color, Rgb(0, 0, 0));
assert_eq!(frame.text, "♪");
}
#[test]
fn progress_peaks_at_half_period() {
let now = epoch();
let h = PulseHandle::start_at("♪", opts(), now);
let frame = h.snapshot(now + Duration::from_millis(500));
assert!((frame.progress - 1.0).abs() < 1e-3);
assert_eq!(frame.color, Rgb(200, 200, 200));
}
#[test]
fn progress_returns_to_zero_at_full_period() {
let now = epoch();
let h = PulseHandle::start_at("♪", opts(), now);
let frame = h.snapshot(now + Duration::from_millis(1000));
assert!(frame.progress.abs() < 1e-3);
}
#[test]
fn quarter_period_is_midway() {
let now = epoch();
let h = PulseHandle::start_at("♪", opts(), now);
let frame = h.snapshot(now + Duration::from_millis(250));
assert!((frame.progress - 0.5).abs() < 1e-3);
assert_eq!(frame.color, Rgb(100, 100, 100));
}
#[test]
fn pulse_repeats_each_period() {
let now = epoch();
let h = PulseHandle::start_at("♪", opts(), now);
let a = h.snapshot(now + Duration::from_millis(300));
let b = h.snapshot(now + Duration::from_millis(1300));
assert!((a.progress - b.progress).abs() < 1e-3);
assert_eq!(a.color, b.color);
}
#[test]
fn zero_period_pins_at_bright() {
let mut o = opts();
o.period = Duration::ZERO;
let now = epoch();
let h = PulseHandle::start_at("♪", o, now);
let frame = h.snapshot(now);
assert_eq!(frame.progress, 1.0);
assert_eq!(frame.color, o.color_bright);
}
#[test]
fn arbitrary_text_is_passed_through() {
let now = epoch();
let h = PulseHandle::start_at("●", opts(), now);
assert_eq!(h.snapshot(now).text, "●");
}
#[test]
fn default_matches_cyan_breath_preset() {
let a = PulseOpts::default();
let b = PulseOpts::cyan_breath();
assert_eq!(a.period, b.period);
assert_eq!(a.color_dim, b.color_dim);
assert_eq!(a.color_bright, b.color_bright);
}
}