Skip to main content

frust_widgets/nav/
transition.rs

1//! Page-transition machinery for the [`navigator`](super::navigator): the
2//! transition vocabulary ([`PageTransition`] presets + [`Timing`]
3//! modes), the per-transition progress [`driver`](TransitionDriver) the navigator
4//! advances during paint, the pure geometry ([`resolve_layers`]) that maps a
5//! progress value onto per-page paint offsets + opacities, and the published
6//! [`TransitionState`] snapshot chrome *outside* the navigator observes a
7//! transition through.
8//!
9//! # Split of concerns
10//!
11//! This module is the **reusable, page-agnostic** half of the transition system:
12//! it knows nothing about the retained page stack. The navigator
13//! ([`super::navigator`]) owns the [`ActiveTransition`](super::navigator) that
14//! pairs a [`TransitionDriver`] with the concrete incoming/outgoing page pods and
15//! drives it from `PaintCtx::frame_time` during paint — see that module for the
16//! ownership/lifecycle contract (create per push/pop, dispose on settle, Flutter
17//! parity).
18//!
19//! # Positioning rule (paint offset = pod origin)
20//!
21//! A slide animates a page's **pod origin** (the navigator writes
22//! [`ChildPod::set_origin`](frust_core::ChildPod::set_origin) each paint), so
23//! paint and hit-testing move together — the same precedent `ScrollWidget` uses.
24//! Opacity is a paint-only effect (`PaintScene::push_layer(alpha)`); it may
25//! diverge from hit-testing, which is irrelevant because the navigator blocks all
26//! input to pages while a transition runs (see [`super::navigator`]).
27//!
28//! # Overshoot: spatial vs effects
29//!
30//! A [`Timing::Spring`] mode drives the progress with an
31//! [`AnimationController::fling`](frust_core::AnimationController::fling): an
32//! under-damped **spatial** preset (M3's `damping_ratio: 0.9`) genuinely
33//! overshoots past `1.0` before settling, and that overshoot is applied to
34//! *position* offsets (raw [`value`](TransitionDriver::value)) so the page visibly
35//! springs past its resting spot. **Opacity** uses the clamped value so alpha
36//! never exceeds `[0, 1]` — a critically-damped effects preset never overshoots
37//! anyway, but clamping is the belt-and-braces guarantee (see
38//! `frust-core`'s `anim` overshoot contract).
39
40use std::time::Duration;
41
42use frust_core::{AnimationController, Curve, FrameTime, Spring, SpringDesc};
43use frust_theme::{MotionScheme, MotionSpring};
44use kurbo::{Affine, Point, Rect, Size};
45
46// --- Named preset constants (see per-constant source comments) --------------
47
48/// M3 shared-axis-X slide distance, in logical px (dp). Confirmed spec value:
49/// Material 3 motion "shared axis" transitions translate by 30dp along the axis.
50/// Source: Material Design 3 motion guidelines (m3.material.io, "Transitions →
51/// Shared axis").
52const M3_SHARED_AXIS_SLIDE_DP: f64 = 30.0;
53
54/// Progress split between the outgoing fade-out and the incoming fade-in for
55/// M3 shared-axis and fade-through transitions: the outgoing page fades out over
56/// `[0, THRESHOLD]` and the incoming page fades in over `[THRESHOLD, 1]`.
57///
58/// M3's "fade through" is defined by *progress fractions* (a fade-out then a
59/// fade-in with a brief gap), not fixed millisecond offsets. ~0.35 is the
60/// split the reference implementations use.
61const M3_FADE_SPLIT: f64 = 0.35;
62
63/// M3 fade-through incoming scale start (the incoming page scales 92% → 100% as
64/// it fades in). Source: Material Design 3 "fade through" spec, matching the
65/// verified Flutter `FadeThroughTransition` staging.
66///
67/// Applied on the incoming [`Layer::scale`] over the `[`[`M3_FADE_THROUGH_SPLIT`]`,
68/// 1]` segment with [`M3_FADE_THROUGH_IN_CURVE`]; the navigator brackets the
69/// incoming page's paint with a `push_transform` when the layer scale differs
70/// from `1.0`.
71const M3_FADE_THROUGH_SCALE_START: f64 = 0.92;
72
73/// M3 fade-through progress split: the outgoing page finishes its fade-out and
74/// the incoming page begins its fade-in + scale-up at this fraction — the
75/// verified Flutter `FadeThroughTransition` staging boundary (the first
76/// **6/20** of the timeline).
77const M3_FADE_THROUGH_SPLIT: f64 = 0.30;
78
79/// M3 fade-through *outgoing* fade-out easing — Flutter `Cubic(0.4,0,1,1)`
80/// applied over `[0, `[`M3_FADE_THROUGH_SPLIT`]`]`, after which the outgoing page
81/// holds at `0` opacity.
82const M3_FADE_THROUGH_OUT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
83
84/// M3 fade-through *incoming* fade-in + scale-up easing — Flutter
85/// `Cubic(0,0,0.2,1)` applied over `[`[`M3_FADE_THROUGH_SPLIT`]`, 1]`. Both the
86/// opacity `0→1` and the scale
87/// [`M3_FADE_THROUGH_SCALE_START`]`→1.0` track this one eased segment.
88const M3_FADE_THROUGH_IN_CURVE: Curve = Curve::Cubic(0.0, 0.0, 0.2, 1.0);
89
90/// Glyph screen-transition slide distance, in logical px ("screen transition:
91/// 340ms spatial slide-in 16px + fade").
92const GLYPH_SLIDE_DP: f64 = 16.0;
93
94/// Glyph screen-transition *enter* duration — the new screen's spatial slide-in
95/// (340ms, the Glyph `slow` token). The unthemed
96/// fallback when no [`MotionScheme`] is threaded (see [`preset_enter_exit`]).
97const GLYPH_ENTER: Duration = Duration::from_millis(340);
98
99/// Glyph screen-transition *exit* duration — the old screen's accelerate-out
100/// (150ms, the Glyph `fast` token) plus the hard rule
101/// "exits always faster than entrances". Unthemed fallback.
102const GLYPH_EXIT: Duration = Duration::from_millis(150);
103
104/// Glyph `spatial` easing (overshoot; position/scale)
105/// (`cubic-bezier(0.34,1.35,0.64,1)`). Unthemed fallback for the enter curve.
106const GLYPH_SPATIAL_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);
107
108/// Glyph `exit` easing (accelerate-out)
109/// (`cubic-bezier(0.4,0,1,1)`). Unthemed fallback for the exit curve.
110const GLYPH_EXIT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);
111
112/// The progress split for the Glyph preset's cross-fade: the leaving page
113/// completes its fade-out by this fraction (≈ the 150/340 exit/enter duration
114/// ratio), after which the entering page fades in — encoding the "exits always
115/// faster than entrances" rule in the single-progress geometry.
116const GLYPH_FADE_SPLIT: f64 = 0.44;
117
118/// Reduced-motion collapse duration: the Glyph design system's hard rule that
119/// `prefers-reduced-motion` collapses *every* pattern to a `≤120ms` linear
120/// crossfade. See [`resolve_spec`].
121const REDUCE_MOTION_DURATION: Duration = Duration::from_millis(120);
122
123/// iOS push parallax fraction: the outgoing (below) page slides out by one third
124/// of the incoming page's travel while the incoming page slides fully across.
125///
126/// **Community-approximate**: UIKit's
127/// `UINavigationController` push does not publish an exact parallax ratio; 1/3 is
128/// the value the community-reverse-engineered reimplementations converge on.
129const IOS_PARALLAX_FRACTION: f64 = 1.0 / 3.0;
130
131/// Maximum dim applied to the outgoing (below) page during an iOS push — its
132/// opacity drops to `1 - IOS_DIM_MAX` at full cover.
133///
134/// **Customary, not stock**: a dim scrim under the incoming page is a
135/// common embellishment, not a documented UIKit constant. Kept small and
136/// approximate.
137const IOS_DIM_MAX: f32 = 0.08;
138
139/// iOS push default duration. **Community-approximate** (~0.35s ease-in-out);
140/// UIKit's exact interactive-transition timing is private.
141const IOS_DEFAULT_DURATION: Duration = Duration::from_millis(350);
142
143/// The default duration for a duration-mode M3 transition (300ms). Source:
144/// Material Design 3 motion durations ("long2" ≈ the 300ms shared-axis default).
145const M3_DEFAULT_DURATION: Duration = Duration::from_millis(300);
146
147/// The spring used to *settle* a transition that was driven manually (the
148/// interactive edge-swipe) but configured in a duration [`Timing`] mode — a duration has no
149/// spring to fling with, so a release needs a fallback. M3's default **spatial**
150/// preset (`damping_ratio: 0.9`, `stiffness: 700`, mass 1). Source:
151/// material-components-android motion tokens (see `frust-theme`'s `motion`).
152const DEFAULT_SETTLE_SPRING: SpringDesc = SpringDesc {
153    mass: 1.0,
154    stiffness: 700.0,
155    damping_ratio: 0.9,
156};
157
158// --- Public vocabulary ------------------------------------------------------
159
160/// The visual shape of a page transition. The navigator maps the active
161/// transition's progress onto per-page geometry through [`resolve_layers`].
162///
163/// `#[allow(unpredictable_function_pointer_comparisons)]`: the derived
164/// `PartialEq`/`Eq` compare a [`PageTransition::Custom`] payload by function
165/// pointer address, which the compiler flags because that address isn't
166/// guaranteed stable across codegen units. Accepted here deliberately — the
167/// contract (documented on `Custom`) is a best-effort "names the same
168/// function" identity check, not a memory-safety- or correctness-critical
169/// comparison; the alternative (dropping `Eq` from the enum) breaks every
170/// other variant's equality for a single edge case. The `#[allow]` sits at
171/// the enum level (not scoped to `Custom` alone) because a field-level
172/// attribute on a tuple-variant payload does not suppress a lint raised
173/// inside the derive macro's generated `PartialEq`/`Eq` impl — confirmed by
174/// attempting exactly that scoping, which left the warning in place; the
175/// derive expands against the whole enum, so only an enum- or module-level
176/// `#[allow]` (or a hand-written `impl PartialEq` dropping the derive
177/// entirely) reaches it.
178#[allow(unpredictable_function_pointer_comparisons)]
179#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
180pub enum PageTransition {
181    /// Instant switch — no animation. The default.
182    #[default]
183    None,
184    /// Material 3 shared-axis-X: a 30dp slide paired with a threshold cross-fade.
185    M3SharedAxisX,
186    /// Material 3 fade-through: a *staged* outgoing fade-out then incoming
187    /// fade-in **with** the incoming [`M3_FADE_THROUGH_SCALE_START`]`→1.0`
188    /// scale-up — the verified Flutter `FadeThroughTransition` staging.
189    /// See [`resolve_layers`]'s `M3FadeThrough` arm.
190    M3FadeThrough,
191    /// iOS-style push/pop: incoming slides full-width from the edge; outgoing
192    /// parallaxes by [`IOS_PARALLAX_FRACTION`] with an optional dim.
193    IosPush,
194    /// Bottom-sheet-style slide-up: the entering page translates in from the
195    /// bottom (full height → 0); pop reverses (slides back down and out). The
196    /// page **below** never moves (a modal sheet floats over a static page,
197    /// unlike [`PageTransition::IosPush`]'s parallaxing below-page). Opacity is
198    /// always `1.0` for both layers — a sheet's scrim is a **page-owned** paint
199    /// concern (the hosting widget paints/fades its own scrim, e.g. reading the
200    /// transition progress itself or a fixed alpha), not a [`Layer`]-level
201    /// effect this preset drives; see [`resolve_layers`]'s `SlideUp` arm.
202    SlideUp,
203    /// Glyph screen transition: a directional
204    /// [`GLYPH_SLIDE_DP`]-px slide paired with a cross-fade. The entering screen
205    /// slides in over the theme's *slow* spatial timing while the leaving screen
206    /// accelerates out over the faster *exit* timing ("exits always faster than
207    /// entrances"); a pop reverses the slide direction. Timing is resolved from
208    /// the active [`MotionScheme`] via [`resolve_spec`]/[`preset_enter_exit`] —
209    /// pair it with [`Timing::ThemeDefault`] (or [`TransitionSpec::glyph`]).
210    Glyph,
211    /// Pure alpha cross-fade with **zero geometric motion** (no slide, no
212    /// scale) — the [`resolve_spec`] `reduce_motion` collapse target
213    /// (the Glyph design system's hard accessibility rule: a reduced transition
214    /// is a short linear cross-fade, never a zoom or slide). Selectable directly,
215    /// but its primary role is the collapse; see [`resolve_layers`]'s arm.
216    ReducedCrossfade,
217    /// A caller-supplied transition: a third-party design system's escape hatch
218    /// for authoring its own page transition without a matching built-in
219    /// preset. The function maps **raw** progress (unclamped, so a spring
220    /// overshoot is visible — the same raw `value` [`resolve_layers`] passes
221    /// every built-in preset for position) plus `is_pop` and the page `Size`
222    /// onto the `(entering, leaving)` [`Layer`] pair, exactly as a built-in
223    /// preset's `resolve_layers` arm does; [`resolve_layers`] invokes it
224    /// verbatim, with no clamping or post-processing beyond what every preset
225    /// gets.
226    ///
227    /// A plain function pointer (not `Box<dyn Fn>`) so `Custom` keeps every
228    /// derive `PageTransition` already has (`Copy`/`Eq` included) — a
229    /// `Box<dyn Fn>` cannot implement either. Function pointers compare by
230    /// address, so `PartialEq`/`Eq` stay meaningful: two `Custom` specs are
231    /// equal iff they name the same function.
232    ///
233    /// **Timing.** `Custom` has no [`MotionScheme`] tokens of its own — pair it
234    /// with an explicit [`Timing::Duration`]/[`Timing::Spring`] for a
235    /// caller-chosen timing, or [`Timing::ThemeDefault`] to fall back to the
236    /// documented M3 default (300ms + [`Curve::Emphasized`] —
237    /// [`preset_enter_exit`]'s fallback arm, matching [`make_driver`]'s
238    /// unthemed `ThemeDefault` fallback).
239    ///
240    /// **Reduce-motion — programmatic path only.** [`resolve_spec`]'s
241    /// collapse to [`PageTransition::ReducedCrossfade`] applies to `Custom`
242    /// like every other preset when a transition is staged
243    /// **programmatically** (a push/pop/replace carrying a
244    /// [`TransitionSpec`]): the navigator resolves it against the active
245    /// `reduce_motion` flag on the transition's first paint, before the
246    /// preset is ever consulted, so a `Custom` fn staged that way is not
247    /// called.
248    ///
249    /// A **user-driven interactive edge-swipe pop** is a different path:
250    /// it is not routed through that collapse at all — the navigator drives
251    /// the popped page's own preset directly, raw, with no
252    /// [`resolve_spec`] step — so under `reduce_motion` the supplied
253    /// function **is** still called there. This is not specific to
254    /// `Custom`: every built-in preset behaves identically on an
255    /// interactive pop (a pre-existing accessibility gap, unrelated to
256    /// `Custom` and tracked separately — this doc narrows the claim to what
257    /// the code does, it does not fix the gap).
258    Custom(fn(progress: f64, is_pop: bool, size: Size) -> (Layer, Layer)),
259}
260
261/// How a transition's `0.0..=1.0` progress is driven.
262#[derive(Clone, Copy, Debug, PartialEq)]
263pub enum Timing {
264    /// Duration + easing curve (bounded, no overshoot).
265    Duration(Duration, Curve),
266    /// A physics spring ([`MotionSpring`], from the theme's motion scheme). A
267    /// spatial preset overshoots position; an effects preset does not.
268    Spring(MotionSpring),
269    /// Resolve concrete timing from the active theme's [`MotionScheme`] (the
270    /// durations/easing tokens), per preset and honoring `reduce_motion`. The
271    /// navigator resolves this via [`resolve_spec`] on the transition's **first
272    /// paint** — the first point a `PaintCtx` carries the theme (the `BuildCtx`
273    /// that stages a transition carries none) — and rebuilds the driver from the
274    /// resolved timing before any frame is staged. If no theme is threaded it
275    /// falls back through [`make_driver`] to the M3 default duration +
276    /// [`Curve::Emphasized`] easing.
277    ThemeDefault,
278}
279
280impl Default for Timing {
281    fn default() -> Self {
282        Timing::Duration(M3_DEFAULT_DURATION, Curve::EaseInOut)
283    }
284}
285
286/// A transition selection: which [`PageTransition`] shape, driven by which
287/// [`Timing`]. Attached per-push/replace (or defaulted at the navigator level);
288/// a pop reverses the popped page's stored spec.
289#[derive(Clone, Copy, Debug, PartialEq)]
290pub struct TransitionSpec {
291    /// The visual preset.
292    pub preset: PageTransition,
293    /// How its progress is driven.
294    pub timing: Timing,
295}
296
297impl Default for TransitionSpec {
298    fn default() -> Self {
299        Self::NONE
300    }
301}
302
303impl TransitionSpec {
304    /// An instant (non-animated) switch — the navigator's default.
305    pub const NONE: Self = TransitionSpec {
306        preset: PageTransition::None,
307        timing: Timing::Duration(M3_DEFAULT_DURATION, Curve::EaseInOut),
308    };
309
310    /// A transition with an explicit preset and timing.
311    pub const fn new(preset: PageTransition, timing: Timing) -> Self {
312        TransitionSpec { preset, timing }
313    }
314
315    /// A duration-driven transition with the preset's natural default duration
316    /// and an ease-in-out curve.
317    pub fn duration(preset: PageTransition) -> Self {
318        let d = match preset {
319            PageTransition::IosPush => IOS_DEFAULT_DURATION,
320            // SlideUp is a Material-family surface (a bottom sheet), not an iOS
321            // one, so it follows the same M3 "long2" 300ms default the other
322            // two Material presets use here — a duration-mode default, not a
323            // theme spring, purely to keep this table uniform; an app wanting a
324            // bouncier sheet can still opt into `TransitionSpec::spring` with
325            // any `MotionSpring` preset (e.g. the theme's `default_spatial`),
326            // same as the other presets.
327            _ => M3_DEFAULT_DURATION,
328        };
329        TransitionSpec {
330            preset,
331            timing: Timing::Duration(d, Curve::EaseInOut),
332        }
333    }
334
335    /// A spring-driven transition using a theme [`MotionSpring`] preset (use a
336    /// *spatial* preset for a visible overshoot, an *effects* preset for none).
337    pub fn spring(preset: PageTransition, spring: MotionSpring) -> Self {
338        TransitionSpec {
339            preset,
340            timing: Timing::Spring(spring),
341        }
342    }
343
344    /// A theme-timed transition: the preset's timing is resolved from the active
345    /// [`MotionScheme`] (via [`resolve_spec`]) on the transition's first paint,
346    /// honoring `reduce_motion`. The idiomatic constructor for
347    /// [`PageTransition::Glyph`].
348    pub const fn themed(preset: PageTransition) -> Self {
349        TransitionSpec {
350            preset,
351            timing: Timing::ThemeDefault,
352        }
353    }
354
355    /// The Glyph screen transition with theme-resolved
356    /// timing — shorthand for
357    /// [`TransitionSpec::themed`]`(`[`PageTransition::Glyph`]`)`.
358    pub const fn glyph() -> Self {
359        Self::themed(PageTransition::Glyph)
360    }
361
362    /// Whether this spec animates at all (`false` for [`PageTransition::None`]).
363    pub fn is_animated(&self) -> bool {
364        self.preset != PageTransition::None
365    }
366}
367
368// --- Published transition snapshot ------------------------------------------
369
370/// A snapshot of the navigator's single in-flight page transition, published by
371/// [`NavigatorWidget`](super::navigator::NavigatorWidget) and read through
372/// [`NavigatorController::transition`](super::navigator::NavigatorController::transition).
373///
374/// Plain `Copy` data — `Send + Sync` **by construction** (every field is a
375/// primitive), so an app may mirror it into an `RwSignal`, hand it across
376/// `provide_context`, or read it directly. `frust-widgets` stays reactive-free:
377/// the navigator publishes this into a plain `Rc<Cell<TransitionState>>`, exactly
378/// as it publishes [`depth`](super::navigator::NavigatorController::depth); any
379/// signal bridging is the facade's job.
380///
381/// # Timing
382///
383/// The full read-timing contract (which pass sees an exact value and which sees
384/// a one-frame-stale one) is documented on
385/// [`NavigatorController::transition`](super::navigator::NavigatorController::transition)
386/// — read it before choreographing anything against `progress`.
387#[derive(Clone, Copy, Debug, PartialEq)]
388pub struct TransitionState {
389    /// Whether a page transition is in flight right now.
390    pub active: bool,
391    /// The RAW driver value, `0.0` → `1.0`. A spatial spring genuinely
392    /// overshoots past `1.0` (see the module docs' overshoot note) — use
393    /// [`clamped`](Self::clamped) for anything driving opacity.
394    pub progress: f64,
395    /// `true` when the transition runs *backwards* (a pop or an interactive
396    /// edge-swipe back); `false` for a push/replace.
397    pub is_pop: bool,
398    /// An interactive edge-swipe is holding the progress (the drag pins it
399    /// between frames rather than a driver advancing it). Cleared when the
400    /// swipe is released into its settle spring.
401    pub interactive: bool,
402    /// The page-stack depth the transition is leaving.
403    pub from_depth: usize,
404    /// The page-stack depth the transition is arriving at. Always the navigator's
405    /// *current* `pages.len()` — a push/pop/replace mutates the stack up front and
406    /// animates afterwards, so the stack is the destination from the first frame.
407    pub to_depth: usize,
408    /// Bumped once per transition started. Distinguishes "the same transition,
409    /// later" from "a new transition at the same progress" — the discriminator a
410    /// chrome observer needs to reset its own per-transition state. Wraps.
411    pub generation: u32,
412}
413
414impl TransitionState {
415    /// The at-rest snapshot for a settled stack of `depth` pages: nothing in
416    /// flight, `from_depth == to_depth == depth`.
417    ///
418    /// `progress` is `1.0` — "fully arrived". With `from_depth == to_depth` the
419    /// value is degenerate (both endpoints are the same stack), so a reader that
420    /// ignores [`active`](Self::active) still sees the destination rather than a
421    /// jump back to the origin.
422    pub const fn settled(depth: usize, generation: u32) -> Self {
423        TransitionState {
424            active: false,
425            progress: 1.0,
426            is_pop: false,
427            interactive: false,
428            from_depth: depth,
429            to_depth: depth,
430            generation,
431        }
432    }
433
434    /// [`progress`](Self::progress) clamped to `[0.0, 1.0]` — the value to drive
435    /// opacity (or any other bounded quantity) with, since a spatial spring's raw
436    /// progress overshoots.
437    pub fn clamped(&self) -> f64 {
438        self.progress.clamp(0.0, 1.0)
439    }
440}
441
442impl Default for TransitionState {
443    /// The at-rest snapshot of an empty stack — what a
444    /// [`NavigatorController`](super::navigator::NavigatorController) reads before
445    /// any navigator attaches to it.
446    fn default() -> Self {
447        Self::settled(0, 0)
448    }
449}
450
451// Compile-time proof of the property the whole seam rests on: the published
452// snapshot is `Send + Sync` BY CONSTRUCTION, so it can ride `provide_context`
453// (which requires `T: Send + Sync`) or be mirrored into an `RwSignal` — unlike
454// the `Rc`-backed `NavigatorController` that hands it out.
455const _: fn() = || {
456    fn assert_send_sync<T: Send + Sync + 'static>() {}
457    assert_send_sync::<TransitionState>();
458};
459
460// --- Progress driver --------------------------------------------------------
461
462/// The result of advancing a [`TransitionDriver`] one frame.
463#[derive(Clone, Copy, Debug)]
464pub struct Advance {
465    /// The progress value this frame (may exceed `[0, 1]` mid-overshoot for a
466    /// spatial spring).
467    pub value: f64,
468    /// Whether the driver is still moving (the caller should request another
469    /// frame).
470    pub animating: bool,
471    /// Whether the driver has reached its resting target this frame (the
472    /// navigator finalizes the transition on the next rebuild).
473    pub done: bool,
474}
475
476/// Drives a transition's `0.0..=1.0` progress. The programmatic push/pop path
477/// uses [`Auto`](Self::Auto) (an [`AnimationController`] advanced during paint);
478/// the [`Held`](Self::Held)/[`Settle`](Self::Settle) variants are the seam the
479/// interactive edge-swipe gesture drives (`set_progress`/`settle` on the navigator).
480#[derive(Clone, Copy, Debug)]
481pub enum TransitionDriver {
482    /// Programmatic drive: an [`AnimationController`] (duration or spring fling)
483    /// advanced from the frame clock.
484    Auto(AnimationController),
485    /// Externally pinned progress (drag-in-progress): paint reads `value`
486    /// verbatim and never advances; the transition stays alive (paused).
487    Held { value: f64 },
488    /// A released spring settle (fling): an analytic [`Spring`] released
489    /// from the held value toward `target`, advanced by frame-time differencing.
490    Settle {
491        spring: Spring,
492        target: f64,
493        elapsed: f64,
494        last: Option<FrameTime>,
495    },
496}
497
498impl TransitionDriver {
499    /// The current progress value (raw — may overshoot for a spatial spring).
500    pub fn value(&self) -> f64 {
501        match self {
502            TransitionDriver::Auto(c) => c.value(),
503            TransitionDriver::Held { value } => *value,
504            TransitionDriver::Settle {
505                spring,
506                target,
507                elapsed,
508                ..
509            } => target + spring.position(*elapsed),
510        }
511    }
512
513    /// Advance to frame time `now`, returning this frame's [`Advance`].
514    pub fn advance(&mut self, now: FrameTime) -> Advance {
515        match self {
516            TransitionDriver::Auto(c) => {
517                let animating = c.advance(now);
518                Advance {
519                    value: c.value(),
520                    animating,
521                    done: !animating,
522                }
523            }
524            TransitionDriver::Held { value } => Advance {
525                value: *value,
526                animating: false,
527                done: false,
528            },
529            TransitionDriver::Settle {
530                spring,
531                target,
532                elapsed,
533                last,
534            } => {
535                let dt = match *last {
536                    Some(prev) => now.saturating_sub(prev).as_secs_f64(),
537                    None => 0.0,
538                };
539                *last = Some(now);
540                *elapsed += dt.max(0.0);
541                let e = *elapsed;
542                if spring.is_at_rest(e, 1e-3) {
543                    Advance {
544                        value: *target,
545                        animating: false,
546                        done: true,
547                    }
548                } else {
549                    Advance {
550                        value: *target + spring.position(e),
551                        animating: true,
552                        done: false,
553                    }
554                }
555            }
556        }
557    }
558}
559
560/// Build a fresh [`TransitionDriver`] (already running `0 → 1`) plus the spring
561/// to use for a later manual settle, from a [`Timing`].
562pub fn make_driver(timing: Timing) -> (TransitionDriver, SpringDesc) {
563    match timing {
564        Timing::Duration(d, curve) => {
565            let mut c = AnimationController::new(d).with_curve(curve);
566            c.forward();
567            (TransitionDriver::Auto(c), DEFAULT_SETTLE_SPRING)
568        }
569        Timing::Spring(spring) => {
570            let desc: SpringDesc = spring.into();
571            // Duration is irrelevant for a fling; the controller starts at 0 and
572            // flings toward 1 with zero release velocity (a spatial preset still
573            // overshoots — that is the point of a bouncy transition).
574            let mut c = AnimationController::new(M3_DEFAULT_DURATION);
575            c.fling(0.0, desc);
576            (TransitionDriver::Auto(c), desc)
577        }
578        Timing::ThemeDefault => {
579            // Normally pre-resolved by `resolve_spec` against the active theme
580            // before the driver is built; an unresolved `ThemeDefault` reaching
581            // here (no theme threaded) falls back to the M3 default duration +
582            // emphasized easing.
583            make_driver(Timing::Duration(M3_DEFAULT_DURATION, Curve::Emphasized))
584        }
585    }
586}
587
588/// The (enter, exit) driver [`Timing`]s a directional preset resolves to from
589/// the active `scheme` (or the unthemed fallback constants when `None`).
590///
591/// `enter` is the longer *spatial* motion (new content arriving); `exit` is the
592/// faster accelerate-out (old content leaving) — Glyph's "exits always faster
593/// than entrances" rule. For [`PageTransition::Glyph`] these are
594/// the theme's `slow`/`fast` durations with the `spatial`/`exit` easings
595/// (340ms spatial in / 150ms exit out); every other
596/// preset reuses its natural [`TransitionSpec::duration`] timing for both,
597/// including [`PageTransition::Custom`], which has no theme tokens of its own
598/// and so falls back to the documented M3 default (300ms +
599/// [`Curve::Emphasized`], matching [`make_driver`]'s unthemed `ThemeDefault`
600/// fallback).
601pub fn preset_enter_exit(
602    preset: PageTransition,
603    scheme: Option<&MotionScheme>,
604) -> (Timing, Timing) {
605    match preset {
606        PageTransition::Glyph => match scheme {
607            Some(s) => (
608                Timing::Duration(
609                    Duration::from_secs_f64(s.durations.slow / 1000.0),
610                    s.easing.spatial,
611                ),
612                Timing::Duration(
613                    Duration::from_secs_f64(s.durations.fast / 1000.0),
614                    s.easing.exit,
615                ),
616            ),
617            None => (
618                Timing::Duration(GLYPH_ENTER, GLYPH_SPATIAL_CURVE),
619                Timing::Duration(GLYPH_EXIT, GLYPH_EXIT_CURVE),
620            ),
621        },
622        PageTransition::Custom(_) => {
623            // No theme tokens of its own: the documented M3 default fallback,
624            // the same one `make_driver` uses for an unresolved `ThemeDefault`.
625            let d = Timing::Duration(M3_DEFAULT_DURATION, Curve::Emphasized);
626            (d, d)
627        }
628        other => {
629            let d = TransitionSpec::duration(other).timing;
630            (d, d)
631        }
632    }
633}
634
635/// Resolve a spec's [`Timing`] into the concrete timing the driver runs, given
636/// the preset and the active `scheme`. A [`Timing::ThemeDefault`] becomes the
637/// preset's *enter* timing ([`preset_enter_exit`]`.0`) — the dominant, longer
638/// motion the single progress driver runs; the faster exit is expressed by the
639/// geometry's cross-fade split ([`GLYPH_FADE_SPLIT`]). An explicit
640/// `Duration`/`Spring` passes through unchanged. `reduce_motion` collapse is
641/// applied at the spec level by [`resolve_spec`], not here.
642pub fn resolve_timing(
643    timing: Timing,
644    preset: PageTransition,
645    scheme: Option<&MotionScheme>,
646) -> Timing {
647    match timing {
648        Timing::ThemeDefault => preset_enter_exit(preset, scheme).0,
649        other => other,
650    }
651}
652
653/// Resolve a [`TransitionSpec`] against the active `scheme` into the concrete
654/// spec the navigator drives:
655///
656/// - `scheme.reduce_motion == true` collapses *any* animated preset to a
657///   `≤120ms` linear cross-fade ([`PageTransition::ReducedCrossfade`] driven by
658///   [`REDUCE_MOTION_DURATION`] + [`Curve::Linear`] — pure alpha, no slide or
659///   scale) — the Glyph design system's hard accessibility rule. A non-animated
660///   ([`PageTransition::None`]) spec is left untouched.
661/// - otherwise a [`Timing::ThemeDefault`] is resolved to the preset's theme
662///   timing ([`resolve_timing`]); the preset is unchanged.
663pub fn resolve_spec(spec: TransitionSpec, scheme: Option<&MotionScheme>) -> TransitionSpec {
664    if spec.is_animated() && scheme.map(|s| s.reduce_motion).unwrap_or(false) {
665        return TransitionSpec {
666            preset: PageTransition::ReducedCrossfade,
667            timing: Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear),
668        };
669    }
670    TransitionSpec {
671        preset: spec.preset,
672        timing: resolve_timing(spec.timing, spec.preset, scheme),
673    }
674}
675
676/// Build a [`TransitionDriver::Settle`] that springs from `from` toward `target`
677/// with initial `velocity` — the navigator's `settle(velocity)` seam.
678pub fn settle_driver(
679    spring: SpringDesc,
680    from: f64,
681    velocity: f64,
682    target: f64,
683) -> TransitionDriver {
684    TransitionDriver::Settle {
685        spring: Spring::new(spring, from - target, velocity),
686        target,
687        elapsed: 0.0,
688        last: None,
689    }
690}
691
692// --- Geometry ---------------------------------------------------------------
693
694/// Per-page paint parameters for one frame of a transition: a paint offset
695/// (applied to the page's pod origin) and an opacity.
696#[derive(Clone, Copy, Debug, PartialEq)]
697pub struct Layer {
698    /// Horizontal paint offset in logical px (added to the page's pod origin).
699    pub dx: f64,
700    /// Vertical paint offset in logical px (added to the page's pod origin).
701    /// Every preset before [`PageTransition::SlideUp`] is horizontal-only and
702    /// leaves this at `0.0`.
703    pub dy: f64,
704    /// Opacity in `[0, 1]` (composited via `PaintScene::push_layer`).
705    pub alpha: f32,
706    /// Uniform scale factor about the page's paint area, composited via
707    /// [`PaintScene::push_transform`](frust_core::PaintScene::push_transform).
708    /// `1.0` for every preset except [`PageTransition::M3FadeThrough`], whose
709    /// incoming page scales [`M3_FADE_THROUGH_SCALE_START`]`→1.0` as it fades in.
710    /// The navigator brackets the page's paint with the scale
711    /// transform when this differs from `1.0`.
712    pub scale: f64,
713}
714
715impl Layer {
716    /// A fully-visible, un-offset, un-scaled layer.
717    pub const IDENTITY: Layer = Layer {
718        dx: 0.0,
719        dy: 0.0,
720        alpha: 1.0,
721        scale: 1.0,
722    };
723}
724
725/// Resolve the (entering, leaving) [`Layer`]s for a transition preset at progress
726/// `value`.
727///
728/// - `entering` is the page that becomes top after the op (a push's new page, or
729///   a pop's revealed page); `leaving` is the page losing the top.
730/// - `value` is raw (used for **position**, so a spatial spring's overshoot shows
731///   in the slide); opacity uses the `[0, 1]`-clamped value.
732/// - `is_pop` reverses the horizontal direction (a pop slides the opposite way).
733pub fn resolve_layers(
734    preset: PageTransition,
735    value: f64,
736    is_pop: bool,
737    size: Size,
738) -> (Layer, Layer) {
739    let p = value; // raw (overshoot allowed) — used for position
740    let pc = value.clamp(0.0, 1.0); // clamped — used for opacity
741    let w = size.width;
742
743    match preset {
744        PageTransition::None => (Layer::IDENTITY, Layer::IDENTITY),
745
746        PageTransition::ReducedCrossfade => {
747            // reduce_motion collapse target: pure alpha cross-fade — by
748            // contract NO geometric motion (dx/dy 0, scale 1.0) so a
749            // reduced-motion user never sees a zoom or slide.
750            let entering = Layer {
751                dx: 0.0,
752                dy: 0.0,
753                alpha: pc as f32,
754                scale: 1.0,
755            };
756            let leaving = Layer {
757                dx: 0.0,
758                dy: 0.0,
759                alpha: (1.0 - pc) as f32,
760                scale: 1.0,
761            };
762            (entering, leaving)
763        }
764
765        PageTransition::M3SharedAxisX => {
766            let slide = M3_SHARED_AXIS_SLIDE_DP;
767            // Push: entering enters from +30dp; pop: from -30dp (mirror).
768            let dir = if is_pop { -1.0 } else { 1.0 };
769            let entering = Layer {
770                dx: dir * (1.0 - p) * slide,
771                dy: 0.0,
772                alpha: ramp(pc, M3_FADE_SPLIT, 1.0),
773                scale: 1.0,
774            };
775            let leaving = Layer {
776                dx: -dir * p * slide,
777                dy: 0.0,
778                alpha: 1.0 - ramp(pc, 0.0, M3_FADE_SPLIT),
779                scale: 1.0,
780            };
781            (entering, leaving)
782        }
783
784        PageTransition::M3FadeThrough => {
785            // Verified Flutter `FadeThroughTransition` staging:
786            // the outgoing page fades 1→0 over the first 6/20 of the timeline
787            // (`Cubic(0.4,0,1,1)`) then holds; the incoming page holds at
788            // `M3_FADE_THROUGH_SCALE_START` scale / 0 opacity for that 6/20,
789            // then fades in AND scales to 1.0 over the remaining 14/20
790            // (`Cubic(0,0,0.2,1)`) — opacity and scale track one shared segment.
791            let out = M3_FADE_THROUGH_OUT_CURVE.interval(0.0, M3_FADE_THROUGH_SPLIT);
792            let inc = M3_FADE_THROUGH_IN_CURVE.interval(M3_FADE_THROUGH_SPLIT, 1.0);
793            let in_progress = inc.transform(pc);
794            let entering = Layer {
795                dx: 0.0,
796                dy: 0.0,
797                alpha: in_progress as f32,
798                scale: M3_FADE_THROUGH_SCALE_START
799                    + (1.0 - M3_FADE_THROUGH_SCALE_START) * in_progress,
800            };
801            let leaving = Layer {
802                dx: 0.0,
803                dy: 0.0,
804                alpha: (1.0 - out.transform(pc)) as f32,
805                scale: 1.0,
806            };
807            (entering, leaving)
808        }
809
810        PageTransition::Glyph => {
811            // Directional 16px slide + cross-fade.
812            // Push: entering enters from +16px; pop: from -16px (back reverses).
813            // The leaving page completes its fade by `GLYPH_FADE_SPLIT`, encoding
814            // "exits always faster than entrances" in the single-progress geometry;
815            // the theme-resolved enter/exit *durations* live in `preset_enter_exit`.
816            let slide = GLYPH_SLIDE_DP;
817            let dir = if is_pop { -1.0 } else { 1.0 };
818            let entering = Layer {
819                dx: dir * (1.0 - p) * slide,
820                dy: 0.0,
821                alpha: ramp(pc, GLYPH_FADE_SPLIT, 1.0),
822                scale: 1.0,
823            };
824            let leaving = Layer {
825                dx: -dir * p * slide,
826                dy: 0.0,
827                alpha: 1.0 - ramp(pc, 0.0, GLYPH_FADE_SPLIT),
828                scale: 1.0,
829            };
830            (entering, leaving)
831        }
832
833        PageTransition::IosPush => {
834            if is_pop {
835                // Revealed page slides back from -parallax to 0; popped page
836                // slides fully off to the right.
837                let entering = Layer {
838                    dx: -(1.0 - p) * w * IOS_PARALLAX_FRACTION,
839                    dy: 0.0,
840                    alpha: 1.0,
841                    scale: 1.0,
842                };
843                let leaving = Layer {
844                    dx: p * w,
845                    dy: 0.0,
846                    alpha: 1.0,
847                    scale: 1.0,
848                };
849                (entering, leaving)
850            } else {
851                // Incoming slides full-width from the right; below page
852                // parallaxes left and dims.
853                let entering = Layer {
854                    dx: (1.0 - p) * w,
855                    dy: 0.0,
856                    alpha: 1.0,
857                    scale: 1.0,
858                };
859                let leaving = Layer {
860                    dx: -p * w * IOS_PARALLAX_FRACTION,
861                    dy: 0.0,
862                    alpha: 1.0 - pc as f32 * IOS_DIM_MAX,
863                    scale: 1.0,
864                };
865                (entering, leaving)
866            }
867        }
868
869        PageTransition::SlideUp => {
870            let h = size.height;
871            if is_pop {
872                // The revealed page below never moved while covered (see the
873                // enum docs) — it stays at rest, full opacity, the whole time.
874                // The popped sheet (leaving) slides from rest back down and out.
875                let entering = Layer {
876                    dx: 0.0,
877                    dy: 0.0,
878                    alpha: 1.0,
879                    scale: 1.0,
880                };
881                let leaving = Layer {
882                    dx: 0.0,
883                    dy: p * h,
884                    alpha: 1.0,
885                    scale: 1.0,
886                };
887                (entering, leaving)
888            } else {
889                // The entering sheet slides up from the bottom (full height
890                // offset) to rest; the page below stays static and fully
891                // opaque throughout (no parallax/dim, unlike `IosPush`).
892                let entering = Layer {
893                    dx: 0.0,
894                    dy: (1.0 - p) * h,
895                    alpha: 1.0,
896                    scale: 1.0,
897                };
898                let leaving = Layer {
899                    dx: 0.0,
900                    dy: 0.0,
901                    alpha: 1.0,
902                    scale: 1.0,
903                };
904                (entering, leaving)
905            }
906        }
907
908        PageTransition::Custom(f) => f(p, is_pop, size),
909    }
910}
911
912// --- Shared-element ("hero") morph geometry -----------------------
913
914/// Linearly interpolate two rects — origin and size independently — at `t`.
915///
916/// The shared-element ("hero") morph interpolates a tagged element's source
917/// rect (its rest position on the outgoing page) toward its destination rect
918/// (its rest position on the incoming page) each transition frame. The caller
919/// clamps `t` to `[0, 1]` when a well-defined (non-negative-extent) rect is
920/// required — a spatial-spring overshoot past `1.0` would otherwise flip a
921/// dimension.
922pub fn lerp_rect(from: Rect, to: Rect, t: f64) -> Rect {
923    let lerp = |a: f64, b: f64| a + (b - a) * t;
924    Rect::from_origin_size(
925        Point::new(lerp(from.x0, to.x0), lerp(from.y0, to.y0)),
926        Size::new(
927            lerp(from.width(), to.width()),
928            lerp(from.height(), to.height()),
929        ),
930    )
931}
932
933/// The affine transform mapping rect `from` onto rect `to`: a translation plus
934/// a non-uniform scale taken about `from`'s top-left, so `from`'s corners land
935/// exactly on `to`'s.
936///
937/// A hero wrapper pushes this ([`PaintScene::push_transform`](frust_core::PaintScene::push_transform))
938/// to repaint its retained subtree at the interpolated morph rect — position
939/// **and** scale, the real morph the pure origin-offset seam every other paint
940/// call uses cannot express. A zero-extent `from` on an axis degenerates to an
941/// identity scale on that axis (no division by zero).
942pub fn rect_to_rect(from: Rect, to: Rect) -> Affine {
943    let sx = if from.width().abs() > f64::EPSILON {
944        to.width() / from.width()
945    } else {
946        1.0
947    };
948    let sy = if from.height().abs() > f64::EPSILON {
949        to.height() / from.height()
950    } else {
951        1.0
952    };
953    Affine::translate((to.x0, to.y0))
954        * Affine::scale_non_uniform(sx, sy)
955        * Affine::translate((-from.x0, -from.y0))
956}
957
958/// A linear ramp: `0` at `start`, `1` at `end`, clamped outside. Used for the
959/// threshold cross-fades. `start == end` degenerates to a step at `start`.
960fn ramp(t: f64, start: f64, end: f64) -> f32 {
961    if end <= start {
962        return if t >= start { 1.0 } else { 0.0 };
963    }
964    (((t - start) / (end - start)).clamp(0.0, 1.0)) as f32
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970
971    fn ft_secs(s: f64) -> FrameTime {
972        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
973    }
974
975    const SIZE: Size = Size::new(400.0, 800.0);
976
977    #[test]
978    fn transition_state_settled_is_at_rest_on_one_depth() {
979        let s = TransitionState::settled(3, 7);
980        assert!(!s.active);
981        assert!(!s.is_pop);
982        assert!(!s.interactive);
983        assert_eq!((s.from_depth, s.to_depth), (3, 3));
984        assert_eq!(s.generation, 7);
985        assert_eq!(s.progress, 1.0, "settled means fully arrived");
986        assert_eq!(
987            TransitionState::default(),
988            TransitionState::settled(0, 0),
989            "the default is an at-rest empty stack (no navigator attached)"
990        );
991    }
992
993    #[test]
994    fn transition_state_clamped_bounds_a_spring_overshoot() {
995        // A spatial spring genuinely overshoots; `progress` keeps the raw value
996        // (so a slide visibly springs past rest) and `clamped` is what drives
997        // opacity.
998        let mut s = TransitionState::settled(1, 0);
999        s.progress = 1.08;
1000        assert_eq!(s.clamped(), 1.0);
1001        s.progress = -0.04;
1002        assert_eq!(s.clamped(), 0.0);
1003        s.progress = 0.42;
1004        assert_eq!(s.clamped(), 0.42);
1005    }
1006
1007    #[test]
1008    fn ramp_is_clamped_linear() {
1009        assert_eq!(ramp(0.0, 0.35, 1.0), 0.0);
1010        assert_eq!(ramp(0.35, 0.35, 1.0), 0.0);
1011        assert_eq!(ramp(1.0, 0.35, 1.0), 1.0);
1012        assert!((ramp(0.675, 0.35, 1.0) - 0.5).abs() < 1e-6);
1013        // Degenerate window → step.
1014        assert_eq!(ramp(0.1, 0.5, 0.5), 0.0);
1015        assert_eq!(ramp(0.9, 0.5, 0.5), 1.0);
1016    }
1017
1018    #[test]
1019    fn shared_axis_slides_and_crossfades_forward() {
1020        // At the start, entering is offset by the full slide and invisible;
1021        // leaving is at rest and fully opaque.
1022        let (enter, leave) = resolve_layers(PageTransition::M3SharedAxisX, 0.0, false, SIZE);
1023        assert_eq!(enter.dx, M3_SHARED_AXIS_SLIDE_DP);
1024        assert_eq!(enter.alpha, 0.0);
1025        assert_eq!(leave.dx, 0.0);
1026        assert_eq!(leave.alpha, 1.0);
1027
1028        // At the end, entering rests at 0 and is fully opaque; leaving is fully
1029        // slid out and transparent.
1030        let (enter, leave) = resolve_layers(PageTransition::M3SharedAxisX, 1.0, false, SIZE);
1031        assert_eq!(enter.dx, 0.0);
1032        assert_eq!(enter.alpha, 1.0);
1033        assert_eq!(leave.dx, -M3_SHARED_AXIS_SLIDE_DP);
1034        assert_eq!(leave.alpha, 0.0);
1035    }
1036
1037    #[test]
1038    fn shared_axis_pop_mirrors_direction() {
1039        let (enter, _leave) = resolve_layers(PageTransition::M3SharedAxisX, 0.0, true, SIZE);
1040        // Pop: entering comes from the *left* (negative offset).
1041        assert_eq!(enter.dx, -M3_SHARED_AXIS_SLIDE_DP);
1042    }
1043
1044    #[test]
1045    fn ios_push_incoming_full_width_and_outgoing_parallax() {
1046        let (enter, leave) = resolve_layers(PageTransition::IosPush, 0.0, false, SIZE);
1047        // Incoming starts one full width to the right; below page at rest.
1048        assert_eq!(enter.dx, SIZE.width);
1049        assert_eq!(leave.dx, 0.0);
1050
1051        let (enter, leave) = resolve_layers(PageTransition::IosPush, 1.0, false, SIZE);
1052        assert_eq!(enter.dx, 0.0);
1053        // Below page parallaxes by 1/3 width and is dimmed.
1054        assert!((leave.dx + SIZE.width * IOS_PARALLAX_FRACTION).abs() < 1e-9);
1055        assert!(leave.alpha < 1.0);
1056    }
1057
1058    #[test]
1059    fn fade_through_has_no_horizontal_slide() {
1060        // Fade-through is a staged fade + incoming scale-up (verified Flutter
1061        // `FadeThroughTransition` staging), never a slide: both
1062        // pages stay horizontally at rest at every progress.
1063        let (enter, leave) = resolve_layers(PageTransition::M3FadeThrough, 0.5, false, SIZE);
1064        assert_eq!(enter.dx, 0.0);
1065        assert_eq!(leave.dx, 0.0);
1066    }
1067
1068    #[test]
1069    fn fade_through_staged_opacity_and_scale_table() {
1070        // Verified Flutter `FadeThroughTransition` staging:
1071        // outgoing fades 1→0 over the first 6/20 (`Cubic(0.4,0,1,1)`) then holds;
1072        // incoming holds at 0.92 scale / 0 opacity for 6/20 then fades in AND
1073        // scales to 1.0 over the remaining 14/20 (`Cubic(0,0,0.2,1)`).
1074        // Split = 6/20 = 0.30. Table at t ∈ {0, 0.3, 0.65, 1.0} for BOTH pages.
1075        let ft = PageTransition::M3FadeThrough;
1076
1077        // t = 0: outgoing fully opaque at rest scale; incoming invisible at 0.92.
1078        let (enter, leave) = resolve_layers(ft, 0.0, false, SIZE);
1079        assert_eq!(leave.alpha, 1.0);
1080        assert_eq!(leave.scale, 1.0);
1081        assert_eq!(enter.alpha, 0.0);
1082        assert!((enter.scale - 0.92).abs() < 1e-9);
1083
1084        // t = 0.30 (the 6/20 split): outgoing has just finished fading (0);
1085        // incoming's window opens here — still 0 opacity / 0.92 scale.
1086        let (enter, leave) = resolve_layers(ft, 0.30, false, SIZE);
1087        assert!(leave.alpha.abs() < 1e-6, "outgoing gone by the split");
1088        assert_eq!(enter.alpha, 0.0, "incoming fade-in opens at the split");
1089        assert!((enter.scale - 0.92).abs() < 1e-9);
1090
1091        // t = 0.65: outgoing long gone; incoming mid fade-in. Local progress into
1092        // the [0.30, 1.0] segment is (0.65-0.30)/0.70 = 0.5, eased by
1093        // `Cubic(0,0,0.2,1)`. Opacity and scale track that one eased value.
1094        let (enter, leave) = resolve_layers(ft, 0.65, false, SIZE);
1095        assert_eq!(leave.alpha, 0.0);
1096        let eased = Curve::Cubic(0.0, 0.0, 0.2, 1.0).transform(0.5);
1097        assert!((enter.alpha as f64 - eased).abs() < 1e-6);
1098        assert!((enter.scale - (0.92 + 0.08 * eased)).abs() < 1e-9);
1099        // Flutter staging sanity: ≈0.84 opacity / ≈0.987 scale at this point.
1100        assert!((enter.alpha as f64 - 0.839).abs() < 2e-2);
1101        assert!((enter.scale - 0.987).abs() < 2e-2);
1102
1103        // t = 1.0: incoming fully arrived (opaque, unit scale); outgoing gone.
1104        let (enter, leave) = resolve_layers(ft, 1.0, false, SIZE);
1105        assert_eq!(enter.alpha, 1.0);
1106        assert!((enter.scale - 1.0).abs() < 1e-9);
1107        assert_eq!(leave.alpha, 0.0);
1108        assert_eq!(leave.scale, 1.0);
1109    }
1110
1111    #[test]
1112    fn glyph_slides_directionally_and_crossfades() {
1113        // A 16px directional slide + cross-fade, no scale.
1114        let g = PageTransition::Glyph;
1115        // Push start: entering offset by the full slide, invisible; leaving at rest.
1116        let (enter, leave) = resolve_layers(g, 0.0, false, SIZE);
1117        assert_eq!(enter.dx, GLYPH_SLIDE_DP);
1118        assert_eq!(enter.alpha, 0.0);
1119        assert_eq!(enter.scale, 1.0);
1120        assert_eq!(leave.dx, 0.0);
1121        assert_eq!(leave.alpha, 1.0);
1122        // Push end: entering at rest, opaque; leaving slid out one slide, transparent.
1123        let (enter, leave) = resolve_layers(g, 1.0, false, SIZE);
1124        assert_eq!(enter.dx, 0.0);
1125        assert_eq!(enter.alpha, 1.0);
1126        assert_eq!(leave.dx, -GLYPH_SLIDE_DP);
1127        assert_eq!(leave.alpha, 0.0);
1128    }
1129
1130    #[test]
1131    fn glyph_pop_mirrors_push_direction() {
1132        // Back reverses direction: entering comes from
1133        // the left (negative offset), the popped page slides right.
1134        let (enter, _leave) = resolve_layers(PageTransition::Glyph, 0.0, true, SIZE);
1135        assert_eq!(enter.dx, -GLYPH_SLIDE_DP);
1136        let (_enter, leave) = resolve_layers(PageTransition::Glyph, 1.0, true, SIZE);
1137        assert_eq!(leave.dx, GLYPH_SLIDE_DP);
1138    }
1139
1140    #[test]
1141    fn glyph_enter_exit_durations_unthemed_fallback() {
1142        // Unthemed fallback = the Glyph screen-transition authored values.
1143        let (enter, exit) = preset_enter_exit(PageTransition::Glyph, None);
1144        assert_eq!(enter, Timing::Duration(GLYPH_ENTER, GLYPH_SPATIAL_CURVE));
1145        assert_eq!(exit, Timing::Duration(GLYPH_EXIT, GLYPH_EXIT_CURVE));
1146        let (Timing::Duration(de, _), Timing::Duration(dx, _)) = (enter, exit) else {
1147            panic!("expected duration timings");
1148        };
1149        assert_eq!(de, Duration::from_millis(340));
1150        assert_eq!(dx, Duration::from_millis(150));
1151        assert!(dx < de, "exits always faster than entrances");
1152    }
1153
1154    #[test]
1155    fn glyph_enter_exit_durations_from_theme_scheme() {
1156        // With a scheme, enter/exit pull the slow/fast duration + spatial/exit
1157        // easing tokens.
1158        let m = MotionScheme::neutral();
1159        let (enter, exit) = preset_enter_exit(PageTransition::Glyph, Some(&m));
1160        assert_eq!(
1161            enter,
1162            Timing::Duration(
1163                Duration::from_secs_f64(m.durations.slow / 1000.0),
1164                m.easing.spatial
1165            )
1166        );
1167        assert_eq!(
1168            exit,
1169            Timing::Duration(
1170                Duration::from_secs_f64(m.durations.fast / 1000.0),
1171                m.easing.exit
1172            )
1173        );
1174    }
1175
1176    #[test]
1177    fn theme_default_timing_resolves_to_enter_and_passes_explicit_through() {
1178        // `ThemeDefault` → the preset's enter timing (the driver's dominant motion).
1179        let resolved = resolve_timing(Timing::ThemeDefault, PageTransition::Glyph, None);
1180        assert_eq!(resolved, Timing::Duration(GLYPH_ENTER, GLYPH_SPATIAL_CURVE));
1181        // An explicit timing passes through unchanged.
1182        let explicit = Timing::Duration(Duration::from_millis(200), Curve::Linear);
1183        assert_eq!(
1184            resolve_timing(explicit, PageTransition::Glyph, None),
1185            explicit
1186        );
1187    }
1188
1189    #[test]
1190    fn resolve_spec_resolves_theme_default_when_motion_enabled() {
1191        let m = MotionScheme::neutral(); // reduce_motion == false
1192        let resolved = resolve_spec(TransitionSpec::glyph(), Some(&m));
1193        assert_eq!(resolved.preset, PageTransition::Glyph);
1194        assert_eq!(
1195            resolved.timing,
1196            Timing::Duration(
1197                Duration::from_secs_f64(m.durations.slow / 1000.0),
1198                m.easing.spatial
1199            )
1200        );
1201    }
1202
1203    #[test]
1204    fn reduce_motion_collapses_every_preset_to_crossfade() {
1205        // The Glyph design system's hard rule: reduced motion → ≤120ms linear
1206        // crossfade for every animated pattern.
1207        let mut m = MotionScheme::neutral();
1208        m.reduce_motion = true;
1209        for preset in [
1210            PageTransition::Glyph,
1211            PageTransition::M3SharedAxisX,
1212            PageTransition::IosPush,
1213            PageTransition::SlideUp,
1214            PageTransition::M3FadeThrough,
1215        ] {
1216            let resolved = resolve_spec(TransitionSpec::themed(preset), Some(&m));
1217            assert_eq!(
1218                resolved.preset,
1219                PageTransition::ReducedCrossfade,
1220                "{preset:?} must collapse to the pure alpha crossfade"
1221            );
1222            let Timing::Duration(d, curve) = resolved.timing else {
1223                panic!("reduced-motion must be a duration crossfade");
1224            };
1225            assert!(d <= Duration::from_millis(120), "{preset:?} not ≤120ms");
1226            assert_eq!(curve, Curve::Linear, "{preset:?}");
1227            // The collapse target must carry ZERO geometric motion at every
1228            // progress point — no slide, no scale (the scale-leak trap:
1229            // M3FadeThrough's 0.92→1.0 zoom must not survive into reduced
1230            // motion).
1231            for p in [0.0, 0.25, 0.5, 0.75, 1.0] {
1232                let (entering, leaving) =
1233                    resolve_layers(resolved.preset, p, false, Size::new(100.0, 100.0));
1234                for (label, l) in [("entering", entering), ("leaving", leaving)] {
1235                    assert_eq!((l.dx, l.dy), (0.0, 0.0), "{label} slid at p={p}");
1236                    assert_eq!(l.scale, 1.0, "{label} scaled at p={p}");
1237                }
1238            }
1239        }
1240        // A non-animated (`None`) spec is left untouched under reduce_motion.
1241        let none = resolve_spec(TransitionSpec::NONE, Some(&m));
1242        assert_eq!(none.preset, PageTransition::None);
1243    }
1244
1245    // A distinctive custom preset used by the `Custom` tests below: a vertical
1246    // slide (dy only) with no cross-fade, so its output is trivially
1247    // distinguishable from every built-in preset's geometry.
1248    fn custom_vertical_slide(p: f64, is_pop: bool, size: Size) -> (Layer, Layer) {
1249        let dir = if is_pop { -1.0 } else { 1.0 };
1250        let entering = Layer {
1251            dx: 0.0,
1252            dy: dir * (1.0 - p) * size.height,
1253            alpha: 1.0,
1254            scale: 1.0,
1255        };
1256        let leaving = Layer::IDENTITY;
1257        (entering, leaving)
1258    }
1259
1260    #[test]
1261    fn custom_preset_drives_caller_supplied_layers() {
1262        let (enter, leave) = resolve_layers(
1263            PageTransition::Custom(custom_vertical_slide),
1264            0.5,
1265            false,
1266            SIZE,
1267        );
1268        // The caller's geometry comes through unmodified — no clamping or
1269        // post-processing beyond what every preset gets.
1270        let (expected_enter, expected_leave) = custom_vertical_slide(0.5, false, SIZE);
1271        assert_eq!(enter, expected_enter);
1272        assert_eq!(leave, expected_leave);
1273        assert_eq!(enter.dy, 0.5 * SIZE.height);
1274        assert_eq!(leave, Layer::IDENTITY);
1275
1276        // Raw (unclamped) progress passes through verbatim too — an overshoot
1277        // past 1.0 is visible in the caller's output, exactly like every
1278        // built-in preset's position calculation.
1279        let (enter, _leave) = resolve_layers(
1280            PageTransition::Custom(custom_vertical_slide),
1281            1.2,
1282            false,
1283            SIZE,
1284        );
1285        assert!((enter.dy - (-0.2 * SIZE.height)).abs() < 1e-9);
1286    }
1287
1288    #[test]
1289    fn custom_preset_falls_back_to_m3_timing_under_theme_default() {
1290        // `Custom` has no theme tokens of its own: `preset_enter_exit` and
1291        // `resolve_timing` both fall back to the documented M3 default
1292        // (300ms + `Curve::Emphasized`) — the same fallback `make_driver` uses
1293        // for an unresolved `ThemeDefault` — regardless of whether a theme is
1294        // threaded.
1295        let expected = Timing::Duration(M3_DEFAULT_DURATION, Curve::Emphasized);
1296
1297        let (enter, exit) = preset_enter_exit(PageTransition::Custom(custom_vertical_slide), None);
1298        assert_eq!(enter, expected);
1299        assert_eq!(exit, expected);
1300
1301        let m = MotionScheme::neutral();
1302        let (enter, exit) =
1303            preset_enter_exit(PageTransition::Custom(custom_vertical_slide), Some(&m));
1304        assert_eq!(enter, expected);
1305        assert_eq!(exit, expected);
1306
1307        let resolved = resolve_timing(
1308            Timing::ThemeDefault,
1309            PageTransition::Custom(custom_vertical_slide),
1310            Some(&m),
1311        );
1312        assert_eq!(resolved, expected);
1313    }
1314
1315    #[test]
1316    fn custom_preset_collapses_under_reduce_motion_on_the_resolve_spec_path() {
1317        // This test covers only the `resolve_spec` seam — the path a
1318        // **programmatic** push/pop/replace resolves its timing through
1319        // (see `navigator.rs`'s `paint_transition`, the sole `resolve_spec`
1320        // call site). Under `reduce_motion`, `resolve_spec` collapses
1321        // `Custom` to `ReducedCrossfade` unchanged, so the supplied
1322        // function itself is never called by `resolve_spec`/`resolve_timing`
1323        // (only `resolve_layers` ever calls it, and this path only ever
1324        // calls `resolve_layers` with the *resolved* preset,
1325        // `ReducedCrossfade` here, not `Custom`).
1326        //
1327        // This is NOT a navigator-wide invariant: a user-driven interactive
1328        // edge-swipe pop does not go through `resolve_spec` at all and DOES
1329        // call the supplied function under `reduce_motion` — see
1330        // `navigator.rs`'s
1331        // `interactive_pop_calls_custom_fn_under_reduce_motion`, and
1332        // `Custom`'s doc comment above.
1333        fn panics_if_called(_p: f64, _is_pop: bool, _size: Size) -> (Layer, Layer) {
1334            panic!("Custom's function must not be invoked on the resolve_spec path");
1335        }
1336
1337        let mut m = MotionScheme::neutral();
1338        m.reduce_motion = true;
1339        let resolved = resolve_spec(
1340            TransitionSpec::themed(PageTransition::Custom(panics_if_called)),
1341            Some(&m),
1342        );
1343        assert_eq!(resolved.preset, PageTransition::ReducedCrossfade);
1344        let Timing::Duration(d, curve) = resolved.timing else {
1345            panic!("reduced-motion must be a duration crossfade");
1346        };
1347        assert!(d <= Duration::from_millis(120));
1348        assert_eq!(curve, Curve::Linear);
1349
1350        // Driving the *resolved* spec's layers never touches the caller's
1351        // function (it's no longer part of the resolved preset at all).
1352        let (entering, leaving) = resolve_layers(resolved.preset, 0.5, false, SIZE);
1353        assert_eq!((entering.dx, entering.dy), (0.0, 0.0));
1354        assert_eq!((leaving.dx, leaving.dy), (0.0, 0.0));
1355    }
1356
1357    #[test]
1358    fn make_driver_theme_default_falls_back_when_unresolved() {
1359        // An unresolved `ThemeDefault` (no theme threaded) degrades to the M3
1360        // default duration; it still runs 0→1 like any duration driver.
1361        let (mut driver, _) = make_driver(Timing::ThemeDefault);
1362        let a = driver.advance(ft_secs(0.0));
1363        assert!(a.animating && !a.done);
1364        // Runs to completion past the M3 default duration (300ms).
1365        let a = driver.advance(ft_secs(1.0));
1366        assert!(a.done);
1367        assert_eq!(a.value, 1.0);
1368    }
1369
1370    #[test]
1371    fn slide_up_enters_from_bottom_and_settles() {
1372        // At the start, the entering sheet sits a full height below rest, fully
1373        // visible (opacity is a page-owned scrim concern, not this preset's).
1374        let (enter, leave) = resolve_layers(PageTransition::SlideUp, 0.0, false, SIZE);
1375        assert_eq!(enter.dx, 0.0);
1376        assert_eq!(enter.dy, SIZE.height);
1377        assert_eq!(enter.alpha, 1.0);
1378        // The page below never moves or fades.
1379        assert_eq!(leave.dx, 0.0);
1380        assert_eq!(leave.dy, 0.0);
1381        assert_eq!(leave.alpha, 1.0);
1382
1383        // At the end, the sheet rests at dy = 0; the below page is unchanged.
1384        let (enter, leave) = resolve_layers(PageTransition::SlideUp, 1.0, false, SIZE);
1385        assert_eq!(enter.dy, 0.0);
1386        assert_eq!(enter.alpha, 1.0);
1387        assert_eq!(leave.dy, 0.0);
1388        assert_eq!(leave.alpha, 1.0);
1389    }
1390
1391    #[test]
1392    fn slide_up_pop_reverses_and_never_moves_below_page() {
1393        // Pop: the sheet (leaving) slides back down; the revealed page
1394        // (entering) stays static at rest throughout.
1395        let (enter, leave) = resolve_layers(PageTransition::SlideUp, 0.0, true, SIZE);
1396        assert_eq!(enter.dy, 0.0);
1397        assert_eq!(leave.dy, 0.0);
1398
1399        let (enter, leave) = resolve_layers(PageTransition::SlideUp, 1.0, true, SIZE);
1400        assert_eq!(enter.dy, 0.0, "revealed page never moves");
1401        assert_eq!(
1402            leave.dy, SIZE.height,
1403            "the sheet slides fully off the bottom"
1404        );
1405        assert_eq!(enter.alpha, 1.0);
1406        assert_eq!(leave.alpha, 1.0, "SlideUp never fades a page's own layer");
1407    }
1408
1409    #[test]
1410    fn duration_driver_runs_zero_to_one_and_settles() {
1411        let (mut driver, _) =
1412            make_driver(Timing::Duration(Duration::from_millis(100), Curve::Linear));
1413        // Seed the clock (zero delta).
1414        let a = driver.advance(ft_secs(0.0));
1415        assert!(a.animating && !a.done);
1416        assert!((a.value - 0.0).abs() < 1e-9);
1417        // Halfway.
1418        let a = driver.advance(ft_secs(0.05));
1419        assert!((a.value - 0.5).abs() < 1e-6);
1420        // Past the end: settles at 1.0, reports done.
1421        let a = driver.advance(ft_secs(0.2));
1422        assert!(!a.animating && a.done);
1423        assert_eq!(a.value, 1.0);
1424    }
1425
1426    #[test]
1427    fn spring_spatial_driver_overshoots_past_one() {
1428        // M3 default spatial: damping 0.9 — overshoots.
1429        let spring = MotionSpring {
1430            damping_ratio: 0.9,
1431            stiffness: 700.0,
1432        };
1433        let (mut driver, _) = make_driver(Timing::Spring(spring));
1434        let mut max = f64::MIN;
1435        let mut t = 0.0;
1436        for _ in 0..100_000 {
1437            let a = driver.advance(ft_secs(t));
1438            max = max.max(a.value);
1439            if a.done {
1440                break;
1441            }
1442            t += 1.0 / 120.0;
1443        }
1444        assert!(
1445            max > 1.0 + 1e-3,
1446            "spatial spring should overshoot past 1.0, got {max}"
1447        );
1448    }
1449
1450    #[test]
1451    fn spring_effects_driver_never_overshoots() {
1452        // M3 default effects: damping 1.0 — critically damped, released from
1453        // rest, so it approaches 1.0 monotonically.
1454        let spring = MotionSpring {
1455            damping_ratio: 1.0,
1456            stiffness: 1600.0,
1457        };
1458        let (mut driver, _) = make_driver(Timing::Spring(spring));
1459        let mut t = 0.0;
1460        for _ in 0..100_000 {
1461            let a = driver.advance(ft_secs(t));
1462            assert!(
1463                a.value <= 1.0 + 1e-9,
1464                "effects spring overshot: {}",
1465                a.value
1466            );
1467            if a.done {
1468                break;
1469            }
1470            t += 1.0 / 120.0;
1471        }
1472    }
1473
1474    #[test]
1475    fn held_driver_pauses_without_finishing() {
1476        let mut driver = TransitionDriver::Held { value: 0.4 };
1477        let a = driver.advance(ft_secs(1.0));
1478        assert_eq!(a.value, 0.4);
1479        assert!(!a.animating);
1480        assert!(!a.done, "a held driver never reports done (drag paused)");
1481    }
1482
1483    #[test]
1484    fn lerp_rect_interpolates_origin_and_size_independently() {
1485        let from = Rect::from_origin_size(Point::new(0.0, 0.0), Size::new(20.0, 20.0));
1486        let to = Rect::from_origin_size(Point::new(100.0, 40.0), Size::new(200.0, 80.0));
1487        // Endpoints are exact.
1488        assert_eq!(lerp_rect(from, to, 0.0), from);
1489        assert_eq!(lerp_rect(from, to, 1.0), to);
1490        // Halfway: origin and size each land midway.
1491        let mid = lerp_rect(from, to, 0.5);
1492        assert_eq!(mid.origin(), Point::new(50.0, 20.0));
1493        assert_eq!(mid.size(), Size::new(110.0, 50.0));
1494    }
1495
1496    #[test]
1497    fn rect_to_rect_maps_corners_exactly() {
1498        let from = Rect::from_origin_size(Point::new(10.0, 20.0), Size::new(20.0, 20.0));
1499        let to = Rect::from_origin_size(Point::new(100.0, 200.0), Size::new(80.0, 40.0));
1500        let t = rect_to_rect(from, to);
1501        let tl = t * Point::new(from.x0, from.y0);
1502        let br = t * Point::new(from.x1, from.y1);
1503        assert!((tl.x - to.x0).abs() < 1e-9 && (tl.y - to.y0).abs() < 1e-9);
1504        assert!((br.x - to.x1).abs() < 1e-9 && (br.y - to.y1).abs() < 1e-9);
1505    }
1506
1507    #[test]
1508    fn rect_to_rect_zero_extent_source_is_identity_scale() {
1509        // A zero-width/height source must not divide by zero — it degenerates to
1510        // an identity scale on that axis (a pure translation of the origin).
1511        let from = Rect::from_origin_size(Point::new(5.0, 5.0), Size::new(0.0, 0.0));
1512        let to = Rect::from_origin_size(Point::new(9.0, 12.0), Size::new(0.0, 0.0));
1513        let t = rect_to_rect(from, to);
1514        let mapped = t * Point::new(5.0, 5.0);
1515        assert!((mapped.x - 9.0).abs() < 1e-9 && (mapped.y - 12.0).abs() < 1e-9);
1516        assert!(t.as_coeffs()[0].is_finite() && t.as_coeffs()[3].is_finite());
1517    }
1518
1519    #[test]
1520    fn settle_driver_springs_to_target() {
1521        let mut driver = settle_driver(DEFAULT_SETTLE_SPRING, 0.6, 0.0, 1.0);
1522        let mut t = 0.0;
1523        let mut done = false;
1524        for _ in 0..100_000 {
1525            let a = driver.advance(ft_secs(t));
1526            if a.done {
1527                done = true;
1528                assert_eq!(a.value, 1.0);
1529                break;
1530            }
1531            t += 1.0 / 120.0;
1532        }
1533        assert!(done, "settle driver failed to reach its target");
1534    }
1535}