mod visibility;
pub(crate) use visibility::VisibleAnimationExt;
use gpui::{
Animation, AnimationElement, AnimationExt as _, App, ElementId, Global, Hsla, IntoElement,
ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Window, div,
ease_in_out, pulsating_between, px, relative,
};
pub(crate) fn ease_out_quint(t: f32) -> f32 {
let inverse = 1.0 - t.clamp(0.0, 1.0);
1.0 - inverse * inverse * inverse * inverse * inverse
}
pub(crate) fn ease_in_out_quart(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t < 0.5 {
8.0 * t * t * t * t
} else {
let inverse = 1.0 - t;
1.0 - 8.0 * inverse * inverse * inverse * inverse
}
}
use gpui_base::motion::{Transition, transition};
use gpui_component::{ActiveTheme as _, StyledExt as _};
use std::collections::HashMap;
use std::time::Duration;
#[cfg(not(target_family = "wasm"))]
pub(crate) use std::time::Instant;
#[cfg(target_family = "wasm")]
pub(crate) use web_time::Instant;
const SHIMMER_BAND: f32 = 0.45;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct EnterSpec {
pub(crate) duration: Duration,
pub(crate) stagger: Duration,
pub(crate) stagger_cap: usize,
pub(crate) rise: f32,
}
impl EnterSpec {
pub(crate) const REVEAL: Self = Self {
duration: Duration::from_millis(260),
stagger: Duration::from_millis(40),
stagger_cap: 12,
rise: 6.0,
};
pub(crate) fn stagger_delay(self, index: usize) -> Duration {
self.stagger * index.min(self.stagger_cap) as u32
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct ProgressLoopSpec {
pub(crate) period: Duration,
pub(crate) duty: f32,
}
impl ProgressLoopSpec {
pub(crate) const SHIMMER: Self = Self {
period: Duration::from_millis(1800),
duty: 0.72,
};
pub(crate) const GRID_SWEEP: Self = Self {
period: Duration::from_millis(1400),
duty: 1.0,
};
pub(crate) const IMAGE_PULSE: Self = Self {
period: Duration::from_millis(1600),
duty: 1.0,
};
pub(crate) const STATUS_SPINNER: Self = Self {
period: Duration::from_millis(900),
duty: 1.0,
};
pub(crate) fn looping(self) -> Animation {
repeating(self.period)
}
pub(crate) fn looping_synced(self) -> Animation {
repeating_synced(self.period)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct AmbientLoopSpec {
pub(crate) period: Duration,
}
impl AmbientLoopSpec {
pub(crate) const BREATHING: Self = Self {
period: Duration::from_millis(1600),
};
pub(crate) const ORB_LATTICE: Self = Self {
period: Duration::from_millis(1700),
};
pub(crate) const fn period_millis(self) -> u64 {
self.period.as_millis() as u64
}
pub(crate) fn looping(self) -> Animation {
repeating(self.period)
}
pub(crate) fn looping_synced(self) -> Animation {
repeating_synced(self.period)
}
}
fn repeating(period: Duration) -> Animation {
Animation::new(period).repeat()
}
fn repeating_synced(period: Duration) -> Animation {
Animation::new(period).repeat_synced()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum MotionPreference {
#[default]
Full,
Crossfade,
Snap,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpringRole {
response: Duration,
damping: f32,
}
impl SpringRole {
const fn new(response: Duration, damping: f32) -> Self {
Self { response, damping }
}
pub const fn response(self) -> Duration {
self.response
}
pub const fn damping(self) -> f32 {
self.damping
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MotionTokens {
preference: MotionPreference,
instant: Duration,
quick: Duration,
hover_glide: Duration,
standard: Duration,
deliberate: Duration,
stagger_beat: Duration,
stagger_cap: usize,
press: SpringRole,
selection: SpringRole,
disclosure: SpringRole,
reflow: SpringRole,
shimmer: ProgressLoopSpec,
grid_sweep: ProgressLoopSpec,
image_pulse: ProgressLoopSpec,
status_spinner: ProgressLoopSpec,
breathing: AmbientLoopSpec,
orb_lattice: AmbientLoopSpec,
}
impl Global for MotionTokens {}
impl Default for MotionTokens {
fn default() -> Self {
Self::DEFAULT
}
}
const STAGGER_PARTICIPANTS: usize = 6;
const STAGGER_TOTAL_CAP: Duration = Duration::from_millis(220);
impl MotionTokens {
pub const DEFAULT: Self = Self {
preference: MotionPreference::Full,
instant: Duration::ZERO,
quick: Duration::from_millis(150),
hover_glide: Duration::from_millis(100),
standard: EnterSpec::REVEAL.duration,
deliberate: Duration::from_millis(380),
stagger_beat: EnterSpec::REVEAL.stagger,
stagger_cap: EnterSpec::REVEAL.stagger_cap,
press: SpringRole::new(Duration::from_millis(140), 1.0),
selection: SpringRole::new(Duration::from_millis(220), 1.0),
disclosure: SpringRole::new(Duration::from_millis(280), 1.0),
reflow: SpringRole::new(Duration::from_millis(180), 1.0),
shimmer: ProgressLoopSpec::SHIMMER,
grid_sweep: ProgressLoopSpec::GRID_SWEEP,
image_pulse: ProgressLoopSpec::IMAGE_PULSE,
status_spinner: ProgressLoopSpec::STATUS_SPINNER,
breathing: AmbientLoopSpec::BREATHING,
orb_lattice: AmbientLoopSpec::ORB_LATTICE,
};
pub fn read(cx: &App) -> &Self {
cx.try_global::<Self>().unwrap_or(&Self::DEFAULT)
}
pub fn set(self, cx: &mut App) {
cx.set_global(self);
}
pub const fn instant(&self) -> Duration {
self.instant
}
pub const fn quick(&self) -> Duration {
self.quick
}
pub const fn hover_glide(&self) -> Duration {
self.hover_glide
}
pub const fn standard(&self) -> Duration {
self.standard
}
pub const fn deliberate(&self) -> Duration {
self.deliberate
}
pub const fn press_spring(&self) -> SpringRole {
self.press
}
pub const fn selection_spring(&self) -> SpringRole {
self.selection
}
pub const fn disclosure_spring(&self) -> SpringRole {
self.disclosure
}
pub const fn reflow_spring(&self) -> SpringRole {
self.reflow
}
pub const fn preference(&self) -> MotionPreference {
self.preference
}
pub fn effective_preference(cx: &App) -> MotionPreference {
let policy = Self::read(cx).preference;
if cx.reduce_motion() {
policy.max(MotionPreference::Crossfade)
} else {
policy
}
}
pub const fn with_preference(mut self, preference: MotionPreference) -> Self {
self.preference = preference;
self
}
pub const fn with_instant(mut self, duration: Duration) -> Self {
self.instant = duration;
self
}
pub const fn with_quick(mut self, duration: Duration) -> Self {
self.quick = duration;
self
}
pub const fn with_hover_glide(mut self, duration: Duration) -> Self {
self.hover_glide = duration;
self
}
pub const fn with_standard(mut self, duration: Duration) -> Self {
self.standard = duration;
self
}
pub const fn with_deliberate(mut self, duration: Duration) -> Self {
self.deliberate = duration;
self
}
pub const fn with_stagger_beat(mut self, beat: Duration) -> Self {
self.stagger_beat = beat;
self
}
pub const fn with_press_spring(mut self, response: Duration, damping: f32) -> Self {
self.press = SpringRole::new(response, damping);
self
}
pub const fn with_selection_spring(mut self, response: Duration, damping: f32) -> Self {
self.selection = SpringRole::new(response, damping);
self
}
pub const fn with_disclosure_spring(mut self, response: Duration, damping: f32) -> Self {
self.disclosure = SpringRole::new(response, damping);
self
}
pub const fn with_reflow_spring(mut self, response: Duration, damping: f32) -> Self {
self.reflow = SpringRole::new(response, damping);
self
}
pub(crate) const fn reveal(&self) -> EnterSpec {
EnterSpec {
duration: self.standard,
stagger: self.stagger_beat,
stagger_cap: self.stagger_cap,
rise: EnterSpec::REVEAL.rise,
}
}
pub(crate) const fn shimmer(&self) -> ProgressLoopSpec {
self.shimmer
}
pub(crate) const fn grid_sweep(&self) -> ProgressLoopSpec {
self.grid_sweep
}
pub(crate) const fn image_pulse(&self) -> ProgressLoopSpec {
self.image_pulse
}
pub(crate) const fn status_spinner(&self) -> ProgressLoopSpec {
self.status_spinner
}
pub(crate) const fn breathing(&self) -> AmbientLoopSpec {
self.breathing
}
pub(crate) const fn orb_lattice(&self) -> AmbientLoopSpec {
self.orb_lattice
}
pub fn arrival_stagger(&self, index: usize, arriving: usize) -> Duration {
let last = arriving.min(STAGGER_PARTICIPANTS).saturating_sub(1);
if last == 0 {
return Duration::ZERO;
}
let natural = self.stagger_beat * last as u32;
let total = natural.min(STAGGER_TOTAL_CAP);
let position = index.min(last) as f32 / last as f32;
let eased = position * (2.0 - position);
total.mul_f32(eased)
}
}
pub(crate) fn install(cx: &mut App) {
if !cx.has_global::<MotionTokens>() {
cx.set_global(MotionTokens::DEFAULT);
}
}
pub(crate) fn disclosure_progress(
id: impl Into<gpui_base::motion::TransitionId>,
open: bool,
window: &mut Window,
cx: &mut App,
) -> f32 {
let target = if open { 1.0f32 } else { 0.0 };
if !motion_is_full(cx) {
return target;
}
let standard = MotionTokens::read(cx).standard();
transition(
id,
target,
Transition::new(standard).ease(ease_out_quint),
window,
cx,
)
}
pub(crate) fn disclosure_clip(
id: impl Into<ElementId>,
progress: f32,
content: impl gpui::IntoElement,
window: &mut Window,
cx: &mut App,
) -> gpui::Div {
use gpui::prelude::FluentBuilder as _;
use gpui::{ParentElement as _, Styled as _};
use gpui_base::ElementExt as _;
let id = id.into();
let measured = window.use_keyed_state((id.clone(), "disclosure-height"), cx, |_, _| {
None::<gpui::Pixels>
});
let height = *measured.read(cx);
let clipping = progress < 1.0;
let clipped = match (height, clipping) {
(Some(height), true) => Some(height * progress.max(0.0)),
(None, true) => Some(gpui::Pixels::ZERO),
_ => None,
};
let recorder = measured;
gpui::div()
.w_full()
.overflow_hidden()
.when_some(clipped, |body, height| body.h(height))
.child(
gpui::div()
.w_full()
.flex_none()
.on_prepaint(move |bounds, _, cx| {
recorder.update(cx, |measured, _| *measured = Some(bounds.size.height));
})
.child(content),
)
}
pub(crate) fn disclosure_fade(
id: impl Into<gpui_base::motion::TransitionId>,
open: bool,
window: &mut Window,
cx: &mut App,
) -> f32 {
let target = if open { 1.0f32 } else { 0.0 };
match MotionTokens::effective_preference(cx) {
MotionPreference::Snap => target,
MotionPreference::Crossfade => {
let id = id.into();
crossfade_toward(
ElementId::Name(format!("{id:?}-disclosure-fade").into()),
target,
window,
cx,
)
}
MotionPreference::Full => disclosure_progress(id, open, window, cx),
}
}
pub(crate) struct ArrivalRoster {
primed: bool,
epoch: bool,
seen: HashMap<ElementId, bool>,
delays: HashMap<ElementId, ArrivalTiming>,
}
struct ArrivalTiming {
started_at: Instant,
delay: Duration,
duration: Duration,
}
impl ArrivalTiming {
fn progress(&self, now: Instant) -> f32 {
let elapsed = now.saturating_duration_since(self.started_at);
if self.duration.is_zero() {
1.0
} else if elapsed < self.delay {
0.0
} else {
(elapsed.saturating_sub(self.delay).as_secs_f32() / self.duration.as_secs_f32())
.min(1.0)
}
}
}
impl ArrivalRoster {
pub(crate) fn new() -> Self {
Self {
primed: false,
epoch: false,
seen: HashMap::new(),
delays: HashMap::new(),
}
}
pub(crate) fn note(
&mut self,
keys: impl Iterator<Item = ElementId>,
assign: bool,
tokens: &MotionTokens,
now: Instant,
) {
self.epoch = !self.epoch;
let mut fresh = Vec::new();
for key in keys {
if self.seen.insert(key.clone(), self.epoch).is_none()
&& fresh.len() < STAGGER_PARTICIPANTS
{
fresh.push(key);
}
}
self.seen.retain(|_, epoch| *epoch == self.epoch);
self.delays.retain(|key, timing| {
assign && self.seen.contains_key(key) && timing.progress(now) < 1.0
});
let primed = std::mem::replace(&mut self.primed, true);
let cascade = primed && assign;
fresh.truncate(STAGGER_PARTICIPANTS.saturating_sub(self.delays.len()));
let batch = fresh.len();
for (position, key) in fresh.into_iter().enumerate() {
if cascade {
self.delays.insert(
key,
ArrivalTiming {
started_at: now,
delay: tokens.arrival_stagger(position, batch),
duration: tokens.standard(),
},
);
}
}
}
pub(crate) fn progress(
&mut self,
key: &ElementId,
window: &mut Window,
cx: &App,
) -> Option<f32> {
let progress = self
.delays
.get(key)?
.progress(cx.background_executor().now());
if MotionTokens::effective_preference(cx) == MotionPreference::Snap || progress >= 1.0 {
self.delays.remove(key);
return None;
}
note_reveal_frame_request();
window.request_animation_frame();
Some(ease_out_quint(progress))
}
#[cfg(test)]
pub(crate) fn delay(&self, key: &ElementId) -> Option<Duration> {
self.delays.get(key).map(|timing| timing.delay)
}
}
#[derive(IntoElement)]
pub struct Shimmer {
id: ElementId,
style: StyleRefinement,
text: SharedString,
active: bool,
base: Option<Hsla>,
highlight: Option<Hsla>,
}
impl Shimmer {
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
text: text.into(),
active: true,
base: None,
highlight: None,
}
}
pub fn active(mut self, active: bool) -> Self {
self.active = active;
self
}
pub fn colors(mut self, base: Hsla, highlight: Hsla) -> Self {
self.base = Some(base);
self.highlight = Some(highlight);
self
}
}
impl Styled for Shimmer {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
fn shimmer_band_start(delta: f32, duty: f32) -> f32 {
let progress = ease_in_out((delta / duty).min(1.0));
-SHIMMER_BAND + progress * (1.0 + SHIMMER_BAND)
}
impl RenderOnce for Shimmer {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let base = self.base.unwrap_or(cx.theme().muted_foreground);
let highlight = self.highlight.unwrap_or(cx.theme().foreground);
if !self.active || !motion_is_full(cx) {
return div()
.whitespace_nowrap()
.text_color(base)
.child(self.text)
.refine_style(&self.style)
.into_any_element();
}
let text = self.text;
let spec = MotionTokens::read(cx).shimmer();
div()
.relative()
.overflow_hidden()
.whitespace_nowrap()
.text_color(base)
.child(text.clone())
.refine_style(&self.style)
.with_visible_animation(
(self.id, "shimmer"),
spec.looping(),
move |container, delta| {
let start = shimmer_band_start(delta, spec.duty);
container.child(
div()
.absolute()
.top_0()
.bottom_0()
.left(relative(start))
.w(relative(SHIMMER_BAND))
.overflow_hidden()
.child(
div()
.absolute()
.top_0()
.left(relative(-start / SHIMMER_BAND))
.whitespace_nowrap()
.text_color(highlight)
.child(text.clone()),
),
)
},
)
.into_any_element()
}
}
pub fn reveal_progress(
id: impl Into<ElementId>,
delay: Duration,
window: &mut Window,
cx: &mut App,
) -> f32 {
let duration = MotionTokens::read(cx).reveal().duration;
timed_progress(id, delay, duration, window, cx)
}
pub(crate) fn press_release_progress(
id: impl Into<ElementId>,
window: &mut Window,
cx: &mut App,
) -> f32 {
let response = MotionTokens::read(cx).press_spring().response();
timed_progress(id, Duration::ZERO, response, window, cx)
}
pub(crate) fn swap_progress(id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> f32 {
let duration = MotionTokens::read(cx).quick();
timed_progress(id, Duration::ZERO, duration, window, cx)
}
pub(crate) fn acknowledged_state(
slot: ElementId,
ordinal: u64,
window: &mut Window,
cx: &mut App,
) -> f32 {
let now = cx.background_executor().now();
let duration = MotionTokens::read(cx).quick();
let reduced =
MotionTokens::effective_preference(cx) == MotionPreference::Snap || duration.is_zero();
let state =
window.use_keyed_state((slot, "acknowledged-state"), cx, |_, _| AcknowledgedState {
ordinal,
started_at: None,
});
let progress = state.update(cx, |state, _| {
if state.ordinal != ordinal {
state.ordinal = ordinal;
state.started_at = Some(now);
}
if reduced {
state.started_at = None;
}
let progress = state.started_at.map_or(1.0, |started| {
(now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0)
});
if progress >= 1.0 {
state.started_at = None;
}
progress
});
if progress < 1.0 {
note_reveal_frame_request();
window.request_animation_frame();
}
ease_out_quint(progress)
}
struct AcknowledgedState {
ordinal: u64,
started_at: Option<Instant>,
}
pub(crate) fn travel(cx: &App) -> f32 {
if MotionTokens::effective_preference(cx) == MotionPreference::Full {
1.0
} else {
0.0
}
}
pub(crate) fn motion_is_full(cx: &App) -> bool {
MotionTokens::effective_preference(cx) == MotionPreference::Full
}
pub(crate) fn retarget_duration(cx: &App, duration: Duration) -> Duration {
if motion_is_full(cx) {
duration
} else {
Duration::ZERO
}
}
fn crossfade_toward(
id: impl Into<ElementId>,
target: f32,
window: &mut Window,
cx: &mut App,
) -> f32 {
let quick = MotionTokens::read(cx)
.quick()
.as_secs_f32()
.max(f32::EPSILON);
let now = cx.background_executor().now();
let state = window.use_keyed_state((id.into(), "crossfade"), cx, |_, _| CrossfadeState {
value: target,
at: now,
});
let value = state.update(cx, |state, _| {
let step = now.saturating_duration_since(state.at).as_secs_f32() / quick;
state.at = now;
state.value = if state.value < target {
(state.value + step).min(target)
} else {
(state.value - step).max(target)
};
state.value
});
if (value - target).abs() > f32::EPSILON {
note_reveal_frame_request();
window.request_animation_frame();
}
value
}
struct CrossfadeState {
value: f32,
at: Instant,
}
fn timed_progress(
id: impl Into<ElementId>,
delay: Duration,
duration: Duration,
window: &mut Window,
cx: &mut App,
) -> f32 {
let (delay, duration) = match MotionTokens::effective_preference(cx) {
MotionPreference::Snap => return 1.0,
MotionPreference::Crossfade => {
(Duration::ZERO, duration.min(MotionTokens::read(cx).quick()))
}
MotionPreference::Full => (delay, duration),
};
let now = cx.background_executor().now();
let started = *window.use_keyed_state(id, cx, |_, _| now).read(cx);
let elapsed = now.saturating_duration_since(started);
let progress = if elapsed <= delay {
0.0
} else {
(elapsed.saturating_sub(delay).as_secs_f32() / duration.as_secs_f32()).min(1.0)
};
if progress < 1.0 {
note_reveal_frame_request();
window.request_animation_frame();
}
ease_out_quint(progress)
}
#[cfg(test)]
thread_local! {
static REVEAL_FRAME_REQUESTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[inline]
fn note_reveal_frame_request() {
#[cfg(test)]
REVEAL_FRAME_REQUESTS.with(|count| count.set(count.get().saturating_add(1)));
}
#[cfg(test)]
pub(crate) fn take_reveal_frame_requests() -> usize {
REVEAL_FRAME_REQUESTS.with(|count| count.replace(0))
}
pub fn reveal<E>(element: E, id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> E
where
E: Styled,
{
apply_reveal(
element,
reveal_progress(id, Duration::ZERO, window, cx),
travel(cx),
)
}
pub fn reveal_staggered<E>(
element: E,
id: impl Into<ElementId>,
index: usize,
window: &mut Window,
cx: &mut App,
) -> E
where
E: Styled,
{
let delay = MotionTokens::read(cx).reveal().stagger_delay(index);
apply_reveal(element, reveal_progress(id, delay, window, cx), travel(cx))
}
const REVEAL_RISE: f32 = EnterSpec::REVEAL.rise;
fn apply_reveal<E: Styled>(element: E, progress: f32, travel: f32) -> E {
element
.opacity(progress)
.top(px(REVEAL_RISE * (1.0 - progress) * travel))
}
pub fn breathing<E>(element: E, id: impl Into<ElementId>) -> AnimationElement<E>
where
E: IntoElement + Styled + 'static,
{
breathing_with(MotionTokens::DEFAULT.breathing(), element, id)
}
pub(crate) fn breathing_with<E>(
spec: AmbientLoopSpec,
element: E,
id: impl Into<ElementId>,
) -> AnimationElement<E>
where
E: IntoElement + Styled + 'static,
{
element.with_animation(
id,
spec.looping().with_easing(pulsating_between(0.35, 1.0)),
|element, alpha| element.opacity(alpha),
)
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Context, Render, TestAppContext, VisualTestContext, WindowHandle, px, size};
use std::cell::Cell;
use std::rc::Rc;
#[test]
fn shimmer_band_travels_fully_across_and_rests_off_the_trailing_edge() {
let default_duty = ProgressLoopSpec::SHIMMER.duty;
assert!(default_duty > 0.0 && default_duty < 1.0);
for duty in [default_duty, 0.4] {
let start = shimmer_band_start(0.0, duty);
let quarter = shimmer_band_start(duty * 0.25, duty);
let middle = shimmer_band_start(duty * 0.5, duty);
let three_quarters = shimmer_band_start(duty * 0.75, duty);
let end = shimmer_band_start(duty, duty);
assert!(start + SHIMMER_BAND <= 0.0, "starts before the label");
assert!(start < quarter && quarter < middle);
assert!(middle < three_quarters && three_quarters < end);
assert!(
(middle + SHIMMER_BAND / 2.0 - 0.5).abs() < 1e-6,
"the band crosses the label's center halfway through travel"
);
assert!(end >= 1.0, "clears the trailing edge");
for delta in [duty + (1.0 - duty) * 0.5, 1.0] {
assert_eq!(shimmer_band_start(delta, duty), end, "rests offscreen");
}
}
}
#[test]
fn stagger_is_bounded_so_long_lists_do_not_wait_forever() {
let spec = MotionTokens::DEFAULT.reveal();
let far = spec.stagger_delay(12);
let capped = spec.stagger_delay(100);
assert_eq!(far, capped);
assert!(spec.stagger_delay(1) < far);
}
#[test]
fn default_policy_uses_the_documented_role_specs() {
let tokens = MotionTokens::DEFAULT;
assert_eq!(tokens.reveal(), EnterSpec::REVEAL);
assert_eq!(tokens.shimmer(), ProgressLoopSpec::SHIMMER);
assert_eq!(tokens.grid_sweep(), ProgressLoopSpec::GRID_SWEEP);
assert_eq!(tokens.image_pulse(), ProgressLoopSpec::IMAGE_PULSE);
assert_eq!(tokens.status_spinner(), ProgressLoopSpec::STATUS_SPINNER);
assert_eq!(tokens.breathing(), AmbientLoopSpec::BREATHING);
assert_eq!(tokens.orb_lattice(), AmbientLoopSpec::ORB_LATTICE);
assert_eq!(tokens.standard(), EnterSpec::REVEAL.duration);
assert_eq!(tokens.instant(), Duration::ZERO, "immediate is immediate");
assert_eq!(MotionTokens::default(), tokens);
}
#[test]
fn default_roles_have_ordered_tempos_and_safe_springs() {
let tokens = MotionTokens::DEFAULT;
assert!(tokens.quick() > Duration::ZERO);
assert!(tokens.quick() < tokens.standard());
assert!(tokens.standard() < tokens.deliberate());
for spring in [
tokens.press_spring(),
tokens.selection_spring(),
tokens.disclosure_spring(),
tokens.reflow_spring(),
] {
assert!(spring.response() > Duration::ZERO);
assert!(spring.damping() > 0.0);
}
assert!(tokens.press_spring().damping() >= 1.0);
assert!(tokens.disclosure_spring().damping() >= 1.0);
assert!(tokens.reflow_spring().damping() >= 1.0);
}
#[test]
fn arrival_stagger_decelerates_within_its_caps() {
let tokens = MotionTokens::DEFAULT;
let delays: Vec<_> = (0..STAGGER_PARTICIPANTS)
.map(|index| tokens.arrival_stagger(index, STAGGER_PARTICIPANTS))
.collect();
assert_eq!(delays[0], Duration::ZERO, "the first item leads at once");
let gaps: Vec<_> = delays.windows(2).map(|pair| pair[1] - pair[0]).collect();
for pair in gaps.windows(2) {
assert!(
pair[1] < pair[0],
"the cascade must decelerate: gaps {gaps:?}"
);
}
let last = *delays.last().expect("participants exist");
assert!(last <= STAGGER_TOTAL_CAP);
assert_eq!(tokens.arrival_stagger(99, 100), last);
assert_eq!(tokens.arrival_stagger(0, 1), Duration::ZERO);
}
#[test]
fn arrival_roster_retires_removed_identities() {
let mut roster = ArrivalRoster::new();
for id in 0..1_000 {
roster.note(
std::iter::once(ElementId::Integer(id)),
true,
&MotionTokens::DEFAULT,
Instant::now(),
);
assert!(
roster.seen.len() <= 1 && roster.delays.len() <= 1,
"a one-item surface must not retain the history of removed items"
);
}
}
#[test]
fn arrival_roster_caps_overlapping_batches_and_retires_settled_clocks() {
let mut roster = ArrivalRoster::new();
let now = Instant::now();
let tokens = MotionTokens::DEFAULT;
roster.note(std::iter::empty(), true, &tokens, now);
roster.note((0..1_000).map(ElementId::Integer), true, &tokens, now);
assert_eq!(roster.delays.len(), STAGGER_PARTICIPANTS);
roster.note((0..2_000).map(ElementId::Integer), true, &tokens, now);
assert_eq!(roster.delays.len(), STAGGER_PARTICIPANTS);
roster.note(
(0..2_000).map(ElementId::Integer),
true,
&tokens,
now + tokens.standard() + STAGGER_TOTAL_CAP,
);
assert!(roster.delays.is_empty(), "settled clocks must be retired");
}
struct AcknowledgmentProbe {
ordinal: u64,
sample: Rc<Cell<f32>>,
}
impl Render for AcknowledgmentProbe {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.sample.set(acknowledged_state(
"ack-probe".into(),
self.ordinal,
window,
cx,
));
div()
}
}
#[gpui::test]
fn acknowledgments_observe_every_change_without_replaying_snapshots(cx: &mut TestAppContext) {
let sample = Rc::new(Cell::new(f32::NAN));
let (probe, cx) = cx.add_window_view({
let sample = sample.clone();
move |_, _| AcknowledgmentProbe { ordinal: 0, sample }
});
draw(cx);
assert_eq!(sample.get(), 1.0, "the first state is already presented");
for ordinal in [1, 0, 1] {
probe.update(cx, |probe, cx| {
probe.ordinal = ordinal;
cx.notify();
});
draw(cx);
assert_eq!(sample.get(), 0.0, "every controlled change acknowledges");
cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
assert_eq!(draw(cx), 0);
assert_eq!(sample.get(), 1.0);
assert_eq!(draw(cx), 0, "unchanged snapshots stay settled");
}
cx.update(|_, cx| cx.set_reduce_motion(true));
probe.update(cx, |probe, cx| {
probe.ordinal = 2;
cx.notify();
});
assert!(draw(cx) > 0, "the crossfade acknowledgment runs");
cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
draw(cx);
assert_eq!(sample.get(), 1.0);
assert_eq!(draw(cx), 0, "and settles");
cx.update(|_, cx| {
MotionTokens::default()
.with_preference(MotionPreference::Snap)
.set(cx)
});
probe.update(cx, |probe, cx| {
probe.ordinal = 3;
cx.notify();
});
assert_eq!(draw(cx), 0, "snap acknowledges in one frame");
assert_eq!(sample.get(), 1.0);
cx.update(|_, cx| {
MotionTokens::default().set(cx);
cx.set_reduce_motion(false);
});
assert_eq!(
draw(cx),
0,
"leaving reduced motion must not replay history"
);
}
#[gpui::test]
fn install_respects_a_policy_the_application_chose_first(cx: &mut TestAppContext) {
cx.update(|cx| {
let chosen = Duration::from_millis(10);
MotionTokens::default().with_standard(chosen).set(cx);
install(cx);
assert_eq!(
MotionTokens::read(cx).standard(),
chosen,
"init must not overwrite an earlier application choice"
);
});
}
#[gpui::test]
fn install_provides_the_default_policy_when_none_was_chosen(cx: &mut TestAppContext) {
cx.update(|cx| {
assert_eq!(
MotionTokens::read(cx),
&MotionTokens::DEFAULT,
"reads before install fall back to the same values"
);
install(cx);
assert_eq!(MotionTokens::read(cx), &MotionTokens::DEFAULT);
});
}
#[derive(Debug, Clone, Copy)]
enum LoopLifecycle {
Active,
Inactive,
Complete,
Offscreen,
Dropped,
}
struct LoopLifecycleProbe {
lifecycle: LoopLifecycle,
}
impl Render for LoopLifecycleProbe {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let animated = || {
div().with_animation(
"lifecycle-loop",
ProgressLoopSpec::SHIMMER.looping(),
|element, _| element,
)
};
match self.lifecycle {
LoopLifecycle::Active => div().child(animated()),
LoopLifecycle::Inactive
| LoopLifecycle::Complete
| LoopLifecycle::Offscreen
| LoopLifecycle::Dropped => div().child(div()),
}
}
}
fn next_frame(window: &WindowHandle<LoopLifecycleProbe>, cx: &mut TestAppContext) -> usize {
let callbacks = window
.update(cx, |_, window, cx| window.simulate_next_frame(cx))
.expect("the motion audit window should remain open");
cx.run_until_parked();
callbacks
}
fn set_lifecycle(
lifecycle: LoopLifecycle,
window: &WindowHandle<LoopLifecycleProbe>,
cx: &mut TestAppContext,
) {
window
.update(cx, |probe, _, cx| {
probe.lifecycle = lifecycle;
cx.notify();
})
.expect("the motion audit window should remain open");
cx.run_until_parked();
next_frame(window, cx);
}
#[gpui::test]
fn repeating_loops_request_frames_only_for_visible_active_work(cx: &mut TestAppContext) {
let window = cx.open_window(size(px(120.), px(80.)), |_, _| LoopLifecycleProbe {
lifecycle: LoopLifecycle::Active,
});
cx.run_until_parked();
assert_eq!(next_frame(&window, cx), 1, "active work must keep moving");
for lifecycle in [
LoopLifecycle::Inactive,
LoopLifecycle::Complete,
LoopLifecycle::Offscreen,
LoopLifecycle::Dropped,
] {
set_lifecycle(lifecycle, &window, cx);
assert_eq!(
next_frame(&window, cx),
0,
"{lifecycle:?} content must settle without idle frame demand"
);
}
set_lifecycle(LoopLifecycle::Active, &window, cx);
assert_eq!(next_frame(&window, cx), 1);
}
#[gpui::test]
fn reduced_motion_holds_a_repeating_loop_on_its_static_frame(cx: &mut TestAppContext) {
cx.update(|cx| cx.set_reduce_motion(true));
let window = cx.open_window(size(px(120.), px(80.)), |_, _| LoopLifecycleProbe {
lifecycle: LoopLifecycle::Active,
});
cx.run_until_parked();
assert_eq!(next_frame(&window, cx), 0);
}
struct RevealProbe {
delay: Duration,
progress: Rc<Cell<f32>>,
}
impl Render for RevealProbe {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.progress
.set(reveal_progress("reveal-probe", self.delay, window, cx));
div()
}
}
fn reveal_probe(
reduce_motion: bool,
cx: &mut TestAppContext,
) -> (Rc<Cell<f32>>, &mut VisualTestContext) {
let progress = Rc::new(Cell::new(f32::NAN));
let (_, cx) = cx.add_window_view({
let progress = progress.clone();
move |_, _| RevealProbe {
delay: Duration::ZERO,
progress,
}
});
cx.update(|_, cx| cx.set_reduce_motion(reduce_motion));
take_reveal_frame_requests();
(progress, cx)
}
fn draw(cx: &mut VisualTestContext) -> usize {
cx.update(|window, cx| window.draw(cx).clear(cx));
take_reveal_frame_requests()
}
#[gpui::test]
fn an_active_reveal_requests_a_frame_per_draw_until_it_settles(cx: &mut TestAppContext) {
let (progress, cx) = reveal_probe(false, cx);
assert_eq!(draw(cx), 1, "a reveal at zero progress must keep drawing");
assert_eq!(progress.get(), 0.0);
cx.executor().advance_clock(EnterSpec::REVEAL.duration / 2);
assert_eq!(draw(cx), 1, "a half-played reveal must keep drawing");
assert!(
(0.0..1.0).contains(&progress.get()),
"progress={}",
progress.get()
);
}
#[gpui::test]
fn a_settled_reveal_requests_no_further_frames(cx: &mut TestAppContext) {
let (progress, cx) = reveal_probe(false, cx);
draw(cx);
cx.executor().advance_clock(EnterSpec::REVEAL.duration);
assert_eq!(draw(cx), 0, "the settling draw must be the last one");
assert_eq!(progress.get(), 1.0);
assert_eq!(draw(cx), 0);
assert_eq!(draw(cx), 0);
assert_eq!(progress.get(), 1.0);
}
#[gpui::test]
fn reduced_motion_crossfades_at_the_quick_tempo(cx: &mut TestAppContext) {
let (progress, cx) = reveal_probe(true, cx);
assert!(draw(cx) > 0, "the crossfade fade is live");
assert!(progress.get() < 1.0);
cx.update(|_, cx| assert_eq!(travel(cx), 0.0, "travel is zero under the flag"));
cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
draw(cx);
assert_eq!(progress.get(), 1.0);
assert_eq!(draw(cx), 0, "a settled crossfade asks for nothing");
}
#[gpui::test]
fn the_snap_preference_returns_the_end_state_without_frames(cx: &mut TestAppContext) {
let (progress, cx) = reveal_probe(false, cx);
cx.update(|_, cx| {
MotionTokens::default()
.with_preference(MotionPreference::Snap)
.set(cx)
});
assert_eq!(draw(cx), 0, "snap must not animate");
assert_eq!(progress.get(), 1.0);
cx.executor().advance_clock(EnterSpec::REVEAL.duration);
assert_eq!(draw(cx), 0);
assert_eq!(progress.get(), 1.0);
}
#[gpui::test]
fn a_replaced_policy_drives_a_running_reveal(cx: &mut TestAppContext) {
let quickened = Duration::from_millis(10);
let (progress, cx) = reveal_probe(false, cx);
cx.update(|_, cx| MotionTokens::default().with_standard(quickened).set(cx));
assert_eq!(draw(cx), 1, "the reveal is live under the new policy");
cx.executor().advance_clock(quickened);
draw(cx);
assert_eq!(
progress.get(),
1.0,
"ten milliseconds settles a ten-millisecond policy"
);
assert_eq!(draw(cx), 0, "and a settled reveal stays settled");
}
#[gpui::test]
fn the_default_policy_is_still_travelling_at_that_moment(cx: &mut TestAppContext) {
let (progress, cx) = reveal_probe(false, cx);
draw(cx);
cx.executor().advance_clock(Duration::from_millis(10));
draw(cx);
assert!(
progress.get() < 1.0,
"progress={} — the default tempo settled implausibly fast",
progress.get()
);
}
#[gpui::test]
fn no_policy_value_can_override_reduced_motion(cx: &mut TestAppContext) {
let (progress, cx) = reveal_probe(true, cx);
cx.update(|_, cx| {
MotionTokens::default()
.with_standard(Duration::from_secs(10))
.with_preference(MotionPreference::Full)
.set(cx);
assert_eq!(
MotionTokens::effective_preference(cx),
MotionPreference::Crossfade,
"full cannot outrank the OS signal"
);
assert_eq!(travel(cx), 0.0);
});
cx.executor().advance_clock(MotionTokens::DEFAULT.quick());
draw(cx);
assert_eq!(progress.get(), 1.0, "settled at quick, not at ten seconds");
cx.update(|_, cx| {
MotionTokens::default()
.with_preference(MotionPreference::Snap)
.set(cx);
assert_eq!(
MotionTokens::effective_preference(cx),
MotionPreference::Snap
);
});
}
}