1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum MacProgressStyle {
18 #[default]
20 Bar,
21 Spinning,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum MacProgressControlSize {
28 #[default]
30 Regular,
31 Small,
33 Mini,
35}
36
37pub 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 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 pub fn bar(progress: f32) -> Self {
91 Self::new().style(MacProgressStyle::Bar).progress(progress)
92 }
93
94 pub fn spinning(time: f64) -> Self {
96 Self::new()
97 .style(MacProgressStyle::Spinning)
98 .indeterminate(time)
99 }
100
101 pub fn style(mut self, style: MacProgressStyle) -> Self {
103 self.style = style;
104 self
105 }
106
107 pub fn control_size(mut self, size: MacProgressControlSize) -> Self {
109 self.control_size = size;
110 self
111 }
112
113 pub fn progress(mut self, progress: f32) -> Self {
115 self.progress = Some(progress.clamp(0.0, 1.0));
116 self
117 }
118
119 pub fn indeterminate(mut self, time: f64) -> Self {
121 self.progress = None;
122 self.time = time;
123 self
124 }
125
126 pub fn time(mut self, time: f64) -> Self {
128 self.time = time;
129 self
130 }
131
132 pub fn label(mut self, label: &'a str) -> Self {
134 self.label = Some(label);
135 self
136 }
137
138 pub fn show_percentage(mut self, show: bool) -> Self {
140 self.show_percentage = show;
141 self
142 }
143
144 pub fn width(mut self, width: f32) -> Self {
146 self.width = Some(width);
147 self
148 }
149
150 pub fn enabled(mut self, enabled: bool) -> Self {
152 self.enabled = enabled;
153 self
154 }
155
156 pub fn theme(mut self, theme: &'a MacTheme) -> Self {
158 self.theme = Some(theme);
159 self
160 }
161
162 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 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}