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}