Skip to main content

fret_ui_kit/primitives/
transition.rs

1//! Transition timelines (deterministic, tick-driven).
2//!
3//! This module provides a small, stable facade around Fret's transition substrate:
4//!
5//! - a headless state machine: [`crate::headless::transition::TransitionTimeline`]
6//! - a runtime-driven driver: [`crate::declarative::transition`]
7//!
8//! Radix does not ship a "Transition" primitive as a public package, but multiple Radix primitives
9//! rely on the same underlying *presence-like* outcome:
10//!
11//! - keep content mounted while closing animations run
12//! - expose a normalized progress value for mapping into opacity/transform/etc.
13//!
14//! In Fret, this is modeled as a generic `TransitionTimeline` plus a deterministic runtime driver
15//! that:
16//! - advances on a monotonic tick source (frame/app ticks),
17//! - holds a continuous-frames lease while animating, and
18//! - requests redraws while animating.
19//!
20//! Most overlay-ish components should prefer [`crate::primitives::presence`] (Radix `Presence`
21//! outcome). This module is intended for non-overlay transitions (e.g. collapsible height motion)
22//! or for authoring new presence-like drivers.
23
24use fret_core::{Px, Size};
25use fret_ui::theme::CubicBezier;
26use fret_ui::{ElementContext, UiHost};
27use std::time::Duration;
28
29pub use crate::headless::transition::{TransitionOutput, TransitionTimeline};
30
31/// Shared transition profile for binary open/close state machines.
32///
33/// This mirrors the common Base UI/Radix pattern where components keep a mounted/present state
34/// while transitioning between two boolean states and expose a progress value for style mapping.
35#[derive(Clone, Copy)]
36pub struct TransitionProfile {
37    pub open_ticks: u64,
38    pub close_ticks: u64,
39    pub ease: fn(f32) -> f32,
40}
41
42impl TransitionProfile {
43    pub fn new(open_ticks: u64, close_ticks: u64, ease: fn(f32) -> f32) -> Self {
44        Self {
45            open_ticks,
46            close_ticks,
47            ease,
48        }
49    }
50}
51
52/// Drive a transition using the UI runtime's monotonic clock (same duration for open/close).
53#[track_caller]
54pub fn drive_transition<H: UiHost>(
55    cx: &mut ElementContext<'_, H>,
56    open: bool,
57    ticks: u64,
58) -> TransitionOutput {
59    crate::declarative::transition::drive_transition(cx, open, ticks)
60}
61
62/// Drive a transition using the UI runtime's monotonic clock, with separate open/close durations.
63#[track_caller]
64pub fn drive_transition_with_durations<H: UiHost>(
65    cx: &mut ElementContext<'_, H>,
66    open: bool,
67    open_ticks: u64,
68    close_ticks: u64,
69) -> TransitionOutput {
70    crate::declarative::transition::drive_transition_with_durations(
71        cx,
72        open,
73        open_ticks,
74        close_ticks,
75    )
76}
77
78/// Drive a transition with separate durations and a custom easing curve.
79#[track_caller]
80pub fn drive_transition_with_durations_and_easing<H: UiHost>(
81    cx: &mut ElementContext<'_, H>,
82    open: bool,
83    open_ticks: u64,
84    close_ticks: u64,
85    ease: fn(f32) -> f32,
86) -> TransitionOutput {
87    crate::declarative::transition::drive_transition_with_durations_and_easing(
88        cx,
89        open,
90        open_ticks,
91        close_ticks,
92        ease,
93    )
94}
95
96/// Drive a transition with separate durations/easing and explicit mount behavior.
97///
98/// When `animate_on_mount` is `false`, the first render snaps to the target state and only
99/// subsequent open/close changes animate.
100#[track_caller]
101pub fn drive_transition_with_durations_and_easing_with_mount_behavior<H: UiHost>(
102    cx: &mut ElementContext<'_, H>,
103    open: bool,
104    open_ticks: u64,
105    close_ticks: u64,
106    ease: fn(f32) -> f32,
107    animate_on_mount: bool,
108) -> TransitionOutput {
109    crate::declarative::transition::drive_transition_with_durations_and_easing_with_mount_behavior(
110        cx,
111        open,
112        open_ticks,
113        close_ticks,
114        ease,
115        animate_on_mount,
116    )
117}
118
119/// Drive a transition with separate durations/cubic-bezier and explicit mount behavior.
120///
121/// When `animate_on_mount` is `false`, the first render snaps to the target state and only
122/// subsequent open/close changes animate.
123#[track_caller]
124pub fn drive_transition_with_durations_and_cubic_bezier_duration_with_mount_behavior<H: UiHost>(
125    cx: &mut ElementContext<'_, H>,
126    open: bool,
127    open_duration: Duration,
128    close_duration: Duration,
129    bezier: CubicBezier,
130    animate_on_mount: bool,
131) -> TransitionOutput {
132    crate::declarative::transition::drive_transition_with_durations_and_cubic_bezier_duration_with_mount_behavior(
133        cx,
134        open,
135        open_duration,
136        close_duration,
137        bezier,
138        animate_on_mount,
139    )
140}
141
142/// Drive a transition using a reusable [`TransitionProfile`].
143#[track_caller]
144pub fn drive_transition_with_profile<H: UiHost>(
145    cx: &mut ElementContext<'_, H>,
146    open: bool,
147    profile: TransitionProfile,
148) -> TransitionOutput {
149    drive_transition_with_durations_and_easing(
150        cx,
151        open,
152        profile.open_ticks,
153        profile.close_ticks,
154        profile.ease,
155    )
156}
157
158/// Drive a transition using a reusable [`TransitionProfile`] and explicit mount behavior.
159#[track_caller]
160pub fn drive_transition_with_profile_and_mount_behavior<H: UiHost>(
161    cx: &mut ElementContext<'_, H>,
162    open: bool,
163    profile: TransitionProfile,
164    animate_on_mount: bool,
165) -> TransitionOutput {
166    drive_transition_with_durations_and_easing_with_mount_behavior(
167        cx,
168        open,
169        profile.open_ticks,
170        profile.close_ticks,
171        profile.ease,
172        animate_on_mount,
173    )
174}
175
176/// Linear interpolation for pixel values.
177pub fn lerp_px(from: Px, to: Px, t: f32) -> Px {
178    let t = t.clamp(0.0, 1.0);
179    Px(from.0 + (to.0 - from.0) * t)
180}
181
182/// Linear interpolation for sizes.
183pub fn lerp_size(from: Size, to: Size, t: f32) -> Size {
184    Size::new(
185        lerp_px(from.width, to.width, t),
186        lerp_px(from.height, to.height, t),
187    )
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use fret_app::App;
194    use fret_core::{AppWindowId, Point, Rect, Size as CoreSize};
195    use fret_runtime::{FrameId, TickId};
196
197    fn bounds() -> Rect {
198        Rect::new(
199            Point::new(Px(0.0), Px(0.0)),
200            CoreSize::new(Px(200.0), Px(120.0)),
201        )
202    }
203
204    #[test]
205    fn lerp_px_is_clamped_and_monotonic() {
206        assert_eq!(lerp_px(Px(0.0), Px(10.0), -1.0), Px(0.0));
207        assert_eq!(lerp_px(Px(0.0), Px(10.0), 0.0), Px(0.0));
208        assert_eq!(lerp_px(Px(0.0), Px(10.0), 0.5), Px(5.0));
209        assert_eq!(lerp_px(Px(0.0), Px(10.0), 1.0), Px(10.0));
210        assert_eq!(lerp_px(Px(0.0), Px(10.0), 2.0), Px(10.0));
211    }
212
213    #[test]
214    fn wrapper_drivers_keep_independent_state_per_call_site() {
215        let window = AppWindowId::default();
216        let mut app = App::new();
217
218        app.set_tick_id(TickId(1));
219        app.set_frame_id(FrameId(1));
220
221        let (a, b) = fret_ui::elements::with_element_cx(&mut app, window, bounds(), "t", |cx| {
222            let a = drive_transition_with_durations(cx, true, 6, 6);
223            let b = drive_transition_with_durations(cx, false, 6, 6);
224            (a, b)
225        });
226
227        assert!(a.present);
228        assert!(a.animating);
229        assert!(a.progress > 0.0 && a.progress < 1.0);
230
231        assert!(!b.present);
232        assert!(!b.animating);
233        assert_eq!(b.progress, 0.0);
234    }
235
236    #[test]
237    fn wrapper_can_snap_on_mount_then_animate_on_toggle() {
238        let window = AppWindowId::default();
239        let mut app = App::new();
240
241        app.set_tick_id(TickId(1));
242        app.set_frame_id(FrameId(1));
243
244        let open = fret_ui::elements::with_element_cx(&mut app, window, bounds(), "t3", |cx| {
245            drive_transition_with_profile_and_mount_behavior(
246                cx,
247                true,
248                TransitionProfile::new(6, 6, crate::headless::easing::smoothstep),
249                false,
250            )
251        });
252        assert!(open.present);
253        assert!(!open.animating);
254        assert_eq!(open.progress, 1.0);
255
256        app.set_tick_id(TickId(2));
257        app.set_frame_id(FrameId(2));
258        let close = fret_ui::elements::with_element_cx(&mut app, window, bounds(), "t3", |cx| {
259            drive_transition_with_profile_and_mount_behavior(
260                cx,
261                false,
262                TransitionProfile::new(6, 6, crate::headless::easing::smoothstep),
263                false,
264            )
265        });
266        assert!(!close.present);
267        assert!(!close.animating);
268        assert_eq!(close.progress, 0.0);
269    }
270}