Skip to main content

elegance/
progress_ring.rs

1//! Determinate circular progress indicator.
2//!
3//! A ring-shaped cousin of [`ProgressBar`](crate::ProgressBar): a faint
4//! track plus an accent-coloured arc that sweeps clockwise from 12
5//! o'clock as the fraction grows. Centre text defaults to a rounded
6//! percent; pass an explicit label via [`ProgressRing::text`] or add a
7//! small sub-caption with [`ProgressRing::caption`].
8//!
9//! For *indeterminate* "still working" loaders, prefer
10//! [`Spinner`](crate::Spinner). This widget renders a fixed fraction
11//! each frame and never requests repaints on its own.
12
13use std::f32::consts::{PI, TAU};
14
15use egui::{
16    epaint::{PathShape, PathStroke},
17    pos2, Color32, Pos2, Response, Sense, Ui, Vec2, Widget, WidgetInfo, WidgetType,
18};
19
20use crate::theme::{Accent, Theme};
21
22/// A themed determinate circular progress indicator.
23///
24/// ```no_run
25/// # use elegance::{Accent, ProgressRing};
26/// # egui::__run_test_ui(|ui| {
27/// // Default: 56 pt diameter, sky arc, percent in the centre.
28/// ui.add(ProgressRing::new(0.42));
29///
30/// // Larger, green, custom centre text + sub-caption.
31/// ui.add(
32///     ProgressRing::new(0.6)
33///         .size(88.0)
34///         .accent(Accent::Green)
35///         .text("12 / 20")
36///         .caption("files"),
37/// );
38///
39/// // Hide the centre text entirely with an empty override.
40/// ui.add(ProgressRing::new(0.3).size(32.0).text(""));
41/// # });
42/// ```
43#[derive(Debug, Clone)]
44#[must_use = "Add with `ui.add(...)`."]
45pub struct ProgressRing {
46    fraction: f32,
47    size: f32,
48    stroke_width: Option<f32>,
49    color: Option<Color32>,
50    accent: Option<Accent>,
51    text: Option<String>,
52    caption: Option<String>,
53}
54
55impl ProgressRing {
56    /// Create a ring at `fraction` (0..=1). NaN and out-of-range values
57    /// are clamped.
58    pub fn new(fraction: f32) -> Self {
59        Self {
60            fraction: if fraction.is_nan() {
61                0.0
62            } else {
63                fraction.clamp(0.0, 1.0)
64            },
65            size: 56.0,
66            stroke_width: None,
67            color: None,
68            accent: None,
69            text: None,
70            caption: None,
71        }
72    }
73
74    /// Outer diameter in points. Default: 56. Clamped to at least 8.
75    #[inline]
76    pub fn size(mut self, size: f32) -> Self {
77        self.size = size.max(8.0);
78        self
79    }
80
81    /// Arc stroke thickness in points. Defaults to ~8 % of `size`,
82    /// clamped to `[3.0, 10.0]`.
83    #[inline]
84    pub fn stroke_width(mut self, width: f32) -> Self {
85        self.stroke_width = Some(width.max(1.0));
86        self
87    }
88
89    /// Paint the arc with an explicit colour. Clears any previously set
90    /// accent.
91    pub fn color(mut self, color: Color32) -> Self {
92        self.color = Some(color);
93        self.accent = None;
94        self
95    }
96
97    /// Pick the arc colour from one of the theme's accents. Clears any
98    /// previously set explicit colour. Default: the theme's sky.
99    pub fn accent(mut self, accent: Accent) -> Self {
100        self.accent = Some(accent);
101        self.color = None;
102        self
103    }
104
105    /// Override the centre text. By default the ring shows the rounded
106    /// percent (e.g. "42%") once `size >= 40`; passing `""` hides the
107    /// text entirely.
108    pub fn text(mut self, text: impl Into<String>) -> Self {
109        self.text = Some(text.into());
110        self
111    }
112
113    /// Add a small muted sub-caption below the primary text.
114    pub fn caption(mut self, caption: impl Into<String>) -> Self {
115        self.caption = Some(caption.into());
116        self
117    }
118}
119
120impl Widget for ProgressRing {
121    fn ui(self, ui: &mut Ui) -> Response {
122        let theme = Theme::current(ui.ctx());
123        let p = &theme.palette;
124        let color = match (self.color, self.accent) {
125            (Some(c), _) => c,
126            (_, Some(a)) => p.accent_fill(a),
127            _ => p.sky,
128        };
129        let stroke_w = self
130            .stroke_width
131            .unwrap_or((self.size * 0.08).clamp(3.0, 10.0));
132
133        let (rect, response) = ui.allocate_exact_size(Vec2::splat(self.size), Sense::hover());
134
135        if ui.is_rect_visible(rect) {
136            let painter = ui.painter();
137            let center = rect.center();
138            // Subtract half-stroke so the ring's outer edge lands on the
139            // allocated rect; extra 1 pt keeps anti-aliased edges clean.
140            let radius = ((self.size * 0.5) - stroke_w * 0.5 - 1.0).max(0.5);
141            let track_color = p.depth_tint(p.card, 0.1);
142
143            // Track and arc share the same sample density + PathShape
144            // primitive so they sit on identical pixels; mixing
145            // `circle_stroke` (internal tessellation) with a manual
146            // `PathShape::line` (polyline) produces a subtle concentric
147            // mismatch.
148            let n_full: usize = 96;
149            let point_at = |a: f32| {
150                let (sin, cos) = a.sin_cos();
151                pos2(center.x + radius * cos, center.y + radius * sin)
152            };
153
154            let track_points: Vec<Pos2> = (0..n_full)
155                .map(|i| point_at((i as f32 / n_full as f32) * TAU))
156                .collect();
157            painter.add(PathShape::closed_line(
158                track_points,
159                PathStroke::new(stroke_w, track_color),
160            ));
161
162            // Arc, clockwise from 12 o'clock.
163            if self.fraction > 0.0 {
164                let sweep = TAU * self.fraction;
165                let start = -PI * 0.5;
166                // Match the track's per-radian sampling so the arc
167                // points lie exactly on the same polygon vertices.
168                let n_points = ((n_full as f32 * self.fraction).ceil() as usize).max(2);
169                let points: Vec<Pos2> = (0..=n_points)
170                    .map(|i| point_at(start + sweep * (i as f32 / n_points as f32)))
171                    .collect();
172
173                // Rounded endpoint caps — PathShape strokes are butt-ended.
174                painter.circle_filled(points[0], stroke_w * 0.5, color);
175                painter.circle_filled(points[n_points], stroke_w * 0.5, color);
176                painter.add(PathShape::line(points, PathStroke::new(stroke_w, color)));
177            }
178
179            // Centre label + caption.
180            let primary: String = match &self.text {
181                Some(s) => s.clone(),
182                None if self.size >= 40.0 => {
183                    format!("{}%", (self.fraction * 100.0).round() as u32)
184                }
185                _ => String::new(),
186            };
187            let caption = self.caption.as_deref().unwrap_or("");
188
189            let primary_size = (self.size * 0.20).clamp(10.0, 24.0);
190            let caption_size = (self.size * 0.11).clamp(8.0, 12.0);
191
192            let primary_galley = (!primary.is_empty()).then(|| {
193                crate::theme::placeholder_galley(ui, &primary, primary_size, true, f32::INFINITY)
194            });
195            let caption_galley = (!caption.is_empty()).then(|| {
196                crate::theme::placeholder_galley(ui, caption, caption_size, false, f32::INFINITY)
197            });
198
199            let primary_h = primary_galley.as_ref().map_or(0.0, |g| g.size().y);
200            let caption_h = caption_galley.as_ref().map_or(0.0, |g| g.size().y);
201            let line_gap = if primary_galley.is_some() && caption_galley.is_some() {
202                2.0
203            } else {
204                0.0
205            };
206            let group_h = primary_h + line_gap + caption_h;
207            let top_y = center.y - group_h * 0.5;
208
209            if let Some(g) = primary_galley {
210                let g_w = g.size().x;
211                painter.galley(pos2(center.x - g_w * 0.5, top_y), g, p.text);
212            }
213            if let Some(g) = caption_galley {
214                let g_w = g.size().x;
215                let y = top_y + primary_h + line_gap;
216                painter.galley(pos2(center.x - g_w * 0.5, y), g, p.text_muted);
217            }
218        }
219
220        response
221            .widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
222        response
223    }
224}