1use crate::get_global_color;
2use eframe::egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Widget};
3use std::f32::consts::PI;
4
5const INDETERMINATE_LINEAR_DURATION_MS: f32 = 1800.0;
7const INDETERMINATE_CIRCULAR_DURATION_MS: f32 = 1333.0 * 2.222;
8
9const TRACK_GAP_RAMP_DOWN_THRESHOLD: f32 = 0.01;
12
13#[derive(Clone, Copy, PartialEq)]
15pub enum ProgressVariant {
16 Linear,
18 Circular,
20}
21
22pub struct MaterialProgress {
69 variant: ProgressVariant,
71 value: f32,
73 max: f32,
75 buffer: Option<f32>,
77 indeterminate: bool,
79 four_color_enabled: bool,
81 size: Vec2,
83 active_color: Option<Color32>,
85 track_color: Option<Color32>,
87 buffer_color: Option<Color32>,
89 border_radius: Option<f32>,
91 stroke_width: Option<f32>,
93 track_gap: Option<f32>,
95 stop_indicator_radius: Option<f32>,
97 stop_indicator_color: Option<Color32>,
99}
100
101impl MaterialProgress {
102 pub fn new(variant: ProgressVariant) -> Self {
104 Self {
105 variant,
106 value: 0.0,
107 max: 1.0,
108 buffer: None,
109 indeterminate: false,
110 four_color_enabled: false,
111 size: match variant {
112 ProgressVariant::Linear => Vec2::new(200.0, 4.0),
113 ProgressVariant::Circular => Vec2::splat(48.0),
114 },
115 active_color: None,
116 track_color: None,
117 buffer_color: None,
118 border_radius: None,
119 stroke_width: None,
120 track_gap: None,
121 stop_indicator_radius: None,
122 stop_indicator_color: None,
123 }
124 }
125
126 pub fn linear() -> Self {
128 Self::new(ProgressVariant::Linear)
129 }
130
131 pub fn circular() -> Self {
133 Self::new(ProgressVariant::Circular)
134 }
135
136 pub fn value(mut self, value: f32) -> Self {
138 self.value = value.clamp(0.0, self.max);
139 self
140 }
141
142 pub fn max(mut self, max: f32) -> Self {
144 self.max = max.max(0.001);
145 self.value = self.value.clamp(0.0, self.max);
146 self
147 }
148
149 pub fn buffer(mut self, buffer: f32) -> Self {
151 self.buffer = Some(buffer.clamp(0.0, self.max));
152 self
153 }
154
155 pub fn indeterminate(mut self, indeterminate: bool) -> Self {
157 self.indeterminate = indeterminate;
158 self
159 }
160
161 pub fn four_color_enabled(mut self, enabled: bool) -> Self {
163 self.four_color_enabled = enabled;
164 self
165 }
166
167 pub fn size(mut self, size: Vec2) -> Self {
169 self.size = size;
170 self
171 }
172
173 pub fn width(mut self, width: f32) -> Self {
175 self.size.x = width;
176 self
177 }
178
179 pub fn height(mut self, height: f32) -> Self {
181 self.size.y = height;
182 self
183 }
184
185 pub fn active_color(mut self, color: Color32) -> Self {
187 self.active_color = Some(color);
188 self
189 }
190
191 pub fn track_color(mut self, color: Color32) -> Self {
193 self.track_color = Some(color);
194 self
195 }
196
197 pub fn buffer_color(mut self, color: Color32) -> Self {
199 self.buffer_color = Some(color);
200 self
201 }
202
203 pub fn border_radius(mut self, radius: f32) -> Self {
205 self.border_radius = Some(radius);
206 self
207 }
208
209 pub fn stroke_width(mut self, width: f32) -> Self {
211 self.stroke_width = Some(width);
212 self
213 }
214
215 pub fn track_gap(mut self, gap: f32) -> Self {
217 self.track_gap = Some(gap);
218 self
219 }
220
221 pub fn stop_indicator_radius(mut self, radius: f32) -> Self {
223 self.stop_indicator_radius = Some(radius);
224 self
225 }
226
227 pub fn stop_indicator_color(mut self, color: Color32) -> Self {
229 self.stop_indicator_color = Some(color);
230 self
231 }
232
233 #[deprecated(note = "Use four_color_enabled() instead")]
235 pub fn four_color(mut self, enabled: bool) -> Self {
236 self.four_color_enabled = enabled;
237 self
238 }
239}
240
241impl Widget for MaterialProgress {
242 fn ui(self, ui: &mut Ui) -> Response {
243 let (rect, response) = ui.allocate_exact_size(self.size, Sense::hover());
244
245 match self.variant {
246 ProgressVariant::Linear => self.render_linear(ui, rect),
247 ProgressVariant::Circular => self.render_circular(ui, rect),
248 }
249
250 response
251 }
252}
253
254fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 {
259 let mut guess = t;
263 for _ in 0..8 {
264 let x = cubic_eval(x1, x2, guess) - t;
265 if x.abs() < 1e-6 {
266 break;
267 }
268 let dx = cubic_eval_derivative(x1, x2, guess);
269 if dx.abs() < 1e-6 {
270 break;
271 }
272 guess -= x / dx;
273 guess = guess.clamp(0.0, 1.0);
274 }
275 cubic_eval(y1, y2, guess)
276}
277
278fn cubic_eval(a: f32, b: f32, t: f32) -> f32 {
279 let t2 = t * t;
281 let t3 = t2 * t;
282 let mt = 1.0 - t;
283 let mt2 = mt * mt;
284 3.0 * mt2 * t * a + 3.0 * mt * t2 * b + t3
285}
286
287fn cubic_eval_derivative(a: f32, b: f32, t: f32) -> f32 {
288 let mt = 1.0 - t;
289 3.0 * mt * mt * a + 6.0 * mt * t * (b - a) + 3.0 * t * t * (1.0 - b)
290}
291
292fn interval(t: f32, begin: f32, end: f32) -> f32 {
294 ((t - begin) / (end - begin)).clamp(0.0, 1.0)
295}
296
297fn line1_head(t: f32) -> f32 {
301 let local_t = interval(t, 0.0, 750.0 / INDETERMINATE_LINEAR_DURATION_MS);
302 cubic_bezier(0.2, 0.0, 0.8, 1.0, local_t)
303}
304
305fn line1_tail(t: f32) -> f32 {
306 let local_t = interval(t, 333.0 / INDETERMINATE_LINEAR_DURATION_MS, 1083.0 / INDETERMINATE_LINEAR_DURATION_MS);
307 cubic_bezier(0.4, 0.0, 1.0, 1.0, local_t)
308}
309
310fn line2_head(t: f32) -> f32 {
311 let local_t = interval(t, 1000.0 / INDETERMINATE_LINEAR_DURATION_MS, 1567.0 / INDETERMINATE_LINEAR_DURATION_MS);
312 cubic_bezier(0.0, 0.0, 0.65, 1.0, local_t)
313}
314
315fn line2_tail(t: f32) -> f32 {
316 let local_t = interval(t, 1267.0 / INDETERMINATE_LINEAR_DURATION_MS, 1800.0 / INDETERMINATE_LINEAR_DURATION_MS);
317 cubic_bezier(0.10, 0.0, 0.45, 1.0, local_t)
318}
319
320const CIRCULAR_PATH_COUNT: f32 = 3.0;
323const CIRCULAR_ROTATION_COUNT: f32 = CIRCULAR_PATH_COUNT * 5.0 / 6.0;
324
325fn sawtooth(t: f32, count: f32) -> f32 {
326 (t * count).fract()
327}
328
329fn circular_head_value(t: f32) -> f32 {
330 let st = sawtooth(t, CIRCULAR_PATH_COUNT);
331 interval(st, 0.0, 0.5)
332}
333
334fn circular_tail_value(t: f32) -> f32 {
335 let st = sawtooth(t, CIRCULAR_PATH_COUNT);
336 interval(st, 0.5, 1.0)
337}
338
339fn circular_offset_value(t: f32) -> f32 {
340 sawtooth(t, CIRCULAR_PATH_COUNT)
341}
342
343fn circular_rotation_value(t: f32) -> f32 {
344 sawtooth(t, CIRCULAR_ROTATION_COUNT)
345}
346
347impl MaterialProgress {
348 fn resolve_active_color(&self) -> Color32 {
350 self.active_color.unwrap_or_else(|| get_global_color("primary"))
351 }
352
353 fn resolve_track_color(&self) -> Color32 {
354 self.track_color.unwrap_or_else(|| get_global_color("secondaryContainer"))
355 }
356
357 fn resolve_buffer_color(&self) -> Color32 {
358 self.buffer_color.unwrap_or_else(|| get_global_color("primaryContainer"))
359 }
360
361 fn resolve_stop_indicator_color(&self) -> Color32 {
362 self.stop_indicator_color.unwrap_or_else(|| get_global_color("primary"))
363 }
364
365 fn resolve_border_radius(&self, rect_height: f32) -> f32 {
366 self.border_radius.unwrap_or(rect_height / 2.0)
367 }
368
369 fn resolve_stroke_width(&self) -> f32 {
370 self.stroke_width.unwrap_or(4.0)
371 }
372
373 fn resolve_track_gap(&self) -> f32 {
374 self.track_gap.unwrap_or(4.0)
375 }
376
377 fn resolve_stop_indicator_radius(&self, rect_height: f32) -> f32 {
378 let r = self.stop_indicator_radius.unwrap_or(2.0);
379 r.min(rect_height / 2.0)
380 }
381
382 fn effective_track_gap_fraction(current_value: f32, track_gap_fraction: f32) -> f32 {
384 track_gap_fraction
385 * current_value.clamp(0.0, TRACK_GAP_RAMP_DOWN_THRESHOLD)
386 / TRACK_GAP_RAMP_DOWN_THRESHOLD
387 }
388
389 fn get_four_color(&self, time: f32) -> Color32 {
391 let colors = [
392 get_global_color("primary"),
393 get_global_color("primaryContainer"),
394 get_global_color("tertiary"),
395 get_global_color("tertiaryContainer"),
396 ];
397 let cycle = (time * 0.5) as usize % 4; colors[cycle]
399 }
400
401 fn render_linear(&self, ui: &mut Ui, rect: Rect) {
402 let active_color = if self.four_color_enabled && self.indeterminate {
403 let time = ui.input(|i| i.time) as f32;
404 self.get_four_color(time)
405 } else {
406 self.resolve_active_color()
407 };
408 let track_color = self.resolve_track_color();
409 let buffer_color = self.resolve_buffer_color();
410 let border_radius = self.resolve_border_radius(rect.height());
411 let rounding = CornerRadius::same(border_radius as u8);
412 let track_gap = self.resolve_track_gap();
413 let track_gap_fraction = track_gap / rect.width();
414
415 if self.indeterminate {
416 let time = ui.input(|i| i.time) as f32;
418 let cycle_duration = INDETERMINATE_LINEAR_DURATION_MS / 1000.0;
419 let animation_value = ((time % cycle_duration) / cycle_duration).clamp(0.0, 1.0);
420
421 let first_line_head = line1_head(animation_value);
422 let first_line_tail = line1_tail(animation_value);
423 let second_line_head = line2_head(animation_value);
424 let second_line_tail = line2_tail(animation_value);
425
426 if first_line_head < 1.0 - track_gap_fraction {
428 let track_start = if first_line_head > 0.0 {
429 first_line_head + Self::effective_track_gap_fraction(first_line_head, track_gap_fraction)
430 } else {
431 0.0
432 };
433 self.draw_linear_segment(ui, rect, track_start, 1.0, track_color, rounding);
434 }
435
436 if first_line_head - first_line_tail > 0.0 {
438 self.draw_linear_segment(ui, rect, first_line_tail, first_line_head, active_color, rounding);
439 }
440
441 if first_line_tail > track_gap_fraction {
443 let track_start = if second_line_head > 0.0 {
444 second_line_head + Self::effective_track_gap_fraction(second_line_head, track_gap_fraction)
445 } else {
446 0.0
447 };
448 let track_end = if first_line_tail < 1.0 {
449 first_line_tail - Self::effective_track_gap_fraction(1.0 - first_line_tail, track_gap_fraction)
450 } else {
451 1.0
452 };
453 if track_end > track_start {
454 self.draw_linear_segment(ui, rect, track_start, track_end, track_color, rounding);
455 }
456 }
457
458 if second_line_head - second_line_tail > 0.0 {
460 self.draw_linear_segment(ui, rect, second_line_tail, second_line_head, active_color, rounding);
461 }
462
463 if second_line_tail > track_gap_fraction {
465 let track_end = if second_line_tail < 1.0 {
466 second_line_tail - Self::effective_track_gap_fraction(1.0 - second_line_tail, track_gap_fraction)
467 } else {
468 1.0
469 };
470 self.draw_linear_segment(ui, rect, 0.0, track_end, track_color, rounding);
471 }
472
473 if first_line_head <= 0.0 && second_line_head <= 0.0 {
475 self.draw_linear_segment(ui, rect, 0.0, 1.0, track_color, rounding);
476 }
477
478 ui.ctx().request_repaint();
479 } else {
480 let progress = (self.value / self.max).clamp(0.0, 1.0);
482
483 let track_start = if track_gap_fraction > 0.0 && progress > 0.0 {
485 progress + Self::effective_track_gap_fraction(progress, track_gap_fraction)
486 } else {
487 0.0
488 };
489 if track_start < 1.0 {
490 self.draw_linear_segment(ui, rect, track_start, 1.0, track_color, rounding);
491 }
492
493 let stop_radius = self.resolve_stop_indicator_radius(rect.height());
495 if stop_radius > 0.0 {
496 let stop_color = self.resolve_stop_indicator_color();
497 let max_radius = rect.height() / 2.0;
498 let center = Pos2::new(
499 rect.max.x - max_radius,
500 rect.min.y + max_radius,
501 );
502 ui.painter().circle_filled(center, stop_radius, stop_color);
503 }
504
505 if let Some(buffer) = self.buffer {
507 let buffer_progress = (buffer / self.max).clamp(0.0, 1.0);
508 if buffer_progress > progress {
509 let buffer_start = if track_gap_fraction > 0.0 && progress > 0.0 {
510 progress + Self::effective_track_gap_fraction(progress, track_gap_fraction)
511 } else {
512 progress
513 };
514 if buffer_progress > buffer_start {
515 self.draw_linear_segment(ui, rect, buffer_start, buffer_progress, buffer_color, rounding);
516 }
517 }
518 }
519
520 if progress > 0.0 {
522 self.draw_linear_segment(ui, rect, 0.0, progress, active_color, rounding);
523 }
524 }
525 }
526
527 fn draw_linear_segment(
528 &self,
529 ui: &mut Ui,
530 rect: Rect,
531 start_fraction: f32,
532 end_fraction: f32,
533 color: Color32,
534 rounding: CornerRadius,
535 ) {
536 if end_fraction - start_fraction <= 0.0 {
537 return;
538 }
539
540 let left = rect.min.x + start_fraction * rect.width();
541 let right = rect.min.x + end_fraction * rect.width();
542 let segment_rect = Rect::from_min_max(
543 Pos2::new(left, rect.min.y),
544 Pos2::new(right, rect.max.y),
545 );
546
547 ui.painter().rect_filled(segment_rect, rounding, color);
548 }
549
550 fn render_circular(&self, ui: &mut Ui, rect: Rect) {
551 let stroke_width = self.resolve_stroke_width();
552 let center = rect.center();
553 let radius = (rect.width().min(rect.height()) / 2.0) - stroke_width / 2.0;
554 let track_color = self.resolve_track_color();
555 let track_gap = self.resolve_track_gap();
556
557 if self.indeterminate {
558 let time = ui.input(|i| i.time) as f32;
559 let cycle_duration = INDETERMINATE_CIRCULAR_DURATION_MS / 1000.0;
560 let animation_value = ((time % cycle_duration) / cycle_duration).clamp(0.0, 1.0);
561
562 let head_value = circular_head_value(animation_value);
563 let tail_value = circular_tail_value(animation_value);
564 let offset_value = circular_offset_value(animation_value);
565 let rotation_value = circular_rotation_value(animation_value);
566
567 ui.painter().circle_stroke(center, radius, Stroke::new(stroke_width, track_color));
569
570 let arc_start = -PI / 2.0
572 + tail_value * 3.0 / 2.0 * PI
573 + rotation_value * PI * 2.0
574 + offset_value * 0.5 * PI;
575 let arc_sweep = (head_value * 3.0 / 2.0 * PI - tail_value * 3.0 / 2.0 * PI).max(0.001);
576
577 let active_color = if self.four_color_enabled {
578 self.get_four_color(time)
579 } else {
580 self.resolve_active_color()
581 };
582
583 self.draw_arc(
584 ui,
585 center,
586 radius,
587 arc_start,
588 arc_start + arc_sweep,
589 stroke_width,
590 active_color,
591 );
592
593 ui.ctx().request_repaint();
594 } else {
595 let progress = (self.value / self.max).clamp(0.0, 1.0);
596 let active_color = self.resolve_active_color();
597
598 let epsilon = 0.001;
599 let two_pi = 2.0 * PI;
600
601 if track_gap > 0.0 && progress > epsilon {
602 let arc_radius = radius;
604 let stroke_radius = stroke_width / arc_radius;
605 let gap_radius = track_gap / arc_radius;
606 let start_gap = stroke_radius + gap_radius;
607 let end_gap = if progress < epsilon { start_gap } else { start_gap * 2.0 };
608 let track_start = -PI / 2.0 + start_gap;
609 let track_sweep = (two_pi - progress.clamp(0.0, 1.0) * two_pi - end_gap).max(0.0);
610
611 if track_sweep > 0.0 {
612 let flipped_start = PI - track_start;
614 self.draw_arc(
615 ui,
616 center,
617 radius,
618 flipped_start,
619 flipped_start - track_sweep,
620 stroke_width,
621 track_color,
622 );
623 }
624 } else {
625 ui.painter().circle_stroke(center, radius, Stroke::new(stroke_width, track_color));
627 }
628
629 if progress > 0.0 {
631 let arc_length = two_pi * progress - epsilon;
632 self.draw_arc(
633 ui,
634 center,
635 radius,
636 -PI / 2.0,
637 -PI / 2.0 + arc_length,
638 stroke_width,
639 active_color,
640 );
641 }
642 }
643 }
644
645 #[allow(clippy::too_many_arguments)]
646 fn draw_arc(
647 &self,
648 ui: &mut Ui,
649 center: Pos2,
650 radius: f32,
651 start_angle: f32,
652 end_angle: f32,
653 stroke_width: f32,
654 color: Color32,
655 ) {
656 let segments = 48;
657 let angle_step = (end_angle - start_angle) / segments as f32;
658
659 for i in 0..segments {
660 let angle1 = start_angle + i as f32 * angle_step;
661 let angle2 = start_angle + (i + 1) as f32 * angle_step;
662
663 let point1 = Pos2::new(
664 center.x + radius * angle1.cos(),
665 center.y + radius * angle1.sin(),
666 );
667 let point2 = Pos2::new(
668 center.x + radius * angle2.cos(),
669 center.y + radius * angle2.sin(),
670 );
671
672 ui.painter()
673 .line_segment([point1, point2], Stroke::new(stroke_width, color));
674 }
675 }
676}
677
678pub fn linear_progress() -> MaterialProgress {
679 MaterialProgress::linear()
680}
681
682pub fn circular_progress() -> MaterialProgress {
683 MaterialProgress::circular()
684}