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:
11 files changed
+1198
-22
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 | |
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user