Skip to main content

fret_ui_headless/
easing.rs

1//! Easing helpers for deterministic motion.
2//!
3//! Fret's UI motion is driven by a monotonic tick/frame clock (ADR 0034). shadcn/ui and Radix
4//! often express easing in CSS (e.g. `ease-[cubic-bezier(0.22,1,0.36,1)]`). This module provides
5//! small, reusable easing math so motion policies can match those outcomes without DOM/CSS.
6
7/// A CSS-style cubic-bezier easing curve.
8///
9/// This models the common CSS `cubic-bezier(x1, y1, x2, y2)` definition where the curve starts at
10/// (0, 0) and ends at (1, 1). For typical easing curves, `x(t)` is monotonic which allows us to
11/// solve `t` for an input progress `x` and then sample `y(t)`.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct CubicBezier {
14    pub x1: f32,
15    pub y1: f32,
16    pub x2: f32,
17    pub y2: f32,
18}
19
20impl CubicBezier {
21    pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
22        Self { x1, y1, x2, y2 }
23    }
24
25    /// Sample the easing output `y` for an input progress `x` in `[0, 1]`.
26    pub fn sample(self, x: f32) -> f32 {
27        let x = x.clamp(0.0, 1.0);
28        if x <= 0.0 {
29            return 0.0;
30        }
31        if x >= 1.0 {
32            return 1.0;
33        }
34
35        // Solve for parameter t where curve_x(t) ~= x.
36        let t = self.solve_t_for_x(x);
37        self.curve_y(t).clamp(0.0, 1.0)
38    }
39
40    /// Sample the easing output `y` for an input progress `x` in `[0, 1]` without clamping.
41    ///
42    /// This is useful for "overshoot" curves like `cubic-bezier(..., y2 > 1.0)` where the
43    /// desired output intentionally goes outside `[0, 1]`.
44    pub fn sample_unclamped(self, x: f32) -> f32 {
45        let x = x.clamp(0.0, 1.0);
46        if x <= 0.0 {
47            return 0.0;
48        }
49        if x >= 1.0 {
50            return 1.0;
51        }
52
53        let t = self.solve_t_for_x(x);
54        self.curve_y(t)
55    }
56
57    fn curve_x(self, t: f32) -> f32 {
58        let t = t.clamp(0.0, 1.0);
59        let u = 1.0 - t;
60        3.0 * u * u * t * self.x1 + 3.0 * u * t * t * self.x2 + t * t * t
61    }
62
63    fn curve_y(self, t: f32) -> f32 {
64        let t = t.clamp(0.0, 1.0);
65        let u = 1.0 - t;
66        3.0 * u * u * t * self.y1 + 3.0 * u * t * t * self.y2 + t * t * t
67    }
68
69    fn curve_dx_dt(self, t: f32) -> f32 {
70        let t = t.clamp(0.0, 1.0);
71        let u = 1.0 - t;
72        // Derivative of the cubic bezier x(t) with endpoints fixed at 0 and 1.
73        3.0 * u * u * self.x1 + 6.0 * u * t * (self.x2 - self.x1) + 3.0 * t * t * (1.0 - self.x2)
74    }
75
76    fn solve_t_for_x(self, x: f32) -> f32 {
77        // First try a small fixed number of Newton steps (fast).
78        let mut t = x;
79        for _ in 0..8 {
80            let x_t = self.curve_x(t);
81            let dx = x_t - x;
82            if dx.abs() < 1e-6 {
83                return t.clamp(0.0, 1.0);
84            }
85            let d = self.curve_dx_dt(t);
86            if d.abs() < 1e-6 {
87                break;
88            }
89            t -= dx / d;
90            if !(0.0..=1.0).contains(&t) {
91                break;
92            }
93        }
94
95        // Fall back to bisection in [0, 1] (robust).
96        let mut lo = 0.0f32;
97        let mut hi = 1.0f32;
98        for _ in 0..24 {
99            let mid = 0.5 * (lo + hi);
100            let x_mid = self.curve_x(mid);
101            if (x_mid - x).abs() < 1e-6 {
102                return mid;
103            }
104            if x_mid < x {
105                lo = mid;
106            } else {
107                hi = mid;
108            }
109        }
110        0.5 * (lo + hi)
111    }
112}
113
114pub const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
115pub const EASE_IN: CubicBezier = CubicBezier::new(0.42, 0.0, 1.0, 1.0);
116pub const EASE_OUT: CubicBezier = CubicBezier::new(0.0, 0.0, 0.58, 1.0);
117pub const EASE_IN_OUT: CubicBezier = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
118
119/// shadcn/ui v4 commonly uses `ease-[cubic-bezier(0.22,1,0.36,1)]` for overlay-like transitions.
120pub const SHADCN_EASE: CubicBezier = CubicBezier::new(0.22, 1.0, 0.36, 1.0);
121
122pub fn linear(t: f32) -> f32 {
123    t.clamp(0.0, 1.0)
124}
125
126pub fn smoothstep(t: f32) -> f32 {
127    let t = t.clamp(0.0, 1.0);
128    t * t * (3.0 - 2.0 * t)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn cubic_bezier_hits_endpoints() {
137        let c = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
138        assert_eq!(c.sample(0.0), 0.0);
139        assert_eq!(c.sample(1.0), 1.0);
140    }
141
142    #[test]
143    fn cubic_bezier_linear_is_identity() {
144        let c = CubicBezier::new(0.0, 0.0, 1.0, 1.0);
145        for i in 0..=10 {
146            let x = i as f32 / 10.0;
147            let y = c.sample(x);
148            assert!((y - x).abs() < 1e-4, "x={x} y={y}");
149        }
150    }
151
152    #[test]
153    fn cubic_bezier_is_monotonic_for_common_presets() {
154        let curves = [EASE, EASE_IN, EASE_OUT, EASE_IN_OUT, SHADCN_EASE];
155        for c in curves {
156            let mut prev = 0.0f32;
157            for i in 0..=100 {
158                let x = i as f32 / 100.0;
159                let y = c.sample(x);
160                assert!(y >= prev - 1e-4, "curve={c:?} x={x} prev={prev} y={y}");
161                prev = y;
162            }
163        }
164    }
165
166    #[test]
167    fn cubic_bezier_sample_unclamped_allows_overshoot() {
168        let c = CubicBezier::new(0.175, 0.885, 0.32, 1.275);
169        let mut seen_overshoot = false;
170        for i in 0..=100 {
171            let x = i as f32 / 100.0;
172            let y = c.sample_unclamped(x);
173            if y > 1.0 {
174                seen_overshoot = true;
175                break;
176            }
177        }
178        assert!(seen_overshoot);
179    }
180}