Skip to main content

sequoia_view/
animated.rs

1use std::cell::{Cell, RefCell};
2use std::rc::Rc;
3use std::time::{Duration, Instant};
4
5use sequoia_animation::{Animator, Easing, Spring};
6use sequoia_core::{Length, Vec2};
7use sequoia_layout::LayoutStyle;
8
9use crate::any::AnyView;
10use crate::runtime::{ElementId, Runtime};
11use crate::view::{PaintCtx, View};
12
13pub const MAX_ANIM_DT: f32 = 1.0 / 20.0;
14
15pub fn fade_sample(from: f32, delay: f32, duration: f32, easing: Easing, elapsed: f32) -> f32 {
16    let from = from.clamp(0.0, 1.0);
17    let delay = delay.max(0.0);
18    if elapsed < delay {
19        return from;
20    }
21    if duration <= 0.0 {
22        return 1.0;
23    }
24    let t = (elapsed - delay) / duration;
25    if t <= 0.0 {
26        return from;
27    }
28    if t >= 1.0 {
29        return 1.0;
30    }
31    from + (1.0 - from) * easing.apply(t)
32}
33
34pub fn slide_progress(delay: f32, duration: f32, easing: Easing, elapsed: f32) -> f32 {
35    let delay = delay.max(0.0);
36    if elapsed < delay {
37        return 0.0;
38    }
39    if duration <= 0.0 {
40        return 1.0;
41    }
42    let t = (elapsed - delay) / duration;
43    if t <= 0.0 {
44        return 0.0;
45    }
46    if t >= 1.0 {
47        return 1.0;
48    }
49    easing.apply(t)
50}
51
52fn wall_elapsed(clock: &Rc<RefCell<Option<Instant>>>) -> f32 {
53    let now = Instant::now();
54    let mut slot = clock.borrow_mut();
55    let start = *slot.get_or_insert(now);
56    now.duration_since(start).as_secs_f32().min(600.0)
57}
58
59#[derive(Clone)]
60pub struct Opacity {
61    child: AnyView,
62    opacity: f32,
63}
64
65impl Opacity {
66    pub fn new<V: View + 'static>(child: V, opacity: f32) -> Self {
67        Self {
68            child: AnyView::new(child),
69            opacity,
70        }
71    }
72
73    pub fn opacity(&self) -> f32 {
74        self.opacity.clamp(0.0, 1.0)
75    }
76}
77
78impl View for Opacity {
79    fn body(&self) -> impl View + 'static {
80        crate::views::Unit
81    }
82    fn children_override(&self) -> Option<Vec<AnyView>> {
83        Some(vec![self.child.clone()])
84    }
85    fn layout_style(&self) -> LayoutStyle {
86        LayoutStyle {
87            width: Length::Fit,
88            height: Length::Fit,
89            ..LayoutStyle::default()
90        }
91    }
92    fn paint_opacity(&self) -> Option<f32> {
93        Some(self.opacity())
94    }
95}
96
97#[derive(Clone)]
98pub struct Translate {
99    child: AnyView,
100    offset: Vec2,
101}
102
103impl Translate {
104    pub fn new<V: View + 'static>(child: V, dx: f32, dy: f32) -> Self {
105        Self {
106            child: AnyView::new(child),
107            offset: Vec2::new(dx, dy),
108        }
109    }
110
111    pub fn offset(&self) -> Vec2 {
112        self.offset
113    }
114}
115
116impl View for Translate {
117    fn body(&self) -> impl View + 'static {
118        crate::views::Unit
119    }
120    fn children_override(&self) -> Option<Vec<AnyView>> {
121        Some(vec![self.child.clone()])
122    }
123    fn layout_style(&self) -> LayoutStyle {
124        LayoutStyle {
125            width: Length::Fit,
126            height: Length::Fit,
127            ..LayoutStyle::default()
128        }
129    }
130    fn paint_offset(&self) -> Vec2 {
131        self.offset
132    }
133}
134
135pub fn pop_sample(
136    from: Vec2,
137    fade_from: f32,
138    spring: Spring,
139    delay: f32,
140    elapsed: f32,
141) -> (f32, Vec2) {
142    let fade_from = fade_from.clamp(0.0, 1.0);
143    let delay = delay.max(0.0);
144    if elapsed <= delay {
145        return (fade_from, from);
146    }
147    let p = spring.value(elapsed - delay);
148    let opacity = (fade_from + (1.0 - fade_from) * p).clamp(0.0, 1.0);
149    let offset = Vec2::new(from.x * (1.0 - p), from.y * (1.0 - p));
150    (opacity, offset)
151}
152
153pub fn pulse_sample(min_opacity: f32, period: f32, elapsed: f32) -> f32 {
154    let min = min_opacity.clamp(0.0, 1.0);
155    let period = period.max(1e-3);
156    let phase = (elapsed.max(0.0) / period) % 1.0;
157
158    let wave = 0.5 - 0.5 * (phase * std::f32::consts::TAU).cos();
159    min + (1.0 - min) * wave
160}
161
162#[derive(Clone)]
163pub struct Pulse {
164    child: AnyView,
165    min_opacity: f32,
166    period: f32,
167    clock: Rc<RefCell<Option<Instant>>>,
168}
169
170impl Pulse {
171    pub fn new<V: View + 'static>(child: V) -> Self {
172        Self {
173            child: AnyView::new(child),
174            min_opacity: 0.35,
175            period: 1.2,
176            clock: Rc::new(RefCell::new(None)),
177        }
178    }
179
180    pub fn min_opacity(mut self, opacity: f32) -> Self {
181        self.min_opacity = opacity.clamp(0.0, 1.0);
182        self
183    }
184
185    pub fn period_duration(mut self, d: Duration) -> Self {
186        self.period = d.as_secs_f32().max(1e-3);
187        self
188    }
189
190    pub fn sample_at(&self, elapsed_secs: f32) -> f32 {
191        pulse_sample(self.min_opacity, self.period, elapsed_secs)
192    }
193
194    fn current(&self) -> f32 {
195        self.sample_at(wall_elapsed(&self.clock))
196    }
197
198    fn current_ctx(&self, rt: &Runtime) -> f32 {
199        if rt.reduced_motion() {
200            1.0
201        } else {
202            self.sample_at(rt.elapsed_secs())
203        }
204    }
205}
206
207impl View for Pulse {
208    fn body(&self) -> impl View + 'static {
209        crate::views::Unit
210    }
211    fn children_override(&self) -> Option<Vec<AnyView>> {
212        Some(vec![self.child.clone()])
213    }
214    fn layout_style(&self) -> LayoutStyle {
215        LayoutStyle {
216            width: Length::Fit,
217            height: Length::Fit,
218            ..LayoutStyle::default()
219        }
220    }
221    fn paint_opacity(&self) -> Option<f32> {
222        Some(self.current())
223    }
224    fn paint_opacity_ctx(&self, rt: &Runtime, _el: ElementId) -> Option<f32> {
225        Some(self.current_ctx(rt))
226    }
227    fn paint_self(&self, ctx: &mut PaintCtx) {
228        if !ctx.runtime().reduced_motion() {
229            ctx.request_frame();
230        }
231    }
232}
233
234pub fn shake_sample(amplitude: f32, period: f32, duration: f32, delay: f32, elapsed: f32) -> Vec2 {
235    let delay = delay.max(0.0);
236    if duration <= 0.0 || elapsed <= delay {
237        return Vec2::ZERO;
238    }
239    let t = elapsed - delay;
240    if t >= duration {
241        return Vec2::ZERO;
242    }
243    let decay = 1.0 - t / duration;
244    let period = period.max(1e-3);
245    let x = (t / period * std::f32::consts::TAU).sin() * amplitude * decay;
246    Vec2::new(x, 0.0)
247}
248
249#[derive(Clone)]
250pub struct Shake {
251    child: AnyView,
252    amplitude: f32,
253    period: f32,
254    duration: f32,
255    delay: f32,
256    clock: Rc<RefCell<Option<Instant>>>,
257}
258
259impl Shake {
260    pub fn new<V: View + 'static>(child: V) -> Self {
261        Self {
262            child: AnyView::new(child),
263            amplitude: 8.0,
264            period: 0.08,
265            duration: 0.36,
266            delay: 0.0,
267            clock: Rc::new(RefCell::new(None)),
268        }
269    }
270
271    pub fn amplitude(mut self, px: f32) -> Self {
272        self.amplitude = px;
273        self
274    }
275
276    pub fn period_duration(mut self, d: Duration) -> Self {
277        self.period = d.as_secs_f32().max(1e-3);
278        self
279    }
280
281    pub fn total_duration(mut self, d: Duration) -> Self {
282        self.duration = d.as_secs_f32().max(0.0);
283        self
284    }
285
286    pub fn delay_duration(mut self, d: Duration) -> Self {
287        self.delay = d.as_secs_f32().max(0.0);
288        self
289    }
290
291    pub fn sample_at(&self, elapsed_secs: f32) -> Vec2 {
292        shake_sample(
293            self.amplitude,
294            self.period,
295            self.duration,
296            self.delay,
297            elapsed_secs,
298        )
299    }
300
301    fn current(&self) -> (Vec2, bool) {
302        let elapsed = wall_elapsed(&self.clock);
303        let offset = self.sample_at(elapsed);
304        let running = elapsed < self.delay + self.duration;
305        (offset, running)
306    }
307
308    fn current_ctx(&self, rt: &Runtime) -> (Vec2, bool) {
309        if rt.reduced_motion() {
310            return (Vec2::ZERO, false);
311        }
312        let elapsed = rt.elapsed_secs();
313        (
314            self.sample_at(elapsed),
315            elapsed < self.delay + self.duration,
316        )
317    }
318}
319
320impl View for Shake {
321    fn body(&self) -> impl View + 'static {
322        crate::views::Unit
323    }
324    fn children_override(&self) -> Option<Vec<AnyView>> {
325        Some(vec![self.child.clone()])
326    }
327    fn layout_style(&self) -> LayoutStyle {
328        LayoutStyle {
329            width: Length::Fit,
330            height: Length::Fit,
331            ..LayoutStyle::default()
332        }
333    }
334    fn paint_offset(&self) -> Vec2 {
335        self.current().0
336    }
337    fn paint_offset_ctx(&self, rt: &Runtime, _el: ElementId) -> Vec2 {
338        self.current_ctx(rt).0
339    }
340    fn paint_self(&self, ctx: &mut PaintCtx) {
341        if self.current_ctx(ctx.runtime()).1 {
342            ctx.request_frame();
343        }
344    }
345}
346
347const TICK_MEMO_WINDOW: Duration = Duration::from_millis(1);
348
349#[derive(Clone)]
350pub struct Animated {
351    child: AnyView,
352    animator: Animator,
353    clock: Rc<RefCell<Option<Instant>>>,
354    memo: Rc<RefCell<(Option<Instant>, bool)>>,
355    ctx_frame: Rc<Cell<Option<u64>>>,
356    ctx_started: Rc<Cell<bool>>,
357    ctx_running: Rc<Cell<bool>>,
358}
359
360impl Animated {
361    pub fn new<V: View + 'static>(child: V, animator: Animator) -> Self {
362        Self {
363            child: AnyView::new(child),
364            animator,
365            clock: Rc::new(RefCell::new(None)),
366            memo: Rc::new(RefCell::new((None, false))),
367            ctx_frame: Rc::new(Cell::new(None)),
368            ctx_started: Rc::new(Cell::new(false)),
369            ctx_running: Rc::new(Cell::new(false)),
370        }
371    }
372
373    pub fn animator(&self) -> Animator {
374        self.animator.clone()
375    }
376
377    fn tick(&self) -> bool {
378        let now = Instant::now();
379        {
380            let memo = self.memo.borrow();
381            if let (Some(last), running) = (memo.0, memo.1)
382                && now.saturating_duration_since(last) < TICK_MEMO_WINDOW
383            {
384                return running;
385            }
386        }
387        let dt = {
388            let mut slot = self.clock.borrow_mut();
389            let dt = slot
390                .map(|prev| (now - prev).as_secs_f32())
391                .unwrap_or(0.0)
392                .min(MAX_ANIM_DT);
393            *slot = Some(now);
394            dt
395        };
396        let running = self.animator.clone().advance(f64::from(dt));
397        *self.memo.borrow_mut() = (Some(now), running);
398        running
399    }
400
401    fn tick_ctx(&self, rt: &Runtime) -> bool {
402        let frame = rt.clock().frame();
403        if self.ctx_frame.get() == Some(frame) {
404            return self.ctx_running.get();
405        }
406        let dt = if self.ctx_started.replace(true) {
407            rt.frame_dt_secs()
408        } else {
409            0.0
410        };
411        let running = if rt.reduced_motion() {
412            self.animator.clone().finish()
413        } else {
414            self.animator.clone().advance(f64::from(dt))
415        };
416        self.ctx_frame.set(Some(frame));
417        self.ctx_running.set(running);
418        running
419    }
420}
421
422impl View for Animated {
423    fn body(&self) -> impl View + 'static {
424        crate::views::Unit
425    }
426    fn children_override(&self) -> Option<Vec<AnyView>> {
427        Some(vec![self.child.clone()])
428    }
429    fn layout_style(&self) -> LayoutStyle {
430        LayoutStyle {
431            width: Length::Fit,
432            height: Length::Fit,
433            ..LayoutStyle::default()
434        }
435    }
436    fn paint_opacity(&self) -> Option<f32> {
437        self.tick();
438        self.animator.opacity()
439    }
440    fn paint_offset(&self) -> Vec2 {
441        self.tick();
442        let p = self.animator.offset();
443        Vec2::new(p.x, p.y)
444    }
445    fn paint_opacity_ctx(&self, rt: &Runtime, _el: ElementId) -> Option<f32> {
446        self.tick_ctx(rt);
447        self.animator.opacity()
448    }
449    fn paint_offset_ctx(&self, rt: &Runtime, _el: ElementId) -> Vec2 {
450        self.tick_ctx(rt);
451        let p = self.animator.offset();
452        Vec2::new(p.x, p.y)
453    }
454    fn paint_self(&self, ctx: &mut PaintCtx) {
455        if self.tick_ctx(ctx.runtime()) {
456            ctx.request_frame();
457        }
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    fn default_fade_at(elapsed: f32) -> f32 {
466        fade_sample(0.0, 0.0, 0.25, Easing::EaseOutCubic, elapsed)
467    }
468
469    #[test]
470    fn fade_sample_walks_from_to_one() {
471        assert_eq!(default_fade_at(-1.0), 0.0);
472        assert_eq!(default_fade_at(0.0), 0.0);
473        let mid = default_fade_at(0.125);
474        assert!(mid > 0.0 && mid < 1.0, "entrance center: {mid}");
475        assert_eq!(default_fade_at(10.0), 1.0);
476    }
477
478    #[test]
479    fn fade_sample_honors_from_delay_and_zero_duration() {
480        let at = |elapsed: f32| fade_sample(0.4, 0.5, 0.0, Easing::EaseOutCubic, elapsed);
481        assert_eq!(at(0.0), 0.4, "przed delayem trzyma `from`");
482        assert_eq!(at(10.0), 1.0, "zero time = immediately full");
483    }
484
485    fn default_slide_at(elapsed: f32) -> Vec2 {
486        let from = Vec2::new(0.0, 24.0);
487        let p = slide_progress(0.0, 0.3, Easing::EaseOutCubic, elapsed);
488        Vec2::new(from.x * (1.0 - p), from.y * (1.0 - p))
489    }
490
491    #[test]
492    fn slide_sample_returns_to_zero() {
493        let start = default_slide_at(0.0);
494        assert!((start.y - 24.0).abs() < 1e-4, "start: {start:?}");
495        let mid = default_slide_at(0.15);
496        assert!(mid.y > 0.0 && mid.y < 24.0, "middle: {mid:?}");
497        assert_eq!(default_slide_at(10.0), Vec2::ZERO);
498    }
499
500    #[test]
501    fn wrappers_report_paint_hooks() {
502        let o = Opacity::new(crate::views::Unit, 0.5);
503        assert_eq!(o.paint_opacity(), Some(0.5));
504        let o = Opacity::new(crate::views::Unit, 42.0);
505        assert_eq!(o.paint_opacity(), Some(1.0), "clamp do 1");
506
507        let t = Translate::new(crate::views::Unit, 3.0, -4.0);
508        assert_eq!(t.paint_offset(), Vec2::new(3.0, -4.0));
509    }
510
511    #[test]
512    fn html_easing_curves_drive_fade_and_slide() {
513        for ease in [
514            Easing::CubicBezier(0.25, 0.1, 0.25, 1.0),
515            Easing::CubicBezier(0.3, 0.7, 0.3, 1.0),
516            Easing::CubicBezier(0.3, 1.3, 0.4, 1.0),
517            Easing::CubicBezier(0.3, 1.4, 0.4, 1.0),
518            Easing::CubicBezier(0.3, 1.2, 0.4, 1.0),
519            Easing::CubicBezier(0.4, 0.0, 0.2, 1.0),
520        ] {
521            let fade_at = |elapsed: f32| fade_sample(0.0, 0.0, 0.4, ease, elapsed);
522            assert_eq!(fade_at(0.0), 0.0);
523            assert_eq!(fade_at(10.0), 1.0);
524            let slide_at = |elapsed: f32| {
525                let p = slide_progress(0.0, 0.4, ease, elapsed);
526                Vec2::new(0.0, 10.0 * (1.0 - p))
527            };
528            assert!((slide_at(0.0).y - 10.0).abs() < 1e-4);
529            assert_eq!(slide_at(10.0), Vec2::ZERO);
530        }
531    }
532
533    #[test]
534    fn pop_sample_starts_at_from_and_overshoots_zero() {
535        let spring = Spring::new(0.4, 4.0);
536        let (op0, off0) = pop_sample(Vec2::new(0.0, 24.0), 0.0, spring, 0.0, 0.0);
537        assert_eq!(op0, 0.0);
538        assert_eq!(off0, Vec2::new(0.0, 24.0));
539
540        let mut min = f32::INFINITY;
541        let mut t = 0.0;
542        while t < 2.0 {
543            min = min.min(pop_sample(Vec2::new(0.0, 24.0), 0.0, spring, 0.0, t).1.y);
544            t += 0.005;
545        }
546        assert!(min < -0.5, "under-damped pop should overshoot: min={min}");
547    }
548
549    #[test]
550    fn pop_sample_reports_opacity_and_offset() {
551        let at = |elapsed: f32| {
552            pop_sample(
553                Vec2::new(0.0, 20.0),
554                0.2,
555                Spring::new(0.6, 5.0),
556                0.0,
557                elapsed,
558            )
559        };
560        assert_eq!(at(0.0).0, 0.2);
561        assert_eq!(at(0.0).1, Vec2::new(0.0, 20.0));
562
563        assert_eq!(at(10.0).0, 1.0);
564        assert_eq!(at(10.0).1, Vec2::ZERO);
565    }
566
567    #[test]
568    fn pulse_breathes_between_min_and_one() {
569        assert_eq!(pulse_sample(0.3, 1.0, 0.0), 0.3, "starts at min");
570        assert_eq!(pulse_sample(0.3, 1.0, 0.5), 1.0, "peak at half period");
571        assert_eq!(pulse_sample(0.3, 1.0, 1.0), 0.3, "wraps");
572        let p = Pulse::new(crate::views::Unit)
573            .min_opacity(0.2)
574            .period_duration(Duration::from_secs_f32(0.8));
575        assert!((p.sample_at(0.0) - 0.2).abs() < 1e-6);
576        let mut max = 0.0f32;
577        for i in 0..=100 {
578            max = max.max(p.sample_at(i as f32 * 0.008));
579        }
580        assert!((max - 1.0).abs() < 1e-3, "peak reaches 1.0: {max}");
581        assert_eq!(p.paint_opacity(), Some(0.2), "deterministic first sample");
582    }
583
584    #[test]
585    fn shake_decays_to_zero() {
586        assert_eq!(shake_sample(8.0, 0.1, 0.4, 0.0, 0.0), Vec2::ZERO);
587        let mut peak = 0.0f32;
588        let mut t = 0.0;
589        while t < 0.4 {
590            peak = peak.max(shake_sample(8.0, 0.1, 0.4, 0.0, t).x.abs());
591            t += 0.002;
592        }
593        assert!(peak > 3.0 && peak <= 8.0, "limits the amplitude: {peak}");
594        assert_eq!(shake_sample(8.0, 0.1, 0.4, 0.0, 0.4), Vec2::ZERO, "stops");
595        assert_eq!(
596            shake_sample(8.0, 0.1, 0.4, 0.0, 10.0),
597            Vec2::ZERO,
598            "stays put"
599        );
600        let s = Shake::new(crate::views::Unit).amplitude(4.0);
601        assert_eq!(s.sample_at(0.0), Vec2::ZERO);
602    }
603
604    #[test]
605    fn animated_applies_a_hosted_animation() {
606        use sequoia_animation::{Animation, Animator, Keyframe, Track, Value};
607        let clip = Animation::new("enter", 0.4)
608            .with_track(
609                "x",
610                Track::try_new(vec![
611                    Keyframe::new(0.0, Value::px(10.0)),
612                    Keyframe::new(0.4, Value::px(50.0)),
613                ])
614                .unwrap(),
615            )
616            .with_track(
617                "opacity",
618                Track::try_new(vec![
619                    Keyframe::new(0.0, Value::f32(0.5)),
620                    Keyframe::new(0.4, Value::f32(1.0)),
621                ])
622                .unwrap(),
623            );
624        let player = Animator::new().with_clip(clip);
625        let view = Animated::new(crate::views::Unit, player);
626
627        let opacity = view.paint_opacity().expect("clip has an opacity track");
628        assert!((0.5..0.55).contains(&opacity), "opacity: {opacity}");
629        let offset = view.paint_offset();
630        assert!((10.0..12.0).contains(&offset.x), "offset: {offset:?}");
631        assert_eq!(offset.y, 0.0);
632        assert!(view.animator().is_playing());
633    }
634
635    #[test]
636    fn animated_coalesces_same_frame_ticks() {
637        use sequoia_animation::{Animation, Animator, Keyframe, Track, Value};
638        let clip = Animation::new("enter", 0.4).with_track(
639            "opacity",
640            Track::try_new(vec![
641                Keyframe::new(0.0, Value::f32(0.5)),
642                Keyframe::new(0.4, Value::f32(1.0)),
643            ])
644            .unwrap(),
645        );
646        let player = Animator::new().with_clip(clip);
647        let view = Animated::new(crate::views::Unit, player);
648        let _ = view.paint_opacity();
649        let e1 = view.animator().elapsed();
650
651        let _ = view.paint_offset();
652        let e2 = view.animator().elapsed();
653        assert_eq!(e1, e2, "tick w tej samej klatce nie kroczy: {e1} vs {e2}");
654    }
655}