Skip to main content

open_gpui_motion/
motion.rs

1//! Renderer-neutral motion descriptors.
2
3use std::time::Duration;
4
5/// User or application preference for transition execution.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum MotionPreference {
8    /// Transitions may animate.
9    Animated,
10    /// Transitions should complete immediately while preserving final semantics.
11    Reduced,
12}
13
14impl MotionPreference {
15    /// Returns whether transitions should complete immediately.
16    pub const fn is_immediate(self) -> bool {
17        matches!(self, Self::Reduced)
18    }
19}
20
21/// Semantic duration bucket for UI motion.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum MotionDuration {
24    /// Immediate completion.
25    Immediate,
26    /// Short affordance motion.
27    Short,
28    /// Medium layout motion.
29    Medium,
30    /// Long emphasis motion.
31    Long,
32    /// Explicit duration.
33    Custom(Duration),
34}
35
36impl MotionDuration {
37    /// Returns the concrete duration represented by this token.
38    pub const fn as_duration(self) -> Duration {
39        match self {
40            Self::Immediate => Duration::from_millis(0),
41            Self::Short => Duration::from_millis(120),
42            Self::Medium => Duration::from_millis(180),
43            Self::Long => Duration::from_millis(260),
44            Self::Custom(duration) => duration,
45        }
46    }
47}
48
49/// Renderer-neutral easing token.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum MotionEasing {
52    /// Linear interpolation.
53    Linear,
54    /// Standard ease-out layout motion.
55    EaseOut,
56    /// Standard ease-in-out motion.
57    EaseInOut,
58    /// Strong ease-out motion for committed layout transitions.
59    EaseOutStrong,
60    /// Strong ease-in-out motion for zoom and continuity transitions.
61    EaseInOutStrong,
62}
63
64impl MotionEasing {
65    /// Samples this easing curve at a clamped unit progress.
66    pub fn sample(self, progress: f32) -> f32 {
67        let progress = progress.clamp(0.0, 1.0);
68        match self {
69            Self::Linear => progress,
70            Self::EaseOut => 1.0 - (1.0 - progress).powi(3),
71            Self::EaseInOut => {
72                if progress < 0.5 {
73                    4.0 * progress.powi(3)
74                } else {
75                    1.0 - (-2.0 * progress + 2.0).powi(3) / 2.0
76                }
77            }
78            Self::EaseOutStrong => 1.0 - (1.0 - progress).powi(4),
79            Self::EaseInOutStrong => {
80                if progress < 0.5 {
81                    8.0 * progress.powi(4)
82                } else {
83                    1.0 - (-2.0 * progress + 2.0).powi(4) / 2.0
84                }
85            }
86        }
87    }
88}
89
90/// A renderer-neutral transition specification.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct MotionSpec {
93    preference: MotionPreference,
94    duration: MotionDuration,
95    easing: MotionEasing,
96}
97
98impl MotionSpec {
99    /// Creates a motion spec from preference, duration, and easing.
100    pub const fn new(
101        preference: MotionPreference,
102        duration: MotionDuration,
103        easing: MotionEasing,
104    ) -> Self {
105        Self {
106            preference,
107            duration,
108            easing,
109        }
110    }
111
112    /// Creates the default layout motion spec for the given preference.
113    pub const fn layout(preference: MotionPreference) -> Self {
114        Self::new(preference, MotionDuration::Medium, MotionEasing::EaseOut)
115    }
116
117    /// Creates short affordance motion for hover feedback, guides, and lightweight overlays.
118    pub const fn affordance(preference: MotionPreference) -> Self {
119        Self::new(preference, MotionDuration::Short, MotionEasing::EaseOut)
120    }
121
122    /// Creates committed layout motion for insert, remove, collapse, and expand transitions.
123    pub const fn committed_layout(preference: MotionPreference) -> Self {
124        Self::new(
125            preference,
126            MotionDuration::Medium,
127            MotionEasing::EaseOutStrong,
128        )
129    }
130
131    /// Creates continuity motion for zoom, unzoom, and retargeted transitions.
132    pub const fn continuity(preference: MotionPreference) -> Self {
133        Self::new(
134            preference,
135            MotionDuration::Long,
136            MotionEasing::EaseInOutStrong,
137        )
138    }
139
140    /// Creates an immediate motion spec.
141    pub const fn immediate() -> Self {
142        Self::new(
143            MotionPreference::Reduced,
144            MotionDuration::Immediate,
145            MotionEasing::Linear,
146        )
147    }
148
149    /// Returns the motion preference.
150    pub const fn preference(self) -> MotionPreference {
151        self.preference
152    }
153
154    /// Returns the duration token.
155    pub const fn duration(self) -> MotionDuration {
156        if self.preference.is_immediate() {
157            MotionDuration::Immediate
158        } else {
159            self.duration
160        }
161    }
162
163    /// Returns the easing token.
164    pub const fn easing(self) -> MotionEasing {
165        self.easing
166    }
167
168    /// Returns whether this spec completes immediately.
169    pub const fn is_immediate(self) -> bool {
170        self.preference.is_immediate() || matches!(self.duration(), MotionDuration::Immediate)
171    }
172
173    /// Returns whether this spec allows spatial movement.
174    pub const fn allows_spatial_motion(self) -> bool {
175        !self.is_immediate()
176    }
177
178    /// Samples this motion spec's eased progress for the elapsed duration.
179    pub fn progress_at(self, elapsed: Duration) -> f32 {
180        if self.is_immediate() {
181            return 1.0;
182        }
183        let duration = self.duration().as_duration();
184        if duration.is_zero() {
185            return 1.0;
186        }
187        let raw = (elapsed.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0);
188        self.easing().sample(raw)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn reduced_motion_forces_immediate_duration() {
198        let spec = MotionSpec::layout(MotionPreference::Reduced);
199
200        assert!(spec.is_immediate());
201        assert_eq!(spec.duration(), MotionDuration::Immediate);
202        assert_eq!(spec.duration().as_duration(), Duration::from_millis(0));
203    }
204
205    #[test]
206    fn animated_motion_preserves_duration_and_easing() {
207        let spec = MotionSpec::new(
208            MotionPreference::Animated,
209            MotionDuration::Long,
210            MotionEasing::EaseInOut,
211        );
212
213        assert!(!spec.is_immediate());
214        assert_eq!(spec.duration(), MotionDuration::Long);
215        assert_eq!(spec.easing(), MotionEasing::EaseInOut);
216        assert_eq!(spec.duration().as_duration(), Duration::from_millis(260));
217    }
218
219    #[test]
220    fn progress_at_samples_duration_and_curve() {
221        let spec = MotionSpec::new(
222            MotionPreference::Animated,
223            MotionDuration::Custom(Duration::from_millis(200)),
224            MotionEasing::Linear,
225        );
226
227        assert_eq!(spec.progress_at(Duration::from_millis(0)), 0.0);
228        assert_eq!(spec.progress_at(Duration::from_millis(100)), 0.5);
229        assert_eq!(spec.progress_at(Duration::from_millis(250)), 1.0);
230    }
231
232    #[test]
233    fn named_layout_specs_use_stronger_curves_without_changing_layout_default() {
234        assert_eq!(
235            MotionSpec::layout(MotionPreference::Animated).easing(),
236            MotionEasing::EaseOut
237        );
238        assert_eq!(
239            MotionSpec::affordance(MotionPreference::Animated).duration(),
240            MotionDuration::Short
241        );
242        assert_eq!(
243            MotionSpec::committed_layout(MotionPreference::Animated).easing(),
244            MotionEasing::EaseOutStrong
245        );
246        assert_eq!(
247            MotionSpec::continuity(MotionPreference::Animated).easing(),
248            MotionEasing::EaseInOutStrong
249        );
250    }
251
252    #[test]
253    fn reduced_motion_disables_spatial_motion() {
254        assert!(!MotionSpec::layout(MotionPreference::Reduced).allows_spatial_motion());
255        assert!(MotionSpec::layout(MotionPreference::Animated).allows_spatial_motion());
256    }
257
258    #[test]
259    fn strong_motion_curves_are_monotonic_and_complete() {
260        for easing in [MotionEasing::EaseOutStrong, MotionEasing::EaseInOutStrong] {
261            let samples = [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0]
262                .into_iter()
263                .map(|progress| easing.sample(progress))
264                .collect::<Vec<_>>();
265
266            assert_eq!(samples.first().copied(), Some(0.0));
267            assert_eq!(samples.last().copied(), Some(1.0));
268            assert!(
269                samples.windows(2).all(|window| window[0] <= window[1]),
270                "{easing:?} should be monotonic: {samples:?}"
271            );
272        }
273    }
274}