1use frus_core::{Color, Curve, Path, Point, Rect, Scene, Stroke, StrokeCap};
6use frus_layout::{Dimension, Style};
7
8use crate::interaction::Status;
9use crate::theme::Theme;
10use crate::widget::Widget;
11
12const DOTS: usize = 8;
14
15#[derive(Copy, Clone, Debug, PartialEq)]
18pub(crate) enum RingMode {
19 Spinning { head: f32 },
21 Filling { progress: f32 },
23}
24
25pub(crate) fn paint_activity_ring(
31 scene: &mut Scene,
32 cx: f32,
33 cy: f32,
34 radius: f32,
35 dot: f32,
36 color: Color,
37 mode: RingMode,
38) {
39 for i in 0..DOTS {
40 let fraction = i as f32 / DOTS as f32;
41 let angle = fraction * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
42 let alpha = match mode {
43 RingMode::Spinning { head } => {
44 let head = head.fract() * DOTS as f32;
45 let behind = (i as f32 - head).rem_euclid(DOTS as f32) / DOTS as f32;
47 0.15 + 0.85 * (1.0 - behind)
48 }
49 RingMode::Filling { progress } => ((progress - fraction) * DOTS as f32).clamp(0.0, 1.0),
52 };
53 if alpha <= 0.0 {
54 continue;
55 }
56 let px = cx + radius * angle.cos();
57 let py = cy + radius * angle.sin();
58 scene.draw_rect(
59 Rect::new(px - dot, py - dot, dot * 2.0, dot * 2.0),
60 color.fade(alpha),
61 dot,
62 0.0,
63 Color::TRANSPARENT,
64 );
65 }
66}
67
68const PATH_PERIOD: f32 = 1.333;
71const ROTATION_PERIOD: f32 = 2.222;
73const EPSILON: f32 = 0.001;
76const START_ANGLE: f32 = -std::f32::consts::FRAC_PI_2;
78
79pub struct CircularProgressIndicator {
88 value: Option<f32>,
89 size: Option<f32>,
90 color: Option<Color>,
91 track_color: Option<Color>,
92 stroke_width: Option<f32>,
93 stroke_align: Option<f32>,
94 stroke_cap: Option<StrokeCap>,
95 track_gap: Option<f32>,
96 padding: Option<f32>,
97 year2023: Option<bool>,
98 semantics_label: Option<String>,
99 semantics_value: Option<String>,
100}
101
102impl CircularProgressIndicator {
103 pub fn new() -> Self {
106 Self::with_value(None)
107 }
108
109 pub fn determinate(value: f32) -> Self {
111 Self::with_value(Some(value))
112 }
113
114 pub fn with_value(value: Option<f32>) -> Self {
117 Self {
118 value: value.map(|v| v.clamp(0.0, 1.0)),
119 size: None,
120 color: None,
121 track_color: None,
122 stroke_width: None,
123 stroke_align: None,
124 stroke_cap: None,
125 track_gap: None,
126 padding: None,
127 year2023: None,
128 semantics_label: None,
129 semantics_value: None,
130 }
131 }
132
133 pub fn size(mut self, size: f32) -> Self {
136 self.size = Some(size);
137 self
138 }
139
140 #[must_use]
142 pub fn color(mut self, color: Color) -> Self {
143 self.color = Some(color);
144 self
145 }
146
147 #[must_use]
151 pub fn track_color(mut self, color: Color) -> Self {
152 self.track_color = Some(color);
153 self
154 }
155
156 #[must_use]
158 pub fn stroke_width(mut self, width: f32) -> Self {
159 self.stroke_width = Some(width);
160 self
161 }
162
163 #[must_use]
166 pub fn stroke_align(mut self, align: f32) -> Self {
167 self.stroke_align = Some(align);
168 self
169 }
170
171 #[must_use]
173 pub fn stroke_cap(mut self, cap: StrokeCap) -> Self {
174 self.stroke_cap = Some(cap);
175 self
176 }
177
178 #[must_use]
180 pub fn track_gap(mut self, gap: f32) -> Self {
181 self.track_gap = Some(gap);
182 self
183 }
184
185 #[must_use]
187 pub fn padding(mut self, padding: f32) -> Self {
188 self.padding = Some(padding);
189 self
190 }
191
192 #[must_use]
195 pub fn year2023(mut self, year2023: bool) -> Self {
196 self.year2023 = Some(year2023);
197 self
198 }
199
200 #[must_use]
202 pub fn semantics_label(mut self, label: impl Into<String>) -> Self {
203 self.semantics_label = Some(label.into());
204 self
205 }
206
207 #[must_use]
209 pub fn semantics_value(mut self, value: impl Into<String>) -> Self {
210 self.semantics_value = Some(value.into());
211 self
212 }
213
214 fn year2023_in(&self, theme: &Theme) -> bool {
215 self.year2023
216 .or(theme.widgets.progress.year2023)
217 .unwrap_or(true)
218 }
219
220 fn extent(&self, theme: &Theme) -> (f32, f32) {
222 let t = &theme.widgets.progress;
223 let y2023 = self.year2023_in(theme);
224 let size = self
225 .size
226 .or(t.circular_size)
227 .unwrap_or(if y2023 { 36.0 } else { 40.0 });
228 let padding = self
229 .padding
230 .or(t.circular_track_padding)
231 .unwrap_or(if y2023 { 0.0 } else { 4.0 });
232 (size, padding)
233 }
234}
235
236impl Default for CircularProgressIndicator {
237 fn default() -> Self {
238 Self::new()
239 }
240}
241
242fn indeterminate_phase(time: f32) -> (f32, f32, f32, f32) {
246 let path = (time / PATH_PERIOD).rem_euclid(1.0);
247 let rotation = (time / ROTATION_PERIOD).rem_euclid(1.0);
248 let half = |begin: f32| Curve::Interval {
249 begin,
250 end: begin + 0.5,
251 inner: Box::new(Curve::Cubic {
252 x1: 0.4,
253 y1: 0.0,
254 x2: 0.2,
255 y2: 1.0,
256 }),
257 };
258 (
259 half(0.0).transform(path),
260 half(0.5).transform(path),
261 path,
262 rotation,
263 )
264}
265
266fn arc(value: Option<f32>, time: f32) -> (f32, f32) {
268 use std::f32::consts::{PI, TAU};
269 match value {
270 Some(v) => (START_ANGLE, v.clamp(0.0, 1.0) * (TAU - EPSILON)),
271 None => {
272 let (head, tail, offset, rotation) = indeterminate_phase(time);
273 (
274 START_ANGLE + tail * 1.5 * PI + rotation * TAU + offset * 0.5 * PI,
275 (head * 1.5 * PI - tail * 1.5 * PI).max(EPSILON),
276 )
277 }
278 }
279}
280
281fn arc_path(center: Point, radius: f32, start: f32, sweep: f32) -> Path {
283 Path::new()
284 .move_to(Point::new(
285 center.x + radius * start.cos(),
286 center.y + radius * start.sin(),
287 ))
288 .arc_to(center, radius, start, start + sweep)
289}
290
291impl<Msg> Widget<Msg> for CircularProgressIndicator {
292 fn style(&self) -> Style {
293 Widget::<Msg>::style_themed(self, &Theme::default())
294 }
295
296 fn style_themed(&self, theme: &Theme) -> Style {
297 let (size, padding) = self.extent(theme);
298 let side = size + 2.0 * padding;
299 Style {
300 width: Dimension::Length(side),
301 height: Dimension::Length(side),
302 ..Default::default()
303 }
304 }
305
306 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
307 &[]
308 }
309
310 fn continuous(&self) -> bool {
313 self.value.is_none()
314 }
315
316 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
317 use std::f32::consts::TAU;
318 let t = &theme.widgets.progress;
319 let y2023 = self.year2023_in(theme);
320 let (_, padding) = self.extent(theme);
321 let o = status.opacity;
322 let stroke = self.stroke_width.or(t.stroke_width).unwrap_or(4.0);
323 let align = self
324 .stroke_align
325 .or(t.stroke_align)
326 .unwrap_or(if y2023 { 0.0 } else { -1.0 });
327 let value_color = self.color.or(t.color).unwrap_or(theme.scheme.primary);
328 let track_color = self
329 .track_color
330 .or(t.circular_track_color)
331 .or((!y2023 && self.value.is_some()).then_some(theme.scheme.secondary_container));
332 let gap = if y2023 {
333 None
334 } else {
335 Some(self.track_gap.or(t.track_gap).unwrap_or(4.0))
336 };
337 let cap = self.stroke_cap.or(t.stroke_cap);
338
339 let inset = padding + stroke * 0.5 * -align;
342 let w = (bounds.width - 2.0 * inset).max(0.0);
343 let h = (bounds.height - 2.0 * inset).max(0.0);
344 let center = Point::new(
345 bounds.x + bounds.width * 0.5,
346 bounds.y + bounds.height * 0.5,
347 );
348 let radius = w.min(h) * 0.5;
349 if radius <= 0.0 {
350 return;
351 }
352
353 if let Some(track) = track_color {
354 let track_stroke =
355 Stroke::new(track.fade(o), stroke).with_cap(cap.unwrap_or(StrokeCap::Round));
356 match (gap, self.value) {
357 (Some(gap), Some(v)) if gap > 0.0 && v > EPSILON => {
360 let start_gap = (stroke + gap) / radius;
361 let sweep = (TAU - v * TAU - 2.0 * start_gap).max(0.0);
362 if sweep > 0.0 {
363 let start = START_ANGLE + v * TAU + start_gap;
364 scene.paint_path(
365 &arc_path(center, radius, start, sweep),
366 None,
367 Some(track_stroke),
368 );
369 }
370 }
371 _ => scene.paint_path(
372 &arc_path(center, radius, 0.0, TAU - EPSILON),
373 None,
374 Some(track_stroke),
375 ),
376 }
377 }
378
379 let cap = match (y2023, cap, self.value) {
380 (_, Some(cap), _) => cap,
381 (true, None, None) => StrokeCap::Square,
382 (true, None, Some(_)) => StrokeCap::Butt,
383 (false, None, _) => StrokeCap::Round,
384 };
385 let (start, sweep) = arc(self.value, status.time);
386 if self.value == Some(0.0) {
387 return;
388 }
389 scene.paint_path(
390 &arc_path(center, radius, start, sweep),
391 None,
392 Some(Stroke::new(value_color.fade(o), stroke).with_cap(cap)),
393 );
394 }
395
396 fn on_click(&self) -> Option<Msg> {
397 None
398 }
399
400 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
403 let mut s = frus_core::SemanticsProperties::new(frus_core::Role::ProgressBar);
404 if let Some(label) = &self.semantics_label {
405 s = s.label(label.clone());
406 }
407 if let Some(value) = self.value {
408 let pct = (value * 100.0).round();
409 s = s.range(0.0, pct, 100.0);
410 s = s.value(self.semantics_value.clone().unwrap_or(format!("{pct}")));
411 } else if let Some(said) = &self.semantics_value {
412 s = s.value(said.clone());
413 }
414 Some(s)
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use frus_core::{PathVerb, Primitive};
422 use std::f32::consts::{PI, TAU};
423
424 struct Arc {
425 start: Point,
426 end: Point,
427 color: Color,
428 width: f32,
429 cap: StrokeCap,
430 }
431
432 fn arcs(spinner: &CircularProgressIndicator, theme: &Theme, time: f32) -> Vec<Arc> {
433 let side = match Widget::<()>::style_themed(spinner, theme).width {
434 Dimension::Length(s) => s,
435 _ => unreachable!(),
436 };
437 let mut scene = Scene::new();
438 let status = Status {
439 time,
440 ..Default::default()
441 };
442 Widget::<()>::paint(
443 spinner,
444 Rect::new(0.0, 0.0, side, side),
445 status,
446 theme,
447 &mut scene,
448 );
449 scene
450 .primitives()
451 .iter()
452 .filter_map(|p| match p {
453 Primitive::Path {
454 path,
455 stroke: Some(s),
456 ..
457 } => {
458 let start = match path.verbs()[0] {
459 PathVerb::MoveTo(p) => p,
460 _ => unreachable!(),
461 };
462 let end = match path.verbs().last() {
463 Some(PathVerb::CubicTo { to, .. }) => *to,
464 _ => start,
465 };
466 Some(Arc {
467 start,
468 end,
469 color: s.color,
470 width: s.width,
471 cap: s.cap,
472 })
473 }
474 _ => None,
475 })
476 .collect()
477 }
478
479 fn near(a: Point, b: Point) -> bool {
480 (a.x - b.x).abs() < 0.05 && (a.y - b.y).abs() < 0.05
481 }
482
483 fn on_circle(c: f32, r: f32, angle: f32) -> Point {
484 Point::new(c + r * angle.cos(), c + r * angle.sin())
485 }
486
487 #[test]
491 fn the_default_look_is_the_reference_s_default() {
492 let theme = Theme::default();
493 let quarter = CircularProgressIndicator::determinate(0.25);
494 assert_eq!(
495 Widget::<()>::style_themed(&quarter, &theme).width,
496 Dimension::Length(36.0)
497 );
498 let a = arcs(&quarter, &theme, 0.0);
499 assert_eq!(a.len(), 1, "one arc and no track");
500 let (c, r) = (18.0, 18.0);
501 assert!(near(a[0].start, on_circle(c, r, -PI / 2.0)), "from the top");
502 assert!(
503 near(
504 a[0].end,
505 on_circle(c, r, -PI / 2.0 + 0.25 * (TAU - EPSILON))
506 ),
507 "a quarter clockwise"
508 );
509 assert_eq!((a[0].width, a[0].cap), (4.0, StrokeCap::Butt));
510 assert_eq!(a[0].color, theme.scheme.primary);
511 let spinning = arcs(&CircularProgressIndicator::new(), &theme, 0.3);
513 assert_eq!(spinning[0].cap, StrokeCap::Square);
514 }
515
516 #[test]
520 fn the_newer_look_is_the_reference_s() {
521 let theme = Theme::default();
522 let half = CircularProgressIndicator::determinate(0.5).year2023(false);
523 assert_eq!(
524 Widget::<()>::style_themed(&half, &theme).width,
525 Dimension::Length(48.0)
526 );
527 let a = arcs(&half, &theme, 0.0);
528 assert_eq!(a.len(), 2, "a track and the arc");
529 let (track, value) = (&a[0], &a[1]);
530 let (c, r) = (24.0, 20.0 - 2.0);
532 assert!(near(value.start, on_circle(c, r, -PI / 2.0)));
533 assert_eq!(value.cap, StrokeCap::Round);
534 assert_eq!(track.color, theme.scheme.secondary_container);
535 let gap = (4.0 + 4.0) / r;
536 assert!(near(track.start, on_circle(c, r, -PI / 2.0 + PI + gap)));
537 assert!(near(track.end, on_circle(c, r, -PI / 2.0 + TAU - gap)));
538 let spinning = arcs(
540 &CircularProgressIndicator::new().year2023(false),
541 &theme,
542 0.3,
543 );
544 assert_eq!(spinning.len(), 1);
545 }
546
547 #[test]
551 fn an_indeterminate_arc_runs_the_reference_s_timing() {
552 let spinner = CircularProgressIndicator::new();
553 assert!(Widget::<()>::continuous(&spinner));
554 assert!(!Widget::<()>::continuous(
555 &CircularProgressIndicator::determinate(0.3)
556 ));
557 let fast_out_slow_in = Curve::Cubic {
558 x1: 0.4,
559 y1: 0.0,
560 x2: 0.2,
561 y2: 1.0,
562 };
563 let time = 0.25 * PATH_PERIOD;
566 let (head, tail, offset, rotation) = indeterminate_phase(time);
567 assert!((head - fast_out_slow_in.transform(0.5)).abs() < 1e-4);
568 assert_eq!(tail, 0.0);
569 assert!((offset - 0.25).abs() < 1e-4);
570 assert!((rotation - time / ROTATION_PERIOD).abs() < 1e-4);
571 let (start, sweep) = arc(None, time);
572 let expect_start = START_ANGLE + rotation * TAU + offset * 0.5 * PI;
573 assert!((start - expect_start).abs() < 1e-4);
574 assert!((sweep - head * 1.5 * PI).abs() < 1e-4);
575 assert_eq!(arc(None, 0.0).1, EPSILON);
577 let a = arcs(&spinner, &Theme::default(), time);
579 assert!(near(a[0].start, on_circle(18.0, 18.0, start)));
580 }
581
582 #[test]
584 fn an_indicator_answers_to_its_theme_and_to_its_caller() {
585 let mut theme = Theme::default();
586 theme.widgets.progress.color = Some(Color::rgb(0.9, 0.1, 0.1));
587 theme.widgets.progress.circular_track_color = Some(Color::rgb(0.2, 0.2, 0.2));
588 theme.widgets.progress.stroke_width = Some(6.0);
589 theme.widgets.progress.stroke_cap = Some(StrokeCap::Round);
590 theme.widgets.progress.circular_size = Some(50.0);
591 let plain = CircularProgressIndicator::determinate(0.5);
592 assert_eq!(
593 Widget::<()>::style_themed(&plain, &theme).width,
594 Dimension::Length(50.0)
595 );
596 let a = arcs(&plain, &theme, 0.0);
597 assert_eq!(a.len(), 2, "the theme's track, whole, then the arc");
598 assert_eq!((a[0].color, a[0].width), (Color::rgb(0.2, 0.2, 0.2), 6.0));
599 assert_eq!(
600 (a[1].color, a[1].cap),
601 (Color::rgb(0.9, 0.1, 0.1), StrokeCap::Round)
602 );
603
604 let told = CircularProgressIndicator::determinate(0.5)
605 .size(20.0)
606 .padding(2.0)
607 .stroke_width(2.0)
608 .stroke_align(1.0)
609 .stroke_cap(StrokeCap::Butt)
610 .color(Color::rgb(0.0, 0.0, 1.0));
611 assert_eq!(
612 Widget::<()>::style_themed(&told, &theme).width,
613 Dimension::Length(24.0)
614 );
615 let a = arcs(&told, &theme, 0.0);
616 assert!(near(a[1].start, on_circle(12.0, 11.0, -PI / 2.0)));
618 assert_eq!((a[1].width, a[1].cap), (2.0, StrokeCap::Butt));
619 assert_eq!(a[1].color, Color::rgb(0.0, 0.0, 1.0));
620 }
621
622 #[test]
624 fn a_reader_hears_the_reference_s_values() {
625 let s = Widget::<()>::semantics(
626 &CircularProgressIndicator::determinate(0.426).semantics_label("Sync"),
627 )
628 .unwrap();
629 assert_eq!(s.label.as_deref(), Some("Sync"));
630 assert_eq!(s.value.as_deref(), Some("43"));
631 assert_eq!(
632 Widget::<()>::semantics(&CircularProgressIndicator::new())
633 .unwrap()
634 .value,
635 None
636 );
637 }
638}