forked from reya/gpui-native-kit
297 lines
8.5 KiB
Rust
297 lines
8.5 KiB
Rust
//! 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<f32>,
|
|
variant: SpinnerVariant,
|
|
size: Size,
|
|
color: Option<Hsla>,
|
|
a11y_label: Option<SharedString>,
|
|
}
|
|
|
|
impl Spinner {
|
|
/// Creates a spinning ring.
|
|
pub fn new<I>(id: I) -> Self
|
|
where
|
|
I: Into<ElementId>,
|
|
{
|
|
Self {
|
|
id: id.into(),
|
|
value: None,
|
|
variant: SpinnerVariant::default(),
|
|
size: Size::default(),
|
|
color: None,
|
|
a11y_label: None,
|
|
}
|
|
}
|
|
|
|
/// Overrides the arc color, for accent-filled surfaces.
|
|
pub fn color(mut self, color: Hsla) -> Self {
|
|
self.color = Some(color);
|
|
self
|
|
}
|
|
|
|
/// 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<L>(mut self, label: L) -> Self
|
|
where
|
|
L: Into<SharedString>,
|
|
{
|
|
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 = self.color.unwrap_or(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<Canvas<()>> {
|
|
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> {
|
|
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<Pixels>,
|
|
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<Pixels>, radius: Pixels, angle: f32) -> Point<Pixels> {
|
|
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<Pixels>,
|
|
radius: Pixels,
|
|
stroke: Pixels,
|
|
start: f32,
|
|
sweep: f32,
|
|
) -> Option<Path<Pixels>> {
|
|
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<Pixels>, radius: Pixels, stroke: Pixels) -> Option<Path<Pixels>> {
|
|
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());
|
|
}
|
|
}
|