1use frus_core::{Color, Rect, Scene};
5use frus_layout::{Dimension, Style};
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11const DOTS: usize = 8;
13const SPEED: f32 = 1.1;
15
16#[derive(Copy, Clone, Debug, PartialEq)]
19pub(crate) enum RingMode {
20 Spinning { head: f32 },
22 Filling { progress: f32 },
24}
25
26pub(crate) fn paint_activity_ring(
32 scene: &mut Scene,
33 cx: f32,
34 cy: f32,
35 radius: f32,
36 dot: f32,
37 color: Color,
38 mode: RingMode,
39) {
40 for i in 0..DOTS {
41 let fraction = i as f32 / DOTS as f32;
42 let angle = fraction * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
43 let alpha = match mode {
44 RingMode::Spinning { head } => {
45 let head = head.fract() * DOTS as f32;
46 let behind = (i as f32 - head).rem_euclid(DOTS as f32) / DOTS as f32;
48 0.15 + 0.85 * (1.0 - behind)
49 }
50 RingMode::Filling { progress } => ((progress - fraction) * DOTS as f32).clamp(0.0, 1.0),
53 };
54 if alpha <= 0.0 {
55 continue;
56 }
57 let px = cx + radius * angle.cos();
58 let py = cy + radius * angle.sin();
59 scene.draw_rect(
60 Rect::new(px - dot, py - dot, dot * 2.0, dot * 2.0),
61 color.fade(alpha),
62 dot,
63 0.0,
64 Color::TRANSPARENT,
65 );
66 }
67}
68
69pub struct CircularProgressIndicator {
71 size: f32,
72 color: Option<Color>,
73 track_color: Option<Color>,
74 stroke_width: Option<f32>,
75}
76
77impl CircularProgressIndicator {
78 pub fn new() -> Self {
80 Self {
81 size: 24.0,
82 color: None,
83 track_color: None,
84 stroke_width: None,
85 }
86 }
87
88 pub fn size(mut self, size: f32) -> Self {
90 self.size = size;
91 self
92 }
93
94 #[must_use]
96 pub fn color(mut self, color: Color) -> Self {
97 self.color = Some(color);
98 self
99 }
100
101 #[must_use]
106 pub fn track_color(mut self, color: Color) -> Self {
107 self.track_color = Some(color);
108 self
109 }
110
111 #[must_use]
116 pub fn stroke_width(mut self, width: f32) -> Self {
117 self.stroke_width = Some(width);
118 self
119 }
120
121 fn dot(&self, side: f32, theme: &Theme) -> f32 {
124 match self.stroke_width.or(theme.widgets.progress.stroke_width) {
125 Some(width) => (width * 0.5).max(0.5),
126 None => (side * 0.12).max(1.0),
127 }
128 }
129}
130
131impl Default for CircularProgressIndicator {
132 fn default() -> Self {
133 Self::new()
134 }
135}
136
137impl<Msg> Widget<Msg> for CircularProgressIndicator {
138 fn style(&self) -> Style {
139 Style {
140 width: Dimension::Length(self.size),
141 height: Dimension::Length(self.size),
142 ..Default::default()
143 }
144 }
145
146 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
147 &[]
148 }
149
150 fn continuous(&self) -> bool {
151 true
152 }
153
154 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
155 let side = bounds.width.min(bounds.height);
156 let cx = bounds.x + bounds.width * 0.5;
157 let cy = bounds.y + bounds.height * 0.5;
158 let radius = side * 0.36;
159 let dot = self.dot(side, theme);
160 if let Some(track) = self
163 .track_color
164 .or(theme.widgets.progress.circular_track_color)
165 {
166 paint_activity_ring(
167 scene,
168 cx,
169 cy,
170 radius,
171 dot,
172 track.fade(status.opacity),
173 RingMode::Filling { progress: 1.0 },
174 );
175 }
176 paint_activity_ring(
177 scene,
178 cx,
179 cy,
180 radius,
181 dot,
182 self.color
183 .or(theme.widgets.progress.color)
184 .unwrap_or(theme.primary)
185 .fade(status.opacity),
186 RingMode::Spinning {
187 head: status.time * SPEED,
188 },
189 );
190 }
191
192 fn on_click(&self) -> Option<Msg> {
193 None
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use frus_core::Primitive;
201
202 fn dot_alphas(time: f32) -> Vec<f32> {
203 let spinner = CircularProgressIndicator::new().size(40.0);
204 let status = Status {
205 time,
206 ..Default::default()
207 };
208 let mut scene = Scene::new();
209 Widget::<()>::paint(
210 &spinner,
211 Rect::new(0.0, 0.0, 40.0, 40.0),
212 status,
213 &Theme::default(),
214 &mut scene,
215 );
216 scene
217 .primitives()
218 .iter()
219 .filter_map(|p| match p {
220 Primitive::Rect { color, .. } => Some(color.a),
221 _ => None,
222 })
223 .collect()
224 }
225
226 #[test]
227 fn draws_ring_of_dots() {
228 assert_eq!(dot_alphas(0.0).len(), DOTS);
229 }
230
231 #[test]
232 fn animation_depends_on_time() {
233 assert_ne!(dot_alphas(0.0), dot_alphas(0.3));
235 }
236
237 #[test]
243 fn a_ring_answers_to_its_theme_and_to_its_caller() {
244 let plain = CircularProgressIndicator::new().size(40.0);
245 assert_eq!(
246 dot_alphas(0.0).len(),
247 DOTS,
248 "no track unless one is asked for"
249 );
250
251 let mut theme = Theme::default();
252 theme.widgets.progress.circular_track_color = Some(frus_core::Color::rgb(0.2, 0.2, 0.2));
253 theme.widgets.progress.color = Some(frus_core::Color::rgb(0.9, 0.1, 0.1));
254 theme.widgets.progress.stroke_width = Some(6.0);
255
256 let mut scene = Scene::new();
257 Widget::<()>::paint(
258 &plain,
259 Rect::new(0.0, 0.0, 40.0, 40.0),
260 Status::default(),
261 &theme,
262 &mut scene,
263 );
264 let dots: Vec<(f32, frus_core::Color)> = scene
265 .primitives()
266 .iter()
267 .filter_map(|p| match p {
268 Primitive::Rect { rect, color, .. } => Some((rect.width, *color)),
269 _ => None,
270 })
271 .collect();
272 assert_eq!(dots.len(), 2 * DOTS, "a track ring under the lit one");
273 assert_eq!(dots[0].0, 6.0, "the theme's stroke, as a diameter");
274 assert_eq!(dots[0].1, frus_core::Color::rgb(0.2, 0.2, 0.2));
275 assert_eq!(
276 dots[DOTS].1.r, 0.9,
277 "and the lit dots take the theme's colour"
278 );
279
280 let told = CircularProgressIndicator::new()
282 .size(40.0)
283 .stroke_width(2.0)
284 .color(frus_core::Color::rgb(0.0, 0.0, 1.0));
285 let mut scene = Scene::new();
286 Widget::<()>::paint(
287 &told,
288 Rect::new(0.0, 0.0, 40.0, 40.0),
289 Status::default(),
290 &theme,
291 &mut scene,
292 );
293 let dots: Vec<(f32, frus_core::Color)> = scene
294 .primitives()
295 .iter()
296 .filter_map(|p| match p {
297 Primitive::Rect { rect, color, .. } => Some((rect.width, *color)),
298 _ => None,
299 })
300 .collect();
301 assert_eq!(dots[0].0, 2.0);
302 assert_eq!(dots[DOTS].1.b, 1.0);
303 }
304
305 #[test]
306 fn declares_continuous() {
307 assert!(Widget::<()>::continuous(&CircularProgressIndicator::new()));
308 }
309}