Skip to main content

armas_basic/components/
progress.rs

1//! Progress Components
2//!
3//! Progress indicators styled like shadcn/ui Progress.
4//! Includes:
5//! - Progress: Simple horizontal progress bar (shadcn style)
6//! - `CircularProgressBar`: Circular/spinner progress
7
8use crate::ext::ArmasContextExt;
9use egui::{Color32, Pos2, Ui, Vec2};
10use std::f32::consts::PI;
11
12const PROGRESS_HEIGHT: f32 = 8.0; // h-2 (8px)
13const PROGRESS_CORNER_RADIUS: f32 = 9999.0; // rounded-full
14
15const CIRCULAR_SIZE: f32 = 48.0;
16const CIRCULAR_STROKE: f32 = 4.0;
17
18/// Progress bar styled like shadcn/ui
19///
20/// A simple horizontal progress indicator.
21///
22/// # Example
23///
24/// ```rust,no_run
25/// # use egui::Ui;
26/// # fn example(ui: &mut Ui) {
27/// use armas_basic::Progress;
28///
29/// // Basic progress (0-100)
30/// Progress::new(65.0).show(ui);
31///
32/// // With custom width
33/// Progress::new(33.0).width(200.0).show(ui);
34/// # }
35/// ```
36pub struct Progress {
37    /// Progress value (0 to 100)
38    value: f32,
39    /// Bar width (None = fill available)
40    width: Option<f32>,
41    /// Bar height
42    height: f32,
43}
44
45impl Progress {
46    /// Create a new progress bar
47    ///
48    /// # Arguments
49    /// * `value` - Progress value from 0 to 100
50    #[must_use]
51    pub const fn new(value: f32) -> Self {
52        Self {
53            value: value.clamp(0.0, 100.0),
54            width: None,
55            height: PROGRESS_HEIGHT,
56        }
57    }
58
59    /// Set the width of the progress bar
60    #[must_use]
61    pub const fn width(mut self, width: f32) -> Self {
62        self.width = Some(width);
63        self
64    }
65
66    /// Set the height of the progress bar
67    #[must_use]
68    pub const fn height(mut self, height: f32) -> Self {
69        self.height = height;
70        self
71    }
72
73    /// Show the progress bar
74    pub fn show(self, ui: &mut Ui) -> egui::Response {
75        let theme = ui.ctx().armas_theme();
76        let desired_width = self.width.unwrap_or_else(|| ui.available_width());
77        let corner_radius = PROGRESS_CORNER_RADIUS.min(self.height / 2.0);
78
79        let (rect, response) =
80            ui.allocate_exact_size(Vec2::new(desired_width, self.height), egui::Sense::hover());
81
82        if ui.is_rect_visible(rect) {
83            // Background track: bg-primary/20 (primary at 20% opacity)
84            let primary = theme.primary();
85            let track_color = Color32::from_rgba_unmultiplied(
86                primary.r(),
87                primary.g(),
88                primary.b(),
89                51, // 20% of 255
90            );
91
92            ui.painter().rect_filled(rect, corner_radius, track_color);
93
94            // Progress indicator: bg-primary
95            let progress_fraction = self.value / 100.0;
96            let fill_width = rect.width() * progress_fraction;
97
98            if fill_width > 0.0 {
99                let fill_rect =
100                    egui::Rect::from_min_size(rect.min, Vec2::new(fill_width, self.height));
101
102                ui.painter().rect_filled(fill_rect, corner_radius, primary);
103            }
104        }
105
106        response
107    }
108}
109
110/// Circular progress indicator
111///
112/// A circular progress display with optional percentage label.
113///
114/// # Example
115///
116/// ```rust,no_run
117/// # use egui::Ui;
118/// # fn example(ui: &mut Ui) {
119/// use armas_basic::CircularProgressBar;
120///
121/// // Determinate progress (0-100)
122/// CircularProgressBar::new(75.0)
123///     .size(80.0)
124///     .show_percentage(true)
125///     .show(ui);
126///
127/// // Indeterminate/loading mode
128/// CircularProgressBar::indeterminate()
129///     .size(60.0)
130///     .show(ui);
131/// # }
132/// ```
133pub struct CircularProgressBar {
134    /// Progress value (0 to 100), None for indeterminate
135    value: Option<f32>,
136    /// Circle diameter
137    size: f32,
138    /// Stroke width
139    stroke_width: f32,
140    /// Show percentage in center
141    show_percentage: bool,
142    /// Animation rotation for indeterminate mode
143    rotation: f32,
144}
145
146impl CircularProgressBar {
147    /// Create a determinate circular progress
148    ///
149    /// # Arguments
150    /// * `value` - Progress value from 0 to 100
151    #[must_use]
152    pub const fn new(value: f32) -> Self {
153        Self {
154            value: Some(value.clamp(0.0, 100.0)),
155            size: CIRCULAR_SIZE,
156            stroke_width: CIRCULAR_STROKE,
157            show_percentage: false,
158            rotation: 0.0,
159        }
160    }
161
162    /// Create an indeterminate circular progress (loading spinner)
163    #[must_use]
164    pub const fn indeterminate() -> Self {
165        Self {
166            value: None,
167            size: CIRCULAR_SIZE,
168            stroke_width: CIRCULAR_STROKE,
169            show_percentage: false,
170            rotation: 0.0,
171        }
172    }
173
174    /// Set circle size (diameter)
175    #[must_use]
176    pub const fn size(mut self, size: f32) -> Self {
177        self.size = size;
178        self
179    }
180
181    /// Set stroke width
182    #[must_use]
183    pub const fn stroke_width(mut self, width: f32) -> Self {
184        self.stroke_width = width;
185        self
186    }
187
188    /// Show percentage in center (only for determinate mode)
189    #[must_use]
190    pub const fn show_percentage(mut self, show: bool) -> Self {
191        self.show_percentage = show;
192        self
193    }
194
195    /// Show the circular progress
196    pub fn show(mut self, ui: &mut Ui) -> egui::Response {
197        let theme = ui.ctx().armas_theme();
198        let (rect, response) = ui.allocate_exact_size(Vec2::splat(self.size), egui::Sense::hover());
199
200        if ui.is_rect_visible(rect) {
201            let center = rect.center();
202            let radius = (self.size - self.stroke_width) / 2.0;
203            let primary = theme.primary();
204
205            // Background track: primary at 20% opacity
206            let track_color =
207                Color32::from_rgba_unmultiplied(primary.r(), primary.g(), primary.b(), 51);
208
209            ui.painter().circle_stroke(
210                center,
211                radius,
212                egui::Stroke::new(self.stroke_width, track_color),
213            );
214
215            if let Some(value) = self.value {
216                // Determinate mode - arc from top
217                let progress_fraction = value / 100.0;
218                let arc_angle = progress_fraction * 2.0 * PI;
219                self.draw_arc(ui, center, radius, -PI / 2.0, arc_angle, primary);
220
221                // Percentage text
222                if self.show_percentage {
223                    let percentage = value as u32;
224                    ui.painter().text(
225                        center,
226                        egui::Align2::CENTER_CENTER,
227                        format!("{percentage}%"),
228                        egui::FontId::proportional(self.size * 0.25),
229                        theme.foreground(),
230                    );
231                }
232            } else {
233                // Indeterminate mode - rotating arc
234                let dt = ui.input(|i| i.stable_dt);
235                self.rotation += dt * 3.0;
236                self.rotation %= 2.0 * PI;
237
238                // Breathing arc length
239                let breath_phase = (self.rotation * 2.0).sin() * 0.5 + 0.5;
240                let arc_len = PI / 4.0 + breath_phase * PI / 2.0;
241
242                self.draw_arc(ui, center, radius, self.rotation, arc_len, primary);
243
244                ui.ctx().request_repaint();
245            }
246        }
247
248        response
249    }
250
251    /// Draw an arc segment
252    fn draw_arc(
253        &self,
254        ui: &mut Ui,
255        center: Pos2,
256        radius: f32,
257        start_angle: f32,
258        arc_length: f32,
259        color: Color32,
260    ) {
261        let segments = 32;
262        let angle_step = arc_length / segments as f32;
263
264        for i in 0..segments {
265            let angle1 = start_angle + i as f32 * angle_step;
266            let angle2 = start_angle + (i + 1) as f32 * angle_step;
267
268            let p1 = Pos2::new(
269                center.x + radius * angle1.cos(),
270                center.y + radius * angle1.sin(),
271            );
272            let p2 = Pos2::new(
273                center.x + radius * angle2.cos(),
274                center.y + radius * angle2.sin(),
275            );
276
277            ui.painter()
278                .line_segment([p1, p2], egui::Stroke::new(self.stroke_width, color));
279        }
280    }
281}