1mod visibility;
58pub(crate) use visibility::VisibleAnimationExt;
59
60use gpui::{
61 Animation, AnimationElement, AnimationExt as _, App, ElementId, Global, Hsla, IntoElement,
62 ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Window, div,
63 ease_in_out, pulsating_between, px, relative,
64};
65pub(crate) fn ease_out_quint(t: f32) -> f32 {
71 let inverse = 1.0 - t.clamp(0.0, 1.0);
72 1.0 - inverse * inverse * inverse * inverse * inverse
73}
74
75pub(crate) fn ease_in_out_quart(t: f32) -> f32 {
80 let t = t.clamp(0.0, 1.0);
81 if t < 0.5 {
82 8.0 * t * t * t * t
83 } else {
84 let inverse = 1.0 - t;
85 1.0 - 8.0 * inverse * inverse * inverse * inverse
86 }
87}
88use gpui_base::motion::{Transition, transition};
89use gpui_component::{ActiveTheme as _, StyledExt as _};
90use std::collections::HashMap;
91use std::time::Duration;
92#[cfg(not(target_family = "wasm"))]
93pub(crate) use std::time::Instant;
94#[cfg(target_family = "wasm")]
95pub(crate) use web_time::Instant;
96
97const SHIMMER_BAND: f32 = 0.45;
101
102#[derive(Debug, Clone, Copy, PartialEq)]
108pub(crate) struct EnterSpec {
109 pub(crate) duration: Duration,
111 pub(crate) stagger: Duration,
113 pub(crate) stagger_cap: usize,
116 pub(crate) rise: f32,
118}
119
120impl EnterSpec {
121 pub(crate) const REVEAL: Self = Self {
123 duration: Duration::from_millis(260),
124 stagger: Duration::from_millis(40),
125 stagger_cap: 12,
126 rise: 6.0,
127 };
128
129 pub(crate) fn stagger_delay(self, index: usize) -> Duration {
132 self.stagger * index.min(self.stagger_cap) as u32
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq)]
140pub(crate) struct ProgressLoopSpec {
141 pub(crate) period: Duration,
143 pub(crate) duty: f32,
147}
148
149impl ProgressLoopSpec {
150 pub(crate) const SHIMMER: Self = Self {
153 period: Duration::from_millis(1800),
154 duty: 0.72,
155 };
156
157 pub(crate) const GRID_SWEEP: Self = Self {
159 period: Duration::from_millis(1400),
160 duty: 1.0,
161 };
162
163 pub(crate) const IMAGE_PULSE: Self = Self {
165 period: Duration::from_millis(1600),
166 duty: 1.0,
167 };
168
169 pub(crate) const STATUS_SPINNER: Self = Self {
171 period: Duration::from_millis(900),
172 duty: 1.0,
173 };
174
175 pub(crate) fn looping(self) -> Animation {
177 repeating(self.period)
178 }
179
180 pub(crate) fn looping_synced(self) -> Animation {
183 repeating_synced(self.period)
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq)]
190pub(crate) struct AmbientLoopSpec {
191 pub(crate) period: Duration,
193}
194
195impl AmbientLoopSpec {
196 pub(crate) const BREATHING: Self = Self {
198 period: Duration::from_millis(1600),
199 };
200
201 pub(crate) const ORB_LATTICE: Self = Self {
203 period: Duration::from_millis(1700),
204 };
205
206 pub(crate) const fn period_millis(self) -> u64 {
209 self.period.as_millis() as u64
210 }
211
212 pub(crate) fn looping(self) -> Animation {
214 repeating(self.period)
215 }
216
217 pub(crate) fn looping_synced(self) -> Animation {
220 repeating_synced(self.period)
221 }
222}
223
224fn repeating(period: Duration) -> Animation {
231 Animation::new(period).repeat()
232}
233
234fn repeating_synced(period: Duration) -> Animation {
242 Animation::new(period).repeat_synced()
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
255pub enum MotionPreference {
256 #[default]
258 Full,
259 Crossfade,
263 Snap,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq)]
277pub struct SpringRole {
278 response: Duration,
279 damping: f32,
280}
281
282impl SpringRole {
283 const fn new(response: Duration, damping: f32) -> Self {
284 Self { response, damping }
285 }
286
287 pub const fn response(self) -> Duration {
289 self.response
290 }
291
292 pub const fn damping(self) -> f32 {
294 self.damping
295 }
296}
297
298#[derive(Debug, Clone, PartialEq)]
326pub struct MotionTokens {
327 preference: MotionPreference,
328 instant: Duration,
329 quick: Duration,
330 hover_glide: Duration,
331 standard: Duration,
332 deliberate: Duration,
333 stagger_beat: Duration,
334 stagger_cap: usize,
335 press: SpringRole,
336 selection: SpringRole,
337 disclosure: SpringRole,
338 reflow: SpringRole,
339 shimmer: ProgressLoopSpec,
340 grid_sweep: ProgressLoopSpec,
341 image_pulse: ProgressLoopSpec,
342 status_spinner: ProgressLoopSpec,
343 breathing: AmbientLoopSpec,
344 orb_lattice: AmbientLoopSpec,
345}
346
347impl Global for MotionTokens {}
348
349impl Default for MotionTokens {
350 fn default() -> Self {
351 Self::DEFAULT
352 }
353}
354
355const STAGGER_PARTICIPANTS: usize = 6;
359
360const STAGGER_TOTAL_CAP: Duration = Duration::from_millis(220);
362
363impl MotionTokens {
364 pub const DEFAULT: Self = Self {
366 preference: MotionPreference::Full,
367 instant: Duration::ZERO,
368 quick: Duration::from_millis(150),
369 hover_glide: Duration::from_millis(100),
371 standard: EnterSpec::REVEAL.duration,
372 deliberate: Duration::from_millis(380),
373 stagger_beat: EnterSpec::REVEAL.stagger,
374 stagger_cap: EnterSpec::REVEAL.stagger_cap,
375 press: SpringRole::new(Duration::from_millis(140), 1.0),
376 selection: SpringRole::new(Duration::from_millis(220), 1.0),
377 disclosure: SpringRole::new(Duration::from_millis(280), 1.0),
378 reflow: SpringRole::new(Duration::from_millis(180), 1.0),
382 shimmer: ProgressLoopSpec::SHIMMER,
383 grid_sweep: ProgressLoopSpec::GRID_SWEEP,
384 image_pulse: ProgressLoopSpec::IMAGE_PULSE,
385 status_spinner: ProgressLoopSpec::STATUS_SPINNER,
386 breathing: AmbientLoopSpec::BREATHING,
387 orb_lattice: AmbientLoopSpec::ORB_LATTICE,
388 };
389
390 pub fn read(cx: &App) -> &Self {
394 cx.try_global::<Self>().unwrap_or(&Self::DEFAULT)
395 }
396
397 pub fn set(self, cx: &mut App) {
399 cx.set_global(self);
400 }
401
402 pub const fn instant(&self) -> Duration {
407 self.instant
408 }
409
410 pub const fn quick(&self) -> Duration {
412 self.quick
413 }
414
415 pub const fn hover_glide(&self) -> Duration {
420 self.hover_glide
421 }
422
423 pub const fn standard(&self) -> Duration {
426 self.standard
427 }
428
429 pub const fn deliberate(&self) -> Duration {
437 self.deliberate
438 }
439
440 pub const fn press_spring(&self) -> SpringRole {
444 self.press
445 }
446
447 pub const fn selection_spring(&self) -> SpringRole {
449 self.selection
450 }
451
452 pub const fn disclosure_spring(&self) -> SpringRole {
455 self.disclosure
456 }
457
458 pub const fn reflow_spring(&self) -> SpringRole {
460 self.reflow
461 }
462
463 pub const fn preference(&self) -> MotionPreference {
467 self.preference
468 }
469
470 pub fn effective_preference(cx: &App) -> MotionPreference {
475 let policy = Self::read(cx).preference;
476 if cx.reduce_motion() {
477 policy.max(MotionPreference::Crossfade)
478 } else {
479 policy
480 }
481 }
482
483 pub const fn with_preference(mut self, preference: MotionPreference) -> Self {
485 self.preference = preference;
486 self
487 }
488
489 pub const fn with_instant(mut self, duration: Duration) -> Self {
491 self.instant = duration;
492 self
493 }
494
495 pub const fn with_quick(mut self, duration: Duration) -> Self {
497 self.quick = duration;
498 self
499 }
500
501 pub const fn with_hover_glide(mut self, duration: Duration) -> Self {
503 self.hover_glide = duration;
504 self
505 }
506
507 pub const fn with_standard(mut self, duration: Duration) -> Self {
510 self.standard = duration;
511 self
512 }
513
514 pub const fn with_deliberate(mut self, duration: Duration) -> Self {
516 self.deliberate = duration;
517 self
518 }
519
520 pub const fn with_stagger_beat(mut self, beat: Duration) -> Self {
523 self.stagger_beat = beat;
524 self
525 }
526
527 pub const fn with_press_spring(mut self, response: Duration, damping: f32) -> Self {
529 self.press = SpringRole::new(response, damping);
530 self
531 }
532
533 pub const fn with_selection_spring(mut self, response: Duration, damping: f32) -> Self {
535 self.selection = SpringRole::new(response, damping);
536 self
537 }
538
539 pub const fn with_disclosure_spring(mut self, response: Duration, damping: f32) -> Self {
541 self.disclosure = SpringRole::new(response, damping);
542 self
543 }
544
545 pub const fn with_reflow_spring(mut self, response: Duration, damping: f32) -> Self {
547 self.reflow = SpringRole::new(response, damping);
548 self
549 }
550
551 pub(crate) const fn reveal(&self) -> EnterSpec {
556 EnterSpec {
557 duration: self.standard,
558 stagger: self.stagger_beat,
559 stagger_cap: self.stagger_cap,
560 rise: EnterSpec::REVEAL.rise,
561 }
562 }
563
564 pub(crate) const fn shimmer(&self) -> ProgressLoopSpec {
566 self.shimmer
567 }
568
569 pub(crate) const fn grid_sweep(&self) -> ProgressLoopSpec {
571 self.grid_sweep
572 }
573
574 pub(crate) const fn image_pulse(&self) -> ProgressLoopSpec {
576 self.image_pulse
577 }
578
579 pub(crate) const fn status_spinner(&self) -> ProgressLoopSpec {
581 self.status_spinner
582 }
583
584 pub(crate) const fn breathing(&self) -> AmbientLoopSpec {
586 self.breathing
587 }
588
589 pub(crate) const fn orb_lattice(&self) -> AmbientLoopSpec {
591 self.orb_lattice
592 }
593
594 pub fn arrival_stagger(&self, index: usize, arriving: usize) -> Duration {
609 let last = arriving.min(STAGGER_PARTICIPANTS).saturating_sub(1);
610 if last == 0 {
611 return Duration::ZERO;
612 }
613 let natural = self.stagger_beat * last as u32;
614 let total = natural.min(STAGGER_TOTAL_CAP);
615 let position = index.min(last) as f32 / last as f32;
616 let eased = position * (2.0 - position);
619 total.mul_f32(eased)
620 }
621}
622
623pub(crate) fn install(cx: &mut App) {
627 if !cx.has_global::<MotionTokens>() {
628 cx.set_global(MotionTokens::DEFAULT);
629 }
630}
631
632pub(crate) fn disclosure_progress(
649 id: impl Into<gpui_base::motion::TransitionId>,
650 open: bool,
651 window: &mut Window,
652 cx: &mut App,
653) -> f32 {
654 let target = if open { 1.0f32 } else { 0.0 };
655 if !motion_is_full(cx) {
656 return target;
659 }
660 let standard = MotionTokens::read(cx).standard();
661 transition(
662 id,
663 target,
664 Transition::new(standard).ease(ease_out_quint),
665 window,
666 cx,
667 )
668}
669
670pub(crate) fn disclosure_clip(
683 id: impl Into<ElementId>,
684 progress: f32,
685 content: impl gpui::IntoElement,
686 window: &mut Window,
687 cx: &mut App,
688) -> gpui::Div {
689 use gpui::prelude::FluentBuilder as _;
690 use gpui::{ParentElement as _, Styled as _};
691 use gpui_base::ElementExt as _;
692
693 let id = id.into();
694 let measured = window.use_keyed_state((id.clone(), "disclosure-height"), cx, |_, _| {
695 None::<gpui::Pixels>
696 });
697 let height = *measured.read(cx);
698 let clipping = progress < 1.0;
699 let clipped = match (height, clipping) {
706 (Some(height), true) => Some(height * progress.max(0.0)),
707 (None, true) => Some(gpui::Pixels::ZERO),
708 _ => None,
709 };
710 let recorder = measured;
711 gpui::div()
712 .w_full()
713 .overflow_hidden()
714 .when_some(clipped, |body, height| body.h(height))
715 .child(
716 gpui::div()
717 .w_full()
718 .flex_none()
719 .on_prepaint(move |bounds, _, cx| {
720 recorder.update(cx, |measured, _| *measured = Some(bounds.size.height));
727 })
728 .child(content),
729 )
730}
731
732pub(crate) fn disclosure_fade(
738 id: impl Into<gpui_base::motion::TransitionId>,
739 open: bool,
740 window: &mut Window,
741 cx: &mut App,
742) -> f32 {
743 let target = if open { 1.0f32 } else { 0.0 };
744 match MotionTokens::effective_preference(cx) {
745 MotionPreference::Snap => target,
746 MotionPreference::Crossfade => {
747 let id = id.into();
748 crossfade_toward(
749 ElementId::Name(format!("{id:?}-disclosure-fade").into()),
750 target,
751 window,
752 cx,
753 )
754 }
755 MotionPreference::Full => disclosure_progress(id, open, window, cx),
756 }
757}
758
759pub(crate) struct ArrivalRoster {
764 primed: bool,
765 epoch: bool,
766 seen: HashMap<ElementId, bool>,
767 delays: HashMap<ElementId, ArrivalTiming>,
768}
769
770struct ArrivalTiming {
771 started_at: Instant,
772 delay: Duration,
773 duration: Duration,
774}
775
776impl ArrivalTiming {
777 fn progress(&self, now: Instant) -> f32 {
778 let elapsed = now.saturating_duration_since(self.started_at);
779 if self.duration.is_zero() {
780 1.0
781 } else if elapsed < self.delay {
782 0.0
783 } else {
784 (elapsed.saturating_sub(self.delay).as_secs_f32() / self.duration.as_secs_f32())
785 .min(1.0)
786 }
787 }
788}
789
790impl ArrivalRoster {
791 pub(crate) fn new() -> Self {
792 Self {
793 primed: false,
794 epoch: false,
795 seen: HashMap::new(),
796 delays: HashMap::new(),
797 }
798 }
799
800 pub(crate) fn note(
803 &mut self,
804 keys: impl Iterator<Item = ElementId>,
805 assign: bool,
806 tokens: &MotionTokens,
807 now: Instant,
808 ) {
809 self.epoch = !self.epoch;
810 let mut fresh = Vec::new();
811 for key in keys {
812 if self.seen.insert(key.clone(), self.epoch).is_none()
813 && fresh.len() < STAGGER_PARTICIPANTS
814 {
815 fresh.push(key);
816 }
817 }
818 self.seen.retain(|_, epoch| *epoch == self.epoch);
819 self.delays.retain(|key, timing| {
820 assign && self.seen.contains_key(key) && timing.progress(now) < 1.0
821 });
822 let primed = std::mem::replace(&mut self.primed, true);
823 let cascade = primed && assign;
824 fresh.truncate(STAGGER_PARTICIPANTS.saturating_sub(self.delays.len()));
825 let batch = fresh.len();
826 for (position, key) in fresh.into_iter().enumerate() {
827 if cascade {
828 self.delays.insert(
829 key,
830 ArrivalTiming {
831 started_at: now,
832 delay: tokens.arrival_stagger(position, batch),
833 duration: tokens.standard(),
834 },
835 );
836 }
837 }
838 }
839
840 pub(crate) fn progress(
843 &mut self,
844 key: &ElementId,
845 window: &mut Window,
846 cx: &App,
847 ) -> Option<f32> {
848 let progress = self
849 .delays
850 .get(key)?
851 .progress(cx.background_executor().now());
852 if MotionTokens::effective_preference(cx) == MotionPreference::Snap || progress >= 1.0 {
853 self.delays.remove(key);
854 return None;
855 }
856 note_reveal_frame_request();
857 window.request_animation_frame();
858 Some(ease_out_quint(progress))
859 }
860
861 #[cfg(test)]
862 pub(crate) fn delay(&self, key: &ElementId) -> Option<Duration> {
863 self.delays.get(key).map(|timing| timing.delay)
864 }
865}
866
867#[derive(IntoElement)]
875pub struct Shimmer {
876 id: ElementId,
877 style: StyleRefinement,
878 text: SharedString,
879 active: bool,
880 base: Option<Hsla>,
881 highlight: Option<Hsla>,
882}
883
884impl Shimmer {
885 pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
887 Self {
888 id: id.into(),
889 style: StyleRefinement::default(),
890 text: text.into(),
891 active: true,
892 base: None,
893 highlight: None,
894 }
895 }
896
897 pub fn active(mut self, active: bool) -> Self {
899 self.active = active;
900 self
901 }
902
903 pub fn colors(mut self, base: Hsla, highlight: Hsla) -> Self {
906 self.base = Some(base);
907 self.highlight = Some(highlight);
908 self
909 }
910}
911
912impl Styled for Shimmer {
913 fn style(&mut self) -> &mut StyleRefinement {
914 &mut self.style
915 }
916}
917
918fn shimmer_band_start(delta: f32, duty: f32) -> f32 {
921 let progress = ease_in_out((delta / duty).min(1.0));
922 -SHIMMER_BAND + progress * (1.0 + SHIMMER_BAND)
923}
924
925impl RenderOnce for Shimmer {
926 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
927 let base = self.base.unwrap_or(cx.theme().muted_foreground);
928 let highlight = self.highlight.unwrap_or(cx.theme().foreground);
929 if !self.active || !motion_is_full(cx) {
930 return div()
931 .whitespace_nowrap()
932 .text_color(base)
933 .child(self.text)
934 .refine_style(&self.style)
935 .into_any_element();
936 }
937
938 let text = self.text;
939 let spec = MotionTokens::read(cx).shimmer();
940 div()
941 .relative()
942 .overflow_hidden()
943 .whitespace_nowrap()
944 .text_color(base)
945 .child(text.clone())
946 .refine_style(&self.style)
947 .with_visible_animation(
948 (self.id, "shimmer"),
949 spec.looping(),
956 move |container, delta| {
957 let start = shimmer_band_start(delta, spec.duty);
958 container.child(
959 div()
960 .absolute()
961 .top_0()
962 .bottom_0()
963 .left(relative(start))
964 .w(relative(SHIMMER_BAND))
965 .overflow_hidden()
966 .child(
967 div()
968 .absolute()
969 .top_0()
970 .left(relative(-start / SHIMMER_BAND))
973 .whitespace_nowrap()
974 .text_color(highlight)
975 .child(text.clone()),
976 ),
977 )
978 },
979 )
980 .into_any_element()
981 }
982}
983
984pub fn reveal_progress(
992 id: impl Into<ElementId>,
993 delay: Duration,
994 window: &mut Window,
995 cx: &mut App,
996) -> f32 {
997 let duration = MotionTokens::read(cx).reveal().duration;
998 timed_progress(id, delay, duration, window, cx)
999}
1000
1001pub(crate) fn press_release_progress(
1008 id: impl Into<ElementId>,
1009 window: &mut Window,
1010 cx: &mut App,
1011) -> f32 {
1012 let response = MotionTokens::read(cx).press_spring().response();
1013 timed_progress(id, Duration::ZERO, response, window, cx)
1014}
1015
1016pub(crate) fn swap_progress(id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> f32 {
1019 let duration = MotionTokens::read(cx).quick();
1020 timed_progress(id, Duration::ZERO, duration, window, cx)
1021}
1022
1023pub(crate) fn acknowledged_state(
1031 slot: ElementId,
1032 ordinal: u64,
1033 window: &mut Window,
1034 cx: &mut App,
1035) -> f32 {
1036 let now = cx.background_executor().now();
1037 let duration = MotionTokens::read(cx).quick();
1038 let reduced =
1039 MotionTokens::effective_preference(cx) == MotionPreference::Snap || duration.is_zero();
1040 let state =
1041 window.use_keyed_state((slot, "acknowledged-state"), cx, |_, _| AcknowledgedState {
1042 ordinal,
1043 started_at: None,
1044 });
1045 let progress = state.update(cx, |state, _| {
1046 if state.ordinal != ordinal {
1047 state.ordinal = ordinal;
1048 state.started_at = Some(now);
1049 }
1050 if reduced {
1051 state.started_at = None;
1052 }
1053 let progress = state.started_at.map_or(1.0, |started| {
1054 (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0)
1055 });
1056 if progress >= 1.0 {
1057 state.started_at = None;
1058 }
1059 progress
1060 });
1061 if progress < 1.0 {
1062 note_reveal_frame_request();
1063 window.request_animation_frame();
1064 }
1065 ease_out_quint(progress)
1066}
1067
1068struct AcknowledgedState {
1069 ordinal: u64,
1070 started_at: Option<Instant>,
1071}
1072
1073pub(crate) fn travel(cx: &App) -> f32 {
1079 if MotionTokens::effective_preference(cx) == MotionPreference::Full {
1080 1.0
1081 } else {
1082 0.0
1083 }
1084}
1085
1086pub(crate) fn motion_is_full(cx: &App) -> bool {
1090 MotionTokens::effective_preference(cx) == MotionPreference::Full
1091}
1092
1093pub(crate) fn retarget_duration(cx: &App, duration: Duration) -> Duration {
1098 if motion_is_full(cx) {
1099 duration
1100 } else {
1101 Duration::ZERO
1102 }
1103}
1104
1105fn crossfade_toward(
1114 id: impl Into<ElementId>,
1115 target: f32,
1116 window: &mut Window,
1117 cx: &mut App,
1118) -> f32 {
1119 let quick = MotionTokens::read(cx)
1120 .quick()
1121 .as_secs_f32()
1122 .max(f32::EPSILON);
1123 let now = cx.background_executor().now();
1124 let state = window.use_keyed_state((id.into(), "crossfade"), cx, |_, _| CrossfadeState {
1125 value: target,
1126 at: now,
1127 });
1128 let value = state.update(cx, |state, _| {
1129 let step = now.saturating_duration_since(state.at).as_secs_f32() / quick;
1130 state.at = now;
1131 state.value = if state.value < target {
1132 (state.value + step).min(target)
1133 } else {
1134 (state.value - step).max(target)
1135 };
1136 state.value
1137 });
1138 if (value - target).abs() > f32::EPSILON {
1139 note_reveal_frame_request();
1140 window.request_animation_frame();
1141 }
1142 value
1143}
1144
1145struct CrossfadeState {
1146 value: f32,
1147 at: Instant,
1148}
1149
1150fn timed_progress(
1154 id: impl Into<ElementId>,
1155 delay: Duration,
1156 duration: Duration,
1157 window: &mut Window,
1158 cx: &mut App,
1159) -> f32 {
1160 let (delay, duration) = match MotionTokens::effective_preference(cx) {
1167 MotionPreference::Snap => return 1.0,
1168 MotionPreference::Crossfade => {
1169 (Duration::ZERO, duration.min(MotionTokens::read(cx).quick()))
1170 }
1171 MotionPreference::Full => (delay, duration),
1172 };
1173 let now = cx.background_executor().now();
1174 let started = *window.use_keyed_state(id, cx, |_, _| now).read(cx);
1175 let elapsed = now.saturating_duration_since(started);
1176 let progress = if elapsed <= delay {
1177 0.0
1178 } else {
1179 (elapsed.saturating_sub(delay).as_secs_f32() / duration.as_secs_f32()).min(1.0)
1180 };
1181 if progress < 1.0 {
1182 note_reveal_frame_request();
1183 window.request_animation_frame();
1184 }
1185 ease_out_quint(progress)
1186}
1187
1188#[cfg(test)]
1189thread_local! {
1190 static REVEAL_FRAME_REQUESTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1195}
1196
1197#[inline]
1203fn note_reveal_frame_request() {
1204 #[cfg(test)]
1205 REVEAL_FRAME_REQUESTS.with(|count| count.set(count.get().saturating_add(1)));
1206}
1207
1208#[cfg(test)]
1213pub(crate) fn take_reveal_frame_requests() -> usize {
1214 REVEAL_FRAME_REQUESTS.with(|count| count.replace(0))
1215}
1216
1217pub fn reveal<E>(element: E, id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> E
1222where
1223 E: Styled,
1224{
1225 apply_reveal(
1226 element,
1227 reveal_progress(id, Duration::ZERO, window, cx),
1228 travel(cx),
1229 )
1230}
1231
1232pub fn reveal_staggered<E>(
1235 element: E,
1236 id: impl Into<ElementId>,
1237 index: usize,
1238 window: &mut Window,
1239 cx: &mut App,
1240) -> E
1241where
1242 E: Styled,
1243{
1244 let delay = MotionTokens::read(cx).reveal().stagger_delay(index);
1245 apply_reveal(element, reveal_progress(id, delay, window, cx), travel(cx))
1246}
1247
1248const REVEAL_RISE: f32 = EnterSpec::REVEAL.rise;
1254
1255fn apply_reveal<E: Styled>(element: E, progress: f32, travel: f32) -> E {
1256 element
1257 .opacity(progress)
1258 .top(px(REVEAL_RISE * (1.0 - progress) * travel))
1259}
1260
1261pub fn breathing<E>(element: E, id: impl Into<ElementId>) -> AnimationElement<E>
1273where
1274 E: IntoElement + Styled + 'static,
1275{
1276 breathing_with(MotionTokens::DEFAULT.breathing(), element, id)
1277}
1278
1279pub(crate) fn breathing_with<E>(
1281 spec: AmbientLoopSpec,
1282 element: E,
1283 id: impl Into<ElementId>,
1284) -> AnimationElement<E>
1285where
1286 E: IntoElement + Styled + 'static,
1287{
1288 element.with_animation(
1289 id,
1290 spec.looping().with_easing(pulsating_between(0.35, 1.0)),
1295 |element, alpha| element.opacity(alpha),
1296 )
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301 use super::*;
1302 use gpui::{Context, Render, TestAppContext, VisualTestContext, WindowHandle, px, size};
1303 use std::cell::Cell;
1304 use std::rc::Rc;
1305
1306 #[test]
1307 fn shimmer_band_travels_fully_across_and_rests_off_the_trailing_edge() {
1308 let default_duty = ProgressLoopSpec::SHIMMER.duty;
1309 assert!(default_duty > 0.0 && default_duty < 1.0);
1310 for duty in [default_duty, 0.4] {
1313 let start = shimmer_band_start(0.0, duty);
1314 let quarter = shimmer_band_start(duty * 0.25, duty);
1315 let middle = shimmer_band_start(duty * 0.5, duty);
1316 let three_quarters = shimmer_band_start(duty * 0.75, duty);
1317 let end = shimmer_band_start(duty, duty);
1318
1319 assert!(start + SHIMMER_BAND <= 0.0, "starts before the label");
1320 assert!(start < quarter && quarter < middle);
1321 assert!(middle < three_quarters && three_quarters < end);
1322 assert!(
1323 (middle + SHIMMER_BAND / 2.0 - 0.5).abs() < 1e-6,
1324 "the band crosses the label's center halfway through travel"
1325 );
1326 assert!(end >= 1.0, "clears the trailing edge");
1327 for delta in [duty + (1.0 - duty) * 0.5, 1.0] {
1328 assert_eq!(shimmer_band_start(delta, duty), end, "rests offscreen");
1329 }
1330 }
1331 }
1332
1333 #[test]
1334 fn stagger_is_bounded_so_long_lists_do_not_wait_forever() {
1335 let spec = MotionTokens::DEFAULT.reveal();
1336 let far = spec.stagger_delay(12);
1337 let capped = spec.stagger_delay(100);
1338 assert_eq!(far, capped);
1339 assert!(spec.stagger_delay(1) < far);
1340 }
1341
1342 #[test]
1343 fn default_policy_uses_the_documented_role_specs() {
1344 let tokens = MotionTokens::DEFAULT;
1345 assert_eq!(tokens.reveal(), EnterSpec::REVEAL);
1346 assert_eq!(tokens.shimmer(), ProgressLoopSpec::SHIMMER);
1347 assert_eq!(tokens.grid_sweep(), ProgressLoopSpec::GRID_SWEEP);
1348 assert_eq!(tokens.image_pulse(), ProgressLoopSpec::IMAGE_PULSE);
1349 assert_eq!(tokens.status_spinner(), ProgressLoopSpec::STATUS_SPINNER);
1350 assert_eq!(tokens.breathing(), AmbientLoopSpec::BREATHING);
1351 assert_eq!(tokens.orb_lattice(), AmbientLoopSpec::ORB_LATTICE);
1352 assert_eq!(tokens.standard(), EnterSpec::REVEAL.duration);
1353 assert_eq!(tokens.instant(), Duration::ZERO, "immediate is immediate");
1354 assert_eq!(MotionTokens::default(), tokens);
1355 }
1356
1357 #[test]
1358 fn default_roles_have_ordered_tempos_and_safe_springs() {
1359 let tokens = MotionTokens::DEFAULT;
1360 assert!(tokens.quick() > Duration::ZERO);
1361 assert!(tokens.quick() < tokens.standard());
1362 assert!(tokens.standard() < tokens.deliberate());
1363 for spring in [
1364 tokens.press_spring(),
1365 tokens.selection_spring(),
1366 tokens.disclosure_spring(),
1367 tokens.reflow_spring(),
1368 ] {
1369 assert!(spring.response() > Duration::ZERO);
1370 assert!(spring.damping() > 0.0);
1371 }
1372 assert!(tokens.press_spring().damping() >= 1.0);
1374 assert!(tokens.disclosure_spring().damping() >= 1.0);
1375 assert!(tokens.reflow_spring().damping() >= 1.0);
1376 }
1377
1378 #[test]
1379 fn arrival_stagger_decelerates_within_its_caps() {
1380 let tokens = MotionTokens::DEFAULT;
1381 let delays: Vec<_> = (0..STAGGER_PARTICIPANTS)
1382 .map(|index| tokens.arrival_stagger(index, STAGGER_PARTICIPANTS))
1383 .collect();
1384 assert_eq!(delays[0], Duration::ZERO, "the first item leads at once");
1385 let gaps: Vec<_> = delays.windows(2).map(|pair| pair[1] - pair[0]).collect();
1386 for pair in gaps.windows(2) {
1387 assert!(
1388 pair[1] < pair[0],
1389 "the cascade must decelerate: gaps {gaps:?}"
1390 );
1391 }
1392 let last = *delays.last().expect("participants exist");
1393 assert!(last <= STAGGER_TOTAL_CAP);
1394 assert_eq!(tokens.arrival_stagger(99, 100), last);
1397 assert_eq!(tokens.arrival_stagger(0, 1), Duration::ZERO);
1399 }
1400
1401 #[test]
1402 fn arrival_roster_retires_removed_identities() {
1403 let mut roster = ArrivalRoster::new();
1404 for id in 0..1_000 {
1405 roster.note(
1406 std::iter::once(ElementId::Integer(id)),
1407 true,
1408 &MotionTokens::DEFAULT,
1409 Instant::now(),
1410 );
1411 assert!(
1412 roster.seen.len() <= 1 && roster.delays.len() <= 1,
1413 "a one-item surface must not retain the history of removed items"
1414 );
1415 }
1416 }
1417
1418 #[test]
1419 fn arrival_roster_caps_overlapping_batches_and_retires_settled_clocks() {
1420 let mut roster = ArrivalRoster::new();
1421 let now = Instant::now();
1422 let tokens = MotionTokens::DEFAULT;
1423 roster.note(std::iter::empty(), true, &tokens, now);
1424 roster.note((0..1_000).map(ElementId::Integer), true, &tokens, now);
1425 assert_eq!(roster.delays.len(), STAGGER_PARTICIPANTS);
1426 roster.note((0..2_000).map(ElementId::Integer), true, &tokens, now);
1427 assert_eq!(roster.delays.len(), STAGGER_PARTICIPANTS);
1428 roster.note(
1429 (0..2_000).map(ElementId::Integer),
1430 true,
1431 &tokens,
1432 now + tokens.standard() + STAGGER_TOTAL_CAP,
1433 );
1434 assert!(roster.delays.is_empty(), "settled clocks must be retired");
1435 }
1436
1437 struct AcknowledgmentProbe {
1438 ordinal: u64,
1439 sample: Rc<Cell<f32>>,
1440 }
1441
1442 impl Render for AcknowledgmentProbe {
1443 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1444 self.sample.set(acknowledged_state(
1445 "ack-probe".into(),
1446 self.ordinal,
1447 window,
1448 cx,
1449 ));
1450 div()
1451 }
1452 }
1453
1454 #[gpui::test]
1455 fn acknowledgments_observe_every_change_without_replaying_snapshots(cx: &mut TestAppContext) {
1456 let sample = Rc::new(Cell::new(f32::NAN));
1457 let (probe, cx) = cx.add_window_view({
1458 let sample = sample.clone();
1459 move |_, _| AcknowledgmentProbe { ordinal: 0, sample }
1460 });
1461 draw(cx);
1462 assert_eq!(sample.get(), 1.0, "the first state is already presented");
1463
1464 for ordinal in [1, 0, 1] {
1465 probe.update(cx, |probe, cx| {
1466 probe.ordinal = ordinal;
1467 cx.notify();
1468 });
1469 draw(cx);
1470 assert_eq!(sample.get(), 0.0, "every controlled change acknowledges");
1471 cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
1472 assert_eq!(draw(cx), 0);
1473 assert_eq!(sample.get(), 1.0);
1474 assert_eq!(draw(cx), 0, "unchanged snapshots stay settled");
1475 }
1476
1477 cx.update(|_, cx| cx.set_reduce_motion(true));
1481 probe.update(cx, |probe, cx| {
1482 probe.ordinal = 2;
1483 cx.notify();
1484 });
1485 assert!(draw(cx) > 0, "the crossfade acknowledgment runs");
1486 cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
1487 draw(cx);
1488 assert_eq!(sample.get(), 1.0);
1489 assert_eq!(draw(cx), 0, "and settles");
1490
1491 cx.update(|_, cx| {
1493 MotionTokens::default()
1494 .with_preference(MotionPreference::Snap)
1495 .set(cx)
1496 });
1497 probe.update(cx, |probe, cx| {
1498 probe.ordinal = 3;
1499 cx.notify();
1500 });
1501 assert_eq!(draw(cx), 0, "snap acknowledges in one frame");
1502 assert_eq!(sample.get(), 1.0);
1503 cx.update(|_, cx| {
1504 MotionTokens::default().set(cx);
1505 cx.set_reduce_motion(false);
1506 });
1507 assert_eq!(
1508 draw(cx),
1509 0,
1510 "leaving reduced motion must not replay history"
1511 );
1512 }
1513
1514 #[gpui::test]
1515 fn install_respects_a_policy_the_application_chose_first(cx: &mut TestAppContext) {
1516 cx.update(|cx| {
1517 let chosen = Duration::from_millis(10);
1518 MotionTokens::default().with_standard(chosen).set(cx);
1519 install(cx);
1520 assert_eq!(
1521 MotionTokens::read(cx).standard(),
1522 chosen,
1523 "init must not overwrite an earlier application choice"
1524 );
1525 });
1526 }
1527
1528 #[gpui::test]
1529 fn install_provides_the_default_policy_when_none_was_chosen(cx: &mut TestAppContext) {
1530 cx.update(|cx| {
1531 assert_eq!(
1532 MotionTokens::read(cx),
1533 &MotionTokens::DEFAULT,
1534 "reads before install fall back to the same values"
1535 );
1536 install(cx);
1537 assert_eq!(MotionTokens::read(cx), &MotionTokens::DEFAULT);
1538 });
1539 }
1540
1541 #[derive(Debug, Clone, Copy)]
1542 enum LoopLifecycle {
1543 Active,
1544 Inactive,
1545 Complete,
1546 Offscreen,
1547 Dropped,
1548 }
1549
1550 struct LoopLifecycleProbe {
1551 lifecycle: LoopLifecycle,
1552 }
1553
1554 impl Render for LoopLifecycleProbe {
1555 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1556 let animated = || {
1557 div().with_animation(
1558 "lifecycle-loop",
1559 ProgressLoopSpec::SHIMMER.looping(),
1560 |element, _| element,
1561 )
1562 };
1563 match self.lifecycle {
1564 LoopLifecycle::Active => div().child(animated()),
1565 LoopLifecycle::Inactive
1569 | LoopLifecycle::Complete
1570 | LoopLifecycle::Offscreen
1571 | LoopLifecycle::Dropped => div().child(div()),
1572 }
1573 }
1574 }
1575
1576 fn next_frame(window: &WindowHandle<LoopLifecycleProbe>, cx: &mut TestAppContext) -> usize {
1577 let callbacks = window
1578 .update(cx, |_, window, cx| window.simulate_next_frame(cx))
1579 .expect("the motion audit window should remain open");
1580 cx.run_until_parked();
1581 callbacks
1582 }
1583
1584 fn set_lifecycle(
1585 lifecycle: LoopLifecycle,
1586 window: &WindowHandle<LoopLifecycleProbe>,
1587 cx: &mut TestAppContext,
1588 ) {
1589 window
1590 .update(cx, |probe, _, cx| {
1591 probe.lifecycle = lifecycle;
1592 cx.notify();
1593 })
1594 .expect("the motion audit window should remain open");
1595 cx.run_until_parked();
1596 next_frame(window, cx);
1599 }
1600
1601 #[gpui::test]
1602 fn repeating_loops_request_frames_only_for_visible_active_work(cx: &mut TestAppContext) {
1603 let window = cx.open_window(size(px(120.), px(80.)), |_, _| LoopLifecycleProbe {
1604 lifecycle: LoopLifecycle::Active,
1605 });
1606 cx.run_until_parked();
1607 assert_eq!(next_frame(&window, cx), 1, "active work must keep moving");
1608
1609 for lifecycle in [
1610 LoopLifecycle::Inactive,
1611 LoopLifecycle::Complete,
1612 LoopLifecycle::Offscreen,
1613 LoopLifecycle::Dropped,
1614 ] {
1615 set_lifecycle(lifecycle, &window, cx);
1616 assert_eq!(
1617 next_frame(&window, cx),
1618 0,
1619 "{lifecycle:?} content must settle without idle frame demand"
1620 );
1621 }
1622
1623 set_lifecycle(LoopLifecycle::Active, &window, cx);
1624 assert_eq!(next_frame(&window, cx), 1);
1625 }
1626
1627 #[gpui::test]
1628 fn reduced_motion_holds_a_repeating_loop_on_its_static_frame(cx: &mut TestAppContext) {
1629 cx.update(|cx| cx.set_reduce_motion(true));
1630 let window = cx.open_window(size(px(120.), px(80.)), |_, _| LoopLifecycleProbe {
1631 lifecycle: LoopLifecycle::Active,
1632 });
1633 cx.run_until_parked();
1634 assert_eq!(next_frame(&window, cx), 0);
1635 }
1636
1637 struct RevealProbe {
1639 delay: Duration,
1640 progress: Rc<Cell<f32>>,
1641 }
1642
1643 impl Render for RevealProbe {
1644 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1645 self.progress
1646 .set(reveal_progress("reveal-probe", self.delay, window, cx));
1647 div()
1648 }
1649 }
1650
1651 fn reveal_probe(
1652 reduce_motion: bool,
1653 cx: &mut TestAppContext,
1654 ) -> (Rc<Cell<f32>>, &mut VisualTestContext) {
1655 let progress = Rc::new(Cell::new(f32::NAN));
1656 let (_, cx) = cx.add_window_view({
1657 let progress = progress.clone();
1658 move |_, _| RevealProbe {
1659 delay: Duration::ZERO,
1660 progress,
1661 }
1662 });
1663 cx.update(|_, cx| cx.set_reduce_motion(reduce_motion));
1664 take_reveal_frame_requests();
1666 (progress, cx)
1667 }
1668
1669 fn draw(cx: &mut VisualTestContext) -> usize {
1670 cx.update(|window, cx| window.draw(cx).clear(cx));
1671 take_reveal_frame_requests()
1672 }
1673
1674 #[gpui::test]
1675 fn an_active_reveal_requests_a_frame_per_draw_until_it_settles(cx: &mut TestAppContext) {
1676 let (progress, cx) = reveal_probe(false, cx);
1677
1678 assert_eq!(draw(cx), 1, "a reveal at zero progress must keep drawing");
1679 assert_eq!(progress.get(), 0.0);
1680
1681 cx.executor().advance_clock(EnterSpec::REVEAL.duration / 2);
1682 assert_eq!(draw(cx), 1, "a half-played reveal must keep drawing");
1683 assert!(
1684 (0.0..1.0).contains(&progress.get()),
1685 "progress={}",
1686 progress.get()
1687 );
1688 }
1689
1690 #[gpui::test]
1691 fn a_settled_reveal_requests_no_further_frames(cx: &mut TestAppContext) {
1692 let (progress, cx) = reveal_probe(false, cx);
1693 draw(cx);
1694
1695 cx.executor().advance_clock(EnterSpec::REVEAL.duration);
1696 assert_eq!(draw(cx), 0, "the settling draw must be the last one");
1697 assert_eq!(progress.get(), 1.0);
1698
1699 assert_eq!(draw(cx), 0);
1701 assert_eq!(draw(cx), 0);
1702 assert_eq!(progress.get(), 1.0);
1703 }
1704
1705 #[gpui::test]
1706 fn reduced_motion_crossfades_at_the_quick_tempo(cx: &mut TestAppContext) {
1707 let (progress, cx) = reveal_probe(true, cx);
1711
1712 assert!(draw(cx) > 0, "the crossfade fade is live");
1713 assert!(progress.get() < 1.0);
1714 cx.update(|_, cx| assert_eq!(travel(cx), 0.0, "travel is zero under the flag"));
1715
1716 cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
1717 draw(cx);
1718 assert_eq!(progress.get(), 1.0);
1719 assert_eq!(draw(cx), 0, "a settled crossfade asks for nothing");
1720 }
1721
1722 #[gpui::test]
1723 fn the_snap_preference_returns_the_end_state_without_frames(cx: &mut TestAppContext) {
1724 let (progress, cx) = reveal_probe(false, cx);
1725 cx.update(|_, cx| {
1726 MotionTokens::default()
1727 .with_preference(MotionPreference::Snap)
1728 .set(cx)
1729 });
1730
1731 assert_eq!(draw(cx), 0, "snap must not animate");
1732 assert_eq!(progress.get(), 1.0);
1733
1734 cx.executor().advance_clock(EnterSpec::REVEAL.duration);
1735 assert_eq!(draw(cx), 0);
1736 assert_eq!(progress.get(), 1.0);
1737 }
1738
1739 #[gpui::test]
1740 fn a_replaced_policy_drives_a_running_reveal(cx: &mut TestAppContext) {
1741 let quickened = Duration::from_millis(10);
1742 let (progress, cx) = reveal_probe(false, cx);
1743 cx.update(|_, cx| MotionTokens::default().with_standard(quickened).set(cx));
1744
1745 assert_eq!(draw(cx), 1, "the reveal is live under the new policy");
1746 cx.executor().advance_clock(quickened);
1747 draw(cx);
1748 assert_eq!(
1749 progress.get(),
1750 1.0,
1751 "ten milliseconds settles a ten-millisecond policy"
1752 );
1753 assert_eq!(draw(cx), 0, "and a settled reveal stays settled");
1754 }
1755
1756 #[gpui::test]
1757 fn the_default_policy_is_still_travelling_at_that_moment(cx: &mut TestAppContext) {
1758 let (progress, cx) = reveal_probe(false, cx);
1762 draw(cx);
1763 cx.executor().advance_clock(Duration::from_millis(10));
1764 draw(cx);
1765 assert!(
1766 progress.get() < 1.0,
1767 "progress={} — the default tempo settled implausibly fast",
1768 progress.get()
1769 );
1770 }
1771
1772 #[gpui::test]
1773 fn no_policy_value_can_override_reduced_motion(cx: &mut TestAppContext) {
1774 let (progress, cx) = reveal_probe(true, cx);
1778 cx.update(|_, cx| {
1779 MotionTokens::default()
1780 .with_standard(Duration::from_secs(10))
1781 .with_preference(MotionPreference::Full)
1782 .set(cx);
1783 assert_eq!(
1784 MotionTokens::effective_preference(cx),
1785 MotionPreference::Crossfade,
1786 "full cannot outrank the OS signal"
1787 );
1788 assert_eq!(travel(cx), 0.0);
1789 });
1790
1791 cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
1792 draw(cx);
1793 assert_eq!(progress.get(), 1.0, "settled at quick, not at ten seconds");
1794
1795 cx.update(|_, cx| {
1797 MotionTokens::default()
1798 .with_preference(MotionPreference::Snap)
1799 .set(cx);
1800 assert_eq!(
1801 MotionTokens::effective_preference(cx),
1802 MotionPreference::Snap
1803 );
1804 });
1805 }
1806}