Skip to main content

fret_ui_headless/
transition.rs

1//! Transition timelines (deterministic, tick-driven).
2//!
3//! This module provides a small, deterministic state machine for UI transitions that need:
4//!
5//! - different open/close durations,
6//! - a stable `present` vs unmounted outcome (keep mounted while closing),
7//! - a normalized progress value (`0..1`) that can be eased.
8//!
9//! It is intended to be driven by a monotonic tick source (typically a frame counter).
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum Phase {
13    Hidden,
14    Opening { start_tick: u64 },
15    Open,
16    Closing { start_tick: u64 },
17}
18
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct TransitionOutput {
21    /// Whether the content should remain mounted/paintable.
22    pub present: bool,
23    /// Linear progress in `[0, 1]` (0 = fully closed, 1 = fully open).
24    pub linear: f32,
25    /// Eased progress in `[0, 1]` using the easing function passed to update.
26    pub progress: f32,
27    /// Whether the transition is currently animating.
28    pub animating: bool,
29}
30
31/// A tiny open/close transition timeline.
32#[derive(Debug, Clone, Copy)]
33pub struct TransitionTimeline {
34    open_ticks: u64,
35    close_ticks: u64,
36    phase: Phase,
37}
38
39impl Default for TransitionTimeline {
40    fn default() -> Self {
41        Self {
42            open_ticks: 4,
43            close_ticks: 4,
44            phase: Phase::Hidden,
45        }
46    }
47}
48
49impl TransitionTimeline {
50    pub fn open_ticks(&self) -> u64 {
51        self.open_ticks
52    }
53
54    pub fn close_ticks(&self) -> u64 {
55        self.close_ticks
56    }
57
58    pub fn set_open_ticks(&mut self, open_ticks: u64) {
59        self.open_ticks = open_ticks.max(1);
60    }
61
62    pub fn set_close_ticks(&mut self, close_ticks: u64) {
63        self.close_ticks = close_ticks.max(1);
64    }
65
66    pub fn set_durations(&mut self, open_ticks: u64, close_ticks: u64) {
67        self.open_ticks = open_ticks.max(1);
68        self.close_ticks = close_ticks.max(1);
69    }
70
71    pub fn update(&mut self, open: bool, tick: u64) -> TransitionOutput {
72        self.update_with_easing(open, tick, crate::easing::smoothstep)
73    }
74
75    pub fn update_with_easing(
76        &mut self,
77        open: bool,
78        tick: u64,
79        ease: fn(f32) -> f32,
80    ) -> TransitionOutput {
81        if open {
82            match self.phase {
83                Phase::Hidden | Phase::Closing { .. } => {
84                    self.phase = Phase::Opening { start_tick: tick };
85                }
86                Phase::Opening { .. } | Phase::Open => {}
87            }
88        } else {
89            match self.phase {
90                Phase::Open | Phase::Opening { .. } => {
91                    self.phase = Phase::Closing { start_tick: tick };
92                }
93                Phase::Closing { .. } | Phase::Hidden => {}
94            }
95        }
96
97        match self.phase {
98            Phase::Hidden => TransitionOutput {
99                present: false,
100                linear: 0.0,
101                progress: 0.0,
102                animating: false,
103            },
104            Phase::Open => TransitionOutput {
105                present: true,
106                linear: 1.0,
107                progress: 1.0,
108                animating: false,
109            },
110            Phase::Opening { start_tick } => {
111                let duration = self.open_ticks.max(1);
112                let elapsed = tick.saturating_sub(start_tick).saturating_add(1);
113                let t = (elapsed as f32 / duration as f32).clamp(0.0, 1.0);
114                let linear = t;
115                let progress = ease(linear).clamp(0.0, 1.0);
116                if t >= 1.0 {
117                    self.phase = Phase::Open;
118                    TransitionOutput {
119                        present: true,
120                        linear: 1.0,
121                        progress: 1.0,
122                        animating: false,
123                    }
124                } else {
125                    TransitionOutput {
126                        present: true,
127                        linear,
128                        progress,
129                        animating: true,
130                    }
131                }
132            }
133            Phase::Closing { start_tick } => {
134                let duration = self.close_ticks.max(1);
135                let elapsed = tick.saturating_sub(start_tick).saturating_add(1);
136                let t = (elapsed as f32 / duration as f32).clamp(0.0, 1.0);
137                let linear = (1.0 - t).clamp(0.0, 1.0);
138                let progress = ease(linear).clamp(0.0, 1.0);
139                if t >= 1.0 {
140                    self.phase = Phase::Hidden;
141                    TransitionOutput {
142                        present: false,
143                        linear: 0.0,
144                        progress: 0.0,
145                        animating: false,
146                    }
147                } else {
148                    TransitionOutput {
149                        present: true,
150                        linear,
151                        progress,
152                        animating: true,
153                    }
154                }
155            }
156        }
157    }
158
159    pub fn update_with_cubic_bezier(
160        &mut self,
161        open: bool,
162        tick: u64,
163        x1: f32,
164        y1: f32,
165        x2: f32,
166        y2: f32,
167    ) -> TransitionOutput {
168        if open {
169            match self.phase {
170                Phase::Hidden | Phase::Closing { .. } => {
171                    self.phase = Phase::Opening { start_tick: tick };
172                }
173                Phase::Opening { .. } | Phase::Open => {}
174            }
175        } else {
176            match self.phase {
177                Phase::Open | Phase::Opening { .. } => {
178                    self.phase = Phase::Closing { start_tick: tick };
179                }
180                Phase::Closing { .. } | Phase::Hidden => {}
181            }
182        }
183
184        match self.phase {
185            Phase::Hidden => TransitionOutput {
186                present: false,
187                linear: 0.0,
188                progress: 0.0,
189                animating: false,
190            },
191            Phase::Open => TransitionOutput {
192                present: true,
193                linear: 1.0,
194                progress: 1.0,
195                animating: false,
196            },
197            Phase::Opening { start_tick } => {
198                let duration = self.open_ticks.max(1);
199                let elapsed = tick.saturating_sub(start_tick).saturating_add(1);
200                let t = (elapsed as f32 / duration as f32).clamp(0.0, 1.0);
201                let linear = t;
202                let progress = cubic_bezier_ease(x1, y1, x2, y2, linear).clamp(0.0, 1.0);
203                if t >= 1.0 {
204                    self.phase = Phase::Open;
205                    TransitionOutput {
206                        present: true,
207                        linear: 1.0,
208                        progress: 1.0,
209                        animating: false,
210                    }
211                } else {
212                    TransitionOutput {
213                        present: true,
214                        linear,
215                        progress,
216                        animating: true,
217                    }
218                }
219            }
220            Phase::Closing { start_tick } => {
221                let duration = self.close_ticks.max(1);
222                let elapsed = tick.saturating_sub(start_tick).saturating_add(1);
223                let t = (elapsed as f32 / duration as f32).clamp(0.0, 1.0);
224                let linear = (1.0 - t).clamp(0.0, 1.0);
225                let progress = cubic_bezier_ease(x1, y1, x2, y2, linear).clamp(0.0, 1.0);
226                if t >= 1.0 {
227                    self.phase = Phase::Hidden;
228                    TransitionOutput {
229                        present: false,
230                        linear: 0.0,
231                        progress: 0.0,
232                        animating: false,
233                    }
234                } else {
235                    TransitionOutput {
236                        present: true,
237                        linear,
238                        progress,
239                        animating: true,
240                    }
241                }
242            }
243        }
244    }
245}
246
247fn cubic_bezier_ease(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 {
248    let t = t.clamp(0.0, 1.0);
249
250    if (x1, y1, x2, y2) == (0.0, 0.0, 1.0, 1.0) {
251        return t;
252    }
253
254    let mut u = t;
255    for _ in 0..8 {
256        let x = cubic_bezier_x(x1, x2, u);
257        let dx = cubic_bezier_x_derivative(x1, x2, u);
258        if dx.abs() < 1e-6 {
259            break;
260        }
261        u = (u - (x - t) / dx).clamp(0.0, 1.0);
262    }
263
264    let mut lo = 0.0;
265    let mut hi = 1.0;
266    for _ in 0..12 {
267        let x = cubic_bezier_x(x1, x2, u);
268        if (x - t).abs() < 1e-4 {
269            break;
270        }
271        if x < t {
272            lo = u;
273        } else {
274            hi = u;
275        }
276        u = (lo + hi) * 0.5;
277    }
278
279    cubic_bezier_y(y1, y2, u).clamp(0.0, 1.0)
280}
281
282fn cubic_bezier_x(p1: f32, p2: f32, u: f32) -> f32 {
283    cubic_bezier_component(u, p1, p2)
284}
285
286fn cubic_bezier_y(p1: f32, p2: f32, u: f32) -> f32 {
287    cubic_bezier_component(u, p1, p2)
288}
289
290fn cubic_bezier_x_derivative(p1: f32, p2: f32, u: f32) -> f32 {
291    cubic_bezier_component_derivative(u, p1, p2)
292}
293
294fn cubic_bezier_component(u: f32, p1: f32, p2: f32) -> f32 {
295    let inv = 1.0 - u;
296    3.0 * inv * inv * u * p1 + 3.0 * inv * u * u * p2 + u * u * u
297}
298
299fn cubic_bezier_component_derivative(u: f32, p1: f32, p2: f32) -> f32 {
300    let inv = 1.0 - u;
301    3.0 * inv * inv * p1 + 6.0 * inv * u * (p2 - p1) + 3.0 * u * u * (1.0 - p2)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn opens_and_closes_with_present_window() {
310        let mut t = TransitionTimeline::default();
311        t.set_durations(3, 3);
312
313        let o0 = t.update(true, 0);
314        assert!(o0.present);
315        assert!(o0.animating);
316        assert!(o0.linear > 0.0 && o0.linear < 1.0);
317
318        let o2 = t.update(true, 2);
319        assert!(o2.present);
320
321        let o3 = t.update(true, 3);
322        assert!(o3.present);
323        assert!(!o3.animating);
324        assert_eq!(o3.linear, 1.0);
325
326        let c0 = t.update(false, 4);
327        assert!(c0.present);
328        assert!(c0.animating);
329        assert!(c0.linear < 1.0);
330
331        let c3 = t.update(false, 7);
332        assert!(!c3.present);
333        assert!(!c3.animating);
334        assert_eq!(c3.linear, 0.0);
335    }
336
337    #[test]
338    fn can_use_shadcn_cubic_bezier_easing() {
339        let mut t = TransitionTimeline::default();
340        t.set_durations(4, 4);
341        let out = t.update_with_easing(true, 0, |x| crate::easing::SHADCN_EASE.sample(x));
342        assert!(out.present);
343        assert!(out.animating);
344        assert!(out.progress >= 0.0 && out.progress <= 1.0);
345    }
346
347    #[test]
348    fn cubic_bezier_transition_matches_linear_for_linear_curve() {
349        let mut t = TransitionTimeline::default();
350        t.set_durations(4, 4);
351        let out = t.update_with_cubic_bezier(true, 0, 0.0, 0.0, 1.0, 1.0);
352        assert!(out.present);
353        assert!(out.animating);
354        assert!((out.progress - out.linear).abs() <= 1e-3);
355    }
356}