Skip to main content

gpui_kit_material/
motion.rs

1//! The motion vocabulary: M3 springs, entrances and retargetable values.
2//!
3//! Reference priority is Figma kit > md3.io > m3e.kyralabs.dev, per value.
4//! The Figma Material 3 Design Kit defines no motion values.
5//!
6//! - Expressive (the default) uses md3.io's sampled CSS curves and durations
7//!   for all six tokens ([`expressive`], floor/material
8//!   c910072f3fc5b660276731d8a935d7186f288364). Under reduced motion it
9//!   follows md3.io's captured policy (`conformance/reference/
10//!   button-states-reduced-motion.json`): spatial tokens finish immediately and
11//!   effects keep their own curve.
12//! - Standard is absent from md3.io, so it uses m3e.kyralabs.dev's analytic
13//!   springs (kyra-labs/m3exp-web `3b1e0047c0021eab7b4c0128fa1bec95c76ca746`,
14//!   `packages/m3e-tokens/src/generated/tokens.css`, generated from Compose
15//!   Material3 1.5.0-beta01 `StandardMotionTokens.kt` @ androidx
16//!   `19991f2c0858aadfcb442ce02b2c8e4743c96898`) timed by Compose's settle
17//!   estimate, and m3e's reduced-motion tokens: spatial tokens finish
18//!   immediately and default/slow effects play the fast effects spring.
19//!   Fixture: `conformance/fixtures/m3e-motion.json`.
20//!
21//! Apply the scheme at the start of a root render, like the theme; helpers
22//! below resolve it on the UI thread.
23use gpui::{prelude::*, *};
24use std::cell::Cell;
25use std::time::Duration;
26
27pub mod expressive;
28
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum MotionScheme {
31    Standard,
32    #[default]
33    Expressive,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum MotionToken {
38    FastSpatial,
39    DefaultSpatial,
40    SlowSpatial,
41    FastEffects,
42    DefaultEffects,
43    SlowEffects,
44}
45
46/// A resolved transition; use the same curve for timing and interpolation.
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub enum MotionCurve {
49    Standard(Spring),
50    Expressive(expressive::SampledSpring),
51}
52
53impl MotionCurve {
54    /// The analytic spring behind this curve: Standard's m3e spring. md3.io
55    /// specifies Expressive as sampled curves, not spring parameters, so an
56    /// Expressive curve has none.
57    pub fn spring(self) -> Option<Spring> {
58        match self {
59            Self::Standard(spring) => Some(spring),
60            Self::Expressive(_) => None,
61        }
62    }
63
64    pub fn duration(self) -> Duration {
65        match self {
66            Self::Standard(spring) => spring.duration(),
67            Self::Expressive(spring) => spring.duration,
68        }
69    }
70
71    pub fn ease(self) -> impl Fn(f32) -> f32 + Copy + 'static {
72        move |progress| match self {
73            Self::Standard(spring) => spring.ease()(progress),
74            Self::Expressive(spring) => spring.position(progress),
75        }
76    }
77
78    /// Position after `seconds` of a 0 → 1 transition on this curve.
79    pub(crate) fn position(self, seconds: f32) -> f32 {
80        self.ease()(seconds / self.duration().as_secs_f32())
81    }
82}
83
84impl MotionScheme {
85    pub fn resolve(self, token: MotionToken) -> MotionCurve {
86        use MotionToken::*;
87        match self {
88            Self::Standard => MotionCurve::Standard(match token {
89                FastSpatial => FAST_SPATIAL,
90                DefaultSpatial => DEFAULT_SPATIAL,
91                SlowSpatial => SLOW_SPATIAL,
92                FastEffects => FAST_EFFECTS,
93                DefaultEffects => DEFAULT_EFFECTS,
94                SlowEffects => SLOW_EFFECTS,
95            }),
96            Self::Expressive => MotionCurve::Expressive(match token {
97                FastSpatial => expressive::FAST_SPATIAL,
98                DefaultSpatial => expressive::DEFAULT_SPATIAL,
99                SlowSpatial => expressive::SLOW_SPATIAL,
100                FastEffects => expressive::FAST_EFFECTS,
101                DefaultEffects => expressive::DEFAULT_EFFECTS,
102                SlowEffects => expressive::SLOW_EFFECTS,
103            }),
104        }
105    }
106}
107
108thread_local! {
109    static SCHEME: Cell<MotionScheme> = const { Cell::new(MotionScheme::Expressive) };
110}
111
112/// Set the motion scheme for this root's render. Defaults to Expressive.
113pub fn set_motion_scheme(scheme: MotionScheme) {
114    SCHEME.set(scheme);
115}
116
117/// Former name of [`set_motion_scheme`]; it read like `theme::apply_scheme`,
118/// which applies a color scheme.
119#[deprecated(note = "renamed to `motion::set_motion_scheme`")]
120pub fn apply_scheme(scheme: MotionScheme) {
121    set_motion_scheme(scheme);
122}
123
124pub fn curve(token: MotionToken) -> MotionCurve {
125    SCHEME.get().resolve(token)
126}
127
128/// The curve a transition plays under the current scheme and motion
129/// preference. Spatial tokens finish through [`token_policy`] in both
130/// schemes. Standard follows m3e's reduced-motion tokens, which replace the
131/// default and slow effects springs with the fast one; Expressive follows
132/// md3.io, whose effects keep their own curve.
133fn effective_curve(token: MotionToken, cx: &App) -> MotionCurve {
134    let scheme = SCHEME.get();
135    scheme.resolve(match token {
136        MotionToken::DefaultEffects | MotionToken::SlowEffects
137            if scheme == MotionScheme::Standard && cx.reduce_motion() =>
138        {
139            MotionToken::FastEffects
140        }
141        token => token,
142    })
143}
144
145#[derive(Clone, Copy)]
146struct RunningCurve {
147    curve: MotionCurve,
148    running: bool,
149}
150
151impl RunningCurve {
152    fn resolve(self, requested: MotionCurve) -> MotionCurve {
153        if self.running { self.curve } else { requested }
154    }
155    fn record(&mut self, resolved: MotionCurve, status: gpui_base::MotionStatus) {
156        self.curve = resolved;
157        self.running = matches!(
158            status,
159            gpui_base::MotionStatus::Running | gpui_base::MotionStatus::Delayed
160        );
161    }
162}
163
164fn token_policy(resolved: MotionCurve, token: MotionToken) -> gpui_base::Transition {
165    gpui_base::Transition::new(resolved.duration())
166        .ease(resolved.ease())
167        .reduced_motion(match token {
168            MotionToken::FastEffects | MotionToken::DefaultEffects | MotionToken::SlowEffects => {
169                gpui_base::ReducedMotion::Continue
170            }
171            _ => gpui_base::ReducedMotion::Finish,
172        })
173}
174
175/// Caller-owned token channel. Keeps the active curve and Base reversal clock
176/// across virtual unmounts, without retaining a window or scheduling frames.
177#[derive(Clone)]
178pub struct TokenTransitionPlayback<T> {
179    playback: gpui_base::TransitionPlayback<T>,
180    curve: std::rc::Rc<std::cell::Cell<Option<RunningCurve>>>,
181    target: std::rc::Rc<std::cell::RefCell<Option<T>>>,
182}
183impl<T> Default for TokenTransitionPlayback<T> {
184    fn default() -> Self {
185        Self {
186            playback: Default::default(),
187            curve: Default::default(),
188            target: Default::default(),
189        }
190    }
191}
192impl<T: gpui_base::Interpolate + PartialEq + 'static> TokenTransitionPlayback<T> {
193    /// Resample an initialized channel's current visual target, preserving
194    /// transient presentation while applying a motion preference change.
195    pub fn resample(&self, token: MotionToken, cx: &App) -> Option<gpui_base::MotionValue<T>> {
196        let target = self.target.borrow().clone()?;
197        Some(self.sample(target, token, cx))
198    }
199
200    /// Forward actual target/preference changes while unmounted. A channel's
201    /// first sample adopts its target; a fresh handle resets its lifecycle.
202    pub fn sample(&self, target: T, token: MotionToken, cx: &App) -> gpui_base::MotionValue<T> {
203        let requested = effective_curve(token, cx);
204        let mut active = self.curve.get().unwrap_or(RunningCurve {
205            curve: requested,
206            running: false,
207        });
208        // An unmounted channel may have settled without being sampled. Let
209        // Base evaluate its old target before choosing the next target's curve.
210        if active.running
211            && let Some(previous) = self.target.borrow().clone()
212        {
213            let old = self
214                .playback
215                .sample(previous, token_policy(active.curve, token), cx);
216            active.record(active.curve, old.status);
217        }
218        let resolved = active.resolve(requested);
219        *self.target.borrow_mut() = Some(target.clone());
220        let sample = self
221            .playback
222            .sample(target, token_policy(resolved, token), cx);
223        active.record(resolved, sample.status);
224        self.curve.set(Some(active));
225        sample
226    }
227}
228
229/// Follow a target with a semantic token. A scheme switch takes effect after
230/// the active transition (including interruptions) settles, so it cannot
231/// replace the easing underneath GPUI Base's existing transition clock.
232pub fn token_value(
233    id: impl Into<ElementId>,
234    target: f32,
235    token: MotionToken,
236    window: &mut Window,
237    cx: &mut App,
238) -> f32 {
239    token_transition(id, target, token, window, cx)
240}
241
242/// Follow an interpolatable target using the selected Material motion token.
243///
244/// Each channel ID must retain the same value type.
245/// Effects tokens preserve fades under reduced motion (Expressive keeps
246/// md3.io's curves; Standard plays default and slow effects on m3e's fast
247/// effects spring); spatial tokens finish immediately. Interpolation is
248/// supplied by the value, so color spaces and compound geometry need not be flattened
249/// into independent scalar clocks. Timing and retargeting belong to GPUI Base.
250pub fn token_transition<T>(
251    id: impl Into<ElementId>,
252    target: T,
253    token: MotionToken,
254    window: &mut Window,
255    cx: &mut App,
256) -> T
257where
258    T: gpui_base::Interpolate + PartialEq + 'static,
259{
260    let id = id.into();
261    let requested = effective_curve(token, cx);
262    let state = window.use_keyed_state((id.clone(), "material-motion-curve"), cx, |_, _| {
263        RunningCurve {
264            curve: requested,
265            running: false,
266        }
267    });
268    let active = state.read(cx);
269    let resolved = active.resolve(requested);
270    let sample =
271        gpui_base::transition_with_status(id, target, token_policy(resolved, token), window, cx);
272    state.update(cx, |state, _| {
273        state.record(resolved, sample.status);
274    });
275    sample.value
276}
277
278/// Short fades, e.g. a menu's entrance.
279pub const ENTER_MS: u64 = 160;
280
281/// A Material 3 spring (damping ratio, stiffness; unit mass). The constants
282/// below are the Standard motion scheme's tokens (md3.io has no Standard
283/// scheme, so m3e is the source), which m3e generates from
284/// Compose `StandardMotionTokens.kt` (L20-L31) and lists in `tokens.css` as
285/// `--m3e-sys-motion-spring-standard-*`. Spatial springs move or reshape
286/// something and may settle with a barely visible overshoot; effects springs
287/// fade color and opacity and never overshoot.
288#[derive(Clone, Copy, Debug, PartialEq)]
289pub struct Spring {
290    pub damping: f32,
291    pub stiffness: f32,
292}
293
294/// Small components: a button's shape, a switch thumb, a selection indicator.
295pub const FAST_SPATIAL: Spring = Spring {
296    damping: 0.9,
297    stiffness: 1400.,
298};
299/// Partly covering surfaces: a page region arriving, a disclosure opening.
300pub const DEFAULT_SPATIAL: Spring = Spring {
301    damping: 0.9,
302    stiffness: 700.,
303};
304pub const SLOW_SPATIAL: Spring = Spring {
305    damping: 0.9,
306    stiffness: 300.,
307};
308pub const FAST_EFFECTS: Spring = Spring {
309    damping: 1.,
310    stiffness: 3800.,
311};
312pub const DEFAULT_EFFECTS: Spring = Spring {
313    damping: 1.,
314    stiffness: 1600.,
315};
316/// Whole-window color changes, such as switching palettes.
317pub const SLOW_EFFECTS: Spring = Spring {
318    damping: 1.,
319    stiffness: 800.,
320};
321
322// Bounds that keep every derived duration finite: a spring with no
323// damping or no stiffness never settles, and `Duration::from_secs_f32`
324// panics on the infinite or NaN time it would take.
325const MIN_DAMPING: f32 = 0.01;
326const MAX_DAMPING: f32 = 1000.;
327const MIN_STIFFNESS: f32 = 1.;
328const MAX_STIFFNESS: f32 = 1.0e6;
329
330impl Spring {
331    /// The same spring with damping and stiffness clamped to finite,
332    /// positive values. NaN damping falls back to critical damping and NaN
333    /// stiffness to the minimum.
334    pub fn sanitized(self) -> Self {
335        let damping = if self.damping.is_nan() {
336            1.
337        } else {
338            self.damping.clamp(MIN_DAMPING, MAX_DAMPING)
339        };
340        let stiffness = if self.stiffness.is_nan() {
341            MIN_STIFFNESS
342        } else {
343            self.stiffness.clamp(MIN_STIFFNESS, MAX_STIFFNESS)
344        };
345        Self { damping, stiffness }
346    }
347
348    fn omega(self) -> f32 {
349        self.stiffness.sqrt()
350    }
351
352    /// Compose's settle estimate for a 0 → 1 move at a 0.1% visibility
353    /// threshold, in whole milliseconds and at least 1ms: the `-duration`
354    /// m3e derives for every spring token (`packages/m3e-motion/src/linear.ts`,
355    /// `FloatSpringSpec.getDurationNanos`).
356    pub fn duration(self) -> Duration {
357        let spring = self.sanitized();
358        let millis = settle_estimate_millis(spring.stiffness as f64, spring.damping as f64, 1000.);
359        Duration::from_millis(millis.max(1))
360    }
361
362    /// Position from rest at 0 toward 1 after `seconds`.
363    pub fn position(self, seconds: f32) -> f32 {
364        let spring = self.sanitized();
365        // NaN time is treated as rest; infinite time has settled.
366        let t = seconds.max(0.);
367        if t == f32::INFINITY {
368            return 1.;
369        }
370        let omega = spring.omega();
371        let zeta = spring.damping;
372        if zeta >= 1. {
373            1. - (1. + omega * t) * (-omega * t).exp()
374        } else {
375            let damped = omega * (1. - zeta * zeta).sqrt();
376            1. - (-zeta * omega * t).exp()
377                * ((damped * t).cos() + zeta * omega / damped * (damped * t).sin())
378        }
379    }
380
381    /// The spring as an easing over its own `duration`, ending exactly at 1.
382    pub fn ease(self) -> impl Fn(f32) -> f32 + Copy + 'static {
383        let seconds = self.duration().as_secs_f32();
384        move |t| {
385            if t >= 1. {
386                1.
387            } else {
388                self.position(t * seconds)
389            }
390        }
391    }
392}
393
394/// Port of Compose `SpringEstimation.kt` (animation-core @ androidx
395/// `19991f2c0858aadfcb442ce02b2c8e4743c96898`, via m3e's
396/// `packages/m3e-motion/src/spring-estimation.ts`): the time in whole
397/// milliseconds after which a spring released `displacement` thresholds from
398/// rest stays within one threshold of its target.
399fn settle_estimate_millis(stiffness: f64, damping_ratio: f64, displacement: f64) -> u64 {
400    let delta = 1.;
401    let p0 = displacement.abs();
402    let v0 = 0.;
403    if p0 == 0. {
404        return 0;
405    }
406    let coefficient = 2. * damping_ratio * stiffness.sqrt();
407    let partial = coefficient * coefficient - 4. * stiffness;
408    let partial_real = if partial < 0. { 0. } else { partial.sqrt() };
409    let partial_imaginary = if partial < 0. {
410        partial.abs().sqrt()
411    } else {
412        0.
413    };
414    let r1 = (-coefficient + partial_real) * 0.5;
415    let i1 = partial_imaginary * 0.5;
416    let r2 = (-coefficient - partial_real) * 0.5;
417    let newton = |mut t: f64, f: &dyn Fn(f64) -> f64, df: &dyn Fn(f64) -> f64| {
418        let mut change = f64::MAX;
419        let mut iterations = 0;
420        while change > 0.001 && iterations < 100 {
421            iterations += 1;
422            let last = t;
423            t -= f(t) / df(t);
424            change = (last - t).abs();
425        }
426        t
427    };
428    let seconds = if damping_ratio > 1. {
429        let c2 = (r1 * p0 - v0) / (r1 - r2);
430        let c1 = p0 - c2;
431        let t1 = (delta / c1).abs().ln() / r1;
432        let t2 = (delta / c2).abs().ln() / r2;
433        let mut t = if !t1.is_finite() {
434            t2
435        } else if !t2.is_finite() {
436            t1
437        } else {
438            t1.max(t2)
439        };
440        let inflection = ((c1 * r1) / (-c2 * r2)).ln() / (r2 - r1);
441        let x_inflection = c1 * (r1 * inflection).exp() + c2 * (r2 * inflection).exp();
442        let signed = if inflection.is_nan() || inflection <= 0. {
443            -delta
444        } else if -x_inflection < delta {
445            if c2 > 0. && c1 < 0. {
446                t = 0.;
447            }
448            -delta
449        } else {
450            t = (-(c2 * r2 * r2) / (c1 * r1 * r1)).ln() / (r1 - r2);
451            delta
452        };
453        if (c1 * r1 * (r1 * t).exp() + c2 * r2 * (r2 * t).exp()).abs() < 0.0001 {
454            t
455        } else {
456            newton(
457                t,
458                &|t| c1 * (r1 * t).exp() + c2 * (r2 * t).exp() + signed,
459                &|t| c1 * r1 * (r1 * t).exp() + c2 * r2 * (r2 * t).exp(),
460            )
461        }
462    } else if damping_ratio < 1. {
463        // Under-damped: the envelope c·e^(r·t) of the oscillation.
464        let c2 = (v0 - r1 * p0) / i1;
465        (delta / (p0 * p0 + c2 * c2).sqrt()).ln() / r1
466    } else {
467        let r = r1;
468        let (c1, c2) = (p0, v0 - r * p0);
469        let t1 = (delta / c1).abs().ln() / r;
470        let guess = (delta / c2).abs().ln();
471        let mut lambert = guess;
472        for _ in 0..=5 {
473            lambert = guess - (lambert / r).abs().ln();
474        }
475        let t2 = lambert / r;
476        let mut t = if !t1.is_finite() {
477            t2
478        } else if !t2.is_finite() {
479            t1
480        } else {
481            t1.max(t2)
482        };
483        let inflection = -(r * c1 + c2) / (r * c2);
484        let x_inflection = c1 * (r * inflection).exp() + c2 * inflection * (r * inflection).exp();
485        let signed = if inflection.is_nan() || inflection <= 0. {
486            -delta
487        } else if -x_inflection < delta {
488            if c2 < 0. && c1 > 0. {
489                t = 0.;
490            }
491            -delta
492        } else {
493            t = -(2. / r) - c1 / c2;
494            delta
495        };
496        newton(t, &|t| (c1 + c2 * t) * (r * t).exp() + signed, &|t| {
497            (c2 * (r * t + 1.) + c1 * r) * (r * t).exp()
498        })
499    };
500    // Kotlin's Double.toLong(): truncation, NaN → 0, saturating.
501    let millis = seconds * 1000.;
502    if millis.is_nan() || millis <= 0. {
503        0
504    } else {
505        millis.min(u64::MAX as f64) as u64
506    }
507}
508
509/// IDs belong to a logical state, not a frame or a progress value.
510pub fn enter<E: IntoElement + Styled + 'static>(id: impl Into<ElementId>, view: E) -> AnyElement {
511    Entrance {
512        id: id.into(),
513        view,
514        distance: None,
515    }
516    .into_any_element()
517}
518
519/// A change of task state settles into place, so the new title and available
520/// action read as a consequence of the user's previous action.
521pub fn state_enter<E: IntoElement + Styled + 'static>(
522    id: impl Into<ElementId>,
523    view: E,
524) -> AnyElement {
525    rise_in(id, view, 8.)
526}
527
528/// A destination, settings category or secondary page arrives as one unit;
529/// navigation and the app bar stay put.
530pub fn page_enter<E: IntoElement + Styled + 'static>(
531    id: impl Into<ElementId>,
532    view: E,
533) -> AnyElement {
534    rise_in(id, view, 12.)
535}
536
537/// Fades in while rising `distance` (100%-scale px, painted in rems so it
538/// follows the text scale) into place: the offset follows the
539/// default spatial spring and the opacity its effects spring, on one clock.
540fn rise_in<E: IntoElement + Styled + 'static>(
541    id: impl Into<ElementId>,
542    view: E,
543    distance: f32,
544) -> AnyElement {
545    Entrance {
546        id: id.into(),
547        view,
548        distance: Some(distance),
549    }
550    .into_any_element()
551}
552
553#[derive(gpui::IntoElement)]
554struct Entrance<E: IntoElement + Styled + 'static> {
555    id: ElementId,
556    view: E,
557    distance: Option<f32>,
558}
559
560impl<E: IntoElement + Styled + 'static> RenderOnce for Entrance<E> {
561    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
562        // A mounted entrance retains its curve even if a root switches schemes.
563        // Keep the wrapper and IDs stable when reduced motion changes, too.
564        let state =
565            window.use_keyed_state((self.id.clone(), "material-entrance-scheme"), cx, |_, _| {
566                (SCHEME.get(), false)
567            });
568        let (scheme, completed) = state.update(cx, |state, cx| {
569            state.1 |= cx.reduce_motion();
570            *state
571        });
572        let effects = scheme.resolve(MotionToken::DefaultEffects);
573        if let Some(distance) = self.distance {
574            let spatial = scheme.resolve(MotionToken::DefaultSpatial);
575            let total = spatial.duration().max(effects.duration());
576            let seconds = total.as_secs_f32();
577            self.view
578                .with_animation(self.id, Animation::new(total), move |view, t| {
579                    let t = if completed { 1. } else { t };
580                    let offset = spatial.position(t * seconds);
581                    let opacity = effects.position(t * seconds);
582                    view.relative()
583                        .top(crate::tokens::typography::rem(distance * (1. - offset)))
584                        .opacity(opacity.clamp(0., 1.))
585                })
586                .into_any_element()
587        } else {
588            self.view
589                .with_animation(
590                    self.id,
591                    Animation::new(effects.duration()).with_easing(effects.ease()),
592                    move |view, t| view.opacity(if completed { 1. } else { t }),
593                )
594                .into_any_element()
595        }
596    }
597}
598
599/// A value that follows `target` on the default spatial spring, retargeting
600/// from its current presentation when the target changes again.
601pub fn value(id: impl Into<ElementId>, target: f32, window: &mut Window, cx: &mut App) -> f32 {
602    token_value(id, target, MotionToken::DefaultSpatial, window, cx)
603}
604
605/// A selection moves and reshapes on the fast spatial spring, retargeting
606/// from wherever it is when the choice changes again.
607pub fn selection_value(
608    id: impl Into<ElementId>,
609    target: f32,
610    window: &mut Window,
611    cx: &mut App,
612) -> f32 {
613    token_value(id, target, MotionToken::FastSpatial, window, cx)
614}
615
616/// Content shown in place when `open`, fading in as it arrives.
617pub fn disclosure(id: impl Into<ElementId>, open: bool, content: Div) -> AnyElement {
618    if open {
619        // Keep intrinsic measurement, padding and child layout in the normal
620        // tree. Measuring in prepaint and clipping to a previous frame's height
621        // cuts off controls when the content or available width changes.
622        enter(id, content)
623    } else {
624        div().hidden().into_any_element()
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::{enter, value};
631    use gpui::{
632        App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
633        Render, RenderOnce, Styled as _, TestAppContext, VisualTestContext, Window, div, px, size,
634    };
635    use std::{cell::Cell, rc::Rc, time::Duration};
636
637    #[test]
638    fn degenerate_springs_have_finite_durations_and_positions() {
639        let springs = [
640            super::Spring {
641                damping: 0.,
642                stiffness: 700.,
643            },
644            super::Spring {
645                damping: -1.,
646                stiffness: 700.,
647            },
648            super::Spring {
649                damping: 0.9,
650                stiffness: 0.,
651            },
652            super::Spring {
653                damping: f32::NAN,
654                stiffness: f32::NAN,
655            },
656            super::Spring {
657                damping: f32::INFINITY,
658                stiffness: f32::INFINITY,
659            },
660        ];
661        for spring in springs {
662            let duration = spring.duration();
663            assert!(duration > Duration::ZERO, "{spring:?}");
664            let curve = super::MotionCurve::Standard(spring);
665            assert!(curve.duration().as_secs_f32().is_finite());
666            for t in [0., 0.25, 0.5, 1., f32::NAN] {
667                assert!(curve.ease()(t).is_finite(), "{spring:?} at {t}");
668            }
669            assert!(spring.position(f32::INFINITY).is_finite());
670            assert_eq!(curve.ease()(1.), 1.);
671        }
672        assert_eq!(super::DEFAULT_SPATIAL.sanitized(), super::DEFAULT_SPATIAL);
673    }
674
675    #[derive(gpui::IntoElement)]
676    struct StatefulChild {
677        target: f32,
678        mounts: Rc<Cell<usize>>,
679        sample: Rc<Cell<f32>>,
680    }
681
682    impl RenderOnce for StatefulChild {
683        fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
684            window.use_keyed_state("motion-child-state", cx, |_, _| {
685                self.mounts.set(self.mounts.get() + 1);
686            });
687            self.sample
688                .set(value("motion-child-value", self.target, window, cx));
689            div().size(px(40.))
690        }
691    }
692
693    struct PreferenceHarness {
694        target: f32,
695        mounts: Rc<Cell<usize>>,
696        sample: Rc<Cell<f32>>,
697    }
698
699    impl Render for PreferenceHarness {
700        fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
701            enter(
702                "motion-preference-parent",
703                div().child(StatefulChild {
704                    target: self.target,
705                    mounts: self.mounts.clone(),
706                    sample: self.sample.clone(),
707                }),
708            )
709        }
710    }
711
712    #[gpui::test]
713    fn reduce_motion_preserves_child_state_and_later_value_transitions(cx: &mut TestAppContext) {
714        let mounts = Rc::new(Cell::new(0));
715        let sample = Rc::new(Cell::new(-1.));
716        let (view, cx) = cx.add_window_view({
717            let mounts = mounts.clone();
718            let sample = sample.clone();
719            move |_, _| PreferenceHarness {
720                target: 0.,
721                mounts,
722                sample,
723            }
724        });
725        cx.update(|window, cx| window.draw(cx).clear(cx));
726        assert_eq!(mounts.get(), 1);
727        assert_eq!(sample.get(), 0.);
728
729        cx.update(|window, cx| {
730            view.update(cx, |view, cx| {
731                view.target = 1.;
732                cx.set_reduce_motion(true);
733                cx.notify();
734            });
735            window.draw(cx).clear(cx);
736        });
737        assert_eq!(mounts.get(), 1, "preference must not remount the child");
738        assert_eq!(
739            sample.get(),
740            1.,
741            "reduced motion paints the target immediately"
742        );
743
744        cx.update(|window, cx| {
745            view.update(cx, |view, cx| {
746                view.target = 0.;
747                cx.set_reduce_motion(false);
748                cx.notify();
749            });
750            window.draw(cx).clear(cx);
751        });
752        assert_eq!(mounts.get(), 1);
753        assert_eq!(
754            sample.get(),
755            1.,
756            "restored motion retains the previous target"
757        );
758        cx.executor().advance_clock(Duration::from_millis(50));
759        cx.update(|window, cx| {
760            window.refresh();
761            window.draw(cx).clear(cx);
762        });
763        assert!(
764            sample.get() > 0. && sample.get() < 1.,
765            "the next change must paint an intermediate value"
766        );
767        assert_eq!(mounts.get(), 1);
768    }
769
770    struct RevealHarness {
771        open: bool,
772        width: Pixels,
773        content_height: Pixels,
774    }
775
776    impl Render for RevealHarness {
777        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
778            let content = div()
779                .debug_selector(|| "motion-reveal-surface".into())
780                .flex()
781                .flex_col()
782                .p(px(16.))
783                .child(
784                    div()
785                        .debug_selector(|| "motion-reveal-content".into())
786                        .h(self.content_height),
787                );
788            let reveal = super::disclosure("geometry-reveal", self.open, content);
789            div()
790                .debug_selector(|| "motion-layout-root".into())
791                .flex()
792                .flex_col()
793                .w(self.width)
794                .gap(px(8.))
795                .child(div().h(px(20.)))
796                .child(reveal)
797                .child(
798                    div()
799                        .debug_selector(|| "motion-layout-footer".into())
800                        .h(px(20.)),
801                )
802        }
803    }
804
805    fn draw_open_reveal(
806        view: &Entity<RevealHarness>,
807        cx: &mut VisualTestContext,
808        width: Pixels,
809        content_height: Pixels,
810    ) {
811        cx.update(|window, cx| {
812            view.update(cx, |view, cx| {
813                view.open = true;
814                view.width = width;
815                view.content_height = content_height;
816                cx.notify();
817            });
818            window.draw(cx).clear(cx);
819        });
820    }
821
822    fn assert_reveal_geometry(cx: &mut VisualTestContext, width: Pixels, height: Pixels) {
823        let root = cx.debug_bounds("motion-layout-root").unwrap();
824        let surface = cx.debug_bounds("motion-reveal-surface").unwrap();
825        let content = cx.debug_bounds("motion-reveal-content").unwrap();
826        let footer = cx.debug_bounds("motion-layout-footer").unwrap();
827        assert_eq!(surface.left(), root.left());
828        assert_eq!(surface.top(), root.top() + px(28.));
829        assert_eq!(surface.size, size(width, height + px(32.)));
830        assert_eq!(content.top() - surface.top(), px(16.));
831        assert_eq!(surface.bottom() - content.bottom(), px(16.));
832        assert_eq!(footer.top() - surface.bottom(), px(8.));
833        assert!(footer.bottom() <= root.bottom());
834    }
835
836    #[gpui::test]
837    fn disclosure_first_frame_preserves_natural_size_and_padding(cx: &mut TestAppContext) {
838        let (view, cx) = cx.add_window_view(|_, _| RevealHarness {
839            open: false,
840            width: px(240.),
841            content_height: px(40.),
842        });
843        draw_open_reveal(&view, cx, px(240.), px(40.));
844        assert_reveal_geometry(cx, px(240.), px(40.));
845    }
846
847    #[gpui::test]
848    fn disclosure_content_resize_updates_following_rows_in_the_same_frame(cx: &mut TestAppContext) {
849        let (view, cx) = cx.add_window_view(|_, _| RevealHarness {
850            open: false,
851            width: px(240.),
852            content_height: px(40.),
853        });
854        draw_open_reveal(&view, cx, px(240.), px(40.));
855        draw_open_reveal(&view, cx, px(180.), px(96.));
856        assert_reveal_geometry(cx, px(180.), px(96.));
857    }
858
859    #[gpui::test]
860    fn disclosure_closes_without_leaving_height_or_children(cx: &mut TestAppContext) {
861        let (view, cx) = cx.add_window_view(|_, _| RevealHarness {
862            open: false,
863            width: px(240.),
864            content_height: px(40.),
865        });
866        draw_open_reveal(&view, cx, px(240.), px(40.));
867        cx.update(|window, cx| {
868            view.update(cx, |view, cx| {
869                view.open = false;
870                cx.notify();
871            });
872            window.draw(cx).clear(cx);
873        });
874        let root = cx.debug_bounds("motion-layout-root").unwrap();
875        let footer = cx.debug_bounds("motion-layout-footer").unwrap();
876        assert_eq!(footer.top(), root.top() + px(28.));
877        assert!(cx.debug_bounds("motion-reveal-surface").is_none());
878        assert!(cx.debug_bounds("motion-reveal-content").is_none());
879    }
880
881    #[derive(gpui::IntoElement)]
882    struct EntranceProbe {
883        style: gpui::StyleRefinement,
884        sample: Rc<std::cell::RefCell<gpui::StyleRefinement>>,
885    }
886    impl gpui::Styled for EntranceProbe {
887        fn style(&mut self) -> &mut gpui::StyleRefinement {
888            &mut self.style
889        }
890    }
891    impl RenderOnce for EntranceProbe {
892        fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
893            *self.sample.borrow_mut() = self.style;
894            div().size(px(40.))
895        }
896    }
897    struct EntranceHarness {
898        rise: bool,
899        sample: Rc<std::cell::RefCell<gpui::StyleRefinement>>,
900    }
901    impl Render for EntranceHarness {
902        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
903            let probe = EntranceProbe {
904                style: Default::default(),
905                sample: self.sample.clone(),
906            };
907            if self.rise {
908                super::page_enter("probe", probe)
909            } else {
910                super::enter("probe", probe)
911            }
912        }
913    }
914    #[gpui::test]
915    fn completed_entrances_do_not_rewind_when_reduced_motion_is_restored(cx: &mut TestAppContext) {
916        for rise in [false, true] {
917            let sample = Rc::new(std::cell::RefCell::new(gpui::StyleRefinement::default()));
918            let (_, cx) = cx.add_window_view({
919                let sample = sample.clone();
920                move |_, _| EntranceHarness { rise, sample }
921            });
922            cx.update(|window, cx| window.draw(cx).clear(cx));
923            cx.update(|window, cx| {
924                cx.set_reduce_motion(true);
925                window.refresh();
926                window.draw(cx).clear(cx);
927            });
928            assert_eq!(sample.borrow().opacity, Some(1.));
929            cx.update(|window, cx| {
930                cx.set_reduce_motion(false);
931                window.refresh();
932                window.draw(cx).clear(cx);
933            });
934            assert_eq!(
935                sample.borrow().opacity,
936                Some(1.),
937                "completed entrance cannot fade backward"
938            );
939            if rise {
940                assert_eq!(
941                    sample.borrow().inset.top,
942                    Some(crate::tokens::typography::rem(0.).into()),
943                    "completed entrance cannot move backward; its rise is in rems"
944                );
945            }
946        }
947    }
948
949    struct SchemeHarness {
950        target: f32,
951        scheme: super::MotionScheme,
952        sample: Rc<Cell<f32>>,
953    }
954
955    impl Render for SchemeHarness {
956        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
957            super::set_motion_scheme(self.scheme);
958            self.sample
959                .set(super::value("scheme-transition", self.target, window, cx));
960            div().size(px(40.))
961        }
962    }
963
964    #[gpui::test]
965    fn switching_schemes_and_reversing_preserve_the_running_presentation(cx: &mut TestAppContext) {
966        use super::{MotionScheme, MotionToken};
967        let sample = Rc::new(Cell::new(-1.));
968        let (view, cx) = cx.add_window_view({
969            let sample = sample.clone();
970            move |_, _| SchemeHarness {
971                target: 0.,
972                scheme: MotionScheme::Expressive,
973                sample,
974            }
975        });
976        cx.update(|window, cx| window.draw(cx).clear(cx));
977        cx.update(|window, cx| {
978            view.update(cx, |view, _| view.target = 1.);
979            window.refresh();
980            window.draw(cx).clear(cx);
981        });
982        cx.executor().advance_clock(Duration::from_millis(120));
983        cx.update(|window, cx| {
984            window.refresh();
985            window.draw(cx).clear(cx);
986        });
987        let before = sample.get();
988        assert!(
989            (before
990                - MotionScheme::Expressive
991                    .resolve(MotionToken::DefaultSpatial)
992                    .position(0.120))
993            .abs()
994                < 0.00001
995        );
996        cx.update(|window, cx| {
997            view.update(cx, |view, _| view.scheme = MotionScheme::Standard);
998            window.refresh();
999            window.draw(cx).clear(cx);
1000        });
1001        assert_eq!(
1002            sample.get(),
1003            before,
1004            "scheme change must not move an in-flight value"
1005        );
1006        cx.update(|window, cx| {
1007            view.update(cx, |view, _| view.target = 0.);
1008            window.refresh();
1009            window.draw(cx).clear(cx);
1010        });
1011        assert_eq!(
1012            sample.get(),
1013            before,
1014            "reversal must begin at the presentation value"
1015        );
1016        cx.executor().advance_clock(Duration::from_millis(50));
1017        cx.update(|window, cx| {
1018            window.refresh();
1019            window.draw(cx).clear(cx);
1020        });
1021        assert!(sample.get() < before && sample.get() > 0.);
1022        cx.executor().advance_clock(Duration::from_secs(1));
1023        cx.update(|window, cx| {
1024            window.refresh();
1025            window.draw(cx).clear(cx);
1026        });
1027        assert_eq!(sample.get(), 0.);
1028        cx.update(|window, cx| {
1029            view.update(cx, |view, _| view.target = 1.);
1030            window.refresh();
1031            window.draw(cx).clear(cx);
1032        });
1033        cx.executor().advance_clock(Duration::from_millis(50));
1034        cx.update(|window, cx| {
1035            window.refresh();
1036            window.draw(cx).clear(cx);
1037        });
1038        let standard = MotionScheme::Standard
1039            .resolve(MotionToken::DefaultSpatial)
1040            .position(0.050);
1041        assert!(
1042            (sample.get() - standard).abs() < 0.00001,
1043            "settled values adopt the new scheme"
1044        );
1045        cx.update(|window, cx| {
1046            cx.set_reduce_motion(true);
1047            view.update(cx, |view, _| view.target = 0.);
1048            window.refresh();
1049            window.draw(cx).clear(cx);
1050        });
1051        assert_eq!(sample.get(), 0.);
1052        super::set_motion_scheme(MotionScheme::Expressive);
1053    }
1054
1055    #[test]
1056    fn standard_scheme_matches_the_pinned_m3e_springs() {
1057        use super::{MotionScheme, MotionToken};
1058        let reference: serde_json::Value = serde_json::from_str(include_str!(
1059            "../../../conformance/fixtures/m3e-motion.json"
1060        ))
1061        .unwrap();
1062        assert_eq!(
1063            reference["source_commit"],
1064            "3b1e0047c0021eab7b4c0128fa1bec95c76ca746"
1065        );
1066        let springs = reference["springs"]["standard"].as_array().unwrap();
1067        assert_eq!(springs.len(), 6);
1068        let mut seen = std::collections::BTreeSet::new();
1069        for entry in springs {
1070            let label = entry["token"].as_str().unwrap();
1071            assert!(seen.insert(label), "duplicate {label}");
1072            let token = match label {
1073                "fast-spatial" => MotionToken::FastSpatial,
1074                "default-spatial" => MotionToken::DefaultSpatial,
1075                "slow-spatial" => MotionToken::SlowSpatial,
1076                "fast-effects" => MotionToken::FastEffects,
1077                "default-effects" => MotionToken::DefaultEffects,
1078                "slow-effects" => MotionToken::SlowEffects,
1079                _ => panic!("unexpected reference token {label}"),
1080            };
1081            let curve = MotionScheme::Standard.resolve(token);
1082            let spring = curve.spring().unwrap();
1083            let number = |key: &str| entry[key].as_f64().unwrap() as f32;
1084            assert_eq!(spring.damping, number("damping_ratio"), "{label} damping");
1085            assert_eq!(spring.stiffness, number("stiffness"), "{label} stiffness");
1086            assert_eq!(
1087                curve.duration().as_millis(),
1088                entry["duration_ms"].as_u64().unwrap() as u128,
1089                "{label} Compose settle duration"
1090            );
1091            // Each linear() stop is the spring sampled on a whole millisecond
1092            // and rounded to 4 digits.
1093            let stops = entry["linear_stops"].as_array().unwrap();
1094            assert!(stops.len() >= 2);
1095            for stop in stops {
1096                let progress = stop[0].as_f64().unwrap() as f32;
1097                let expected = stop[1].as_f64().unwrap() as f32;
1098                let actual = curve.ease()(progress);
1099                assert!(
1100                    (actual - expected).abs() < 0.0005,
1101                    "{label} at {progress}: {actual} vs m3e {expected}"
1102                );
1103            }
1104        }
1105        assert_eq!(
1106            reference["reduced_motion"]["default-effects"]["replaced_by"],
1107            "fast-effects"
1108        );
1109        assert_eq!(
1110            reference["reduced_motion"]["slow-effects"]["replaced_by"],
1111            "fast-effects"
1112        );
1113    }
1114
1115    #[gpui::test]
1116    fn reduced_motion_follows_each_schemes_source(cx: &mut TestAppContext) {
1117        use super::{MotionScheme, MotionToken, effective_curve};
1118        cx.update(|cx| {
1119            for reduce in [false, true] {
1120                cx.set_reduce_motion(reduce);
1121                for token in [MotionToken::DefaultEffects, MotionToken::SlowEffects] {
1122                    super::set_motion_scheme(MotionScheme::Standard);
1123                    // m3e: default/slow effects play the fast effects spring.
1124                    assert_eq!(
1125                        effective_curve(token, cx),
1126                        MotionScheme::Standard.resolve(if reduce {
1127                            MotionToken::FastEffects
1128                        } else {
1129                            token
1130                        }),
1131                        "Standard {token:?} reduce={reduce}"
1132                    );
1133                    super::set_motion_scheme(MotionScheme::Expressive);
1134                    // md3.io: effects keep their own curve.
1135                    assert_eq!(
1136                        effective_curve(token, cx),
1137                        MotionScheme::Expressive.resolve(token),
1138                        "Expressive {token:?} reduce={reduce}"
1139                    );
1140                }
1141            }
1142            cx.set_reduce_motion(false);
1143        });
1144    }
1145
1146    #[test]
1147    fn springs_settle_on_their_target() {
1148        use super::{DEFAULT_EFFECTS, DEFAULT_SPATIAL, FAST_SPATIAL, SLOW_EFFECTS};
1149        for spring in [FAST_SPATIAL, DEFAULT_SPATIAL, DEFAULT_EFFECTS, SLOW_EFFECTS] {
1150            let ease = spring.ease();
1151            assert_eq!(ease(0.), 0.);
1152            assert_eq!(ease(1.), 1.);
1153            assert!((ease(0.999) - 1.).abs() < 0.002, "{spring:?}");
1154            let duration = spring.duration().as_secs_f32();
1155            assert!((0.1..0.5).contains(&duration), "{spring:?}: {duration}");
1156        }
1157        // Effects springs never overshoot; spatial ones barely do.
1158        let effects = (0..=200).map(|i| DEFAULT_EFFECTS.ease()(i as f32 / 200.));
1159        assert!(effects.clone().all(|v| v <= 1.));
1160        assert!(effects.collect::<Vec<_>>().windows(2).all(|p| p[0] <= p[1]));
1161        assert!((0..=200).all(|i| FAST_SPATIAL.ease()(i as f32 / 200.) < 1.003));
1162    }
1163
1164    // Heap ownership deliberately prevents Copy: callers can animate compound values.
1165    #[derive(Clone, Debug, PartialEq)]
1166    struct CompoundEffects(Vec<f32>);
1167
1168    impl gpui_base::Interpolate for CompoundEffects {
1169        fn interpolate(&self, target: &Self, progress: f32) -> Self {
1170            Self(
1171                self.0
1172                    .iter()
1173                    .zip(&target.0)
1174                    .map(|(from, to)| from + (to - from) * progress)
1175                    .collect(),
1176            )
1177        }
1178    }
1179
1180    struct CompoundEffectsHarness {
1181        target: CompoundEffects,
1182        sample: Rc<std::cell::RefCell<CompoundEffects>>,
1183    }
1184
1185    impl Render for CompoundEffectsHarness {
1186        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1187            super::set_motion_scheme(super::MotionScheme::Expressive);
1188            *self.sample.borrow_mut() = super::token_transition(
1189                "compound-effects-transition",
1190                self.target.clone(),
1191                super::MotionToken::DefaultEffects,
1192                window,
1193                cx,
1194            );
1195            div().size(px(40.))
1196        }
1197    }
1198
1199    #[gpui::test]
1200    fn generic_effects_interpolate_reverse_and_preserve_fades_under_reduced_motion(
1201        cx: &mut TestAppContext,
1202    ) {
1203        let start = CompoundEffects(vec![0., 10.]);
1204        let target = CompoundEffects(vec![1., 30.]);
1205        let sample = Rc::new(std::cell::RefCell::new(start.clone()));
1206        let (view, cx) = cx.add_window_view({
1207            let sample = sample.clone();
1208            let start = start.clone();
1209            move |_, _| CompoundEffectsHarness {
1210                target: start,
1211                sample,
1212            }
1213        });
1214        cx.update(|window, cx| window.draw(cx).clear(cx));
1215        assert_eq!(*sample.borrow(), start);
1216        cx.update(|window, cx| {
1217            view.update(cx, |view, _| view.target = target.clone());
1218            window.refresh();
1219            window.draw(cx).clear(cx);
1220        });
1221        assert_eq!(*sample.borrow(), start);
1222        cx.executor().advance_clock(Duration::from_millis(125));
1223        cx.update(|window, cx| {
1224            window.refresh();
1225            window.draw(cx).clear(cx);
1226        });
1227        // Expressive DefaultEffects is md3.io's 250ms sampled curve, whose
1228        // midpoint stop is 0.96.
1229        let expected = super::MotionScheme::Expressive
1230            .resolve(super::MotionToken::DefaultEffects)
1231            .position(0.125);
1232        let midpoint = sample.borrow().clone();
1233        assert!((expected - 0.96).abs() < 0.00001, "{expected}");
1234        assert!((midpoint.0[0] - expected).abs() < 0.00001);
1235        assert!((midpoint.0[1] - (10. + 20. * expected)).abs() < 0.0001);
1236        cx.update(|window, cx| {
1237            view.update(cx, |view, _| view.target = start.clone());
1238            window.refresh();
1239            window.draw(cx).clear(cx);
1240        });
1241        assert_eq!(*sample.borrow(), midpoint, "reversal must not jump");
1242        cx.executor().advance_clock(Duration::from_millis(50));
1243        cx.update(|window, cx| {
1244            window.refresh();
1245            window.draw(cx).clear(cx);
1246        });
1247        let reversing = sample.borrow().clone();
1248        assert!(reversing.0[0] > 0. && reversing.0[0] < midpoint.0[0]);
1249        assert!(reversing.0[1] > 10. && reversing.0[1] < midpoint.0[1]);
1250        cx.update(|window, cx| {
1251            cx.set_reduce_motion(true);
1252            window.refresh();
1253            window.draw(cx).clear(cx);
1254        });
1255        assert_eq!(
1256            *sample.borrow(),
1257            reversing,
1258            "reduced motion preserves the in-flight fade"
1259        );
1260        cx.update(|window, cx| {
1261            view.update(cx, |view, _| view.target = target.clone());
1262            window.refresh();
1263            window.draw(cx).clear(cx);
1264        });
1265        assert_eq!(
1266            *sample.borrow(),
1267            reversing,
1268            "retargeting still begins continuously"
1269        );
1270        cx.executor().advance_clock(Duration::from_millis(250));
1271        cx.update(|window, cx| {
1272            window.refresh();
1273            window.draw(cx).clear(cx);
1274        });
1275        assert_eq!(
1276            *sample.borrow(),
1277            target,
1278            "fade completes under reduced motion"
1279        );
1280        cx.update(|window, cx| {
1281            cx.set_reduce_motion(false);
1282            view.update(cx, |view, _| view.target = start.clone());
1283            window.refresh();
1284            window.draw(cx).clear(cx);
1285        });
1286        assert_eq!(
1287            *sample.borrow(),
1288            target,
1289            "restoring motion retains presentation"
1290        );
1291        cx.executor().advance_clock(Duration::from_millis(250));
1292        cx.update(|window, cx| {
1293            window.refresh();
1294            window.draw(cx).clear(cx);
1295        });
1296        assert_eq!(*sample.borrow(), start);
1297    }
1298}