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 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 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 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 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 }
299
300 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 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 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 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 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 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 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 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 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 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 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 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 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 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}