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}