Skip to main content

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}