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}