From d4066661d587ecd2701656ed90262ad1422dc548 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 16:58:05 -0400 Subject: [PATCH] T11a loading: spinner in six motions, skeletons that breathe, a shimmer cut to its corners, loading buttons and load more --- AGENTS.md | 2 + TASKS.md | 8 +- examples/gallery/pages/mod.rs | 2 + examples/gallery/pages/motion/loading.rs | 240 ++++++++++++++ examples/gallery/pages/motion/mod.rs | 42 +++ src/buttons/button.rs | 52 ++- src/buttons/mod.rs | 3 + src/buttons/tests.rs | 65 ++++ src/motion/mod.rs | 4 + src/motion/skeleton.rs | 396 +++++++++++++++++++++++ src/motion/spinner.rs | 231 +++++++++++++ src/navigation/mod.rs | 2 +- src/navigation/pages.rs | 79 ++++- src/navigation/tests.rs | 51 ++- src/theme/palette.rs | 3 + src/theme/tokens.rs | 5 + tasks/ch11-20.md | 27 +- 17 files changed, 1184 insertions(+), 28 deletions(-) create mode 100644 examples/gallery/pages/motion/loading.rs create mode 100644 examples/gallery/pages/motion/mod.rs create mode 100644 src/buttons/tests.rs create mode 100644 src/motion/skeleton.rs create mode 100644 src/motion/spinner.rs diff --git a/AGENTS.md b/AGENTS.md index 93cf155..2982118 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +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. - `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. @@ -44,6 +45,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires. - gpui sends a drag's moves to every listener of its type. Each drag payload carries its owner's `EntityId`, and handlers check it. - A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. +- The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band. - The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level. - Posted events cannot move a macOS window, so window drags are not scripted. `drag_region` follows Zed's title bar. - An overlay takes focus with `primitives::take_focus` and returns it with `give_back` on every close path. While open, it takes focus back whenever the focused element leaves the tree (gpui's `on_focus_lost`). diff --git a/TASKS.md b/TASKS.md index d4691f2..6c2d45a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -38,8 +38,9 @@ Per-component lines live in `tasks/`. Tags there: - [x] T09b Overlays · Guides & floats - [x] T10a Feedback · Messages - [x] T10b Feedback · States -- [ ] T11a Loading — `tasks/ch11-20.md` -- [ ] T11b Motion +- [x] T11a Loading · Spinners & skeletons — `tasks/ch11-20.md` +- [ ] T11b Loading · Progress & states +- [ ] T11c Motion - [ ] T12 Data Display - [ ] T13 Lists & Trees - [ ] T14 Tables @@ -166,3 +167,6 @@ Per-component lines live in `tasks/`. Tags there: | T10b | 1 | FAIL | the state view's prose and detail widths were literals again, and pad put zeros ahead of a minus sign | | T10b | 2 | FAIL | the error chain did not wrap inside its narrowed box, and ConnectionStatus's doc said OfflineIndicator shows only while offline | | T10b | 3 | FAIL | a wrapped body did not push the actions down; the prose is now a plain block, and a test compares a short and a long body's height (fixed after the cap) | +| T11a | 1 | FAIL | the shimmer band crossed the container's rounded corners, and the skeleton card's picture height was a literal | +| T11a | 2 | FAIL | a rounded_full shimmer used the raw 9999px radius, so the band's path left the box | +| T11a | 3 | PASS | | diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index ec60e33..5b7dfab 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -3,6 +3,7 @@ mod feedback; mod forms; mod layout; mod menus; +mod motion; mod navigation; mod overlays; mod primitives; @@ -35,6 +36,7 @@ pub const ALL: &[Page] = &[ menus::PAGE, overlays::PAGE, feedback::PAGE, + motion::PAGE, theme::PAGE, ]; diff --git a/examples/gallery/pages/motion/loading.rs b/examples/gallery/pages/motion/loading.rs new file mode 100644 index 0000000..af26461 --- /dev/null +++ b/examples/gallery/pages/motion/loading.rs @@ -0,0 +1,240 @@ +use std::time::Duration; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + motion::{ + Shimmer, Skeleton, SkeletonAvatar, SkeletonCard, SkeletonTable, SkeletonText, Spinner, + SpinnerStyle, + }, + navigation::LoadMore, + theme::{ActiveTheme, IconSize, Radius, TextSize}, + typography::Caption, +}; +use gpui::{ + App, Entity, FontWeight, IntoElement, ParentElement, SharedString, Styled, Window, div, +}; + +use crate::{ + probe::probe, + ui::{keep, row, section, set, specimen, specimens}, +}; + +/// Sets `state` to `value` after `wait`, as a slow server would answer. +fn later(state: &Entity, value: T, wait: Duration, cx: &mut App) { + let state = state.clone(); + cx.spawn(async move |cx| { + cx.background_executor().timer(wait).await; + if let Err(error) = cx.update(|cx| set(&state, value, cx)) { + log::error!("gallery: the demo closed before its answer: {error:#}"); + } + }) + .detach(); +} + +pub fn spinners(cx: &mut App) -> impl IntoElement + use<> { + const STYLES: [(SpinnerStyle, &str); 6] = [ + (SpinnerStyle::Ring, "ring"), + (SpinnerStyle::Dots, "dots"), + (SpinnerStyle::Bars, "bars"), + (SpinnerStyle::Pulse, "pulse"), + (SpinnerStyle::Orbit, "orbit"), + (SpinnerStyle::Wave, "wave"), + ]; + const SIZES: [(IconSize, &str); 5] = [ + (IconSize::Xs, "xs"), + (IconSize::Sm, "sm"), + (IconSize::Md, "md"), + (IconSize::Lg, "lg"), + (IconSize::Xl, "xl"), + ]; + section( + "Spinner / DotsLoader / BarsLoader / PulseLoader / RingLoader / OrbitLoader / WaveLoader / InlineLoader", + "Six small motions for waiting, one component. Under reduced motion each rests on one frame.", + cx, + ) + .child(specimens().children(STYLES.map(|(style, name)| { + specimen( + name, + Spinner::new(SharedString::from(format!("spinner-{name}"))).style(style).size(IconSize::Xl), + cx, + ) + }))) + .child(specimens().children(SIZES.map(|(size, name)| { + specimen(name, Spinner::new(SharedString::from(format!("spinner-size-{name}"))).size(size), cx) + }))) +} + +pub fn skeletons(cx: &mut App) -> impl IntoElement + use<> { + section( + "Skeleton / SkeletonText / SkeletonAvatar / SkeletonCard / SkeletonTable", + "Placeholders in the shape of what is coming. They breathe together, slowly.", + cx, + ) + .child( + div() + .flex() + .items_start() + .gap_6() + .child(div().w_80().child(SkeletonCard::new("skeleton-card"))) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap_6() + .child( + div() + .flex() + .items_center() + .gap_3() + .child(SkeletonAvatar::new("skeleton-avatar")) + .child(div().flex_1().child(SkeletonText::new("skeleton-text", 3))), + ) + .child(SkeletonTable::new("skeleton-table", 3, 4)) + .child(Skeleton::new("skeleton-block").w_full().h_16()), + ), + ) +} + +pub fn shimmer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let loaded = keep("shimmer-loaded", || false, window, cx); + let (now, toggle) = (*loaded.read(cx), loaded.clone()); + let theme = cx.theme(); + let card = if now { + div() + .w_80() + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child("Quarterly review"), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child("Revenue rose 12% on the quarter. Churn held at 2.1%; three new markets opened."), + ) + .into_any_element() + } else { + Shimmer::new("shimmer") + .w_80() + .rounded(theme.radius(Radius::Lg)) + .child(SkeletonCard::new("shimmer-card")) + .into_any_element() + }; + section( + "Shimmer", + "A soft band of light sweeps what is loading. Load swaps in the content.", + cx, + ) + .child(card) + .child( + row().child(probe( + "shimmer-load", + Button::new("shimmer-load", if now { "Reload" } else { "Load" }) + .variant(ButtonVariant::Ghost) + .on_click(move |_, _, cx| set(&toggle, !now, cx)), + )), + ) +} + +pub fn buttons(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let saving = keep("button-saving", || false, window, cx); + let exporting = keep("button-exporting", || false, window, cx); + let (save_now, export_now) = (*saving.read(cx), *exporting.read(cx)); + section( + "LoadingButton", + "A button spins in place at the same width and ignores presses until the work is done.", + cx, + ) + .child( + row() + .child(probe( + "save", + Button::new("save", "Save changes") + .primary() + .loading(save_now) + .on_click(move |_, _, cx| { + set(&saving, true, cx); + later(&saving, false, Duration::from_millis(1600), cx); + }), + )) + .child( + Button::new("export", "Export") + .loading(export_now) + .on_click(move |_, _, cx| { + set(&exporting, true, cx); + later(&exporting, false, Duration::from_millis(2400), cx); + }), + ), + ) +} + +pub fn more(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + const PEOPLE: [&str; 18] = [ + "Ada Lovelace", + "Alan Turing", + "Grace Hopper", + "Katherine Johnson", + "Tim Berners-Lee", + "Margaret Hamilton", + "Donald Knuth", + "Barbara Liskov", + "Ken Thompson", + "Radia Perlman", + "Edsger Dijkstra", + "Frances Allen", + "Dennis Ritchie", + "Hedy Lamarr", + "John McCarthy", + "Sophie Wilson", + "Yukihiro Matsumoto", + "Fei-Fei Li", + ]; + let shown = keep("more-shown", || 5usize, window, cx); + let busy = keep("more-busy", || false, window, cx); + let (count, loading) = (*shown.read(cx), *busy.read(cx)); + let theme = cx.theme(); + section( + "LoadMore", + "A list that loads in pages ends in one button. It spins while the next page comes, and counts what shows.", + cx, + ) + .child( + div() + .w_96() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) + .children(PEOPLE[..count].iter().map(|name| { + div() + .px_4() + .py_2() + .border_b_1() + .border_color(theme.colors.border) + .child(*name) + })) + .child(probe( + "load-more", + div().w_96().child( + LoadMore::new("more", loading) + .shown(count, PEOPLE.len()) + .on_load(move |_, cx| { + set(&busy, true, cx); + later(&busy, false, Duration::from_millis(900), cx); + later(&shown, (count + 5).min(PEOPLE.len()), Duration::from_millis(900), cx); + }), + ), + )), + ) + .child(Caption::new("Five at a time, eighteen in all.")) +} diff --git a/examples/gallery/pages/motion/mod.rs b/examples/gallery/pages/motion/mod.rs new file mode 100644 index 0000000..16ca015 --- /dev/null +++ b/examples/gallery/pages/motion/mod.rs @@ -0,0 +1,42 @@ +mod loading; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 11, + slug: "motion", + title: "Loading & Motion", + summary: "Waiting, shown honestly, and the motion everything else moves with.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::Rest, + Step::Click("save"), + Step::Wait(250), + Step::Shot("loading-button"), + Step::Click("load-more"), + Step::Wait(300), + Step::Shot("load-more-busy"), + Step::Wait(1200), + Step::Shot("load-more-done"), + Step::Click("shimmer-load"), + Step::Wait(500), + Step::Shot("shimmer-loaded"), + Step::Click("shimmer-load"), + Step::Rest, +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(loading::spinners(cx)) + .child(loading::skeletons(cx)) + .child(loading::shimmer(window, cx)) + .child(loading::buttons(window, cx)) + .child(loading::more(window, cx)) + .into_any_element() +} diff --git a/src/buttons/button.rs b/src/buttons/button.rs index 491bb40..0baddcd 100644 --- a/src/buttons/button.rs +++ b/src/buttons/button.rs @@ -4,6 +4,7 @@ use gpui::{ }; use crate::{ + motion::Spinner, primitives::{FocusRing, Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize}, typography::keys::{Platform, keystroke_labels}, @@ -119,6 +120,7 @@ pub struct Button { variant: ButtonVariant, size: ControlSize, disabled: bool, + loading: bool, full_width: bool, pub(crate) slot: Option, on_click: Option, @@ -135,6 +137,7 @@ impl Button { variant: ButtonVariant::default(), size: ControlSize::default(), disabled: false, + loading: false, full_width: false, slot: None, on_click: None, @@ -178,6 +181,12 @@ impl Button { self } + /// Spins in place of its content, at the same width, and ignores presses. Tab still reaches it. + pub fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self + } + pub fn full_width(mut self) -> Self { self.full_width = true; self @@ -201,6 +210,23 @@ impl RenderOnce for Button { let icon = |name| Icon::new(name).size(icon_size).color(tone.fg); let radius = theme.radius(Radius::Md); let hint = self.shortcut.as_ref().map(shortcut_text); + let spinner = (self.id.clone(), "spinner"); + let parts = div() + .flex() + .items_center() + .gap_1p5() + .when_some(self.icon, |el, name| el.child(icon(name))) + .when(!self.label.is_empty(), |el| el.child(self.label)) + .when_some(self.trailing_icon, |el, name| el.child(icon(name))) + .when_some(hint, |el, hint| { + el.child( + div() + .pl_1() + .font_weight(FontWeight::NORMAL) + .text_color(tone.fg.opacity(0.55)) + .child(hint), + ) + }); div() .id(self.id) @@ -224,22 +250,30 @@ impl RenderOnce for Button { .text_color(tone.fg) .bg(tone.bg) .when(self.full_width, |el| el.w_full()) - .when_some(self.icon, |el, name| el.child(icon(name))) - .when(!self.label.is_empty(), |el| el.child(self.label)) - .when_some(self.trailing_icon, |el, name| el.child(icon(name))) - .when_some(hint, |el, hint| { - el.child( + .map(|el| { + if !self.loading { + return el.child(parts); + } + el.relative().child(parts.invisible()).child( div() - .pl_1() - .font_weight(FontWeight::NORMAL) - .text_color(tone.fg.opacity(0.55)) - .child(hint), + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child(Spinner::new(spinner).size(icon_size).color(tone.fg)), ) }) .map(|el| { if self.disabled { return el.opacity(0.45).cursor_not_allowed(); } + if self.loading { + return el + .tab_index(0) + .focus_ring(cx) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()); + } el.cursor_pointer() .tab_index(0) .hover(|style| { diff --git a/src/buttons/mod.rs b/src/buttons/mod.rs index 3bb0770..f04189d 100644 --- a/src/buttons/mod.rs +++ b/src/buttons/mod.rs @@ -11,6 +11,9 @@ mod segmented; mod share; mod sheet; +#[cfg(all(test, feature = "test-support"))] +mod tests; + pub use bars::{ActionBar, BulkActionBar}; pub use button::{Button, ButtonVariant}; pub use confirm::{ConfirmButton, ConfirmMode}; diff --git a/src/buttons/tests.rs b/src/buttons/tests.rs new file mode 100644 index 0000000..ca02679 --- /dev/null +++ b/src/buttons/tests.rs @@ -0,0 +1,65 @@ +use gpui::{ + Context, FocusHandle, IntoElement, KeyBinding, KeyUpEvent, Keystroke, ParentElement, Render, + Styled, TestAppContext, VisualTestContext, Window, +}; + +use super::Button; +use crate::{ + primitives::{FocusNext, FocusScope}, + theme::Theme, +}; + +/// One button that counts its presses. +struct Bench { + root: FocusHandle, + loading: bool, + presses: usize, +} + +impl Render for Bench { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + FocusScope::new(&self.root).root().size_full().child( + Button::new("save", "Save") + .loading(self.loading) + .on_click(move |_, _, cx| view.update(cx, |bench, _| bench.presses += 1)), + ) + } +} + +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + cx.run_until_parked(); +} + +#[gpui::test] +fn a_loading_button_ignores_presses_and_keeps_its_tab_stop(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (view, cx) = cx.add_window_view(|_, cx| Bench { + root: cx.focus_handle(), + loading: true, + presses: 0, + }); + let root = view.read_with(cx, |bench, _| bench.root.clone()); + cx.update(|window, _| window.focus(&root)); + press("tab", cx); + press("enter", cx); + assert_eq!(view.read_with(cx, |bench, _| bench.presses), 0); + view.update(cx, |bench, cx| { + bench.loading = false; + cx.notify(); + }); + cx.run_until_parked(); + press("enter", cx); + assert_eq!( + view.read_with(cx, |bench, _| bench.presses), + 1, + "focus stayed on it" + ); +} diff --git a/src/motion/mod.rs b/src/motion/mod.rs index 4899fdf..4f60382 100644 --- a/src/motion/mod.rs +++ b/src/motion/mod.rs @@ -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}; diff --git a/src/motion/skeleton.rs b/src/motion/skeleton.rs new file mode 100644 index 0000000..7511333 --- /dev/null +++ b/src/motion/skeleton.rs @@ -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) -> 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, 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) -> 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) -> 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, 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) -> 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) { + 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, 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, + 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 = (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| 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:?}"); + } +} diff --git a/src/motion/spinner.rs b/src/motion/spinner.rs new file mode 100644 index 0000000..faa0fec --- /dev/null +++ b/src/motion/spinner.rs @@ -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, +} + +impl Spinner { + pub fn new(id: impl Into) -> 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, 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, + 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, 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() + } +} diff --git a/src/navigation/mod.rs b/src/navigation/mod.rs index 2244bb1..feebeb5 100644 --- a/src/navigation/mod.rs +++ b/src/navigation/mod.rs @@ -21,7 +21,7 @@ pub use history::BackForwardNavigation; pub use menu::NavigationMenu; pub use nav::{NavGroup, NavItem}; pub use overflow::TabOverflowMenu; -pub use pages::Pagination; +pub use pages::{LoadMore, Pagination}; pub use palette::{ Command, CommandPalette, QuickLauncher, QuickOpen, QuickSwitcher, SearchPalette, }; diff --git a/src/navigation/pages.rs b/src/navigation/pages.rs index c770daa..9868f7e 100644 --- a/src/navigation/pages.rs +++ b/src/navigation/pages.rs @@ -1,13 +1,16 @@ use std::rc::Rc; use gpui::{ - App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div, + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, + div, prelude::*, }; use crate::{ buttons::{Button, ButtonVariant, IconButton}, + forms::Run, primitives::IconName, - theme::{ActiveTheme, ControlSize}, + theme::{ActiveTheme, ControlSize, TextSize}, + typography::format::{self, Separators}, }; /// The first, the last, `current` and `near` pages either side; `None` marks a gap of two or more. @@ -164,3 +167,75 @@ mod tests { assert_eq!(shown(3, 5), "1 2 3 4 5"); } } + +/// Ends a list that loads in pages: a button that spins while more come, and how many show. Once all show, only the count stays. +#[derive(IntoElement)] +pub struct LoadMore { + id: ElementId, + loading: bool, + shown: Option<(usize, usize)>, + on_load: Option, +} + +impl LoadMore { + pub fn new(id: impl Into, loading: bool) -> Self { + Self { + id: id.into(), + loading, + shown: None, + on_load: None, + } + } + + /// How many show, of how many there are. + pub fn shown(mut self, shown: usize, total: usize) -> Self { + assert!(shown <= total, "{shown} shown of only {total}"); + self.shown = Some((shown, total)); + self + } + + pub fn on_load(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_load = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for LoadMore { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let count = |value: usize| format::number(value as f64, 0, Separators::EN); + let done = self.shown.is_some_and(|(shown, total)| shown == total); + let (id, on_load) = (self.id.clone(), self.on_load); + div() + .id(self.id.clone()) + .flex() + .flex_col() + .items_center() + .gap_2() + .py_4() + .when(!done, |more| { + more.child( + Button::new((self.id.clone(), "button"), "Load more") + .loading(self.loading) + .on_click(move |_, window, cx| { + log::info!("load more {id:?}: asked"); + if let Some(on_load) = &on_load { + on_load(window, cx); + } + }), + ) + }) + .when_some(self.shown, |more, (shown, total)| { + more.child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .child(if done { + format!("All {} shown", count(total)) + } else { + format!("Showing {} of {}", count(shown), count(total)) + }), + ) + }) + } +} diff --git a/src/navigation/tests.rs b/src/navigation/tests.rs index 5b3a8aa..36bbe5f 100644 --- a/src/navigation/tests.rs +++ b/src/navigation/tests.rs @@ -5,8 +5,8 @@ use gpui::{ }; use super::{ - BackForwardNavigation, Breadcrumb, Crumb, EditorTab, EditorTabs, GoToLine, NavigationMenu, - Tabs, Wizard, + BackForwardNavigation, Breadcrumb, Crumb, EditorTab, EditorTabs, GoToLine, LoadMore, + NavigationMenu, Tabs, Wizard, }; use crate::{ forms::{Choice, TextInput}, @@ -391,3 +391,50 @@ fn enter_jumps_to_a_line_and_column(cx: &mut TestAppContext) { cx.simulate_keystrokes("enter"); assert_eq!(view.read_with(cx, |view, _| view.to), Some((12, Some(4)))); } + +/// A list's end that loads more, counting what it asked. +struct More { + loading: bool, + shown: usize, + asks: usize, +} + +impl Render for More { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + LoadMore::new("more", self.loading) + .shown(self.shown, 30) + .on_load(move |_, cx| view.update(cx, |more, _| more.asks += 1)) + } +} + +#[gpui::test] +fn load_more_asks_once_per_press_and_goes_once_all_show(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| More { + loading: false, + shown: 10, + asks: 0, + }); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + assert_eq!(view.read_with(cx, |more, _| more.asks), 1); + view.update(cx, |more, cx| { + more.loading = true; + cx.notify(); + }); + press("enter", cx); + assert_eq!(view.read_with(cx, |more, _| more.asks), 1, "busy"); + view.update(cx, |more, cx| { + more.loading = false; + more.shown = 30; + cx.notify(); + }); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + assert_eq!( + view.read_with(cx, |more, _| more.asks), + 1, + "all shown, no button" + ); +} diff --git a/src/theme/palette.rs b/src/theme/palette.rs index d5d7294..c2c5279 100644 --- a/src/theme/palette.rs +++ b/src/theme/palette.rs @@ -97,6 +97,7 @@ mixable!( info_subtle: Hsla, backdrop: Hsla, media_backdrop: Hsla, + shimmer: Hsla, shadow: Hsla, tooltip_bg: Hsla, tooltip_fg: Hsla, @@ -146,6 +147,7 @@ impl Palette { info_subtle: c(0xebf4ff), backdrop: rgba(0x18161352).into(), media_backdrop: rgba(0x0e0d0bf8).into(), + shimmer: rgba(0xffffffb3).into(), shadow: c(0x181613), tooltip_bg: c(0x181613), tooltip_fg: c(0xfcfaf7), @@ -212,6 +214,7 @@ impl Palette { info_subtle: c(0x19273a), backdrop: rgba(0x00000080).into(), media_backdrop: rgba(0x000000f8).into(), + shimmer: rgba(0xffffff10).into(), shadow: c(0x000000), tooltip_bg: c(0x373533), tooltip_fg: c(0xf3f1f0), diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 8579656..aea884f 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -248,6 +248,11 @@ impl Theme { px_to_rems(360.0) } + /// A picture's height in a skeleton card. + pub fn skeleton_media(&self) -> Rems { + px_to_rems(128.0) + } + /// Widest a centered block of prose runs, such as an empty state's help. pub fn prose_width(&self) -> Rems { px_to_rems(320.0) diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index 1fa30dc..d4b1718 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -1,28 +1,31 @@ # Chapters 11–20 -## T11a Loading — `src/motion` +## T11a Loading · Spinners & skeletons — `src/motion` + +- [x] Spinner — ring, dots, bars, pulse, orbit, wave; also the Button loading state (T05 LoadingButton) and T07a LoadMore → `motion::Spinner`, `buttons::Button::loading`, `navigation::LoadMore` +- [x] DotsLoader → `motion::Spinner` +- [x] BarsLoader → `motion::Spinner` +- [x] PulseLoader → `motion::Spinner` +- [x] RingLoader → `motion::Spinner` +- [x] OrbitLoader → `motion::Spinner` +- [x] WaveLoader → `motion::Spinner` +- [x] Skeleton / SkeletonText / SkeletonAvatar / SkeletonCard / SkeletonTable → `motion::Skeleton` and kin +- [x] Shimmer → `motion::Shimmer` + +## T11b Loading · Progress & states -- [ ] Spinner — ring, dots, bars, pulse, orbit, wave; also the Button loading state (T05 LoadingButton) and T07a LoadMore -- [ ] DotsLoader → `motion::Spinner` -- [ ] BarsLoader → `motion::Spinner` -- [ ] PulseLoader → `motion::Spinner` -- [ ] RingLoader → `motion::Spinner` -- [ ] OrbitLoader → `motion::Spinner` -- [ ] WaveLoader → `motion::Spinner` -- [ ] Skeleton / SkeletonText / SkeletonAvatar / SkeletonCard / SkeletonTable -- [ ] Shimmer - [ ] ProgressBar — determinate, indeterminate, segmented, buffered — also T06e UploadList - [ ] ProgressCircle / ProgressRing - [ ] StepProgress - [ ] LoadingOverlay - [ ] LoadingScreen -- [ ] InlineLoader → `motion::Spinner` +- [x] InlineLoader → `motion::Spinner` - [ ] LazyLoad - [ ] Suspense / AsyncView - [ ] RefreshIndicator - [ ] TypingIndicator -## T11b Motion +## T11c Motion - [ ] Transition — fade, slide, scale, collapse - [ ] AnimatePresence