Skip to main content

armas_basic/components/
loading.rs

1//! Loading Components
2//!
3//! Loading indicators styled for consistent UX.
4//! Includes:
5//! - Spinner: Classic rotating bar spinner
6//! - Skeleton: Content placeholder with shimmer
7
8use crate::ext::ArmasContextExt;
9use egui::{Color32, Pos2, Rect, Response, Ui, Vec2};
10use std::f32::consts::PI;
11
12const SPINNER_SIZE: f32 = 40.0;
13const SPINNER_BAR_COUNT: usize = 12;
14const SPINNER_BAR_WIDTH: f32 = 2.0;
15
16const SKELETON_CORNER_RADIUS: f32 = 6.0; // rounded-md
17const SKELETON_SHIMMER_WIDTH: f32 = 0.3;
18
19/// Rotating spinner with multiple bars
20///
21/// A classic loading spinner with 12 rotating bars that have staggered opacity
22/// for a smooth animation effect.
23///
24/// # Example
25///
26/// ```rust,no_run
27/// use armas_basic::components::Spinner;
28///
29/// fn ui(ui: &mut egui::Ui, spinner: &mut Spinner) {
30///     spinner.show(ui);
31/// }
32/// ```
33#[derive(Debug, Clone)]
34pub struct Spinner {
35    /// Size of the spinner in pixels
36    pub size: f32,
37    /// Rotation speed in radians per second
38    pub speed: f32,
39    /// Color of the spinner bars (None = use theme primary color)
40    color: Option<Color32>,
41    /// Current rotation angle
42    rotation: f32,
43    /// Number of bars in the spinner
44    pub bar_count: usize,
45    /// Width of each bar
46    pub bar_width: f32,
47}
48
49impl Default for Spinner {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl Spinner {
56    /// Create a new spinner with default settings
57    /// Color defaults to theme primary color
58    #[must_use]
59    pub fn new() -> Self {
60        Self {
61            size: SPINNER_SIZE,
62            speed: 2.0 * PI,
63            color: None, // Will use theme.primary()
64            rotation: 0.0,
65            bar_count: SPINNER_BAR_COUNT,
66            bar_width: SPINNER_BAR_WIDTH,
67        }
68    }
69
70    /// Set the spinner size
71    #[must_use]
72    pub const fn size(mut self, size: f32) -> Self {
73        self.size = size;
74        self
75    }
76
77    /// Set the spinner color (overrides theme)
78    #[must_use]
79    pub const fn color(mut self, color: Color32) -> Self {
80        self.color = Some(color);
81        self
82    }
83
84    /// Set the rotation speed (in radians per second)
85    #[must_use]
86    pub const fn speed(mut self, speed: f32) -> Self {
87        self.speed = speed;
88        self
89    }
90
91    /// Set the number of bars
92    #[must_use]
93    pub fn bar_count(mut self, count: usize) -> Self {
94        self.bar_count = count.max(3);
95        self
96    }
97
98    /// Show the spinner
99    pub fn show(&mut self, ui: &mut Ui) -> Response {
100        let theme = ui.ctx().armas_theme();
101        let (rect, response) = ui.allocate_exact_size(Vec2::splat(self.size), egui::Sense::hover());
102
103        // Calculate rotation from time for stateless animation
104        let time = ui.input(|i| i.time) as f32;
105        self.rotation = (time * self.speed) % (2.0 * PI);
106
107        // Draw the spinner
108        self.draw_spinner(ui, rect, &theme);
109
110        // Request repaint for animation
111        ui.ctx().request_repaint();
112
113        response
114    }
115
116    /// Draw the rotating spinner
117    fn draw_spinner(&self, ui: &mut Ui, rect: Rect, theme: &crate::Theme) {
118        let painter = ui.painter();
119        let center = rect.center();
120        let radius = self.size / 2.0;
121        let bar_length = radius * 0.3;
122        let bar_start = radius * 0.5;
123
124        // Use custom color or theme primary
125        let base_color = self.color.unwrap_or_else(|| theme.primary());
126
127        for i in 0..self.bar_count {
128            let angle = (i as f32 / self.bar_count as f32) * 2.0 * PI + self.rotation;
129
130            // Calculate opacity with stagger effect (bars further in rotation are more opaque)
131            let opacity_index = (self.bar_count - i) as f32 / self.bar_count as f32;
132            let alpha = (opacity_index * 255.0) as u8;
133
134            let color = Color32::from_rgba_unmultiplied(
135                base_color.r(),
136                base_color.g(),
137                base_color.b(),
138                alpha,
139            );
140
141            // Calculate bar start and end positions
142            let start = Pos2::new(
143                center.x + angle.cos() * bar_start,
144                center.y + angle.sin() * bar_start,
145            );
146            let end = Pos2::new(
147                center.x + angle.cos() * (bar_start + bar_length),
148                center.y + angle.sin() * (bar_start + bar_length),
149            );
150
151            painter.line_segment([start, end], egui::Stroke::new(self.bar_width, color));
152        }
153    }
154}
155
156/// Skeleton loader for placeholder content
157///
158/// A shimmer effect that animates across a rectangular area,
159/// useful for indicating loading content.
160///
161/// # Example
162///
163/// ```rust,no_run
164/// use armas_basic::components::Skeleton;
165///
166/// fn ui(ui: &mut egui::Ui, skeleton: &mut Skeleton) {
167///     skeleton.show(ui);
168/// }
169/// ```
170#[derive(Debug, Clone)]
171pub struct Skeleton {
172    /// Width of the skeleton
173    pub width: f32,
174    /// Height of the skeleton
175    pub height: f32,
176    /// Base color of the skeleton (None = use theme `surface_variant`)
177    base_color: Option<Color32>,
178    /// Highlight color for the shimmer (None = use theme surface)
179    highlight_color: Option<Color32>,
180    /// Animation position (0.0 to 1.0)
181    shimmer_pos: f32,
182    /// Animation speed
183    pub speed: f32,
184    /// Corner radius (None = use theme spacing.xs)
185    corner_radius: Option<f32>,
186    /// Width of the shimmer effect
187    pub shimmer_width: f32,
188}
189
190impl Skeleton {
191    /// Create a new skeleton loader
192    /// Colors default to theme `surface_variant` and surface
193    #[must_use]
194    pub const fn new(width: f32, height: f32) -> Self {
195        Self {
196            width,
197            height,
198            base_color: None,      // Will use theme.muted()
199            highlight_color: None, // Will use theme.card()
200            shimmer_pos: 0.0,
201            speed: 0.5,
202            corner_radius: Some(SKELETON_CORNER_RADIUS),
203            shimmer_width: SKELETON_SHIMMER_WIDTH,
204        }
205    }
206
207    /// Set the base color (overrides theme)
208    #[must_use]
209    pub const fn base_color(mut self, color: Color32) -> Self {
210        self.base_color = Some(color);
211        self
212    }
213
214    /// Set the highlight color (overrides theme)
215    #[must_use]
216    pub const fn highlight_color(mut self, color: Color32) -> Self {
217        self.highlight_color = Some(color);
218        self
219    }
220
221    /// Set the animation speed
222    #[must_use]
223    pub const fn speed(mut self, speed: f32) -> Self {
224        self.speed = speed;
225        self
226    }
227
228    /// Set the corner radius (overrides theme)
229    #[must_use]
230    pub const fn corner_radius(mut self, radius: f32) -> Self {
231        self.corner_radius = Some(radius);
232        self
233    }
234
235    /// Set the shimmer width (as a fraction of total width)
236    #[must_use]
237    pub const fn shimmer_width(mut self, width: f32) -> Self {
238        self.shimmer_width = width.clamp(0.1, 1.0);
239        self
240    }
241
242    /// Show the skeleton loader
243    pub fn show(&mut self, ui: &mut Ui) -> Response {
244        let theme = ui.ctx().armas_theme();
245        let (rect, response) =
246            ui.allocate_exact_size(Vec2::new(self.width, self.height), egui::Sense::hover());
247
248        // Calculate shimmer position from time for stateless animation
249        let time = ui.input(|i| i.time) as f32;
250        self.shimmer_pos = (time * self.speed) % 1.0;
251
252        // Draw the skeleton
253        self.draw_skeleton(ui, rect, &theme);
254
255        // Request repaint for animation
256        ui.ctx().request_repaint();
257
258        response
259    }
260
261    /// Draw the skeleton with shimmer effect
262    fn draw_skeleton(&self, ui: &mut Ui, rect: Rect, theme: &crate::Theme) {
263        let painter = ui.painter();
264
265        // Use custom colors or theme defaults
266        let base_color = self.base_color.unwrap_or_else(|| theme.muted());
267        let highlight_color = self.highlight_color.unwrap_or_else(|| theme.card());
268        let corner_radius = self.corner_radius.unwrap_or(theme.spacing.xs);
269
270        // Draw base rectangle
271        painter.rect_filled(rect, corner_radius, base_color);
272
273        // Draw shimmer effect as a gradient
274        let shimmer_pixel_width = self.width * self.shimmer_width;
275        let shimmer_center = rect.min.x + self.shimmer_pos * (self.width + shimmer_pixel_width)
276            - shimmer_pixel_width / 2.0;
277
278        // Draw shimmer as multiple rectangles with varying opacity
279        let steps = 20;
280        for i in 0..steps {
281            let offset_from_center = (i as f32 - steps as f32 / 2.0) / (steps as f32 / 2.0);
282            let x = shimmer_center + offset_from_center * shimmer_pixel_width / 2.0;
283
284            // Only draw if within bounds
285            if x >= rect.min.x && x < rect.max.x {
286                let alpha_multiplier = 1.0 - offset_from_center.abs();
287                let alpha = (f32::from(highlight_color.a()) * alpha_multiplier) as u8;
288
289                let shimmer_color = Color32::from_rgba_unmultiplied(
290                    highlight_color.r(),
291                    highlight_color.g(),
292                    highlight_color.b(),
293                    alpha,
294                );
295
296                let step_width = shimmer_pixel_width / steps as f32;
297                let shimmer_rect = Rect::from_min_max(
298                    Pos2::new(x, rect.min.y),
299                    Pos2::new((x + step_width).min(rect.max.x), rect.max.y),
300                );
301
302                painter.rect_filled(shimmer_rect, corner_radius, shimmer_color);
303            }
304        }
305    }
306}
307
308impl Default for Skeleton {
309    fn default() -> Self {
310        Self::new(200.0, 20.0)
311    }
312}