1mod circular;
102mod linear;
103mod motion;
104
105use frust::Theme;
106use frust::authoring::scene::arc_path;
107use frust::authoring::{
108 BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, Role, SemanticsCtx,
109 View, Widget,
110};
111use kurbo::{Point, Size};
112use peniko::{Brush, Color};
113
114pub use self::motion::AmplitudeCurve;
115
116const FALLBACK_ACTIVE: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
119const FALLBACK_LINEAR_TRACK: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
123const FALLBACK_CIRCULAR_TRACK: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
127
128#[derive(Clone, Copy, Debug, PartialEq)]
130pub enum ProgressValue {
131 Determinate(f64),
135 Indeterminate,
137}
138
139impl ProgressValue {
140 fn fraction(self) -> Option<f64> {
143 match self {
144 ProgressValue::Determinate(v) if v.is_nan() => Some(0.0),
145 ProgressValue::Determinate(v) => Some(v.clamp(0.0, 1.0)),
146 ProgressValue::Indeterminate => None,
147 }
148 }
149}
150
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
156pub enum ProgressSize {
157 S,
159 #[default]
161 M,
162}
163
164#[derive(Clone, Copy, Debug, PartialEq)]
170struct LinearProps {
171 value: ProgressValue,
172 wavy: bool,
173 size: ProgressSize,
174 color: Option<Color>,
175 track_color: Option<Color>,
176 stroke_width: Option<f64>,
177 track_stroke_width: Option<f64>,
178 gap_size: Option<f64>,
179 stop_size: Option<f64>,
180 amplitude: Option<f64>,
181 wavelength: Option<f64>,
182 wave_speed: Option<f64>,
183}
184
185impl LinearProps {
186 fn new(value: ProgressValue, wavy: bool) -> Self {
187 LinearProps {
188 value,
189 wavy,
190 size: ProgressSize::default(),
191 color: None,
192 track_color: None,
193 stroke_width: None,
194 track_stroke_width: None,
195 gap_size: None,
196 stop_size: None,
197 amplitude: None,
198 wavelength: None,
199 wave_speed: None,
200 }
201 }
202
203 fn fraction(&self) -> Option<f64> {
204 self.value.fraction()
205 }
206
207 fn stroke(&self) -> f64 {
210 if self.wavy {
211 self.stroke_width.unwrap_or(linear::STROKE_WIDTH)
212 } else {
213 linear::flat_layout(self.size).track_height
214 }
215 }
216
217 fn track_stroke(&self) -> f64 {
219 if self.wavy {
220 self.track_stroke_width
221 .unwrap_or(linear::TRACK_STROKE_WIDTH)
222 } else {
223 linear::flat_layout(self.size).track_height
224 }
225 }
226
227 fn wavelength(&self) -> f64 {
230 self.wavelength.unwrap_or(if self.fraction().is_none() {
231 linear::INDETERMINATE_WAVELENGTH
232 } else {
233 linear::DETERMINATE_WAVELENGTH
234 })
235 }
236
237 fn wave_speed(&self) -> f64 {
240 self.wave_speed.unwrap_or_else(|| self.wavelength())
241 }
242
243 fn wavy_spec(&self) -> linear::WavySpec {
244 linear::WavySpec {
245 stroke: self.stroke(),
246 track_stroke: self.track_stroke(),
247 gap: self.gap_size.unwrap_or(linear::GAP_SIZE),
248 stop_size: self.stop_size.unwrap_or(linear::STOP_SIZE),
249 }
250 }
251}
252
253pub struct LinearProgressView {
255 props: LinearProps,
256 amplitude_for_progress: Option<AmplitudeCurve>,
257}
258
259pub fn linear_progress(value: ProgressValue) -> LinearProgressView {
262 LinearProgressView {
263 props: LinearProps::new(value, false),
264 amplitude_for_progress: None,
265 }
266}
267
268pub fn linear_wavy_progress(value: ProgressValue) -> LinearProgressView {
271 LinearProgressView {
272 props: LinearProps::new(value, true),
273 amplitude_for_progress: None,
274 }
275}
276
277#[allow(non_snake_case)]
280pub fn LinearProgress(value: ProgressValue) -> LinearProgressView {
281 linear_progress(value)
282}
283
284#[allow(non_snake_case)]
286pub fn LinearWavyProgress(value: ProgressValue) -> LinearProgressView {
287 linear_wavy_progress(value)
288}
289
290impl LinearProgressView {
291 pub fn wavy(mut self) -> Self {
294 self.props.wavy = true;
295 self
296 }
297
298 pub fn size(mut self, size: ProgressSize) -> Self {
301 self.props.size = size;
302 self
303 }
304
305 pub fn color(mut self, color: Color) -> Self {
308 self.props.color = Some(color);
309 self
310 }
311
312 pub fn track_color(mut self, color: Color) -> Self {
315 self.props.track_color = Some(color);
316 self
317 }
318
319 pub fn stroke_width(mut self, stroke: f64) -> Self {
323 self.props.stroke_width = Some(stroke);
324 self
325 }
326
327 pub fn track_stroke_width(mut self, stroke: f64) -> Self {
330 self.props.track_stroke_width = Some(stroke);
331 self
332 }
333
334 pub fn gap_size(mut self, gap: f64) -> Self {
338 self.props.gap_size = Some(gap);
339 self
340 }
341
342 pub fn stop_size(mut self, size: f64) -> Self {
347 self.props.stop_size = Some(size);
348 self
349 }
350
351 pub fn amplitude(mut self, amplitude: f64) -> Self {
356 self.props.amplitude = Some(amplitude);
357 self
358 }
359
360 pub fn amplitude_for_progress<F: Fn(f64) -> f64 + 'static>(mut self, curve: F) -> Self {
367 self.amplitude_for_progress = Some(std::rc::Rc::new(curve));
368 self
369 }
370
371 pub fn wavelength(mut self, wavelength: f64) -> Self {
375 self.props.wavelength = Some(wavelength);
376 self
377 }
378
379 pub fn wave_speed(mut self, wave_speed: f64) -> Self {
383 self.props.wave_speed = Some(wave_speed);
384 self
385 }
386}
387
388impl<State: 'static> View<State> for LinearProgressView {
389 type Element = LinearProgressWidget;
390
391 fn build(&self, _ctx: &mut BuildCtx<'_>) -> LinearProgressWidget {
392 LinearProgressWidget {
393 props: self.props,
394 amplitude_for_progress: self.amplitude_for_progress.clone(),
395 cycle: motion::LinearClock::new(),
396 wave: motion::WaveClock::new(),
397 }
398 }
399
400 fn rebuild(
401 &self,
402 prev: &Self,
403 element: &mut LinearProgressWidget,
404 _ctx: &mut BuildCtx<'_>,
405 ) -> ChangeFlags {
406 element.amplitude_for_progress = self.amplitude_for_progress.clone();
410 if prev.props == self.props {
411 return ChangeFlags::NONE;
412 }
413 let resized = prev.props.wavy != self.props.wavy
414 || prev.props.size != self.props.size
415 || prev.props.stroke_width != self.props.stroke_width
416 || (self.props.wavy && prev.props.value != self.props.value);
420 element.props = self.props;
421 if resized {
422 ChangeFlags::LAYOUT | ChangeFlags::PAINT
423 } else {
424 ChangeFlags::PAINT
425 }
426 }
427}
428
429pub struct LinearProgressWidget {
432 props: LinearProps,
433 amplitude_for_progress: Option<AmplitudeCurve>,
434 cycle: motion::LinearClock,
436 wave: motion::WaveClock,
438}
439
440impl LinearProgressWidget {
441 fn amplitude_factor(&self) -> f64 {
444 motion::amplitude_factor(
445 self.props.fraction(),
446 self.props.amplitude,
447 self.amplitude_for_progress.as_deref(),
448 )
449 }
450
451 fn colors(&self, theme: Option<&Theme>) -> (Color, Color) {
455 (
456 self.props
457 .color
458 .or_else(|| theme.map(|t| t.scheme().primary))
459 .unwrap_or(FALLBACK_ACTIVE),
460 self.props
461 .track_color
462 .or_else(|| theme.map(|t| t.scheme().surface_container_highest))
463 .unwrap_or(FALLBACK_LINEAR_TRACK),
464 )
465 }
466}
467
468impl Widget for LinearProgressWidget {
469 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
470 let width = if bc.max().width.is_finite() {
473 bc.max().width
474 } else {
475 bc.min().width
476 };
477 let height = if self.props.wavy {
478 linear::wavy_height(self.props.stroke(), self.amplitude_factor())
479 } else {
480 linear::flat_layout(self.props.size).track_height
481 };
482 bc.constrain(Size::new(width, height))
483 }
484
485 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
486 let theme = Theme::from_paint_ctx(ctx);
487 let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
488 let (active, track) = self.colors(theme);
489 let size = ctx.size();
490 let origin = ctx.origin();
491 let fraction = self.props.fraction();
492 let factor = self.amplitude_factor();
493 let amplitude = if self.props.wavy {
494 linear::WAVE_AMPLITUDE * factor
495 } else {
496 0.0
497 };
498
499 let mut animating = false;
501 if fraction.is_none() {
502 if !reduce_motion {
503 self.cycle.step(ctx.frame_time());
504 }
505 animating = true;
506 }
507 if amplitude > 0.0 {
508 if !reduce_motion {
509 self.wave.step(ctx.frame_time());
510 }
511 animating = true;
512 }
513 if animating && !reduce_motion {
514 ctx.request_frame_paced();
515 }
516
517 let segments = self.cycle.segments();
518 let ops = if self.props.wavy {
519 linear::wavy_ops(self.props.wavy_spec(), size.width, fraction, segments)
520 } else {
521 linear::flat_ops(
522 linear::flat_layout(self.props.size),
523 size.width,
524 fraction,
525 segments,
526 )
527 };
528
529 let cy = origin.y + size.height / 2.0;
530 let stroke = self.props.stroke();
531 let track_stroke = self.props.track_stroke();
532 let phase = self
533 .wave
534 .radians(self.props.wavelength(), self.props.wave_speed());
535 for op in ops {
536 match op {
537 linear::LinearOp::Track { x0, x1 } => scene.stroke_line(
538 Point::new(origin.x + x0, cy),
539 Point::new(origin.x + x1, cy),
540 track_stroke,
541 track,
542 ),
543 linear::LinearOp::Active { x0, x1 } => scene.stroke_line(
544 Point::new(origin.x + x0, cy),
545 Point::new(origin.x + x1, cy),
546 stroke,
547 active,
548 ),
549 linear::LinearOp::Wave { x0, x1 } => {
550 if let Some(path) = linear::wave_path(
551 origin.x + x0,
552 origin.x + x1,
553 cy,
554 amplitude,
555 self.props.wavelength(),
556 phase,
557 ) {
558 scene.stroke_path(Point::ZERO, &path, stroke, &Brush::Solid(active));
559 }
560 }
561 linear::LinearOp::Stop { center_x, diameter } => scene.fill_rounded_rect(
562 Point::new(origin.x + center_x - diameter / 2.0, cy - diameter / 2.0),
563 Size::new(diameter, diameter),
564 diameter / 2.0,
565 active,
566 ),
567 }
568 }
569 }
570
571 fn semantics(&self, ctx: &mut SemanticsCtx) {
572 push_progress_node(ctx, self.props.fraction());
573 }
574}
575
576#[derive(Clone, Copy, Debug, PartialEq)]
580struct CircularProps {
581 value: ProgressValue,
582 wavy: bool,
583 diameter: Option<f64>,
584 color: Option<Color>,
585 track_color: Option<Color>,
586 stroke_width: Option<f64>,
587 track_stroke_width: Option<f64>,
588 gap_size: Option<f64>,
589 amplitude: Option<f64>,
590 wavelength: Option<f64>,
591 wave_speed: Option<f64>,
592}
593
594impl CircularProps {
595 fn new(value: ProgressValue, wavy: bool) -> Self {
596 CircularProps {
597 value,
598 wavy,
599 diameter: None,
600 color: None,
601 track_color: None,
602 stroke_width: None,
603 track_stroke_width: None,
604 gap_size: None,
605 amplitude: None,
606 wavelength: None,
607 wave_speed: None,
608 }
609 }
610
611 fn fraction(&self) -> Option<f64> {
612 self.value.fraction()
613 }
614
615 fn diameter(&self) -> f64 {
618 self.diameter.unwrap_or(if self.wavy {
619 circular::WAVY_SIZE
620 } else {
621 circular::DEFAULT_SIZE
622 })
623 }
624
625 fn stroke(&self) -> f64 {
626 self.stroke_width.unwrap_or(circular::STROKE_WIDTH)
627 }
628
629 fn track_stroke(&self) -> f64 {
630 self.track_stroke_width
631 .unwrap_or(circular::TRACK_STROKE_WIDTH)
632 }
633
634 fn gap(&self) -> f64 {
635 self.gap_size.unwrap_or(circular::GAP_SIZE)
636 }
637
638 fn wavelength(&self) -> f64 {
639 self.wavelength.unwrap_or(circular::WAVELENGTH)
640 }
641
642 fn wave_speed(&self) -> f64 {
643 self.wave_speed.unwrap_or_else(|| self.wavelength())
644 }
645}
646
647pub struct CircularProgressView {
650 props: CircularProps,
651 amplitude_for_progress: Option<AmplitudeCurve>,
652}
653
654pub fn circular_progress(value: ProgressValue) -> CircularProgressView {
657 CircularProgressView {
658 props: CircularProps::new(value, false),
659 amplitude_for_progress: None,
660 }
661}
662
663pub fn circular_wavy_progress(value: ProgressValue) -> CircularProgressView {
666 CircularProgressView {
667 props: CircularProps::new(value, true),
668 amplitude_for_progress: None,
669 }
670}
671
672#[allow(non_snake_case)]
675pub fn CircularProgress(value: ProgressValue) -> CircularProgressView {
676 circular_progress(value)
677}
678
679#[allow(non_snake_case)]
681pub fn CircularWavyProgress(value: ProgressValue) -> CircularProgressView {
682 circular_wavy_progress(value)
683}
684
685impl CircularProgressView {
686 pub fn wavy(mut self) -> Self {
689 self.props.wavy = true;
690 self
691 }
692
693 pub fn diameter(mut self, diameter: f64) -> Self {
696 self.props.diameter = Some(diameter);
697 self
698 }
699
700 pub fn color(mut self, color: Color) -> Self {
703 self.props.color = Some(color);
704 self
705 }
706
707 pub fn track_color(mut self, color: Color) -> Self {
710 self.props.track_color = Some(color);
711 self
712 }
713
714 pub fn stroke_width(mut self, stroke: f64) -> Self {
717 self.props.stroke_width = Some(stroke);
718 self
719 }
720
721 pub fn track_stroke_width(mut self, stroke: f64) -> Self {
724 self.props.track_stroke_width = Some(stroke);
725 self
726 }
727
728 pub fn gap_size(mut self, gap: f64) -> Self {
732 self.props.gap_size = Some(gap);
733 self
734 }
735
736 pub fn amplitude(mut self, amplitude: f64) -> Self {
740 self.props.amplitude = Some(amplitude);
741 self
742 }
743
744 pub fn amplitude_for_progress<F: Fn(f64) -> f64 + 'static>(mut self, curve: F) -> Self {
748 self.amplitude_for_progress = Some(std::rc::Rc::new(curve));
749 self
750 }
751
752 pub fn wavelength(mut self, wavelength: f64) -> Self {
757 self.props.wavelength = Some(wavelength);
758 self
759 }
760
761 pub fn wave_speed(mut self, wave_speed: f64) -> Self {
765 self.props.wave_speed = Some(wave_speed);
766 self
767 }
768}
769
770impl<State: 'static> View<State> for CircularProgressView {
771 type Element = CircularProgressWidget;
772
773 fn build(&self, _ctx: &mut BuildCtx<'_>) -> CircularProgressWidget {
774 CircularProgressWidget {
775 props: self.props,
776 amplitude_for_progress: self.amplitude_for_progress.clone(),
777 spin: motion::CircularClock::new(),
778 wave: motion::WaveClock::new(),
779 }
780 }
781
782 fn rebuild(
783 &self,
784 prev: &Self,
785 element: &mut CircularProgressWidget,
786 _ctx: &mut BuildCtx<'_>,
787 ) -> ChangeFlags {
788 element.amplitude_for_progress = self.amplitude_for_progress.clone();
789 if prev.props == self.props {
790 return ChangeFlags::NONE;
791 }
792 let resized =
793 prev.props.diameter() != self.props.diameter() || prev.props.wavy != self.props.wavy;
794 element.props = self.props;
795 if resized {
796 ChangeFlags::LAYOUT | ChangeFlags::PAINT
797 } else {
798 ChangeFlags::PAINT
799 }
800 }
801}
802
803pub struct CircularProgressWidget {
806 props: CircularProps,
807 amplitude_for_progress: Option<AmplitudeCurve>,
808 spin: motion::CircularClock,
810 wave: motion::WaveClock,
812}
813
814impl CircularProgressWidget {
815 fn amplitude_factor(&self) -> f64 {
816 motion::amplitude_factor(
817 self.props.fraction(),
818 self.props.amplitude,
819 self.amplitude_for_progress.as_deref(),
820 )
821 }
822
823 fn colors(&self, theme: Option<&Theme>) -> (Color, Color) {
827 (
828 self.props
829 .color
830 .or_else(|| theme.map(|t| t.scheme().primary))
831 .unwrap_or(FALLBACK_ACTIVE),
832 self.props
833 .track_color
834 .or_else(|| theme.map(|t| t.scheme().secondary_container))
835 .unwrap_or(FALLBACK_CIRCULAR_TRACK),
836 )
837 }
838}
839
840impl Widget for CircularProgressWidget {
841 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
842 let diameter = self.props.diameter();
843 bc.constrain(Size::new(diameter, diameter))
844 }
845
846 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
847 let theme = Theme::from_paint_ctx(ctx);
848 let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
849 let (active, track) = self.colors(theme);
850 let fraction = self.props.fraction();
851 let amplitude = if self.props.wavy {
852 circular::WAVE_AMPLITUDE * self.amplitude_factor()
853 } else {
854 0.0
855 };
856
857 let mut animating = false;
858 if fraction.is_none() {
859 if !reduce_motion {
860 self.spin.step(ctx.frame_time());
861 }
862 animating = true;
863 }
864 if amplitude > 0.0 {
865 if !reduce_motion {
866 self.wave.step(ctx.frame_time());
867 }
868 animating = true;
869 }
870 if animating && !reduce_motion {
871 ctx.request_frame_paced();
872 }
873
874 let Some(geometry) = circular::geometry(
875 ctx.origin(),
876 ctx.size(),
877 self.props.stroke(),
878 self.props.track_stroke(),
879 self.props.gap(),
880 amplitude,
881 self.props.wavelength(),
882 ) else {
883 return;
886 };
887
888 let (rotation, sweep) = if fraction.is_none() {
889 (self.spin.rotation(), self.spin.sweep())
890 } else {
891 (0.0, 0.0)
892 };
893 let spans = circular::arc_spans(
894 fraction,
895 circular::START_ANGLE + rotation,
896 sweep,
897 geometry.gap_angle,
898 );
899
900 if let Some((start, span)) = spans.track {
901 let path = arc_path(geometry.center, geometry.radius, start, span);
902 scene.stroke_path(
903 Point::ZERO,
904 &path,
905 self.props.track_stroke(),
906 &Brush::Solid(track),
907 );
908 }
909 if let Some((start, span)) = spans.active {
910 let path = if self.props.wavy {
911 let phase = self
912 .wave
913 .radians(self.props.wavelength(), self.props.wave_speed());
914 circular::wavy_arc_path(&geometry, start, span, amplitude, phase)
915 } else {
916 arc_path(geometry.center, geometry.radius, start, span)
917 };
918 scene.stroke_path(
919 Point::ZERO,
920 &path,
921 self.props.stroke(),
922 &Brush::Solid(active),
923 );
924 }
925 }
926
927 fn semantics(&self, ctx: &mut SemanticsCtx) {
928 push_progress_node(ctx, self.props.fraction());
929 }
930}
931
932fn push_progress_node(ctx: &mut SemanticsCtx, fraction: Option<f64>) {
938 ctx.push_node(Role::ProgressIndicator, |node| {
939 if let Some(fraction) = fraction {
940 node.set_numeric_value(fraction);
941 node.set_min_numeric_value(0.0);
942 node.set_max_numeric_value(1.0);
943 }
944 });
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950 use frust::FrameTime;
951 use kurbo::{BezPath, PathEl};
952
953 const LINEAR_WIDTH: f64 = 200.0;
954
955 fn secs(t: f64) -> FrameTime {
956 FrameTime::from_nanos((t * 1e9) as u64)
957 }
958
959 fn build_linear(view: LinearProgressView) -> LinearProgressWidget {
960 let mut counter = 0u64;
961 <LinearProgressView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
962 }
963
964 fn build_circular(view: CircularProgressView) -> CircularProgressWidget {
965 let mut counter = 0u64;
966 <CircularProgressView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
967 }
968
969 #[derive(Default)]
972 struct RecordingScene {
973 lines: Vec<(Point, Point, f64, Color)>,
974 rrects: Vec<(Point, Size, f64, Color)>,
975 strokes: Vec<(BezPath, f64)>,
976 }
977
978 impl PaintScene for RecordingScene {
979 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
980 fn draw_text(&mut self, _o: Point, _t: &str) {}
981 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
982 self.rrects.push((o, s, radius, color));
983 }
984 fn stroke_line(&mut self, p0: Point, p1: Point, width: f64, color: Color) {
985 self.lines.push((p0, p1, width, color));
986 }
987 fn stroke_path(&mut self, _origin: Point, path: &BezPath, width: f64, _brush: &Brush) {
988 self.strokes.push((path.clone(), width));
989 }
990 }
991
992 fn paint(w: &mut dyn Widget, size: Size) -> RecordingScene {
999 let mut ctx = PaintCtx::new(Point::ZERO, size);
1000 let mut scene = RecordingScene::default();
1001 w.paint(&mut ctx, &mut scene);
1002 scene
1003 }
1004
1005 fn linear_size(w: &LinearProgressWidget) -> Size {
1006 Size::new(LINEAR_WIDTH, w.props.stroke())
1007 }
1008
1009 fn paint_linear(w: &mut LinearProgressWidget) -> RecordingScene {
1010 let size = linear_size(w);
1011 paint(w, size)
1012 }
1013
1014 fn paint_circular(w: &mut CircularProgressWidget) -> RecordingScene {
1015 let size = circular_size(w);
1016 paint(w, size)
1017 }
1018
1019 fn actives(scene: &RecordingScene, active: Color) -> Vec<(f64, f64)> {
1020 scene
1021 .lines
1022 .iter()
1023 .filter(|(_, _, _, c)| *c == active)
1024 .map(|(p0, p1, _, _)| (p0.x, p1.x))
1025 .collect()
1026 }
1027
1028 fn tracks(scene: &RecordingScene, track: Color) -> Vec<(f64, f64)> {
1029 scene
1030 .lines
1031 .iter()
1032 .filter(|(_, _, _, c)| *c == track)
1033 .map(|(p0, p1, _, _)| (p0.x, p1.x))
1034 .collect()
1035 }
1036
1037 #[test]
1040 fn linear_determinate_fills_proportionally_to_value() {
1041 let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.25)));
1042 let scene = paint_linear(&mut w);
1043 let active = actives(&scene, FALLBACK_ACTIVE);
1045 assert_eq!(active.len(), 1);
1046 assert!((active[0].1 - (4.0 + 188.0 * 0.25)).abs() < 1e-9);
1047 assert_eq!(tracks(&scene, FALLBACK_LINEAR_TRACK).len(), 1);
1048 }
1049
1050 #[test]
1051 fn linear_determinate_clamps_out_of_range_values() {
1052 let mut over = build_linear(linear_progress(ProgressValue::Determinate(1.5)));
1053 let scene = paint_linear(&mut over);
1054 assert_eq!(actives(&scene, FALLBACK_ACTIVE), vec![(4.0, 192.0)]);
1055 assert!(tracks(&scene, FALLBACK_LINEAR_TRACK).is_empty());
1056
1057 let mut under = build_linear(linear_progress(ProgressValue::Determinate(-0.5)));
1058 let scene = paint_linear(&mut under);
1059 assert!(
1060 actives(&scene, FALLBACK_ACTIVE).is_empty(),
1061 "a clamped-to-zero fraction paints no active span"
1062 );
1063 }
1064
1065 #[test]
1066 fn linear_determinate_paints_the_stop_indicator() {
1067 let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.5)));
1068 let scene = paint_linear(&mut w);
1069 assert_eq!(scene.rrects.len(), 1, "exactly one stop dot");
1070 let (origin, size, radius, color) = scene.rrects[0];
1071 assert_eq!(size, Size::new(4.0, 4.0));
1072 assert_eq!(radius, 2.0, "a rounded rect at half its side is a circle");
1073 assert_eq!(color, FALLBACK_ACTIVE);
1074 assert!((origin.x + 2.0 - 192.0).abs() < 1e-9);
1076 }
1077
1078 #[test]
1079 fn linear_indeterminate_paints_no_stop_indicator() {
1080 let mut w = build_linear(linear_progress(ProgressValue::Indeterminate));
1081 let scene = paint_linear(&mut w);
1082 assert!(scene.rrects.is_empty());
1083 }
1084
1085 #[test]
1086 fn linear_flat_size_matrix_drives_the_box_height() {
1087 let mut small =
1088 build_linear(linear_progress(ProgressValue::Determinate(0.5)).size(ProgressSize::S));
1089 let mut medium = build_linear(linear_progress(ProgressValue::Determinate(0.5)));
1090 let bc = BoxConstraints::new(Size::ZERO, Size::new(LINEAR_WIDTH, 100.0));
1091 assert_eq!(small.layout(&mut LayoutCtx::new(), &bc).height, 4.0);
1092 assert_eq!(medium.layout(&mut LayoutCtx::new(), &bc).height, 8.0);
1093 }
1094
1095 #[test]
1098 fn linear_indeterminate_paints_two_traveling_lines_not_one() {
1099 let mut w = build_linear(linear_progress(ProgressValue::Indeterminate));
1100 w.cycle.step(secs(0.0));
1101 w.cycle.step(secs(1.0));
1102 let scene = paint_linear(&mut w);
1103 let active = actives(&scene, FALLBACK_ACTIVE);
1104 assert_eq!(
1105 active.len(),
1106 2,
1107 "the reference choreography runs two segments at once"
1108 );
1109 assert!(
1110 active[0].0 > active[1].1,
1111 "the leading line is painted first, with track between the two"
1112 );
1113 }
1114
1115 #[test]
1116 fn linear_indeterminate_segment_positions_match_the_curve_table() {
1117 let mut w = build_linear(linear_progress(ProgressValue::Indeterminate));
1120 w.cycle.step(secs(0.0));
1121 w.cycle.step(secs(1.0));
1122 let t = 1000.0 / 1750.0;
1123 assert!((w.cycle.cycle() - t).abs() < 1e-9);
1124 let expected = motion::segments(t);
1125
1126 let scene = paint_linear(&mut w);
1127 let active = actives(&scene, FALLBACK_ACTIVE);
1128 let span = 188.0;
1129 let to_fraction = |x: f64| (x - 4.0) / span;
1130 assert!((to_fraction(active[0].0) - expected.first_tail).abs() < 1e-9);
1132 assert!((to_fraction(active[0].1) - expected.first_head).abs() < 1e-9);
1133 assert!((to_fraction(active[1].0) - expected.second_tail).abs() < 1e-9);
1134 assert!((to_fraction(active[1].1) - expected.second_head).abs() < 1e-9);
1135 }
1136
1137 #[test]
1138 fn linear_indeterminate_requests_a_paced_frame_every_paint() {
1139 let mut w = build_linear(linear_progress(ProgressValue::Indeterminate));
1140 for _ in 0..3 {
1141 let mut ctx = PaintCtx::new(Point::ZERO, linear_size(&w));
1142 let mut scene = RecordingScene::default();
1143 w.paint(&mut ctx, &mut scene);
1144 assert!(ctx.needs_frame());
1145 assert!(
1146 ctx.needs_frame_paced_only(),
1147 "a perpetual decorative loop must be pace-able by the frame gate"
1148 );
1149 }
1150 }
1151
1152 #[test]
1153 fn linear_determinate_flat_requests_no_frame_at_all() {
1154 let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.4)));
1155 let mut ctx = PaintCtx::new(Point::ZERO, linear_size(&w));
1156 let mut scene = RecordingScene::default();
1157 w.paint(&mut ctx, &mut scene);
1158 assert!(!ctx.needs_frame(), "nothing is animating");
1159 }
1160
1161 #[test]
1162 fn linear_indeterminate_reduce_motion_freezes_and_stops_requesting_frames() {
1163 let mut theme = crate::baseline();
1164 theme.motion.reduce_motion = true;
1165 let mut w = build_linear(linear_progress(ProgressValue::Indeterminate));
1166 w.cycle.step(secs(0.0));
1167 w.cycle.step(secs(1.0));
1168 let frozen = w.cycle.cycle();
1169 for _ in 0..3 {
1170 let mut ctx = PaintCtx::new(Point::ZERO, linear_size(&w)).with_theme(&theme);
1171 let mut scene = RecordingScene::default();
1172 w.paint(&mut ctx, &mut scene);
1173 assert!(!ctx.needs_frame());
1174 }
1175 assert_eq!(w.cycle.cycle(), frozen, "and the cycle stands still");
1176 }
1177
1178 #[test]
1181 fn linear_wavy_determinate_strokes_a_wave_instead_of_a_flat_line() {
1182 let mut w = build_linear(linear_wavy_progress(ProgressValue::Determinate(0.5)));
1183 let scene = paint(&mut w, Size::new(LINEAR_WIDTH, 10.0));
1184 assert_eq!(scene.strokes.len(), 1, "the active span is a stroked path");
1185 assert_eq!(
1186 actives(&scene, FALLBACK_ACTIVE).len(),
1187 0,
1188 "and not a flat line"
1189 );
1190 assert_eq!(tracks(&scene, FALLBACK_LINEAR_TRACK).len(), 1);
1191 assert_eq!(scene.rrects.len(), 1, "the stop dot still lands");
1192 }
1193
1194 #[test]
1195 fn linear_wavy_flattens_near_both_ends_of_the_track() {
1196 for value in [0.05, 0.99] {
1199 let mut w = build_linear(linear_wavy_progress(ProgressValue::Determinate(value)));
1200 let scene = paint(&mut w, Size::new(LINEAR_WIDTH, 10.0));
1201 let cy = 5.0;
1202 for (path, _) in &scene.strokes {
1203 for el in path.elements() {
1204 if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
1205 assert!(
1206 (p.y - cy).abs() < 1e-9,
1207 "the ramp must flatten the wave at value {value}"
1208 );
1209 }
1210 }
1211 }
1212 }
1213 }
1214
1215 #[test]
1216 fn linear_wavy_amplitude_overrides_beat_the_ramp() {
1217 let mut w =
1218 build_linear(linear_wavy_progress(ProgressValue::Determinate(0.99)).amplitude(1.0));
1219 let scene = paint(&mut w, Size::new(LINEAR_WIDTH, 10.0));
1220 let off_centre = scene.strokes[0]
1221 .0
1222 .elements()
1223 .iter()
1224 .any(|el| matches!(el, PathEl::LineTo(p) if (p.y - 5.0).abs() > 0.5));
1225 assert!(off_centre, "an explicit amplitude replaces the ramp");
1226 }
1227
1228 #[test]
1229 fn linear_wavy_amplitude_curve_outranks_the_constant() {
1230 let mut w = build_linear(
1231 linear_wavy_progress(ProgressValue::Determinate(0.5))
1232 .amplitude(1.0)
1233 .amplitude_for_progress(|_| 0.0),
1234 );
1235 let scene = paint(&mut w, Size::new(LINEAR_WIDTH, 10.0));
1236 for el in scene.strokes[0].0.elements() {
1237 if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
1238 assert!((p.y - 5.0).abs() < 1e-9, "the curve flattened the wave");
1239 }
1240 }
1241 }
1242
1243 #[test]
1244 fn linear_wavy_box_height_follows_the_amplitude() {
1245 let bc = BoxConstraints::new(Size::ZERO, Size::new(LINEAR_WIDTH, 100.0));
1246 let mut waving = build_linear(linear_wavy_progress(ProgressValue::Determinate(0.5)));
1247 assert_eq!(
1248 waving.layout(&mut LayoutCtx::new(), &bc).height,
1249 linear::WAVY_CONTAINER_HEIGHT
1250 );
1251 let mut tall =
1252 build_linear(linear_wavy_progress(ProgressValue::Determinate(0.5)).stroke_width(8.0));
1253 assert_eq!(tall.layout(&mut LayoutCtx::new(), &bc).height, 14.0);
1254 }
1255
1256 #[test]
1257 fn linear_wavy_advances_its_phase_and_freezes_under_reduce_motion() {
1258 let mut w = build_linear(linear_wavy_progress(ProgressValue::Determinate(0.5)));
1259 w.wave.step(secs(0.0));
1260 w.wave.step(secs(0.25));
1261 let moved = w.wave.seconds();
1262 assert!(moved > 0.0);
1263
1264 let mut theme = crate::baseline();
1265 theme.motion.reduce_motion = true;
1266 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LINEAR_WIDTH, 10.0)).with_theme(&theme);
1267 let mut scene = RecordingScene::default();
1268 w.paint(&mut ctx, &mut scene);
1269 assert!(!ctx.needs_frame());
1270 assert_eq!(w.wave.seconds(), moved, "the phase stands still");
1271 assert_eq!(
1272 scene.strokes.len(),
1273 1,
1274 "the wave is still drawn, just still"
1275 );
1276 }
1277
1278 #[test]
1279 fn linear_wavy_at_zero_amplitude_requests_no_frame() {
1280 let mut w =
1283 build_linear(linear_wavy_progress(ProgressValue::Determinate(0.5)).amplitude(0.0));
1284 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LINEAR_WIDTH, 10.0));
1285 let mut scene = RecordingScene::default();
1286 w.paint(&mut ctx, &mut scene);
1287 assert!(!ctx.needs_frame());
1288 }
1289
1290 #[test]
1291 fn linear_wavy_indeterminate_runs_both_clocks() {
1292 let mut w = build_linear(linear_wavy_progress(ProgressValue::Indeterminate));
1293 w.cycle.step(secs(0.0));
1294 w.cycle.step(secs(1.0));
1295 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LINEAR_WIDTH, 10.0));
1296 let mut scene = RecordingScene::default();
1297 w.paint(&mut ctx, &mut scene);
1298 assert!(ctx.needs_frame_paced_only());
1299 assert_eq!(scene.strokes.len(), 2, "two traveling waves, as flat does");
1300 }
1301
1302 #[test]
1305 fn rebuild_reconciles_the_view_supplied_value() {
1306 let prev = linear_progress(ProgressValue::Determinate(0.1));
1307 let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.1)));
1308 let next = linear_progress(ProgressValue::Determinate(0.9));
1309 let mut counter = 0u64;
1310 let flags = <LinearProgressView as View<()>>::rebuild(
1311 &next,
1312 &prev,
1313 &mut w,
1314 &mut BuildCtx::new(&mut counter),
1315 );
1316 assert!(flags.contains(ChangeFlags::PAINT));
1317 assert!(
1318 !flags.needs_layout(),
1319 "a flat indicator's box does not depend on its value"
1320 );
1321 let scene = paint_linear(&mut w);
1322 let active = actives(&scene, FALLBACK_ACTIVE);
1323 assert!((active[0].1 - (4.0 + 188.0 * 0.9)).abs() < 1e-9);
1324 }
1325
1326 #[test]
1327 fn rebuild_of_a_wavy_value_relayouts_because_the_box_can_change() {
1328 let prev = linear_wavy_progress(ProgressValue::Determinate(0.05));
1329 let mut w = build_linear(linear_wavy_progress(ProgressValue::Determinate(0.05)));
1330 let next = linear_wavy_progress(ProgressValue::Determinate(0.5));
1331 let mut counter = 0u64;
1332 let flags = <LinearProgressView as View<()>>::rebuild(
1333 &next,
1334 &prev,
1335 &mut w,
1336 &mut BuildCtx::new(&mut counter),
1337 );
1338 assert!(flags.needs_layout());
1339 }
1340
1341 #[test]
1342 fn rebuild_with_identical_props_reports_nothing() {
1343 let prev = linear_progress(ProgressValue::Determinate(0.4));
1344 let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.4)));
1345 let next = linear_progress(ProgressValue::Determinate(0.4));
1346 let mut counter = 0u64;
1347 let flags = <LinearProgressView as View<()>>::rebuild(
1348 &next,
1349 &prev,
1350 &mut w,
1351 &mut BuildCtx::new(&mut counter),
1352 );
1353 assert_eq!(flags, ChangeFlags::NONE);
1354 }
1355
1356 #[test]
1359 fn linear_colours_follow_the_theme_then_an_explicit_override() {
1360 let theme = crate::baseline();
1361 let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.5)));
1362 let mut ctx = PaintCtx::new(Point::ZERO, linear_size(&w)).with_theme(&theme);
1363 let mut scene = RecordingScene::default();
1364 w.paint(&mut ctx, &mut scene);
1365 assert!(!actives(&scene, theme.scheme().primary).is_empty());
1366 assert!(!tracks(&scene, theme.scheme().surface_container_highest).is_empty());
1367
1368 let pink = Color::from_rgb8(0xFF, 0x00, 0x99);
1369 let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.5)).color(pink));
1370 let mut ctx = PaintCtx::new(Point::ZERO, linear_size(&w)).with_theme(&theme);
1371 let mut scene = RecordingScene::default();
1372 w.paint(&mut ctx, &mut scene);
1373 assert!(!actives(&scene, pink).is_empty(), "an override wins");
1374 }
1375
1376 fn circular_size(w: &CircularProgressWidget) -> Size {
1379 let d = w.props.diameter();
1380 Size::new(d, d)
1381 }
1382
1383 #[test]
1384 fn circular_default_diameters_differ_by_variant() {
1385 let bc = BoxConstraints::new(Size::ZERO, Size::new(200.0, 200.0));
1386 let mut classic = build_circular(circular_progress(ProgressValue::Indeterminate));
1387 let mut wavy = build_circular(circular_wavy_progress(ProgressValue::Indeterminate));
1388 assert_eq!(
1389 classic.layout(&mut LayoutCtx::new(), &bc),
1390 Size::new(circular::DEFAULT_SIZE, circular::DEFAULT_SIZE)
1391 );
1392 assert_eq!(
1393 wavy.layout(&mut LayoutCtx::new(), &bc),
1394 Size::new(circular::WAVY_SIZE, circular::WAVY_SIZE)
1395 );
1396 }
1397
1398 #[test]
1399 fn circular_determinate_paints_a_track_and_an_active_arc() {
1400 let mut w = build_circular(circular_progress(ProgressValue::Determinate(0.5)));
1401 let scene = paint_circular(&mut w);
1402 assert_eq!(scene.strokes.len(), 2, "track first, then the active arc");
1403 }
1404
1405 #[test]
1406 fn circular_complete_is_a_single_full_ring() {
1407 let mut w = build_circular(circular_progress(ProgressValue::Determinate(1.0)));
1408 let scene = paint_circular(&mut w);
1409 assert_eq!(scene.strokes.len(), 1, "no track survives a complete ring");
1410 }
1411
1412 #[test]
1413 fn circular_zero_progress_is_all_track() {
1414 let mut w = build_circular(circular_progress(ProgressValue::Determinate(0.0)));
1415 let scene = paint_circular(&mut w);
1416 assert_eq!(scene.strokes.len(), 1);
1417 }
1418
1419 #[test]
1420 fn circular_indeterminate_rotation_and_sweep_match_the_clock() {
1421 let mut w = build_circular(circular_progress(ProgressValue::Indeterminate));
1422 w.spin.step(secs(0.0));
1423 w.spin.step(secs(0.65));
1424 assert!((w.spin.rotation() - 169.0_f64.to_radians()).abs() < 1e-9);
1426 assert!((w.spin.sweep() - 0.485 * std::f64::consts::TAU).abs() < 1e-9);
1427
1428 let scene = paint_circular(&mut w);
1429 assert_eq!(scene.strokes.len(), 2, "a rotating arc plus its track");
1430 let geometry = circular::geometry(
1432 Point::ZERO,
1433 circular_size(&w),
1434 circular::STROKE_WIDTH,
1435 circular::TRACK_STROKE_WIDTH,
1436 circular::GAP_SIZE,
1437 0.0,
1438 circular::WAVELENGTH,
1439 )
1440 .expect("a 40dp ring");
1441 let start = circular::START_ANGLE + w.spin.rotation();
1442 let expected = Point::new(
1443 geometry.center.x + geometry.radius * start.cos(),
1444 geometry.center.y + geometry.radius * start.sin(),
1445 );
1446 let Some(PathEl::MoveTo(actual)) = scene.strokes[1].0.elements().first() else {
1447 panic!("an arc path opens with a MoveTo");
1448 };
1449 assert!((actual.x - expected.x).abs() < 1e-6);
1450 assert!((actual.y - expected.y).abs() < 1e-6);
1451 }
1452
1453 #[test]
1454 fn circular_indeterminate_requests_a_paced_frame_every_paint() {
1455 let mut w = build_circular(circular_progress(ProgressValue::Indeterminate));
1456 for _ in 0..3 {
1457 let mut ctx = PaintCtx::new(Point::ZERO, circular_size(&w));
1458 let mut scene = RecordingScene::default();
1459 w.paint(&mut ctx, &mut scene);
1460 assert!(ctx.needs_frame());
1461 assert!(ctx.needs_frame_paced_only());
1462 }
1463 }
1464
1465 #[test]
1466 fn circular_indeterminate_reduce_motion_freezes_and_stops_requesting_frames() {
1467 let mut theme = crate::baseline();
1468 theme.motion.reduce_motion = true;
1469 let mut w = build_circular(circular_progress(ProgressValue::Indeterminate));
1470 w.spin.step(secs(0.0));
1471 w.spin.step(secs(0.65));
1472 let frozen = w.spin.rotation();
1473 for _ in 0..3 {
1474 let mut ctx = PaintCtx::new(Point::ZERO, circular_size(&w)).with_theme(&theme);
1475 let mut scene = RecordingScene::default();
1476 w.paint(&mut ctx, &mut scene);
1477 assert!(!ctx.needs_frame());
1478 assert_eq!(scene.strokes.len(), 2, "still paints a frozen ring");
1479 }
1480 assert_eq!(w.spin.rotation(), frozen);
1481 }
1482
1483 #[test]
1484 fn circular_wavy_determinate_modulates_its_radius() {
1485 let mut w = build_circular(circular_wavy_progress(ProgressValue::Determinate(0.5)));
1486 let size = circular_size(&w);
1487 let scene = paint(&mut w, size);
1488 let geometry = circular::geometry(
1489 Point::ZERO,
1490 size,
1491 circular::STROKE_WIDTH,
1492 circular::TRACK_STROKE_WIDTH,
1493 circular::GAP_SIZE,
1494 circular::WAVE_AMPLITUDE,
1495 circular::WAVELENGTH,
1496 )
1497 .expect("a 48dp ring");
1498 let radii: Vec<f64> = scene.strokes[1]
1499 .0
1500 .elements()
1501 .iter()
1502 .filter_map(|el| match el {
1503 PathEl::MoveTo(p) | PathEl::LineTo(p) => Some((*p - geometry.center).hypot()),
1504 _ => None,
1505 })
1506 .collect();
1507 let max = radii.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1508 let min = radii.iter().cloned().fold(f64::INFINITY, f64::min);
1509 assert!(
1510 max - min > circular::WAVE_AMPLITUDE,
1511 "a wavy arc must reach both a crest and a trough"
1512 );
1513 }
1514
1515 #[test]
1516 fn circular_wavy_flattens_near_both_ends_of_the_ring() {
1517 let mut w = build_circular(circular_wavy_progress(ProgressValue::Determinate(0.99)));
1518 let size = circular_size(&w);
1519 let scene = paint(&mut w, size);
1520 let geometry = circular::geometry(
1521 Point::ZERO,
1522 size,
1523 circular::STROKE_WIDTH,
1524 circular::TRACK_STROKE_WIDTH,
1525 circular::GAP_SIZE,
1526 0.0,
1527 circular::WAVELENGTH,
1528 )
1529 .expect("a 48dp ring");
1530 for el in scene.strokes[0].0.elements() {
1531 if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
1532 assert!(((*p - geometry.center).hypot() - geometry.radius).abs() < 1e-6);
1533 }
1534 }
1535 }
1536
1537 #[test]
1538 fn circular_wavy_indeterminate_paints_a_wavy_arc_and_a_flat_track() {
1539 let mut w = build_circular(circular_wavy_progress(ProgressValue::Indeterminate));
1540 w.spin.step(secs(0.0));
1541 w.spin.step(secs(0.65));
1542 let scene = paint_circular(&mut w);
1543 assert_eq!(scene.strokes.len(), 2);
1544 assert_eq!(scene.strokes[1].0.elements().len(), 121);
1546 assert!(scene.strokes[0].0.elements().len() < 121);
1547 }
1548
1549 #[test]
1550 fn circular_in_a_box_too_small_for_its_stroke_paints_nothing() {
1551 let mut w = build_circular(circular_progress(ProgressValue::Determinate(0.5)));
1552 let scene = paint(&mut w, Size::new(2.0, 2.0));
1553 assert!(scene.strokes.is_empty());
1554 }
1555
1556 #[test]
1557 fn circular_colours_follow_the_theme_then_an_explicit_override() {
1558 let theme = crate::baseline();
1559 let mut w = build_circular(circular_progress(ProgressValue::Determinate(0.5)));
1560 let mut ctx = PaintCtx::new(Point::ZERO, circular_size(&w)).with_theme(&theme);
1561 let mut scene = RecordingScene::default();
1562 w.paint(&mut ctx, &mut scene);
1563 assert_eq!(scene.strokes.len(), 2);
1564 assert_eq!(
1567 w.colors(Some(&theme)),
1568 (theme.scheme().primary, theme.scheme().secondary_container)
1569 );
1570 assert_eq!(
1571 w.colors(None),
1572 (FALLBACK_ACTIVE, FALLBACK_CIRCULAR_TRACK),
1573 "unthemed falls back to the M3 baseline roles"
1574 );
1575 }
1576
1577 fn semantics_root_node<V>(logic: impl FnMut(&mut ()) -> V) -> frust::authoring::Node
1585 where
1586 V: View<()> + 'static,
1587 {
1588 let mut root: frust_core::RenderRoot<(), V> = frust_core::RenderRoot::new();
1589 let mut state = ();
1590 let mut logic = logic;
1591 root.rebuild(&mut logic, &mut state);
1592 root.layout(Size::new(200.0, 200.0));
1593 let update = root.semantics();
1594 let (_, node) = update
1595 .nodes
1596 .iter()
1597 .find(|(id, _)| *id != update.root)
1598 .expect("the progress indicator contributes exactly one node");
1599 node.clone()
1600 }
1601
1602 #[test]
1603 fn linear_determinate_semantics_reports_numeric_value_and_range() {
1604 let node = semantics_root_node(|_| linear_progress(ProgressValue::Determinate(0.42)));
1605 assert_eq!(node.role(), Role::ProgressIndicator);
1606 assert_eq!(node.numeric_value(), Some(0.42));
1607 assert_eq!(node.min_numeric_value(), Some(0.0));
1608 assert_eq!(node.max_numeric_value(), Some(1.0));
1609 }
1610
1611 #[test]
1612 fn indeterminate_semantics_omits_numeric_value() {
1613 let node = semantics_root_node(|_| linear_progress(ProgressValue::Indeterminate));
1614 assert_eq!(node.role(), Role::ProgressIndicator);
1615 assert_eq!(node.numeric_value(), None);
1616 }
1617
1618 #[test]
1619 fn circular_semantics_role_and_value() {
1620 let node = semantics_root_node(|_| circular_progress(ProgressValue::Determinate(0.75)));
1621 assert_eq!(node.role(), Role::ProgressIndicator);
1622 assert_eq!(node.numeric_value(), Some(0.75));
1623 }
1624}