1use crate::{
11 color::ColorRole,
12 motion::Spring,
13 shapes::{Morph, PathCache, RoundedPolygon, ShapeKind, morph_path, polygon_path},
14 theme::color,
15 tokens::typography::rem,
16};
17use gpui::{prelude::*, *};
18use gpui_base::Progress;
19use std::{sync::OnceLock, time::Duration};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum LoadingShape {
24 Circle,
26 SoftBurst,
27 Cookie9Sided,
28 Pentagon,
29 Pill,
30 Sunny,
31 Cookie4Sided,
32 Oval,
33}
34
35impl LoadingShape {
36 pub const SEQUENCE: [Self; 7] = [
38 Self::SoftBurst,
39 Self::Cookie9Sided,
40 Self::Pentagon,
41 Self::Pill,
42 Self::Sunny,
43 Self::Cookie4Sided,
44 Self::Oval,
45 ];
46
47 pub const fn kind(self) -> ShapeKind {
49 match self {
50 Self::Circle => ShapeKind::Circle,
51 Self::SoftBurst => ShapeKind::SoftBurst,
52 Self::Cookie9Sided => ShapeKind::Cookie9Sided,
53 Self::Pentagon => ShapeKind::Pentagon,
54 Self::Pill => ShapeKind::Pill,
55 Self::Sunny => ShapeKind::Sunny,
56 Self::Cookie4Sided => ShapeKind::Cookie4Sided,
57 Self::Oval => ShapeKind::Oval,
58 }
59 }
60}
61
62pub const LOADING_INDICATOR_SIZE: f32 = 48.;
65const ACTIVE_INDICATOR_SCALE: f32 = 38. / LOADING_INDICATOR_SIZE;
68
69const MORPH_INTERVAL_MS: f32 = 650.;
71const MORPH_SPRING: Spring = Spring {
73 damping: 0.6,
74 stiffness: 200.,
75};
76const MORPH_ROTATION: f32 = 90.;
78const LOOP_MORPHS: usize = 28;
82const LOOP_MS: f32 = MORPH_INTERVAL_MS * LOOP_MORPHS as f32;
83const LOOP_TURNS: f32 = 4.;
84
85#[derive(Clone, Copy, Debug, PartialEq)]
87pub struct LoadingFrame {
88 pub from: LoadingShape,
89 pub to: LoadingShape,
90 pub morph: f32,
92 pub rotation_degrees: f32,
93}
94
95impl LoadingFrame {
96 pub fn still(shape: LoadingShape) -> Self {
98 Self {
99 from: shape,
100 to: shape,
101 morph: 0.,
102 rotation_degrees: 0.,
103 }
104 }
105
106 pub fn determinate(progress: f32) -> Self {
109 let progress = if progress.is_finite() {
110 progress.clamp(0., 1.)
111 } else {
112 0.
113 };
114 Self {
115 from: LoadingShape::Circle,
116 to: LoadingShape::SoftBurst,
117 morph: progress,
118 rotation_degrees: 180. * progress,
119 }
120 }
121}
122
123pub fn loading_indicator_frame(elapsed_ms: f32) -> LoadingFrame {
125 let t = if elapsed_ms.is_finite() {
126 elapsed_ms.rem_euclid(LOOP_MS)
127 } else {
128 0.
129 };
130 let index = ((t / MORPH_INTERVAL_MS).floor() as usize).min(LOOP_MORPHS - 1);
131 let local = t - index as f32 * MORPH_INTERVAL_MS;
132 let seconds = local / 1000.;
135 let rest = 1. - MORPH_SPRING.position(MORPH_INTERVAL_MS / 1000.);
136 let morph = MORPH_SPRING.position(seconds) + rest * local / MORPH_INTERVAL_MS;
137 let sequence = LoadingShape::SEQUENCE;
138 LoadingFrame {
139 from: sequence[index % sequence.len()],
140 to: sequence[(index + 1) % sequence.len()],
141 morph,
142 rotation_degrees: (360. * LOOP_TURNS * t / LOOP_MS
143 + MORPH_ROTATION * (index as f32 + morph))
144 .rem_euclid(360.),
145 }
146}
147
148fn scale_factor<'a>(polygons: impl IntoIterator<Item = &'a RoundedPolygon>) -> f32 {
152 let mut factor = 1_f32;
153 for polygon in polygons {
154 let bounds = polygon.bounds(true);
155 let max = polygon.max_bounds();
156 let scale_x = (bounds[2] - bounds[0]) / (max[2] - max[0]);
157 let scale_y = (bounds[3] - bounds[1]) / (max[3] - max[1]);
158 factor = factor.min(scale_x.max(scale_y));
160 }
161 factor
162}
163
164fn indeterminate_factor() -> f32 {
166 static FACTOR: OnceLock<f32> = OnceLock::new();
167 *FACTOR.get_or_init(|| scale_factor(LoadingShape::SEQUENCE.map(|s| s.kind().polygon())))
168}
169
170fn determinate() -> &'static (Morph, f32) {
173 static DETERMINATE: OnceLock<(Morph, f32)> = OnceLock::new();
174 DETERMINATE.get_or_init(|| {
175 let circle = ShapeKind::Circle.polygon().rotated(360. / 20.).normalized();
176 let burst = ShapeKind::SoftBurst.polygon();
177 let factor = scale_factor([&circle, burst]);
178 (Morph::new(&circle, burst), factor)
179 })
180}
181
182fn frame_path(frame: LoadingFrame, center: Point<Pixels>, container: f32) -> Option<Path<Pixels>> {
184 let side = container * ACTIVE_INDICATOR_SCALE;
185 let turn = frame.rotation_degrees;
186 let (from, to) = (frame.from, frame.to);
187 if from == to {
188 let factor = if from == LoadingShape::Circle {
189 determinate().1
190 } else {
191 indeterminate_factor()
192 };
193 polygon_path(from.kind().polygon(), center, side * factor, turn)
194 } else if (from, to) == (LoadingShape::Circle, LoadingShape::SoftBurst) {
195 let (morph, factor) = determinate();
196 morph_path(morph, frame.morph, center, side * factor, turn)
197 } else {
198 let morph = from.kind().morph_to(to.kind());
199 morph_path(
200 morph,
201 frame.morph,
202 center,
203 side * indeterminate_factor(),
204 turn,
205 )
206 }
207}
208
209#[derive(gpui::IntoElement)]
216pub struct LoadingIndicator {
217 id: ElementId,
218 label: SharedString,
219 progress: Option<f32>,
220 contained: bool,
221 size: f32,
222 shape: Option<LoadingShape>,
223 turn: f32,
224 playback: Option<RepeatAnimationPlayback>,
225}
226
227impl LoadingIndicator {
228 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
229 Self {
230 id: id.into(),
231 label: label.into(),
232 progress: None,
233 contained: false,
234 size: LOADING_INDICATOR_SIZE,
235 shape: None,
236 turn: 0.,
237 playback: None,
238 }
239 }
240
241 pub fn contained(mut self, contained: bool) -> Self {
243 self.contained = contained;
244 self
245 }
246
247 pub fn size(mut self, size: f32) -> Self {
250 if size.is_finite() && size > 0. {
251 self.size = size;
252 }
253 self
254 }
255
256 pub fn progress(mut self, progress: Option<f32>) -> Self {
258 self.progress = progress;
259 self
260 }
261
262 pub fn turn(mut self, degrees: f32) -> Self {
265 if degrees.is_finite() {
266 self.turn = degrees;
267 }
268 self
269 }
270
271 pub fn shape(mut self, shape: LoadingShape) -> Self {
273 self.shape = Some(shape);
274 self
275 }
276
277 pub fn playback(mut self, playback: RepeatAnimationPlayback) -> Self {
282 self.playback = Some(playback);
283 self
284 }
285}
286
287fn held_canvas(
291 frame: LoadingFrame,
292 ink: Rgba,
293 id: &ElementId,
294 window: &mut Window,
295 cx: &mut App,
296) -> impl IntoElement + use<> {
297 let paths = window.use_keyed_state((id.clone(), "loading-path"), cx, |_, _| {
298 PathCache::<(LoadingFrame, f32, Point<Pixels>)>::default()
299 });
300 canvas(
301 |_, _, _| {},
302 move |bounds, _, window, cx| {
303 let container = f32::from(bounds.size.width.min(bounds.size.height));
304 let center = bounds.center();
305 let path = paths.update(cx, |paths, _| {
306 paths.path((frame, container, center), || {
307 frame_path(frame, center, container)
308 })
309 });
310 if let Some(path) = path {
311 window.paint_path(path, ink);
312 }
313 },
314 )
315 .size_full()
316}
317
318fn shape_canvas(frame: LoadingFrame, ink: Rgba) -> impl IntoElement {
319 canvas(
320 |_, _, _| {},
321 move |bounds, _, window, _| {
322 let container = f32::from(bounds.size.width.min(bounds.size.height));
323 if let Some(path) = frame_path(frame, bounds.center(), container) {
324 window.paint_path(path, ink);
325 }
326 },
327 )
328 .size_full()
329}
330
331impl RenderOnce for LoadingIndicator {
332 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
333 let ink = color(if self.contained {
334 ColorRole::ON_PRIMARY_CONTAINER
335 } else {
336 ColorRole::PRIMARY
337 });
338 let measured = self
339 .progress
340 .map(|p| if p.is_finite() { p.clamp(0., 1.) } else { 0. });
341 let root = Progress::new(self.id.clone())
342 .accessibility_label(self.label)
343 .indeterminate(measured.is_none())
344 .value(measured.unwrap_or(0.) * 100.)
345 .flex_shrink_0()
346 .size(rem(self.size))
347 .rounded_full()
348 .when(self.contained, |root| {
349 root.bg(color(ColorRole::PRIMARY_CONTAINER))
350 });
351 if let Some(shape) = self.shape {
352 let canvas = held_canvas(LoadingFrame::still(shape), ink, &self.id, window, cx);
353 return root.child(canvas);
354 }
355 if let Some(target) = measured {
356 let shown =
357 crate::motion::value((self.id.clone(), "loading-progress"), target, window, cx)
358 .clamp(0., 1.);
359 let mut frame = LoadingFrame::determinate(shown);
360 frame.rotation_degrees += self.turn;
361 let canvas = held_canvas(frame, ink, &self.id, window, cx);
362 return root.child(canvas);
363 }
364 let animation = Animation::new(Duration::from_secs_f32(LOOP_MS / 1000.)).repeat();
365 let paint =
366 move |slot: Div, t| slot.child(shape_canvas(loading_indicator_frame(t * LOOP_MS), ink));
367 let slot = div().size_full();
368 match self.playback {
369 None => root.child(slot.with_animation(self.id, animation, paint)),
372 Some(playback) => root.child(
373 slot.with_animation(self.id, animation, paint)
374 .with_repeat_playback(playback)
375 .repeat_when_visible(),
376 ),
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use core::prelude::v1::test;
385
386 #[test]
387 fn the_loop_morphs_through_seven_shapes_and_closes_seamlessly() {
388 let start = loading_indicator_frame(0.);
389 assert_eq!(start.from, LoadingShape::SoftBurst);
390 assert_eq!(start.to, LoadingShape::Cookie9Sided);
391 assert_eq!(start.morph, 0.);
392 let shapes: Vec<_> = (0..7)
393 .map(|i| loading_indicator_frame(i as f32 * MORPH_INTERVAL_MS + 1.).from)
394 .collect();
395 assert_eq!(shapes, LoadingShape::SEQUENCE);
396 let settled = loading_indicator_frame(MORPH_INTERVAL_MS - 0.01);
398 assert!((settled.morph - 1.).abs() < 0.01, "{}", settled.morph);
399 let end = loading_indicator_frame(LOOP_MS - 0.01);
401 assert_eq!(end.to, LoadingShape::SoftBurst);
402 let gap = (end.rotation_degrees - 360.)
403 .abs()
404 .min(end.rotation_degrees);
405 assert!(gap < 0.1, "rotation jumps by {gap}°");
406 }
407
408 #[test]
409 fn the_spring_overshoots_and_each_morph_adds_a_quarter_turn() {
410 let peak = (0..65)
411 .map(|i| loading_indicator_frame(i as f32 * 10.).morph)
412 .fold(0., f32::max);
413 assert!(peak > 1.05 && peak < 1.2, "{peak}");
414 let a = loading_indicator_frame(MORPH_INTERVAL_MS - 0.01).rotation_degrees;
415 let b = loading_indicator_frame(0.).rotation_degrees;
416 let constant = 360. * LOOP_TURNS * MORPH_INTERVAL_MS / LOOP_MS;
417 assert!((a - b - 90. - constant).abs() < 0.5, "{a} {b}");
418 }
419
420 const SAMPLES: usize = 120;
421
422 fn radii(shape: LoadingShape) -> [f32; SAMPLES] {
425 let (cx, cy) = shape.kind().polygon().center();
428 let mut outline = Vec::new();
429 for cubic in shape.kind().polygon().cubics() {
430 outline.extend((0..24).map(|i| cubic.point_at(i as f32 / 24.)));
431 }
432 let mut radii = [0_f32; SAMPLES];
433 for (i, radius) in radii.iter_mut().enumerate() {
434 let angle = i as f32 * std::f32::consts::TAU / SAMPLES as f32;
435 let (dx, dy) = (angle.cos(), angle.sin());
436 for (j, &(px, py)) in outline.iter().enumerate() {
437 let (qx, qy) = outline[(j + 1) % outline.len()];
438 let (px, py, ex, ey) = (px - cx, py - cy, qx - px, qy - py);
439 let det = ex * dy - ey * dx;
441 if det.abs() < 1e-9 {
442 continue;
443 }
444 let t = (ex * py - ey * px) / det;
445 let s = (dx * py - dy * px) / det;
446 if (-1e-5..=1. + 1e-5).contains(&s) && t > *radius {
447 *radius = t;
448 }
449 }
450 }
451 let max = radii.iter().copied().fold(0., f32::max).max(1e-6);
452 radii.map(|radius| radius / max)
453 }
454
455 #[test]
456 fn shapes_are_normalized_star_shaped_and_distinct() {
457 let circle = radii(LoadingShape::Circle);
458 assert!(circle.iter().all(|r| (r - 1.).abs() < 0.01));
459 for shape in LoadingShape::SEQUENCE {
460 let r = radii(shape);
461 let max = r.iter().copied().fold(0., f32::max);
462 let min = r.iter().copied().fold(1., f32::min);
463 assert!((max - 1.).abs() < 1e-4, "{shape:?}");
464 assert!(min > 0.3 && min < 0.97, "{shape:?}: {min}");
465 }
466 let cookie = radii(LoadingShape::Cookie4Sided);
468 let pill = radii(LoadingShape::Pill);
469 let quarter = SAMPLES / 4;
470 assert!((0..SAMPLES).all(|i| (cookie[i] - cookie[(i + quarter) % SAMPLES]).abs() < 0.02));
471 assert!((0..SAMPLES).any(|i| (pill[i] - pill[(i + quarter) % SAMPLES]).abs() > 0.1));
472 }
473
474 #[test]
475 fn determinate_grows_a_circle_into_the_soft_burst() {
476 assert_eq!(LoadingFrame::determinate(0.).from, LoadingShape::Circle);
477 assert_eq!(LoadingFrame::determinate(f32::NAN).morph, 0.);
478 let full = LoadingFrame::determinate(2.);
479 assert_eq!((full.to, full.morph), (LoadingShape::SoftBurst, 1.));
480 assert_eq!(full.rotation_degrees, 180.);
481 assert!(frame_path(full, point(px(24.), px(24.)), 48.).is_some());
482 }
483
484 #[test]
485 fn every_morph_of_the_sequence_stays_in_the_active_box_while_it_turns() {
486 let half = 48. * ACTIVE_INDICATOR_SCALE / 2. * indeterminate_factor();
489 let sequence = LoadingShape::SEQUENCE;
490 for (i, from) in sequence.into_iter().enumerate() {
491 let to = sequence[(i + 1) % sequence.len()];
492 let morph = from.kind().morph_to(to.kind());
493 for cubic in morph.as_cubics(0.5) {
494 let (x, y) = cubic.anchor0();
495 let reach = (x - 0.5).hypot(y - 0.5) * 2. * half;
496 assert!(reach < 48. / 2., "{from:?} -> {to:?}: {reach}");
497 }
498 }
499 let burst = ShapeKind::SoftBurst.polygon().bounds(false);
502 let across = (burst[2] - burst[0]) * half * 2.;
503 assert!((30. ..=38.).contains(&across), "{across}");
504 }
505
506 #[test]
507 fn the_determinate_morph_is_the_rotated_circle_into_the_soft_burst() {
508 let (morph, factor) = determinate();
509 assert!((0.5..=1.).contains(factor), "{factor}");
510 let start = morph.as_cubics(0.);
511 for cubic in &start {
513 let (x, y) = cubic.anchor0();
514 assert!(((x - 0.5).hypot(y - 0.5) - 0.5).abs() < 0.02, "{x} {y}");
515 }
516 }
517
518 #[test]
519 fn shapes_map_onto_the_library() {
520 assert_eq!(LoadingShape::Pill.kind(), ShapeKind::Pill);
521 for shape in LoadingShape::SEQUENCE {
522 assert_ne!(shape.kind(), ShapeKind::Circle);
523 }
524 }
525
526 struct Harness;
527 impl Render for Harness {
528 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
529 window.set_rem_size(px(14.));
530 div()
531 .flex()
532 .items_start()
533 .child(
534 div()
535 .debug_selector(|| "loading".into())
536 .child(LoadingIndicator::new("li", "Loading").contained(true)),
537 )
538 .child(LoadingIndicator::new("li-measured", "Uploading").progress(Some(0.4)))
539 .child(
540 div()
541 .debug_selector(|| "loading-large".into())
542 .child(LoadingIndicator::new("li-large", "Loading").size(96.)),
543 )
544 }
545 }
546
547 #[gpui::test]
548 fn the_indicator_is_a_48dp_progress_node(cx: &mut gpui::TestAppContext) {
549 cx.update(|cx| cx.set_reduce_motion(true));
550 let (_, cx) = cx.add_window_view(|_, _| Harness);
551 cx.update(|window, cx| {
552 window.refresh();
553 window.draw(cx).clear(cx);
554 });
555 let bounds = cx.debug_bounds("loading").unwrap();
556 assert_eq!(bounds.size, size(px(48.), px(48.)));
557 let large = cx.debug_bounds("loading-large").unwrap();
558 assert_eq!(large.size, size(px(96.), px(96.)), "a sized container");
559 cx.update(|window, _| {
562 for (id, label) in [("li", "Loading"), ("li-measured", "Uploading")] {
563 let node = gpui_base::test_support::find(window, &[], &ElementId::from(id))
564 .unwrap_or_else(|| panic!("{id} was not rendered"));
565 assert_eq!(node.role(), Some(Role::ProgressIndicator), "{id}");
566 assert_eq!(node.label(), Some(label), "{id}");
567 assert_eq!(node.bounds().size, size(px(48.), px(48.)), "{id}");
568 }
569 });
570 }
571}