use serde::Deserialize;
use crate::Rgba;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Easing {
Linear,
#[default]
EaseInOut,
EaseOut,
}
impl Easing {
pub fn apply(self, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
match self {
Easing::Linear => t,
Easing::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
Easing::EaseInOut => {
if t < 0.5 { 2.0 * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(2) / 2.0 }
}
}
}
}
pub fn lerp(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
pub fn lerp2(a: [f32; 2], b: [f32; 2], t: f32) -> [f32; 2] {
[lerp(a[0], b[0], t), lerp(a[1], b[1], t)]
}
pub fn lerp4(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
[lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t), lerp(a[3], b[3], t)]
}
pub fn pulse(phase_ms: f32, period_ms: f32) -> f32 {
if period_ms <= 0.0 { return 0.0; }
let x = (phase_ms / period_ms) * std::f32::consts::TAU;
0.5 - 0.5 * x.cos()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HaloEmphasis { Primary, Secondary }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct HaloSpec {
pub enabled: bool,
pub color: Rgba,
pub base_scale: f32,
pub gap: f32,
pub pulse_period_ms: f32,
pub pulse_amplitude: f32,
pub softness: f32,
pub secondary_alpha: f32,
pub secondary_amplitude: f32,
}
impl Default for HaloSpec {
fn default() -> Self {
Self {
enabled: true,
color: Rgba([0.45, 0.85, 1.0, 0.85]),
base_scale: 2.1,
gap: 8.0,
pulse_period_ms: 1800.0,
pulse_amplitude: 0.25,
softness: 0.5,
secondary_alpha: 0.5,
secondary_amplitude: 0.6,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct TransitionSpec {
pub duration_ms: f32,
pub easing: Easing,
pub camera_follow: bool,
pub layout_tween: bool,
}
impl Default for TransitionSpec {
fn default() -> Self {
Self { duration_ms: 260.0, easing: Easing::EaseInOut, camera_follow: true, layout_tween: true }
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct AnimationSpec {
pub halo: HaloSpec,
pub transition: TransitionSpec,
pub reduced_motion: bool,
}
impl AnimationSpec {
pub fn effective_duration(&self) -> f32 {
if self.reduced_motion { 0.0 } else { self.transition.duration_ms }
}
pub fn pulse_animates(&self) -> bool { !self.reduced_motion }
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialHaloStyle {
#[serde(default)] pub radius: Option<f32>,
#[serde(default)] pub color: Option<Rgba>,
#[serde(default)] pub opacity: Option<f32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn easing_hits_endpoints_and_is_monotonic() {
for e in [Easing::Linear, Easing::EaseInOut, Easing::EaseOut] {
assert!((e.apply(0.0) - 0.0).abs() < 1e-6, "{e:?} @0");
assert!((e.apply(1.0) - 1.0).abs() < 1e-6, "{e:?} @1");
let mut prev = -1.0;
for i in 0..=10 {
let v = e.apply(i as f32 / 10.0);
assert!(v >= prev - 1e-6, "{e:?} not monotonic at {i}");
prev = v;
}
}
assert_eq!(Easing::Linear.apply(-1.0), 0.0);
assert_eq!(Easing::Linear.apply(2.0), 1.0);
}
#[test]
fn lerps_interpolate() {
assert_eq!(lerp(0.0, 10.0, 0.5), 5.0);
assert_eq!(lerp2([0.0, 0.0], [10.0, 20.0], 0.5), [5.0, 10.0]);
assert_eq!(lerp4([0.0; 4], [1.0; 4], 0.25), [0.25; 4]);
}
#[test]
fn pulse_is_bounded_and_periodic() {
assert!(pulse(0.0, 1000.0).abs() < 1e-4);
assert!((pulse(500.0, 1000.0) - 1.0).abs() < 1e-4);
assert!(pulse(1000.0, 1000.0).abs() < 1e-4);
for i in 0..50 {
let v = pulse(i as f32 * 37.0, 1000.0);
assert!((0.0..=1.0).contains(&v), "pulse out of range: {v}");
}
assert_eq!(pulse(123.0, 0.0), 0.0);
}
#[test]
fn animation_spec_defaults_are_sane() {
let a = AnimationSpec::default();
assert!(a.halo.enabled);
assert_eq!(a.transition.duration_ms, 260.0);
assert_eq!(a.transition.easing, Easing::EaseInOut);
assert!(a.transition.camera_follow);
assert!(a.transition.layout_tween);
assert!(!a.reduced_motion);
assert_eq!(a.halo.pulse_period_ms, 1800.0);
}
#[test]
fn animation_spec_parses_partial_json_and_keeps_defaults() {
let a: AnimationSpec =
serde_json::from_str(r#"{ "transition": { "camera_follow": false } }"#).unwrap();
assert!(!a.transition.camera_follow);
assert!(a.transition.layout_tween);
assert!(a.halo.enabled);
}
#[test]
fn animation_spec_rejects_unknown_fields() {
assert!(serde_json::from_str::<AnimationSpec>(r#"{ "wobble": true }"#).is_err());
assert!(serde_json::from_str::<AnimationSpec>(r#"{ "halo": { "nope": 1 } }"#).is_err());
}
#[test]
fn reduced_motion_collapses_duration_and_freezes_pulse() {
let mut a = AnimationSpec::default();
assert_eq!(a.effective_duration(), 260.0);
assert!(a.pulse_animates());
a.reduced_motion = true;
assert_eq!(a.effective_duration(), 0.0);
assert!(!a.pulse_animates());
}
#[test]
fn partial_halo_style_parses_and_denies_unknown() {
let p: PartialHaloStyle =
serde_json::from_str(r#"{ "radius": 30, "opacity": 0.5 }"#).unwrap();
assert_eq!(p.radius, Some(30.0));
assert_eq!(p.opacity, Some(0.5));
assert!(p.color.is_none());
assert!(serde_json::from_str::<PartialHaloStyle>(r#"{ "glow": 1 }"#).is_err());
}
}