use gpui::{
AnimationExt as _, AnyElement, App, ElementId, IntoElement, ParentElement, Styled, Svg,
Transformation, div, percentage, px, relative,
};
use gpui_kit_theme::Theme;
use super::spec::{MotionSpec, pulse_wave, shimmer_offset};
use super::{CubicBezier, Easing, reduce_motion};
const BREATH_FLOOR: f32 = 0.45;
const SWEEP_BAND: f32 = 0.35;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Activity {
Advancing,
Working,
Deliberating,
}
impl Activity {
pub fn period_ms(self, theme: &Theme) -> u64 {
match self {
Self::Advancing => theme.motion.shimmer_ms,
Self::Working => theme.motion.spin_ms,
Self::Deliberating => theme.motion.pulse_ms,
}
}
pub fn curve(self, theme: &Theme) -> CubicBezier {
match self {
Self::Working => Easing::Linear.curve(theme),
Self::Advancing | Self::Deliberating => Easing::EaseInOut.curve(theme),
}
}
fn spec(self, theme: &Theme) -> MotionSpec {
MotionSpec::new(self.period_ms(theme), self.curve(theme))
}
}
pub fn spin(icon: Svg, id: impl Into<ElementId>, theme: &Theme, cx: &App) -> AnyElement {
if reduce_motion(cx) {
return icon.into_any_element();
}
let activity = Activity::Working;
icon.with_animation(
id.into(),
activity.spec(theme).repeating(),
|element, progress| {
element.with_transformation(Transformation::rotate(percentage(progress)))
},
)
.into_any_element()
}
pub fn breathe<E>(element: E, id: impl Into<ElementId>, theme: &Theme, cx: &App) -> AnyElement
where
E: Styled + IntoElement + 'static,
{
if reduce_motion(cx) {
return element.into_any_element();
}
let activity = Activity::Deliberating;
element
.with_animation(
id.into(),
activity.spec(theme).repeating(),
|element, progress| {
element.opacity(BREATH_FLOOR + (1.0 - BREATH_FLOOR) * pulse_wave(progress))
},
)
.into_any_element()
}
pub fn sweep(
id: impl Into<ElementId>,
theme: &Theme,
color: gpui::Hsla,
cx: &App,
) -> Option<AnyElement> {
if reduce_motion(cx) {
return None;
}
let activity = Activity::Advancing;
let band = div()
.absolute()
.top_0()
.bottom_0()
.flex()
.flex_row()
.w(relative(SWEEP_BAND))
.child(div().h_full().w(relative(0.5)).bg(gpui::linear_gradient(
90.0,
gpui::linear_color_stop(color.opacity(0.0), 0.0),
gpui::linear_color_stop(color, 1.0),
)))
.child(div().h_full().w(relative(0.5)).bg(gpui::linear_gradient(
90.0,
gpui::linear_color_stop(color, 0.0),
gpui::linear_color_stop(color.opacity(0.0), 1.0),
)))
.with_animation(
id.into(),
activity.spec(theme).repeating(),
|element, progress| element.left(relative(shimmer_offset(progress, SWEEP_BAND))),
);
Some(band.into_any_element())
}
pub fn breathing_dot(
id: impl Into<ElementId>,
theme: &Theme,
color: gpui::Hsla,
size: f32,
cx: &App,
) -> AnyElement {
breathe(div().size(px(size)).rounded_full().bg(color), id, theme, cx)
}
#[cfg(test)]
mod tests {
use super::*;
fn theme() -> Theme {
Theme::studio_dark()
}
#[test]
fn the_three_activities_run_at_three_different_rates() {
let theme = theme();
let periods = [
Activity::Advancing.period_ms(&theme),
Activity::Working.period_ms(&theme),
Activity::Deliberating.period_ms(&theme),
];
for (index, period) in periods.iter().enumerate() {
for other in &periods[index + 1..] {
assert_ne!(period, other);
}
}
}
#[test]
fn a_turn_runs_linear_and_the_others_do_not() {
let theme = theme();
let linear = Easing::Linear.curve(&theme);
assert_eq!(Activity::Working.curve(&theme), linear);
assert_ne!(Activity::Deliberating.curve(&theme), linear);
assert_ne!(Activity::Advancing.curve(&theme), linear);
}
#[test]
fn every_period_is_a_token_the_theme_carries() {
let theme = theme();
assert_eq!(
Activity::Advancing.period_ms(&theme),
theme.motion.shimmer_ms
);
assert_eq!(Activity::Working.period_ms(&theme), theme.motion.spin_ms);
assert_eq!(
Activity::Deliberating.period_ms(&theme),
theme.motion.pulse_ms
);
}
#[test]
fn a_breath_never_fades_to_nothing() {
const { assert!(BREATH_FLOOR > 0.0) };
for step in 0..=8 {
let opacity = BREATH_FLOOR + (1.0 - BREATH_FLOOR) * pulse_wave(step as f32 / 8.0);
assert!(opacity >= BREATH_FLOOR, "{opacity}");
assert!(opacity <= 1.0, "{opacity}");
}
}
}