fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `Loading` — the web svg spinner, painted with gpui paths: a gray track
//! under an arc running both web animations, the 2s linear rotation and the
//! 1.4s ease-in-out dash cycle.

use std::sync::LazyLock;
use std::time::Instant;

use fluidattacks_designs_domain::loading::{dash_state, Color, Size, DASH_MS, ROTATION_MS};
use fluidattacks_designs_domain::typography::{TextSize, FONT_PRIMARY};
use gpui::prelude::FluentBuilder as _;
use gpui::{
    canvas, div, point, px, rgb, App, Bounds, IntoElement, ParentElement as _, PathBuilder, Pixels,
    Point, RenderOnce, Rgba, SharedString, Styled as _, Window,
};

use crate::theme::ActiveTheme as _;

/// The animation clocks' shared origin; spinners only need consistent phase,
/// not wall time.
static EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);

const TAU: f32 = core::f32::consts::TAU;
const ARC_SEGMENTS: u16 = 64;
const DOT_SEGMENTS: u16 = 24;

#[derive(IntoElement)]
pub struct Loading {
    size: Size,
    color: Color,
    label: Option<SharedString>,
}

impl Loading {
    #[must_use]
    pub const fn new() -> Self {
        Self {
            size: Size::Md,
            color: Color::Red,
            label: None,
        }
    }

    #[must_use]
    pub const fn size(mut self, size: Size) -> Self {
        self.size = size;
        self
    }

    #[must_use]
    pub const fn color(mut self, color: Color) -> Self {
        self.color = color;
        self
    }

    #[must_use]
    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
        self.label = Some(label.into());
        self
    }
}

impl Default for Loading {
    fn default() -> Self {
        Self::new()
    }
}

/// The spinner's frame geometry: the web svg's circle (viewBox 100, r45,
/// stroke 10) scaled to the widget's bounds.
struct Geometry {
    center_x: f32,
    center_y: f32,
    radius: f32,
    width: f32,
}

impl Geometry {
    fn of(bounds: &Bounds<Pixels>) -> Self {
        let scale = f32::from(bounds.size.width) / 100.0;
        let center = bounds.center();
        Self {
            center_x: f32::from(center.x),
            center_y: f32::from(center.y),
            radius: 45.0 * scale,
            width: 10.0 * scale,
        }
    }

    fn on_circle(&self, angle: f32) -> Point<Pixels> {
        point(
            px(self.radius.mul_add(angle.cos(), self.center_x)),
            px(self.radius.mul_add(angle.sin(), self.center_y)),
        )
    }
}

fn stroke_arc(window: &mut Window, geometry: &Geometry, start: f32, sweep: f32, color: Rgba) {
    let mut builder = PathBuilder::stroke(px(geometry.width));
    builder.move_to(geometry.on_circle(start));
    for segment in 1..=ARC_SEGMENTS {
        let step = f32::from(segment) / f32::from(ARC_SEGMENTS);
        builder.line_to(geometry.on_circle(sweep.mul_add(step, start)));
    }
    if let Ok(path) = builder.build() {
        window.paint_path(path, color);
    }
}

/// The web's round line caps, painted as end dots (gpui does not expose
/// lyon's cap options).
fn fill_dot(window: &mut Window, geometry: &Geometry, angle: f32, color: Rgba) {
    let end = geometry.on_circle(angle);
    let cap = Geometry {
        center_x: f32::from(end.x),
        center_y: f32::from(end.y),
        radius: geometry.width / 2.0,
        width: 0.0,
    };
    let mut builder = PathBuilder::fill();
    builder.move_to(cap.on_circle(0.0));
    for segment in 1..=DOT_SEGMENTS {
        let step = f32::from(segment) / f32::from(DOT_SEGMENTS);
        builder.line_to(cap.on_circle(TAU * step));
    }
    if let Ok(path) = builder.build() {
        window.paint_path(path, color);
    }
}

fn paint_spinner(bounds: &Bounds<Pixels>, track: Rgba, stroke: Rgba, window: &mut Window) {
    let geometry = Geometry::of(bounds);
    stroke_arc(window, &geometry, 0.0, TAU, track);

    let elapsed = EPOCH.elapsed().as_secs_f32() * 1000.0;
    let spin = (elapsed / ROTATION_MS).fract();
    let (visible, dash_turn) = dash_state((elapsed / DASH_MS).fract());
    let start = (spin + dash_turn) * TAU;
    let sweep = visible * TAU;
    stroke_arc(window, &geometry, start, sweep, stroke);
    fill_dot(window, &geometry, start, stroke);
    fill_dot(window, &geometry, start + sweep, stroke);

    window.request_animation_frame();
}

impl RenderOnce for Loading {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = &cx.theme().colors;
        let track = Rgba {
            alpha: self.color.track_opacity(),
            ..rgb(self.color.track(theme))
        };
        let stroke = rgb(self.color.stroke(theme));
        let label_color = rgb(self.color.label(theme));
        div()
            .flex()
            .items_center()
            .gap(px(8.0))
            .child(
                canvas(
                    |_, _, _| (),
                    move |bounds, (), window, _paint_cx| {
                        paint_spinner(&bounds, track, stroke, window);
                    },
                )
                .size(px(self.size.px())),
            )
            .when_some(self.label, |el, label| {
                el.child(
                    div()
                        .font_family(FONT_PRIMARY)
                        .text_size(px(TextSize::Md.px()))
                        .line_height(px(TextSize::Md.line_height_px()))
                        .text_color(label_color)
                        .child(label),
                )
            })
    }
}