use std::time::Duration;
use frust_core::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, FrameTime,
InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, Tween, View, Widget, any,
};
use frust_theme::{MotionSpring, Theme};
use kurbo::{Affine, Point, Size};
use crate::nav::transition::{TransitionDriver, make_driver};
use crate::{Alignment, Timing};
const FALLBACK_EFFECTS_SPRING: MotionSpring = MotionSpring {
damping_ratio: 1.0,
stiffness: 1600.0,
};
const FALLBACK_SPATIAL_SPRING: MotionSpring = MotionSpring {
damping_ratio: 0.9,
stiffness: 700.0,
};
fn resolve_opacity_timing(theme: Option<&Theme>, explicit: Option<Timing>) -> Timing {
explicit.unwrap_or_else(|| {
let spring = match theme {
Some(theme) => theme.motion.default_effects,
None => FALLBACK_EFFECTS_SPRING,
};
Timing::Spring(spring)
})
}
fn resolve_scale_timing(theme: Option<&Theme>, explicit: Option<Timing>) -> Timing {
explicit.unwrap_or_else(|| {
let spring = match theme {
Some(theme) => theme.motion.default_spatial,
None => FALLBACK_SPATIAL_SPRING,
};
Timing::Spring(spring)
})
}
fn alignment_fraction(component: f64) -> f64 {
(component + 1.0) / 2.0
}
fn scale_about(pivot: Point, scale: f64) -> Affine {
Affine::translate((pivot.x, pivot.y))
* Affine::scale(scale)
* Affine::translate((-pivot.x, -pivot.y))
}
struct ImplicitAnim {
target: f64,
driving_target: f64,
tween: Tween<f64>,
driver: TransitionDriver,
}
impl ImplicitAnim {
fn new(initial: f64) -> Self {
let (driver, _) = make_driver(Timing::Duration(Duration::ZERO, frust_core::Curve::Linear));
Self {
target: initial,
driving_target: initial,
tween: Tween::new(initial, initial),
driver,
}
}
fn value(&self) -> f64 {
self.tween.lerp(self.driver.value())
}
fn set_target(&mut self, target: f64) {
self.target = target;
}
fn advance(&mut self, now: FrameTime, timing: Timing) -> bool {
if self.target != self.driving_target {
let from = self.value();
self.tween = Tween::new(from, self.target);
self.driving_target = self.target;
let (driver, _) = make_driver(timing);
self.driver = driver;
}
self.driver.advance(now).animating
}
}
pub struct AnimatedOpacityView<State: 'static> {
target: f64,
timing: Option<Timing>,
child: AnyView<State>,
}
pub fn animated_opacity<State: 'static, V: View<State>>(
target: f64,
child: V,
) -> AnimatedOpacityView<State> {
AnimatedOpacityView {
target: target.clamp(0.0, 1.0),
timing: None,
child: any(child),
}
}
#[allow(non_snake_case)]
pub fn AnimatedOpacity<State: 'static, V: View<State>>(
target: f64,
child: V,
) -> AnimatedOpacityView<State> {
animated_opacity(target, child)
}
impl<State: 'static> AnimatedOpacityView<State> {
pub fn timing(mut self, timing: Timing) -> Self {
self.timing = Some(timing);
self
}
}
pub struct AnimatedOpacityWidget {
child: ChildPod,
explicit_timing: Option<Timing>,
anim: ImplicitAnim,
}
impl<State: 'static> View<State> for AnimatedOpacityView<State> {
type Element = AnimatedOpacityWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> AnimatedOpacityWidget {
AnimatedOpacityWidget {
child: crate::authoring::build_child(&self.child, ctx),
explicit_timing: self.timing,
anim: ImplicitAnim::new(self.target),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut AnimatedOpacityWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if prev.target != self.target {
element.anim.set_target(self.target);
flags |= ChangeFlags::PAINT;
}
if prev.timing != self.timing {
element.explicit_timing = self.timing;
flags |= ChangeFlags::PAINT;
}
flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
flags
}
fn teardown(&self, element: &mut AnimatedOpacityWidget, ctx: &mut BuildCtx<'_>) {
crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
}
}
impl Widget for AnimatedOpacityWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let size = self.child.layout_child(ctx, bc);
self.child.set_origin(Point::ZERO);
size
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let timing = resolve_opacity_timing(theme, self.explicit_timing);
if self.anim.advance(ctx.frame_time(), timing) {
ctx.request_frame();
}
let alpha = self.anim.value().clamp(0.0, 1.0) as f32;
let origin = ctx.origin();
let size = ctx.size();
scene.push_layer(origin, size, alpha);
self.child.paint_child(ctx, scene);
scene.pop_layer();
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
crate::authoring::route_event_single(&mut self.child, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
self.child.semantics_child(ctx);
}
crate::authoring::visit_children!(child);
}
pub struct AnimatedScaleView<State: 'static> {
target: f64,
alignment: Alignment,
timing: Option<Timing>,
child: AnyView<State>,
}
pub fn animated_scale<State: 'static, V: View<State>>(
target: f64,
child: V,
) -> AnimatedScaleView<State> {
AnimatedScaleView {
target,
alignment: Alignment::CENTER,
timing: None,
child: any(child),
}
}
#[allow(non_snake_case)]
pub fn AnimatedScale<State: 'static, V: View<State>>(
target: f64,
child: V,
) -> AnimatedScaleView<State> {
animated_scale(target, child)
}
impl<State: 'static> AnimatedScaleView<State> {
pub fn alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
pub fn timing(mut self, timing: Timing) -> Self {
self.timing = Some(timing);
self
}
}
pub struct AnimatedScaleWidget {
child: ChildPod,
alignment: Alignment,
explicit_timing: Option<Timing>,
anim: ImplicitAnim,
}
impl<State: 'static> View<State> for AnimatedScaleView<State> {
type Element = AnimatedScaleWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> AnimatedScaleWidget {
AnimatedScaleWidget {
child: crate::authoring::build_child(&self.child, ctx),
alignment: self.alignment,
explicit_timing: self.timing,
anim: ImplicitAnim::new(self.target),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut AnimatedScaleWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if prev.target != self.target {
element.anim.set_target(self.target);
flags |= ChangeFlags::PAINT;
}
if prev.alignment != self.alignment {
element.alignment = self.alignment;
flags |= ChangeFlags::PAINT;
}
if prev.timing != self.timing {
element.explicit_timing = self.timing;
flags |= ChangeFlags::PAINT;
}
flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
flags
}
fn teardown(&self, element: &mut AnimatedScaleWidget, ctx: &mut BuildCtx<'_>) {
crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
}
}
impl Widget for AnimatedScaleWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let size = self.child.layout_child(ctx, bc);
self.child.set_origin(Point::ZERO);
size
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let timing = resolve_scale_timing(theme, self.explicit_timing);
if self.anim.advance(ctx.frame_time(), timing) {
ctx.request_frame();
}
let scale = self.anim.value();
let origin = ctx.origin();
let size = ctx.size();
let fx = alignment_fraction(self.alignment.x);
let fy = alignment_fraction(self.alignment.y);
let pivot = Point::new(origin.x + size.width * fx, origin.y + size.height * fy);
scene.push_transform(scale_about(pivot, scale));
self.child.paint_child(ctx, scene);
scene.pop_transform();
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
crate::authoring::route_event_single(&mut self.child, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
self.child.semantics_child(ctx);
}
crate::authoring::visit_children!(child);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{RecordingScene, leaf_any};
use frust_core::Curve;
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
fn build<S: 'static, V: View<S>>(view: &V) -> V::Element {
let mut counter = 0u64;
view.build(&mut BuildCtx::new(&mut counter))
}
#[test]
fn opacity_mid_animation_paint_emits_expected_interpolated_alpha() {
let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
let view: AnimatedOpacityView<()> =
animated_opacity(0.0, leaf_any(10.0, 10.0)).timing(timing);
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
w.anim.set_target(1.0);
let mut pctx = PaintCtx::new(Point::ZERO, size);
let mut scene = RecordingScene::default();
w.paint(&mut pctx, &mut scene);
assert_eq!(
scene.layers[0].2, 0.0,
"alpha must still be 0.0 on the seed frame"
);
assert!(
w.anim.advance(ft_ms(50.0), timing),
"must still be animating at the midpoint"
);
let mut pctx2 = PaintCtx::new(Point::ZERO, size);
let mut scene2 = RecordingScene::default();
w.paint(&mut pctx2, &mut scene2);
assert_eq!(scene2.layers.len(), 1, "expected one push_layer call");
let (layer_origin, layer_size, alpha) = scene2.layers[0];
assert_eq!(layer_origin, Point::ZERO);
assert_eq!(layer_size, size);
assert!(
(alpha - 0.5).abs() < 1e-4,
"expected ~0.5 alpha halfway through a linear fade, got {alpha}"
);
assert_eq!(
scene2.layer_pops, 1,
"push_layer must be popped exactly once"
);
}
#[test]
fn opacity_settles_at_target_and_stops_requesting_frames() {
let timing = Timing::Duration(Duration::from_millis(50), Curve::Linear);
let view: AnimatedOpacityView<()> =
animated_opacity(0.0, leaf_any(10.0, 10.0)).timing(timing);
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
w.anim.set_target(1.0);
let mut running = true;
let mut t = 0.0;
for _ in 0..1000 {
running = w.anim.advance(ft_ms(t), timing);
if !running {
break;
}
t += 1000.0 / 120.0;
}
assert!(
!running,
"the fade must settle within a bounded number of steps"
);
let mut pctx = PaintCtx::new(Point::ZERO, size);
let mut scene = RecordingScene::default();
w.paint(&mut pctx, &mut scene);
assert!(
!pctx.needs_frame(),
"a settled animation must not request a frame"
);
let (_, _, alpha) = scene.layers[0];
assert!((alpha - 1.0).abs() < 1e-6);
}
#[test]
fn scale_emits_push_transform_and_pop_transform_bracketing_the_child() {
let view: AnimatedScaleView<()> = animated_scale(1.0, leaf_any(20.0, 20.0))
.timing(Timing::Duration(Duration::from_millis(50), Curve::Linear));
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
let mut pctx = PaintCtx::new(Point::ZERO, size);
let mut scene = RecordingScene::default();
w.paint(&mut pctx, &mut scene);
assert_eq!(
scene.transforms.len(),
1,
"expected one push_transform call"
);
assert_eq!(
scene.transform_pops, 1,
"push_transform must be popped exactly once"
);
assert_eq!(
scene.rects.len(),
1,
"the child must paint (its fill_rect) between push/pop"
);
}
#[test]
fn scale_target_change_mid_flight_retargets_without_a_value_jump() {
let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
let view: AnimatedScaleView<()> = animated_scale(1.0, leaf_any(20.0, 20.0)).timing(timing);
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
w.anim.set_target(2.0);
assert!(w.anim.advance(ft_ms(0.0), timing));
assert!(w.anim.advance(ft_ms(50.0), timing));
let value_before_retarget = w.anim.value();
assert!(
(value_before_retarget - 1.5).abs() < 1e-6,
"expected 1.5 halfway through 1.0 -> 2.0, got {value_before_retarget}"
);
w.anim.set_target(0.5);
assert!(w.anim.advance(ft_ms(50.0), timing));
let value_after_retarget = w.anim.value();
assert!(
(value_before_retarget - value_after_retarget).abs() < 1e-9,
"a retarget must not jump the value: before {value_before_retarget}, after {value_after_retarget}"
);
let mut pctx = PaintCtx::new(Point::ZERO, size);
let mut scene = RecordingScene::default();
w.paint(&mut pctx, &mut scene);
let painted = scene.transforms[0] * Point::new(1.0, 0.0);
let pivot_x = size.width / 2.0;
let expected_x = pivot_x + (1.0 - pivot_x) * value_after_retarget;
assert!(
(painted.x - expected_x).abs() < 1e-6,
"the painted transform must reflect the just-verified continuous value"
);
}
#[test]
fn scale_default_timing_resolves_unthemed_fallback_and_animates() {
let view: AnimatedScaleView<()> = animated_scale(1.0, leaf_any(10.0, 10.0));
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
w.anim.set_target(2.0);
let mut pctx = PaintCtx::new(Point::ZERO, size);
let mut scene = RecordingScene::default();
w.paint(&mut pctx, &mut scene);
assert_eq!(scene.transforms.len(), 1);
assert!(
pctx.needs_frame(),
"a freshly retargeted spring must request another frame"
);
}
#[test]
fn rebuild_forwards_a_target_change_into_the_retained_anim() {
let view: AnimatedOpacityView<()> = animated_opacity(0.0, leaf_any(10.0, 10.0));
let mut w = build(&view);
let retarget: AnimatedOpacityView<()> = animated_opacity(1.0, leaf_any(10.0, 10.0));
let mut counter = 0u64;
let flags = retarget.rebuild(&view, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(flags, ChangeFlags::PAINT);
assert_eq!(w.anim.target, 1.0, "rebuild must forward the new target");
}
}