Skip to main content

frust_widgets/motion/
animated.rs

1//! Declarative single-child implicit-animation wrappers:
2//! [`AnimatedOpacity`]/[`AnimatedScale`].
3//!
4//! # Shape
5//!
6//! [`animated_opacity(target, child)`](animated_opacity)/
7//! [`animated_scale(target, child)`](animated_scale) are controlled,
8//! transparent single-child wrappers (Flutter's `AnimatedOpacity`/
9//! `AnimatedScale` analogs): on every rebuild a changed `target` retargets a
10//! retained animation, and paint composites the child under
11//! [`PaintScene::push_layer`] (opacity, `alpha`) or
12//! [`PaintScene::push_transform`] (scale, about an [`Alignment`] point) —
13//! advancing during paint and calling [`PaintCtx::request_frame`] while still
14//! in flight, the crate's shared advance-during-paint contract (see
15//! `docs/CODE_STANDARDS.md`'s Theming & Animation Conventions). Layout is a
16//! transparent pass-through: this wrapper reports exactly its child's size.
17//!
18//! # Retargeting: value continuity, not velocity continuity
19//!
20//! [`ImplicitAnim`] is the shared retarget driver both wrappers use. A
21//! target change captures the *exact currently-painted value* as the new
22//! [`Tween`]'s `begin`, then launches a fresh `0..1` progress
23//! [`crate::nav::transition::TransitionDriver`] (reusing the
24//! duration/spring [`Timing`] progress driver — the same vocabulary a page
25//! transition's progress is driven by, just mapped onto an arbitrary value
26//! range via `Tween` instead of a page's own `0..1` transition progress) via
27//! [`crate::nav::transition::make_driver`]. Because the fresh driver's
28//! progress starts at exactly `0.0` and `Tween::lerp(0.0) == begin` by
29//! construction, retargeting mid-flight is **value-continuous**: the painted
30//! value never jumps at the instant of a retarget. It is *not*
31//! velocity-continuous — a spring [`Timing::Spring`] retarget always
32//! launches its canonical from-rest shape rather than preserving the
33//! interrupted spring's exact instantaneous velocity (`AnimationController`
34//! exposes no public velocity accessor to seed a fresh `Spring` with); this
35//! is a documented simplification, not a bug, and still reads as smooth
36//! motion since the value itself never discontinues.
37//!
38//! # Hit-testing: paint-only effects, unscaled event space (v1)
39//!
40//! Both `push_layer`/`push_transform` are **paint-only** compositing
41//! primitives (`docs/CODE_STANDARDS.md`'s Compositing-primitive
42//! constraints) — a scaled or faded subtree still hit-tests at its
43//! *unscaled* layout geometry. This wrapper's `event`/layout never apply the
44//! animation, so a child under [`AnimatedScale`] mid-shrink is still hit at
45//! its full laid-out bounds — the same v1 rule huddle's `PressPop` precedent
46//! established (see `push_transform`'s own doc). A future addition may add a
47//! scaled-hit-test opt-in; this module deliberately does not.
48
49use std::time::Duration;
50
51use frust_core::{
52    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, FrameTime,
53    InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, Tween, View, Widget, any,
54};
55use frust_theme::{MotionSpring, Theme};
56use kurbo::{Affine, Point, Size};
57
58use crate::nav::transition::{TransitionDriver, make_driver};
59use crate::{Alignment, Timing};
60
61// --- Unthemed fallbacks (CODE_STANDARDS' "resolve theme tokens with an
62// unthemed-fallback constant per resolved value" rule) -----------------------
63
64/// The unthemed fallback spring for opacity fades, matching
65/// `MotionScheme::m3_expressive()`'s `default_effects` preset exactly
66/// (critically damped, stiffness 1600) — see `frust-theme`'s `motion` module
67/// docs for why "effects" springs (opacity/color) are critically damped
68/// (never overshoot).
69const FALLBACK_EFFECTS_SPRING: MotionSpring = MotionSpring {
70    damping_ratio: 1.0,
71    stiffness: 1600.0,
72};
73
74/// The unthemed fallback spring for scale, matching
75/// `MotionScheme::m3_expressive()`'s `default_spatial` preset exactly (a
76/// slight overshoot, ζ 0.9) — "spatial" springs (position/size) allow a
77/// small bounce past the target.
78const FALLBACK_SPATIAL_SPRING: MotionSpring = MotionSpring {
79    damping_ratio: 0.9,
80    stiffness: 700.0,
81};
82
83/// Resolve the effective [`Timing`] for opacity: an explicit builder value
84/// always wins; otherwise the theme's `default_effects` spring
85/// (`MotionScheme`'s "effects slot" — see `frust-theme::motion`'s module
86/// docs: "'Effects' springs (opacity/color) are critically damped"), falling
87/// back to [`FALLBACK_EFFECTS_SPRING`] when no theme is threaded.
88fn resolve_opacity_timing(theme: Option<&Theme>, explicit: Option<Timing>) -> Timing {
89    explicit.unwrap_or_else(|| {
90        let spring = match theme {
91            Some(theme) => theme.motion.default_effects,
92            None => FALLBACK_EFFECTS_SPRING,
93        };
94        Timing::Spring(spring)
95    })
96}
97
98/// Resolve the effective [`Timing`] for scale: an explicit builder value
99/// always wins; otherwise the theme's `default_spatial` spring
100/// (`MotionScheme`'s "spatial slot" — see `frust-theme::motion`'s module
101/// docs: "'spatial' springs (position/size) are `0.9`, allowing a small
102/// overshoot"), falling back to [`FALLBACK_SPATIAL_SPRING`] when no theme is
103/// threaded.
104fn resolve_scale_timing(theme: Option<&Theme>, explicit: Option<Timing>) -> Timing {
105    explicit.unwrap_or_else(|| {
106        let spring = match theme {
107            Some(theme) => theme.motion.default_spatial,
108            None => FALLBACK_SPATIAL_SPRING,
109        };
110        Timing::Spring(spring)
111    })
112}
113
114/// Map an [`Alignment`] component in `-1.0..=1.0` to a `0.0..=1.0` fraction —
115/// duplicated from `align.rs`'s private `Alignment::fraction` (same math,
116/// kept local since that helper isn't `pub`) so [`AnimatedScaleWidget`] can
117/// locate its scale pivot the same way `Align` locates a child.
118fn alignment_fraction(component: f64) -> f64 {
119    (component + 1.0) / 2.0
120}
121
122/// Build the affine that scales uniformly by `scale` about the absolute
123/// point `pivot` — a translate/scale/translate-back sandwich, the standard
124/// "scale about a point" construction (mirrors
125/// `nav::transition::rect_to_rect`'s translate-scale-translate shape).
126fn scale_about(pivot: Point, scale: f64) -> Affine {
127    Affine::translate((pivot.x, pivot.y))
128        * Affine::scale(scale)
129        * Affine::translate((-pivot.x, -pivot.y))
130}
131
132// --- Shared retarget driver --------------------------------------------------
133
134/// Shared implicit-animation driver for [`AnimatedOpacityWidget`]/
135/// [`AnimatedScaleWidget`] — see the [module docs](self)'s Retargeting
136/// section for the full value-continuity contract.
137struct ImplicitAnim {
138    /// The app-confirmed value, updated by `rebuild` (a `BuildCtx` pass has
139    /// no theme to resolve a default [`Timing`] from, so retargeting itself
140    /// is deferred to the next `paint` — mirrors `SwitchWidget`'s
141    /// `checked`/`anim_target` split).
142    target: f64,
143    /// The target the current `driver` is actually animating toward.
144    /// Compared against `target` on every [`Self::advance`] call to detect a
145    /// pending retarget.
146    driving_target: f64,
147    /// Maps the driver's `0..1` progress onto the actual value range:
148    /// `begin` is the value at the instant the current retarget started,
149    /// `end` is `driving_target`.
150    tween: Tween<f64>,
151    /// The `0..1` progress driver (the page-transition driver, reused here —
152    /// see the [module docs](self)).
153    driver: TransitionDriver,
154}
155
156impl ImplicitAnim {
157    /// A fresh driver settled at `initial` — no animate-in on first mount
158    /// (Flutter's implicit-animation convention: only a *later* value change
159    /// animates, never the initial one).
160    fn new(initial: f64) -> Self {
161        // A degenerate zero-duration Timing settles a driver immediately, but
162        // it's moot here regardless: `tween`'s begin/end are both `initial`,
163        // so `value()` reads `initial` no matter what progress the
164        // placeholder driver reports.
165        let (driver, _) = make_driver(Timing::Duration(Duration::ZERO, frust_core::Curve::Linear));
166        Self {
167            target: initial,
168            driving_target: initial,
169            tween: Tween::new(initial, initial),
170            driver,
171        }
172    }
173
174    /// The current interpolated value.
175    fn value(&self) -> f64 {
176        self.tween.lerp(self.driver.value())
177    }
178
179    /// Record a new app-confirmed target (called from `rebuild`). The actual
180    /// retarget (capturing `value()` as the new tween's `begin`, launching a
181    /// fresh driver) happens lazily in [`Self::advance`], the next time a
182    /// theme is in scope to resolve a default `timing` from.
183    fn set_target(&mut self, target: f64) {
184        self.target = target;
185    }
186
187    /// Advance one frame at `now`. If `target` disagrees with
188    /// `driving_target` (a retarget is pending), captures the current value
189    /// as the new tween's `begin` and launches a fresh driver under
190    /// `timing`. Returns whether still animating (the caller should
191    /// [`PaintCtx::request_frame`]).
192    fn advance(&mut self, now: FrameTime, timing: Timing) -> bool {
193        if self.target != self.driving_target {
194            let from = self.value();
195            self.tween = Tween::new(from, self.target);
196            self.driving_target = self.target;
197            let (driver, _) = make_driver(timing);
198            self.driver = driver;
199        }
200        self.driver.advance(now).animating
201    }
202}
203
204// --- AnimatedOpacity ---------------------------------------------------------
205
206/// A declarative opacity wrapper. See the [module docs](self).
207pub struct AnimatedOpacityView<State: 'static> {
208    target: f64,
209    timing: Option<Timing>,
210    child: AnyView<State>,
211}
212
213/// Fade `child` toward `target` opacity (clamped to `0.0..=1.0`) — see the
214/// [module docs](self). Defaults its [`Timing`] from the theme's
215/// `default_effects` spring; override with
216/// [`.timing(...)`](AnimatedOpacityView::timing).
217pub fn animated_opacity<State: 'static, V: View<State>>(
218    target: f64,
219    child: V,
220) -> AnimatedOpacityView<State> {
221    AnimatedOpacityView {
222        target: target.clamp(0.0, 1.0),
223        timing: None,
224        child: any(child),
225    }
226}
227
228/// PascalCase alias for [`animated_opacity`] (mirrors `Switch`/`switch`'s
229/// dual naming).
230#[allow(non_snake_case)]
231pub fn AnimatedOpacity<State: 'static, V: View<State>>(
232    target: f64,
233    child: V,
234) -> AnimatedOpacityView<State> {
235    animated_opacity(target, child)
236}
237
238impl<State: 'static> AnimatedOpacityView<State> {
239    /// Override the default (theme-resolved) fade timing.
240    pub fn timing(mut self, timing: Timing) -> Self {
241        self.timing = Some(timing);
242        self
243    }
244}
245
246/// The retained widget for an [`AnimatedOpacityView`].
247pub struct AnimatedOpacityWidget {
248    child: ChildPod,
249    explicit_timing: Option<Timing>,
250    anim: ImplicitAnim,
251}
252
253impl<State: 'static> View<State> for AnimatedOpacityView<State> {
254    type Element = AnimatedOpacityWidget;
255
256    fn build(&self, ctx: &mut BuildCtx<'_>) -> AnimatedOpacityWidget {
257        AnimatedOpacityWidget {
258            child: crate::authoring::build_child(&self.child, ctx),
259            explicit_timing: self.timing,
260            anim: ImplicitAnim::new(self.target),
261        }
262    }
263
264    fn rebuild(
265        &self,
266        prev: &Self,
267        element: &mut AnimatedOpacityWidget,
268        ctx: &mut BuildCtx<'_>,
269    ) -> ChangeFlags {
270        let mut flags = ChangeFlags::NONE;
271        if prev.target != self.target {
272            element.anim.set_target(self.target);
273            flags |= ChangeFlags::PAINT;
274        }
275        if prev.timing != self.timing {
276            element.explicit_timing = self.timing;
277            flags |= ChangeFlags::PAINT;
278        }
279        flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
280        flags
281    }
282
283    fn teardown(&self, element: &mut AnimatedOpacityWidget, ctx: &mut BuildCtx<'_>) {
284        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
285    }
286}
287
288impl Widget for AnimatedOpacityWidget {
289    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
290        // Transparent wrapper: exactly the child's size (module docs).
291        let size = self.child.layout_child(ctx, bc);
292        self.child.set_origin(Point::ZERO);
293        size
294    }
295
296    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
297        let theme = Theme::from_paint_ctx(ctx);
298        let timing = resolve_opacity_timing(theme, self.explicit_timing);
299        if self.anim.advance(ctx.frame_time(), timing) {
300            ctx.request_frame();
301        }
302        // Clamped: alpha is never meaningful outside [0, 1], even if a spring
303        // `Timing` would otherwise overshoot past the target (see the module
304        // docs' Retargeting section and `nav::transition`'s identical
305        // opacity-clamps/position-doesn't precedent).
306        let alpha = self.anim.value().clamp(0.0, 1.0) as f32;
307        let origin = ctx.origin();
308        let size = ctx.size();
309        scene.push_layer(origin, size, alpha);
310        self.child.paint_child(ctx, scene);
311        scene.pop_layer();
312    }
313
314    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
315        crate::authoring::route_event_single(&mut self.child, ctx, event)
316    }
317
318    fn semantics(&self, ctx: &mut SemanticsCtx) {
319        self.child.semantics_child(ctx);
320    }
321
322    crate::authoring::visit_children!(child);
323}
324
325// --- AnimatedScale ------------------------------------------------------------
326
327/// A declarative scale wrapper. See the [module docs](self).
328pub struct AnimatedScaleView<State: 'static> {
329    target: f64,
330    alignment: Alignment,
331    timing: Option<Timing>,
332    child: AnyView<State>,
333}
334
335/// Scale `child` toward `target` (a scale factor — `1.0` is unscaled; no
336/// range restriction, unlike opacity) about [`Alignment::CENTER`] by default —
337/// see the [module docs](self). Defaults its [`Timing`] from the theme's
338/// `default_spatial` spring; override with
339/// [`.timing(...)`](AnimatedScaleView::timing); override the pivot with
340/// [`.alignment(...)`](AnimatedScaleView::alignment).
341pub fn animated_scale<State: 'static, V: View<State>>(
342    target: f64,
343    child: V,
344) -> AnimatedScaleView<State> {
345    AnimatedScaleView {
346        target,
347        alignment: Alignment::CENTER,
348        timing: None,
349        child: any(child),
350    }
351}
352
353/// PascalCase alias for [`animated_scale`] (mirrors `Switch`/`switch`'s dual
354/// naming).
355#[allow(non_snake_case)]
356pub fn AnimatedScale<State: 'static, V: View<State>>(
357    target: f64,
358    child: V,
359) -> AnimatedScaleView<State> {
360    animated_scale(target, child)
361}
362
363impl<State: 'static> AnimatedScaleView<State> {
364    /// Override the pivot point the scale is applied about (default
365    /// [`Alignment::CENTER`]).
366    pub fn alignment(mut self, alignment: Alignment) -> Self {
367        self.alignment = alignment;
368        self
369    }
370
371    /// Override the default (theme-resolved) scale timing.
372    pub fn timing(mut self, timing: Timing) -> Self {
373        self.timing = Some(timing);
374        self
375    }
376}
377
378/// The retained widget for an [`AnimatedScaleView`].
379pub struct AnimatedScaleWidget {
380    child: ChildPod,
381    alignment: Alignment,
382    explicit_timing: Option<Timing>,
383    anim: ImplicitAnim,
384}
385
386impl<State: 'static> View<State> for AnimatedScaleView<State> {
387    type Element = AnimatedScaleWidget;
388
389    fn build(&self, ctx: &mut BuildCtx<'_>) -> AnimatedScaleWidget {
390        AnimatedScaleWidget {
391            child: crate::authoring::build_child(&self.child, ctx),
392            alignment: self.alignment,
393            explicit_timing: self.timing,
394            anim: ImplicitAnim::new(self.target),
395        }
396    }
397
398    fn rebuild(
399        &self,
400        prev: &Self,
401        element: &mut AnimatedScaleWidget,
402        ctx: &mut BuildCtx<'_>,
403    ) -> ChangeFlags {
404        let mut flags = ChangeFlags::NONE;
405        if prev.target != self.target {
406            element.anim.set_target(self.target);
407            flags |= ChangeFlags::PAINT;
408        }
409        if prev.alignment != self.alignment {
410            element.alignment = self.alignment;
411            flags |= ChangeFlags::PAINT;
412        }
413        if prev.timing != self.timing {
414            element.explicit_timing = self.timing;
415            flags |= ChangeFlags::PAINT;
416        }
417        flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
418        flags
419    }
420
421    fn teardown(&self, element: &mut AnimatedScaleWidget, ctx: &mut BuildCtx<'_>) {
422        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
423    }
424}
425
426impl Widget for AnimatedScaleWidget {
427    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
428        // Transparent wrapper: exactly the child's size, unaffected by the
429        // (paint-only) scale — see the module docs' hit-testing section.
430        let size = self.child.layout_child(ctx, bc);
431        self.child.set_origin(Point::ZERO);
432        size
433    }
434
435    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
436        let theme = Theme::from_paint_ctx(ctx);
437        let timing = resolve_scale_timing(theme, self.explicit_timing);
438        if self.anim.advance(ctx.frame_time(), timing) {
439            ctx.request_frame();
440        }
441        // Unclamped: a spatial spring's overshoot past the target is real,
442        // intended motion (see the module docs' Retargeting section and
443        // `AnimationController`'s own Overshoot docs).
444        let scale = self.anim.value();
445
446        let origin = ctx.origin();
447        let size = ctx.size();
448        let fx = alignment_fraction(self.alignment.x);
449        let fy = alignment_fraction(self.alignment.y);
450        let pivot = Point::new(origin.x + size.width * fx, origin.y + size.height * fy);
451
452        scene.push_transform(scale_about(pivot, scale));
453        self.child.paint_child(ctx, scene);
454        scene.pop_transform();
455    }
456
457    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
458        // Unscaled event space (v1) — see the module docs' hit-testing
459        // section: `route_event_single` hit-tests the child's laid-out
460        // (unscaled) bounds via `ChildPod::contains`, never the paint-time
461        // transform.
462        crate::authoring::route_event_single(&mut self.child, ctx, event)
463    }
464
465    fn semantics(&self, ctx: &mut SemanticsCtx) {
466        self.child.semantics_child(ctx);
467    }
468
469    crate::authoring::visit_children!(child);
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::test_support::{RecordingScene, leaf_any};
476    use frust_core::Curve;
477
478    fn ft_ms(ms: f64) -> FrameTime {
479        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
480    }
481
482    fn build<S: 'static, V: View<S>>(view: &V) -> V::Element {
483        let mut counter = 0u64;
484        view.build(&mut BuildCtx::new(&mut counter))
485    }
486
487    // Every test below advances the widget's retained `anim` field directly
488    // (a private field, reachable here since `tests` is a descendant module
489    // of `animated`) rather than through repeated `Widget::paint` calls with
490    // different `PaintCtx` frame times -- `PaintCtx::set_frame_time` is
491    // crate-private to `frust-core`, so `frust-widgets` cannot construct a
492    // `PaintCtx` at an arbitrary frame time. This mirrors
493    // `material::switch`'s own paint/animation tests exactly (see that
494    // module's `animation_progresses_toward_and_settles_at_target` test and
495    // its comment). A single `paint` call at the default `FrameTime::ZERO`
496    // (to trigger the lazy retarget, or to read back the scene after
497    // manually advancing `anim`) is still exercised in every test, so the
498    // real `Widget::paint` compositing path (`push_layer`/`push_transform`)
499    // is what's actually asserted against.
500
501    // --- AnimatedOpacity ---
502
503    #[test]
504    fn opacity_mid_animation_paint_emits_expected_interpolated_alpha() {
505        let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
506        let view: AnimatedOpacityView<()> =
507            animated_opacity(0.0, leaf_any(10.0, 10.0)).timing(timing);
508        let mut w = build(&view);
509        let mut lctx = LayoutCtx::new();
510        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
511
512        // Simulate what `rebuild` would do on a target change (mirrors
513        // `SwitchWidget`'s test precedent of poking the confirmed-value
514        // field directly rather than calling `View::rebuild`).
515        w.anim.set_target(1.0);
516
517        // First paint (frame_time == FrameTime::ZERO) starts the retarget:
518        // seeds the driver's clock at a zero delta, alpha still 0.0.
519        let mut pctx = PaintCtx::new(Point::ZERO, size);
520        let mut scene = RecordingScene::default();
521        w.paint(&mut pctx, &mut scene);
522        assert_eq!(
523            scene.layers[0].2, 0.0,
524            "alpha must still be 0.0 on the seed frame"
525        );
526
527        // Directly advance to the 50ms midpoint of the 100ms linear fade.
528        assert!(
529            w.anim.advance(ft_ms(50.0), timing),
530            "must still be animating at the midpoint"
531        );
532
533        // Re-paint at (still) FrameTime::ZERO: the retarget check is a no-op
534        // (already retargeted above), and re-advancing the driver at ZERO
535        // produces a zero -- never negative -- delta (`FrameTime::saturating_sub`),
536        // so this just repaints the already-advanced midpoint value.
537        let mut pctx2 = PaintCtx::new(Point::ZERO, size);
538        let mut scene2 = RecordingScene::default();
539        w.paint(&mut pctx2, &mut scene2);
540
541        assert_eq!(scene2.layers.len(), 1, "expected one push_layer call");
542        let (layer_origin, layer_size, alpha) = scene2.layers[0];
543        assert_eq!(layer_origin, Point::ZERO);
544        assert_eq!(layer_size, size);
545        assert!(
546            (alpha - 0.5).abs() < 1e-4,
547            "expected ~0.5 alpha halfway through a linear fade, got {alpha}"
548        );
549        assert_eq!(
550            scene2.layer_pops, 1,
551            "push_layer must be popped exactly once"
552        );
553    }
554
555    #[test]
556    fn opacity_settles_at_target_and_stops_requesting_frames() {
557        let timing = Timing::Duration(Duration::from_millis(50), Curve::Linear);
558        let view: AnimatedOpacityView<()> =
559            animated_opacity(0.0, leaf_any(10.0, 10.0)).timing(timing);
560        let mut w = build(&view);
561        let mut lctx = LayoutCtx::new();
562        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
563
564        w.anim.set_target(1.0);
565        // Well past the 50ms duration: settles at the target.
566        let mut running = true;
567        let mut t = 0.0;
568        for _ in 0..1000 {
569            running = w.anim.advance(ft_ms(t), timing);
570            if !running {
571                break;
572            }
573            t += 1000.0 / 120.0;
574        }
575        assert!(
576            !running,
577            "the fade must settle within a bounded number of steps"
578        );
579
580        let mut pctx = PaintCtx::new(Point::ZERO, size);
581        let mut scene = RecordingScene::default();
582        w.paint(&mut pctx, &mut scene);
583
584        assert!(
585            !pctx.needs_frame(),
586            "a settled animation must not request a frame"
587        );
588        let (_, _, alpha) = scene.layers[0];
589        assert!((alpha - 1.0).abs() < 1e-6);
590    }
591
592    // --- AnimatedScale ---
593
594    #[test]
595    fn scale_emits_push_transform_and_pop_transform_bracketing_the_child() {
596        let view: AnimatedScaleView<()> = animated_scale(1.0, leaf_any(20.0, 20.0))
597            .timing(Timing::Duration(Duration::from_millis(50), Curve::Linear));
598        let mut w = build(&view);
599        let mut lctx = LayoutCtx::new();
600        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
601
602        let mut pctx = PaintCtx::new(Point::ZERO, size);
603        let mut scene = RecordingScene::default();
604        w.paint(&mut pctx, &mut scene);
605
606        assert_eq!(
607            scene.transforms.len(),
608            1,
609            "expected one push_transform call"
610        );
611        assert_eq!(
612            scene.transform_pops, 1,
613            "push_transform must be popped exactly once"
614        );
615        assert_eq!(
616            scene.rects.len(),
617            1,
618            "the child must paint (its fill_rect) between push/pop"
619        );
620    }
621
622    #[test]
623    fn scale_target_change_mid_flight_retargets_without_a_value_jump() {
624        // Start at 1.0, retarget to 2.0, advance partway, read the
625        // interpolated value, then retarget again to 0.5 at the SAME
626        // instant (zero dt) -- the freshly-captured `from` must exactly
627        // equal the last-painted value: no jump.
628        let timing = Timing::Duration(Duration::from_millis(100), Curve::Linear);
629        let view: AnimatedScaleView<()> = animated_scale(1.0, leaf_any(20.0, 20.0)).timing(timing);
630        let mut w = build(&view);
631        let mut lctx = LayoutCtx::new();
632        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
633
634        w.anim.set_target(2.0);
635        // Seed (zero dt), then advance to the 50ms halfway point: value
636        // should read 1.5 (halfway 1.0 -> 2.0 under a linear curve).
637        assert!(w.anim.advance(ft_ms(0.0), timing));
638        assert!(w.anim.advance(ft_ms(50.0), timing));
639        let value_before_retarget = w.anim.value();
640        assert!(
641            (value_before_retarget - 1.5).abs() < 1e-6,
642            "expected 1.5 halfway through 1.0 -> 2.0, got {value_before_retarget}"
643        );
644
645        // Retarget again, same instant (zero dt on the very next advance).
646        w.anim.set_target(0.5);
647        assert!(w.anim.advance(ft_ms(50.0), timing));
648        let value_after_retarget = w.anim.value();
649
650        assert!(
651            (value_before_retarget - value_after_retarget).abs() < 1e-9,
652            "a retarget must not jump the value: before {value_before_retarget},              after {value_after_retarget}"
653        );
654
655        // And the paint path reflects whatever `anim.value()` reports, end to end.
656        let mut pctx = PaintCtx::new(Point::ZERO, size);
657        let mut scene = RecordingScene::default();
658        w.paint(&mut pctx, &mut scene);
659        let painted = scene.transforms[0] * Point::new(1.0, 0.0);
660        let pivot_x = size.width / 2.0;
661        let expected_x = pivot_x + (1.0 - pivot_x) * value_after_retarget;
662        assert!(
663            (painted.x - expected_x).abs() < 1e-6,
664            "the painted transform must reflect the just-verified continuous value"
665        );
666    }
667
668    #[test]
669    fn scale_default_timing_resolves_unthemed_fallback_and_animates() {
670        // No explicit `.timing(...)` and no theme threaded: `paint` must
671        // resolve the unthemed fallback spring, animate, and request a
672        // continuation frame (not panic, not stall at the wrong value).
673        let view: AnimatedScaleView<()> = animated_scale(1.0, leaf_any(10.0, 10.0));
674        let mut w = build(&view);
675        let mut lctx = LayoutCtx::new();
676        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
677
678        w.anim.set_target(2.0);
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!(scene.transforms.len(), 1);
684        assert!(
685            pctx.needs_frame(),
686            "a freshly retargeted spring must request another frame"
687        );
688    }
689
690    #[test]
691    fn rebuild_forwards_a_target_change_into_the_retained_anim() {
692        // Unlike the tests above (which poke `anim` directly, matching
693        // `SwitchWidget`'s precedent), this one exercises the real
694        // `View::rebuild` path end to end, proving the wiring itself (not
695        // just `ImplicitAnim` in isolation).
696        let view: AnimatedOpacityView<()> = animated_opacity(0.0, leaf_any(10.0, 10.0));
697        let mut w = build(&view);
698
699        let retarget: AnimatedOpacityView<()> = animated_opacity(1.0, leaf_any(10.0, 10.0));
700        let mut counter = 0u64;
701        let flags = retarget.rebuild(&view, &mut w, &mut BuildCtx::new(&mut counter));
702
703        assert_eq!(flags, ChangeFlags::PAINT);
704        assert_eq!(w.anim.target, 1.0, "rebuild must forward the new target");
705    }
706}