T11d motion: marquee, ripple and confetti, glow and pulse, blink, flash and shake, drifting particles and tonal drift; Lottie is blocked

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 18:28:36 -04:00
1 parent 8e7e7ccace
commit 76da4bd39e
11 files changed
+1198 -22

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- One component, one home. Duplicates point to it.
- Fail fast. No silent fallbacks. Log state changes with `log`.
- Files stay at or under 500 lines. Comments are one short line, and rare.
- A repeating animation (spinner, breath, sweep) holds still under reduced motion instead of shortening, or it would spin at 1 ms a turn.
- A repeating animation (spinner, breath, sweep) holds still under reduced motion instead of shortening, or it would spin at 1 ms a turn. An effect set off by a change (flash, shake) reads elapsed time rather than restarting an animation id, so what it holds keeps its state.
- `motion::duration()` honors reduced motion. Springs overshoot: use them inside animators, never as gpui easings, because gpui asserts eased values stay in `0..=1`.
- `svg()` paints only with its own `text_color`. `Icon` always sets one.
- Buttons stay out of focus on click (`prevent_default` on mouse down). Tab still reaches them.
+4 -1
View File
@@ -41,7 +41,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T11a Loading · Spinners & skeletons — `tasks/ch11-20.md`
- [x] T11b Loading · Progress & states
- [x] T11c Motion · Presence & order
- [ ] T11d Motion · Effects
- [x] T11d Motion · Effects
- [ ] T12 Data Display
- [ ] T13 Lists & Trees
- [ ] T14 Tables
@@ -176,3 +176,6 @@ Per-component lines live in `tasks/`. Tags there:
| T11c | 1 | FAIL | a drag kept a stale row index and panicked when the list shrank, a move changed each row's animation id and rebuilt its state, and repeated demo tags shared a key |
| T11c | 2 | FAIL | a row first seen hidden drew open and asked for no frame, so it lingered until something else redrew |
| T11c | 3 | PASS | |
| T11d | 1 | FAIL | the ripple drew inverted columns in a cut corner, motes crossed rounded corners, a narrow marquee jumped, confetti burst on a falling count, and the Lottie note misread velato |
| T11d | 2 | FAIL | Marquee's doc still said its content is drawn twice |
| T11d | 3 | PASS | |
+240
View File
@@ -0,0 +1,240 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
feedback::InlineMessage,
motion::{
AnimatedGradient, Blink, Confetti, Flash, Glow, Marquee, ParticleBackground, Pulse, Ripple,
Shake,
},
primitives::Severity,
theme::{ActiveTheme, Radius, TextSize},
typography::tabular,
};
use gpui::{
App, FontWeight, IntoElement, ParentElement, Styled, Window, div, prelude::FluentBuilder,
};
use crate::{
probe::probe,
ui::{blocked, keep, row, section, set, specimen, specimens},
};
pub fn marquee(cx: &mut App) -> impl IntoElement + use<> {
let theme = cx.theme();
let (muted, size) = (theme.colors.fg_muted, theme.text_size(TextSize::Sm));
section(
"Marquee",
"A line that runs past at an even pace, with no seam where it loops.",
cx,
)
.child(
Marquee::new("marquee", move || {
div()
.flex()
.gap_8()
.pr_8()
.text_size(size)
.text_color(muted)
.children([
"Designed in the open",
"Built in Rust",
"Light and dark",
"Motion with restraint",
])
})
.w_128()
.py_2()
.border_t_1()
.border_b_1()
.border_color(theme.colors.border),
)
}
pub fn ripple(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let bursts = keep("confetti-bursts", || 0usize, window, cx);
let (now, more) = (*bursts.read(cx), bursts.clone());
let theme = cx.theme();
section(
"Ripple / Confetti",
"A circle of light spreads from each press, cut to the card's corners. A burst of paper for a real win, in the chart's quiet colors.",
cx,
)
.child(
row()
.items_start()
.child(probe(
"ripple",
Ripple::new("ripple")
.w_64()
.h_32()
.flex()
.items_center()
.justify_center()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.surface)
.child("Press anywhere"),
))
.child(
div()
.relative()
.w_64()
.h_32()
.flex()
.items_center()
.justify_center()
.child(probe(
"celebrate",
Button::new("celebrate", "Celebrate")
.primary()
.on_click(move |_, _, cx| set(&more, now + 1, cx)),
))
.child(Confetti::new("confetti", now)),
),
)
}
pub fn light(cx: &mut App) -> impl IntoElement + use<> {
let theme = cx.theme();
section(
"Glow / Pulse",
"A soft light that breathes around a card; rings that call out a live dot.",
cx,
)
.child(
specimens()
.child(specimen(
"glow",
Glow::new("glow")
.px_5()
.py_4()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.surface)
.child("Focus mode"),
cx,
))
.child(specimen(
"pulse",
div()
.flex()
.items_center()
.gap_2()
.child(
Pulse::new("pulse")
.size_2()
.rounded_full()
.bg(theme.colors.success),
)
.child("Live"),
cx,
)),
)
}
pub fn cues(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let price = keep("flash-price", || 1284u32, window, cx);
let tries = keep("shake-tries", || 0usize, window, cx);
let (value, tick) = (*price.read(cx), price.clone());
let (count, again) = (*tries.read(cx), tries.clone());
let theme = cx.theme();
section(
"Blink / Flash / Shake",
"A caret's rhythm; a tint that fades when a value moves; a side-to-side no for a wrong entry.",
cx,
)
.child(
specimens()
.child(specimen(
"blink",
div().flex().items_center().child("Type here").child(
Blink::new("blink").w_0p5().h_4().ml_0p5().bg(theme.colors.fg),
),
cx,
))
.child(specimen(
"flash",
row()
.child(
Flash::new("flash", value)
.px_2()
.rounded(theme.radius(Radius::Sm))
.child(tabular(div()).font_weight(FontWeight::MEDIUM).child(format!("{}.{:02}", value / 100, value % 100))),
)
.child(probe(
"flash-tick",
Button::new("flash-tick", "Tick")
.variant(ButtonVariant::Ghost)
.on_click(move |_, _, cx| set(&tick, value + 37, cx)),
)),
cx,
))
.child(specimen(
"shake",
row()
.child(
Shake::new("shake", count).child(
div()
.w_48()
.px_3()
.py_1p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if count > 0 { theme.colors.danger } else { theme.colors.border })
.child("••••••"),
),
)
.child(probe(
"shake-try",
Button::new("shake-try", "Sign in").on_click(move |_, _, cx| set(&again, count + 1, cx)),
))
.when(count > 0, |line| line.child(InlineMessage::new(Severity::Danger, "Wrong password"))),
cx,
)),
)
}
pub fn backdrops(cx: &mut App) -> impl IntoElement + use<> {
let theme = cx.theme();
let panel = |text: &'static str| {
div()
.size_full()
.flex()
.items_center()
.justify_center()
.text_size(theme.text_size(TextSize::Lg))
.font_weight(FontWeight::SEMIBOLD)
.child(text)
};
section(
"ParticleBackground / AnimatedGradient / Lottie Player",
"Faint motes that drift behind a panel; a tone that drifts, slowly, a breath toward the accent.",
cx,
)
.child(
row()
.child(
ParticleBackground::new("particles")
.w_80()
.h_40()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.child(panel("Quiet motion")),
)
.child(
AnimatedGradient::new("tide")
.w_80()
.h_40()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.child(panel("Tonal drift")),
),
)
.child(blocked(
"Lottie Player is blocked: gpui 0.2.2 paints paths without the clip masks, mattes, blend modes and radial gradients Lottie files lean on, so frames would come out wrong.",
cx,
))
}
+13
View File
@@ -1,3 +1,4 @@
mod effects;
mod loading;
mod moves;
mod progress;
@@ -53,6 +54,13 @@ const SCRIPT: &[Step] = &[
Step::Click("flip-shuffle"),
Step::Wait(100),
Step::Shot("flip-gliding"),
Step::Click("ripple"),
Step::Wait(120),
Step::Shot("ripple"),
Step::Click("flash-tick"),
Step::Click("shake-try"),
Step::Wait(100),
Step::Shot("cues"),
Step::Rest,
];
@@ -74,5 +82,10 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(moves::presence(window, cx))
.child(moves::flip(window, cx))
.child(moves::reorder(window, cx))
.child(effects::marquee(cx))
.child(effects::ripple(window, cx))
.child(effects::light(cx))
.child(effects::cues(window, cx))
.child(effects::backdrops(cx))
.into_any_element()
}
+395
View File
@@ -0,0 +1,395 @@
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,
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,
base: div(),
body: SmallVec::new(),
}
}
}
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 = 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);
}
}
+4
View File
@@ -1,7 +1,9 @@
mod changes;
mod curve;
mod effects;
mod list;
mod overlay;
mod particles;
mod progress;
mod reorder;
mod skeleton;
@@ -14,8 +16,10 @@ mod tests;
pub(crate) use changes::changes;
pub use curve::*;
pub use effects::{Blink, Flash, Glow, Marquee, Pulse, Shake};
pub use list::{AnimatePresence, Flip};
pub use overlay::{LazyLoad, LoadingOverlay, Refresh, RefreshIndicator, TypingIndicator};
pub use particles::{AnimatedGradient, Confetti, ParticleBackground, Ripple};
pub use progress::{ProgressBar, ProgressRing};
pub use reorder::Reorder;
pub use skeleton::{Shimmer, Skeleton, SkeletonAvatar, SkeletonCard, SkeletonTable, SkeletonText};
+450
View File
@@ -0,0 +1,450 @@
use std::{
f32::consts::TAU,
time::{Duration, Instant},
};
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 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)));
}
}
+14 -11
View File
@@ -260,8 +260,20 @@ impl ParentElement for Shimmer {
/// The corners' radii, clockwise from the top left.
type Radii = [Pixels; 4];
/// A style's corner radii in pixels, clockwise from the top left.
pub(super) fn radii(style: &StyleRefinement, rem: Pixels) -> Radii {
let corners = &style.corner_radii;
let radius = |corner: Option<AbsoluteLength>| corner.map_or(Pixels::ZERO, |r| r.to_pixels(rem));
[
radius(corners.top_left),
radius(corners.top_right),
radius(corners.bottom_right),
radius(corners.bottom_left),
]
}
/// Where a rounded box's outline crosses the column at `x`: its top and bottom. Radii past half the box are clamped, as gpui draws them.
fn span(bounds: Bounds<Pixels>, radii: Radii, x: Pixels) -> (Pixels, Pixels) {
pub(super) fn span(bounds: Bounds<Pixels>, radii: Radii, x: Pixels) -> (Pixels, Pixels) {
let limit = bounds.size.width.min(bounds.size.height) / 2.0;
let [tl, tr, br, bl] = radii.map(|radius| radius.min(limit));
let bite = |radius: Pixels, from_edge: Pixels| {
@@ -326,16 +338,7 @@ impl RenderOnce for Shimmer {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (shine, clear) = (theme.colors.shimmer, theme.colors.shimmer.alpha(0.0));
let rem = window.rem_size();
let radius =
|corner: Option<AbsoluteLength>| corner.map_or(Pixels::ZERO, |r| r.to_pixels(rem));
let corners = self.base.style().corner_radii.clone();
let radii: Radii = [
radius(corners.top_left),
radius(corners.top_right),
radius(corners.bottom_right),
radius(corners.bottom_left),
];
let radii = radii(self.base.style(), window.rem_size());
let still = theme.reduced_motion;
let base = self.base.relative().children(self.body);
if still {
+63
View File
@@ -339,3 +339,66 @@ fn a_row_hidden_from_the_start_is_gone_at_once(cx: &mut TestAppContext) {
let log = view.read_with(cx, |stage, _| stage.log.clone());
assert_eq!(log, ["gone c"], "no fold to wait for");
}
/// A shaken and a flashed box around keyed state, and a press target under a ripple and confetti.
struct Effects {
key: usize,
presses: usize,
seen: Rc<std::cell::RefCell<std::collections::HashMap<&'static str, gpui::EntityId>>>,
}
impl Render for Effects {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let marker = |key| Marker {
key,
seen: self.seen.clone(),
};
div()
.child(super::Shake::new("shake", self.key).child(marker("shaken")))
.child(super::Flash::new("flash", self.key).child(marker("flashed")))
.child(
div()
.relative()
.w(px(200.0))
.h(px(100.0))
.child(super::Ripple::new("ripple").size_full().child(
div().id("target").size_full().on_click(move |_, _, cx| {
view.update(cx, |effects, _| effects.presses += 1)
}),
))
.child(super::Confetti::new("confetti", self.key)),
)
}
}
#[gpui::test]
fn effects_keep_their_content_and_let_presses_through(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
let (view, cx) = cx.add_window_view(|_, _| Effects {
key: 0,
presses: 0,
seen: Rc::default(),
});
settle(cx);
let seen = view.read_with(cx, |effects, _| effects.seen.clone());
let before = seen.borrow().clone();
view.update(cx, |effects, cx| {
effects.key += 1;
cx.notify();
});
settle(cx);
assert_eq!(
*seen.borrow(),
before,
"a shake and a flash keep what they hold"
);
let at = point(px(100.0), px(90.0));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_click(at, Modifiers::none());
settle(cx);
assert_eq!(view.read_with(cx, |effects, _| effects.presses), 1);
}
+5
View File
@@ -253,6 +253,11 @@ impl Theme {
px_to_rems(360.0)
}
/// How far a glow's light reaches at its brightest.
pub fn glow_reach(&self) -> Rems {
px_to_rems(20.0)
}
/// A picture's height in a skeleton card.
pub fn skeleton_media(&self) -> Rems {
px_to_rems(128.0)
+9 -9
View File
@@ -38,15 +38,15 @@
## T11d Motion · Effects
- [ ] Marquee
- [ ] Ripple
- [ ] Confetti
- [ ] Glow / Pulse
- [ ] Blink / Flash
- [ ] Shake
- [ ] ParticleBackground
- [ ] AnimatedGradient — slow tonal drift
- [ ] Lottie Player — prove: `rlottie` frames
- [x] Marquee → `motion::Marquee`
- [x] Ripple → `motion::Ripple`
- [x] Confetti → `motion::Confetti`
- [x] Glow / Pulse → `motion::Glow`, `motion::Pulse`
- [x] Blink / Flash → `motion::Blink`, `motion::Flash`
- [x] Shake → `motion::Shake`
- [x] ParticleBackground → `motion::ParticleBackground`
- [x] AnimatedGradient — slow tonal drift → `motion::AnimatedGradient`
- [x] Lottie Player — blocked: gpui 0.2.2 paints paths without the clip masks, mattes, blend modes and radial gradients Lottie files lean on, so frames would come out wrong
## T12 Data Display — `src/data_display`