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::gauge::GaugeZones;
21use crate::theme::{placeholder_galley, Accent, Theme, BASELINE_FRAC};
22
23/// A themed determinate circular progress indicator.
24///
25/// Doubles as a circular gauge: pass [`ProgressRing::zones`] to colour
26/// the arc by which threshold band the fraction falls in, add a
27/// baseline-aligned unit suffix with [`ProgressRing::unit`], and use
28/// [`ProgressRing::caption_below`] to anchor a descriptive caption
29/// underneath the ring instead of inside it.
30///
31/// ```no_run
32/// # use elegance::{Accent, GaugeZones, ProgressRing};
33/// # egui::__run_test_ui(|ui| {
34/// // Default: 56 pt diameter, sky arc, percent in the centre.
35/// ui.add(ProgressRing::new(0.42));
36///
37/// // Larger, green, custom centre text + sub-caption.
38/// ui.add(
39///     ProgressRing::new(0.6)
40///         .size(88.0)
41///         .accent(Accent::Green)
42///         .text("12 / 20")
43///         .caption("files"),
44/// );
45///
46/// // Donut-style gauge: threshold zones drive the arc colour, the
47/// // unit suffix is baseline-aligned next to the value, and the
48/// // caption sits below the ring.
49/// ui.add(
50///     ProgressRing::new(0.68)
51///         .size(160.0)
52///         .zones(GaugeZones::new(0.6, 0.85))
53///         .text("68")
54///         .unit("GB")
55///         .caption_below("of 100"),
56/// );
57///
58/// // Hide the centre text entirely with an empty override.
59/// ui.add(ProgressRing::new(0.3).size(32.0).text(""));
60/// # });
61/// ```
62#[derive(Debug, Clone)]
63#[must_use = "Add with `ui.add(...)`."]
64pub struct ProgressRing {
65    fraction: f32,
66    size: f32,
67    stroke_width: Option<f32>,
68    color: Option<Color32>,
69    accent: Option<Accent>,
70    zones: Option<GaugeZones>,
71    text: Option<String>,
72    unit: Option<String>,
73    caption: Option<String>,
74    caption_below: bool,
75}
76
77impl ProgressRing {
78    /// Create a ring at `fraction` (0..=1). NaN and out-of-range values
79    /// are clamped.
80    pub fn new(fraction: f32) -> Self {
81        Self {
82            fraction: if fraction.is_nan() {
83                0.0
84            } else {
85                fraction.clamp(0.0, 1.0)
86            },
87            size: 56.0,
88            stroke_width: None,
89            color: None,
90            accent: None,
91            zones: None,
92            text: None,
93            unit: None,
94            caption: None,
95            caption_below: false,
96        }
97    }
98
99    /// Outer diameter in points. Default: 56. Clamped to at least 8.
100    #[inline]
101    pub fn size(mut self, size: f32) -> Self {
102        self.size = size.max(8.0);
103        self
104    }
105
106    /// Arc stroke thickness in points. Defaults to ~8 % of `size`,
107    /// clamped to `[3.0, 10.0]`.
108    #[inline]
109    pub fn stroke_width(mut self, width: f32) -> Self {
110        self.stroke_width = Some(width.max(1.0));
111        self
112    }
113
114    /// Paint the arc with an explicit colour. Clears any previously set
115    /// accent.
116    pub fn color(mut self, color: Color32) -> Self {
117        self.color = Some(color);
118        self.accent = None;
119        self
120    }
121
122    /// Pick the arc colour from one of the theme's accents. Clears any
123    /// previously set explicit colour. Default: the theme's sky.
124    pub fn accent(mut self, accent: Accent) -> Self {
125        self.accent = Some(accent);
126        self.color = None;
127        self
128    }
129
130    /// Drive the arc colour from threshold zones (`success` / `warning` /
131    /// `danger` depending on which band the current fraction falls in),
132    /// turning the ring into a circular gauge. Takes precedence over
133    /// [`accent`](Self::accent) but loses to an explicit
134    /// [`color`](Self::color).
135    pub fn zones(mut self, zones: GaugeZones) -> Self {
136        self.zones = Some(zones);
137        self
138    }
139
140    /// Override the centre text. By default the ring shows the rounded
141    /// percent (e.g. "42%") once `size >= 40`; passing `""` hides the
142    /// text entirely.
143    pub fn text(mut self, text: impl Into<String>) -> Self {
144        self.text = Some(text.into());
145        self
146    }
147
148    /// Add a small muted unit suffix next to the centre text,
149    /// baseline-aligned with the primary value (e.g. `text("68")`,
150    /// `unit("GB")` reads as `68 GB` with the unit slightly smaller
151    /// and the bottoms aligned to the value's baseline).
152    pub fn unit(mut self, unit: impl Into<String>) -> Self {
153        self.unit = Some(unit.into());
154        self
155    }
156
157    /// Add a small muted sub-caption directly under the primary text,
158    /// inside the ring. See [`caption_below`](Self::caption_below) for
159    /// a variant that anchors the caption outside the ring instead.
160    pub fn caption(mut self, caption: impl Into<String>) -> Self {
161        self.caption = Some(caption.into());
162        self.caption_below = false;
163        self
164    }
165
166    /// Add a small muted caption beneath the entire ring (outside the
167    /// circle). Useful for descriptive phrases like `"of 100"` or
168    /// `"of monthly budget"` that would crowd the centre if rendered
169    /// inside. Reserves vertical space below the ring for the caption.
170    pub fn caption_below(mut self, caption: impl Into<String>) -> Self {
171        self.caption = Some(caption.into());
172        self.caption_below = true;
173        self
174    }
175}
176
177impl Widget for ProgressRing {
178    fn ui(self, ui: &mut Ui) -> Response {
179        let theme = Theme::current(ui.ctx());
180        let p = &theme.palette;
181        let color = match (self.color, &self.zones, self.accent) {
182            (Some(c), _, _) => c,
183            (_, Some(z), _) => z.color(self.fraction, p),
184            (_, _, Some(a)) => p.accent_fill(a),
185            _ => p.sky,
186        };
187        let stroke_w = self
188            .stroke_width
189            .unwrap_or((self.size * 0.08).clamp(3.0, 10.0));
190
191        let primary_size = (self.size * 0.20).clamp(10.0, 24.0);
192        let unit_size = (self.size * 0.09).clamp(11.0, 17.0);
193        let caption_size = (self.size * 0.11).clamp(8.0, 13.0);
194
195        let caption_text = self.caption.as_deref().unwrap_or("");
196        let caption_below_present = self.caption_below && !caption_text.is_empty();
197        let caption_below_h = if caption_below_present {
198            caption_size + 4.0
199        } else {
200            0.0
201        };
202
203        let total_h = self.size + caption_below_h;
204        let (rect, response) =
205            ui.allocate_exact_size(Vec2::new(self.size, total_h), Sense::hover());
206
207        if ui.is_rect_visible(rect) {
208            let painter = ui.painter();
209            let ring_rect = egui::Rect::from_min_size(rect.min, Vec2::splat(self.size));
210            let center = ring_rect.center();
211            // Subtract half-stroke so the ring's outer edge lands on the
212            // allocated rect; extra 1 pt keeps anti-aliased edges clean.
213            let radius = ((self.size * 0.5) - stroke_w * 0.5 - 1.0).max(0.5);
214            let track_color = p.depth_tint(p.card, 0.1);
215
216            // Track and arc share the same sample density + PathShape
217            // primitive so they sit on identical pixels; mixing
218            // `circle_stroke` (internal tessellation) with a manual
219            // `PathShape::line` (polyline) produces a subtle concentric
220            // mismatch.
221            let n_full: usize = 96;
222            let point_at = |a: f32| {
223                let (sin, cos) = a.sin_cos();
224                pos2(center.x + radius * cos, center.y + radius * sin)
225            };
226
227            let track_points: Vec<Pos2> = (0..n_full)
228                .map(|i| point_at((i as f32 / n_full as f32) * TAU))
229                .collect();
230            painter.add(PathShape::closed_line(
231                track_points,
232                PathStroke::new(stroke_w, track_color),
233            ));
234
235            // Arc, clockwise from 12 o'clock.
236            if self.fraction > 0.0 {
237                let sweep = TAU * self.fraction;
238                let start = -PI * 0.5;
239                // Match the track's per-radian sampling so the arc
240                // points lie exactly on the same polygon vertices.
241                let n_points = ((n_full as f32 * self.fraction).ceil() as usize).max(2);
242                let points: Vec<Pos2> = (0..=n_points)
243                    .map(|i| point_at(start + sweep * (i as f32 / n_points as f32)))
244                    .collect();
245
246                // Rounded endpoint caps — PathShape strokes are butt-ended.
247                painter.circle_filled(points[0], stroke_w * 0.5, color);
248                painter.circle_filled(points[n_points], stroke_w * 0.5, color);
249                painter.add(PathShape::line(points, PathStroke::new(stroke_w, color)));
250            }
251
252            // Centre value + (optional baseline-aligned unit) +
253            // (optional inside caption). The caption_below variant is
254            // anchored beneath the ring further down.
255            let primary: String = match &self.text {
256                Some(s) => s.clone(),
257                None if self.size >= 40.0 => {
258                    format!("{}%", (self.fraction * 100.0).round() as u32)
259                }
260                _ => String::new(),
261            };
262            let unit_text = self.unit.as_deref().unwrap_or("");
263            let inside_caption = if self.caption_below { "" } else { caption_text };
264
265            let primary_galley = (!primary.is_empty())
266                .then(|| placeholder_galley(ui, &primary, primary_size, true, f32::INFINITY));
267            let unit_galley = (primary_galley.is_some() && !unit_text.is_empty())
268                .then(|| placeholder_galley(ui, unit_text, unit_size, false, f32::INFINITY));
269            let inside_caption_galley = (!inside_caption.is_empty()).then(|| {
270                placeholder_galley(ui, inside_caption, caption_size, false, f32::INFINITY)
271            });
272
273            let primary_h = primary_galley.as_ref().map_or(0.0, |g| g.size().y);
274            let inside_caption_h = inside_caption_galley.as_ref().map_or(0.0, |g| g.size().y);
275            let line_gap = if primary_galley.is_some() && inside_caption_galley.is_some() {
276                2.0
277            } else {
278                0.0
279            };
280            let group_h = primary_h + line_gap + inside_caption_h;
281            let top_y = center.y - group_h * 0.5;
282
283            if let Some(g) = primary_galley {
284                let primary_w = g.size().x;
285                let unit_w = unit_galley.as_ref().map_or(0.0, |g| g.size().x);
286                let gap = if unit_galley.is_some() { 4.0 } else { 0.0 };
287                let total_w = primary_w + gap + unit_w;
288                let start_x = center.x - total_w * 0.5;
289                painter.galley(pos2(start_x, top_y), g, p.text);
290                if let Some(u) = unit_galley {
291                    let baseline = top_y + primary_h * BASELINE_FRAC;
292                    let unit_y = baseline - u.size().y * BASELINE_FRAC;
293                    painter.galley(pos2(start_x + primary_w + gap, unit_y), u, p.text_muted);
294                }
295            }
296            if let Some(g) = inside_caption_galley {
297                let g_w = g.size().x;
298                let y = top_y + primary_h + line_gap;
299                painter.galley(pos2(center.x - g_w * 0.5, y), g, p.text_muted);
300            }
301
302            // Caption below the ring, outside the circle.
303            if caption_below_present {
304                let g = placeholder_galley(ui, caption_text, caption_size, false, f32::INFINITY);
305                painter.galley(
306                    pos2(center.x - g.size().x * 0.5, ring_rect.bottom() + 4.0),
307                    g,
308                    p.text_faint,
309                );
310            }
311        }
312
313        response
314            .widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "progress"));
315        response
316    }
317}