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}