use frus_core::{Color, Curve, Path, Point, Rect, Scene, Stroke, StrokeCap};
use frus_layout::{Dimension, Style};
use crate::interaction::Status;
use crate::theme::Theme;
use crate::widget::Widget;
const DOTS: usize = 8;
#[derive(Copy, Clone, Debug, PartialEq)]
pub(crate) enum RingMode {
Spinning { head: f32 },
Filling { progress: f32 },
}
pub(crate) fn paint_activity_ring(
scene: &mut Scene,
cx: f32,
cy: f32,
radius: f32,
dot: f32,
color: Color,
mode: RingMode,
) {
for i in 0..DOTS {
let fraction = i as f32 / DOTS as f32;
let angle = fraction * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
let alpha = match mode {
RingMode::Spinning { head } => {
let head = head.fract() * DOTS as f32;
let behind = (i as f32 - head).rem_euclid(DOTS as f32) / DOTS as f32;
0.15 + 0.85 * (1.0 - behind)
}
RingMode::Filling { progress } => ((progress - fraction) * DOTS as f32).clamp(0.0, 1.0),
};
if alpha <= 0.0 {
continue;
}
let px = cx + radius * angle.cos();
let py = cy + radius * angle.sin();
scene.draw_rect(
Rect::new(px - dot, py - dot, dot * 2.0, dot * 2.0),
color.fade(alpha),
dot,
0.0,
Color::TRANSPARENT,
);
}
}
const PATH_PERIOD: f32 = 1.333;
const ROTATION_PERIOD: f32 = 2.222;
const EPSILON: f32 = 0.001;
const START_ANGLE: f32 = -std::f32::consts::FRAC_PI_2;
pub struct CircularProgressIndicator {
value: Option<f32>,
size: Option<f32>,
color: Option<Color>,
track_color: Option<Color>,
stroke_width: Option<f32>,
stroke_align: Option<f32>,
stroke_cap: Option<StrokeCap>,
track_gap: Option<f32>,
padding: Option<f32>,
year2023: Option<bool>,
semantics_label: Option<String>,
semantics_value: Option<String>,
}
impl CircularProgressIndicator {
pub fn new() -> Self {
Self::with_value(None)
}
pub fn determinate(value: f32) -> Self {
Self::with_value(Some(value))
}
pub fn with_value(value: Option<f32>) -> Self {
Self {
value: value.map(|v| v.clamp(0.0, 1.0)),
size: None,
color: None,
track_color: None,
stroke_width: None,
stroke_align: None,
stroke_cap: None,
track_gap: None,
padding: None,
year2023: None,
semantics_label: None,
semantics_value: None,
}
}
pub fn size(mut self, size: f32) -> Self {
self.size = Some(size);
self
}
#[must_use]
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
#[must_use]
pub fn track_color(mut self, color: Color) -> Self {
self.track_color = Some(color);
self
}
#[must_use]
pub fn stroke_width(mut self, width: f32) -> Self {
self.stroke_width = Some(width);
self
}
#[must_use]
pub fn stroke_align(mut self, align: f32) -> Self {
self.stroke_align = Some(align);
self
}
#[must_use]
pub fn stroke_cap(mut self, cap: StrokeCap) -> Self {
self.stroke_cap = Some(cap);
self
}
#[must_use]
pub fn track_gap(mut self, gap: f32) -> Self {
self.track_gap = Some(gap);
self
}
#[must_use]
pub fn padding(mut self, padding: f32) -> Self {
self.padding = Some(padding);
self
}
#[must_use]
pub fn year2023(mut self, year2023: bool) -> Self {
self.year2023 = Some(year2023);
self
}
#[must_use]
pub fn semantics_label(mut self, label: impl Into<String>) -> Self {
self.semantics_label = Some(label.into());
self
}
#[must_use]
pub fn semantics_value(mut self, value: impl Into<String>) -> Self {
self.semantics_value = Some(value.into());
self
}
fn year2023_in(&self, theme: &Theme) -> bool {
self.year2023
.or(theme.widgets.progress.year2023)
.unwrap_or(true)
}
fn extent(&self, theme: &Theme) -> (f32, f32) {
let t = &theme.widgets.progress;
let y2023 = self.year2023_in(theme);
let size = self
.size
.or(t.circular_size)
.unwrap_or(if y2023 { 36.0 } else { 40.0 });
let padding = self
.padding
.or(t.circular_track_padding)
.unwrap_or(if y2023 { 0.0 } else { 4.0 });
(size, padding)
}
}
impl Default for CircularProgressIndicator {
fn default() -> Self {
Self::new()
}
}
fn indeterminate_phase(time: f32) -> (f32, f32, f32, f32) {
let path = (time / PATH_PERIOD).rem_euclid(1.0);
let rotation = (time / ROTATION_PERIOD).rem_euclid(1.0);
let half = |begin: f32| Curve::Interval {
begin,
end: begin + 0.5,
inner: Box::new(Curve::Cubic {
x1: 0.4,
y1: 0.0,
x2: 0.2,
y2: 1.0,
}),
};
(
half(0.0).transform(path),
half(0.5).transform(path),
path,
rotation,
)
}
fn arc(value: Option<f32>, time: f32) -> (f32, f32) {
use std::f32::consts::{PI, TAU};
match value {
Some(v) => (START_ANGLE, v.clamp(0.0, 1.0) * (TAU - EPSILON)),
None => {
let (head, tail, offset, rotation) = indeterminate_phase(time);
(
START_ANGLE + tail * 1.5 * PI + rotation * TAU + offset * 0.5 * PI,
(head * 1.5 * PI - tail * 1.5 * PI).max(EPSILON),
)
}
}
}
fn arc_path(center: Point, radius: f32, start: f32, sweep: f32) -> Path {
Path::new()
.move_to(Point::new(
center.x + radius * start.cos(),
center.y + radius * start.sin(),
))
.arc_to(center, radius, start, start + sweep)
}
impl<Msg> Widget<Msg> for CircularProgressIndicator {
fn style(&self) -> Style {
Widget::<Msg>::style_themed(self, &Theme::default())
}
fn style_themed(&self, theme: &Theme) -> Style {
let (size, padding) = self.extent(theme);
let side = size + 2.0 * padding;
Style {
width: Dimension::Length(side),
height: Dimension::Length(side),
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&[]
}
fn continuous(&self) -> bool {
self.value.is_none()
}
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
use std::f32::consts::TAU;
let t = &theme.widgets.progress;
let y2023 = self.year2023_in(theme);
let (_, padding) = self.extent(theme);
let o = status.opacity;
let stroke = self.stroke_width.or(t.stroke_width).unwrap_or(4.0);
let align = self
.stroke_align
.or(t.stroke_align)
.unwrap_or(if y2023 { 0.0 } else { -1.0 });
let value_color = self.color.or(t.color).unwrap_or(theme.scheme.primary);
let track_color = self
.track_color
.or(t.circular_track_color)
.or((!y2023 && self.value.is_some()).then_some(theme.scheme.secondary_container));
let gap = if y2023 {
None
} else {
Some(self.track_gap.or(t.track_gap).unwrap_or(4.0))
};
let cap = self.stroke_cap.or(t.stroke_cap);
let inset = padding + stroke * 0.5 * -align;
let w = (bounds.width - 2.0 * inset).max(0.0);
let h = (bounds.height - 2.0 * inset).max(0.0);
let center = Point::new(
bounds.x + bounds.width * 0.5,
bounds.y + bounds.height * 0.5,
);
let radius = w.min(h) * 0.5;
if radius <= 0.0 {
return;
}
if let Some(track) = track_color {
let track_stroke =
Stroke::new(track.fade(o), stroke).with_cap(cap.unwrap_or(StrokeCap::Round));
match (gap, self.value) {
(Some(gap), Some(v)) if gap > 0.0 && v > EPSILON => {
let start_gap = (stroke + gap) / radius;
let sweep = (TAU - v * TAU - 2.0 * start_gap).max(0.0);
if sweep > 0.0 {
let start = START_ANGLE + v * TAU + start_gap;
scene.paint_path(
&arc_path(center, radius, start, sweep),
None,
Some(track_stroke),
);
}
}
_ => scene.paint_path(
&arc_path(center, radius, 0.0, TAU - EPSILON),
None,
Some(track_stroke),
),
}
}
let cap = match (y2023, cap, self.value) {
(_, Some(cap), _) => cap,
(true, None, None) => StrokeCap::Square,
(true, None, Some(_)) => StrokeCap::Butt,
(false, None, _) => StrokeCap::Round,
};
let (start, sweep) = arc(self.value, status.time);
if self.value == Some(0.0) {
return;
}
scene.paint_path(
&arc_path(center, radius, start, sweep),
None,
Some(Stroke::new(value_color.fade(o), stroke).with_cap(cap)),
);
}
fn on_click(&self) -> Option<Msg> {
None
}
fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
let mut s = frus_core::SemanticsProperties::new(frus_core::Role::ProgressBar);
if let Some(label) = &self.semantics_label {
s = s.label(label.clone());
}
if let Some(value) = self.value {
let pct = (value * 100.0).round();
s = s.range(0.0, pct, 100.0);
s = s.value(self.semantics_value.clone().unwrap_or(format!("{pct}")));
} else if let Some(said) = &self.semantics_value {
s = s.value(said.clone());
}
Some(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use frus_core::{PathVerb, Primitive};
use std::f32::consts::{PI, TAU};
struct Arc {
start: Point,
end: Point,
color: Color,
width: f32,
cap: StrokeCap,
}
fn arcs(spinner: &CircularProgressIndicator, theme: &Theme, time: f32) -> Vec<Arc> {
let side = match Widget::<()>::style_themed(spinner, theme).width {
Dimension::Length(s) => s,
_ => unreachable!(),
};
let mut scene = Scene::new();
let status = Status {
time,
..Default::default()
};
Widget::<()>::paint(
spinner,
Rect::new(0.0, 0.0, side, side),
status,
theme,
&mut scene,
);
scene
.primitives()
.iter()
.filter_map(|p| match p {
Primitive::Path {
path,
stroke: Some(s),
..
} => {
let start = match path.verbs()[0] {
PathVerb::MoveTo(p) => p,
_ => unreachable!(),
};
let end = match path.verbs().last() {
Some(PathVerb::CubicTo { to, .. }) => *to,
_ => start,
};
Some(Arc {
start,
end,
color: s.color,
width: s.width,
cap: s.cap,
})
}
_ => None,
})
.collect()
}
fn near(a: Point, b: Point) -> bool {
(a.x - b.x).abs() < 0.05 && (a.y - b.y).abs() < 0.05
}
fn on_circle(c: f32, r: f32, angle: f32) -> Point {
Point::new(c + r * angle.cos(), c + r * angle.sin())
}
#[test]
fn the_default_look_is_the_reference_s_default() {
let theme = Theme::default();
let quarter = CircularProgressIndicator::determinate(0.25);
assert_eq!(
Widget::<()>::style_themed(&quarter, &theme).width,
Dimension::Length(36.0)
);
let a = arcs(&quarter, &theme, 0.0);
assert_eq!(a.len(), 1, "one arc and no track");
let (c, r) = (18.0, 18.0);
assert!(near(a[0].start, on_circle(c, r, -PI / 2.0)), "from the top");
assert!(
near(
a[0].end,
on_circle(c, r, -PI / 2.0 + 0.25 * (TAU - EPSILON))
),
"a quarter clockwise"
);
assert_eq!((a[0].width, a[0].cap), (4.0, StrokeCap::Butt));
assert_eq!(a[0].color, theme.scheme.primary);
let spinning = arcs(&CircularProgressIndicator::new(), &theme, 0.3);
assert_eq!(spinning[0].cap, StrokeCap::Square);
}
#[test]
fn the_newer_look_is_the_reference_s() {
let theme = Theme::default();
let half = CircularProgressIndicator::determinate(0.5).year2023(false);
assert_eq!(
Widget::<()>::style_themed(&half, &theme).width,
Dimension::Length(48.0)
);
let a = arcs(&half, &theme, 0.0);
assert_eq!(a.len(), 2, "a track and the arc");
let (track, value) = (&a[0], &a[1]);
let (c, r) = (24.0, 20.0 - 2.0);
assert!(near(value.start, on_circle(c, r, -PI / 2.0)));
assert_eq!(value.cap, StrokeCap::Round);
assert_eq!(track.color, theme.scheme.secondary_container);
let gap = (4.0 + 4.0) / r;
assert!(near(track.start, on_circle(c, r, -PI / 2.0 + PI + gap)));
assert!(near(track.end, on_circle(c, r, -PI / 2.0 + TAU - gap)));
let spinning = arcs(
&CircularProgressIndicator::new().year2023(false),
&theme,
0.3,
);
assert_eq!(spinning.len(), 1);
}
#[test]
fn an_indeterminate_arc_runs_the_reference_s_timing() {
let spinner = CircularProgressIndicator::new();
assert!(Widget::<()>::continuous(&spinner));
assert!(!Widget::<()>::continuous(
&CircularProgressIndicator::determinate(0.3)
));
let fast_out_slow_in = Curve::Cubic {
x1: 0.4,
y1: 0.0,
x2: 0.2,
y2: 1.0,
};
let time = 0.25 * PATH_PERIOD;
let (head, tail, offset, rotation) = indeterminate_phase(time);
assert!((head - fast_out_slow_in.transform(0.5)).abs() < 1e-4);
assert_eq!(tail, 0.0);
assert!((offset - 0.25).abs() < 1e-4);
assert!((rotation - time / ROTATION_PERIOD).abs() < 1e-4);
let (start, sweep) = arc(None, time);
let expect_start = START_ANGLE + rotation * TAU + offset * 0.5 * PI;
assert!((start - expect_start).abs() < 1e-4);
assert!((sweep - head * 1.5 * PI).abs() < 1e-4);
assert_eq!(arc(None, 0.0).1, EPSILON);
let a = arcs(&spinner, &Theme::default(), time);
assert!(near(a[0].start, on_circle(18.0, 18.0, start)));
}
#[test]
fn an_indicator_answers_to_its_theme_and_to_its_caller() {
let mut theme = Theme::default();
theme.widgets.progress.color = Some(Color::rgb(0.9, 0.1, 0.1));
theme.widgets.progress.circular_track_color = Some(Color::rgb(0.2, 0.2, 0.2));
theme.widgets.progress.stroke_width = Some(6.0);
theme.widgets.progress.stroke_cap = Some(StrokeCap::Round);
theme.widgets.progress.circular_size = Some(50.0);
let plain = CircularProgressIndicator::determinate(0.5);
assert_eq!(
Widget::<()>::style_themed(&plain, &theme).width,
Dimension::Length(50.0)
);
let a = arcs(&plain, &theme, 0.0);
assert_eq!(a.len(), 2, "the theme's track, whole, then the arc");
assert_eq!((a[0].color, a[0].width), (Color::rgb(0.2, 0.2, 0.2), 6.0));
assert_eq!(
(a[1].color, a[1].cap),
(Color::rgb(0.9, 0.1, 0.1), StrokeCap::Round)
);
let told = CircularProgressIndicator::determinate(0.5)
.size(20.0)
.padding(2.0)
.stroke_width(2.0)
.stroke_align(1.0)
.stroke_cap(StrokeCap::Butt)
.color(Color::rgb(0.0, 0.0, 1.0));
assert_eq!(
Widget::<()>::style_themed(&told, &theme).width,
Dimension::Length(24.0)
);
let a = arcs(&told, &theme, 0.0);
assert!(near(a[1].start, on_circle(12.0, 11.0, -PI / 2.0)));
assert_eq!((a[1].width, a[1].cap), (2.0, StrokeCap::Butt));
assert_eq!(a[1].color, Color::rgb(0.0, 0.0, 1.0));
}
#[test]
fn a_reader_hears_the_reference_s_values() {
let s = Widget::<()>::semantics(
&CircularProgressIndicator::determinate(0.426).semantics_label("Sync"),
)
.unwrap();
assert_eq!(s.label.as_deref(), Some("Sync"));
assert_eq!(s.value.as_deref(), Some("43"));
assert_eq!(
Widget::<()>::semantics(&CircularProgressIndicator::new())
.unwrap()
.value,
None
);
}
}