1use 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#[derive(Clone, Copy, Debug, PartialEq)]
48pub enum MotionCurve {
49 Standard(Spring),
50 Expressive(expressive::SampledSpring),
51}
52
53impl MotionCurve {
54 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 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
112pub fn set_motion_scheme(scheme: MotionScheme) {
114 SCHEME.set(scheme);
115}
116
117#[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
128fn 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#[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 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 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 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
229pub 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
242pub 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
278pub const ENTER_MS: u64 = 160;
280
281#[derive(Clone, Copy, Debug, PartialEq)]
289pub struct Spring {
290 pub damping: f32,
291 pub stiffness: f32,
292}
293
294pub const FAST_SPATIAL: Spring = Spring {
296 damping: 0.9,
297 stiffness: 1400.,
298};
299pub 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};
316pub const SLOW_EFFECTS: Spring = Spring {
318 damping: 1.,
319 stiffness: 800.,
320};
321
322const 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 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 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 pub fn position(self, seconds: f32) -> f32 {
364 let spring = self.sanitized();
365 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 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
394fn 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 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 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
509pub 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
519pub 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
528pub 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
537fn 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 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
599pub 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
605pub 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
616pub fn disclosure(id: impl Into<ElementId>, open: bool, content: Div) -> AnyElement {
618 if open {
619 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 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 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 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 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 #[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 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}