1use serde::Deserialize;
5use crate::Rgba;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
10#[serde(rename_all = "kebab-case")]
11pub enum Easing {
12 Linear,
13 #[default]
14 EaseInOut,
15 EaseOut,
16}
17
18impl Easing {
19 pub fn apply(self, t: f32) -> f32 {
21 let t = t.clamp(0.0, 1.0);
22 match self {
23 Easing::Linear => t,
24 Easing::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
25 Easing::EaseInOut => {
26 if t < 0.5 { 2.0 * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(2) / 2.0 }
27 }
28 }
29 }
30}
31
32pub fn lerp(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
33pub fn lerp2(a: [f32; 2], b: [f32; 2], t: f32) -> [f32; 2] {
34 [lerp(a[0], b[0], t), lerp(a[1], b[1], t)]
35}
36pub fn lerp4(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
37 [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t), lerp(a[3], b[3], t)]
38}
39
40pub fn pulse(phase_ms: f32, period_ms: f32) -> f32 {
43 if period_ms <= 0.0 { return 0.0; }
44 let x = (phase_ms / period_ms) * std::f32::consts::TAU;
45 0.5 - 0.5 * x.cos()
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum HaloEmphasis { Primary, Secondary }
53
54#[derive(Debug, Clone, Deserialize)]
55#[serde(deny_unknown_fields, default)]
56pub struct HaloSpec {
57 pub enabled: bool,
58 pub color: Rgba,
59 pub base_scale: f32,
61 pub gap: f32,
63 pub pulse_period_ms: f32,
64 pub pulse_amplitude: f32,
66 pub softness: f32,
68 pub secondary_alpha: f32,
70 pub secondary_amplitude: f32,
72}
73
74impl Default for HaloSpec {
75 fn default() -> Self {
76 Self {
77 enabled: true,
78 color: Rgba([0.45, 0.85, 1.0, 0.85]),
81 base_scale: 2.1,
82 gap: 8.0,
83 pulse_period_ms: 1800.0,
84 pulse_amplitude: 0.25,
85 softness: 0.5,
86 secondary_alpha: 0.5,
87 secondary_amplitude: 0.6,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Deserialize)]
93#[serde(deny_unknown_fields, default)]
94pub struct TransitionSpec {
95 pub duration_ms: f32,
96 pub easing: Easing,
97 pub camera_follow: bool,
99 pub layout_tween: bool,
101}
102
103impl Default for TransitionSpec {
104 fn default() -> Self {
105 Self { duration_ms: 260.0, easing: Easing::EaseInOut, camera_follow: true, layout_tween: true }
106 }
107}
108
109#[derive(Debug, Clone, Default, Deserialize)]
110#[serde(deny_unknown_fields, default)]
111pub struct AnimationSpec {
112 pub halo: HaloSpec,
113 pub transition: TransitionSpec,
114 pub reduced_motion: bool,
117}
118
119impl AnimationSpec {
120 pub fn effective_duration(&self) -> f32 {
122 if self.reduced_motion { 0.0 } else { self.transition.duration_ms }
123 }
124 pub fn pulse_animates(&self) -> bool { !self.reduced_motion }
126}
127
128#[derive(Debug, Clone, Default, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct PartialHaloStyle {
133 #[serde(default)] pub radius: Option<f32>,
134 #[serde(default)] pub color: Option<Rgba>,
135 #[serde(default)] pub opacity: Option<f32>,
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn easing_hits_endpoints_and_is_monotonic() {
144 for e in [Easing::Linear, Easing::EaseInOut, Easing::EaseOut] {
145 assert!((e.apply(0.0) - 0.0).abs() < 1e-6, "{e:?} @0");
146 assert!((e.apply(1.0) - 1.0).abs() < 1e-6, "{e:?} @1");
147 let mut prev = -1.0;
148 for i in 0..=10 {
149 let v = e.apply(i as f32 / 10.0);
150 assert!(v >= prev - 1e-6, "{e:?} not monotonic at {i}");
151 prev = v;
152 }
153 }
154 assert_eq!(Easing::Linear.apply(-1.0), 0.0);
155 assert_eq!(Easing::Linear.apply(2.0), 1.0);
156 }
157
158 #[test]
159 fn lerps_interpolate() {
160 assert_eq!(lerp(0.0, 10.0, 0.5), 5.0);
161 assert_eq!(lerp2([0.0, 0.0], [10.0, 20.0], 0.5), [5.0, 10.0]);
162 assert_eq!(lerp4([0.0; 4], [1.0; 4], 0.25), [0.25; 4]);
163 }
164
165 #[test]
166 fn pulse_is_bounded_and_periodic() {
167 assert!(pulse(0.0, 1000.0).abs() < 1e-4);
168 assert!((pulse(500.0, 1000.0) - 1.0).abs() < 1e-4);
169 assert!(pulse(1000.0, 1000.0).abs() < 1e-4);
170 for i in 0..50 {
171 let v = pulse(i as f32 * 37.0, 1000.0);
172 assert!((0.0..=1.0).contains(&v), "pulse out of range: {v}");
173 }
174 assert_eq!(pulse(123.0, 0.0), 0.0);
175 }
176
177 #[test]
178 fn animation_spec_defaults_are_sane() {
179 let a = AnimationSpec::default();
180 assert!(a.halo.enabled);
181 assert_eq!(a.transition.duration_ms, 260.0);
182 assert_eq!(a.transition.easing, Easing::EaseInOut);
183 assert!(a.transition.camera_follow);
184 assert!(a.transition.layout_tween);
185 assert!(!a.reduced_motion);
186 assert_eq!(a.halo.pulse_period_ms, 1800.0);
187 }
188
189 #[test]
190 fn animation_spec_parses_partial_json_and_keeps_defaults() {
191 let a: AnimationSpec =
192 serde_json::from_str(r#"{ "transition": { "camera_follow": false } }"#).unwrap();
193 assert!(!a.transition.camera_follow);
194 assert!(a.transition.layout_tween);
195 assert!(a.halo.enabled);
196 }
197
198 #[test]
199 fn animation_spec_rejects_unknown_fields() {
200 assert!(serde_json::from_str::<AnimationSpec>(r#"{ "wobble": true }"#).is_err());
201 assert!(serde_json::from_str::<AnimationSpec>(r#"{ "halo": { "nope": 1 } }"#).is_err());
202 }
203
204 #[test]
205 fn reduced_motion_collapses_duration_and_freezes_pulse() {
206 let mut a = AnimationSpec::default();
207 assert_eq!(a.effective_duration(), 260.0);
208 assert!(a.pulse_animates());
209 a.reduced_motion = true;
210 assert_eq!(a.effective_duration(), 0.0);
211 assert!(!a.pulse_animates());
212 }
213
214 #[test]
215 fn partial_halo_style_parses_and_denies_unknown() {
216 let p: PartialHaloStyle =
217 serde_json::from_str(r#"{ "radius": 30, "opacity": 0.5 }"#).unwrap();
218 assert_eq!(p.radius, Some(30.0));
219 assert_eq!(p.opacity, Some(0.5));
220 assert!(p.color.is_none());
221 assert!(serde_json::from_str::<PartialHaloStyle>(r#"{ "glow": 1 }"#).is_err());
222 }
223}