449 lines
15 KiB
Rust
449 lines
15 KiB
Rust
use std::{f32::consts::TAU, time::Duration};
|
|
|
|
use gpui::{
|
|
Animation, AnimationExt, AnyElement, App, Bounds, Div, ElementId, Hsla, InteractiveElement,
|
|
IntoElement, MouseButton, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
|
|
StyleRefinement, Styled, Window, canvas, div, fill, linear_color_stop, linear_gradient, point,
|
|
px, size,
|
|
};
|
|
use smallvec::SmallVec;
|
|
use web_time::Instant;
|
|
|
|
use super::{
|
|
duration,
|
|
skeleton::{radii, span},
|
|
};
|
|
use crate::theme::ActiveTheme;
|
|
|
|
/// How long a burst of confetti falls, and how many pieces it throws.
|
|
const FALL: Duration = Duration::from_millis(1_800);
|
|
const PIECES: usize = 42;
|
|
/// One slow round of drifting particles, and how many there are.
|
|
const DRIFT: Duration = Duration::from_secs(24);
|
|
const MOTES: usize = 36;
|
|
/// One full turn of a tonal drift.
|
|
const TIDE: Duration = Duration::from_secs(40);
|
|
/// How long a ripple takes to spread and fade.
|
|
const SPREAD: Duration = Duration::from_millis(560);
|
|
|
|
/// A small repeatable random stream, so a burst or a field looks the same each time.
|
|
struct Seed(u32);
|
|
|
|
impl Seed {
|
|
fn next(&mut self) -> f32 {
|
|
self.0 ^= self.0 << 13;
|
|
self.0 ^= self.0 >> 17;
|
|
self.0 ^= self.0 << 5;
|
|
(self.0 % 10_000) as f32 / 10_000.0
|
|
}
|
|
}
|
|
|
|
/// A burst of paper pieces thrown up from the middle each time `bursts` grows; they tumble and fall in the chart's quiet colors. Under reduced motion nothing is thrown.
|
|
#[derive(IntoElement)]
|
|
pub struct Confetti {
|
|
id: ElementId,
|
|
bursts: usize,
|
|
}
|
|
|
|
impl Confetti {
|
|
/// Place it over the area the pieces should fill; it takes no presses.
|
|
pub fn new(id: impl Into<ElementId>, bursts: usize) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
bursts,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The last count and the burst number after a new `bursts`: only growth throws a burst.
|
|
fn step((last, turn): (usize, usize), bursts: usize) -> (usize, usize) {
|
|
(bursts, turn + usize::from(bursts > last))
|
|
}
|
|
|
|
fn piece(center: Point<Pixels>, side: Pixels, turn: f32, color: Hsla, window: &mut Window) {
|
|
let (cos, sin) = (turn.cos(), turn.sin());
|
|
let corner = |x: f32, y: f32| {
|
|
let (dx, dy) = (side * x, side * 0.5 * y);
|
|
point(
|
|
center.x + dx * cos - dy * sin,
|
|
center.y + dx * sin + dy * cos,
|
|
)
|
|
};
|
|
let mut path = PathBuilder::fill();
|
|
path.move_to(corner(-0.5, -0.5));
|
|
path.line_to(corner(0.5, -0.5));
|
|
path.line_to(corner(0.5, 0.5));
|
|
path.line_to(corner(-0.5, 0.5));
|
|
path.close();
|
|
match path.build() {
|
|
Ok(path) => window.paint_path(path, color),
|
|
Err(error) => log::error!("confetti: a piece failed to build: {error:#}"),
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for Confetti {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let bursts = self.bursts;
|
|
let count =
|
|
window.use_keyed_state((self.id.clone(), "bursts"), cx, |_, _| (bursts, 0usize));
|
|
let next = step(*count.read(cx), bursts);
|
|
if next != *count.read(cx) {
|
|
count.update(cx, |count, _| *count = next);
|
|
}
|
|
let turn = next.1;
|
|
let theme = cx.theme();
|
|
if turn == 0 || theme.reduced_motion {
|
|
return div().absolute().inset_0().into_any_element();
|
|
}
|
|
let colors = theme.colors.chart;
|
|
let side = theme.status_dot() * 1.5;
|
|
let side = side.to_pixels(window.rem_size());
|
|
div()
|
|
.absolute()
|
|
.inset_0()
|
|
.with_animation(
|
|
(self.id, format!("burst-{turn}")),
|
|
Animation::new(FALL),
|
|
move |sky, t| {
|
|
sky.child(
|
|
canvas(
|
|
|_, _, _| {},
|
|
move |bounds, _, window, _| {
|
|
let mut seed = Seed(0x9e37_79b9 ^ turn as u32);
|
|
let origin = bounds.center();
|
|
let height = f32::from(bounds.size.height);
|
|
let secs = t * FALL.as_secs_f32();
|
|
let fade = 1.0 - ((t - 0.7) / 0.3).clamp(0.0, 1.0);
|
|
for ix in 0..PIECES {
|
|
let angle = -TAU / 4.0 + (seed.next() - 0.5) * 1.4;
|
|
let speed = height * (2.4 + 1.2 * seed.next());
|
|
let (vx, vy) = (angle.cos() * speed, angle.sin() * speed);
|
|
let x = vx * secs;
|
|
let y = vy * secs + 0.5 * height * 4.5 * secs * secs;
|
|
let spin =
|
|
seed.next() * TAU + secs * (6.0 + seed.next() * 10.0);
|
|
let color = colors[ix % colors.len()].opacity(fade);
|
|
let at = point(origin.x + px(x), origin.y + px(y));
|
|
piece(at, side, spin, color, window);
|
|
}
|
|
},
|
|
)
|
|
.size_full(),
|
|
)
|
|
},
|
|
)
|
|
.into_any_element()
|
|
}
|
|
}
|
|
|
|
macro_rules! backdrop {
|
|
($name:ident, $doc:literal) => {
|
|
#[doc = $doc]
|
|
#[derive(IntoElement)]
|
|
pub struct $name {
|
|
id: ElementId,
|
|
base: Div,
|
|
body: SmallVec<[AnyElement; 2]>,
|
|
}
|
|
|
|
impl $name {
|
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
base: div(),
|
|
body: SmallVec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
backdrop!(
|
|
ParticleBackground,
|
|
"Faint motes that drift slowly behind what it holds. Under reduced motion they rest."
|
|
);
|
|
|
|
/// Whether a dot of `radius` at `at` lies wholly inside the rounded box.
|
|
fn inside(bounds: Bounds<Pixels>, radii: [Pixels; 4], at: Point<Pixels>, radius: Pixels) -> bool {
|
|
[at.x - radius, at.x, at.x + radius].iter().all(|x| {
|
|
let (top, bottom) = span(bounds, radii, *x);
|
|
at.y - radius >= top && at.y + radius <= bottom
|
|
})
|
|
}
|
|
|
|
fn motes(
|
|
bounds: Bounds<Pixels>,
|
|
radii: [Pixels; 4],
|
|
t: f32,
|
|
color: Hsla,
|
|
radius: Pixels,
|
|
window: &mut Window,
|
|
) {
|
|
let mut seed = Seed(0x2545_f491);
|
|
for _ in 0..MOTES {
|
|
let (x, y, phase, reach) = (
|
|
seed.next(),
|
|
seed.next(),
|
|
seed.next() * TAU,
|
|
0.02 + seed.next() * 0.04,
|
|
);
|
|
let drift = TAU * t + phase;
|
|
let at = point(
|
|
bounds.left() + bounds.size.width * (x + reach * drift.cos()),
|
|
bounds.top() + bounds.size.height * (y + reach * drift.sin()),
|
|
);
|
|
if !inside(bounds, radii, at, radius) {
|
|
continue;
|
|
}
|
|
let corner = point(at.x - radius, at.y - radius);
|
|
window.paint_quad(
|
|
fill(Bounds::new(corner, size(radius * 2.0, radius * 2.0)), color).corner_radii(radius),
|
|
);
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for ParticleBackground {
|
|
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let radii = radii(self.base.style(), window.rem_size());
|
|
let theme = cx.theme();
|
|
let color = theme.colors.fg.opacity(0.08);
|
|
let radius = (theme.status_dot() / 2.0).to_pixels(window.rem_size());
|
|
let still = theme.reduced_motion;
|
|
let field = move |t: f32| {
|
|
canvas(
|
|
|_, _, _| {},
|
|
move |bounds, _, window, _| motes(bounds, radii, t, color, radius, window),
|
|
)
|
|
.absolute()
|
|
.inset_0()
|
|
};
|
|
let layer = if still {
|
|
div()
|
|
.absolute()
|
|
.inset_0()
|
|
.child(field(0.0))
|
|
.into_any_element()
|
|
} else {
|
|
div()
|
|
.absolute()
|
|
.inset_0()
|
|
.with_animation(self.id, Animation::new(DRIFT).repeat(), move |layer, t| {
|
|
layer.child(field(t))
|
|
})
|
|
.into_any_element()
|
|
};
|
|
self.base
|
|
.relative()
|
|
.overflow_hidden()
|
|
.child(layer)
|
|
.children(self.body)
|
|
}
|
|
}
|
|
|
|
backdrop!(
|
|
AnimatedGradient,
|
|
"A background whose tone drifts, slowly, between the surface and a breath of the accent. Under reduced motion it rests."
|
|
);
|
|
|
|
impl RenderOnce for AnimatedGradient {
|
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let theme = cx.theme();
|
|
let (from, to) = (theme.colors.surface, theme.colors.selection);
|
|
let tone = move |t: f32| {
|
|
linear_gradient(
|
|
90.0 + 360.0 * t,
|
|
linear_color_stop(from, 0.0),
|
|
linear_color_stop(to, 1.0),
|
|
)
|
|
};
|
|
let base = self.base.children(self.body);
|
|
if theme.reduced_motion {
|
|
return base.bg(tone(0.0)).into_any_element();
|
|
}
|
|
base.with_animation(self.id, Animation::new(TIDE).repeat(), move |base, t| {
|
|
base.bg(tone(t))
|
|
})
|
|
.into_any_element()
|
|
}
|
|
}
|
|
|
|
/// Where the last press landed, and when.
|
|
#[derive(Clone, Copy)]
|
|
struct Press {
|
|
at: Point<Pixels>,
|
|
since: Instant,
|
|
turn: usize,
|
|
}
|
|
|
|
backdrop!(
|
|
Ripple,
|
|
"A circle of light that spreads from each press and fades, cut to the box's rounded corners. Under reduced motion none shows."
|
|
);
|
|
|
|
impl RenderOnce for Ripple {
|
|
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let press = window.use_keyed_state((self.id.clone(), "press"), cx, |_, _| None::<Press>);
|
|
let theme = cx.theme();
|
|
let (still, color) = (theme.reduced_motion, theme.colors.fg);
|
|
let radii = radii(self.base.style(), window.rem_size());
|
|
let held = press.clone();
|
|
let base = self
|
|
.base
|
|
.id(self.id.clone())
|
|
.relative()
|
|
.children(self.body)
|
|
.on_mouse_down(MouseButton::Left, move |event, _, cx| {
|
|
held.update(cx, |press, cx| {
|
|
let turn = press.map_or(1, |press| press.turn + 1);
|
|
*press = Some(Press {
|
|
at: event.position,
|
|
since: Instant::now(),
|
|
turn,
|
|
});
|
|
cx.notify();
|
|
});
|
|
});
|
|
let Some(last) = *press.read(cx) else {
|
|
return base.into_any_element();
|
|
};
|
|
let length = duration(SPREAD, cx);
|
|
if still || last.since.elapsed() >= length {
|
|
return base.into_any_element();
|
|
}
|
|
window.request_animation_frame();
|
|
base.child(div().absolute().inset_0().with_animation(
|
|
(self.id, format!("ripple-{}", last.turn)),
|
|
Animation::new(length),
|
|
move |layer, t| {
|
|
layer.child(
|
|
canvas(
|
|
|_, _, _| {},
|
|
move |bounds, _, window, _| ring(bounds, radii, last.at, t, color, window),
|
|
)
|
|
.size_full(),
|
|
)
|
|
},
|
|
))
|
|
.into_any_element()
|
|
}
|
|
}
|
|
|
|
/// Where a disc of `reach` around `at` covers the rounded box: each column's x, top and bottom, left to right.
|
|
fn disc_columns(
|
|
bounds: Bounds<Pixels>,
|
|
radii: [Pixels; 4],
|
|
at: Point<Pixels>,
|
|
reach: f32,
|
|
) -> Vec<(Pixels, Pixels, Pixels)> {
|
|
let (left, right) = (
|
|
(at.x - px(reach)).max(bounds.left()),
|
|
(at.x + px(reach)).min(bounds.right()),
|
|
);
|
|
if left >= right {
|
|
return Vec::new();
|
|
}
|
|
(0..=24)
|
|
.map(|ix| left + (right - left) * (ix as f32 / 24.0))
|
|
.map(|x| {
|
|
let dx = f32::from(x - at.x);
|
|
let half = px((reach * reach - dx * dx).max(0.0).sqrt());
|
|
let (top, bottom) = span(bounds, radii, x);
|
|
(x, (at.y - half).max(top), (at.y + half).min(bottom))
|
|
})
|
|
.filter(|(_, top, bottom)| top < bottom)
|
|
.collect()
|
|
}
|
|
|
|
/// The ripple at `t`: a disc from `at`, cut to the rounded box, fading as it grows.
|
|
fn ring(
|
|
bounds: Bounds<Pixels>,
|
|
radii: [Pixels; 4],
|
|
at: Point<Pixels>,
|
|
t: f32,
|
|
color: Hsla,
|
|
window: &mut Window,
|
|
) {
|
|
let far = [
|
|
bounds.origin,
|
|
bounds.top_right(),
|
|
bounds.bottom_left(),
|
|
bounds.bottom_right(),
|
|
]
|
|
.iter()
|
|
.map(|corner| {
|
|
let (dx, dy) = (f32::from(corner.x - at.x), f32::from(corner.y - at.y));
|
|
(dx * dx + dy * dy).sqrt()
|
|
})
|
|
.fold(0.0_f32, f32::max);
|
|
let reach = far * (1.0 - (1.0 - t).powi(3));
|
|
let columns = disc_columns(bounds, radii, at, reach);
|
|
if columns.len() < 2 {
|
|
return;
|
|
}
|
|
let mut path = PathBuilder::fill();
|
|
path.move_to(point(columns[0].0, columns[0].1));
|
|
for (x, top, _) in &columns[1..] {
|
|
path.line_to(point(*x, *top));
|
|
}
|
|
for (x, _, bottom) in columns.iter().rev() {
|
|
path.line_to(point(*x, *bottom));
|
|
}
|
|
path.close();
|
|
match path.build() {
|
|
Ok(path) => window.paint_path(path, color.opacity(0.12 * (1.0 - t))),
|
|
Err(error) => log::error!("ripple: the ring failed to build: {error:#}"),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::Seed;
|
|
|
|
use gpui::{Bounds, point, px, size};
|
|
|
|
use super::{disc_columns, inside, step};
|
|
|
|
#[test]
|
|
fn only_a_growing_count_throws_a_burst() {
|
|
assert_eq!(step((2, 1), 3), (3, 2));
|
|
assert_eq!(step((2, 1), 1), (1, 1));
|
|
assert_eq!(step((2, 1), 2), (2, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn a_ripple_and_a_mote_keep_inside_rounded_corners() {
|
|
let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(40.0)));
|
|
let pill = [px(20.0); 4];
|
|
assert!(
|
|
disc_columns(bounds, pill, point(px(1.0), px(1.0)), 4.0).is_empty(),
|
|
"the corner is outside the pill"
|
|
);
|
|
assert!(!disc_columns(bounds, pill, point(px(50.0), px(20.0)), 10.0).is_empty());
|
|
assert!(!inside(bounds, pill, point(px(3.0), px(3.0)), px(2.0)));
|
|
assert!(inside(bounds, pill, point(px(50.0), px(20.0)), px(2.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_seed_gives_the_same_stream_each_time() {
|
|
let draw = |seed| {
|
|
let mut seed = Seed(seed);
|
|
(0..4).map(|_| seed.next()).collect::<Vec<f32>>()
|
|
};
|
|
assert_eq!(draw(7), draw(7));
|
|
assert_ne!(draw(7), draw(8));
|
|
assert!(draw(7).iter().all(|value| (0.0..1.0).contains(value)));
|
|
}
|
|
}
|