Skip to main content

fret_ui_kit/declarative/
motion.rs

1use std::hash::Hash;
2use std::panic::Location;
3use std::time::Duration;
4use std::{collections::HashMap, hash::Hasher};
5
6use fret_core::{Color, WindowFrameClockService, WindowMetricsService};
7use fret_ui::elements::GlobalElementId;
8use fret_ui::{ElementContext, Invalidation, UiHost};
9use fret_ui_headless::motion::inertia::{InertiaBounds, InertiaSimulation};
10use fret_ui_headless::motion::simulation::Simulation1D;
11use fret_ui_headless::motion::spring::{SpringDescription, SpringSimulation};
12use fret_ui_headless::motion::tolerance::Tolerance;
13
14use crate::declarative::scheduling::set_continuous_frames;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct DrivenMotionF32 {
18    pub value: f32,
19    pub velocity: f32,
20    pub animating: bool,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct DrivenMotionColor {
25    pub value: Color,
26    pub animating: bool,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct SpringKick {
31    pub id: u64,
32    pub velocity: f32,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct InertiaKick {
37    pub id: u64,
38    pub velocity: f32,
39}
40
41#[derive(Debug, Clone, Copy)]
42struct TweenF32State {
43    initialized: bool,
44    last_frame_id: u64,
45    start: f32,
46    target: f32,
47    value: f32,
48    velocity: f32,
49    elapsed: Duration,
50    duration: Duration,
51    ease: fn(f32) -> f32,
52    animating: bool,
53}
54
55impl Default for TweenF32State {
56    fn default() -> Self {
57        Self {
58            initialized: false,
59            last_frame_id: 0,
60            start: 0.0,
61            target: 0.0,
62            value: 0.0,
63            velocity: 0.0,
64            elapsed: Duration::ZERO,
65            duration: Duration::from_millis(200),
66            ease: crate::headless::easing::smoothstep,
67            animating: false,
68        }
69    }
70}
71
72#[derive(Debug, Default)]
73struct TweenF32StateMap {
74    entries: HashMap<u64, TweenF32State>,
75}
76
77#[derive(Debug, Default)]
78struct TweenColorStateMap {
79    entries: HashMap<u64, TweenColorState>,
80}
81
82#[derive(Debug, Clone, Copy)]
83struct TweenColorState {
84    initialized: bool,
85    last_frame_id: u64,
86    start: Color,
87    target: Color,
88    value: Color,
89    elapsed: Duration,
90    duration: Duration,
91    ease: fn(f32) -> f32,
92    animating: bool,
93}
94
95impl Default for TweenColorState {
96    fn default() -> Self {
97        Self {
98            initialized: false,
99            last_frame_id: 0,
100            start: Color {
101                r: 0.0,
102                g: 0.0,
103                b: 0.0,
104                a: 0.0,
105            },
106            target: Color {
107                r: 0.0,
108                g: 0.0,
109                b: 0.0,
110                a: 0.0,
111            },
112            value: Color {
113                r: 0.0,
114                g: 0.0,
115                b: 0.0,
116                a: 0.0,
117            },
118            elapsed: Duration::ZERO,
119            duration: Duration::from_millis(200),
120            ease: crate::headless::easing::smoothstep,
121            animating: false,
122        }
123    }
124}
125
126const REFERENCE_FRAME_DELTA_60HZ: Duration = Duration::from_nanos(1_000_000_000 / 60);
127const MAX_FRAME_DELTA: Duration = Duration::from_millis(50);
128
129fn clamp_frame_delta(dt: Duration) -> Duration {
130    if dt == Duration::ZERO {
131        return REFERENCE_FRAME_DELTA_60HZ;
132    }
133    dt.min(MAX_FRAME_DELTA)
134}
135
136pub fn effective_frame_delta_for_cx<H: UiHost>(cx: &ElementContext<'_, H>) -> Duration {
137    let Some(svc) = cx.app.global::<WindowFrameClockService>() else {
138        return REFERENCE_FRAME_DELTA_60HZ;
139    };
140
141    if let Some(fixed) = svc.effective_fixed_delta(cx.window) {
142        return clamp_frame_delta(fixed);
143    }
144
145    let has_window_metrics = cx.app.global::<WindowMetricsService>().is_some();
146    if !has_window_metrics {
147        // Headless tests often drive "frames" without present-time. In that regime, snapshot deltas
148        // can reflect CPU time (near-zero), which would effectively stall a Duration-driven
149        // animation. Use a stable reference delta unless a fixed delta is explicitly configured.
150        return REFERENCE_FRAME_DELTA_60HZ;
151    }
152
153    let Some(snapshot) = svc.snapshot(cx.window) else {
154        return REFERENCE_FRAME_DELTA_60HZ;
155    };
156
157    clamp_frame_delta(snapshot.delta)
158}
159
160pub(crate) fn tween_value_at(
161    start: f32,
162    end: f32,
163    duration: Duration,
164    ease: fn(f32) -> f32,
165    elapsed: Duration,
166) -> f32 {
167    if duration == Duration::ZERO {
168        return end;
169    }
170    let t = (elapsed.as_secs_f64() / duration.as_secs_f64()).clamp(0.0, 1.0) as f32;
171    let eased = ease(t).clamp(0.0, 1.0);
172    start + (end - start) * eased
173}
174
175fn tween_color_at(
176    start: Color,
177    end: Color,
178    duration: Duration,
179    ease: fn(f32) -> f32,
180    elapsed: Duration,
181) -> Color {
182    if duration == Duration::ZERO {
183        return end;
184    }
185    let t = (elapsed.as_secs_f64() / duration.as_secs_f64()).clamp(0.0, 1.0) as f32;
186    let eased = ease(t).clamp(0.0, 1.0);
187    Color {
188        r: start.r + (end.r - start.r) * eased,
189        g: start.g + (end.g - start.g) * eased,
190        b: start.b + (end.b - start.b) * eased,
191        a: start.a + (end.a - start.a) * eased,
192    }
193}
194
195pub(crate) fn tween_value_at_unclamped(
196    start: f32,
197    end: f32,
198    duration: Duration,
199    ease: fn(f32) -> f32,
200    elapsed: Duration,
201) -> f32 {
202    if duration == Duration::ZERO {
203        return end;
204    }
205    let t = (elapsed.as_secs_f64() / duration.as_secs_f64()).clamp(0.0, 1.0) as f32;
206    let eased = ease(t);
207    start + (end - start) * eased
208}
209
210pub(crate) fn tween_velocity_at(
211    start: f32,
212    end: f32,
213    duration: Duration,
214    ease: fn(f32) -> f32,
215    elapsed: Duration,
216) -> f32 {
217    // Finite-difference approximation. This is primarily used for retargeting continuity.
218    let dt = Duration::from_millis(1);
219    let t0 = elapsed.saturating_sub(dt);
220    let t1 = (elapsed + dt).min(duration);
221    if t1 <= t0 {
222        return 0.0;
223    }
224    let v0 = tween_value_at(start, end, duration, ease, t0);
225    let v1 = tween_value_at(start, end, duration, ease, t1);
226    (v1 - v0) / (t1 - t0).as_secs_f32()
227}
228
229pub(crate) fn tween_velocity_at_unclamped(
230    start: f32,
231    end: f32,
232    duration: Duration,
233    ease: fn(f32) -> f32,
234    elapsed: Duration,
235) -> f32 {
236    // Finite-difference approximation. This is primarily used for retargeting continuity.
237    let dt = Duration::from_millis(1);
238    let t0 = elapsed.saturating_sub(dt);
239    let t1 = (elapsed + dt).min(duration);
240    if t1 <= t0 {
241        return 0.0;
242    }
243    let v0 = tween_value_at_unclamped(start, end, duration, ease, t0);
244    let v1 = tween_value_at_unclamped(start, end, duration, ease, t1);
245    (v1 - v0) / (t1 - t0).as_secs_f32()
246}
247
248#[track_caller]
249pub fn drive_tween_f32<H: UiHost>(
250    cx: &mut ElementContext<'_, H>,
251    target: f32,
252    duration: Duration,
253    ease: fn(f32) -> f32,
254) -> DrivenMotionF32 {
255    let reduced_motion = super::prefers_reduced_motion(cx, Invalidation::Paint, false);
256    if reduced_motion {
257        set_continuous_frames(cx, false);
258        return DrivenMotionF32 {
259            value: target,
260            velocity: 0.0,
261            animating: false,
262        };
263    }
264
265    let loc = Location::caller();
266    cx.keyed(
267        (loc.file(), loc.line(), loc.column(), "drive_tween_f32"),
268        |cx| {
269            let frame_id = cx.frame_id.0;
270            let dt = effective_frame_delta_for_cx(cx);
271
272            let out = cx.slot_state(TweenF32State::default, |st| {
273                if !st.initialized {
274                    st.initialized = true;
275                    st.last_frame_id = frame_id;
276                    st.start = target;
277                    st.target = target;
278                    st.value = target;
279                    st.velocity = 0.0;
280                    st.elapsed = Duration::ZERO;
281                    st.duration = duration;
282                    st.ease = ease;
283                    st.animating = false;
284                }
285
286                // Retarget.
287                if target != st.target
288                    || st.duration != duration
289                    || st.ease as usize != ease as usize
290                {
291                    st.start = st.value;
292                    st.target = target;
293                    st.duration = duration;
294                    st.ease = ease;
295                    st.elapsed = Duration::ZERO;
296                    st.animating = true;
297                    // Keep current value as start to avoid a jump.
298                }
299
300                // Advance at most once per frame.
301                if st.animating && st.last_frame_id != frame_id {
302                    st.last_frame_id = frame_id;
303                    st.elapsed = st.elapsed.saturating_add(dt);
304
305                    let value =
306                        tween_value_at(st.start, st.target, st.duration, st.ease, st.elapsed);
307                    let velocity =
308                        tween_velocity_at(st.start, st.target, st.duration, st.ease, st.elapsed);
309                    st.value = value;
310                    st.velocity = velocity;
311
312                    if st.elapsed >= st.duration {
313                        st.value = st.target;
314                        st.velocity = 0.0;
315                        st.animating = false;
316                    }
317                } else if st.last_frame_id == 0 {
318                    st.last_frame_id = frame_id;
319                }
320
321                DrivenMotionF32 {
322                    value: st.value,
323                    velocity: st.velocity,
324                    animating: st.animating,
325                }
326            });
327
328            set_continuous_frames(cx, out.animating);
329            if out.animating {
330                cx.notify_for_animation_frame();
331            }
332            out
333        },
334    )
335}
336
337#[track_caller]
338pub fn drive_tween_f32_for_element<H: UiHost, K: Hash>(
339    cx: &mut ElementContext<'_, H>,
340    element: GlobalElementId,
341    key: K,
342    target: f32,
343    duration: Duration,
344    ease: fn(f32) -> f32,
345) -> DrivenMotionF32 {
346    let reduced_motion = super::prefers_reduced_motion(cx, Invalidation::Paint, false);
347    if reduced_motion {
348        set_continuous_frames(cx, false);
349        return DrivenMotionF32 {
350            value: target,
351            velocity: 0.0,
352            animating: false,
353        };
354    }
355
356    let mut hasher = std::collections::hash_map::DefaultHasher::new();
357    ("drive_tween_f32_for_element").hash(&mut hasher);
358    key.hash(&mut hasher);
359    let entry_key = hasher.finish();
360
361    let frame_id = cx.frame_id.0;
362    let dt = effective_frame_delta_for_cx(cx);
363
364    let out = cx.state_for(element, TweenF32StateMap::default, |map| {
365        let st = map
366            .entries
367            .entry(entry_key)
368            .or_insert_with(TweenF32State::default);
369
370        if !st.initialized {
371            st.initialized = true;
372            st.last_frame_id = frame_id;
373            st.start = target;
374            st.target = target;
375            st.value = target;
376            st.velocity = 0.0;
377            st.elapsed = Duration::ZERO;
378            st.duration = duration;
379            st.ease = ease;
380            st.animating = false;
381        }
382
383        // Retarget.
384        if target != st.target || st.duration != duration || st.ease as usize != ease as usize {
385            st.start = st.value;
386            st.target = target;
387            st.duration = duration;
388            st.ease = ease;
389            st.elapsed = Duration::ZERO;
390            st.animating = true;
391        }
392
393        // Advance at most once per frame.
394        if st.animating && st.last_frame_id != frame_id {
395            st.last_frame_id = frame_id;
396            st.elapsed = st.elapsed.saturating_add(dt);
397
398            let value = tween_value_at(st.start, st.target, st.duration, st.ease, st.elapsed);
399            let velocity = tween_velocity_at(st.start, st.target, st.duration, st.ease, st.elapsed);
400            st.value = value;
401            st.velocity = velocity;
402
403            if st.elapsed >= st.duration {
404                st.value = st.target;
405                st.velocity = 0.0;
406                st.animating = false;
407            }
408        } else if st.last_frame_id == 0 {
409            st.last_frame_id = frame_id;
410        }
411
412        DrivenMotionF32 {
413            value: st.value,
414            velocity: st.velocity,
415            animating: st.animating,
416        }
417    });
418
419    set_continuous_frames(cx, out.animating);
420    if out.animating {
421        cx.notify_for_animation_frame();
422    }
423    out
424}
425
426#[track_caller]
427pub fn drive_tween_color<H: UiHost>(
428    cx: &mut ElementContext<'_, H>,
429    target: Color,
430    duration: Duration,
431    ease: fn(f32) -> f32,
432) -> DrivenMotionColor {
433    let reduced_motion = super::prefers_reduced_motion(cx, Invalidation::Paint, false);
434    if reduced_motion {
435        set_continuous_frames(cx, false);
436        return DrivenMotionColor {
437            value: target,
438            animating: false,
439        };
440    }
441
442    let loc = Location::caller();
443    cx.keyed(
444        (loc.file(), loc.line(), loc.column(), "drive_tween_color"),
445        |cx| {
446            let frame_id = cx.frame_id.0;
447            let dt = effective_frame_delta_for_cx(cx);
448
449            let out = cx.slot_state(TweenColorState::default, |st| {
450                if !st.initialized {
451                    st.initialized = true;
452                    st.last_frame_id = frame_id;
453                    st.start = target;
454                    st.target = target;
455                    st.value = target;
456                    st.elapsed = Duration::ZERO;
457                    st.duration = duration;
458                    st.ease = ease;
459                    st.animating = false;
460                }
461
462                // Retarget.
463                if target != st.target
464                    || st.duration != duration
465                    || st.ease as usize != ease as usize
466                {
467                    st.start = st.value;
468                    st.target = target;
469                    st.duration = duration;
470                    st.ease = ease;
471                    st.elapsed = Duration::ZERO;
472                    st.animating = true;
473                }
474
475                // Advance at most once per frame.
476                if st.animating && st.last_frame_id != frame_id {
477                    st.last_frame_id = frame_id;
478                    st.elapsed = st.elapsed.saturating_add(dt);
479
480                    st.value =
481                        tween_color_at(st.start, st.target, st.duration, st.ease, st.elapsed);
482                    if st.elapsed >= st.duration {
483                        st.value = st.target;
484                        st.animating = false;
485                    }
486                } else if st.last_frame_id == 0 {
487                    st.last_frame_id = frame_id;
488                }
489
490                DrivenMotionColor {
491                    value: st.value,
492                    animating: st.animating,
493                }
494            });
495
496            set_continuous_frames(cx, out.animating);
497            if out.animating {
498                cx.notify_for_animation_frame();
499            }
500            out
501        },
502    )
503}
504
505#[track_caller]
506pub fn drive_tween_color_for_element<H: UiHost, K: Hash>(
507    cx: &mut ElementContext<'_, H>,
508    element: GlobalElementId,
509    key: K,
510    target: Color,
511    duration: Duration,
512    ease: fn(f32) -> f32,
513) -> DrivenMotionColor {
514    let reduced_motion = super::prefers_reduced_motion(cx, Invalidation::Paint, false);
515    if reduced_motion {
516        set_continuous_frames(cx, false);
517        return DrivenMotionColor {
518            value: target,
519            animating: false,
520        };
521    }
522
523    let mut hasher = std::collections::hash_map::DefaultHasher::new();
524    ("drive_tween_color_for_element").hash(&mut hasher);
525    key.hash(&mut hasher);
526    let entry_key = hasher.finish();
527
528    cx.keyed(("drive_tween_color_for_element", entry_key), |cx| {
529        let frame_id = cx.frame_id.0;
530        let dt = effective_frame_delta_for_cx(cx);
531
532        let out = cx.state_for(element, TweenColorStateMap::default, |map| {
533            let st = map
534                .entries
535                .entry(entry_key)
536                .or_insert_with(TweenColorState::default);
537
538            if !st.initialized {
539                st.initialized = true;
540                st.last_frame_id = frame_id;
541                st.start = target;
542                st.target = target;
543                st.value = target;
544                st.elapsed = Duration::ZERO;
545                st.duration = duration;
546                st.ease = ease;
547                st.animating = false;
548            }
549
550            // Retarget.
551            if target != st.target || st.duration != duration || st.ease as usize != ease as usize {
552                st.start = st.value;
553                st.target = target;
554                st.duration = duration;
555                st.ease = ease;
556                st.elapsed = Duration::ZERO;
557                st.animating = true;
558            }
559
560            // Advance at most once per frame.
561            if st.animating && st.last_frame_id != frame_id {
562                st.last_frame_id = frame_id;
563                st.elapsed = st.elapsed.saturating_add(dt);
564
565                st.value = tween_color_at(st.start, st.target, st.duration, st.ease, st.elapsed);
566                if st.elapsed >= st.duration {
567                    st.value = st.target;
568                    st.animating = false;
569                }
570            } else if st.last_frame_id == 0 {
571                st.last_frame_id = frame_id;
572            }
573
574            DrivenMotionColor {
575                value: st.value,
576                animating: st.animating,
577            }
578        });
579
580        set_continuous_frames(cx, out.animating);
581        if out.animating {
582            cx.notify_for_animation_frame();
583        }
584        out
585    })
586}
587
588#[track_caller]
589pub fn drive_tween_f32_unclamped<H: UiHost>(
590    cx: &mut ElementContext<'_, H>,
591    target: f32,
592    duration: Duration,
593    ease: fn(f32) -> f32,
594) -> DrivenMotionF32 {
595    let reduced_motion = super::prefers_reduced_motion(cx, Invalidation::Paint, false);
596    if reduced_motion {
597        set_continuous_frames(cx, false);
598        return DrivenMotionF32 {
599            value: target,
600            velocity: 0.0,
601            animating: false,
602        };
603    }
604
605    let loc = Location::caller();
606    cx.keyed(
607        (
608            loc.file(),
609            loc.line(),
610            loc.column(),
611            "drive_tween_f32_unclamped",
612        ),
613        |cx| {
614            let frame_id = cx.frame_id.0;
615            let dt = effective_frame_delta_for_cx(cx);
616
617            let out = cx.slot_state(TweenF32State::default, |st| {
618                if !st.initialized {
619                    st.initialized = true;
620                    st.last_frame_id = frame_id;
621                    st.start = target;
622                    st.target = target;
623                    st.value = target;
624                    st.velocity = 0.0;
625                    st.elapsed = Duration::ZERO;
626                    st.duration = duration;
627                    st.ease = ease;
628                    st.animating = false;
629                }
630
631                // Retarget.
632                if target != st.target
633                    || st.duration != duration
634                    || st.ease as usize != ease as usize
635                {
636                    st.start = st.value;
637                    st.target = target;
638                    st.duration = duration;
639                    st.ease = ease;
640                    st.elapsed = Duration::ZERO;
641                    st.animating = true;
642                }
643
644                // Advance at most once per frame.
645                if st.animating && st.last_frame_id != frame_id {
646                    st.last_frame_id = frame_id;
647                    st.elapsed = st.elapsed.saturating_add(dt);
648
649                    let value = tween_value_at_unclamped(
650                        st.start,
651                        st.target,
652                        st.duration,
653                        st.ease,
654                        st.elapsed,
655                    );
656                    let velocity = tween_velocity_at_unclamped(
657                        st.start,
658                        st.target,
659                        st.duration,
660                        st.ease,
661                        st.elapsed,
662                    );
663                    st.value = value;
664                    st.velocity = velocity;
665
666                    if st.elapsed >= st.duration {
667                        st.value = st.target;
668                        st.velocity = 0.0;
669                        st.animating = false;
670                    }
671                } else if st.last_frame_id == 0 {
672                    st.last_frame_id = frame_id;
673                }
674
675                DrivenMotionF32 {
676                    value: st.value,
677                    velocity: st.velocity,
678                    animating: st.animating,
679                }
680            });
681
682            set_continuous_frames(cx, out.animating);
683            if out.animating {
684                cx.notify_for_animation_frame();
685            }
686            out
687        },
688    )
689}
690
691#[derive(Debug, Clone, Copy, PartialEq)]
692pub struct DrivenLoopProgress {
693    /// Normalized progress within the loop cycle. (`[0.0, 1.0)` while animating.)
694    pub progress: f32,
695    pub animating: bool,
696}
697
698#[derive(Debug, Clone, Copy)]
699struct LoopProgressState {
700    initialized: bool,
701    last_frame_id: u64,
702    elapsed: Duration,
703    period: Duration,
704}
705
706impl Default for LoopProgressState {
707    fn default() -> Self {
708        Self {
709            initialized: false,
710            last_frame_id: 0,
711            elapsed: Duration::ZERO,
712            period: Duration::from_secs(1),
713        }
714    }
715}
716
717fn duration_mod(elapsed: Duration, period: Duration) -> Duration {
718    if period == Duration::ZERO {
719        return Duration::ZERO;
720    }
721    let period_ns = period.as_nanos();
722    if period_ns == 0 {
723        return Duration::ZERO;
724    }
725    let rem_ns = elapsed.as_nanos() % period_ns;
726    Duration::from_nanos(rem_ns.min(u64::MAX as u128) as u64)
727}
728
729#[track_caller]
730pub fn drive_loop_progress<H: UiHost>(
731    cx: &mut ElementContext<'_, H>,
732    enabled: bool,
733    period: Duration,
734) -> DrivenLoopProgress {
735    let loc = Location::caller();
736    drive_loop_progress_keyed(
737        cx,
738        (loc.file(), loc.line(), loc.column(), "drive_loop_progress"),
739        enabled,
740        period,
741    )
742}
743
744pub fn drive_loop_progress_keyed<H: UiHost, K: Hash>(
745    cx: &mut ElementContext<'_, H>,
746    key: K,
747    enabled: bool,
748    period: Duration,
749) -> DrivenLoopProgress {
750    let reduced_motion = super::prefers_reduced_motion(cx, Invalidation::Paint, false);
751    cx.keyed(key, |cx| {
752        let frame_id = cx.frame_id.0;
753        let dt = effective_frame_delta_for_cx(cx);
754
755        let out = cx.slot_state(LoopProgressState::default, |st| {
756            if !st.initialized {
757                st.initialized = true;
758                st.last_frame_id = frame_id;
759                st.elapsed = Duration::ZERO;
760                st.period = period;
761            }
762
763            if reduced_motion || !enabled || period == Duration::ZERO {
764                *st = LoopProgressState::default();
765                return DrivenLoopProgress {
766                    progress: 0.0,
767                    animating: false,
768                };
769            }
770
771            if st.period != period {
772                let frac = if st.period == Duration::ZERO {
773                    0.0
774                } else {
775                    (st.elapsed.as_secs_f64() / st.period.as_secs_f64()).clamp(0.0, 1.0)
776                };
777                st.period = period;
778                st.elapsed = Duration::from_secs_f64(frac * period.as_secs_f64());
779            }
780
781            if st.last_frame_id != frame_id {
782                st.last_frame_id = frame_id;
783                st.elapsed = duration_mod(st.elapsed.saturating_add(dt), st.period);
784            } else if st.last_frame_id == 0 {
785                st.last_frame_id = frame_id;
786            }
787
788            let progress = if st.period == Duration::ZERO {
789                0.0
790            } else {
791                (st.elapsed.as_secs_f64() / st.period.as_secs_f64()).clamp(0.0, 1.0) as f32
792            };
793
794            DrivenLoopProgress {
795                progress,
796                animating: true,
797            }
798        });
799
800        set_continuous_frames(cx, out.animating);
801        if out.animating {
802            cx.notify_for_animation_frame();
803        }
804        out
805    })
806}
807
808#[derive(Debug, Clone, Copy)]
809struct InertiaF32State {
810    initialized: bool,
811    last_frame_id: u64,
812    start: f32,
813    start_velocity: f32,
814    value: f32,
815    velocity: f32,
816    elapsed: Duration,
817    drag: f64,
818    bounds: Option<(f32, f32)>,
819    bounce_spring: SpringDescription,
820    tolerance: Tolerance,
821    last_kick_id: u64,
822    animating: bool,
823}
824
825impl Default for InertiaF32State {
826    fn default() -> Self {
827        Self {
828            initialized: false,
829            last_frame_id: 0,
830            start: 0.0,
831            start_velocity: 0.0,
832            value: 0.0,
833            velocity: 0.0,
834            elapsed: Duration::ZERO,
835            drag: 0.135,
836            bounds: None,
837            bounce_spring: SpringDescription::with_duration_and_bounce(
838                Duration::from_millis(240),
839                0.25,
840            ),
841            tolerance: Tolerance::default(),
842            last_kick_id: 0,
843            animating: false,
844        }
845    }
846}
847
848#[track_caller]
849pub fn drive_inertia_f32<H: UiHost>(
850    cx: &mut ElementContext<'_, H>,
851    kick: Option<InertiaKick>,
852    drag: f64,
853    bounds: Option<(f32, f32)>,
854    bounce_spring: SpringDescription,
855    tolerance: Tolerance,
856) -> DrivenMotionF32 {
857    let inertia_state_slot = cx.keyed_slot_id("drive_inertia_f32");
858    let reduced_motion = super::prefers_reduced_motion(cx, Invalidation::Paint, false);
859    if reduced_motion {
860        set_continuous_frames(cx, false);
861        return DrivenMotionF32 {
862            value: cx.state_for(inertia_state_slot, InertiaF32State::default, |st| st.value),
863            velocity: 0.0,
864            animating: false,
865        };
866    }
867
868    let loc = Location::caller();
869    cx.keyed(
870        (loc.file(), loc.line(), loc.column(), "drive_inertia_f32"),
871        |cx| {
872            let frame_id = cx.frame_id.0;
873            let dt = effective_frame_delta_for_cx(cx);
874
875            let out = cx.state_for(inertia_state_slot, InertiaF32State::default, |st| {
876                if !st.initialized {
877                    st.initialized = true;
878                    st.last_frame_id = frame_id;
879                    st.start = 0.0;
880                    st.start_velocity = 0.0;
881                    st.value = 0.0;
882                    st.velocity = 0.0;
883                    st.elapsed = Duration::ZERO;
884                    st.drag = drag;
885                    st.bounds = bounds;
886                    st.bounce_spring = bounce_spring;
887                    st.tolerance = tolerance;
888                    st.last_kick_id = kick.map(|k| k.id).unwrap_or(0);
889                    st.animating = false;
890                }
891
892                let kick_retarget =
893                    kick.is_some() && kick.map(|k| k.id).unwrap_or(0) != st.last_kick_id;
894                if kick_retarget
895                    || st.drag != drag
896                    || st.bounds != bounds
897                    || st.bounce_spring != bounce_spring
898                    || st.tolerance != tolerance
899                {
900                    if let Some(kick) = kick {
901                        st.last_kick_id = kick.id;
902                        st.start = st.value;
903                        st.start_velocity = kick.velocity;
904                        st.velocity = kick.velocity;
905                        st.animating = true;
906                        st.elapsed = Duration::ZERO;
907                    } else if st.animating {
908                        // Parameter change while animating: rebase from current state.
909                        st.start = st.value;
910                        st.start_velocity = st.velocity;
911                        st.elapsed = Duration::ZERO;
912                    }
913                    st.drag = drag;
914                    st.bounds = bounds;
915                    st.bounce_spring = bounce_spring;
916                    st.tolerance = tolerance;
917                }
918
919                if st.animating && st.last_frame_id != frame_id {
920                    st.last_frame_id = frame_id;
921                    st.elapsed = st.elapsed.saturating_add(dt);
922
923                    let inertia_bounds = st.bounds.map(|(min, max)| InertiaBounds {
924                        min: min as f64,
925                        max: max as f64,
926                    });
927
928                    let sim = InertiaSimulation::new(
929                        st.start as f64,
930                        st.start_velocity as f64,
931                        st.drag,
932                        inertia_bounds,
933                        st.bounce_spring,
934                        st.tolerance,
935                    );
936
937                    st.value = sim.x(st.elapsed) as f32;
938                    st.velocity = sim.dx(st.elapsed) as f32;
939                    if sim.is_done(st.elapsed) {
940                        st.value = sim.final_x() as f32;
941                        st.velocity = 0.0;
942                        st.animating = false;
943                    }
944                } else if st.last_frame_id == 0 {
945                    st.last_frame_id = frame_id;
946                }
947
948                DrivenMotionF32 {
949                    value: st.value,
950                    velocity: st.velocity,
951                    animating: st.animating,
952                }
953            });
954
955            set_continuous_frames(cx, out.animating);
956            if out.animating {
957                cx.notify_for_animation_frame();
958            }
959            out
960        },
961    )
962}
963
964#[derive(Debug, Clone, Copy)]
965struct SpringF32State {
966    initialized: bool,
967    last_frame_id: u64,
968    start: f32,
969    target: f32,
970    value: f32,
971    velocity: f32,
972    elapsed: Duration,
973    spring: SpringDescription,
974    tolerance: Tolerance,
975    snap_to_target: bool,
976    last_kick_id: u64,
977    animating: bool,
978}
979
980impl Default for SpringF32State {
981    fn default() -> Self {
982        Self {
983            initialized: false,
984            last_frame_id: 0,
985            start: 0.0,
986            target: 0.0,
987            value: 0.0,
988            velocity: 0.0,
989            elapsed: Duration::ZERO,
990            spring: SpringDescription::with_duration_and_bounce(Duration::from_millis(240), 0.0),
991            tolerance: Tolerance::default(),
992            snap_to_target: true,
993            last_kick_id: 0,
994            animating: false,
995        }
996    }
997}
998
999#[track_caller]
1000pub fn drive_spring_f32<H: UiHost>(
1001    cx: &mut ElementContext<'_, H>,
1002    target: f32,
1003    kick: Option<SpringKick>,
1004    spring: SpringDescription,
1005    tolerance: Tolerance,
1006    snap_to_target: bool,
1007) -> DrivenMotionF32 {
1008    let reduced_motion = super::prefers_reduced_motion(cx, Invalidation::Paint, false);
1009    if reduced_motion {
1010        set_continuous_frames(cx, false);
1011        return DrivenMotionF32 {
1012            value: target,
1013            velocity: 0.0,
1014            animating: false,
1015        };
1016    }
1017
1018    let loc = Location::caller();
1019    cx.keyed(
1020        (loc.file(), loc.line(), loc.column(), "drive_spring_f32"),
1021        |cx| {
1022            let frame_id = cx.frame_id.0;
1023            let dt = effective_frame_delta_for_cx(cx);
1024
1025            let out = cx.slot_state(SpringF32State::default, |st| {
1026                if !st.initialized {
1027                    st.initialized = true;
1028                    st.last_frame_id = frame_id;
1029                    st.start = target;
1030                    st.target = target;
1031                    st.value = target;
1032                    st.velocity = 0.0;
1033                    st.elapsed = Duration::ZERO;
1034                    st.spring = spring;
1035                    st.tolerance = tolerance;
1036                    st.snap_to_target = snap_to_target;
1037                    st.last_kick_id = kick.map(|k| k.id).unwrap_or(0);
1038                    st.animating = false;
1039                }
1040
1041                let kick_retarget =
1042                    kick.is_some() && kick.map(|k| k.id).unwrap_or(0) != st.last_kick_id;
1043
1044                if target != st.target
1045                    || st.spring != spring
1046                    || st.tolerance != tolerance
1047                    || st.snap_to_target != snap_to_target
1048                    || kick_retarget
1049                {
1050                    st.start = st.value;
1051                    st.target = target;
1052                    st.elapsed = Duration::ZERO;
1053                    st.spring = spring;
1054                    st.tolerance = tolerance;
1055                    st.snap_to_target = snap_to_target;
1056                    st.animating = true;
1057
1058                    if let Some(kick) = kick
1059                        && kick.id != st.last_kick_id
1060                    {
1061                        st.velocity = kick.velocity;
1062                        st.last_kick_id = kick.id;
1063                    }
1064                }
1065
1066                if st.animating && st.last_frame_id != frame_id {
1067                    st.last_frame_id = frame_id;
1068                    st.elapsed = st.elapsed.saturating_add(dt);
1069
1070                    let sim = SpringSimulation::new(
1071                        st.spring,
1072                        st.start as f64,
1073                        st.target as f64,
1074                        st.velocity as f64,
1075                        st.snap_to_target,
1076                        st.tolerance,
1077                    );
1078
1079                    st.value = sim.x(st.elapsed) as f32;
1080                    st.velocity = sim.dx(st.elapsed) as f32;
1081
1082                    if sim.is_done(st.elapsed) {
1083                        st.value = st.target;
1084                        st.velocity = 0.0;
1085                        st.animating = false;
1086                    }
1087                } else if st.last_frame_id == 0 {
1088                    st.last_frame_id = frame_id;
1089                }
1090
1091                DrivenMotionF32 {
1092                    value: st.value,
1093                    velocity: st.velocity,
1094                    animating: st.animating,
1095                }
1096            });
1097
1098            set_continuous_frames(cx, out.animating);
1099            if out.animating {
1100                cx.notify_for_animation_frame();
1101            }
1102            out
1103        },
1104    )
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109    use super::*;
1110
1111    use fret_app::App;
1112    use fret_core::{AppWindowId, WindowFrameClockService};
1113    use fret_runtime::{FrameId, TickId};
1114    use fret_ui::elements::with_element_cx;
1115
1116    fn bounds() -> fret_core::Rect {
1117        fret_core::Rect::new(
1118            fret_core::Point::new(fret_core::Px(0.0), fret_core::Px(0.0)),
1119            fret_core::Size::new(fret_core::Px(800.0), fret_core::Px(600.0)),
1120        )
1121    }
1122
1123    #[test]
1124    fn loop_progress_advances_across_frames() {
1125        let window = AppWindowId::default();
1126        let mut app = App::new();
1127
1128        app.set_tick_id(TickId(1));
1129        app.set_frame_id(FrameId(1));
1130        let p0 = with_element_cx(&mut app, window, bounds(), "loop", |cx| {
1131            drive_loop_progress_keyed(cx, "loop_progress", true, Duration::from_secs(2)).progress
1132        });
1133
1134        app.set_tick_id(TickId(2));
1135        app.set_frame_id(FrameId(2));
1136        let p1 = with_element_cx(&mut app, window, bounds(), "loop", |cx| {
1137            drive_loop_progress_keyed(cx, "loop_progress", true, Duration::from_secs(2)).progress
1138        });
1139
1140        assert!(
1141            p1 > p0,
1142            "expected loop progress to advance (p0={p0} p1={p1})"
1143        );
1144        assert!(
1145            p1 < 1.0,
1146            "expected loop progress to remain normalized (p1={p1})"
1147        );
1148    }
1149
1150    #[test]
1151    fn tween_scales_with_fixed_delta_and_settles_in_expected_frames() {
1152        let window = AppWindowId::default();
1153        let mut app = App::new();
1154
1155        app.with_global_mut(WindowFrameClockService::default, |svc, _app| {
1156            svc.set_fixed_delta(window, Some(Duration::from_millis(8)));
1157        });
1158
1159        for fid in [FrameId(1), FrameId(2)] {
1160            app.set_frame_id(fid);
1161            app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1162                svc.record_frame(window, app.frame_id());
1163            });
1164        }
1165
1166        fn drive<H: UiHost>(cx: &mut ElementContext<'_, H>, target: f32) -> DrivenMotionF32 {
1167            drive_tween_f32(
1168                cx,
1169                target,
1170                Duration::from_millis(200),
1171                crate::headless::easing::linear,
1172            )
1173        }
1174
1175        // Initialize at 0.0 so we can retarget to 1.0 and observe motion over time.
1176        app.set_tick_id(TickId(1));
1177        app.set_frame_id(FrameId(2));
1178        let _ = with_element_cx(&mut app, window, bounds(), "tween", |cx| drive(cx, 0.0));
1179
1180        let mut frames = 0u64;
1181        let mut frame_id = 2u64;
1182        loop {
1183            frames += 1;
1184            frame_id += 1;
1185            app.set_tick_id(TickId(frames));
1186            app.set_frame_id(FrameId(frame_id));
1187            app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1188                svc.record_frame(window, app.frame_id());
1189            });
1190
1191            let out = with_element_cx(&mut app, window, bounds(), "tween", |cx| drive(cx, 1.0));
1192            if !out.animating {
1193                break;
1194            }
1195            assert!(
1196                frames < 200,
1197                "tween did not settle in a reasonable number of frames"
1198            );
1199        }
1200
1201        // 200ms / 8ms ~= 25 frames.
1202        assert_eq!(frames, 25);
1203    }
1204
1205    #[test]
1206    fn tween_for_element_advances_without_snapping_on_retarget() {
1207        let window = AppWindowId::default();
1208        let mut app = App::new();
1209
1210        app.with_global_mut(WindowFrameClockService::default, |svc, _app| {
1211            svc.set_fixed_delta(window, Some(Duration::from_millis(16)));
1212        });
1213
1214        for fid in [FrameId(1), FrameId(2)] {
1215            app.set_frame_id(fid);
1216            app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1217                svc.record_frame(window, app.frame_id());
1218            });
1219        }
1220
1221        app.set_tick_id(TickId(1));
1222        app.set_frame_id(FrameId(2));
1223        let anchor = with_element_cx(&mut app, window, bounds(), "tween_for_element", |cx| {
1224            cx.keyed("anchor", |cx| cx.root_id())
1225        });
1226
1227        let _ = with_element_cx(&mut app, window, bounds(), "tween_for_element", |cx| {
1228            drive_tween_f32_for_element(
1229                cx,
1230                anchor,
1231                "value",
1232                0.0,
1233                Duration::from_millis(150),
1234                crate::headless::easing::linear,
1235            )
1236        });
1237
1238        app.set_tick_id(TickId(2));
1239        app.set_frame_id(FrameId(3));
1240        app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1241            svc.record_frame(window, app.frame_id());
1242        });
1243
1244        let out = with_element_cx(&mut app, window, bounds(), "tween_for_element", |cx| {
1245            drive_tween_f32_for_element(
1246                cx,
1247                anchor,
1248                "value",
1249                1.0,
1250                Duration::from_millis(150),
1251                crate::headless::easing::linear,
1252            )
1253        });
1254
1255        assert!(
1256            out.value > 0.0 && out.value < 1.0,
1257            "expected tween to advance but not snap; got value={}",
1258            out.value
1259        );
1260        assert!(out.animating, "expected tween to still be animating");
1261    }
1262
1263    #[test]
1264    fn tween_for_element_respects_cubic_bezier_ease() {
1265        let window = AppWindowId::default();
1266        let mut app = App::new();
1267
1268        app.with_global_mut(WindowFrameClockService::default, |svc, _app| {
1269            svc.set_fixed_delta(window, Some(Duration::from_millis(16)));
1270        });
1271
1272        for fid in [FrameId(1), FrameId(2)] {
1273            app.set_frame_id(fid);
1274            app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1275                svc.record_frame(window, app.frame_id());
1276            });
1277        }
1278
1279        let ease = |t: f32| crate::headless::easing::CubicBezier::new(0.4, 0.0, 0.2, 1.0).sample(t);
1280
1281        app.set_tick_id(TickId(1));
1282        app.set_frame_id(FrameId(2));
1283        let anchor = with_element_cx(
1284            &mut app,
1285            window,
1286            bounds(),
1287            "tween_for_element_cubic",
1288            |cx| cx.keyed("anchor", |cx| cx.root_id()),
1289        );
1290
1291        let _ = with_element_cx(
1292            &mut app,
1293            window,
1294            bounds(),
1295            "tween_for_element_cubic",
1296            |cx| {
1297                drive_tween_f32_for_element(
1298                    cx,
1299                    anchor,
1300                    "value",
1301                    0.0,
1302                    Duration::from_millis(150),
1303                    ease,
1304                )
1305            },
1306        );
1307
1308        app.set_tick_id(TickId(2));
1309        app.set_frame_id(FrameId(3));
1310        app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1311            svc.record_frame(window, app.frame_id());
1312        });
1313
1314        let out = with_element_cx(
1315            &mut app,
1316            window,
1317            bounds(),
1318            "tween_for_element_cubic",
1319            |cx| {
1320                drive_tween_f32_for_element(
1321                    cx,
1322                    anchor,
1323                    "value",
1324                    1.0,
1325                    Duration::from_millis(150),
1326                    ease,
1327                )
1328            },
1329        );
1330
1331        assert!(
1332            out.value > 0.0 && out.value < 1.0,
1333            "expected tween to advance but not snap; got value={}",
1334            out.value
1335        );
1336        assert!(out.animating, "expected tween to still be animating");
1337    }
1338
1339    #[test]
1340    fn color_tween_for_element_advances_without_snapping_on_retarget() {
1341        let window = AppWindowId::default();
1342        let mut app = App::new();
1343
1344        app.with_global_mut(WindowFrameClockService::default, |svc, _app| {
1345            svc.set_fixed_delta(window, Some(Duration::from_millis(16)));
1346        });
1347
1348        for fid in [FrameId(1), FrameId(2)] {
1349            app.set_frame_id(fid);
1350            app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1351                svc.record_frame(window, app.frame_id());
1352            });
1353        }
1354
1355        let ease = |t: f32| crate::headless::easing::CubicBezier::new(0.4, 0.0, 0.2, 1.0).sample(t);
1356
1357        app.set_tick_id(TickId(1));
1358        app.set_frame_id(FrameId(2));
1359        let anchor = with_element_cx(
1360            &mut app,
1361            window,
1362            bounds(),
1363            "tween_color_for_element",
1364            |cx| cx.keyed("anchor", |cx| cx.root_id()),
1365        );
1366
1367        let c0 = Color {
1368            r: 0.0,
1369            g: 0.0,
1370            b: 0.0,
1371            a: 1.0,
1372        };
1373        let c1 = Color {
1374            r: 1.0,
1375            g: 0.0,
1376            b: 0.0,
1377            a: 1.0,
1378        };
1379
1380        let _ = with_element_cx(
1381            &mut app,
1382            window,
1383            bounds(),
1384            "tween_color_for_element",
1385            |cx| {
1386                drive_tween_color_for_element(
1387                    cx,
1388                    anchor,
1389                    "value",
1390                    c0,
1391                    Duration::from_millis(150),
1392                    ease,
1393                )
1394            },
1395        );
1396
1397        app.set_tick_id(TickId(2));
1398        app.set_frame_id(FrameId(3));
1399        app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1400            svc.record_frame(window, app.frame_id());
1401        });
1402
1403        let out = with_element_cx(
1404            &mut app,
1405            window,
1406            bounds(),
1407            "tween_color_for_element",
1408            |cx| {
1409                drive_tween_color_for_element(
1410                    cx,
1411                    anchor,
1412                    "value",
1413                    c1,
1414                    Duration::from_millis(150),
1415                    ease,
1416                )
1417            },
1418        );
1419
1420        assert!(
1421            out.value != c0 && out.value != c1,
1422            "expected color tween to advance but not snap; got value={:?}",
1423            out.value
1424        );
1425        assert!(out.animating, "expected color tween to still be animating");
1426    }
1427
1428    #[test]
1429    fn spring_settles_with_fixed_delta_and_kick_velocity() {
1430        let window = AppWindowId::default();
1431        let mut app = App::new();
1432
1433        app.with_global_mut(WindowFrameClockService::default, |svc, _app| {
1434            svc.set_fixed_delta(window, Some(Duration::from_millis(8)));
1435        });
1436
1437        for fid in [FrameId(1), FrameId(2)] {
1438            app.set_frame_id(fid);
1439            app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1440                svc.record_frame(window, app.frame_id());
1441            });
1442        }
1443
1444        fn drive<H: UiHost>(
1445            cx: &mut ElementContext<'_, H>,
1446            target: f32,
1447            kick: Option<SpringKick>,
1448        ) -> DrivenMotionF32 {
1449            drive_spring_f32(
1450                cx,
1451                target,
1452                kick,
1453                SpringDescription::with_duration_and_bounce(Duration::from_millis(240), 0.0),
1454                Tolerance::default(),
1455                true,
1456            )
1457        }
1458
1459        // Initialize at rest.
1460        app.set_tick_id(TickId(1));
1461        app.set_frame_id(FrameId(2));
1462        let _ = with_element_cx(&mut app, window, bounds(), "spring", |cx| {
1463            drive(cx, 0.0, None)
1464        });
1465
1466        let kick = SpringKick {
1467            id: 1,
1468            velocity: 1200.0,
1469        };
1470        let mut frame_id = 2u64;
1471        let mut frames = 0u64;
1472        loop {
1473            frames += 1;
1474            frame_id += 1;
1475            app.set_tick_id(TickId(frames));
1476            app.set_frame_id(FrameId(frame_id));
1477            app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1478                svc.record_frame(window, app.frame_id());
1479            });
1480
1481            let out = with_element_cx(&mut app, window, bounds(), "spring", |cx| {
1482                drive(cx, 1.0, Some(kick))
1483            });
1484
1485            if !out.animating {
1486                assert!((out.value - 1.0).abs() < 1e-4);
1487                assert!(out.velocity.abs() < 1e-3);
1488                break;
1489            }
1490
1491            assert!(
1492                frames < 200,
1493                "spring did not settle in a reasonable number of frames"
1494            );
1495        }
1496    }
1497
1498    #[test]
1499    fn inertia_decays_and_respects_bounds_under_fixed_delta() {
1500        let window = AppWindowId::default();
1501        let mut app = App::new();
1502
1503        app.with_global_mut(WindowFrameClockService::default, |svc, _app| {
1504            svc.set_fixed_delta(window, Some(Duration::from_millis(8)));
1505        });
1506
1507        for fid in [FrameId(1), FrameId(2)] {
1508            app.set_frame_id(fid);
1509            app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1510                svc.record_frame(window, app.frame_id());
1511            });
1512        }
1513
1514        fn drive<H: UiHost>(
1515            cx: &mut ElementContext<'_, H>,
1516            kick: Option<InertiaKick>,
1517        ) -> DrivenMotionF32 {
1518            drive_inertia_f32(
1519                cx,
1520                kick,
1521                0.135,
1522                Some((0.0, 1.0)),
1523                SpringDescription::with_duration_and_bounce(Duration::from_millis(240), 0.25),
1524                Tolerance::default(),
1525            )
1526        }
1527
1528        app.set_tick_id(TickId(1));
1529        app.set_frame_id(FrameId(2));
1530        let _ = with_element_cx(&mut app, window, bounds(), "inertia", |cx| drive(cx, None));
1531
1532        let kick = InertiaKick {
1533            id: 1,
1534            velocity: 5000.0,
1535        };
1536
1537        let mut frames = 0u64;
1538        let mut frame_id = 2u64;
1539        let mut saw_motion = false;
1540        loop {
1541            frames += 1;
1542            frame_id += 1;
1543            app.set_tick_id(TickId(frames));
1544            app.set_frame_id(FrameId(frame_id));
1545            app.with_global_mut(WindowFrameClockService::default, |svc, app| {
1546                svc.record_frame(window, app.frame_id());
1547            });
1548
1549            let out = with_element_cx(&mut app, window, bounds(), "inertia", |cx| {
1550                drive(cx, Some(kick))
1551            });
1552            if out.animating {
1553                saw_motion = true;
1554            }
1555            assert!(
1556                (0.0..=1.0).contains(&out.value) || out.value.is_finite(),
1557                "inertia output must be finite; got value={:?}",
1558                out.value
1559            );
1560            if !out.animating {
1561                assert!(saw_motion, "expected inertia to animate at least one frame");
1562                assert!((out.value - 1.0).abs() < 1e-3);
1563                break;
1564            }
1565
1566            assert!(frames < 800, "inertia did not settle in time");
1567        }
1568    }
1569}