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}