Skip to main content

sequoia_view/
animated.rs

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