Skip to main content

gpui_navigator/
transition.rs

1//! Route transition animations.
2//!
3//! This module provides a declarative transition system for route changes,
4//! gated behind the `transition` feature flag.
5//!
6//! # Available transitions
7//!
8//! | Variant | Constructor | Description |
9//! |---------|-------------|-------------|
10//! | [`Transition::None`] | default | No animation |
11//! | [`Transition::Fade`] | [`Transition::fade`] | Cross-fade (old fades out, new fades in) |
12//! | [`Transition::Slide`] | [`Transition::slide_left`], etc. | Positional slide in any direction |
13//!
14//! Each transition carries a `duration_ms` controlling animation length.
15//!
16//! # Per-route configuration
17//!
18//! Attach a transition to a route via the builder:
19//!
20//! ```ignore
21//! use gpui_navigator::{Route, Transition};
22//!
23//! Route::new("/dashboard", |_, cx, _| gpui::div())
24//!     .transition(Transition::fade(200));
25//! ```
26//!
27//! # One-off overrides
28//!
29//! Use [`TransitionConfig::set_override`] or `Navigator::push_with_transition`
30//! to override the default for a single navigation.
31
32use gpui::{div, px, Div, IntoElement, ParentElement, Styled};
33use std::time::Duration;
34
35/// Direction for slide transitions
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37#[non_exhaustive]
38pub enum SlideDirection {
39    /// Slide from left to right
40    Left,
41    /// Slide from right to left
42    Right,
43    /// Slide from top to bottom
44    Up,
45    /// Slide from bottom to top
46    Down,
47}
48
49/// Built-in transition types for route animations.
50///
51/// # Examples
52///
53/// ```
54/// use gpui_navigator::transition::Transition;
55/// use std::time::Duration;
56///
57/// let fade = Transition::fade(200);
58/// assert_eq!(fade.duration(), Duration::from_millis(200));
59/// assert!(!fade.is_none());
60///
61/// let slide = Transition::slide_left(300);
62/// assert_eq!(slide.duration(), Duration::from_millis(300));
63/// ```
64#[derive(Debug, Clone, Default)]
65#[non_exhaustive]
66pub enum Transition {
67    /// No transition animation
68    #[default]
69    None,
70
71    /// Cross-fade transition: old content fades out while new content fades in
72    Fade {
73        /// Duration in milliseconds
74        duration_ms: u64,
75    },
76
77    /// Slide transition
78    Slide {
79        /// Direction to slide
80        direction: SlideDirection,
81        /// Duration in milliseconds
82        duration_ms: u64,
83    },
84}
85
86impl Transition {
87    /// Create a cross-fade transition (old fades out, new fades in simultaneously)
88    #[must_use] 
89    pub const fn fade(duration_ms: u64) -> Self {
90        Self::Fade { duration_ms }
91    }
92
93    /// Create a slide-left transition
94    #[must_use] 
95    pub const fn slide_left(duration_ms: u64) -> Self {
96        Self::Slide {
97            direction: SlideDirection::Left,
98            duration_ms,
99        }
100    }
101
102    /// Create a slide-right transition
103    #[must_use] 
104    pub const fn slide_right(duration_ms: u64) -> Self {
105        Self::Slide {
106            direction: SlideDirection::Right,
107            duration_ms,
108        }
109    }
110
111    /// Create a slide-up transition
112    #[must_use] 
113    pub const fn slide_up(duration_ms: u64) -> Self {
114        Self::Slide {
115            direction: SlideDirection::Up,
116            duration_ms,
117        }
118    }
119
120    /// Create a slide-down transition
121    #[must_use] 
122    pub const fn slide_down(duration_ms: u64) -> Self {
123        Self::Slide {
124            direction: SlideDirection::Down,
125            duration_ms,
126        }
127    }
128
129    /// Get the duration of this transition
130    #[must_use] 
131    pub const fn duration(&self) -> Duration {
132        match self {
133            Self::None => Duration::ZERO,
134            Self::Fade { duration_ms, .. } | Self::Slide { duration_ms, .. } => {
135                Duration::from_millis(*duration_ms)
136            }
137        }
138    }
139
140    /// Check if this is a no-op transition
141    #[must_use] 
142    pub const fn is_none(&self) -> bool {
143        matches!(self, Self::None)
144    }
145}
146
147/// Per-route transition configuration with optional one-off override.
148///
149/// Stores a `default` transition applied to every navigation and an optional
150/// `override_next` that takes precedence for the next navigation only.
151#[derive(Clone)]
152pub struct TransitionConfig {
153    /// Default transition for this route
154    pub default: Transition,
155
156    /// Override transition for specific navigation
157    pub override_next: Option<Transition>,
158}
159
160impl Default for TransitionConfig {
161    fn default() -> Self {
162        Self {
163            default: Transition::None,
164            override_next: None,
165        }
166    }
167}
168
169impl TransitionConfig {
170    /// Create a new transition config with a default transition
171    #[must_use] 
172    pub const fn new(default: Transition) -> Self {
173        Self {
174            default,
175            override_next: None,
176        }
177    }
178
179    /// Get the active transition (override if set, otherwise default)
180    #[must_use] 
181    pub fn active(&self) -> &Transition {
182        self.override_next.as_ref().unwrap_or(&self.default)
183    }
184
185    /// Set an override transition for the next navigation
186    pub fn set_override(&mut self, transition: Transition) {
187        self.override_next = Some(transition);
188    }
189
190    /// Clear the override transition
191    pub fn clear_override(&mut self) {
192        self.override_next = None;
193    }
194
195    /// Check if there's an active override
196    #[must_use] 
197    pub const fn has_override(&self) -> bool {
198        self.override_next.is_some()
199    }
200}
201
202// ============================================================================
203// Transition Builder
204// ============================================================================
205
206/// Context values passed to custom transition renderers.
207pub struct TransitionContext {
208    /// Animation progress from 0.0 to 1.0
209    pub animation: f32,
210    /// Secondary animation for exit transitions (1.0 to 0.0)
211    pub secondary_animation: f32,
212}
213
214/// Apply a transition effect to an element.
215///
216/// Given a `progress` value in `0.0..=1.0`, wraps `element` in a [`Div`]
217/// with the appropriate positional offset and opacity for the transition type.
218///
219/// - [`Transition::None`] — returns the element unchanged (opacity 1, no offset).
220/// - [`Transition::Fade`] — sets opacity to `progress`.
221/// - [`Transition::Slide`] — offsets by `(1 - progress) * 100px` in the
222///   appropriate direction while also fading in.
223pub fn apply_transition(element: impl IntoElement, transition: &Transition, progress: f32) -> Div {
224    // Always use consistent method chain to avoid recursion limit
225    // Calculate all values first, then apply them in one chain
226    let (x, y, opacity) = match transition {
227        Transition::None => (0.0, 0.0, 1.0),
228
229        Transition::Fade { .. } => {
230            // Fade effect — progress controls opacity
231            (0.0, 0.0, progress)
232        }
233
234        Transition::Slide { direction, .. } => {
235            let offset_px = (1.0 - progress) * 100.0;
236            let (x, y) = match direction {
237                SlideDirection::Left => (offset_px, 0.0),
238                SlideDirection::Right => (-offset_px, 0.0),
239                SlideDirection::Up => (0.0, offset_px),
240                SlideDirection::Down => (0.0, -offset_px),
241            };
242            (x, y, progress)
243        }
244    };
245
246    // Unified return type - same method chain for all branches
247    div()
248        .relative()
249        .left(px(x))
250        .top(px(y))
251        .opacity(opacity)
252        .child(element)
253}
254
255/// Cubic ease-in-out easing function (`t` in `0.0..=1.0`).
256#[must_use] 
257pub fn ease_in_out_cubic(t: f32) -> f32 {
258    if t < 0.5 {
259        4.0 * t * t * t
260    } else {
261        1.0 - (-2.0f32).mul_add(t, 2.0).powi(3) / 2.0
262    }
263}
264
265/// Clamp `progress` to `0.0..=1.0` and apply [`ease_in_out_cubic`].
266#[must_use] 
267pub fn apply_easing(progress: f32) -> f32 {
268    ease_in_out_cubic(progress.clamp(0.0, 1.0))
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_slide_direction() {
277        assert_eq!(SlideDirection::Left, SlideDirection::Left);
278        assert_ne!(SlideDirection::Left, SlideDirection::Right);
279    }
280
281    #[test]
282    fn test_transition_none() {
283        let transition = Transition::None;
284        assert!(transition.is_none());
285        assert_eq!(transition.duration(), Duration::ZERO);
286    }
287
288    #[test]
289    fn test_transition_fade() {
290        let transition = Transition::fade(200);
291        assert!(!transition.is_none());
292        assert_eq!(transition.duration(), Duration::from_millis(200));
293    }
294
295    #[test]
296    fn test_transition_slide() {
297        let transition = Transition::slide_left(300);
298        assert!(!transition.is_none());
299        assert_eq!(transition.duration(), Duration::from_millis(300));
300
301        if let Transition::Slide { direction, .. } = transition {
302            assert_eq!(direction, SlideDirection::Left);
303        } else {
304            panic!("Expected Slide transition");
305        }
306    }
307
308    #[test]
309    fn test_transition_config_default() {
310        let config = TransitionConfig::default();
311        assert!(config.active().is_none());
312        assert!(!config.has_override());
313    }
314
315    #[test]
316    fn test_transition_config_with_default() {
317        let config = TransitionConfig::new(Transition::fade(200));
318        assert!(!config.active().is_none());
319        assert!(!config.has_override());
320    }
321
322    #[test]
323    fn test_transition_config_override() {
324        let mut config = TransitionConfig::new(Transition::fade(200));
325
326        config.set_override(Transition::slide_left(300));
327        assert!(config.has_override());
328        assert_eq!(config.active().duration(), Duration::from_millis(300));
329
330        config.clear_override();
331        assert!(!config.has_override());
332        assert_eq!(config.active().duration(), Duration::from_millis(200));
333    }
334
335    #[test]
336    fn test_transition_helpers() {
337        // Test all helper methods
338        let _ = Transition::fade(200);
339        let _ = Transition::slide_left(300);
340        let _ = Transition::slide_right(300);
341        let _ = Transition::slide_up(300);
342        let _ = Transition::slide_down(300);
343    }
344}