Skip to main content

ccf_gpui_widgets/widgets/
spinner.rs

1//! Spinner widget
2//!
3//! A loading spinner for indicating ongoing operations.
4//! Purely visual, not focusable.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ccf_gpui_widgets::widgets::{Spinner, SpinnerSize};
10//!
11//! // Small inline spinner
12//! let small = cx.new(|_cx| {
13//!     Spinner::new()
14//!         .size(SpinnerSize::Small)
15//! });
16//!
17//! // Medium spinner with label
18//! let loading = cx.new(|_cx| {
19//!     Spinner::new()
20//!         .size(SpinnerSize::Medium)
21//!         .label("Loading...")
22//! });
23//!
24//! // Large centered spinner
25//! let large = cx.new(|_cx| {
26//!     Spinner::new()
27//!         .size(SpinnerSize::Large)
28//! });
29//! ```
30
31use std::f32::consts::PI;
32use std::time::Duration;
33
34use gpui::prelude::*;
35use gpui::*;
36
37use crate::theme::{get_theme_or, Theme};
38
39/// Spinner size presets
40#[derive(Clone, Copy, Debug, Default)]
41pub enum SpinnerSize {
42    /// Small (16px)
43    Small,
44    /// Medium (24px, default)
45    #[default]
46    Medium,
47    /// Large (32px)
48    Large,
49    /// Custom size in pixels
50    Custom(f32),
51}
52
53impl SpinnerSize {
54    /// Get the size in pixels
55    pub fn pixels(&self) -> f32 {
56        match self {
57            SpinnerSize::Small => 16.0,
58            SpinnerSize::Medium => 24.0,
59            SpinnerSize::Large => 32.0,
60            SpinnerSize::Custom(px) => *px,
61        }
62    }
63}
64
65/// Spinner widget
66pub struct Spinner {
67    size: SpinnerSize,
68    custom_theme: Option<Theme>,
69    label: Option<SharedString>,
70}
71
72impl Spinner {
73    /// Create a new spinner
74    pub fn new() -> Self {
75        Self {
76            size: SpinnerSize::default(),
77            custom_theme: None,
78            label: None,
79        }
80    }
81
82    /// Set spinner size (builder pattern)
83    #[must_use]
84    pub fn size(mut self, size: SpinnerSize) -> Self {
85        self.size = size;
86        self
87    }
88
89    /// Set label text (builder pattern)
90    #[must_use]
91    pub fn label(mut self, text: impl Into<SharedString>) -> Self {
92        self.label = Some(text.into());
93        self
94    }
95
96    /// Set custom theme (builder pattern)
97    #[must_use]
98    pub fn theme(mut self, theme: Theme) -> Self {
99        self.custom_theme = Some(theme);
100        self
101    }
102}
103
104impl Default for Spinner {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl Render for Spinner {
111    fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
112        let theme = get_theme_or(cx, self.custom_theme.as_ref());
113        let size = self.size.pixels();
114        let label = self.label.clone();
115
116        // Number of dots in the spinner
117        let dot_count = 8;
118        let dot_size = size * 0.15;
119        let radius = (size - dot_size) / 2.0;
120
121        div()
122            .id("ccf_spinner")
123            .flex()
124            .flex_row()
125            .gap_2()
126            .items_center()
127            // Spinner container
128            .child(
129                div()
130                    .relative()
131                    .w(px(size))
132                    .h(px(size))
133                    .children((0..dot_count).map(|i| {
134                        // Calculate position for each dot
135                        let angle = (i as f32 / dot_count as f32) * 2.0 * PI;
136                        let x = radius * angle.cos() + (size - dot_size) / 2.0;
137                        let y = radius * angle.sin() + (size - dot_size) / 2.0;
138
139                        // Base opacity for static appearance
140                        let base_opacity = 0.2 + (i as f32 / dot_count as f32) * 0.8;
141                        let dot_index = i;
142
143                        div()
144                            .absolute()
145                            .left(px(x))
146                            .top(px(y))
147                            .w(px(dot_size))
148                            .h(px(dot_size))
149                            .rounded_full()
150                            .bg(rgb(theme.primary))
151                            .with_animation(
152                                ElementId::Name(format!("spinner_dot_{}", i).into()),
153                                Animation::new(Duration::from_millis(1000))
154                                    .repeat(),
155                                move |el, delta| {
156                                    // Create a "chasing" effect by offsetting each dot's animation phase
157                                    let phase = (delta + (dot_index as f32 / dot_count as f32)) % 1.0;
158                                    // Opacity varies: high when "active", low otherwise
159                                    let opacity = if phase < 0.125 {
160                                        1.0
161                                    } else {
162                                        base_opacity * (1.0 - phase * 0.5)
163                                    };
164                                    el.opacity(opacity)
165                                },
166                            )
167                    }))
168            )
169            // Optional label
170            .when_some(label, |d, text| {
171                d.child(
172                    div()
173                        .text_sm()
174                        .text_color(rgb(theme.text_muted))
175                        .child(text)
176                )
177            })
178    }
179}