Files
Ely-GPUI-Components/src/motion/effects.rs
T

404 lines
12 KiB
Rust

use std::{
f32::consts::{PI, TAU},
time::{Duration, Instant},
};
use gpui::{
Animation, AnimationExt, AnyElement, App, BoxShadow, Div, ElementId, Hsla, IntoElement,
ParentElement, Pixels, RenderOnce, StyleRefinement, Styled, Window, canvas, div, point,
prelude::*,
};
use smallvec::SmallVec;
use super::{NUDGE, SLOW, duration, ease_out_cubic};
use crate::theme::ActiveTheme;
/// A caret's rhythm, on then off.
const BLINK: Duration = Duration::from_millis(1_060);
/// How long a flash takes to fade.
const FLASH: Duration = Duration::from_millis(900);
/// How long a shake lasts, and how far it swings.
const SHAKE: Duration = Duration::from_millis(420);
/// One breath of a glow, and one ring of a pulse.
const BREATH: Duration = Duration::from_millis(2_400);
const RING: Duration = Duration::from_millis(1_600);
/// How fast a marquee travels.
const PACE: f32 = 40.0;
/// How far into its effect a change is, 0 to 1, and whether it still moves; it asks for the next frame while it does.
fn since_change<K: Clone + PartialEq + 'static>(
id: &ElementId,
key: K,
length: Duration,
window: &mut Window,
cx: &mut App,
) -> Option<f32> {
let state = window.use_keyed_state((id.clone(), "since"), cx, |_, _| {
(key.clone(), None::<Instant>)
});
if state.read(cx).0 != key {
state.update(cx, |state, _| *state = (key, Some(Instant::now())));
}
let since = state.read(cx).1?;
let share = since.elapsed().as_secs_f32() / length.as_secs_f32().max(f32::EPSILON);
if share >= 1.0 {
return None;
}
window.request_animation_frame();
Some(share)
}
macro_rules! holder {
($name:ident, $doc:literal) => {
#[doc = $doc]
#[derive(IntoElement)]
pub struct $name {
id: ElementId,
base: Div,
body: SmallVec<[AnyElement; 2]>,
}
impl Styled for $name {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for $name {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
};
}
holder!(
Blink,
"Blinks what it holds on and off, a caret's rhythm. Under reduced motion it stays on."
);
impl Blink {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
body: SmallVec::new(),
}
}
}
impl RenderOnce for Blink {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let held = self.base.children(self.body);
if cx.theme().reduced_motion {
return held.into_any_element();
}
held.with_animation(self.id, Animation::new(BLINK).repeat(), |held, t| {
held.opacity(if t < 0.5 { 1.0 } else { 0.0 })
})
.into_any_element()
}
}
/// Tints what it holds for a moment each time `key` changes, then lets the tint fade: a cue that a value moved.
#[derive(IntoElement)]
pub struct Flash<K: Clone + PartialEq + 'static> {
id: ElementId,
key: K,
tint: Option<Hsla>,
base: Div,
body: SmallVec<[AnyElement; 2]>,
}
impl<K: Clone + PartialEq + 'static> Flash<K> {
pub fn new(id: impl Into<ElementId>, key: K) -> Self {
Self {
id: id.into(),
key,
tint: None,
base: div(),
body: SmallVec::new(),
}
}
/// The color it flashes; the selection's unless set.
pub fn tint(mut self, tint: Hsla) -> Self {
self.tint = Some(tint);
self
}
}
impl<K: Clone + PartialEq + 'static> Styled for Flash<K> {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl<K: Clone + PartialEq + 'static> ParentElement for Flash<K> {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl<K: Clone + PartialEq + 'static> RenderOnce for Flash<K> {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let share = since_change(&self.id, self.key, duration(FLASH, cx), window, cx);
let tint = self.tint.unwrap_or(cx.theme().colors.selection);
self.base.children(self.body).when_some(share, |held, t| {
held.bg(tint.opacity(1.0 - ease_out_cubic(t)))
})
}
}
/// Shakes what it holds side to side each time `key` changes, as a field refusing an entry. Under reduced motion it holds still.
#[derive(IntoElement)]
pub struct Shake<K: Clone + PartialEq + 'static> {
id: ElementId,
key: K,
base: Div,
body: SmallVec<[AnyElement; 2]>,
}
impl<K: Clone + PartialEq + 'static> Shake<K> {
pub fn new(id: impl Into<ElementId>, key: K) -> Self {
Self {
id: id.into(),
key,
base: div(),
body: SmallVec::new(),
}
}
}
impl<K: Clone + PartialEq + 'static> Styled for Shake<K> {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl<K: Clone + PartialEq + 'static> ParentElement for Shake<K> {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl<K: Clone + PartialEq + 'static> RenderOnce for Shake<K> {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let share = since_change(&self.id, self.key, SHAKE, window, cx);
let still = cx.theme().reduced_motion;
let swing = NUDGE * 1.5;
div()
.child(self.base.children(self.body))
.when_some(share.filter(|_| !still), move |held, t| {
held.ml(swing * (t * PI * 6.0).sin() * (1.0 - t))
})
}
}
holder!(
Glow,
"A soft light that breathes around what it holds. Under reduced motion it holds its middle glow."
);
impl Glow {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
body: SmallVec::new(),
}
}
}
fn glow(color: Hsla, reach: Pixels, strength: f32) -> Vec<BoxShadow> {
vec![BoxShadow {
color: color.opacity(0.25 * strength),
offset: point(Pixels::ZERO, Pixels::ZERO),
blur_radius: reach * (0.4 + 0.6 * strength),
spread_radius: Pixels::ZERO,
}]
}
impl RenderOnce for Glow {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (color, reach) = (
theme.colors.accent,
theme.glow_reach().to_pixels(window.rem_size()),
);
let held = self.base.children(self.body);
if theme.reduced_motion {
return held.shadow(glow(color, reach, 0.5)).into_any_element();
}
held.with_animation(self.id, Animation::new(BREATH).repeat(), move |held, t| {
held.shadow(glow(color, reach, 0.5 - 0.5 * (TAU * t).cos()))
})
.into_any_element()
}
}
holder!(
Pulse,
"Rings that widen and fade from what it holds, as a live dot calls out. Under reduced motion no ring shows."
);
impl Pulse {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
body: SmallVec::new(),
}
}
}
impl RenderOnce for Pulse {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let color = theme.colors.accent;
let held = self.base.relative().children(self.body);
if theme.reduced_motion {
return held.into_any_element();
}
held.child(div().absolute().inset_0().with_animation(
self.id,
Animation::new(RING).repeat(),
move |ring, t| {
ring.child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let side = bounds.size.width.max(bounds.size.height);
let radius = side / 2.0 * (1.0 + t);
let reach = bounds.center();
let corner = point(reach.x - radius, reach.y - radius);
window.paint_quad(
gpui::outline(
gpui::Bounds::new(
corner,
gpui::size(radius * 2.0, radius * 2.0),
),
color.opacity(0.5 * (1.0 - t)),
gpui::BorderStyle::Solid,
)
.corner_radii(radius),
);
},
)
.size_full(),
)
},
))
.into_any_element()
}
}
/// Copies that keep the box covered while the row slides one copy's width: enough to span the box, and one more.
fn copies(span: Pixels, room: Pixels) -> usize {
if span <= Pixels::ZERO {
return 2;
}
((f32::from(room) / f32::from(span)).ceil() as usize + 1).max(2)
}
/// Runs its content past in a loop, right to left, at an even pace. Under reduced motion it stands still.
#[derive(IntoElement)]
pub struct Marquee {
id: ElementId,
base: Div,
content: Box<dyn Fn() -> AnyElement>,
}
impl Marquee {
/// `content` is drawn as many times as it takes to cover the box, and once more, so the loop has no seam.
pub fn new<E: IntoElement>(
id: impl Into<ElementId>,
content: impl Fn() -> E + 'static,
) -> Self {
Self {
id: id.into(),
base: div(),
content: Box::new(move || content().into_any_element()),
}
}
}
impl Styled for Marquee {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Marquee {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let widths = window.use_keyed_state((self.id.clone(), "widths"), cx, |_, _| {
(Pixels::ZERO, Pixels::ZERO)
});
let (span, room) = *widths.read(cx);
let measure = |pick: fn(&mut (Pixels, Pixels)) -> &mut Pixels| {
let widths = widths.clone();
canvas(
move |bounds, window, cx| {
let mut now = *widths.read(cx);
if *pick(&mut now) != bounds.size.width {
*pick(&mut now) = bounds.size.width;
widths.update(cx, |widths, cx| {
*widths = now;
cx.notify();
});
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
};
let copies = copies(span, room);
let first = div()
.flex_none()
.relative()
.child((self.content)())
.child(measure(|widths| &mut widths.0));
let row = div()
.flex()
.flex_none()
.child(first)
.children((1..copies).map(|_| div().flex_none().child((self.content)())));
let still = cx.theme().reduced_motion || span <= Pixels::ZERO;
let lap = Duration::from_secs_f32(f32::from(span) / PACE).max(SLOW);
let row = if still {
row.into_any_element()
} else {
row.with_animation(
(self.id.clone(), "lap"),
Animation::new(lap).repeat(),
move |row, t| row.ml(-span * t),
)
.into_any_element()
};
self.base
.relative()
.overflow_hidden()
.child(measure(|widths| &mut widths.1))
.child(row)
}
}
#[cfg(test)]
mod tests {
use gpui::px;
use super::copies;
#[test]
fn a_marquee_draws_enough_copies_to_stay_covered() {
assert_eq!(copies(px(80.0), px(450.0)), 7);
assert!(copies(px(80.0), px(450.0)) as f32 * 80.0 >= 450.0 + 80.0);
assert_eq!(copies(px(600.0), px(450.0)), 2);
assert_eq!(copies(px(0.0), px(450.0)), 2);
}
}