frust_widgets/motion/patterns.rs
1//! Core transition patterns: the object-safe [`TransitionPattern`] trait plus
2//! the four source-verified patterns — [`FadeThrough`], [`SharedAxis`]
3//! (X/Y/Scaled), [`FadeScale`], and [`ContainerTransform`] (rect-to-rect
4//! morph + fade-through composite) — that
5//! [`super::switcher::PatternSwitcher`] stages an outgoing/incoming child pair
6//! under. A design system's own patterns live in its own crate
7//! (`frust_glyph::motion` is the shipped example) and implement the same
8//! public trait from there.
9//!
10//! # Pure staging math, no widgets
11//!
12//! A pattern is *pure staging math*: given a `0.0..=1.0` transition progress
13//! (`p`), a `reverse` flag, and the container [`Size`], it yields per-child
14//! paint parameters ([`PatternLayer`] — `{alpha, dx, dy, scale}`) for the
15//! **incoming** and **exiting** children. It never touches a widget, so the
16//! staging numbers are table-testable in isolation (mirroring
17//! `nav::transition::resolve_layers`'s pure-geometry precedent); the switcher
18//! is the sole consumer that applies a resolved [`PatternLayer`] via
19//! `push_layer`/`push_transform` and a pod-origin offset.
20//!
21//! # Progress: raw for position, clamped for opacity
22//!
23//! `p` is **raw** — a spring [`Timing`](crate::Timing) can overshoot past
24//! `1.0`, and that overshoot is applied to *position*/scale so a slide visibly
25//! springs past its rest spot. Opacity always uses the `[0, 1]`-clamped value
26//! (`push_layer` alpha is never meaningful outside that range) — the same
27//! split `nav::transition::resolve_layers` uses.
28//!
29//! # Source-verified staging numbers
30//!
31//! Every constant below is either a Material 3 published value or a
32//! source-verified Flutter `animations`-package number, cited per constant
33//! per `docs/CODE_STANDARDS.md`'s named-constant rule.
34//!
35//! # Scaffold contract
36//!
37//! This file owns its own contents only — the [`TransitionPattern`] trait and
38//! the four patterns above all live here and never edit `motion/mod.rs`'s
39//! module list or re-export block (see that module's docs).
40//! The trait's `size` parameter is a deliberate extension point the
41//! hero-rect-bearing patterns hang off of, kept in the signature now so a
42//! boxed `dyn TransitionPattern` never needs a shape change.
43
44use std::time::Duration;
45
46use frust_core::Curve;
47use frust_core::anim::Lerp;
48use kurbo::{Point, Rect, Size};
49
50// --- Fade-through staging (source-verified) -------------------
51
52/// Fade-through progress split: the outgoing child finishes its fade-out and
53/// the incoming child begins its fade-in + scale-up at this fraction — the
54/// verified Flutter `FadeThroughTransition` staging boundary (the first
55/// **6/20** of the timeline).
56const FADE_THROUGH_SPLIT: f64 = 0.30;
57
58/// Fade-through *outgoing* fade-out easing — Flutter `Cubic(0.4,0,1,1)` applied
59/// over `[0, `[`FADE_THROUGH_SPLIT`]`]`, after which the outgoing child holds at
60/// `0` opacity.
61const FADE_THROUGH_OUT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
62
63/// Fade-through *incoming* fade-in + scale-up easing — Flutter `Cubic(0,0,0.2,1)`
64/// applied over `[`[`FADE_THROUGH_SPLIT`]`, 1]`. Both the
65/// opacity `0→1` and the scale [`FADE_THROUGH_SCALE_START`]`→1.0` track this one
66/// eased segment.
67const FADE_THROUGH_IN_CURVE: Curve = Curve::Cubic(0.0, 0.0, 0.2, 1.0);
68
69/// Fade-through incoming scale start (the incoming child scales 92% → 100% as it
70/// fades in) — the verified Flutter `FadeThroughTransition` staging.
71const FADE_THROUGH_SCALE_START: f64 = 0.92;
72
73// --- Shared-axis staging (M3 published + Flutter source-verified) ---------------------
74
75/// Shared-axis slide distance, in logical px (dp). Material 3 motion "shared
76/// axis" transitions translate by 30dp along the axis. Source: Material Design 3
77/// motion guidelines (m3.material.io, "Transitions → Shared axis").
78const SHARED_AXIS_SLIDE_DP: f64 = 30.0;
79
80/// Shared-axis "scaled" incoming scale start (92% → 100%). Flutter
81/// `SharedAxisTransition(transitionType: scaled)` scales the entering child up
82/// from `0.80`; Material 3's own shared-axis-scaled uses a subtler start —
83/// `0.80` is the source-verified Flutter value.
84const SHARED_AXIS_SCALE_IN_START: f64 = 0.80;
85
86/// Shared-axis "scaled" exiting scale end (100% → 110%): the leaving child
87/// scales *up and out* while fading, the source-verified Flutter
88/// `SharedAxisTransition` scaled staging.
89const SHARED_AXIS_SCALE_OUT_END: f64 = 1.10;
90
91// --- Fade-scale staging (source-verified) --------------------
92
93/// Fade-scale fade interval end: the incoming child's opacity ramps `0→1` over
94/// `[0, `[`FADE_SCALE_FADE_END`]`]` (Flutter `FadeScaleTransition`'s
95/// `Interval(0, 0.3)`), while its scale runs the full timeline.
96const FADE_SCALE_FADE_END: f64 = 0.30;
97
98/// Fade-scale incoming scale start (`0.80 → 1.00` over the full timeline) —
99/// Flutter `FadeScaleTransition`.
100const FADE_SCALE_SCALE_START: f64 = 0.80;
101
102/// Fade-scale scale easing — Flutter `Easing.legacyDecelerate`
103/// (`Cubic(0,0,0.2,1)`).
104const FADE_SCALE_SCALE_CURVE: Curve = Curve::Cubic(0.0, 0.0, 0.2, 1.0);
105
106/// Fade-scale default *forward* duration — Flutter `FadeScaleTransition`'s
107/// 150ms enter. A source-verified pattern-native constant the
108/// switcher's caller can pass to [`.timing(...)`](super::switcher::PatternSwitcherView::timing);
109/// the switcher's own default resolves from the theme instead.
110pub const FADE_SCALE_FORWARD: Duration = Duration::from_millis(150);
111
112/// Fade-scale default *reverse* duration — Flutter `FadeScaleTransition`'s 75ms
113/// exit ("exits always faster than entrances"). See
114/// [`FADE_SCALE_FORWARD`].
115pub const FADE_SCALE_REVERSE: Duration = Duration::from_millis(75);
116
117// --- The layer + trait ------------------------------------------------------
118
119/// Per-child paint parameters for one frame of a pattern transition: a paint
120/// offset (added to the child's pod origin, so paint and hit-testing move
121/// together), an opacity (composited via `push_layer`), and a uniform scale
122/// (composited via `push_transform` about the child's centre).
123#[derive(Clone, Copy, Debug, PartialEq)]
124pub struct PatternLayer {
125 /// Horizontal paint offset in logical px (added to the child's pod origin).
126 pub dx: f64,
127 /// Vertical paint offset in logical px (added to the child's pod origin).
128 pub dy: f64,
129 /// Opacity in `[0, 1]` (composited via `push_layer`).
130 pub alpha: f32,
131 /// Uniform scale factor about the child's paint centre (composited via
132 /// `push_transform`).
133 pub scale: f64,
134}
135
136impl PatternLayer {
137 /// A fully-visible, un-offset, un-scaled layer.
138 pub const IDENTITY: PatternLayer = PatternLayer {
139 dx: 0.0,
140 dy: 0.0,
141 alpha: 1.0,
142 scale: 1.0,
143 };
144}
145
146/// Pure staging math for a keyed child switch: maps a `0.0..=1.0` transition
147/// progress onto per-child [`PatternLayer`]s for the incoming and exiting
148/// children. See the [module docs](self) for the raw-vs-clamped progress
149/// contract.
150///
151/// **Object-safe by contract.** The trait takes only scalar/`Copy` arguments
152/// and returns a concrete `(PatternLayer, PatternLayer)`, so any future
153/// pattern can hold a `Box<dyn TransitionPattern>` without a signature change —
154/// the `size` parameter is the reserved extension point for a hero-rect-bearing
155/// container-transform pattern.
156pub trait TransitionPattern {
157 /// Resolve `(incoming, exiting)` layers at transition progress `p` (raw —
158 /// may overshoot for a spring), `reverse` flipping any directional motion,
159 /// at container `size`.
160 fn resolve(&self, p: f64, reverse: bool, size: Size) -> (PatternLayer, PatternLayer);
161}
162
163// A compile-time proof the trait stays object-safe, even though no consumer
164// in this crate currently boxes it (`PatternSwitcher` is generic over `P:
165// TransitionPattern` instead).
166const _: fn() = || {
167 let _: Option<&dyn TransitionPattern> = None;
168};
169
170/// The eased `(incoming, outgoing)` fade progresses shared by [`FadeThrough`]
171/// and [`SharedAxis`]: the outgoing child fades out over `[0, split]` and the
172/// incoming child fades in over `[split, 1]`, both from the clamped progress
173/// `pc` — Material 3's "fade through" opacity staging.
174fn fade_through_progress(pc: f64) -> (f64, f64) {
175 let out = FADE_THROUGH_OUT_CURVE
176 .interval(0.0, FADE_THROUGH_SPLIT)
177 .transform(pc);
178 let inc = FADE_THROUGH_IN_CURVE
179 .interval(FADE_THROUGH_SPLIT, 1.0)
180 .transform(pc);
181 (inc, out)
182}
183
184// --- FadeThrough ------------------------------------------------------------
185
186/// Material 3 "fade through": the outgoing child fades `1→0`
187/// over the first 6/20 of the timeline then holds, while the incoming child
188/// holds at [`FADE_THROUGH_SCALE_START`] scale / `0` opacity for that 6/20 then
189/// fades in **and** scales up to `1.0` over the remaining 14/20. Non-directional
190/// (no slide) — `reverse` has no effect. The idiomatic pattern for a
191/// destination change with no spatial relationship (bottom-nav switch, account
192/// switch), and the `reduce_motion` collapse target.
193#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
194pub struct FadeThrough;
195
196impl TransitionPattern for FadeThrough {
197 fn resolve(&self, p: f64, _reverse: bool, _size: Size) -> (PatternLayer, PatternLayer) {
198 let pc = p.clamp(0.0, 1.0);
199 let (inc, out) = fade_through_progress(pc);
200 let incoming = PatternLayer {
201 dx: 0.0,
202 dy: 0.0,
203 alpha: inc as f32,
204 scale: FADE_THROUGH_SCALE_START + (1.0 - FADE_THROUGH_SCALE_START) * inc,
205 };
206 let exiting = PatternLayer {
207 dx: 0.0,
208 dy: 0.0,
209 alpha: (1.0 - out) as f32,
210 scale: 1.0,
211 };
212 (incoming, exiting)
213 }
214}
215
216// --- SharedAxis -------------------------------------------------------------
217
218/// Which axis (or the scaled variant) a [`SharedAxis`] transition moves along.
219#[derive(Clone, Copy, Debug, PartialEq, Eq)]
220pub enum SharedAxis {
221 /// Horizontal 30dp slide + fade-through opacity (hierarchical forward/back).
222 X,
223 /// Vertical 30dp slide + fade-through opacity (a step within a flow).
224 Y,
225 /// A scale (no slide) + fade-through opacity: incoming
226 /// [`SHARED_AXIS_SCALE_IN_START`]`→1.0`, exiting `1.0→`[`SHARED_AXIS_SCALE_OUT_END`].
227 Scaled,
228}
229
230impl TransitionPattern for SharedAxis {
231 fn resolve(&self, p: f64, reverse: bool, _size: Size) -> (PatternLayer, PatternLayer) {
232 let pc = p.clamp(0.0, 1.0);
233 let (inc, out) = fade_through_progress(pc);
234 // Direction: forward the incoming child arrives from the positive
235 // side; `reverse` mirrors it (a back navigation slides the other way).
236 let dir = if reverse { -1.0 } else { 1.0 };
237 let slide_in = dir * (1.0 - p) * SHARED_AXIS_SLIDE_DP;
238 let slide_out = -dir * p * SHARED_AXIS_SLIDE_DP;
239
240 let inc_alpha = inc as f32;
241 let out_alpha = (1.0 - out) as f32;
242
243 match self {
244 SharedAxis::X => (
245 PatternLayer {
246 dx: slide_in,
247 dy: 0.0,
248 alpha: inc_alpha,
249 scale: 1.0,
250 },
251 PatternLayer {
252 dx: slide_out,
253 dy: 0.0,
254 alpha: out_alpha,
255 scale: 1.0,
256 },
257 ),
258 SharedAxis::Y => (
259 PatternLayer {
260 dx: 0.0,
261 dy: slide_in,
262 alpha: inc_alpha,
263 scale: 1.0,
264 },
265 PatternLayer {
266 dx: 0.0,
267 dy: slide_out,
268 alpha: out_alpha,
269 scale: 1.0,
270 },
271 ),
272 SharedAxis::Scaled => (
273 PatternLayer {
274 dx: 0.0,
275 dy: 0.0,
276 alpha: inc_alpha,
277 scale: SHARED_AXIS_SCALE_IN_START + (1.0 - SHARED_AXIS_SCALE_IN_START) * inc,
278 },
279 PatternLayer {
280 dx: 0.0,
281 dy: 0.0,
282 alpha: out_alpha,
283 scale: 1.0 + (SHARED_AXIS_SCALE_OUT_END - 1.0) * out,
284 },
285 ),
286 }
287 }
288}
289
290// --- FadeScale --------------------------------------------------------------
291
292/// Material 3 "fade scale" (Flutter `FadeScaleTransition`): the
293/// incoming child fades in over the first 30% of the timeline while scaling
294/// [`FADE_SCALE_SCALE_START`]`→1.0` over the full timeline
295/// ([`FADE_SCALE_SCALE_CURVE`]); the exiting child **fades only** (no scale).
296/// Non-directional — `reverse` has no geometric effect (it selects the faster
297/// [`FADE_SCALE_REVERSE`] duration at the switcher's timing layer instead). The
298/// idiomatic pattern for a dialog/menu/FAB appearing over stable content.
299#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
300pub struct FadeScale;
301
302impl TransitionPattern for FadeScale {
303 fn resolve(&self, p: f64, _reverse: bool, _size: Size) -> (PatternLayer, PatternLayer) {
304 let pc = p.clamp(0.0, 1.0);
305 // Opacity ramps over the first 30% (both children); scale runs the full
306 // eased timeline for the incoming child only.
307 let fade = Curve::Linear
308 .interval(0.0, FADE_SCALE_FADE_END)
309 .transform(pc);
310 let scale_prog = FADE_SCALE_SCALE_CURVE.transform(pc);
311 let incoming = PatternLayer {
312 dx: 0.0,
313 dy: 0.0,
314 alpha: fade as f32,
315 scale: FADE_SCALE_SCALE_START + (1.0 - FADE_SCALE_SCALE_START) * scale_prog,
316 };
317 let exiting = PatternLayer {
318 dx: 0.0,
319 dy: 0.0,
320 alpha: (1.0 - fade) as f32,
321 scale: 1.0,
322 };
323 (incoming, exiting)
324 }
325}
326
327// --- ContainerTransform (OpenContainer) ----------------------
328
329/// ContainerTransform default duration — the Material 3 container-transform /
330/// Flutter `OpenContainer` 300ms morph.
331pub const CONTAINER_TRANSFORM_DURATION: Duration = Duration::from_millis(300);
332
333/// The container-transform bounds (rect-morph) easing — Flutter `OpenContainer`
334/// tweens its container bounds on `Curves.fastOutSlowIn` (`Cubic(0.4,0,0.2,1)`).
335const CONTAINER_TRANSFORM_MORPH_CURVE: Curve = Curve::Cubic(0.4, 0.0, 0.2, 1.0);
336
337/// The plain-`Fade` variant's cross-fade easing — the same decelerate curve the
338/// fade-through incoming segment uses (`Cubic(0,0,0.2,1)`), run
339/// over the full timeline for both children simultaneously.
340const CONTAINER_TRANSFORM_FADE_CURVE: Curve = Curve::Cubic(0.0, 0.0, 0.2, 1.0);
341
342/// How a [`ContainerTransform`] stages opacity while its bounds morph
343/// (the fade / fadeThrough variants).
344#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
345pub enum ContainerFade {
346 /// Fade-through: the source content fades out over the first
347 /// [`FADE_THROUGH_SPLIT`] of the timeline, the incoming content fades in
348 /// over the remainder (the M3 default; no simultaneous double-image).
349 #[default]
350 FadeThrough,
351 /// A plain simultaneous cross-fade of both children over the full timeline.
352 Fade,
353}
354
355/// Material 3 "container transform" (Flutter `OpenContainer`
356/// semantics): the incoming content morphs from a captured `source` rect to a
357/// `target` rect while opacity fades the source content out and the incoming
358/// content in.
359///
360/// # Pattern-local rect capture (no HeroFrames coupling)
361///
362/// Unlike `nav::hero`, this pattern carries its own `source`/`target` rects as
363/// data (captured from layout by the caller) — it never reaches into the
364/// navigator's `HeroFrames` registry, so it composes inside a plain
365/// [`super::switcher::PatternSwitcher`] with no core/navigator plumbing. A
366/// zero-area `target` is resolved to the full container at `resolve` time (the
367/// common "incoming child fills the container" case).
368///
369/// # v1 morph gap: uniform scale, not a true rect-to-rect affine
370///
371/// [`PatternLayer`] composites a **uniform** scale about the child's paint
372/// centre plus an offset — it cannot express the non-uniform (independent
373/// width/height) scale a full rect-to-rect affine needs. So this pattern
374/// approximates the morph as a **width-based uniform scale** placed at the
375/// interpolated rect's centre: exact when `source`/`target` share an aspect
376/// ratio, an approximation otherwise. A true non-uniform morph would need a
377/// richer layer type (a `PatternLayer` change rippling into the switcher's
378/// `paint_staged_child`), deferred rather than hacked into the v1 contract.
379#[derive(Clone, Copy, Debug, PartialEq)]
380pub struct ContainerTransform {
381 /// The source container rect the morph grows from (container-local logical px).
382 pub source: Rect,
383 /// The target rect the morph settles into; a zero-area rect means "the full
384 /// container" (resolved from the `size` passed to [`resolve`](TransitionPattern::resolve)).
385 pub target: Rect,
386 /// Opacity staging variant.
387 pub fade: ContainerFade,
388}
389
390impl ContainerTransform {
391 /// A fade-through container transform morphing `source → target`.
392 pub const fn new(source: Rect, target: Rect) -> Self {
393 Self {
394 source,
395 target,
396 fade: ContainerFade::FadeThrough,
397 }
398 }
399
400 /// A container transform whose incoming child fills the container: `target`
401 /// is left zero-area and resolved to the full container size at paint time.
402 pub const fn from_source(source: Rect) -> Self {
403 Self::new(source, Rect::ZERO)
404 }
405
406 /// Select the opacity staging [`ContainerFade`] variant.
407 pub const fn with_fade(mut self, fade: ContainerFade) -> Self {
408 self.fade = fade;
409 self
410 }
411
412 /// The interpolated bounds at raw progress `p` (spatial-eased via
413 /// [`CONTAINER_TRANSFORM_MORPH_CURVE`]), morphing `source → target` forward
414 /// and `target → source` when `reverse`. `target` is used as configured
415 /// here — see [`resolve`](TransitionPattern::resolve) for the zero-area
416 /// full-container fallback.
417 pub fn morph_rect(&self, p: f64, reverse: bool) -> Rect {
418 morph_between(self.source, self.target, p, reverse)
419 }
420
421 /// `(incoming, exiting)` opacity at clamped progress `pc` for the configured
422 /// [`ContainerFade`] variant.
423 fn fade_alphas(&self, pc: f64) -> (f64, f64) {
424 match self.fade {
425 ContainerFade::FadeThrough => {
426 let (inc, out) = fade_through_progress(pc);
427 (inc, 1.0 - out)
428 }
429 ContainerFade::Fade => {
430 let f = CONTAINER_TRANSFORM_FADE_CURVE.transform(pc);
431 (f, 1.0 - f)
432 }
433 }
434 }
435}
436
437/// Bounds interpolation shared by [`ContainerTransform::morph_rect`] and its
438/// `resolve`: spatial-ease `p`, then lerp `source → target` (or the reverse).
439fn morph_between(source: Rect, target: Rect, p: f64, reverse: bool) -> Rect {
440 let pe = CONTAINER_TRANSFORM_MORPH_CURVE.transform(p);
441 let (from, to) = if reverse {
442 (target, source)
443 } else {
444 (source, target)
445 };
446 from.lerp(&to, pe)
447}
448
449impl TransitionPattern for ContainerTransform {
450 fn resolve(&self, p: f64, reverse: bool, size: Size) -> (PatternLayer, PatternLayer) {
451 let pc = p.clamp(0.0, 1.0);
452 // Zero-area target → the full container (the "incoming fills container"
453 // case); otherwise the caller-captured target rect.
454 let target = if self.target.width() > 0.0 && self.target.height() > 0.0 {
455 self.target
456 } else {
457 Rect::from_origin_size(Point::ZERO, size)
458 };
459 let morph = morph_between(self.source, target, p, reverse);
460
461 // Width-based uniform scale about the child centre + a centre offset that
462 // places the (container-filling) child within `morph` (see the type's
463 // morph-gap note). Guard a degenerate target.
464 let scale = if target.width() > 0.0 {
465 morph.width() / target.width()
466 } else {
467 1.0
468 };
469 let dx = morph.center().x - target.center().x;
470 let dy = morph.center().y - target.center().y;
471
472 let (in_a, out_a) = self.fade_alphas(pc);
473 let incoming = PatternLayer {
474 dx,
475 dy,
476 alpha: in_a as f32,
477 scale,
478 };
479 let exiting = PatternLayer {
480 alpha: out_a as f32,
481 ..PatternLayer::IDENTITY
482 };
483 (incoming, exiting)
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 const SIZE: Size = Size::new(400.0, 800.0);
492
493 // --- FadeThrough (source-verified staging) ---------------
494
495 #[test]
496 fn fade_through_staged_opacity_and_scale_table() {
497 // Outgoing fades 1→0 over the first 6/20 (`Cubic(0.4,0,1,1)`) then holds;
498 // incoming holds at 0.92 scale / 0 opacity for 6/20 then fades in AND
499 // scales to 1.0 over the remaining 14/20 (`Cubic(0,0,0.2,1)`). Split =
500 // 6/20 = 0.30. Table at t ∈ {0, 0.3, 0.5, 1.0} for BOTH children.
501 let ft = FadeThrough;
502
503 // t = 0: outgoing fully opaque at rest scale; incoming invisible at 0.92.
504 let (inc, out) = ft.resolve(0.0, false, SIZE);
505 assert_eq!(out.alpha, 1.0);
506 assert_eq!(out.scale, 1.0);
507 assert_eq!(inc.alpha, 0.0);
508 assert!((inc.scale - 0.92).abs() < 1e-9);
509
510 // t = 0.30 (the split): outgoing just finished fading; incoming opens.
511 let (inc, out) = ft.resolve(0.30, false, SIZE);
512 assert!(out.alpha.abs() < 1e-6, "outgoing gone by the split");
513 assert_eq!(inc.alpha, 0.0, "incoming fade-in opens at the split");
514 assert!((inc.scale - 0.92).abs() < 1e-9);
515
516 // t = 0.50: outgoing long gone; incoming mid fade-in. Local progress into
517 // the [0.30, 1.0] segment is (0.50-0.30)/0.70, eased by `Cubic(0,0,0.2,1)`.
518 let (inc, out) = ft.resolve(0.50, false, SIZE);
519 assert_eq!(out.alpha, 0.0);
520 let eased = Curve::Cubic(0.0, 0.0, 0.2, 1.0).transform((0.50 - 0.30) / 0.70);
521 assert!((inc.alpha as f64 - eased).abs() < 1e-6);
522 assert!((inc.scale - (0.92 + 0.08 * eased)).abs() < 1e-9);
523
524 // t = 1.0: incoming fully arrived (opaque, unit scale); outgoing gone.
525 let (inc, out) = ft.resolve(1.0, false, SIZE);
526 assert_eq!(inc.alpha, 1.0);
527 assert!((inc.scale - 1.0).abs() < 1e-9);
528 assert_eq!(out.alpha, 0.0);
529 assert_eq!(out.scale, 1.0);
530 }
531
532 #[test]
533 fn fade_through_is_non_directional_reverse_equals_forward() {
534 for t in [0.0, 0.3, 0.5, 1.0] {
535 assert_eq!(
536 FadeThrough.resolve(t, false, SIZE),
537 FadeThrough.resolve(t, true, SIZE),
538 "fade-through has no direction: reverse must match forward at t={t}"
539 );
540 }
541 }
542
543 // --- SharedAxis (X/Y/Scaled) --------------------------------------------
544
545 #[test]
546 fn shared_axis_x_slides_and_crossfades_forward_and_reverse() {
547 let sx = SharedAxis::X;
548 // Forward start: incoming offset by the full slide, invisible; exiting at
549 // rest, fully opaque.
550 let (inc, out) = sx.resolve(0.0, false, SIZE);
551 assert_eq!(inc.dx, SHARED_AXIS_SLIDE_DP);
552 assert_eq!(inc.alpha, 0.0);
553 assert_eq!(out.dx, 0.0);
554 assert_eq!(out.alpha, 1.0);
555 // Forward end: incoming at rest & opaque; exiting slid out & transparent.
556 let (inc, out) = sx.resolve(1.0, false, SIZE);
557 assert_eq!(inc.dx, 0.0);
558 assert_eq!(inc.alpha, 1.0);
559 assert_eq!(out.dx, -SHARED_AXIS_SLIDE_DP);
560 assert_eq!(out.alpha, 0.0);
561 // Reverse mirrors the horizontal direction.
562 let (inc, _out) = sx.resolve(0.0, true, SIZE);
563 assert_eq!(inc.dx, -SHARED_AXIS_SLIDE_DP);
564 // Fade split at t=0.30: exiting gone, incoming still invisible.
565 let (inc, out) = sx.resolve(0.30, false, SIZE);
566 assert!(out.alpha.abs() < 1e-6);
567 assert_eq!(inc.alpha, 0.0);
568 // Opacity uses the same eased fade-through progress as FadeThrough.
569 let (fi, _fo) = SharedAxis::X.resolve(0.5, false, SIZE);
570 let (fti, _fto) = FadeThrough.resolve(0.5, false, SIZE);
571 assert!((fi.alpha - fti.alpha).abs() < 1e-9);
572 }
573
574 #[test]
575 fn shared_axis_y_slides_vertically_only() {
576 let (inc, out) = SharedAxis::Y.resolve(0.0, false, SIZE);
577 assert_eq!(inc.dx, 0.0);
578 assert_eq!(inc.dy, SHARED_AXIS_SLIDE_DP);
579 assert_eq!(out.dy, 0.0);
580 let (inc, out) = SharedAxis::Y.resolve(1.0, false, SIZE);
581 assert_eq!(inc.dy, 0.0);
582 assert_eq!(out.dy, -SHARED_AXIS_SLIDE_DP);
583 }
584
585 #[test]
586 fn shared_axis_scaled_scales_without_sliding() {
587 let s = SharedAxis::Scaled;
588 // t=0: incoming at 0.80 scale/invisible; exiting at 1.0/opaque.
589 let (inc, out) = s.resolve(0.0, false, SIZE);
590 assert_eq!(inc.dx, 0.0);
591 assert_eq!(inc.dy, 0.0);
592 assert!((inc.scale - SHARED_AXIS_SCALE_IN_START).abs() < 1e-9);
593 assert_eq!(inc.alpha, 0.0);
594 assert_eq!(out.scale, 1.0);
595 assert_eq!(out.alpha, 1.0);
596 // t=1: incoming rests at 1.0; exiting scaled up to 1.10, transparent.
597 let (inc, out) = s.resolve(1.0, false, SIZE);
598 assert!((inc.scale - 1.0).abs() < 1e-9);
599 assert_eq!(inc.alpha, 1.0);
600 assert!((out.scale - SHARED_AXIS_SCALE_OUT_END).abs() < 1e-9);
601 assert_eq!(out.alpha, 0.0);
602 }
603
604 // --- FadeScale (source-verified staging) -----------------
605
606 #[test]
607 fn fade_scale_incoming_fades_early_scales_full_exiting_fades_only() {
608 let fs = FadeScale;
609 // t=0: incoming invisible at 0.80 scale; exiting opaque, unscaled.
610 let (inc, out) = fs.resolve(0.0, false, SIZE);
611 assert_eq!(inc.alpha, 0.0);
612 assert!((inc.scale - FADE_SCALE_SCALE_START).abs() < 1e-9);
613 assert_eq!(out.alpha, 1.0);
614 assert_eq!(out.scale, 1.0);
615
616 // t=0.30: fade interval closes — incoming opaque, exiting gone; scale
617 // still mid-flight (< 1.0) on the incoming child.
618 let (inc, out) = fs.resolve(0.30, false, SIZE);
619 assert!((inc.alpha - 1.0).abs() < 1e-6, "fade completes by 0.30");
620 assert!(out.alpha.abs() < 1e-6);
621 let scale_at_30 = FADE_SCALE_SCALE_START
622 + (1.0 - FADE_SCALE_SCALE_START) * FADE_SCALE_SCALE_CURVE.transform(0.30);
623 assert!((inc.scale - scale_at_30).abs() < 1e-9);
624 assert!(inc.scale < 1.0, "scale is still animating at t=0.30");
625
626 // t=0.50: still opaque, scale continuing.
627 let (inc, _out) = fs.resolve(0.50, false, SIZE);
628 assert!((inc.alpha - 1.0).abs() < 1e-6);
629 let scale_at_50 = FADE_SCALE_SCALE_START
630 + (1.0 - FADE_SCALE_SCALE_START) * FADE_SCALE_SCALE_CURVE.transform(0.50);
631 assert!((inc.scale - scale_at_50).abs() < 1e-9);
632
633 // t=1.0: incoming fully settled; exiting fully faded, never scaled.
634 let (inc, out) = fs.resolve(1.0, false, SIZE);
635 assert!((inc.alpha - 1.0).abs() < 1e-6);
636 assert!((inc.scale - 1.0).abs() < 1e-9);
637 assert_eq!(out.alpha, 0.0);
638 assert_eq!(
639 out.scale, 1.0,
640 "the exiting child never scales (fades only)"
641 );
642 }
643
644 #[test]
645 fn fade_scale_reverse_is_geometrically_identical() {
646 // Reverse selects the faster duration at the switcher's timing layer, not
647 // a different geometry — the staging math is identical.
648 for t in [0.0, 0.3, 0.5, 1.0] {
649 assert_eq!(
650 FadeScale.resolve(t, false, SIZE),
651 FadeScale.resolve(t, true, SIZE),
652 "fade-scale geometry is direction-independent at t={t}"
653 );
654 }
655 }
656
657 #[test]
658 fn forward_default_durations_encode_exits_faster_than_entrances() {
659 assert_eq!(FADE_SCALE_FORWARD, Duration::from_millis(150));
660 assert_eq!(FADE_SCALE_REVERSE, Duration::from_millis(75));
661 assert!(
662 FADE_SCALE_REVERSE < FADE_SCALE_FORWARD,
663 "exits always faster than entrances"
664 );
665 }
666
667 // --- ContainerTransform (OpenContainer) ------------------
668
669 const SRC: Rect = Rect::new(100.0, 200.0, 200.0, 400.0); // 100x200 @ (100,200)
670
671 #[test]
672 fn container_transform_rect_interpolation_at_fixed_t() {
673 // Morph a small source rect into the full container; the interpolated
674 // bounds ease `source → target` on the fastOutSlowIn curve.
675 let ct = ContainerTransform::new(SRC, Rect::from_origin_size(Point::ZERO, SIZE));
676 let target = Rect::from_origin_size(Point::ZERO, SIZE);
677
678 // t=0: exactly the source rect.
679 let r0 = ct.morph_rect(0.0, false);
680 assert!((r0.x0 - SRC.x0).abs() < 1e-9 && (r0.y0 - SRC.y0).abs() < 1e-9);
681 assert!((r0.width() - SRC.width()).abs() < 1e-9);
682
683 // t=1: exactly the target (full container).
684 let r1 = ct.morph_rect(1.0, false);
685 assert!((r1.width() - SIZE.width).abs() < 1e-9);
686 assert!((r1.height() - SIZE.height).abs() < 1e-9);
687
688 // t=0.5: the eased lerp of source→target (fastOutSlowIn at 0.5).
689 let pe = CONTAINER_TRANSFORM_MORPH_CURVE.transform(0.5);
690 let r5 = ct.morph_rect(0.5, false);
691 let expect = SRC.lerp(&target, pe);
692 assert!((r5.x0 - expect.x0).abs() < 1e-9);
693 assert!((r5.width() - expect.width()).abs() < 1e-9);
694
695 // reverse morphs target → source.
696 let rr = ct.morph_rect(0.0, true);
697 assert!(
698 (rr.width() - SIZE.width).abs() < 1e-9,
699 "reverse starts at target"
700 );
701 }
702
703 #[test]
704 fn container_transform_zero_area_target_resolves_to_full_container() {
705 // `from_source` leaves the target zero-area; resolve fills the container.
706 let ct = ContainerTransform::from_source(SRC);
707 // t=1: incoming settled at unit scale / no offset (fills the container).
708 let (inc, _out) = ct.resolve(1.0, false, SIZE);
709 assert!((inc.scale - 1.0).abs() < 1e-9);
710 assert!(inc.dx.abs() < 1e-9 && inc.dy.abs() < 1e-9);
711 // t=0: incoming shrunk to the source width fraction, centred on source.
712 let (inc0, _out0) = ct.resolve(0.0, false, SIZE);
713 assert!((inc0.scale - SRC.width() / SIZE.width).abs() < 1e-9);
714 let full = Rect::from_origin_size(Point::ZERO, SIZE);
715 assert!((inc0.dx - (SRC.center().x - full.center().x)).abs() < 1e-9);
716 assert!((inc0.dy - (SRC.center().y - full.center().y)).abs() < 1e-9);
717 }
718
719 #[test]
720 fn container_transform_fade_through_opacity_staging() {
721 let ct = ContainerTransform::from_source(SRC); // FadeThrough default
722 // t=0: source fully opaque, incoming invisible.
723 let (inc, out) = ct.resolve(0.0, false, SIZE);
724 assert_eq!(out.alpha, 1.0);
725 assert_eq!(inc.alpha, 0.0);
726 // At the split: source gone, incoming still opening.
727 let (inc, out) = ct.resolve(FADE_THROUGH_SPLIT, false, SIZE);
728 assert!(out.alpha.abs() < 1e-6);
729 assert_eq!(inc.alpha, 0.0);
730 // t=1: incoming opaque, source gone.
731 let (inc, out) = ct.resolve(1.0, false, SIZE);
732 assert!((inc.alpha - 1.0).abs() < 1e-6);
733 assert_eq!(out.alpha, 0.0);
734 }
735
736 #[test]
737 fn container_transform_plain_fade_variant_cross_fades_simultaneously() {
738 let ct = ContainerTransform::from_source(SRC).with_fade(ContainerFade::Fade);
739 // Both children cross-fade together: alphas sum to ~1 across the timeline.
740 for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
741 let (inc, out) = ct.resolve(t, false, SIZE);
742 assert!(
743 (inc.alpha + out.alpha - 1.0).abs() < 1e-6,
744 "plain fade cross-fades (alphas sum to 1) at t={t}"
745 );
746 }
747 }
748
749 #[test]
750 fn transition_patterns_reduce_motion_collapse_target_removes_motion() {
751 // The switcher collapses ANY pattern to a fast FadeThrough
752 // crossfade under reduce_motion. Prove the collapse *removes spatial
753 // motion* — FadeThrough (the collapse target) is non-directional (no
754 // slide) where a translating pattern moves, at a shared mid-progress.
755 // (FadeThrough keeps its own subtle scale-up; the removed motion is the
756 // directional slide/morph offset.) `SharedAxis` and
757 // `ContainerTransform` are the two in-crate translating patterns; an
758 // out-of-crate pattern (e.g. `frust_glyph::motion::GlyphSlide`)
759 // collapses identically, since the switcher substitutes the pattern
760 // wholesale rather than asking it to reduce itself.
761 let (ft_in, _) = FadeThrough.resolve(0.5, false, SIZE);
762 assert_eq!(ft_in.dx, 0.0);
763 assert_eq!(ft_in.dy, 0.0);
764
765 let (axis_in, _) = SharedAxis::X.resolve(0.5, false, SIZE);
766 assert!(
767 axis_in.dx.abs() > 0.0,
768 "SharedAxis offsets where the collapse does not"
769 );
770
771 let (ct_in, _) = ContainerTransform::from_source(SRC).resolve(0.5, false, SIZE);
772 assert!(
773 (ct_in.scale - 1.0).abs() > 1e-6 || ct_in.dx.abs() > 0.0 || ct_in.dy.abs() > 0.0,
774 "ContainerTransform morphs where the collapse does not"
775 );
776 }
777}