Skip to main content

sequoia_view/
live.rs

1#![forbid(unsafe_code)]
2
3use std::rc::Rc;
4use std::time::{Duration, Instant};
5
6use sequoia_animation::Easing;
7use sequoia_core::{Color, Vec2};
8use sequoia_reactive::{Memo, Signal};
9
10use crate::clock::MAX_FRAME_STEP_SECS;
11
12#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
13pub struct Prop(pub u64);
14
15impl Prop {
16    pub const OPACITY: Self = Self(1);
17    pub const OFFSET: Self = Self(2);
18    pub const OFFSET_X: Self = Self(3);
19    pub const OFFSET_Y: Self = Self(4);
20    pub const BLUR: Self = Self(5);
21    pub const BACKGROUND: Self = Self(6);
22    pub const SHADOW: Self = Self(7);
23    pub const COLOR: Self = Self(8);
24    pub const RADIUS: Self = Self(9);
25
26    pub const SCALE: Self = Self(10);
27
28    pub const ROTATION: Self = Self(11);
29
30    pub const WIDTH: Self = Self(12);
31
32    pub const HEIGHT: Self = Self(13);
33
34    pub const fn custom(id: u64) -> Self {
35        Self(id)
36    }
37}
38
39#[derive(Clone, Copy, Debug, PartialEq)]
40pub struct AnimSpec {
41    pub duration_secs: f32,
42    pub delay_secs: f32,
43    pub easing: Easing,
44}
45
46impl AnimSpec {
47    pub const FAST: Self = Self {
48        duration_secs: 0.12,
49        delay_secs: 0.0,
50        easing: Easing::Linear,
51    };
52
53    pub const SLOW: Self = Self {
54        duration_secs: 0.25,
55        delay_secs: 0.0,
56        easing: Easing::EaseOutCubic,
57    };
58
59    pub const SMOOTH: Self = Self {
60        duration_secs: 0.20,
61        delay_secs: 0.0,
62        easing: Easing::EaseOutCubic,
63    };
64
65    pub const SNAPPY: Self = Self {
66        duration_secs: 0.12,
67        delay_secs: 0.0,
68        easing: Easing::EaseOutCubic,
69    };
70
71    pub const GENTLE: Self = Self {
72        duration_secs: 0.35,
73        delay_secs: 0.0,
74        easing: Easing::EaseInOutCubic,
75    };
76
77    pub fn fast() -> Self {
78        Self::FAST
79    }
80    pub fn smooth() -> Self {
81        Self::SMOOTH
82    }
83    pub fn snappy() -> Self {
84        Self::SNAPPY
85    }
86    pub fn gentle() -> Self {
87        Self::GENTLE
88    }
89    pub fn slow() -> Self {
90        Self::SLOW
91    }
92
93    pub fn linear(duration: Duration) -> Self {
94        Self {
95            duration_secs: duration.as_secs_f32().max(0.0),
96            delay_secs: 0.0,
97            easing: Easing::Linear,
98        }
99    }
100
101    pub fn ease_out_cubic(duration: Duration) -> Self {
102        Self {
103            duration_secs: duration.as_secs_f32().max(0.0),
104            delay_secs: 0.0,
105            easing: Easing::EaseOutCubic,
106        }
107    }
108
109    pub fn ease_out(duration: Duration) -> Self {
110        Self::ease_out_cubic(duration)
111    }
112
113    pub fn ease_in_out(duration: Duration) -> Self {
114        Self {
115            duration_secs: duration.as_secs_f32().max(0.0),
116            delay_secs: 0.0,
117            easing: Easing::EaseInOutCubic,
118        }
119    }
120
121    pub fn from_duration(duration: Duration, easing: Easing) -> Self {
122        Self {
123            duration_secs: duration.as_secs_f32().max(0.0),
124            delay_secs: 0.0,
125            easing,
126        }
127    }
128
129    pub fn delay_duration(mut self, d: Duration) -> Self {
130        self.delay_secs = d.as_secs_f32().max(0.0);
131        self
132    }
133
134    pub fn sample(&self, from: f32, to: f32, elapsed_secs: f32) -> f32 {
135        let t = elapsed_secs.max(0.0);
136        if t < self.delay_secs {
137            return from;
138        }
139        if self.duration_secs <= 0.0 {
140            return to;
141        }
142        let k = ((t - self.delay_secs) / self.duration_secs).clamp(0.0, 1.0);
143        from + (to - from) * self.easing.apply(k)
144    }
145
146    pub fn is_running(&self, elapsed_secs: f32) -> bool {
147        elapsed_secs < self.delay_secs + self.duration_secs
148    }
149}
150
151impl From<Duration> for AnimSpec {
152    fn from(d: Duration) -> Self {
153        Self {
154            duration_secs: d.as_secs_f32().max(0.0),
155            delay_secs: 0.0,
156            easing: Easing::EaseOutCubic,
157        }
158    }
159}
160
161#[derive(Clone, Copy, Debug, PartialEq)]
162pub struct SpringSpec {
163    pub stiffness: f32,
164    pub damping: f32,
165    pub mass: f32,
166}
167
168impl SpringSpec {
169    pub const DEFAULT: Self = Self {
170        stiffness: 180.0,
171        damping: 20.0,
172        mass: 1.0,
173    };
174
175    pub const SNAPPY: Self = Self {
176        stiffness: 320.0,
177        damping: 22.0,
178        mass: 1.0,
179    };
180
181    pub const BOUNCY: Self = Self {
182        stiffness: 170.0,
183        damping: 11.0,
184        mass: 1.0,
185    };
186
187    pub const GENTLE: Self = Self {
188        stiffness: 90.0,
189        damping: 19.0,
190        mass: 1.0,
191    };
192
193    pub fn snappy() -> Self {
194        Self::SNAPPY
195    }
196    pub fn bouncy() -> Self {
197        Self::BOUNCY
198    }
199    pub fn gentle() -> Self {
200        Self::GENTLE
201    }
202
203    pub fn stiffness(mut self, k: f32) -> Self {
204        self.stiffness = k.max(0.0);
205        self
206    }
207
208    pub fn damping(mut self, c: f32) -> Self {
209        self.damping = c.max(0.0);
210        self
211    }
212
213    pub fn mass(mut self, m: f32) -> Self {
214        self.mass = m.max(0.0001);
215        self
216    }
217}
218
219impl Default for SpringSpec {
220    fn default() -> Self {
221        Self::DEFAULT
222    }
223}
224
225enum Source<T> {
226    Fixed(T),
227    Signal(Signal<T>),
228    Memo(Memo<T>),
229    Derived(Rc<dyn Fn() -> T>),
230}
231
232impl<T: Clone> Clone for Source<T> {
233    fn clone(&self) -> Self {
234        match self {
235            Source::Fixed(v) => Source::Fixed(v.clone()),
236            Source::Signal(s) => Source::Signal(s.clone()),
237            Source::Memo(m) => Source::Memo(m.clone()),
238            Source::Derived(f) => Source::Derived(Rc::clone(f)),
239        }
240    }
241}
242
243pub struct Live<T> {
244    source: Source<T>,
245    tween: Option<AnimSpec>,
246    spring: Option<SpringSpec>,
247}
248
249impl<T: Clone> Clone for Live<T> {
250    fn clone(&self) -> Self {
251        Self {
252            source: self.source.clone(),
253            tween: self.tween,
254            spring: self.spring,
255        }
256    }
257}
258
259impl<T: Clone + PartialEq + 'static> Live<T> {
260    pub fn fixed(value: T) -> Self {
261        Self {
262            source: Source::Fixed(value),
263            tween: None,
264            spring: None,
265        }
266    }
267
268    pub fn of_signal(signal: Signal<T>) -> Self {
269        Self {
270            source: Source::Signal(signal),
271            tween: None,
272            spring: None,
273        }
274    }
275
276    pub fn of_memo(memo: Memo<T>) -> Self {
277        Self {
278            source: Source::Memo(memo),
279            tween: None,
280            spring: None,
281        }
282    }
283
284    pub fn derived(f: impl Fn() -> T + 'static) -> Self {
285        Self {
286            source: Source::Derived(Rc::new(f)),
287            tween: None,
288            spring: None,
289        }
290    }
291
292    pub fn animate(mut self, spec: impl Into<AnimSpec>) -> Self {
293        self.tween = Some(spec.into());
294        self.spring = None;
295        self
296    }
297
298    pub fn spring(mut self, spec: SpringSpec) -> Self {
299        self.spring = Some(spec);
300        self.tween = None;
301        self
302    }
303
304    pub fn animate_mut(&mut self, spec: impl Into<AnimSpec>) {
305        self.tween = Some(spec.into());
306        self.spring = None;
307    }
308
309    pub fn spring_mut(&mut self, spec: SpringSpec) {
310        self.spring = Some(spec);
311        self.tween = None;
312    }
313
314    pub fn tween_spec(&self) -> Option<AnimSpec> {
315        self.tween
316    }
317
318    pub fn spring_spec(&self) -> Option<SpringSpec> {
319        self.spring
320    }
321
322    pub fn is_animated(&self) -> bool {
323        self.tween.is_some() || self.spring.is_some()
324    }
325
326    pub fn target(&self) -> T {
327        match &self.source {
328            Source::Fixed(v) => v.clone(),
329            Source::Signal(s) => s.get(),
330            Source::Memo(m) => m.get(),
331            Source::Derived(f) => f(),
332        }
333    }
334
335    pub fn target_untracked(&self) -> T
336    where
337        T: Clone,
338    {
339        match &self.source {
340            Source::Fixed(v) => v.clone(),
341            Source::Signal(s) => s.get_untracked(),
342            Source::Memo(m) => m.get_untracked(),
343            Source::Derived(f) => sequoia_reactive::untracked(|| f()),
344        }
345    }
346}
347
348impl<T: Clone + PartialEq + 'static> From<T> for Live<T> {
349    fn from(value: T) -> Self {
350        Self::fixed(value)
351    }
352}
353
354impl<T: Clone + PartialEq + 'static> From<Signal<T>> for Live<T> {
355    fn from(signal: Signal<T>) -> Self {
356        Self::of_signal(signal)
357    }
358}
359
360impl<T: Clone + PartialEq + 'static> From<Memo<T>> for Live<T> {
361    fn from(memo: Memo<T>) -> Self {
362        Self::of_memo(memo)
363    }
364}
365
366impl From<f64> for Live<f32> {
367    fn from(v: f64) -> Self {
368        Self::fixed(v as f32)
369    }
370}
371
372pub trait SignalLiveExt<T: Clone + PartialEq + 'static> {
373    fn live(self) -> Live<T>;
374    fn map_live<U: Clone + PartialEq + 'static>(&self, f: impl Fn(&T) -> U + 'static) -> Live<U>;
375    fn animate(self, spec: impl Into<AnimSpec>) -> Live<T>;
376    fn spring(self, spec: SpringSpec) -> Live<T>;
377}
378
379impl<T: Clone + PartialEq + 'static> SignalLiveExt<T> for Signal<T> {
380    fn live(self) -> Live<T> {
381        Live::of_signal(self)
382    }
383
384    fn map_live<U: Clone + PartialEq + 'static>(&self, f: impl Fn(&T) -> U + 'static) -> Live<U> {
385        let s = self.clone();
386        Live::derived(move || f(&s.get()))
387    }
388
389    fn animate(self, spec: impl Into<AnimSpec>) -> Live<T> {
390        Live::of_signal(self).animate(spec)
391    }
392
393    fn spring(self, spec: SpringSpec) -> Live<T> {
394        Live::of_signal(self).spring(spec)
395    }
396}
397
398pub trait MemoLiveExt<T: Clone + PartialEq + 'static> {
399    fn live(self) -> Live<T>;
400    fn map_live<U: Clone + PartialEq + 'static>(&self, f: impl Fn(&T) -> U + 'static) -> Live<U>;
401    fn animate(self, spec: impl Into<AnimSpec>) -> Live<T>;
402    fn spring(self, spec: SpringSpec) -> Live<T>;
403}
404
405impl<T: Clone + PartialEq + 'static> MemoLiveExt<T> for Memo<T> {
406    fn live(self) -> Live<T> {
407        Live::of_memo(self)
408    }
409
410    fn map_live<U: Clone + PartialEq + 'static>(&self, f: impl Fn(&T) -> U + 'static) -> Live<U> {
411        let m = self.clone();
412        Live::derived(move || f(&m.get()))
413    }
414
415    fn animate(self, spec: impl Into<AnimSpec>) -> Live<T> {
416        Live::of_memo(self).animate(spec)
417    }
418
419    fn spring(self, spec: SpringSpec) -> Live<T> {
420        Live::of_memo(self).spring(spec)
421    }
422}
423
424fn lerp_f32(a: f32, b: f32, t: f32) -> f32 {
425    a + (b - a) * t
426}
427
428fn lerp_color(a: Color, b: Color, t: f32) -> Color {
429    Color::rgba(
430        lerp_f32(a.r, b.r, t),
431        lerp_f32(a.g, b.g, t),
432        lerp_f32(a.b, b.b, t),
433        lerp_f32(a.a, b.a, t),
434    )
435}
436
437fn lerp_vec2(a: Vec2, b: Vec2, t: f32) -> Vec2 {
438    Vec2::new(lerp_f32(a.x, b.x, t), lerp_f32(a.y, b.y, t))
439}
440
441#[derive(Clone, Debug)]
442pub(crate) enum AnimState {
443    F32(F32State),
444    Color(ColorState),
445    Vec2(Vec2State),
446}
447
448#[derive(Clone, Debug)]
449pub(crate) struct F32State {
450    pub current: f32,
451    pub from: f32,
452    pub start: Instant,
453    pub last_target: f32,
454    pub tween: Option<AnimSpec>,
455    pub spring: Option<SpringSpec>,
456    pub velocity: f32,
457    pub last_tick: Instant,
458    pub initialized: bool,
459}
460
461#[derive(Clone, Debug)]
462pub(crate) struct ColorState {
463    pub current: Color,
464    pub from: Color,
465    pub start: Instant,
466    pub last_target: Color,
467    pub tween: Option<AnimSpec>,
468
469    pub smooth_rate: Option<f32>,
470    pub last_tick: Instant,
471    pub initialized: bool,
472}
473
474pub fn exp_blend(current: Color, target: Color, dt_secs: f32, rate: f32) -> Color {
475    let t = 1.0 - (-rate.max(0.0) * dt_secs.max(0.0)).exp();
476    Color::rgba(
477        current.r + (target.r - current.r) * t,
478        current.g + (target.g - current.g) * t,
479        current.b + (target.b - current.b) * t,
480        current.a + (target.a - current.a) * t,
481    )
482}
483
484pub const EXP_SETTLE_EPS: f32 = 0.004;
485
486pub fn exp_settled(current: Color, target: Color) -> bool {
487    (current.r - target.r).abs() < EXP_SETTLE_EPS
488        && (current.g - target.g).abs() < EXP_SETTLE_EPS
489        && (current.b - target.b).abs() < EXP_SETTLE_EPS
490        && (current.a - target.a).abs() < EXP_SETTLE_EPS
491}
492
493#[derive(Clone, Debug)]
494pub(crate) struct Vec2State {
495    pub current: Vec2,
496    pub from: Vec2,
497    pub start: Instant,
498    pub last_target: Vec2,
499    pub tween: Option<AnimSpec>,
500    pub spring: Option<SpringSpec>,
501    pub velocity: Vec2,
502    pub last_tick: Instant,
503    pub initialized: bool,
504}
505
506fn spring_step(current: f32, velocity: f32, target: f32, spec: &SpringSpec, dt: f32) -> (f32, f32) {
507    let m = spec.mass.max(0.0001);
508    let k = spec.stiffness.max(0.0);
509    let c = spec.damping.max(0.0);
510
511    let force = -k * (current - target) - c * velocity;
512    let vel = velocity + force / m * dt;
513    let pos = current + vel * dt;
514    (pos, vel)
515}
516
517impl AnimState {
518    pub(crate) fn sample_f32(
519        state: &mut F32State,
520        target: f32,
521        tween: Option<&AnimSpec>,
522        spring: Option<&SpringSpec>,
523        now: Instant,
524    ) -> (f32, bool) {
525        if !state.initialized {
526            state.current = target;
527            state.from = target;
528            state.last_target = target;
529            state.tween = tween.copied();
530            state.spring = spring.copied();
531            state.start = now;
532            state.last_tick = now;
533            state.velocity = 0.0;
534            state.initialized = true;
535            return (target, false);
536        }
537        let spec_changed = state.tween != tween.copied() || state.spring != spring.copied();
538        if target != state.last_target || spec_changed {
539            state.from = state.current;
540            state.last_target = target;
541            state.tween = tween.copied();
542            state.spring = spring.copied();
543            state.start = now;
544            state.last_tick = now;
545            if spring.is_some() {
546            } else {
547                state.velocity = 0.0;
548            }
549        }
550        if let Some(sp) = spring {
551            let dt = now
552                .duration_since(state.last_tick)
553                .as_secs_f32()
554                .clamp(0.0, MAX_FRAME_STEP_SECS);
555            state.last_tick = now;
556
557            let steps = ((dt * 120.0).ceil() as usize).max(1);
558            let h = dt / steps as f32;
559            for _ in 0..steps {
560                let (p, v) = spring_step(state.current, state.velocity, target, sp, h.max(0.0));
561                state.current = p;
562                state.velocity = v;
563            }
564            let settled = (state.current - target).abs() < 0.0005 && state.velocity.abs() < 0.0005;
565            if settled {
566                state.current = target;
567                state.velocity = 0.0;
568                return (target, false);
569            }
570            return (state.current, true);
571        }
572        if let Some(spec) = tween {
573            let elapsed = now.duration_since(state.start).as_secs_f32();
574            let v = spec.sample(state.from, target, elapsed);
575            state.current = v;
576            return (v, spec.is_running(elapsed));
577        }
578        state.current = target;
579        (target, false)
580    }
581
582    pub(crate) fn sample_color(
583        state: &mut ColorState,
584        target: Color,
585        tween: Option<&AnimSpec>,
586        smooth_rate: Option<f32>,
587        now: Instant,
588    ) -> (Color, bool) {
589        if !state.initialized {
590            state.current = target;
591            state.from = target;
592            state.last_target = target;
593            state.tween = tween.copied();
594            state.smooth_rate = smooth_rate;
595            state.start = now;
596            state.last_tick = now;
597            state.initialized = true;
598            return (target, false);
599        }
600        if target != state.last_target
601            || state.tween != tween.copied()
602            || state.smooth_rate != smooth_rate
603        {
604            state.from = state.current;
605            state.last_target = target;
606            state.tween = tween.copied();
607            state.smooth_rate = smooth_rate;
608            state.start = now;
609            state.last_tick = now;
610        }
611        if let Some(rate) = smooth_rate {
612            let dt = now
613                .duration_since(state.last_tick)
614                .as_secs_f32()
615                .clamp(0.0, MAX_FRAME_STEP_SECS);
616            state.last_tick = now;
617            if exp_settled(state.current, target) {
618                state.current = target;
619                return (target, false);
620            }
621            let next = exp_blend(state.current, target, dt, rate);
622            state.current = next;
623            if exp_settled(next, target) {
624                state.current = target;
625                return (target, false);
626            }
627            return (next, true);
628        }
629        if let Some(spec) = tween {
630            let elapsed = now.duration_since(state.start).as_secs_f32();
631            let k = if spec.duration_secs <= 0.0 || elapsed < spec.delay_secs {
632                if elapsed < spec.delay_secs { 0.0 } else { 1.0 }
633            } else {
634                spec.easing
635                    .apply(((elapsed - spec.delay_secs) / spec.duration_secs).clamp(0.0, 1.0))
636            };
637            let v = lerp_color(state.from, target, k);
638            state.current = v;
639            return (v, spec.is_running(elapsed));
640        }
641        state.current = target;
642        (target, false)
643    }
644
645    pub(crate) fn sample_vec2(
646        state: &mut Vec2State,
647        target: Vec2,
648        tween: Option<&AnimSpec>,
649        spring: Option<&SpringSpec>,
650        now: Instant,
651    ) -> (Vec2, bool) {
652        if !state.initialized {
653            state.current = target;
654            state.from = target;
655            state.last_target = target;
656            state.tween = tween.copied();
657            state.spring = spring.copied();
658            state.start = now;
659            state.last_tick = now;
660            state.velocity = Vec2::ZERO;
661            state.initialized = true;
662            return (target, false);
663        }
664        let spec_changed = state.tween != tween.copied() || state.spring != spring.copied();
665        if target != state.last_target || spec_changed {
666            state.from = state.current;
667            state.last_target = target;
668            state.tween = tween.copied();
669            state.spring = spring.copied();
670            state.start = now;
671            state.last_tick = now;
672        }
673        if let Some(sp) = spring {
674            let dt = now
675                .duration_since(state.last_tick)
676                .as_secs_f32()
677                .clamp(0.0, MAX_FRAME_STEP_SECS);
678            state.last_tick = now;
679            let steps = ((dt * 120.0).ceil() as usize).max(1);
680            let h = dt / steps as f32;
681            for _ in 0..steps {
682                let (px, vx) =
683                    spring_step(state.current.x, state.velocity.x, target.x, sp, h.max(0.0));
684                let (py, vy) =
685                    spring_step(state.current.y, state.velocity.y, target.y, sp, h.max(0.0));
686                state.current = Vec2::new(px, py);
687                state.velocity = Vec2::new(vx, vy);
688            }
689            let settled = (state.current.x - target.x).abs() < 0.0005
690                && (state.current.y - target.y).abs() < 0.0005
691                && state.velocity.x.abs() < 0.0005
692                && state.velocity.y.abs() < 0.0005;
693            if settled {
694                state.current = target;
695                state.velocity = Vec2::ZERO;
696                return (target, false);
697            }
698            return (state.current, true);
699        }
700        if let Some(spec) = tween {
701            let elapsed = now.duration_since(state.start).as_secs_f32();
702            let k = if elapsed < spec.delay_secs {
703                0.0
704            } else if spec.duration_secs <= 0.0 {
705                1.0
706            } else {
707                spec.easing
708                    .apply(((elapsed - spec.delay_secs) / spec.duration_secs).clamp(0.0, 1.0))
709            };
710            let v = lerp_vec2(state.from, target, k);
711            state.current = v;
712            return (v, spec.is_running(elapsed));
713        }
714        state.current = target;
715        (target, false)
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    #[test]
724    fn live_fixed_and_signal_targets() {
725        let a = Live::fixed(3.0f32);
726        assert_eq!(a.target_untracked(), 3.0);
727        assert!(!a.is_animated());
728        let s = Signal::new(2.0f32);
729        let b = s.clone().live().animate(AnimSpec::FAST);
730        assert!(b.is_animated());
731        assert_eq!(b.target_untracked(), 2.0);
732        s.set(5.0);
733        assert_eq!(b.target_untracked(), 5.0);
734    }
735
736    #[test]
737    fn signal_map_live_derives() {
738        let hovered = Signal::new(false);
739        let bg = hovered.map_live(|v| if *v { 20.0f32 } else { 8.0f32 });
740        assert_eq!(bg.target_untracked(), 8.0);
741        hovered.set(true);
742        assert_eq!(bg.target_untracked(), 20.0);
743    }
744
745    #[test]
746    fn tween_state_retargets_from_current() {
747        let now = Instant::now();
748        let spec = AnimSpec::FAST;
749        let mut st = F32State {
750            current: 0.0,
751            from: 0.0,
752            start: now,
753            last_target: 0.0,
754            tween: None,
755            spring: None,
756            velocity: 0.0,
757            last_tick: now,
758            initialized: false,
759        };
760
761        let (v0, r0) = AnimState::sample_f32(&mut st, 0.0, Some(&spec), None, now);
762        assert_eq!(v0, 0.0);
763        assert!(!r0, "first sample anchors without gear");
764
765        let (v1a, r1a) = AnimState::sample_f32(&mut st, 1.0, Some(&spec), None, now);
766        assert_eq!(v1a, 0.0, "retarget nie skacze w tej samej klatce");
767        assert!(r1a);
768        let later = now + Duration::from_millis(60);
769        let (v1, r1) = AnimState::sample_f32(&mut st, 1.0, Some(&spec), None, later);
770        assert!((v1 - 0.5).abs() < 0.05, "half way: {v1}");
771        assert!(r1);
772
773        let (v2, _) = AnimState::sample_f32(&mut st, 0.0, Some(&spec), None, later);
774        assert!((v2 - v1).abs() < 1e-5, "retarget nie skacze: {v2} vs {v1}");
775    }
776
777    #[test]
778    fn exp_color_matches_button_blend_math() {
779        use sequoia_core::Color;
780
781        let step = exp_blend(Color::BLACK, Color::WHITE, 1.0 / 60.0, 12.0);
782        assert!(step.r > 0.1 && step.r < 1.0, "pierwsza klatka: {}", step.r);
783        assert!(!exp_settled(Color::BLACK, Color::WHITE));
784        assert!(exp_settled(Color::WHITE, Color::WHITE));
785
786        let now = Instant::now();
787        let mut st = ColorState {
788            current: Color::BLACK,
789            from: Color::BLACK,
790            start: now,
791            last_target: Color::BLACK,
792            tween: None,
793            smooth_rate: None,
794            last_tick: now,
795            initialized: false,
796        };
797        let (v0, r0) = AnimState::sample_color(&mut st, Color::BLACK, None, Some(12.0), now);
798        assert_eq!(v0, Color::BLACK);
799        assert!(!r0);
800        let mut t = now;
801        let mut running = true;
802        for _ in 0..240 {
803            t += Duration::from_millis(16);
804            let (_, r) = AnimState::sample_color(&mut st, Color::WHITE, None, Some(12.0), t);
805            running = r;
806            if !running {
807                break;
808            }
809        }
810        assert!(!running, "240 klatek musi dobiec");
811        assert_eq!(st.current, Color::WHITE);
812    }
813
814    #[test]
815    fn spring_state_settles() {
816        let now = Instant::now();
817        let spec = SpringSpec::DEFAULT;
818        let mut st = F32State {
819            current: 0.0,
820            from: 0.0,
821            start: now,
822            last_target: 0.0,
823            tween: None,
824            spring: None,
825            velocity: 0.0,
826            last_tick: now,
827            initialized: false,
828        };
829        let mut t = now;
830        let _ = AnimState::sample_f32(&mut st, 100.0, None, Some(&spec), t);
831        for _ in 0..600 {
832            t += Duration::from_millis(16);
833            let _ = AnimState::sample_f32(&mut st, 100.0, None, Some(&spec), t);
834        }
835        assert!(
836            (st.current - 100.0).abs() < 1.0,
837            "spring reaches: {}",
838            st.current
839        );
840    }
841}