Skip to main content

egui_macview/controls/
progress_indicator.rs

1//! Authentic macOS AppKit Progress Indicator (`NSProgressIndicator` equivalent, SPEC-011).
2//!
3//! Grounded in Apple AppKit `NSProgressIndicator` and W3C DTCG tokens via [`MacTheme`].
4//! Supports `Bar` (determinate with `CASpring` smoothing & indeterminate pulse) and
5//! `Spinning` (12-spoke radial fin spinner) across `Regular`, `Small`, and `Mini` control sizes.
6
7use crate::layout::subpixel_snap_rect;
8use crate::motion::CASpring;
9use crate::theme::MacTheme;
10use crate::tokens::ProgressSizeMetrics;
11use egui::{
12    Align2, CornerRadius, FontId, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, Widget,
13};
14
15/// Presentation style for [`MacProgressIndicator`].
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum MacProgressStyle {
18    /// Horizontal determinate or indeterminate progress bar (`NSProgressIndicatorBarStyle`).
19    #[default]
20    Bar,
21    /// Radial 12-spoke fin spinner (`NSProgressIndicatorSpinningStyle`).
22    Spinning,
23}
24
25/// Control size tier for [`MacProgressIndicator`].
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum MacProgressControlSize {
28    /// Standard size (`NSControlSizeRegular`: 6pt bar, 32pt spinner).
29    #[default]
30    Regular,
31    /// Compact size (`NSControlSizeSmall`: 4pt bar, 16pt spinner).
32    Small,
33    /// Miniature size (`NSControlSizeMini`: 4pt bar, 12pt spinner).
34    Mini,
35}
36
37/// Authentic macOS AppKit progress indicator (`NSProgressIndicator` equivalent).
38///
39/// # Example
40///
41/// ```rust
42/// use egui_macview::{MacProgressControlSize, MacProgressIndicator, MacTheme};
43///
44/// # let ctx = egui::Context::default();
45/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
46/// let theme = MacTheme::default();
47/// MacProgressIndicator::bar(0.75)
48///     .theme(&theme)
49///     .control_size(MacProgressControlSize::Regular)
50///     .show_percentage(true)
51///     .show(ui);
52/// # });
53/// # output.textures_delta.clear();
54/// ```
55pub struct MacProgressIndicator<'a> {
56    style: MacProgressStyle,
57    control_size: MacProgressControlSize,
58    progress: Option<f32>,
59    time: f64,
60    label: Option<&'a str>,
61    show_percentage: bool,
62    width: Option<f32>,
63    enabled: bool,
64    theme: Option<&'a MacTheme>,
65}
66
67impl<'a> Default for MacProgressIndicator<'a> {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl<'a> MacProgressIndicator<'a> {
74    /// Creates a new determinate bar progress indicator at `0.0`.
75    pub fn new() -> Self {
76        Self {
77            style: MacProgressStyle::Bar,
78            control_size: MacProgressControlSize::Regular,
79            progress: Some(0.0),
80            time: 0.0,
81            label: None,
82            show_percentage: false,
83            width: None,
84            enabled: true,
85            theme: None,
86        }
87    }
88
89    /// Creates a determinate horizontal progress bar with progress in `0.0..=1.0`.
90    pub fn bar(progress: f32) -> Self {
91        Self::new().style(MacProgressStyle::Bar).progress(progress)
92    }
93
94    /// Creates a radial spinning fin indicator animated by `time` in seconds.
95    pub fn spinning(time: f64) -> Self {
96        Self::new()
97            .style(MacProgressStyle::Spinning)
98            .indeterminate(time)
99    }
100
101    /// Sets the presentation style (`Bar` or `Spinning`).
102    pub fn style(mut self, style: MacProgressStyle) -> Self {
103        self.style = style;
104        self
105    }
106
107    /// Sets the AppKit control size (`Regular`, `Small`, or `Mini`).
108    pub fn control_size(mut self, size: MacProgressControlSize) -> Self {
109        self.control_size = size;
110        self
111    }
112
113    /// Sets determinate progress fraction (`0.0..=1.0`).
114    pub fn progress(mut self, progress: f32) -> Self {
115        self.progress = Some(progress.clamp(0.0, 1.0));
116        self
117    }
118
119    /// Configures indeterminate mode driven by animation `time` in seconds.
120    pub fn indeterminate(mut self, time: f64) -> Self {
121        self.progress = None;
122        self.time = time;
123        self
124    }
125
126    /// Sets animation timestamp `time` in seconds.
127    pub fn time(mut self, time: f64) -> Self {
128        self.time = time;
129        self
130    }
131
132    /// Attaches an optional status label next to or below the indicator.
133    pub fn label(mut self, label: &'a str) -> Self {
134        self.label = Some(label);
135        self
136    }
137
138    /// Enables or disables automatic percentage readout (`0%`..=`100%`) for determinate bars.
139    pub fn show_percentage(mut self, show: bool) -> Self {
140        self.show_percentage = show;
141        self
142    }
143
144    /// Overrides horizontal bar width in points.
145    pub fn width(mut self, width: f32) -> Self {
146        self.width = Some(width);
147        self
148    }
149
150    /// Enables or disables the control.
151    pub fn enabled(mut self, enabled: bool) -> Self {
152        self.enabled = enabled;
153        self
154    }
155
156    /// Injects dynamic [`MacTheme`].
157    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
158        self.theme = Some(theme);
159        self
160    }
161
162    /// Returns the clamped determinate progress in `0.0..=1.0`, if determinate.
163    pub fn clamped_progress(&self) -> Option<f32> {
164        self.progress.map(|p| p.clamp(0.0, 1.0))
165    }
166
167    fn size_metrics<'t>(&self, theme: &'t MacTheme) -> &'t ProgressSizeMetrics {
168        let pm = theme.progress_indicator();
169        match self.control_size {
170            MacProgressControlSize::Regular => &pm.regular,
171            MacProgressControlSize::Small => &pm.small,
172            MacProgressControlSize::Mini => &pm.mini,
173        }
174    }
175
176    /// Renders the progress indicator in `ui`.
177    pub fn show(self, ui: &mut Ui) -> Response {
178        ui.add(self)
179    }
180}
181
182impl<'a> Widget for MacProgressIndicator<'a> {
183    fn ui(self, ui: &mut Ui) -> Response {
184        let fallback_theme;
185        let theme = match self.theme {
186            Some(t) => t,
187            None => {
188                fallback_theme = MacTheme::from_ctx(ui.ctx());
189                &fallback_theme
190            }
191        };
192
193        let pm = theme.progress_indicator();
194        let sm = self.size_metrics(theme);
195        let font_sz = sm.font_size.points();
196        let label_spacing = pm.label_spacing.points();
197        let pct_scale = pm.percentage_scale.points();
198
199        let status_text: Option<String> = match (self.label, self.show_percentage, self.progress) {
200            (Some(lbl), true, Some(p)) => {
201                let pct = (p.clamp(0.0, 1.0) * pct_scale).round() as u32;
202                Some(format!("{lbl} ({pct}%)"))
203            }
204            (Some(lbl), _, _) => Some(lbl.to_string()),
205            (None, true, Some(p)) => {
206                let pct = (p.clamp(0.0, 1.0) * pct_scale).round() as u32;
207                Some(format!("{pct}%"))
208            }
209            (None, _, _) => None,
210        };
211
212        match self.style {
213            MacProgressStyle::Bar => {
214                let bar_w = self.width.unwrap_or_else(|| pm.default_bar_width.points());
215                let bar_h = sm.bar_height.points();
216                let total_h = if status_text.is_some() {
217                    bar_h + label_spacing + font_sz
218                } else {
219                    bar_h
220                };
221
222                let (raw_rect, response) =
223                    ui.allocate_exact_size(Vec2::new(bar_w, total_h), Sense::hover());
224                let rect = subpixel_snap_rect(raw_rect, ui.ctx().pixels_per_point());
225                let track_rect = Rect::from_min_size(rect.min, Vec2::new(rect.width(), bar_h));
226                let radius = CornerRadius::same(sm.bar_corner_radius.points() as u8);
227                let painter = ui.painter_at(rect);
228
229                painter.rect(
230                    track_rect,
231                    radius,
232                    theme.control(),
233                    Stroke::new(pm.border_width.points(), theme.separator()),
234                    StrokeKind::Inside,
235                );
236
237                let fill_color = if self.enabled {
238                    theme.control_accent()
239                } else {
240                    theme.selected_control_inactive()
241                };
242
243                if let Some(target_p) = self.clamped_progress() {
244                    let spring_preset = &theme.motion().swiftui_smooth;
245                    let spring = CASpring::new(spring_preset.response, spring_preset.damping_ratio);
246                    let anim_id = response.id.with("spring_progress");
247                    let now = if self.time > 0.0 {
248                        self.time
249                    } else {
250                        ui.input(|i| i.time)
251                    };
252                    let (mut from_p, mut to_p, mut start_t) = ui
253                        .data(|d| d.get_temp::<(f32, f32, f64)>(anim_id))
254                        .unwrap_or((target_p, target_p, now));
255
256                    let elapsed = ((now - start_t).max(0.0)) as f32;
257                    let cur_eval = spring.evaluate(elapsed).clamp(0.0, 1.0);
258                    let cur_p = (from_p + (to_p - from_p) * cur_eval).clamp(0.0, 1.0);
259
260                    let smooth_p = if (target_p - to_p).abs() > f32::EPSILON {
261                        from_p = cur_p;
262                        to_p = target_p;
263                        start_t = now;
264                        ui.ctx().request_repaint();
265                        from_p
266                    } else {
267                        if elapsed < spring.response * 2.0 {
268                            ui.ctx().request_repaint();
269                        }
270                        cur_p
271                    };
272                    ui.data_mut(|d| d.insert_temp(anim_id, (from_p, to_p, start_t)));
273
274                    if smooth_p > 0.0 {
275                        let fill_w = track_rect.width() * smooth_p;
276                        let fill_rect =
277                            Rect::from_min_size(track_rect.min, Vec2::new(fill_w, bar_h));
278                        painter.rect_filled(fill_rect, radius, fill_color);
279                    }
280                } else {
281                    let pill_ratio = pm.indeterminate_pill_ratio.points().clamp(0.0, 1.0);
282                    let pill_w = track_rect.width() * pill_ratio;
283                    let travel = (track_rect.width() - pill_w).max(0.0);
284                    let freq = pm.bar_indeterminate_speed.points() as f64;
285                    let tau = pm.tau_radians.points() as f64;
286                    let phase = (((self.time * freq * tau).sin() + 1.0) * 0.5) as f32;
287                    let pill_x = track_rect.left() + travel * phase;
288                    let pill_rect = Rect::from_min_size(
289                        Pos2::new(pill_x, track_rect.top()),
290                        Vec2::new(pill_w, bar_h),
291                    );
292                    painter.rect_filled(pill_rect, radius, fill_color);
293                    ui.ctx().request_repaint();
294                }
295
296                if let Some(text) = status_text {
297                    let label_y = track_rect.bottom() + label_spacing;
298                    painter.text(
299                        Pos2::new(track_rect.left(), label_y),
300                        Align2::LEFT_TOP,
301                        text,
302                        FontId::proportional(font_sz),
303                        theme.secondary_label(),
304                    );
305                }
306
307                response
308            }
309            MacProgressStyle::Spinning => {
310                let spin_sz = sm.spinner_size.points();
311                let (raw_rect, response) =
312                    ui.allocate_exact_size(Vec2::splat(spin_sz), Sense::hover());
313                let rect = subpixel_snap_rect(raw_rect, ui.ctx().pixels_per_point());
314                let painter = ui.painter_at(rect);
315
316                let center = rect.center();
317                let spoke_w = sm.spoke_width.points();
318                let tip_r = spoke_w * 0.5;
319                let spin_r = spin_sz * 0.5;
320                let outer_r = (spin_r - tip_r).max(0.0);
321                let inner_ratio = pm.spoke_inner_ratio.points();
322                let inner_r = outer_r * inner_ratio;
323                let spoke_count = (pm.spoke_count.points().round() as usize).max(1);
324                let tau = pm.tau_radians.points();
325                let min_alpha = pm.min_spoke_alpha.points();
326                let speed_hz = pm.spin_speed_hz.points() as f64;
327                let active_step =
328                    ((self.time * speed_hz * spoke_count as f64).floor() as usize) % spoke_count;
329
330                let base_color = theme.secondary_label();
331
332                for idx in 0..spoke_count {
333                    let angle = (idx as f32 / spoke_count as f32) * tau;
334                    let dir = Vec2::new(angle.sin(), -angle.cos());
335                    let p0 = center + dir * inner_r;
336                    let p1 = center + dir * outer_r;
337
338                    let dist = (active_step + spoke_count - idx) % spoke_count;
339                    let decay = 1.0 - (dist as f32 / spoke_count as f32);
340                    let factor = min_alpha + (1.0 - min_alpha) * decay;
341                    let spoke_color = base_color.gamma_multiply(factor.clamp(0.0, 1.0));
342
343                    painter.line_segment([p0, p1], Stroke::new(spoke_w, spoke_color));
344                    painter.circle_filled(p0, tip_r, spoke_color);
345                    painter.circle_filled(p1, tip_r, spoke_color);
346                }
347
348                ui.ctx().request_repaint();
349                response
350            }
351        }
352    }
353}