//! The loading ring: spins for unknown work, or holds a determinate arc. use std::cell::Cell; use std::f32::consts::{PI, TAU}; use std::rc::Rc; use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ Animation, AnimationElement, AnimationExt as _, App, Canvas, ElementId, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, Path, PathBuilder, Pixels, Point, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Transformation, Window, canvas, div, percentage, point, px, }; use gpui_native_icons::{Icon, IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Size}; use crate::common::Sizable; /// One revolution takes this long, the system loaders' cadence. const SPIN_PERIOD: Duration = Duration::from_millis(800); /// The share of the circle the spinning arc paints. const SPIN_SWEEP: f32 = 0.75; /// The ring stroke as a share of the side. const RING_STROKE: f32 = 0.125; /// The visual language of a [`Spinner`]. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum SpinnerVariant { /// The painted arc ring. #[default] Ring, /// The platform loader glyph, rotated. Icon, } /// A loading ring. #[derive(IntoElement)] pub struct Spinner { id: ElementId, value: Option, variant: SpinnerVariant, size: Size, a11y_label: Option, } impl Spinner { /// Creates a spinning ring. pub fn new(id: I) -> Self where I: Into, { Self { id: id.into(), value: None, variant: SpinnerVariant::default(), size: Size::default(), a11y_label: None, } } /// Sets the spinner's visual language. pub fn variant(mut self, variant: SpinnerVariant) -> Self { self.variant = variant; self } /// Paints a determinate arc at `value` (`0..=1`) instead of spinning. pub fn value(mut self, value: f32) -> Self { self.value = Some(value); self } /// Sets the accessibility label. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } } impl Sizable for Spinner { fn apply_size(&mut self, size: Size) { self.size = size; } } impl RenderOnce for Spinner { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let side = theme.sizes.resolve(self.size).icon_size; let stroke = side * RING_STROKE; let track = theme.colors.border; let accent = theme.ramps.primary.base; let value = self.value.map(|value| value.clamp(0.0, 1.0)); div() .id(self.id) .flex_none() .size(side) .role(Role::ProgressIndicator) .when_some(self.a11y_label, |spinner, label| spinner.aria_label(label)) .when_some(value, |spinner, value| { spinner .aria_numeric_value(f64::from(value)) .aria_min_numeric_value(0.0) .aria_max_numeric_value(1.0) }) .child(match (self.variant, value) { (SpinnerVariant::Ring, Some(value)) => { determinate_ring(side, stroke, track, accent, value).into_any_element() } (SpinnerVariant::Ring, None) => { spinning_ring(side, stroke, accent).into_any_element() } (SpinnerVariant::Icon, Some(_)) => icon(IconRole::Loader) .size(side) .color(accent) .into_any_element(), (SpinnerVariant::Icon, None) => spinning_icon(side, accent).into_any_element(), }) } } fn determinate_ring( side: Pixels, stroke: Pixels, track: Hsla, accent: Hsla, value: f32, ) -> Canvas<()> { canvas( |_, _, _| (), move |bounds, (), window, _| { let center = bounds.center(); let radius = bounds.size.width / 2.0 - stroke / 2.0; paint_circle(center, radius, stroke, track, window); if value >= 1.0 { paint_circle(center, radius, stroke, accent, window); } else if value > 0.0 && let Some(path) = arc_path(center, radius, stroke, 0.0, value * TAU) { window.paint_path(path, accent); } }, ) .size(side) } fn spinning_ring(side: Pixels, stroke: Pixels, accent: Hsla) -> AnimationElement> { let angle = Rc::new(Cell::new(0.0)); let spun = angle.clone(); canvas( |_, _, _| (), move |bounds, (), window, _| { let radius = bounds.size.width / 2.0 - stroke / 2.0; if let Some(path) = arc_path( bounds.center(), radius, stroke, spun.get(), SPIN_SWEEP * TAU, ) { window.paint_path(path, accent); } }, ) .size(side) .with_animation( "spin", Animation::new(SPIN_PERIOD).repeat().with_max_fps(60.), move |ring, delta| { angle.set(delta * TAU); ring }, ) } fn spinning_icon(side: Pixels, accent: Hsla) -> AnimationElement { icon(IconRole::Loader) .size(side) .color(accent) .with_animation( "spin", Animation::new(SPIN_PERIOD).repeat().with_max_fps(60.), |glyph, delta| glyph.with_transformation(Transformation::rotate(percentage(delta))), ) } fn paint_circle( center: Point, radius: Pixels, stroke: Pixels, color: Hsla, window: &mut Window, ) { if let Some(path) = circle_path(center, radius, stroke) { window.paint_path(path, color); } } /// A point on the ring at `angle`, clockwise from 12 o'clock. fn ring_point(center: Point, radius: Pixels, angle: f32) -> Point { point( center.x + radius * angle.sin(), center.y - radius * angle.cos(), ) } /// A stroked arc from `start`, sweeping clockwise by `sweep` radians. fn arc_path( center: Point, radius: Pixels, stroke: Pixels, start: f32, sweep: f32, ) -> Option> { let mut builder = PathBuilder::stroke(stroke); builder.move_to(ring_point(center, radius, start)); builder.arc_to( point(radius, radius), px(0.), sweep > PI, true, ring_point(center, radius, start + sweep), ); builder.build().ok() } /// A stroked full circle; `None` on degenerate geometry. fn circle_path(center: Point, radius: Pixels, stroke: Pixels) -> Option> { let mut builder = PathBuilder::stroke(stroke); builder.move_to(ring_point(center, radius, 0.0)); builder.arc_to( point(radius, radius), px(0.), false, true, ring_point(center, radius, PI), ); builder.arc_to( point(radius, radius), px(0.), false, true, ring_point(center, radius, TAU), ); builder.build().ok() } #[cfg(test)] mod tests { use super::*; use std::f32::consts::FRAC_PI_2; #[test] fn ring_points_run_clockwise_from_twelve() { let center = point(px(10.0), px(20.0)); let radius = px(4.0); assert_eq!(ring_point(center, radius, 0.0), point(px(10.0), px(16.0))); assert_eq!( ring_point(center, radius, FRAC_PI_2), point(px(14.0), px(20.0)) ); assert_eq!(ring_point(center, radius, PI), point(px(10.0), px(24.0))); assert_eq!( ring_point(center, radius, -FRAC_PI_2), point(px(6.0), px(20.0)) ); } #[test] fn arcs_tessellate_across_the_range() { let center = point(px(0.0), px(0.0)); let radius = px(8.0); let stroke = px(2.0); // The large-arc flag flips past half a circle; both sides must build. for fraction in [0.25, 0.5, 0.75] { assert!( arc_path(center, radius, stroke, 0.0, fraction * TAU).is_some(), "fraction {fraction} failed to build" ); } assert!(circle_path(center, radius, stroke).is_some()); } }