Skip to main content

animato_js/
easing.rs

1//! JavaScript-friendly easing parser and utility exports.
2
3use crate::error::{JsResult, js_error};
4use crate::types::{normalize_name, string_array};
5use animato_core::Easing;
6use js_sys::Array;
7use wasm_bindgen::prelude::*;
8
9/// Canonical easing names exposed to JavaScript picker UIs.
10pub const EASING_NAMES: &[&str] = &[
11    "linear",
12    "easeInQuad",
13    "easeOutQuad",
14    "easeInOutQuad",
15    "easeInCubic",
16    "easeOutCubic",
17    "easeInOutCubic",
18    "easeInQuart",
19    "easeOutQuart",
20    "easeInOutQuart",
21    "easeInQuint",
22    "easeOutQuint",
23    "easeInOutQuint",
24    "easeInSine",
25    "easeOutSine",
26    "easeInOutSine",
27    "easeInExpo",
28    "easeOutExpo",
29    "easeInOutExpo",
30    "easeInCirc",
31    "easeOutCirc",
32    "easeInOutCirc",
33    "easeInBack",
34    "easeOutBack",
35    "easeInOutBack",
36    "easeInElastic",
37    "easeOutElastic",
38    "easeInOutElastic",
39    "easeInBounce",
40    "easeOutBounce",
41    "easeInOutBounce",
42    "steps(n)",
43    "cubicBezier(x1,y1,x2,y2)",
44    "roughEase(strength,points)",
45    "slowMo(linearRatio,power)",
46    "wiggle(wiggles)",
47    "customBounce(strength)",
48    "expoScale(start,end)",
49];
50
51/// Parse a JavaScript-friendly easing name into Animato's [`Easing`] enum.
52pub fn parse_easing(name: &str) -> JsResult<Easing> {
53    let trimmed = name.trim();
54    if let Some(args) = call_args(trimmed, "steps") {
55        let values = parse_numbers(args)?;
56        if values.len() != 1 {
57            return Err(js_error("steps() expects one numeric argument"));
58        }
59        return Ok(Easing::Steps(values[0].max(1.0).round() as u32));
60    }
61    if let Some(args) =
62        call_args(trimmed, "cubicBezier").or_else(|| call_args(trimmed, "cubic-bezier"))
63    {
64        let values = parse_numbers(args)?;
65        if values.len() != 4 {
66            return Err(js_error("cubicBezier() expects four numeric arguments"));
67        }
68        return Ok(Easing::CubicBezier(
69            values[0], values[1], values[2], values[3],
70        ));
71    }
72    if let Some(args) = call_args(trimmed, "roughEase") {
73        let values = parse_numbers(args)?;
74        if values.len() != 2 {
75            return Err(js_error("roughEase() expects strength and points"));
76        }
77        return Ok(Easing::RoughEase {
78            strength: values[0],
79            points: values[1].round().max(1.0) as u32,
80        });
81    }
82    if let Some(args) = call_args(trimmed, "slowMo") {
83        let values = parse_numbers(args)?;
84        if values.len() != 2 {
85            return Err(js_error("slowMo() expects linearRatio and power"));
86        }
87        return Ok(Easing::SlowMo {
88            linear_ratio: values[0],
89            power: values[1],
90        });
91    }
92    if let Some(args) = call_args(trimmed, "wiggle") {
93        let values = parse_numbers(args)?;
94        if values.len() != 1 {
95            return Err(js_error("wiggle() expects one numeric argument"));
96        }
97        return Ok(Easing::Wiggle {
98            wiggles: values[0].round().max(1.0) as u32,
99        });
100    }
101    if let Some(args) = call_args(trimmed, "customBounce") {
102        let values = parse_numbers(args)?;
103        if values.len() != 1 {
104            return Err(js_error("customBounce() expects one numeric argument"));
105        }
106        return Ok(Easing::CustomBounce {
107            strength: values[0],
108        });
109    }
110    if let Some(args) = call_args(trimmed, "expoScale") {
111        let values = parse_numbers(args)?;
112        if values.len() != 2 {
113            return Err(js_error("expoScale() expects start and end"));
114        }
115        return Ok(Easing::ExpoScale {
116            start: values[0],
117            end: values[1],
118        });
119    }
120
121    match normalize_name(trimmed).as_str() {
122        "linear" => Ok(Easing::Linear),
123        "easeinquad" => Ok(Easing::EaseInQuad),
124        "easeoutquad" => Ok(Easing::EaseOutQuad),
125        "easeinoutquad" => Ok(Easing::EaseInOutQuad),
126        "easeincubic" => Ok(Easing::EaseInCubic),
127        "easeoutcubic" => Ok(Easing::EaseOutCubic),
128        "easeinoutcubic" => Ok(Easing::EaseInOutCubic),
129        "easeinquart" => Ok(Easing::EaseInQuart),
130        "easeoutquart" => Ok(Easing::EaseOutQuart),
131        "easeinoutquart" => Ok(Easing::EaseInOutQuart),
132        "easeinquint" => Ok(Easing::EaseInQuint),
133        "easeoutquint" => Ok(Easing::EaseOutQuint),
134        "easeinoutquint" => Ok(Easing::EaseInOutQuint),
135        "easeinsine" => Ok(Easing::EaseInSine),
136        "easeoutsine" => Ok(Easing::EaseOutSine),
137        "easeinoutsine" => Ok(Easing::EaseInOutSine),
138        "easeinexpo" => Ok(Easing::EaseInExpo),
139        "easeoutexpo" => Ok(Easing::EaseOutExpo),
140        "easeinoutexpo" => Ok(Easing::EaseInOutExpo),
141        "easeincirc" => Ok(Easing::EaseInCirc),
142        "easeoutcirc" => Ok(Easing::EaseOutCirc),
143        "easeinoutcirc" => Ok(Easing::EaseInOutCirc),
144        "easeinback" => Ok(Easing::EaseInBack),
145        "easeoutback" => Ok(Easing::EaseOutBack),
146        "easeinoutback" => Ok(Easing::EaseInOutBack),
147        "easeinelastic" => Ok(Easing::EaseInElastic),
148        "easeoutelastic" => Ok(Easing::EaseOutElastic),
149        "easeinoutelastic" => Ok(Easing::EaseInOutElastic),
150        "easeinbounce" => Ok(Easing::EaseInBounce),
151        "easeoutbounce" => Ok(Easing::EaseOutBounce),
152        "easeinoutbounce" => Ok(Easing::EaseInOutBounce),
153        _ => Err(js_error(format!("unknown easing `{name}`"))),
154    }
155}
156
157/// Return a canonical string for a parsed easing value.
158pub fn easing_name(easing: &Easing) -> &'static str {
159    match easing {
160        Easing::Linear => "linear",
161        Easing::EaseInQuad => "easeInQuad",
162        Easing::EaseOutQuad => "easeOutQuad",
163        Easing::EaseInOutQuad => "easeInOutQuad",
164        Easing::EaseInCubic => "easeInCubic",
165        Easing::EaseOutCubic => "easeOutCubic",
166        Easing::EaseInOutCubic => "easeInOutCubic",
167        Easing::EaseInQuart => "easeInQuart",
168        Easing::EaseOutQuart => "easeOutQuart",
169        Easing::EaseInOutQuart => "easeInOutQuart",
170        Easing::EaseInQuint => "easeInQuint",
171        Easing::EaseOutQuint => "easeOutQuint",
172        Easing::EaseInOutQuint => "easeInOutQuint",
173        Easing::EaseInSine => "easeInSine",
174        Easing::EaseOutSine => "easeOutSine",
175        Easing::EaseInOutSine => "easeInOutSine",
176        Easing::EaseInExpo => "easeInExpo",
177        Easing::EaseOutExpo => "easeOutExpo",
178        Easing::EaseInOutExpo => "easeInOutExpo",
179        Easing::EaseInCirc => "easeInCirc",
180        Easing::EaseOutCirc => "easeOutCirc",
181        Easing::EaseInOutCirc => "easeInOutCirc",
182        Easing::EaseInBack => "easeInBack",
183        Easing::EaseOutBack => "easeOutBack",
184        Easing::EaseInOutBack => "easeInOutBack",
185        Easing::EaseInElastic => "easeInElastic",
186        Easing::EaseOutElastic => "easeOutElastic",
187        Easing::EaseInOutElastic => "easeInOutElastic",
188        Easing::EaseInBounce => "easeInBounce",
189        Easing::EaseOutBounce => "easeOutBounce",
190        Easing::EaseInOutBounce => "easeInOutBounce",
191        Easing::CubicBezier(..) => "cubicBezier",
192        Easing::Steps(_) => "steps",
193        Easing::RoughEase { .. } => "roughEase",
194        Easing::SlowMo { .. } => "slowMo",
195        Easing::Wiggle { .. } => "wiggle",
196        Easing::CustomBounce { .. } => "customBounce",
197        Easing::ExpoScale { .. } => "expoScale",
198        Easing::Custom(_) => "custom",
199    }
200}
201
202/// Parse an easing name and return its canonical name.
203#[wasm_bindgen(js_name = parseEasing)]
204pub fn parse_easing_for_js(name: &str) -> Result<String, JsValue> {
205    Ok(easing_name(&parse_easing(name)?).to_owned())
206}
207
208/// Apply an easing by name to normalized progress `t`.
209#[wasm_bindgen]
210pub fn ease(name: &str, t: f32) -> Result<f32, JsValue> {
211    Ok(parse_easing(name)?.apply(t))
212}
213
214/// Return every available easing name.
215#[wasm_bindgen(js_name = availableEasings)]
216pub fn available_easings() -> Array {
217    string_array(EASING_NAMES)
218}
219
220fn call_args<'a>(input: &'a str, function_name: &str) -> Option<&'a str> {
221    let input_normalized = normalize_name(input);
222    let name_normalized = normalize_name(function_name);
223    if !input_normalized.starts_with(&name_normalized) {
224        return None;
225    }
226    let open = input.find('(')?;
227    let close = input.rfind(')')?;
228    if close <= open {
229        return None;
230    }
231    Some(&input[open + 1..close])
232}
233
234fn parse_numbers(input: &str) -> JsResult<Vec<f32>> {
235    if input.trim().is_empty() {
236        return Ok(Vec::new());
237    }
238    input
239        .split(',')
240        .map(|part| {
241            let value = part
242                .trim()
243                .parse::<f32>()
244                .map_err(|_| js_error(format!("invalid numeric argument `{}`", part.trim())))?;
245            if value.is_finite() {
246                Ok(value)
247            } else {
248                Err(js_error("numeric arguments must be finite"))
249            }
250        })
251        .collect()
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn parses_named_easing() {
260        assert_eq!(
261            parse_easing("ease-out-cubic").unwrap(),
262            Easing::EaseOutCubic
263        );
264        assert_eq!(
265            parse_easing("easeInOutBack").unwrap(),
266            Easing::EaseInOutBack
267        );
268    }
269
270    #[test]
271    fn parses_parameterized_easing() {
272        assert_eq!(parse_easing("steps(5)").unwrap(), Easing::Steps(5));
273        assert_eq!(
274            parse_easing("cubicBezier(0.4, 0, 0.2, 1)").unwrap(),
275            Easing::CubicBezier(0.4, 0.0, 0.2, 1.0)
276        );
277        assert!(matches!(
278            parse_easing("roughEase(0.4, 8)").unwrap(),
279            Easing::RoughEase { points: 8, .. }
280        ));
281    }
282
283    #[test]
284    fn rejects_bad_easing() {
285        assert!(parse_easing("nope").is_err());
286        assert!(parse_easing("steps(1, 2)").is_err());
287    }
288}