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