1use 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#[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 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 #[inline]
101 pub fn size(mut self, size: f32) -> Self {
102 self.size = size.max(8.0);
103 self
104 }
105
106 #[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 pub fn color(mut self, color: Color32) -> Self {
117 self.color = Some(color);
118 self.accent = None;
119 self
120 }
121
122 pub fn accent(mut self, accent: Accent) -> Self {
125 self.accent = Some(accent);
126 self.color = None;
127 self
128 }
129
130 pub fn zones(mut self, zones: GaugeZones) -> Self {
136 self.zones = Some(zones);
137 self
138 }
139
140 pub fn text(mut self, text: impl Into<String>) -> Self {
144 self.text = Some(text.into());
145 self
146 }
147
148 pub fn unit(mut self, unit: impl Into<String>) -> Self {
153 self.unit = Some(unit.into());
154 self
155 }
156
157 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 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 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 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 if self.fraction > 0.0 {
237 let sweep = TAU * self.fraction;
238 let start = -PI * 0.5;
239 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 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 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 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}