T11a loading: spinner in six motions, skeletons that breathe, a shimmer cut to its corners, loading buttons and load more

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 16:58:05 -04:00
1 parent 866335912a
commit d4066661d5
17 files changed
+1184 -28

No files matched your search

+4
View File
@@ -1,7 +1,11 @@
mod changes;
mod curve;
mod skeleton;
mod slide;
mod spinner;
pub(crate) use changes::changes;
pub use curve::*;
pub use skeleton::{Shimmer, Skeleton, SkeletonAvatar, SkeletonCard, SkeletonTable, SkeletonText};
pub(crate) use slide::{Axis, Marker, glide, measure_item, measure_origin, slide};
pub use spinner::{Spinner, SpinnerStyle};
+396
View File
@@ -0,0 +1,396 @@
use std::{f32::consts::TAU, time::Duration};
use gpui::{
AbsoluteLength, Animation, AnimationExt, AnyElement, App, Bounds, Div, ElementId, Hsla,
IntoElement, ParentElement, PathBuilder, Pixels, RenderOnce, StyleRefinement, Styled, Window,
canvas, div, linear_color_stop, linear_gradient, point, prelude::*, px, relative,
};
use smallvec::SmallVec;
use crate::theme::{ActiveTheme, ControlSize, Mix, Radius, TextSize};
/// One breath of a skeleton, in and out.
const BREATH: Duration = Duration::from_millis(1_600);
/// One pass of a shimmer's band.
const SWEEP: Duration = Duration::from_millis(1_800);
/// Columns in each half of a shimmer's band, where it meets rounded corners.
const RAMP: usize = 12;
/// Widths a skeleton table's cells cycle through.
const CELLS: [f32; 4] = [0.72, 0.48, 0.6, 0.36];
/// A placeholder in the shape of what is loading; it breathes until the content arrives. Size it as the content.
#[derive(IntoElement)]
pub struct Skeleton {
id: ElementId,
base: Div,
}
impl Skeleton {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
}
}
/// Round, for avatars and icons.
pub fn circle(mut self) -> Self {
self.base = self.base.rounded_full();
self
}
}
impl Styled for Skeleton {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Skeleton {
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let mut block = div()
.rounded(theme.radius(Radius::Sm))
.bg(theme.colors.active);
block.style().refine(self.base.style());
if theme.reduced_motion {
return block.into_any_element();
}
block
.with_animation(self.id, Animation::new(BREATH).repeat(), |block, t| {
block.opacity(0.775 + 0.225 * (TAU * t).cos())
})
.into_any_element()
}
}
/// Lines of text to come; the last runs short.
#[derive(IntoElement)]
pub struct SkeletonText {
id: ElementId,
lines: usize,
}
impl SkeletonText {
pub fn new(id: impl Into<ElementId>, lines: usize) -> Self {
assert!(lines > 0, "a skeleton text needs a line");
Self {
id: id.into(),
lines,
}
}
}
impl RenderOnce for SkeletonText {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let line = cx.theme().text_size(TextSize::Base);
div()
.w_full()
.flex()
.flex_col()
.gap_2()
.children((0..self.lines).map(|ix| {
let width = if ix + 1 == self.lines && self.lines > 1 {
0.6
} else {
1.0
};
Skeleton::new((self.id.clone(), format!("line-{ix}")))
.h(line)
.w(relative(width))
}))
}
}
/// A round avatar to come.
#[derive(IntoElement)]
pub struct SkeletonAvatar {
id: ElementId,
}
impl SkeletonAvatar {
pub fn new(id: impl Into<ElementId>) -> Self {
Self { id: id.into() }
}
}
impl RenderOnce for SkeletonAvatar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
Skeleton::new(self.id)
.circle()
.flex_none()
.size(cx.theme().control_height(ControlSize::Lg))
}
}
/// A card to come: who, a picture and a few lines.
#[derive(IntoElement)]
pub struct SkeletonCard {
id: ElementId,
}
impl SkeletonCard {
pub fn new(id: impl Into<ElementId>) -> Self {
Self { id: id.into() }
}
}
impl RenderOnce for SkeletonCard {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let line = theme.text_size(TextSize::Base);
let id = |part: &'static str| (self.id.clone(), part);
div()
.w_full()
.flex()
.flex_col()
.gap_4()
.p_4()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.surface)
.child(
div()
.flex()
.items_center()
.gap_3()
.child(SkeletonAvatar::new(id("avatar")))
.child(
div()
.flex_1()
.flex()
.flex_col()
.gap_2()
.child(Skeleton::new(id("name")).h(line).w(relative(0.45)))
.child(Skeleton::new(id("when")).h(line).w(relative(0.25))),
),
)
.child(
Skeleton::new(id("picture"))
.w_full()
.h(theme.skeleton_media())
.rounded(theme.radius(Radius::Md)),
)
.child(SkeletonText::new(id("text"), 2))
}
}
/// A table to come: a header and rows of cells.
#[derive(IntoElement)]
pub struct SkeletonTable {
id: ElementId,
rows: usize,
columns: usize,
}
impl SkeletonTable {
pub fn new(id: impl Into<ElementId>, rows: usize, columns: usize) -> Self {
assert!(rows > 0 && columns > 0, "a skeleton table needs a cell");
Self {
id: id.into(),
rows,
columns,
}
}
}
impl RenderOnce for SkeletonTable {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (line, border) = (theme.text_size(TextSize::Base), theme.colors.border);
let row = |row: usize| {
div()
.flex()
.gap_4()
.py_3()
.border_b_1()
.border_color(border)
.children((0..self.columns).map(|column| {
let width = if row == 0 {
0.4
} else {
CELLS[(row + column) % CELLS.len()]
};
div().flex_1().child(
Skeleton::new((self.id.clone(), format!("cell-{row}-{column}")))
.h(line)
.w(relative(width)),
)
}))
};
div()
.w_full()
.flex()
.flex_col()
.children((0..=self.rows).map(row))
}
}
/// Sweeps a soft band of light across what it holds while that loads. It holds still under reduced motion.
#[derive(IntoElement)]
pub struct Shimmer {
id: ElementId,
base: Div,
body: SmallVec<[AnyElement; 2]>,
}
impl Shimmer {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
body: SmallVec::new(),
}
}
}
impl Styled for Shimmer {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for Shimmer {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
/// The corners' radii, clockwise from the top left.
type Radii = [Pixels; 4];
/// 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) {
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| {
if radius <= Pixels::ZERO || from_edge >= radius {
return Pixels::ZERO;
}
let d = f32::from(radius - from_edge.max(Pixels::ZERO));
radius - px((f32::from(radius).powi(2) - d * d).max(0.0).sqrt())
};
let (left, right) = (x - bounds.left(), bounds.right() - x);
let top = bite(tl, left).max(bite(tr, right));
let bottom = bite(bl, left).max(bite(br, right));
(bounds.top() + top, bounds.bottom() - bottom)
}
/// Fills the part of the rounded box between `from` and `to` with a ramp from `start` to `end`.
fn ramp(
bounds: Bounds<Pixels>,
radii: Radii,
(from, to): (Pixels, Pixels),
(start, end): (Hsla, Hsla),
window: &mut Window,
) {
let (a, b) = (from.max(bounds.left()), to.min(bounds.right()));
if a >= b {
return;
}
let at = |x: Pixels| {
let share = f32::from(x - from) / f32::from(to - from);
start.mix(&end, share)
};
let columns: Vec<Pixels> = (0..=RAMP)
.map(|ix| a + (b - a) * (ix as f32 / RAMP as f32))
.collect();
let mut path = PathBuilder::fill();
for (ix, x) in columns.iter().enumerate() {
let top = point(*x, span(bounds, radii, *x).0);
if ix == 0 {
path.move_to(top);
} else {
path.line_to(top);
}
}
for x in columns.iter().rev() {
path.line_to(point(*x, span(bounds, radii, *x).1));
}
path.close();
match path.build() {
Ok(path) => window.paint_path(
path,
linear_gradient(
90.0,
linear_color_stop(at(a), 0.0),
linear_color_stop(at(b), 1.0),
),
),
Err(error) => log::error!("shimmer: a band failed to build: {error:#}"),
}
}
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 still = theme.reduced_motion;
let base = self.base.relative().children(self.body);
if still {
return base.into_any_element();
}
base.child(
div().absolute().inset_0().with_animation(
self.id,
Animation::new(SWEEP)
.repeat()
.with_easing(crate::motion::ease_in_out_cubic),
move |overlay, t| {
overlay.child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let width = bounds.size.width * 0.4;
let from = bounds.left() + bounds.size.width * (-0.4 + 1.4 * t);
let middle = from + width / 2.0;
ramp(bounds, radii, (from, middle), (clear, shine), window);
ramp(
bounds,
radii,
(middle, from + width),
(shine, clear),
window,
);
},
)
.size_full(),
)
},
),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use gpui::{Bounds, point, px, size};
use super::span;
#[test]
fn a_band_keeps_inside_rounded_corners() {
let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(50.0)));
let radii = [px(8.0); 4];
assert_eq!(span(bounds, radii, px(0.0)), (px(8.0), px(42.0)));
assert_eq!(span(bounds, radii, px(50.0)), (px(0.0), px(50.0)));
assert_eq!(span(bounds, radii, px(100.0)), (px(8.0), px(42.0)));
let pill = [px(9999.0); 4];
assert_eq!(span(bounds, pill, px(0.0)), (px(25.0), px(25.0)));
assert_eq!(span(bounds, pill, px(50.0)), (px(0.0), px(50.0)));
let (top, _) = span(bounds, radii, px(4.0));
assert!(top > px(0.0) && top < px(8.0), "the arc eases in: {top:?}");
}
}
+231
View File
@@ -0,0 +1,231 @@
use std::{
f32::consts::{FRAC_PI_2, TAU},
time::Duration,
};
use gpui::{
Animation, AnimationExt, App, Bounds, ElementId, Hsla, IntoElement, PathBuilder, Pixels, Point,
RenderOnce, Styled, Window, canvas, div, fill, point, prelude::*, size,
};
use crate::theme::{ActiveTheme, IconSize};
/// How a spinner moves.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SpinnerStyle {
/// An arc that runs around a faint ring.
#[default]
Ring,
/// Three dots that brighten in turn.
Dots,
/// Four bars that rise in turn.
Bars,
/// A dot that sends out a fading ring.
Pulse,
/// A dot that circles a faint ring.
Orbit,
/// Three dots that bob in a wave.
Wave,
}
impl SpinnerStyle {
fn period(self) -> Duration {
Duration::from_millis(match self {
Self::Ring => 900,
Self::Dots | Self::Wave => 1_200,
Self::Bars => 1_000,
Self::Pulse => 1_400,
Self::Orbit => 1_100,
})
}
}
/// Where in its cycle a still spinner rests.
const STILL: f32 = 0.3;
/// Segments in a drawn arc.
const ARC: usize = 32;
/// A small busy mark, one of six motions. Under reduced motion it rests on one frame.
#[derive(IntoElement)]
pub struct Spinner {
id: ElementId,
style: SpinnerStyle,
size: IconSize,
color: Option<Hsla>,
}
impl Spinner {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: SpinnerStyle::default(),
size: IconSize::Md,
color: None,
}
}
pub fn style(mut self, style: SpinnerStyle) -> Self {
self.style = style;
self
}
pub fn size(mut self, size: IconSize) -> Self {
self.size = size;
self
}
/// Defaults to muted text.
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
}
/// A 0..1 bump that peaks once per cycle, `lag` cycles late.
fn bump(t: f32, lag: f32) -> f32 {
0.5 - 0.5 * (TAU * (t - lag)).cos()
}
fn dot(center: Point<Pixels>, radius: Pixels, color: Hsla, window: &mut Window) {
let side = radius * 2.0;
let corner = point(center.x - radius, center.y - radius);
window.paint_quad(fill(Bounds::new(corner, size(side, side)), color).corner_radii(radius));
}
fn arc(
center: Point<Pixels>,
radius: Pixels,
from: f32,
sweep: f32,
width: Pixels,
color: Hsla,
window: &mut Window,
) {
let steps = ((ARC as f32 * sweep / TAU).ceil() as usize).max(2);
let mut path = PathBuilder::stroke(width);
for step in 0..=steps {
let angle = from + sweep * step as f32 / steps as f32;
let at = point(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
);
if step == 0 {
path.move_to(at);
} else {
path.line_to(at);
}
}
match path.build() {
Ok(path) => window.paint_path(path, color),
Err(error) => log::error!("spinner: an arc failed to build: {error:#}"),
}
}
/// One frame of `style` at `t` in its cycle.
fn paint(style: SpinnerStyle, bounds: Bounds<Pixels>, t: f32, color: Hsla, window: &mut Window) {
let side = bounds.size.width.min(bounds.size.height);
let center = bounds.center();
let stroke = side / 8.0;
let faint = color.opacity(0.2);
match style {
SpinnerStyle::Ring => {
let radius = side / 2.0 - stroke;
arc(center, radius, 0.0, TAU, stroke, faint, window);
let sweep = TAU * (0.2 + 0.3 * bump(t, 0.0));
arc(
center,
radius,
-FRAC_PI_2 + TAU * t,
sweep,
stroke,
color,
window,
);
}
SpinnerStyle::Dots | SpinnerStyle::Wave => {
let radius = side / 9.0;
for ix in 0..3 {
let lag = ix as f32 * 0.18;
let x = center.x + (ix as f32 - 1.0) * radius * 3.2;
if style == SpinnerStyle::Dots {
dot(
point(x, center.y),
radius,
color.opacity(0.3 + 0.7 * bump(t, lag)),
window,
);
} else {
let lift = side * 0.22 * bump(t, lag);
dot(
point(x, center.y + side * 0.11 - lift),
radius,
color,
window,
);
}
}
}
SpinnerStyle::Bars => {
let width = side / 8.0;
for ix in 0..4 {
let height = side * (0.3 + 0.55 * bump(t, ix as f32 * 0.14));
let x = center.x + (ix as f32 - 1.5) * width * 2.0 - width / 2.0;
let top = center.y - height / 2.0;
window.paint_quad(
fill(Bounds::new(point(x, top), size(width, height)), color)
.corner_radii(width / 2.0),
);
}
}
SpinnerStyle::Pulse => {
let core = side / 6.0;
let reach = core + (side / 2.0 - core) * t;
arc(
center,
reach,
0.0,
TAU,
stroke * 0.75,
color.opacity(1.0 - t),
window,
);
dot(center, core, color, window);
}
SpinnerStyle::Orbit => {
let radius = side / 2.0 - stroke;
arc(center, radius, 0.0, TAU, stroke * 0.75, faint, window);
let angle = -FRAC_PI_2 + TAU * t;
let at = point(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
);
dot(at, stroke * 1.1, color, window);
}
}
}
impl RenderOnce for Spinner {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let color = self.color.unwrap_or(theme.colors.fg_muted);
let style = self.style;
let face = move |t: f32| {
canvas(
|_, _, _| {},
move |bounds, _, window, _| paint(style, bounds, t, color, window),
)
.size_full()
};
let frame = div().flex_none().size(theme.icon_size(self.size));
if theme.reduced_motion {
return frame.child(face(STILL)).into_any_element();
}
frame
.with_animation(
self.id,
Animation::new(style.period()).repeat(),
move |frame, t| frame.child(face(t)),
)
.into_any_element()
}
}