Skip to main content

orinium_browser/engine/animation/
easing.rs

1//! Easing and interpolation helpers for CSS transitions/animations.
2
3use crate::engine::animation::types::{Easing, StepPosition};
4use crate::engine::css::values::{CssValue, Unit};
5use crate::engine::layouter::resolve_css_color;
6use crate::engine::layouter::types::ColorScheme;
7
8/// Evaluate an easing function at progress `t` in `[0, 1]`.
9pub fn apply_easing(t: f32, easing: Easing) -> f32 {
10    let t = t.clamp(0.0, 1.0);
11    match easing {
12        Easing::Linear => t,
13        Easing::Ease => cubic_bezier(0.25, 0.1, 0.25, 1.0, t),
14        Easing::EaseIn => cubic_bezier(0.42, 0.0, 1.0, 1.0, t),
15        Easing::EaseOut => cubic_bezier(0.0, 0.0, 0.58, 1.0, t),
16        Easing::EaseInOut => cubic_bezier(0.42, 0.0, 0.58, 1.0, t),
17        Easing::CubicBezier(x1, y1, x2, y2) => cubic_bezier(x1, y1, x2, y2, t),
18        Easing::Steps(steps, pos) => steps_easing(steps.max(1), pos, t),
19    }
20}
21
22fn steps_easing(steps: u32, pos: StepPosition, t: f32) -> f32 {
23    let n = steps as f32;
24    if n <= 0.0 {
25        return t;
26    }
27    let (jumps, offset): (f32, f32) = match pos {
28        StepPosition::JumpEnd => (n, 0.0),
29        StepPosition::JumpStart => (n, 1.0),
30        StepPosition::JumpNone => ((n - 1.0).max(1.0), 0.0),
31        StepPosition::JumpBoth => (n + 1.0, 1.0),
32    };
33    let step = ((t * jumps) + offset).floor().min(n);
34    let denom = match pos {
35        StepPosition::JumpNone => (n - 1.0).max(1.0),
36        StepPosition::JumpEnd => n,
37        StepPosition::JumpStart => n,
38        StepPosition::JumpBoth => n,
39    };
40    (step / denom).clamp(0.0, 1.0)
41}
42
43fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 {
44    if t <= 0.0 {
45        return 0.0;
46    }
47    if t >= 1.0 {
48        return 1.0;
49    }
50    // Newton-Raphson to find x(t)=input
51    let mut u = t;
52    for _ in 0..8 {
53        let x = sample_curve(x1, x2, u) - t;
54        if x.abs() < 1e-5 {
55            break;
56        }
57        let d = sample_derivative(x1, x2, u);
58        if d.abs() < 1e-6 {
59            break;
60        }
61        u = (u - x / d).clamp(0.0, 1.0);
62    }
63    sample_curve(y1, y2, u).clamp(0.0, 1.0)
64}
65
66fn sample_curve(a: f32, b: f32, t: f32) -> f32 {
67    // (1-t)^3*0 + 3(1-t)^2*t*a + 3(1-t)*t^2*b + t^3*1
68    let mt = 1.0 - t;
69    3.0 * mt * mt * t * a + 3.0 * mt * t * t * b + t * t * t
70}
71
72fn sample_derivative(a: f32, b: f32, t: f32) -> f32 {
73    let mt = 1.0 - t;
74    3.0 * mt * mt * a + 6.0 * mt * t * (b - a) + 3.0 * t * t * (1.0 - b)
75}
76
77/// Extract the angle (in radians) from a `rotate()` function's parsed args.
78/// Args shape: `vec![vec![Length(angle, unit)]]` (or a bare Number treated as radians).
79fn transform_function_angle(args: &[Vec<CssValue>]) -> Option<f32> {
80    let v = args.first()?.first()?;
81    match *v {
82        CssValue::Length(val, Unit::Deg) => Some(val.to_radians()),
83        CssValue::Length(val, Unit::Rad) => Some(val),
84        CssValue::Length(val, Unit::Grad) => Some(val * std::f32::consts::PI / 200.0),
85        CssValue::Length(val, Unit::Turn) => Some(val * 2.0 * std::f32::consts::PI),
86        CssValue::Number(n) => Some(n.to_radians()),
87        _ => None,
88    }
89}
90
91/// Extract `(sx, sy)` from a `scale()` function's parsed args.
92/// Missing `sy` defaults to `sx` (CSS `scale(s)` === `scale(s, s)`).
93fn transform_function_scale(args: &[Vec<CssValue>]) -> Option<(f32, f32)> {
94    let flat: Vec<&CssValue> = args.iter().flatten().collect();
95    let sx = match flat.first()? {
96        CssValue::Number(n) => *n,
97        _ => return None,
98    };
99    let sy = match flat.get(1).copied() {
100        Some(CssValue::Number(n)) => *n,
101        _ => sx,
102    };
103    Some((sx, sy))
104}
105
106/// Extract `(tx, ty)` from a translate-family function's parsed args.
107///
108/// - `translate(tx, ty?)` → `(tx, ty)` with `ty` defaulting to `0`
109/// - `translateX(tx)` → `(tx, 0)`
110/// - `translateY(ty)` → `(0, ty)`
111///
112/// Lengths are resolved to pixels; bare numbers are treated as pixels.
113/// Percentage/viewport lengths are context-dependent and not interpolable here.
114fn transform_function_translate(fn_name: &str, args: &[Vec<CssValue>]) -> Option<(f32, f32)> {
115    let flat: Vec<&CssValue> = args.iter().flatten().collect();
116    let to_px = |v: &CssValue| -> Option<f32> {
117        match v {
118            CssValue::Number(n) => Some(*n),
119            CssValue::Length(val, Unit::Px) => Some(*val),
120            _ => None,
121        }
122    };
123    let first = flat.first()?;
124    let tx_val = to_px(first)?;
125    let ty_val = match flat.get(1).copied() {
126        Some(CssValue::Number(n)) => *n,
127        Some(CssValue::Length(val, Unit::Px)) => *val,
128        _ => 0.0,
129    };
130    match fn_name {
131        "translatex" => Some((tx_val, 0.0)),
132        "translatey" => Some((0.0, tx_val)),
133        "translate" => Some((tx_val, ty_val)),
134        _ => None,
135    }
136}
137
138/// Whether a `CssValue` could possibly resolve to a color. Used to avoid
139/// calling `resolve_css_color` (and logging errors) on non-color values such
140/// as transform functions and lists.
141fn could_be_css_color(value: &CssValue) -> bool {
142    match value {
143        CssValue::Color(_) => true,
144        CssValue::Keyword(_) => true,
145        CssValue::Function(name, _) => matches!(
146            name.to_ascii_lowercase().as_str(),
147            "rgb"
148                | "rgba"
149                | "hsl"
150                | "hsla"
151                | "lab"
152                | "lch"
153                | "oklab"
154                | "oklch"
155                | "color-mix"
156                | "light-dark"
157                | "hwb"
158                | "color"
159        ),
160        _ => false,
161    }
162}
163
164/// Build an identity transform matching `value`'s transform functions, so
165/// `none` ↔ `transform` transitions can interpolate instead of snapping.
166fn transform_identity_like(value: &CssValue) -> Option<CssValue> {
167    match value {
168        CssValue::Function(name, _) => {
169            let identity = match name.to_ascii_lowercase().as_str() {
170                "translate" | "translatex" | "translatey" => CssValue::Function(
171                    "translate".into(),
172                    vec![vec![
173                        CssValue::Length(0.0, Unit::Px),
174                        CssValue::Length(0.0, Unit::Px),
175                    ]],
176                ),
177                "scale" => CssValue::Function("scale".into(), vec![vec![CssValue::Number(1.0)]]),
178                "rotate" => CssValue::Function(
179                    "rotate".into(),
180                    vec![vec![CssValue::Length(0.0, Unit::Deg)]],
181                ),
182                _ => return None,
183            };
184            Some(identity)
185        }
186        CssValue::List(items) => {
187            let identities = items
188                .iter()
189                .map(transform_identity_like)
190                .collect::<Option<Vec<_>>>()?;
191            Some(CssValue::List(identities))
192        }
193        _ => None,
194    }
195}
196
197/// Interpolate between two CSS values for supported animatable properties.
198/// Returns `None` when interpolation is not supported (caller should snap).
199pub fn interpolate_value(
200    from: &CssValue,
201    to: &CssValue,
202    t: f32,
203    color_scheme: ColorScheme,
204) -> Option<CssValue> {
205    let t = t.clamp(0.0, 1.0);
206    if matches!(from, CssValue::Keyword(value) if value.eq_ignore_ascii_case("none"))
207        && let Some(identity) = transform_identity_like(to)
208    {
209        return interpolate_value(&identity, to, t, color_scheme);
210    }
211    if matches!(to, CssValue::Keyword(value) if value.eq_ignore_ascii_case("none"))
212        && let Some(identity) = transform_identity_like(from)
213    {
214        return interpolate_value(from, &identity, t, color_scheme);
215    }
216    match (from, to) {
217        (CssValue::Number(a), CssValue::Number(b)) => Some(CssValue::Number(a + (b - a) * t)),
218        (CssValue::Length(a, ua), CssValue::Length(b, ub)) if ua == ub => {
219            Some(CssValue::Length(a + (b - a) * t, *ua))
220        }
221        (CssValue::Length(_, Unit::Px), CssValue::Number(_))
222        | (CssValue::Number(_), CssValue::Length(_, Unit::Px)) => {
223            let av = match from {
224                CssValue::Length(v, _) => *v,
225                CssValue::Number(v) => *v,
226                _ => 0.0,
227            };
228            let bv = match to {
229                CssValue::Length(v, _) => *v,
230                CssValue::Number(v) => *v,
231                _ => 0.0,
232            };
233            Some(CssValue::Length(av + (bv - av) * t, Unit::Px))
234        }
235        // CSS transform functions: interpolate matching transform functions.
236        // Translate-family functions (translate/translateX/translateY) are
237        // interchangeable and all resolve to a (tx, ty) pair.
238        (CssValue::Function(from_name, from_args), CssValue::Function(to_name, to_args)) => {
239            let f = from_name.to_ascii_lowercase();
240            let g = to_name.to_ascii_lowercase();
241            let same_group = match f.as_str() {
242                "rotate" => g == "rotate",
243                "scale" => g == "scale",
244                "translate" | "translatex" | "translatey" => {
245                    matches!(g.as_str(), "translate" | "translatex" | "translatey")
246                }
247                _ => false,
248            };
249            if !same_group {
250                return None;
251            }
252            match f.as_str() {
253                "rotate" => {
254                    let a0 = transform_function_angle(from_args)?;
255                    let a1 = transform_function_angle(to_args)?;
256                    let interp = a0 + (a1 - a0) * t;
257                    Some(CssValue::Function(
258                        "rotate".into(),
259                        vec![vec![CssValue::Length(interp.to_degrees(), Unit::Deg)]],
260                    ))
261                }
262                "scale" => {
263                    let (sx0, sy0) = transform_function_scale(from_args)?;
264                    let (sx1, sy1) = transform_function_scale(to_args)?;
265                    let sx = sx0 + (sx1 - sx0) * t;
266                    let sy = sy0 + (sy1 - sy0) * t;
267                    if (sx - sy).abs() < f32::EPSILON {
268                        Some(CssValue::Function(
269                            "scale".into(),
270                            vec![vec![CssValue::Number(sx)]],
271                        ))
272                    } else {
273                        Some(CssValue::Function(
274                            "scale".into(),
275                            vec![vec![CssValue::Number(sx)], vec![CssValue::Number(sy)]],
276                        ))
277                    }
278                }
279                "translate" | "translatex" | "translatey" => {
280                    let (tx0, ty0) = transform_function_translate(&f, from_args)?;
281                    let (tx1, ty1) = transform_function_translate(&g, to_args)?;
282                    let tx = tx0 + (tx1 - tx0) * t;
283                    let ty = ty0 + (ty1 - ty0) * t;
284                    Some(CssValue::Function(
285                        "translate".into(),
286                        vec![vec![
287                            CssValue::Length(tx, Unit::Px),
288                            CssValue::Length(ty, Unit::Px),
289                        ]],
290                    ))
291                }
292                _ => None,
293            }
294        }
295        // Transform function lists (e.g. `translateX(0) scale(1)` → `translateX(50) scale(1.5)`):
296        // interpolate element-wise when both sides have the same number of functions.
297        (CssValue::List(from_list), CssValue::List(to_list)) => {
298            if from_list.is_empty() || from_list.len() != to_list.len() {
299                return None;
300            }
301            let mut result = Vec::with_capacity(from_list.len());
302            for (from_fn, to_fn) in from_list.iter().zip(to_list.iter()) {
303                {
304                    let v = interpolate_value(from_fn, to_fn, t, color_scheme)?;
305                    result.push(v)
306                }
307            }
308            Some(CssValue::List(result))
309        }
310        _ => {
311            // Only attempt color resolution for values that could be colors.
312            // Transform values (lists, rotate/scale/translate functions) are not colors;
313            // calling resolve_css_color on them produces spurious error logs.
314            // The caller handles None by snapping to an endpoint.
315            if !could_be_css_color(from) || !could_be_css_color(to) {
316                return None;
317            }
318            // Try color interpolation
319            if let (Some(ca), Some(cb)) = (
320                resolve_css_color("animation", from, color_scheme),
321                resolve_css_color("animation", to, color_scheme),
322            ) {
323                let a = ca.to_linear_f32_array();
324                let b = cb.to_linear_f32_array();
325                let mixed = [
326                    a[0] + (b[0] - a[0]) * t,
327                    a[1] + (b[1] - a[1]) * t,
328                    a[2] + (b[2] - a[2]) * t,
329                    a[3] + (b[3] - a[3]) * t,
330                ];
331                let c = crate::engine::layouter::types::Color::from_linear_f32_array(mixed);
332                // resolve_css_color accepts CssValue::Color as hex (with or without alpha).
333                Some(CssValue::Color(format!(
334                    "{:02x}{:02x}{:02x}{:02x}",
335                    c.0, c.1, c.2, c.3
336                )))
337            } else {
338                None
339            }
340        }
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn easing_linear_identity() {
350        assert!((apply_easing(0.0, Easing::Linear) - 0.0).abs() < 1e-6);
351        assert!((apply_easing(0.5, Easing::Linear) - 0.5).abs() < 1e-6);
352        assert!((apply_easing(1.0, Easing::Linear) - 1.0).abs() < 1e-6);
353    }
354
355    #[test]
356    fn easing_ease_endpoints() {
357        for e in [
358            Easing::Ease,
359            Easing::EaseIn,
360            Easing::EaseOut,
361            Easing::EaseInOut,
362            Easing::CubicBezier(0.4, 0.0, 0.2, 1.0),
363        ] {
364            assert!((apply_easing(0.0, e) - 0.0).abs() < 1e-3, "{e:?}");
365            assert!((apply_easing(1.0, e) - 1.0).abs() < 1e-3, "{e:?}");
366        }
367    }
368
369    #[test]
370    fn interpolate_numbers_and_lengths() {
371        let n = interpolate_value(
372            &CssValue::Number(0.0),
373            &CssValue::Number(10.0),
374            0.5,
375            ColorScheme::Light,
376        )
377        .unwrap();
378        assert_eq!(n, CssValue::Number(5.0));
379        let l = interpolate_value(
380            &CssValue::Length(0.0, Unit::Px),
381            &CssValue::Length(100.0, Unit::Px),
382            0.25,
383            ColorScheme::Light,
384        )
385        .unwrap();
386        assert_eq!(l, CssValue::Length(25.0, Unit::Px));
387    }
388
389    fn rotate_fn(deg: f32) -> CssValue {
390        CssValue::Function(
391            "rotate".into(),
392            vec![vec![CssValue::Length(deg, Unit::Deg)]],
393        )
394    }
395
396    fn scale_fn(sx: f32, sy: f32) -> CssValue {
397        CssValue::Function(
398            "scale".into(),
399            vec![vec![CssValue::Number(sx)], vec![CssValue::Number(sy)]],
400        )
401    }
402
403    #[test]
404    fn interpolate_rotate_degrees() {
405        let v =
406            interpolate_value(&rotate_fn(0.0), &rotate_fn(90.0), 0.5, ColorScheme::Light).unwrap();
407        match v {
408            CssValue::Function(name, args) if name == "rotate" => {
409                let CssValue::Length(deg, Unit::Deg) = args[0][0] else {
410                    panic!("expected Length deg, got {args:?}")
411                };
412                assert!((deg - 45.0).abs() < 1e-4, "got {deg}");
413            }
414            other => panic!("unexpected {other:?}"),
415        }
416    }
417
418    #[test]
419    fn interpolate_rotate_wraps_turns_and_degs() {
420        // 0.5turn = 180deg; interpolate to 90deg => 135deg
421        let from = CssValue::Function(
422            "rotate".into(),
423            vec![vec![CssValue::Length(0.5, Unit::Turn)]],
424        );
425        let to = rotate_fn(90.0);
426        let v = interpolate_value(&from, &to, 0.5, ColorScheme::Light).unwrap();
427        match v {
428            CssValue::Function(name, args) if name == "rotate" => {
429                let CssValue::Length(deg, Unit::Deg) = args[0][0] else {
430                    panic!("expected Length deg, got {args:?}")
431                };
432                assert!((deg - 135.0).abs() < 1e-4, "got {deg}");
433            }
434            other => panic!("unexpected {other:?}"),
435        }
436    }
437
438    #[test]
439    fn interpolate_scale_uniform() {
440        let v = interpolate_value(
441            &scale_fn(1.0, 1.0),
442            &scale_fn(2.0, 2.0),
443            0.25,
444            ColorScheme::Light,
445        )
446        .unwrap();
447        match v {
448            CssValue::Function(name, args) if name == "scale" => {
449                assert_eq!(args.len(), 1);
450                let CssValue::Number(s) = args[0][0] else {
451                    panic!("expected Number, got {args:?}")
452                };
453                assert!((s - 1.25).abs() < 1e-4, "got {s}");
454            }
455            other => panic!("unexpected {other:?}"),
456        }
457    }
458
459    #[test]
460    fn interpolate_scale_non_uniform() {
461        let v = interpolate_value(
462            &scale_fn(1.0, 2.0),
463            &scale_fn(3.0, 4.0),
464            0.5,
465            ColorScheme::Light,
466        )
467        .unwrap();
468        match v {
469            CssValue::Function(name, args) if name == "scale" => {
470                assert_eq!(args.len(), 2);
471                let CssValue::Number(sx) = args[0][0] else {
472                    panic!("expected Number, got {args:?}")
473                };
474                let CssValue::Number(sy) = args[1][0] else {
475                    panic!("expected Number, got {args:?}")
476                };
477                assert!((sx - 2.0).abs() < 1e-4, "got {sx}");
478                assert!((sy - 3.0).abs() < 1e-4, "got {sy}");
479            }
480            other => panic!("unexpected {other:?}"),
481        }
482    }
483
484    #[test]
485    fn interpolate_mismatched_transform_functions_returns_none() {
486        let v = interpolate_value(
487            &rotate_fn(0.0),
488            &scale_fn(1.0, 1.0),
489            0.5,
490            ColorScheme::Light,
491        );
492        assert!(v.is_none());
493    }
494
495    #[test]
496    fn interpolate_single_arg_scale_defaults_sy() {
497        let from = CssValue::Function("scale".into(), vec![vec![CssValue::Number(1.0)]]);
498        let to = CssValue::Function("scale".into(), vec![vec![CssValue::Number(3.0)]]);
499        let v = interpolate_value(&from, &to, 0.5, ColorScheme::Light).unwrap();
500        match v {
501            CssValue::Function(name, args) if name == "scale" => {
502                assert_eq!(args.len(), 1);
503                let CssValue::Number(s) = args[0][0] else {
504                    panic!("expected Number, got {args:?}")
505                };
506                assert!((s - 2.0).abs() < 1e-4, "got {s}");
507            }
508            other => panic!("unexpected {other:?}"),
509        }
510    }
511
512    fn translate_fn(tx: f32, ty: f32) -> CssValue {
513        CssValue::Function(
514            "translate".into(),
515            vec![vec![
516                CssValue::Length(tx, Unit::Px),
517                CssValue::Length(ty, Unit::Px),
518            ]],
519        )
520    }
521
522    fn translatex_fn(tx: f32) -> CssValue {
523        CssValue::Function(
524            "translateX".into(),
525            vec![vec![CssValue::Length(tx, Unit::Px)]],
526        )
527    }
528
529    fn translatey_fn(ty: f32) -> CssValue {
530        CssValue::Function(
531            "translateY".into(),
532            vec![vec![CssValue::Length(ty, Unit::Px)]],
533        )
534    }
535
536    #[test]
537    fn interpolate_translate() {
538        let v = interpolate_value(
539            &translate_fn(0.0, 0.0),
540            &translate_fn(100.0, 200.0),
541            0.25,
542            ColorScheme::Light,
543        )
544        .unwrap();
545        match v {
546            CssValue::Function(name, args) if name == "translate" => {
547                assert_eq!(args.len(), 1);
548                let CssValue::Length(x, Unit::Px) = args[0][0] else {
549                    panic!("expected Length px, got {args:?}")
550                };
551                let CssValue::Length(y, Unit::Px) = args[0][1] else {
552                    panic!("expected Length px, got {args:?}")
553                };
554                assert!((x - 25.0).abs() < 1e-4, "got {x}");
555                assert!((y - 50.0).abs() < 1e-4, "got {y}");
556            }
557            other => panic!("unexpected {other:?}"),
558        }
559    }
560
561    #[test]
562    fn interpolate_translatex() {
563        let v = interpolate_value(
564            &translatex_fn(0.0),
565            &translatex_fn(100.0),
566            0.5,
567            ColorScheme::Light,
568        )
569        .unwrap();
570        match v {
571            CssValue::Function(name, args) if name == "translate" => {
572                let CssValue::Length(x, Unit::Px) = args[0][0] else {
573                    panic!("expected Length px, got {args:?}")
574                };
575                let CssValue::Length(y, Unit::Px) = args[0][1] else {
576                    panic!("expected Length px, got {args:?}")
577                };
578                assert!((x - 50.0).abs() < 1e-4, "got {x}");
579                assert_eq!(y, 0.0);
580            }
581            other => panic!("unexpected {other:?}"),
582        }
583    }
584
585    #[test]
586    fn interpolate_translatey() {
587        let v = interpolate_value(
588            &translatey_fn(0.0),
589            &translatey_fn(100.0),
590            0.5,
591            ColorScheme::Light,
592        )
593        .unwrap();
594        match v {
595            CssValue::Function(name, args) if name == "translate" => {
596                let CssValue::Length(x, Unit::Px) = args[0][0] else {
597                    panic!("expected Length px, got {args:?}")
598                };
599                let CssValue::Length(y, Unit::Px) = args[0][1] else {
600                    panic!("expected Length px, got {args:?}")
601                };
602                assert_eq!(x, 0.0);
603                assert!((y - 50.0).abs() < 1e-4, "got {y}");
604            }
605            other => panic!("unexpected {other:?}"),
606        }
607    }
608
609    #[test]
610    fn interpolate_translate_family_mixed_names() {
611        // translateX(0) animating to translate(100, 200) should still interpolate
612        let v = interpolate_value(
613            &translatex_fn(0.0),
614            &translate_fn(100.0, 200.0),
615            0.5,
616            ColorScheme::Light,
617        )
618        .unwrap();
619        match v {
620            CssValue::Function(name, args) if name == "translate" => {
621                let CssValue::Length(x, Unit::Px) = args[0][0] else {
622                    panic!("expected Length px, got {args:?}")
623                };
624                let CssValue::Length(y, Unit::Px) = args[0][1] else {
625                    panic!("expected Length px, got {args:?}")
626                };
627                assert!((x - 50.0).abs() < 1e-4, "got {x}");
628                assert!((y - 100.0).abs() < 1e-4, "got {y}");
629            }
630            other => panic!("unexpected {other:?}"),
631        }
632    }
633
634    #[test]
635    fn interpolate_translate_vs_scale_returns_none() {
636        let v = interpolate_value(
637            &translate_fn(0.0, 0.0),
638            &CssValue::Function("scale".into(), vec![vec![CssValue::Number(2.0)]]),
639            0.5,
640            ColorScheme::Light,
641        );
642        assert!(v.is_none());
643    }
644
645    fn transform_list(functions: Vec<CssValue>) -> CssValue {
646        CssValue::List(functions)
647    }
648
649    #[test]
650    fn interpolate_transform_list_element_wise() {
651        let from = transform_list(vec![
652            translatex_fn(0.0),
653            CssValue::Function("scale".into(), vec![vec![CssValue::Number(1.0)]]),
654        ]);
655        let to = transform_list(vec![
656            translatex_fn(100.0),
657            CssValue::Function("scale".into(), vec![vec![CssValue::Number(3.0)]]),
658        ]);
659        let v = interpolate_value(&from, &to, 0.5, ColorScheme::Light).unwrap();
660        match v {
661            CssValue::List(items) => {
662                assert_eq!(items.len(), 2);
663                match &items[0] {
664                    CssValue::Function(name, args) if name == "translate" => {
665                        let CssValue::Length(x, Unit::Px) = args[0][0] else {
666                            panic!("expected Length px, got {args:?}")
667                        };
668                        assert!((x - 50.0).abs() < 1e-4, "got {x}");
669                    }
670                    other => panic!("expected translate function, got {other:?}"),
671                }
672                match &items[1] {
673                    CssValue::Function(name, args) if name == "scale" => {
674                        let CssValue::Number(s) = args[0][0] else {
675                            panic!("expected Number, got {args:?}")
676                        };
677                        assert!((s - 2.0).abs() < 1e-4, "got {s}");
678                    }
679                    other => panic!("expected scale function, got {other:?}"),
680                }
681            }
682            other => panic!("expected List, got {other:?}"),
683        }
684    }
685
686    #[test]
687    fn interpolate_transform_list_mismatched_length_returns_none() {
688        let from = transform_list(vec![translatex_fn(0.0)]);
689        let to = transform_list(vec![translatex_fn(0.0), translatex_fn(100.0)]);
690        let v = interpolate_value(&from, &to, 0.5, ColorScheme::Light);
691        assert!(v.is_none());
692    }
693
694    #[test]
695    fn interpolate_transform_list_non_color_values_do_not_log_error() {
696        // Both are non-color Lists; must return None without calling
697        // resolve_css_color (which would log an error).
698        let from = CssValue::Keyword("red".into());
699        let to = transform_list(vec![translatex_fn(0.0), translatex_fn(100.0)]);
700        let v = interpolate_value(&from, &to, 0.5, ColorScheme::Light);
701        assert!(v.is_none());
702    }
703
704    #[test]
705    fn interpolate_empty_transform_list_returns_none() {
706        let from = CssValue::List(vec![]);
707        let to = CssValue::List(vec![]);
708        let v = interpolate_value(&from, &to, 0.5, ColorScheme::Light);
709        assert!(v.is_none());
710    }
711
712    #[test]
713    fn steps_easing_jump_end() {
714        // 4 steps, jump-end: 0, 0.25, 0.5, 0.75, 1.0
715        assert!((steps_easing(4, StepPosition::JumpEnd, 0.0) - 0.0).abs() < 1e-6);
716        assert!((steps_easing(4, StepPosition::JumpEnd, 0.25) - 0.25).abs() < 1e-6);
717        assert!((steps_easing(4, StepPosition::JumpEnd, 0.5) - 0.5).abs() < 1e-6);
718        assert!((steps_easing(4, StepPosition::JumpEnd, 0.75) - 0.75).abs() < 1e-6);
719        assert!((steps_easing(4, StepPosition::JumpEnd, 1.0) - 1.0).abs() < 1e-6);
720        // Between steps: stays at previous step
721        assert!((steps_easing(4, StepPosition::JumpEnd, 0.1) - 0.0).abs() < 1e-6);
722        assert!((steps_easing(4, StepPosition::JumpEnd, 0.3) - 0.25).abs() < 1e-6);
723    }
724
725    #[test]
726    fn steps_easing_jump_start() {
727        // 4 steps, jump-start: 0.25, 0.5, 0.75, 1.0, 1.0
728        assert!((steps_easing(4, StepPosition::JumpStart, 0.0) - 0.25).abs() < 1e-6);
729        assert!((steps_easing(4, StepPosition::JumpStart, 0.25) - 0.5).abs() < 1e-6);
730        assert!((steps_easing(4, StepPosition::JumpStart, 0.5) - 0.75).abs() < 1e-6);
731        assert!((steps_easing(4, StepPosition::JumpStart, 1.0) - 1.0).abs() < 1e-6);
732    }
733
734    #[test]
735    fn steps_easing_jump_none() {
736        // 4 steps, jump-none: 0, 1/3, 2/3, 1 (n-1 jumps, no jump at either end)
737        assert!((steps_easing(4, StepPosition::JumpNone, 0.0) - 0.0).abs() < 1e-6);
738        assert!((steps_easing(4, StepPosition::JumpNone, 0.25) - 0.0).abs() < 1e-6);
739        assert!((steps_easing(4, StepPosition::JumpNone, 0.5) - 1.0 / 3.0).abs() < 1e-6);
740        assert!((steps_easing(4, StepPosition::JumpNone, 1.0) - 1.0).abs() < 1e-6);
741    }
742
743    #[test]
744    fn steps_easing_jump_both() {
745        // 4 steps, jump-both: 1/4, 2/4, 3/4, 4/4 (jump at both ends)
746        assert!((steps_easing(4, StepPosition::JumpBoth, 0.0) - 0.25).abs() < 1e-6);
747        assert!((steps_easing(4, StepPosition::JumpBoth, 0.25) - 0.5).abs() < 1e-6);
748        assert!((steps_easing(4, StepPosition::JumpBoth, 0.5) - 0.75).abs() < 1e-6);
749        assert!((steps_easing(4, StepPosition::JumpBoth, 1.0) - 1.0).abs() < 1e-6);
750    }
751
752    #[test]
753    fn steps_easing_apply_easing_integration() {
754        // apply_easing wraps steps_easing
755        let easing = Easing::Steps(4, StepPosition::JumpEnd);
756        assert!((apply_easing(0.0, easing) - 0.0).abs() < 1e-6);
757        assert!((apply_easing(0.25, easing) - 0.25).abs() < 1e-6);
758        assert!((apply_easing(0.5, easing) - 0.5).abs() < 1e-6);
759        assert!((apply_easing(0.75, easing) - 0.75).abs() < 1e-6);
760        assert!((apply_easing(1.0, easing) - 1.0).abs() < 1e-6);
761    }
762}