From 76da4bd39e2db808dda433116f6062e83b2df8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 18:28:36 -0400 Subject: [PATCH] T11d motion: marquee, ripple and confetti, glow and pulse, blink, flash and shake, drifting particles and tonal drift; Lottie is blocked --- AGENTS.md | 2 +- TASKS.md | 5 +- examples/gallery/pages/motion/effects.rs | 240 ++++++++++++ examples/gallery/pages/motion/mod.rs | 13 + src/motion/effects.rs | 395 ++++++++++++++++++++ src/motion/mod.rs | 4 + src/motion/particles.rs | 450 +++++++++++++++++++++++ src/motion/skeleton.rs | 25 +- src/motion/tests.rs | 63 ++++ src/theme/tokens.rs | 5 + tasks/ch11-20.md | 18 +- 11 files changed, 1198 insertions(+), 22 deletions(-) create mode 100644 examples/gallery/pages/motion/effects.rs create mode 100644 src/motion/effects.rs create mode 100644 src/motion/particles.rs diff --git a/AGENTS.md b/AGENTS.md index 20e09ee..64fd8a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/TASKS.md b/TASKS.md index 83084d3..ff85d73 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/examples/gallery/pages/motion/effects.rs b/examples/gallery/pages/motion/effects.rs new file mode 100644 index 0000000..0d87abb --- /dev/null +++ b/examples/gallery/pages/motion/effects.rs @@ -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, + )) +} diff --git a/examples/gallery/pages/motion/mod.rs b/examples/gallery/pages/motion/mod.rs index 785af02..1fe511a 100644 --- a/examples/gallery/pages/motion/mod.rs +++ b/examples/gallery/pages/motion/mod.rs @@ -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() } diff --git a/src/motion/effects.rs b/src/motion/effects.rs new file mode 100644 index 0000000..76403d1 --- /dev/null +++ b/src/motion/effects.rs @@ -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( + id: &ElementId, + key: K, + length: Duration, + window: &mut Window, + cx: &mut App, +) -> Option { + let state = window.use_keyed_state((id.clone(), "since"), cx, |_, _| { + (key.clone(), None::) + }); + 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) { + 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) -> 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 { + id: ElementId, + key: K, + base: Div, + body: SmallVec<[AnyElement; 2]>, +} + +impl Flash { + pub fn new(id: impl Into, key: K) -> Self { + Self { + id: id.into(), + key, + base: div(), + body: SmallVec::new(), + } + } +} + +impl Styled for Flash { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for Flash { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for Flash { + 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 { + id: ElementId, + key: K, + base: Div, + body: SmallVec<[AnyElement; 2]>, +} + +impl Shake { + pub fn new(id: impl Into, key: K) -> Self { + Self { + id: id.into(), + key, + base: div(), + body: SmallVec::new(), + } + } +} + +impl Styled for Shake { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for Shake { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for Shake { + 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) -> Self { + Self { + id: id.into(), + base: div(), + body: SmallVec::new(), + } + } +} + +fn glow(color: Hsla, reach: Pixels, strength: f32) -> Vec { + 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) -> 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 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( + id: impl Into, + 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); + } +} diff --git a/src/motion/mod.rs b/src/motion/mod.rs index 77663c6..341b082 100644 --- a/src/motion/mod.rs +++ b/src/motion/mod.rs @@ -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}; diff --git a/src/motion/particles.rs b/src/motion/particles.rs new file mode 100644 index 0000000..6d9fe74 --- /dev/null +++ b/src/motion/particles.rs @@ -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, 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, 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) -> 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) { + 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, radii: [Pixels; 4], at: Point, 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, + 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, + 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::); + 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, + radii: [Pixels; 4], + at: Point, + 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, + radii: [Pixels; 4], + at: Point, + 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::>() + }; + assert_eq!(draw(7), draw(7)); + assert_ne!(draw(7), draw(8)); + assert!(draw(7).iter().all(|value| (0.0..1.0).contains(value))); + } +} diff --git a/src/motion/skeleton.rs b/src/motion/skeleton.rs index 7511333..a7f6f84 100644 --- a/src/motion/skeleton.rs +++ b/src/motion/skeleton.rs @@ -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| 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, radii: Radii, x: Pixels) -> (Pixels, Pixels) { +pub(super) fn span(bounds: Bounds, 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| 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 { diff --git a/src/motion/tests.rs b/src/motion/tests.rs index 789e49b..ab508f7 100644 --- a/src/motion/tests.rs +++ b/src/motion/tests.rs @@ -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>>, +} + +impl Render for Effects { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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); +} diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index a660f4a..661e10d 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -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) diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index 885c16e..7a93e95 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -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`