Skip to main content

guise/
transition.rs

1//! Mount transitions and `Collapse`, built on gpui's animation API.
2//!
3//! [`Transition`] plays a one-shot fade/slide as its child appears;
4//! [`Collapse`] reveals gated content — give it the content height and it
5//! animates that height open *and* closed (overflow clipped), falling back
6//! to a fade when the height is unknown. Both take an [`Easing`], including
7//! springs. For exit animations on arbitrary conditionals, see
8//! [`Presence`](crate::anim::Presence).
9
10use gpui::prelude::*;
11use gpui::{div, px, AnimationExt, AnyElement, App, ElementId, IntoElement, Window};
12
13use crate::anim::Easing;
14use crate::devtools::ProbedAny;
15
16/// The kind of entrance motion [`Transition`] plays.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TransitionKind {
19    Fade,
20    SlideUp,
21    SlideDown,
22    SlideLeft,
23    SlideRight,
24}
25
26/// Plays a one-shot entrance animation around its child.
27#[derive(IntoElement)]
28pub struct Transition {
29    id: ElementId,
30    kind: TransitionKind,
31    easing: Easing,
32    duration: u64,
33    child: Option<AnyElement>,
34}
35
36impl Transition {
37    pub fn new(id: impl Into<ElementId>) -> Self {
38        Transition {
39            id: id.into(),
40            kind: TransitionKind::Fade,
41            easing: Easing::default(),
42            duration: 200,
43            child: None,
44        }
45    }
46
47    pub fn kind(mut self, kind: TransitionKind) -> Self {
48        self.kind = kind;
49        self
50    }
51
52    /// Timing curve, including `Easing::Spring(..)`.
53    pub fn easing(mut self, easing: Easing) -> Self {
54        self.easing = easing;
55        self
56    }
57
58    pub fn duration_ms(mut self, duration: u64) -> Self {
59        self.duration = duration;
60        self
61    }
62
63    pub fn child(mut self, child: impl IntoElement) -> Self {
64        self.child = Some(child.into_any_element());
65        self
66    }
67}
68
69impl RenderOnce for Transition {
70    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
71        let child = self.child.unwrap_or_else(|| div().into_any_element());
72        let kind = self.kind;
73        // Linear clock + animator-side curve: overshooting easings (springs)
74        // return deltas past 1.0, which gpui's easing slot debug-asserts
75        // against but the animator accepts — margins overshoot and settle,
76        // opacity clamps to its legal range.
77        let easing = self.easing;
78        div()
79            .child(child)
80            .with_animation(self.id, easing.clock(self.duration), move |el, t| {
81                let delta = easing.apply(t);
82                let opacity = delta.clamp(0.0, 1.0);
83                let shift = (1.0 - delta) * 8.0;
84                match kind {
85                    TransitionKind::Fade => el.opacity(opacity),
86                    TransitionKind::SlideUp => el.opacity(opacity).mt(px(shift)),
87                    TransitionKind::SlideDown => el.opacity(opacity).mt(px(-shift)),
88                    TransitionKind::SlideLeft => el.opacity(opacity).ml(px(shift)),
89                    TransitionKind::SlideRight => el.opacity(opacity).ml(px(-shift)),
90                }
91            })
92            .probe_any("Transition")
93            .into_any_element()
94    }
95}
96
97/// Reveals gated content. With a known content `height`, the box height
98/// animates open and closed (a real collapse, clipped while moving); without
99/// one it fades in and unmounts instantly on close.
100#[derive(IntoElement)]
101pub struct Collapse {
102    id: ElementId,
103    open: bool,
104    height: Option<f32>,
105    easing: Easing,
106    duration: u64,
107    child: Option<AnyElement>,
108}
109
110impl Collapse {
111    pub fn new(id: impl Into<ElementId>) -> Self {
112        Collapse {
113            id: id.into(),
114            open: false,
115            height: None,
116            easing: Easing::default(),
117            duration: 180,
118            child: None,
119        }
120    }
121
122    pub fn open(mut self, open: bool) -> Self {
123        self.open = open;
124        self
125    }
126
127    /// The content's height in px. Unlocks the true height animation — the
128    /// closed state keeps the child mounted at height 0 so it can animate
129    /// back open.
130    pub fn height(mut self, height: f32) -> Self {
131        self.height = Some(height.max(0.0));
132        self
133    }
134
135    pub fn easing(mut self, easing: Easing) -> Self {
136        self.easing = easing;
137        self
138    }
139
140    pub fn duration_ms(mut self, duration: u64) -> Self {
141        self.duration = duration;
142        self
143    }
144
145    pub fn child(mut self, child: impl IntoElement) -> Self {
146        self.child = Some(child.into_any_element());
147        self
148    }
149}
150
151impl RenderOnce for Collapse {
152    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
153        // Linear clock + animator-side curve; see `Transition::render`.
154        let easing = self.easing;
155        let animation = easing.clock(self.duration);
156
157        let Some(height) = self.height else {
158            // No measured height: fade in on open, vanish on close.
159            if !self.open {
160                return div().into_any_element();
161            }
162            let child = self.child.unwrap_or_else(|| div().into_any_element());
163            return div()
164                .child(child)
165                .with_animation(self.id, animation, move |el, t| {
166                    el.opacity(easing.apply(t).clamp(0.0, 1.0))
167                })
168                .into_any_element();
169        };
170
171        let child = self.child.unwrap_or_else(|| div().into_any_element());
172        let open = self.open;
173        // Swapping the animation id replays the animation: one id per
174        // direction gives a real two-way collapse from stateless renders.
175        let direction = if open {
176            "guise-collapse-open"
177        } else {
178            "guise-collapse-close"
179        };
180        div()
181            .id(self.id)
182            .overflow_hidden()
183            .child(child)
184            .with_animation(direction, animation, move |el, t| {
185                let d = if open {
186                    easing.apply(t)
187                } else {
188                    1.0 - easing.apply(t)
189                };
190                // A springy open overshoots the height and settles back;
191                // opacity and the closing height stay in legal range.
192                el.h(px(height * d.max(0.0))).opacity(d.clamp(0.0, 1.0))
193            })
194            .into_any_element()
195            .probe_any("Collapse")
196            .into_any_element()
197    }
198}