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`].
267///
268/// Keyboard input (`Shortcut`, `Key`, `Ime`, `EditCommand`) reaches the child
269/// and the child is a Tab stop only while the fade is above opacity 0; an
270/// invisible child answers no chord and takes no grant.
271pub struct AnimatedOpacityWidget {
272    child: ChildPod,
273    explicit_timing: Option<Timing>,
274    anim: ImplicitAnim,
275    /// The retained-page boundary the child paints through, when
276    /// [`cached`](AnimatedOpacityView::cached).
277    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        // Transparent wrapper: exactly the child's size (module docs).
323        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        // Clamped: alpha is never meaningful outside [0, 1], even if a spring
335        // `Timing` would otherwise overshoot past the target (see the module
336        // docs' Retargeting section and `nav::transition`'s identical
337        // opacity-clamps/position-doesn't precedent).
338        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        // Fully faded out the child still paints but is invisible: it must not
352        // answer a chord or a key (see the type docs).
353        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        // An invisible child is no Tab stop either.
369        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
381// --- AnimatedScale ------------------------------------------------------------
382
383/// A declarative scale wrapper. See the [module docs](self).
384pub struct AnimatedScaleView<State: 'static> {
385    target: f64,
386    alignment: Alignment,
387    timing: Option<Timing>,
388    child: AnyView<State>,
389}
390
391/// Scale `child` toward `target` (a scale factor — `1.0` is unscaled; no
392/// range restriction, unlike opacity) about [`Alignment::CENTER`] by default —
393/// see the [module docs](self). Defaults its [`Timing`] from the theme's
394/// `default_spatial` spring; override with
395/// [`.timing(...)`](AnimatedScaleView::timing); override the pivot with
396/// [`.alignment(...)`](AnimatedScaleView::alignment).
397pub 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/// PascalCase alias for [`animated_scale`] (mirrors `Switch`/`switch`'s dual
410/// naming).
411#[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    /// Override the pivot point the scale is applied about (default
421    /// [`Alignment::CENTER`]).
422    pub fn alignment(mut self, alignment: Alignment) -> Self {
423        self.alignment = alignment;
424        self
425    }
426
427    /// Override the default (theme-resolved) scale timing.
428    pub fn timing(mut self, timing: Timing) -> Self {
429        self.timing = Some(timing);
430        self
431    }
432}
433
434/// The retained widget for an [`AnimatedScaleView`].
435pub 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        // Transparent wrapper: exactly the child's size, unaffected by the
485        // (paint-only) scale — see the module docs' hit-testing section.
486        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        // Unclamped: a spatial spring's overshoot past the target is real,
498        // intended motion (see the module docs' Retargeting section and
499        // `AnimationController`'s own Overshoot docs).
500        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        // Unscaled event space (v1) — see the module docs' hit-testing
515        // section: `route_event_single` hit-tests the child's laid-out
516        // (unscaled) bounds via `ChildPod::contains`, never the paint-time
517        // transform.
518        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    // Every test below advances the widget's retained `anim` field directly
544    // (a private field, reachable here since `tests` is a descendant module
545    // of `animated`) rather than through repeated `Widget::paint` calls with
546    // different `PaintCtx` frame times -- `PaintCtx::set_frame_time` is
547    // crate-private to `frust-core`, so `frust-widgets` cannot construct a
548    // `PaintCtx` at an arbitrary frame time. This mirrors
549    // `material::switch`'s own paint/animation tests exactly (see that
550    // module's `animation_progresses_toward_and_settles_at_target` test and
551    // its comment). A single `paint` call at the default `FrameTime::ZERO`
552    // (to trigger the lazy retarget, or to read back the scene after
553    // manually advancing `anim`) is still exercised in every test, so the
554    // real `Widget::paint` compositing path (`push_layer`/`push_transform`)
555    // is what's actually asserted against.
556
557    // --- AnimatedOpacity ---
558
559    #[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        // Simulate what `rebuild` would do on a target change (mirrors
590        // `SwitchWidget`'s test precedent of poking the confirmed-value
591        // field directly rather than calling `View::rebuild`).
592        w.anim.set_target(1.0);
593
594        // First paint (frame_time == FrameTime::ZERO) starts the retarget:
595        // seeds the driver's clock at a zero delta, alpha still 0.0.
596        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        // Directly advance to the 50ms midpoint of the 100ms linear fade.
605        assert!(
606            w.anim.advance(ft_ms(50.0), timing),
607            "must still be animating at the midpoint"
608        );
609
610        // Re-paint at (still) FrameTime::ZERO: the retarget check is a no-op
611        // (already retargeted above), and re-advancing the driver at ZERO
612        // produces a zero -- never negative -- delta (`FrameTime::saturating_sub`),
613        // so this just repaints the already-advanced midpoint value.
614        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        // Well past the 50ms duration: settles at the target.
643        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    // --- AnimatedScale ---
670
671    #[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        // Start at 1.0, retarget to 2.0, advance partway, read the
702        // interpolated value, then retarget again to 0.5 at the SAME
703        // instant (zero dt) -- the freshly-captured `from` must exactly
704        // equal the last-painted value: no jump.
705        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        // Seed (zero dt), then advance to the 50ms halfway point: value
713        // should read 1.5 (halfway 1.0 -> 2.0 under a linear curve).
714        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        // Retarget again, same instant (zero dt on the very next advance).
723        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        // And the paint path reflects whatever `anim.value()` reports, end to end.
733        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        // No explicit `.timing(...)` and no theme threaded: `paint` must
748        // resolve the unthemed fallback spring, animate, and request a
749        // continuation frame (not panic, not stall at the wrong value).
750        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        // Unlike the tests above (which poke `anim` directly, matching
770        // `SwitchWidget`'s precedent), this one exercises the real
771        // `View::rebuild` path end to end, proving the wiring itself (not
772        // just `ImplicitAnim` in isolation).
773        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}