T11b loading: progress bars and rings that glide, loading overlay, lazy load, suspense and async view, refresh and typing indicators, upload list; draw-time notifies ask for a frame

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 17:27:21 -04:00
1 parent d4066661d5
commit ce31c7fb06
38 files changed
+1646 -91

No files matched your search

+7
View File
@@ -1,11 +1,18 @@
mod changes;
mod curve;
mod overlay;
mod progress;
mod skeleton;
mod slide;
mod spinner;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub(crate) use changes::changes;
pub use curve::*;
pub use overlay::{LazyLoad, LoadingOverlay, Refresh, RefreshIndicator, TypingIndicator};
pub use progress::{ProgressBar, ProgressRing};
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};
+282
View File
@@ -0,0 +1,282 @@
use std::{f32::consts::PI, time::Instant};
use gpui::{
Animation, AnimationExt, AnyElement, App, Div, ElementId, InteractiveElement, IntoElement,
Length, ParentElement, RenderOnce, SharedString, StyleRefinement, Styled, Window, div,
prelude::*, radians,
};
use smallvec::SmallVec;
use super::{BASE, FAST, NUDGE, Skeleton, Spinner, changes, duration, ease_out_cubic};
use crate::{
primitives::{Icon, IconName, IntersectionObserver},
theme::{ActiveTheme, IconSize, TextSize},
};
/// Veils what it holds with a spinner while `loading`; presses stop at the veil. It fades in and out.
#[derive(IntoElement)]
pub struct LoadingOverlay {
id: ElementId,
base: Div,
loading: bool,
label: Option<SharedString>,
body: SmallVec<[AnyElement; 2]>,
}
impl LoadingOverlay {
pub fn new(id: impl Into<ElementId>, loading: bool) -> Self {
Self {
id: id.into(),
base: div(),
loading,
label: None,
body: SmallVec::new(),
}
}
pub fn label(mut self, text: impl Into<SharedString>) -> Self {
self.label = Some(text.into());
self
}
}
impl Styled for LoadingOverlay {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for LoadingOverlay {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
impl RenderOnce for LoadingOverlay {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let loading = self.loading;
let turn = changes((self.id.clone(), "loading"), loading, window, cx);
let fade = duration(FAST, cx);
let since = window.use_keyed_state((self.id.clone(), "since"), cx, |_, _| {
(loading, Instant::now())
});
if since.read(cx).0 != loading {
since.update(cx, |since, _| *since = (loading, Instant::now()));
}
let fading = !loading && turn > 0 && since.read(cx).1.elapsed() < fade;
let radii = self.base.style().corner_radii.clone();
let theme = cx.theme();
let veil = div()
.id((self.id.clone(), "veil"))
.absolute()
.inset_0()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.bg(theme.colors.bg.opacity(0.72))
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.when(loading, |veil| veil.occlude())
.child(Spinner::new((self.id.clone(), "spinner")).size(IconSize::Lg))
.children(self.label)
.map(|mut veil| {
veil.style().corner_radii = radii;
veil
})
.with_animation(
(self.id.clone(), format!("veil-{turn}")),
Animation::new(fade).with_easing(ease_out_cubic),
move |veil, t| match (turn, loading) {
(0, _) => veil,
(_, true) => veil.opacity(t),
(_, false) => veil.opacity(1.0 - t),
},
);
self.base
.id(self.id)
.relative()
.children(self.body)
.when(loading || fading, |host| host.child(veil))
}
}
type Build = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
/// Builds its content once it first scrolls into view; until then a skeleton holds its place.
#[derive(IntoElement)]
pub struct LazyLoad {
id: ElementId,
height: Length,
build: Build,
}
impl LazyLoad {
/// `height` is the placeholder's, before the content is built.
pub fn new<E: IntoElement>(
id: impl Into<ElementId>,
height: impl Into<Length>,
build: impl FnOnce(&mut Window, &mut App) -> E + 'static,
) -> Self {
Self {
id: id.into(),
height: height.into(),
build: Box::new(move |window, cx| build(window, cx).into_any_element()),
}
}
}
impl RenderOnce for LazyLoad {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let seen = window.use_keyed_state((self.id.clone(), "seen"), cx, |_, _| false);
if *seen.read(cx) {
return (self.build)(window, cx);
}
let id = self.id.clone();
IntersectionObserver::new((self.id.clone(), "watch"), move |visible, _, cx| {
if visible {
log::debug!("lazy load {id:?}: in view, building");
seen.update(cx, |seen, cx| {
*seen = true;
cx.notify();
});
}
})
.w_full()
.child(
Skeleton::new((self.id, "placeholder"))
.w_full()
.h(self.height),
)
.into_any_element()
}
}
/// Where a pull to refresh stands.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Refresh {
Idle,
/// Pulled this far toward the point where it lets go, 0 to 1.
Pulling(f32),
Refreshing,
Done,
}
/// The mark at the top of a list that refreshes: an arrow that turns with the pull, a spinner while it works, a check when done.
#[derive(IntoElement)]
pub struct RefreshIndicator {
id: ElementId,
state: Refresh,
}
impl RefreshIndicator {
pub fn new(id: impl Into<ElementId>, state: Refresh) -> Self {
if let Refresh::Pulling(pull) = state {
assert!((0.0..=1.0).contains(&pull), "pull {pull} is not 0..=1");
}
Self {
id: id.into(),
state,
}
}
}
impl RenderOnce for RefreshIndicator {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let phase = std::mem::discriminant(&self.state);
let turn = changes((self.id.clone(), "phase"), phase, window, cx);
let theme = cx.theme();
let muted = theme.colors.fg_muted;
let mark = match self.state {
Refresh::Idle => return div().id(self.id).into_any_element(),
Refresh::Pulling(pull) => Icon::new(IconName::RefreshCw)
.size(IconSize::Md)
.color(muted.opacity(0.35 + 0.65 * pull))
.rotate(radians(PI * 1.5 * pull))
.into_any_element(),
Refresh::Refreshing => Spinner::new((self.id.clone(), "spinner")).into_any_element(),
Refresh::Done => div()
.flex()
.items_center()
.gap_1p5()
.text_size(theme.text_size(TextSize::Sm))
.text_color(muted)
.child(Icon::new(IconName::Check).size(IconSize::Sm).color(muted))
.child("Up to date")
.into_any_element(),
};
div()
.id(self.id.clone())
.flex()
.justify_center()
.py_2()
.child(mark)
.with_animation(
(self.id, format!("phase-{turn}")),
Animation::new(duration(BASE, cx)).with_easing(ease_out_cubic),
move |row, t| {
if turn == 0 {
row
} else {
row.opacity(t).mt(NUDGE * (t - 1.0))
}
},
)
.into_any_element()
}
}
/// Someone is typing: three dots bob in a small bubble, beside who it is when named.
#[derive(IntoElement)]
pub struct TypingIndicator {
id: ElementId,
who: Option<SharedString>,
}
impl TypingIndicator {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
who: None,
}
}
/// Names who is typing, as "Mia is typing…".
pub fn who(mut self, name: impl Into<SharedString>) -> Self {
self.who = Some(name.into());
self
}
}
impl RenderOnce for TypingIndicator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let muted = theme.colors.fg_muted;
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.px_3()
.py_2()
.rounded_full()
.bg(theme.colors.sunken)
.child(
Spinner::new(self.id)
.style(super::SpinnerStyle::Wave)
.size(IconSize::Lg)
.color(muted),
),
)
.when_some(self.who, |row, who| {
row.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(muted)
.child(format!("{who} is typing…")),
)
})
}
}
+245
View File
@@ -0,0 +1,245 @@
use std::f32::consts::{FRAC_PI_2, TAU};
use gpui::{
Animation, AnimationExt, App, ElementId, IntoElement, ParentElement, RenderOnce, Styled,
Window, canvas, div, prelude::*, relative,
};
use super::{BASE, SLOW, duration, ease_in_out_cubic, ease_out_cubic, spinner::arc};
use crate::{
theme::{ActiveTheme, TextSize},
typography::tabular,
};
/// Where a bar or ring glides from, to its latest value.
struct Glide {
from: f32,
to: f32,
turn: usize,
}
/// Keeps `value`'s last change; returns where to glide from, and the change's number.
fn glide(id: &ElementId, value: f32, window: &mut Window, cx: &mut App) -> (f32, usize) {
let state = window.use_keyed_state((id.clone(), "glide"), cx, |_, _| Glide {
from: value,
to: value,
turn: 0,
});
if state.read(cx).to != value {
state.update(cx, |glide, _| {
glide.from = glide.to;
glide.to = value;
glide.turn += 1;
});
}
let glide = state.read(cx);
(glide.from, glide.turn)
}
fn checked(value: f32, what: &str) -> f32 {
assert!((0.0..=1.0).contains(&value), "{what} {value} is not 0..=1");
value
}
/// How far a task has come: a thin track that fills and glides to each new value, or sweeps when none is known. A buffer shows what has loaded ahead; segments split it into steps.
#[derive(IntoElement)]
pub struct ProgressBar {
id: ElementId,
value: Option<f32>,
buffer: Option<f32>,
segments: usize,
}
impl ProgressBar {
pub fn new(id: impl Into<ElementId>, value: f32) -> Self {
Self {
id: id.into(),
value: Some(checked(value, "progress")),
buffer: None,
segments: 1,
}
}
/// No value known: a short fill sweeps the track.
pub fn indeterminate(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
value: None,
buffer: None,
segments: 1,
}
}
/// What has loaded ahead of the value, drawn fainter.
pub fn buffer(mut self, buffer: f32) -> Self {
let value = self.value.expect("an indeterminate bar has no buffer");
assert!(
checked(buffer, "buffer") >= value,
"buffer {buffer} is behind {value}"
);
self.buffer = Some(buffer);
self
}
/// Splits the track into `count` steps with gaps between.
pub fn segments(mut self, count: usize) -> Self {
assert!(count >= 2, "segments need at least two, got {count}");
self.segments = count;
self
}
}
impl RenderOnce for ProgressBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (fill, faint, track) = (
theme.colors.fg,
theme.colors.fg.opacity(0.25),
theme.colors.border,
);
let thickness = theme.progress_thickness();
let lane = move || {
div()
.relative()
.h(thickness)
.rounded_full()
.overflow_hidden()
.bg(track)
};
let bar = move |share: f32| {
div()
.absolute()
.top_0()
.left_0()
.h_full()
.rounded_full()
.w(relative(share))
};
let Some(value) = self.value else {
let sweep = bar(0.3).bg(fill);
if theme.reduced_motion {
return lane()
.w_full()
.child(sweep.left(relative(0.35)))
.into_any_element();
}
return lane()
.w_full()
.child(
sweep.with_animation(
(self.id, "sweep"),
Animation::new(SLOW * 4)
.repeat()
.with_easing(ease_in_out_cubic),
|bar, t| bar.left(relative(-0.3 + 1.3 * t)),
),
)
.into_any_element();
};
let (from, turn) = glide(&self.id, value, window, cx);
let (buffer, segments) = (self.buffer, self.segments);
let lanes = move |at: f32| {
(0..segments).map(move |ix| {
let share = |whole: f32| (whole * segments as f32 - ix as f32).clamp(0.0, 1.0);
lane()
.flex_1()
.when_some(buffer, |lane, buffer| {
lane.child(bar(share(buffer)).bg(faint))
})
.child(bar(share(at)).bg(fill))
})
};
div()
.w_full()
.flex()
.gap_1()
.with_animation(
(self.id, format!("glide-{turn}")),
Animation::new(duration(BASE, cx)).with_easing(ease_out_cubic),
move |row, t| {
let at = if turn == 0 {
value
} else {
from + (value - from) * t
};
row.children(lanes(at))
},
)
.into_any_element()
}
}
/// Progress as a ring that fills clockwise from the top and glides to each new value; the percent can sit inside.
#[derive(IntoElement)]
pub struct ProgressRing {
id: ElementId,
value: f32,
percent: bool,
}
impl ProgressRing {
pub fn new(id: impl Into<ElementId>, value: f32) -> Self {
Self {
id: id.into(),
value: checked(value, "progress"),
percent: false,
}
}
/// Shows the value as a percent inside the ring.
pub fn percent(mut self) -> Self {
self.percent = true;
self
}
}
impl RenderOnce for ProgressRing {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (value, percent) = (self.value, self.percent);
let (from, turn) = glide(&self.id, value, window, cx);
let theme = cx.theme();
let (fill, track) = (theme.colors.fg, theme.colors.border);
let text = theme.text_size(TextSize::Xs);
div()
.relative()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(theme.progress_ring())
.with_animation(
(self.id, format!("glide-{turn}")),
Animation::new(duration(BASE, cx)).with_easing(ease_out_cubic),
move |ring, t| {
let at = if turn == 0 {
value
} else {
from + (value - from) * t
};
ring.child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let side = bounds.size.width.min(bounds.size.height);
let width = side / 12.0;
let (center, radius) = (bounds.center(), side / 2.0 - width);
arc(center, radius, 0.0, TAU, width, track, window);
if at > 0.0 {
arc(center, radius, -FRAC_PI_2, TAU * at, width, fill, window);
}
},
)
.absolute()
.inset_0(),
)
.when(percent, |ring| {
ring.child(
tabular(div())
.text_size(text)
.child(format!("{}%", (at * 100.0).round())),
)
})
},
)
}
}
+4 -2
View File
@@ -87,12 +87,13 @@ pub(crate) fn slide(
/// Records item `ix`'s span; place it as an absolute child that fills the item.
pub(crate) fn measure_item(state: Entity<Slide>, ix: usize, axis: Axis) -> impl IntoElement {
canvas(
move |bounds: Bounds<Pixels>, _, cx| {
move |bounds: Bounds<Pixels>, window, cx| {
let span = Some(axis.span(bounds, state.read(cx).origin));
if state.read(cx).spans.get(ix) != Some(&span) {
state.update(cx, |slide, cx| {
slide.spans[ix] = span;
cx.notify();
window.request_animation_frame();
});
}
},
@@ -107,12 +108,13 @@ pub(crate) fn measure_item(state: Entity<Slide>, ix: usize, axis: Axis) -> impl
/// Records where the container starts; place it as an absolute child that fills it.
pub(crate) fn measure_origin(state: Entity<Slide>, axis: Axis) -> impl IntoElement {
canvas(
move |bounds: Bounds<Pixels>, _, cx| {
move |bounds: Bounds<Pixels>, window, cx| {
let start = axis.start(bounds);
if state.read(cx).origin != start {
state.update(cx, |slide, cx| {
slide.origin = start;
cx.notify();
window.request_animation_frame();
});
}
},
+1 -1
View File
@@ -92,7 +92,7 @@ fn dot(center: Point<Pixels>, radius: Pixels, color: Hsla, window: &mut Window)
window.paint_quad(fill(Bounds::new(corner, size(side, side)), color).corner_radii(radius));
}
fn arc(
pub(super) fn arc(
center: Point<Pixels>,
radius: Pixels,
from: f32,
+152
View File
@@ -0,0 +1,152 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
Context, InteractiveElement, IntoElement, Modifiers, ParentElement, Render, ScrollDelta,
ScrollWheelEvent, StatefulInteractiveElement, Styled, TestAppContext, TouchPhase,
VisualTestContext, Window, div, point, px,
};
use super::{LazyLoad, LoadingOverlay, ProgressBar};
use crate::theme::Theme;
/// A pressable box under a loading veil, and a lazy row far down a scroll.
struct Bench {
loading: bool,
presses: usize,
built: Rc<Cell<usize>>,
}
impl Render for Bench {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (view, built) = (cx.entity(), self.built.clone());
div()
.child(
LoadingOverlay::new("veil", self.loading).child(
div()
.id("under")
.w(px(200.0))
.h(px(100.0))
.on_click(move |_, _, cx| view.update(cx, |bench, _| bench.presses += 1)),
),
)
.child(
div()
.id("scroll")
.h(px(100.0))
.overflow_y_scroll()
.child(div().h(px(1000.0)))
.child(LazyLoad::new("row", px(20.0), move |_, _| {
built.set(built.get() + 1);
div().h(px(20.0)).child("row")
})),
)
}
}
fn bench(loading: bool, cx: &mut TestAppContext) -> (gpui::Entity<Bench>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
let (view, cx) = cx.add_window_view(|_, _| Bench {
loading,
presses: 0,
built: Rc::new(Cell::new(0)),
});
cx.run_until_parked();
(view, cx)
}
fn press_box(cx: &mut VisualTestContext) {
let at = point(px(100.0), px(50.0));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_click(at, Modifiers::none());
cx.run_until_parked();
}
#[gpui::test]
fn the_veil_stops_presses_only_while_loading(cx: &mut TestAppContext) {
let (view, cx) = bench(true, cx);
press_box(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();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
press_box(cx);
assert_eq!(view.read_with(cx, |bench, _| bench.presses), 1);
}
#[gpui::test]
fn a_lazy_row_builds_once_it_scrolls_into_view(cx: &mut TestAppContext) {
let (view, cx) = bench(false, cx);
let built = view.read_with(cx, |bench, _| bench.built.clone());
assert_eq!(built.get(), 0);
cx.simulate_event(ScrollWheelEvent {
position: point(px(50.0), px(150.0)),
delta: ScrollDelta::Pixels(point(px(0.0), px(-2000.0))),
modifiers: Modifiers::none(),
touch_phase: TouchPhase::Moved,
});
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
assert!(built.get() >= 1, "the row was built");
}
#[test]
#[should_panic(expected = "is behind")]
fn a_buffer_behind_the_value_is_refused() {
let _ = ProgressBar::new("bar", 0.6).buffer(0.4);
}
#[test]
#[should_panic(expected = "not 0..=1")]
fn a_value_past_whole_is_refused() {
let _ = ProgressBar::new("bar", 1.2);
}
/// A sweeping bar at the top of a tall column, and where the next child starts.
struct Column {
after: Rc<Cell<f32>>,
}
impl Render for Column {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let after = self.after.clone();
div()
.flex()
.flex_col()
.h(px(100.0))
.child(ProgressBar::indeterminate("sweep"))
.child(
crate::primitives::Measure::new("after", move |bounds, _, _| {
after.set(f32::from(bounds.origin.y))
})
.h(px(10.0)),
)
}
}
#[gpui::test]
fn a_sweeping_bar_keeps_its_thickness_in_a_tall_column(cx: &mut TestAppContext) {
cx.update(Theme::init);
let after = Rc::new(Cell::new(-1.0));
let (_, cx) = cx.add_window_view({
let after = after.clone();
move |_, _| Column { after }
});
cx.run_until_parked();
let thickness = cx.update(|window, cx| {
f32::from(
crate::theme::ActiveTheme::theme(cx)
.progress_thickness()
.to_pixels(window.rem_size()),
)
});
assert_eq!(after.get(), thickness, "the bar took only its thickness");
}