elegance/
progress_ring.rs1use 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#[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 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 #[inline]
76 pub fn size(mut self, size: f32) -> Self {
77 self.size = size.max(8.0);
78 self
79 }
80
81 #[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 pub fn color(mut self, color: Color32) -> Self {
92 self.color = Some(color);
93 self.accent = None;
94 self
95 }
96
97 pub fn accent(mut self, accent: Accent) -> Self {
100 self.accent = Some(accent);
101 self.color = None;
102 self
103 }
104
105 pub fn text(mut self, text: impl Into<String>) -> Self {
109 self.text = Some(text.into());
110 self
111 }
112
113 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 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 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 if self.fraction > 0.0 {
164 let sweep = TAU * self.fraction;
165 let start = -PI * 0.5;
166 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 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 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}