1use frus_core::{BorderRadius, Color, Curve, Rect, Scene, TextDirection};
6use frus_layout::{Dimension, Style};
7
8use crate::interaction::Status;
9use crate::theme::Theme;
10use crate::widget::Widget;
11
12const HEIGHT: f32 = 4.0;
14const TRACK_GAP: f32 = 4.0;
17const STOP_RADIUS: f32 = 2.0;
20const RADIUS_2024: f32 = 2.0;
23const INDETERMINATE_PERIOD: f32 = 1.8;
25const GAP_RAMP: f32 = 0.01;
28
29pub struct LinearProgressIndicator {
38 value: Option<f32>,
39 width: Dimension,
40 sized: bool,
43 color: Option<Color>,
44 track_color: Option<Color>,
45 min_height: Option<f32>,
46 radius: Option<BorderRadius>,
47 stop_indicator_color: Option<Color>,
48 stop_indicator_radius: Option<f32>,
49 track_gap: Option<f32>,
50 year2023: Option<bool>,
51 semantics_label: Option<String>,
52 semantics_value: Option<String>,
53}
54
55impl LinearProgressIndicator {
56 pub fn new(value: f32) -> Self {
58 Self::with_value(Some(value))
59 }
60
61 pub fn indeterminate() -> Self {
64 Self::with_value(None)
65 }
66
67 pub fn with_value(value: Option<f32>) -> Self {
70 Self {
71 value: value.map(|v| v.clamp(0.0, 1.0)),
72 width: Dimension::Length(200.0),
73 sized: false,
74 color: None,
75 track_color: None,
76 min_height: None,
77 radius: None,
78 stop_indicator_color: None,
79 stop_indicator_radius: None,
80 track_gap: None,
81 year2023: None,
82 semantics_label: None,
83 semantics_value: None,
84 }
85 }
86
87 pub fn width(mut self, width: f32) -> Self {
89 self.width = Dimension::Length(width);
90 self.sized = true;
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]
104 pub fn track_color(mut self, color: Color) -> Self {
105 self.track_color = Some(color);
106 self
107 }
108
109 #[must_use]
111 pub fn min_height(mut self, height: f32) -> Self {
112 self.min_height = Some(height);
113 self
114 }
115
116 #[must_use]
119 pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
120 self.radius = Some(radius.into());
121 self
122 }
123
124 #[must_use]
127 pub fn stop_indicator_color(mut self, color: Color) -> Self {
128 self.stop_indicator_color = Some(color);
129 self
130 }
131
132 #[must_use]
135 pub fn stop_indicator_radius(mut self, radius: f32) -> Self {
136 self.stop_indicator_radius = Some(radius);
137 self
138 }
139
140 #[must_use]
142 pub fn track_gap(mut self, gap: f32) -> Self {
143 self.track_gap = Some(gap);
144 self
145 }
146
147 #[must_use]
151 pub fn year2023(mut self, year2023: bool) -> Self {
152 self.year2023 = Some(year2023);
153 self
154 }
155
156 #[must_use]
158 pub fn semantics_label(mut self, label: impl Into<String>) -> Self {
159 self.semantics_label = Some(label.into());
160 self
161 }
162
163 #[must_use]
165 pub fn semantics_value(mut self, value: impl Into<String>) -> Self {
166 self.semantics_value = Some(value.into());
167 self
168 }
169
170 fn height(&self, theme: &Theme) -> f32 {
172 self.min_height
173 .or(theme.widgets.progress.linear_min_height)
174 .unwrap_or(HEIGHT)
175 }
176}
177
178struct Resolved {
181 value_color: Color,
182 track_color: Color,
183 radius: Option<BorderRadius>,
184 stop: Option<(Color, f32)>,
185 gap: f32,
186 rtl: bool,
187}
188
189impl LinearProgressIndicator {
190 fn resolve(&self, theme: &Theme) -> Resolved {
191 let t = &theme.widgets.progress;
192 let year2023 = self.year2023.or(t.year2023).unwrap_or(true);
193 let value_color = self.color.or(t.color).unwrap_or(theme.scheme.primary);
194 let newer = |own: Option<f32>, themed: Option<f32>, default: f32| {
195 (!year2023).then(|| own.or(themed).unwrap_or(default))
196 };
197 let stop_radius = newer(
198 self.stop_indicator_radius,
199 t.stop_indicator_radius,
200 STOP_RADIUS,
201 );
202 Resolved {
203 value_color,
204 track_color: self
205 .track_color
206 .or(t.linear_track_color)
207 .unwrap_or(theme.scheme.secondary_container),
208 radius: self
209 .radius
210 .or(t.border_radius)
211 .or((!year2023).then(|| BorderRadius::uniform(RADIUS_2024))),
212 stop: stop_radius.filter(|r| *r > 0.0).map(|r| {
213 let color = self
214 .stop_indicator_color
215 .or(t.stop_indicator_color)
216 .unwrap_or(theme.scheme.primary);
217 (color, r)
218 }),
219 gap: newer(self.track_gap, t.track_gap, TRACK_GAP).unwrap_or(0.0),
220 rtl: theme.direction == TextDirection::Rtl,
221 }
222 }
223}
224
225fn indeterminate_ends(t: f32) -> [f32; 4] {
228 let ms = |ms: f32| ms / (INDETERMINATE_PERIOD * 1000.0);
229 let curve = |begin: f32, len: f32, x1: f32, y1: f32, x2: f32, y2: f32| Curve::Interval {
230 begin: ms(begin),
231 end: ms(begin + len),
232 inner: Box::new(Curve::Cubic { x1, y1, x2, y2 }),
233 };
234 [
235 curve(0.0, 750.0, 0.2, 0.0, 0.8, 1.0).transform(t),
236 curve(333.0, 750.0, 0.4, 0.0, 1.0, 1.0).transform(t),
237 curve(1000.0, 567.0, 0.0, 0.0, 0.65, 1.0).transform(t),
238 curve(1267.0, 533.0, 0.10, 0.0, 0.45, 1.0).transform(t),
239 ]
240}
241
242fn paint_bar(
244 scene: &mut Scene,
245 bounds: Rect,
246 value: Option<f32>,
247 animation: f32,
248 r: &Resolved,
249 opacity: f32,
250) {
251 let draw = |scene: &mut Scene, start: f32, end: f32, color: Color| {
252 if end - start <= 0.0 {
253 return;
254 }
255 let (left, right) = if r.rtl {
256 (1.0 - end, 1.0 - start)
257 } else {
258 (start, end)
259 };
260 scene.draw_rect(
261 Rect::new(
262 bounds.x + left * bounds.width,
263 bounds.y,
264 (right - left) * bounds.width,
265 bounds.height,
266 ),
267 color.fade(opacity),
268 r.radius.unwrap_or(BorderRadius::uniform(0.0)),
269 0.0,
270 Color::TRANSPARENT,
271 );
272 };
273 let gap = if bounds.width > 0.0 {
274 r.gap / bounds.width
275 } else {
276 0.0
277 };
278 let ramp = |v: f32| gap * v.clamp(0.0, GAP_RAMP) / GAP_RAMP;
280
281 if let Some(value) = value {
282 let track_start = if gap > 0.0 { value + ramp(value) } else { 0.0 };
283 if track_start < 1.0 {
284 draw(scene, track_start, 1.0, r.track_color);
285 }
286 if let Some((color, radius)) = r.stop {
287 let max = bounds.height * 0.5;
289 let radius = radius.min(max);
290 let cx = if r.rtl {
291 bounds.x + max
292 } else {
293 bounds.x + bounds.width - max
294 };
295 let cy = bounds.y + max;
296 scene.draw_rect(
297 Rect::new(cx - radius, cy - radius, 2.0 * radius, 2.0 * radius),
298 color.fade(opacity),
299 radius,
300 0.0,
301 Color::TRANSPARENT,
302 );
303 }
304 if value > 0.0 {
305 draw(scene, 0.0, value, r.value_color);
306 }
307 return;
308 }
309
310 let [head1, tail1, head2, tail2] = indeterminate_ends(animation);
311 if head1 < 1.0 - gap {
313 let start = if head1 > 0.0 {
314 head1 + ramp(head1)
315 } else {
316 0.0
317 };
318 draw(scene, start, 1.0, r.track_color);
319 }
320 if head1 - tail1 > 0.0 {
321 draw(scene, tail1, head1, r.value_color);
322 }
323 if tail1 > gap {
325 let start = if head2 > 0.0 {
326 head2 + ramp(head2)
327 } else {
328 0.0
329 };
330 let end = if tail1 < 1.0 {
331 tail1 - ramp(1.0 - tail1)
332 } else {
333 1.0
334 };
335 draw(scene, start, end, r.track_color);
336 }
337 if head2 - tail2 > 0.0 {
338 draw(scene, tail2, head2, r.value_color);
339 }
340 if tail2 > gap {
342 let end = if tail2 < 1.0 {
343 tail2 - ramp(1.0 - tail2)
344 } else {
345 1.0
346 };
347 draw(scene, 0.0, end, r.track_color);
348 }
349}
350
351impl<Msg> Widget<Msg> for LinearProgressIndicator {
352 fn style(&self) -> Style {
353 Style {
354 width: self.width,
355 height: Dimension::Length(HEIGHT),
356 ..Default::default()
357 }
358 }
359
360 fn style_themed(&self, theme: &Theme) -> Style {
361 Style {
362 width: self.width,
363 height: Dimension::Length(self.height(theme)),
364 ..Default::default()
365 }
366 }
367
368 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
371 crate::widget::FillAxes {
372 horizontal: !self.sized,
373 vertical: false,
374 }
375 }
376
377 fn soft_extent(&self) -> crate::widget::FillAxes {
380 crate::widget::FillAxes {
381 horizontal: !self.sized,
382 vertical: false,
383 }
384 }
385
386 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
387 &[]
388 }
389
390 fn continuous(&self) -> bool {
393 self.value.is_none()
394 }
395
396 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
397 let animation = (status.time / INDETERMINATE_PERIOD).rem_euclid(1.0);
398 paint_bar(
399 scene,
400 bounds,
401 self.value,
402 animation,
403 &self.resolve(theme),
404 status.opacity,
405 );
406 }
407
408 fn on_click(&self) -> Option<Msg> {
409 None
410 }
411
412 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
416 let mut s = frus_core::SemanticsProperties::new(frus_core::Role::ProgressBar);
417 if let Some(label) = &self.semantics_label {
418 s = s.label(label.clone());
419 }
420 if let Some(value) = self.value {
421 let pct = (value * 100.0).round();
422 s = s.range(0.0, pct, 100.0);
423 s = s.value(self.semantics_value.clone().unwrap_or(format!("{pct}")));
424 } else if let Some(said) = &self.semantics_value {
425 s = s.value(said.clone());
426 }
427 Some(s)
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use frus_core::Primitive;
435
436 fn painted_at(
437 bar: &LinearProgressIndicator,
438 theme: &Theme,
439 time: f32,
440 ) -> Vec<(Rect, Color, f32)> {
441 let mut scene = Scene::new();
442 let status = Status {
443 time,
444 ..Status::default()
445 };
446 Widget::<()>::paint(
447 bar,
448 Rect::new(0.0, 0.0, 100.0, HEIGHT),
449 status,
450 theme,
451 &mut scene,
452 );
453 scene
454 .primitives()
455 .iter()
456 .filter_map(|p| match p {
457 Primitive::Rect {
458 rect,
459 color,
460 radius,
461 ..
462 } => Some((*rect, *color, radius.top_left)),
463 _ => None,
464 })
465 .collect()
466 }
467
468 fn painted(bar: &LinearProgressIndicator, theme: &Theme) -> Vec<(Rect, Color, f32)> {
469 painted_at(bar, theme, 0.0)
470 }
471
472 fn near(a: f32, b: f32) -> bool {
473 (a - b).abs() < 1e-3
474 }
475
476 #[test]
480 fn the_default_look_is_the_reference_s_default() {
481 let theme = Theme::default();
482 let half = painted(&LinearProgressIndicator::new(0.5).width(100.0), &theme);
483 assert_eq!(half.len(), 2, "a track and a fill: {half:#?}");
484 let (track, fill) = (half[0], half[1]);
485 assert_eq!(
486 (track.0.x, track.0.width),
487 (0.0, 100.0),
488 "the track is whole"
489 );
490 assert_eq!(track.1, theme.scheme.secondary_container);
491 assert_eq!((fill.0.x, fill.0.width), (0.0, 50.0));
492 assert_eq!(fill.1, theme.scheme.primary);
493 assert_eq!((track.2, fill.2), (0.0, 0.0), "square ends");
494 let tiny = painted(&LinearProgressIndicator::new(0.001).width(100.0), &theme);
496 assert!(near(tiny[1].0.width, 0.1), "{tiny:#?}");
497 let asked = LinearProgressIndicator::new(0.5)
499 .width(100.0)
500 .track_gap(10.0)
501 .stop_indicator_radius(2.0);
502 assert_eq!(painted(&asked, &theme).len(), 2);
503 }
504
505 #[test]
509 fn the_newer_look_is_the_reference_s() {
510 let theme = Theme::default();
511 let bar = |v: f32| LinearProgressIndicator::new(v).width(100.0).year2023(false);
512 let half = painted(&bar(0.5), &theme);
513 assert_eq!(half.len(), 3, "track, dot, fill: {half:#?}");
514 let (track, dot, fill) = (half[0], half[1], half[2]);
515 assert!(near(track.0.x, 50.0 + TRACK_GAP));
516 assert_eq!(track.2, RADIUS_2024);
517 assert_eq!(
518 dot.0,
519 Rect::new(96.0, 0.0, 4.0, 4.0),
520 "centred at the far end"
521 );
522 assert_eq!(dot.1, theme.scheme.primary);
523 assert_eq!((fill.0.width, fill.2), (50.0, RADIUS_2024));
524 let ramp = painted(&bar(0.005), &theme);
526 assert!(near(ramp[0].0.x, 0.5 + TRACK_GAP * 0.5), "{ramp:#?}");
527 let full = painted(&bar(1.0), &theme);
529 assert_eq!(full.len(), 2, "the dot and the fill over it: {full:#?}");
530 assert_eq!(full[1].0.width, 100.0);
531 let big = painted(&bar(0.5).stop_indicator_radius(10.0), &theme);
533 assert_eq!(big[1].0.width, HEIGHT);
534 }
535
536 #[test]
539 fn right_to_left_mirrors_the_bar() {
540 let theme = Theme::default().rtl();
541 let bar = LinearProgressIndicator::new(0.25)
542 .width(100.0)
543 .year2023(false);
544 let p = painted(&bar, &theme);
545 let (track, dot, fill) = (p[0], p[1], p[2]);
546 assert_eq!((fill.0.x, fill.0.width), (75.0, 25.0));
547 assert!(near(track.0.x, 0.0) && near(track.0.width, 75.0 - TRACK_GAP));
548 assert_eq!(dot.0.x, 0.0);
549 }
550
551 #[test]
555 fn an_indeterminate_bar_runs_the_reference_s_lines() {
556 let theme = Theme::default();
557 let bar = LinearProgressIndicator::indeterminate().width(100.0);
558 assert!(Widget::<()>::continuous(&bar));
559 assert!(!Widget::<()>::continuous(&LinearProgressIndicator::new(
560 0.3
561 )));
562 let [h1, t1, h2, t2] = indeterminate_ends(0.375 / INDETERMINATE_PERIOD);
565 let cubic = Curve::Cubic {
566 x1: 0.2,
567 y1: 0.0,
568 x2: 0.8,
569 y2: 1.0,
570 };
571 assert!(near(h1, cubic.transform(0.5)));
572 assert!(t1 > 0.0 && t1 < h1);
573 assert_eq!((h2, t2), (0.0, 0.0));
574 let p = painted_at(&bar, &theme, 0.375);
575 let lines: Vec<_> = p.iter().filter(|x| x.1 == theme.scheme.primary).collect();
576 assert_eq!(lines.len(), 1, "only line 1 so far: {p:#?}");
577 assert!(near(lines[0].0.x, t1 * 100.0) && near(lines[0].0.width, (h1 - t1) * 100.0));
578 let p = painted_at(&bar, &theme, 1.05);
581 assert_eq!(p.iter().filter(|x| x.1 == theme.scheme.primary).count(), 2);
582 assert_eq!(
584 painted_at(&bar, &theme, 0.375 + INDETERMINATE_PERIOD),
585 painted_at(&bar, &theme, 0.375)
586 );
587 }
588
589 #[test]
592 fn a_bar_answers_to_its_theme_and_to_its_caller() {
593 let mut theme = Theme::default();
594 theme.widgets.progress.color = Some(Color::rgb(0.1, 0.2, 0.3));
595 theme.widgets.progress.linear_track_color = Some(Color::rgb(0.4, 0.5, 0.6));
596 theme.widgets.progress.linear_min_height = Some(11.0);
597 theme.widgets.progress.year2023 = Some(false);
598 theme.widgets.progress.track_gap = Some(0.0);
599 theme.widgets.progress.stop_indicator_radius = Some(0.0);
600
601 let bar = LinearProgressIndicator::new(0.5).width(100.0);
602 let themed = painted(&bar, &theme);
603 assert_eq!(themed.len(), 2, "no gap and no dot: {themed:#?}");
604 assert_eq!(themed[0].1, Color::rgb(0.4, 0.5, 0.6));
605 assert_eq!(themed[1].1, Color::rgb(0.1, 0.2, 0.3));
606 assert_eq!(themed[0].2, RADIUS_2024, "the theme chose the newer look");
607 assert_eq!(
608 Widget::<()>::style_themed(&bar, &theme).height,
609 Dimension::Length(11.0)
610 );
611
612 let told = LinearProgressIndicator::new(0.5)
613 .width(100.0)
614 .color(Color::rgb(0.9, 0.0, 0.0))
615 .track_color(Color::rgb(0.0, 0.9, 0.0))
616 .track_gap(10.0)
617 .min_height(7.0)
618 .radius(1.0);
619 let painted = painted(&told, &theme);
620 assert_eq!(painted[0].1, Color::rgb(0.0, 0.9, 0.0));
621 assert_eq!(painted[1].1, Color::rgb(0.9, 0.0, 0.0));
622 assert!(near(painted[0].0.x, 60.0));
623 assert_eq!(painted[0].2, 1.0);
624 assert_eq!(
625 Widget::<()>::style_themed(&told, &theme).height,
626 Dimension::Length(7.0)
627 );
628 }
629
630 #[test]
633 fn a_reader_hears_the_reference_s_values() {
634 let s =
635 Widget::<()>::semantics(&LinearProgressIndicator::new(0.426).semantics_label("Upload"))
636 .unwrap();
637 assert_eq!(s.label.as_deref(), Some("Upload"));
638 assert_eq!(s.value.as_deref(), Some("43"));
639 let s = Widget::<()>::semantics(&LinearProgressIndicator::indeterminate()).unwrap();
640 assert_eq!(s.value, None);
641 let s =
642 Widget::<()>::semantics(&LinearProgressIndicator::new(0.5).semantics_value("3 of 6"))
643 .unwrap();
644 assert_eq!(s.value.as_deref(), Some("3 of 6"));
645 }
646
647 #[test]
648 fn value_is_clamped() {
649 let theme = Theme::default();
650 let full = painted(&LinearProgressIndicator::new(2.0).width(100.0), &theme);
651 assert_eq!(full[1].0.width, 100.0);
652 let none = painted(&LinearProgressIndicator::new(-1.0).width(100.0), &theme);
653 assert_eq!(none.len(), 1, "only the track");
654 }
655}