1use std::time::Duration;
56
57use frust_core::{
58 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, FrameTime,
59 InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, Tween, View, Widget, any,
60};
61use frust_theme::{MotionSpring, Theme};
62use kurbo::{Affine, Point, Size};
63
64use crate::nav::transition::{TransitionDriver, make_driver};
65use crate::repaint_boundary::RetainedBoundary;
66use crate::{Alignment, Timing};
67
68const FALLBACK_EFFECTS_SPRING: MotionSpring = MotionSpring {
77 damping_ratio: 1.0,
78 stiffness: 1600.0,
79};
80
81const FALLBACK_SPATIAL_SPRING: MotionSpring = MotionSpring {
86 damping_ratio: 0.9,
87 stiffness: 700.0,
88};
89
90fn resolve_opacity_timing(theme: Option<&Theme>, explicit: Option<Timing>) -> Timing {
96 explicit.unwrap_or_else(|| {
97 let spring = match theme {
98 Some(theme) => theme.motion.default_effects,
99 None => FALLBACK_EFFECTS_SPRING,
100 };
101 Timing::Spring(spring)
102 })
103}
104
105fn resolve_scale_timing(theme: Option<&Theme>, explicit: Option<Timing>) -> Timing {
112 explicit.unwrap_or_else(|| {
113 let spring = match theme {
114 Some(theme) => theme.motion.default_spatial,
115 None => FALLBACK_SPATIAL_SPRING,
116 };
117 Timing::Spring(spring)
118 })
119}
120
121fn alignment_fraction(component: f64) -> f64 {
126 (component + 1.0) / 2.0
127}
128
129fn scale_about(pivot: Point, scale: f64) -> Affine {
134 Affine::translate((pivot.x, pivot.y))
135 * Affine::scale(scale)
136 * Affine::translate((-pivot.x, -pivot.y))
137}
138
139struct ImplicitAnim {
145 target: f64,
150 driving_target: f64,
154 tween: Tween<f64>,
158 driver: TransitionDriver,
161}
162
163impl ImplicitAnim {
164 fn new(initial: f64) -> Self {
168 let (driver, _) = make_driver(Timing::Duration(Duration::ZERO, frust_core::Curve::Linear));
173 Self {
174 target: initial,
175 driving_target: initial,
176 tween: Tween::new(initial, initial),
177 driver,
178 }
179 }
180
181 fn value(&self) -> f64 {
183 self.tween.lerp(self.driver.value())
184 }
185
186 fn set_target(&mut self, target: f64) {
191 self.target = target;
192 }
193
194 fn advance(&mut self, now: FrameTime, timing: Timing) -> bool {
200 if self.target != self.driving_target {
201 let from = self.value();
202 self.tween = Tween::new(from, self.target);
203 self.driving_target = self.target;
204 let (driver, _) = make_driver(timing);
205 self.driver = driver;
206 }
207 self.driver.advance(now).animating
208 }
209}
210
211pub struct AnimatedOpacityView<State: 'static> {
215 target: f64,
216 timing: Option<Timing>,
217 cached: bool,
218 child: AnyView<State>,
219}
220
221pub fn animated_opacity<State: 'static, V: View<State>>(
226 target: f64,
227 child: V,
228) -> AnimatedOpacityView<State> {
229 AnimatedOpacityView {
230 target: target.clamp(0.0, 1.0),
231 timing: None,
232 cached: false,
233 child: any(child),
234 }
235}
236
237#[allow(non_snake_case)]
240pub fn AnimatedOpacity<State: 'static, V: View<State>>(
241 target: f64,
242 child: V,
243) -> AnimatedOpacityView<State> {
244 animated_opacity(target, child)
245}
246
247impl<State: 'static> AnimatedOpacityView<State> {
248 pub fn timing(mut self, timing: Timing) -> Self {
250 self.timing = Some(timing);
251 self
252 }
253
254 pub fn cached(mut self, cached: bool) -> Self {
261 self.cached = cached;
262 self
263 }
264}
265
266pub struct AnimatedOpacityWidget {
272 child: ChildPod,
273 explicit_timing: Option<Timing>,
274 anim: ImplicitAnim,
275 boundary: Option<RetainedBoundary>,
278}
279
280impl<State: 'static> View<State> for AnimatedOpacityView<State> {
281 type Element = AnimatedOpacityWidget;
282
283 fn build(&self, ctx: &mut BuildCtx<'_>) -> AnimatedOpacityWidget {
284 AnimatedOpacityWidget {
285 child: crate::authoring::build_child(&self.child, ctx),
286 explicit_timing: self.timing,
287 anim: ImplicitAnim::new(self.target),
288 boundary: self.cached.then(RetainedBoundary::new),
289 }
290 }
291
292 fn rebuild(
293 &self,
294 prev: &Self,
295 element: &mut AnimatedOpacityWidget,
296 ctx: &mut BuildCtx<'_>,
297 ) -> ChangeFlags {
298 let mut flags = ChangeFlags::NONE;
299 if prev.target != self.target {
300 element.anim.set_target(self.target);
301 flags |= ChangeFlags::PAINT;
302 }
303 if prev.timing != self.timing {
304 element.explicit_timing = self.timing;
305 flags |= ChangeFlags::PAINT;
306 }
307 if element.boundary.is_some() != self.cached {
308 element.boundary = self.cached.then(RetainedBoundary::new);
309 flags |= ChangeFlags::PAINT;
310 }
311 flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
312 flags
313 }
314
315 fn teardown(&self, element: &mut AnimatedOpacityWidget, ctx: &mut BuildCtx<'_>) {
316 crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
317 }
318}
319
320impl Widget for AnimatedOpacityWidget {
321 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
322 let size = self.child.layout_child(ctx, bc);
324 self.child.set_origin(Point::ZERO);
325 size
326 }
327
328 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
329 let theme = Theme::from_paint_ctx(ctx);
330 let timing = resolve_opacity_timing(theme, self.explicit_timing);
331 if self.anim.advance(ctx.frame_time(), timing) {
332 ctx.request_frame();
333 }
334 let alpha = self.anim.value().clamp(0.0, 1.0) as f32;
339 let origin = ctx.origin();
340 let size = ctx.size();
341 if let Some(boundary) = &mut self.boundary {
342 boundary.paint(&mut self.child, ctx, scene, origin, size, alpha, true);
343 return;
344 }
345 scene.push_layer(origin, size, alpha);
346 self.child.paint_child(ctx, scene);
347 scene.pop_layer();
348 }
349
350 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
351 if self.anim.value() <= 0.0
354 && matches!(
355 event,
356 InputEvent::Shortcut(_)
357 | InputEvent::Key(_)
358 | InputEvent::Ime(_)
359 | InputEvent::EditCommand(_)
360 )
361 {
362 return EventResult::Ignored;
363 }
364 crate::authoring::route_event_single(&mut self.child, ctx, event)
365 }
366
367 fn visit_focus_children(&self, visitor: &mut dyn FnMut(&ChildPod)) {
368 if self.anim.value() > 0.0 {
370 visitor(&self.child);
371 }
372 }
373
374 fn semantics(&self, ctx: &mut SemanticsCtx) {
375 self.child.semantics_child(ctx);
376 }
377
378 crate::authoring::visit_children!(child);
379}
380
381pub struct AnimatedScaleView<State: 'static> {
385 target: f64,
386 alignment: Alignment,
387 timing: Option<Timing>,
388 child: AnyView<State>,
389}
390
391pub fn animated_scale<State: 'static, V: View<State>>(
398 target: f64,
399 child: V,
400) -> AnimatedScaleView<State> {
401 AnimatedScaleView {
402 target,
403 alignment: Alignment::CENTER,
404 timing: None,
405 child: any(child),
406 }
407}
408
409#[allow(non_snake_case)]
412pub fn AnimatedScale<State: 'static, V: View<State>>(
413 target: f64,
414 child: V,
415) -> AnimatedScaleView<State> {
416 animated_scale(target, child)
417}
418
419impl<State: 'static> AnimatedScaleView<State> {
420 pub fn alignment(mut self, alignment: Alignment) -> Self {
423 self.alignment = alignment;
424 self
425 }
426
427 pub fn timing(mut self, timing: Timing) -> Self {
429 self.timing = Some(timing);
430 self
431 }
432}
433
434pub struct AnimatedScaleWidget {
436 child: ChildPod,
437 alignment: Alignment,
438 explicit_timing: Option<Timing>,
439 anim: ImplicitAnim,
440}
441
442impl<State: 'static> View<State> for AnimatedScaleView<State> {
443 type Element = AnimatedScaleWidget;
444
445 fn build(&self, ctx: &mut BuildCtx<'_>) -> AnimatedScaleWidget {
446 AnimatedScaleWidget {
447 child: crate::authoring::build_child(&self.child, ctx),
448 alignment: self.alignment,
449 explicit_timing: self.timing,
450 anim: ImplicitAnim::new(self.target),
451 }
452 }
453
454 fn rebuild(
455 &self,
456 prev: &Self,
457 element: &mut AnimatedScaleWidget,
458 ctx: &mut BuildCtx<'_>,
459 ) -> ChangeFlags {
460 let mut flags = ChangeFlags::NONE;
461 if prev.target != self.target {
462 element.anim.set_target(self.target);
463 flags |= ChangeFlags::PAINT;
464 }
465 if prev.alignment != self.alignment {
466 element.alignment = self.alignment;
467 flags |= ChangeFlags::PAINT;
468 }
469 if prev.timing != self.timing {
470 element.explicit_timing = self.timing;
471 flags |= ChangeFlags::PAINT;
472 }
473 flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
474 flags
475 }
476
477 fn teardown(&self, element: &mut AnimatedScaleWidget, ctx: &mut BuildCtx<'_>) {
478 crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
479 }
480}
481
482impl Widget for AnimatedScaleWidget {
483 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
484 let size = self.child.layout_child(ctx, bc);
487 self.child.set_origin(Point::ZERO);
488 size
489 }
490
491 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
492 let theme = Theme::from_paint_ctx(ctx);
493 let timing = resolve_scale_timing(theme, self.explicit_timing);
494 if self.anim.advance(ctx.frame_time(), timing) {
495 ctx.request_frame();
496 }
497 let scale = self.anim.value();
501
502 let origin = ctx.origin();
503 let size = ctx.size();
504 let fx = alignment_fraction(self.alignment.x);
505 let fy = alignment_fraction(self.alignment.y);
506 let pivot = Point::new(origin.x + size.width * fx, origin.y + size.height * fy);
507
508 scene.push_transform(scale_about(pivot, scale));
509 self.child.paint_child(ctx, scene);
510 scene.pop_transform();
511 }
512
513 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
514 crate::authoring::route_event_single(&mut self.child, ctx, event)
519 }
520
521 fn semantics(&self, ctx: &mut SemanticsCtx) {
522 self.child.semantics_child(ctx);
523 }
524
525 crate::authoring::visit_children!(child);
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::test_support::{RecordingScene, leaf_any};
532 use frust_core::Curve;
533
534 fn ft_ms(ms: f64) -> FrameTime {
535 FrameTime::from_nanos((ms * 1_000_000.0) as u64)
536 }
537
538 fn build<S: 'static, V: View<S>>(view: &V) -> V::Element {
539 let mut counter = 0u64;
540 view.build(&mut BuildCtx::new(&mut counter))
541 }
542
543 #[test]
560 fn a_fully_faded_out_child_answers_no_shortcut_but_a_visible_one_does() {
561 use crate::test_support::probe;
562 let shortcut = InputEvent::Shortcut(frust_core::ShortcutId::next());
563 let fire = |target: f64| {
564 let view: AnimatedOpacityView<Vec<u32>> = animated_opacity(target, probe(7));
565 let mut w = build(&view);
566 let size = w.layout(
567 &mut LayoutCtx::new(),
568 &BoxConstraints::tight(Size::new(50.0, 50.0)),
569 );
570 let mut log: Vec<u32> = Vec::new();
571 let mut ectx = EventCtx::new(&mut log, Point::ZERO, size);
572 w.event(&mut ectx, &shortcut);
573 log
574 };
575 assert!(fire(0.0).is_empty(), "an invisible child must not answer");
576 assert_eq!(fire(1.0), vec![7], "a visible child still answers");
577 assert_eq!(fire(0.4), vec![7], "a partly faded child still answers");
578 }
579
580 #[test]
581 fn opacity_mid_animation_paint_emits_expected_interpolated_alpha() {
582 let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
583 let view: AnimatedOpacityView<()> =
584 animated_opacity(0.0, leaf_any(10.0, 10.0)).timing(timing);
585 let mut w = build(&view);
586 let mut lctx = LayoutCtx::new();
587 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
588
589 w.anim.set_target(1.0);
593
594 let mut pctx = PaintCtx::new(Point::ZERO, size);
597 let mut scene = RecordingScene::default();
598 w.paint(&mut pctx, &mut scene);
599 assert_eq!(
600 scene.layers[0].2, 0.0,
601 "alpha must still be 0.0 on the seed frame"
602 );
603
604 assert!(
606 w.anim.advance(ft_ms(50.0), timing),
607 "must still be animating at the midpoint"
608 );
609
610 let mut pctx2 = PaintCtx::new(Point::ZERO, size);
615 let mut scene2 = RecordingScene::default();
616 w.paint(&mut pctx2, &mut scene2);
617
618 assert_eq!(scene2.layers.len(), 1, "expected one push_layer call");
619 let (layer_origin, layer_size, alpha) = scene2.layers[0];
620 assert_eq!(layer_origin, Point::ZERO);
621 assert_eq!(layer_size, size);
622 assert!(
623 (alpha - 0.5).abs() < 1e-4,
624 "expected ~0.5 alpha halfway through a linear fade, got {alpha}"
625 );
626 assert_eq!(
627 scene2.layer_pops, 1,
628 "push_layer must be popped exactly once"
629 );
630 }
631
632 #[test]
633 fn opacity_settles_at_target_and_stops_requesting_frames() {
634 let timing = Timing::Duration(Duration::from_millis(50), Curve::Linear);
635 let view: AnimatedOpacityView<()> =
636 animated_opacity(0.0, leaf_any(10.0, 10.0)).timing(timing);
637 let mut w = build(&view);
638 let mut lctx = LayoutCtx::new();
639 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
640
641 w.anim.set_target(1.0);
642 let mut running = true;
644 let mut t = 0.0;
645 for _ in 0..1000 {
646 running = w.anim.advance(ft_ms(t), timing);
647 if !running {
648 break;
649 }
650 t += 1000.0 / 120.0;
651 }
652 assert!(
653 !running,
654 "the fade must settle within a bounded number of steps"
655 );
656
657 let mut pctx = PaintCtx::new(Point::ZERO, size);
658 let mut scene = RecordingScene::default();
659 w.paint(&mut pctx, &mut scene);
660
661 assert!(
662 !pctx.needs_frame(),
663 "a settled animation must not request a frame"
664 );
665 let (_, _, alpha) = scene.layers[0];
666 assert!((alpha - 1.0).abs() < 1e-6);
667 }
668
669 #[test]
672 fn scale_emits_push_transform_and_pop_transform_bracketing_the_child() {
673 let view: AnimatedScaleView<()> = animated_scale(1.0, leaf_any(20.0, 20.0))
674 .timing(Timing::Duration(Duration::from_millis(50), Curve::Linear));
675 let mut w = build(&view);
676 let mut lctx = LayoutCtx::new();
677 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
678
679 let mut pctx = PaintCtx::new(Point::ZERO, size);
680 let mut scene = RecordingScene::default();
681 w.paint(&mut pctx, &mut scene);
682
683 assert_eq!(
684 scene.transforms.len(),
685 1,
686 "expected one push_transform call"
687 );
688 assert_eq!(
689 scene.transform_pops, 1,
690 "push_transform must be popped exactly once"
691 );
692 assert_eq!(
693 scene.rects.len(),
694 1,
695 "the child must paint (its fill_rect) between push/pop"
696 );
697 }
698
699 #[test]
700 fn scale_target_change_mid_flight_retargets_without_a_value_jump() {
701 let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
706 let view: AnimatedScaleView<()> = animated_scale(1.0, leaf_any(20.0, 20.0)).timing(timing);
707 let mut w = build(&view);
708 let mut lctx = LayoutCtx::new();
709 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
710
711 w.anim.set_target(2.0);
712 assert!(w.anim.advance(ft_ms(0.0), timing));
715 assert!(w.anim.advance(ft_ms(50.0), timing));
716 let value_before_retarget = w.anim.value();
717 assert!(
718 (value_before_retarget - 1.5).abs() < 1e-6,
719 "expected 1.5 halfway through 1.0 -> 2.0, got {value_before_retarget}"
720 );
721
722 w.anim.set_target(0.5);
724 assert!(w.anim.advance(ft_ms(50.0), timing));
725 let value_after_retarget = w.anim.value();
726
727 assert!(
728 (value_before_retarget - value_after_retarget).abs() < 1e-9,
729 "a retarget must not jump the value: before {value_before_retarget}, after {value_after_retarget}"
730 );
731
732 let mut pctx = PaintCtx::new(Point::ZERO, size);
734 let mut scene = RecordingScene::default();
735 w.paint(&mut pctx, &mut scene);
736 let painted = scene.transforms[0] * Point::new(1.0, 0.0);
737 let pivot_x = size.width / 2.0;
738 let expected_x = pivot_x + (1.0 - pivot_x) * value_after_retarget;
739 assert!(
740 (painted.x - expected_x).abs() < 1e-6,
741 "the painted transform must reflect the just-verified continuous value"
742 );
743 }
744
745 #[test]
746 fn scale_default_timing_resolves_unthemed_fallback_and_animates() {
747 let view: AnimatedScaleView<()> = animated_scale(1.0, leaf_any(10.0, 10.0));
751 let mut w = build(&view);
752 let mut lctx = LayoutCtx::new();
753 let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
754
755 w.anim.set_target(2.0);
756 let mut pctx = PaintCtx::new(Point::ZERO, size);
757 let mut scene = RecordingScene::default();
758 w.paint(&mut pctx, &mut scene);
759
760 assert_eq!(scene.transforms.len(), 1);
761 assert!(
762 pctx.needs_frame(),
763 "a freshly retargeted spring must request another frame"
764 );
765 }
766
767 #[test]
768 fn rebuild_forwards_a_target_change_into_the_retained_anim() {
769 let view: AnimatedOpacityView<()> = animated_opacity(0.0, leaf_any(10.0, 10.0));
774 let mut w = build(&view);
775
776 let retarget: AnimatedOpacityView<()> = animated_opacity(1.0, leaf_any(10.0, 10.0));
777 let mut counter = 0u64;
778 let flags = retarget.rebuild(&view, &mut w, &mut BuildCtx::new(&mut counter));
779
780 assert_eq!(flags, ChangeFlags::PAINT);
781 assert_eq!(w.anim.target, 1.0, "rebuild must forward the new target");
782 }
783}