1use tessera_ui::{
7 Color, ComputedData, Constraint, Dp, LayoutResult, MeasurementError, Modifier,
8 ParentConstraint, Px, PxPosition, State,
9 accesskit::Role,
10 current_frame_nanos,
11 layout::{MeasureScope, layout},
12 receive_frame_nanos, remember, tessera, use_context,
13};
14
15use crate::{
16 modifier::{ModifierExt as _, SemanticsArgs},
17 pipelines::progress_arc::command::{ProgressArcCap, ProgressArcCommand},
18 shape_def::Shape,
19 surface::surface,
20 theme::MaterialTheme,
21};
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub enum ProgressStrokeCap {
26 #[default]
28 Round,
29 Butt,
31}
32
33impl ProgressStrokeCap {
34 fn effective_is_butt(self, component_width: Px, component_height: Px) -> bool {
35 self == ProgressStrokeCap::Butt || component_height.0 > component_width.0
36 }
37}
38
39#[derive(Clone, PartialEq)]
40struct LinearProgressLayout {
41 progress: Option<f32>,
42 stroke_cap: ProgressStrokeCap,
43 gap_size: Dp,
44 draw_stop_indicator: bool,
45 animation_cycle: Option<f32>,
46}
47
48impl tessera_ui::LayoutModifierNode for LinearProgressLayout {
49 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
50 let mut result = LayoutResult::default();
51 let (self_width, self_height) = resolve_linear_size(input.parent_constraint());
52 let is_butt = self.stroke_cap.effective_is_butt(self_width, self_height);
53 let gap_fraction =
54 adjusted_linear_gap_fraction(self_width, self_height, self.gap_size, is_butt);
55 let child_height = self_height;
56
57 let children = input.children();
58
59 if let Some(progress) = self.progress {
60 let progress = if progress.is_nan() {
61 0.0
62 } else {
63 progress.clamp(0.0, 1.0)
64 };
65 let track_start = progress + progress.min(gap_fraction);
66
67 let track = children[0];
68 let indicator = children[1];
69 let stop_id = if self.draw_stop_indicator {
70 children.get(2).copied()
71 } else {
72 None
73 };
74
75 if let Some((x, w)) =
76 linear_segment_bounds(0.0, progress, self_width, self_height, is_butt)
77 {
78 let constraint = Constraint::new(w, child_height);
79 indicator.measure(input, &constraint)?;
80 result.place_child(indicator, PxPosition::new(x, Px(0)));
81 } else {
82 let constraint = Constraint::new(Px(0), child_height);
83 indicator.measure(input, &constraint)?;
84 result.place_child(indicator, PxPosition::new(Px(0), Px(0)));
85 }
86
87 if track_start <= 1.0
88 && let Some((x, w)) =
89 linear_segment_bounds(track_start, 1.0, self_width, self_height, is_butt)
90 {
91 let constraint = Constraint::new(w, child_height);
92 track.measure(input, &constraint)?;
93 result.place_child(track, PxPosition::new(x, Px(0)));
94 } else {
95 let constraint = Constraint::new(Px(0), child_height);
96 track.measure(input, &constraint)?;
97 result.place_child(track, PxPosition::new(Px(0), Px(0)));
98 }
99
100 if let Some(stop) = stop_id {
101 let (pos, stop_size) = stop_indicator_bounds(self_width, self_height);
102 let constraint = Constraint::new(stop_size, stop_size);
103 stop.measure(input, &constraint)?;
104 result.place_child(stop, pos);
105 }
106 } else {
107 let cycle = self.animation_cycle.unwrap_or(0.0);
108 let first_head = keyframe_0_to_1(cycle, 0, 1000, 1750, emphasized_accelerate);
109 let first_tail = keyframe_0_to_1(cycle, 250, 1000, 1750, emphasized_accelerate);
110 let second_head = keyframe_0_to_1(cycle, 650, 850, 1750, emphasized_accelerate);
111 let second_tail = keyframe_0_to_1(cycle, 900, 850, 1750, emphasized_accelerate);
112
113 let track_before = children[0];
114 let line1 = children[1];
115 let track_between = children[2];
116 let line2 = children[3];
117 let track_after = children[4];
118
119 let mut set_segment = |child: tessera_ui::layout::LayoutChild,
120 start: f32,
121 end: f32|
122 -> Result<(), MeasurementError> {
123 if let Some((x, w)) =
124 linear_segment_bounds(start, end, self_width, self_height, is_butt)
125 {
126 let constraint = Constraint::new(w, child_height);
127 child.measure(input, &constraint)?;
128 result.place_child(child, PxPosition::new(x, Px(0)));
129 } else {
130 let constraint = Constraint::new(Px(0), child_height);
131 child.measure(input, &constraint)?;
132 result.place_child(child, PxPosition::new(Px(0), Px(0)));
133 }
134 Ok(())
135 };
136
137 if first_head < 1.0 - gap_fraction {
138 let start = if first_head > 0.0 {
139 first_head + gap_fraction
140 } else {
141 0.0
142 };
143 set_segment(track_before, start, 1.0)?;
144 } else {
145 set_segment(track_before, 0.0, 0.0)?;
146 }
147
148 if first_head - first_tail > 0.0 {
149 set_segment(line1, first_head, first_tail)?;
150 } else {
151 set_segment(line1, 0.0, 0.0)?;
152 }
153
154 if first_tail > gap_fraction {
155 let start = if second_head > 0.0 {
156 second_head + gap_fraction
157 } else {
158 0.0
159 };
160 let end = if first_tail < 1.0 {
161 first_tail - gap_fraction
162 } else {
163 1.0
164 };
165 set_segment(track_between, start, end)?;
166 } else {
167 set_segment(track_between, 0.0, 0.0)?;
168 }
169
170 if second_head - second_tail > 0.0 {
171 set_segment(line2, second_head, second_tail)?;
172 } else {
173 set_segment(line2, 0.0, 0.0)?;
174 }
175
176 if second_tail > gap_fraction {
177 let end = if second_tail < 1.0 {
178 second_tail - gap_fraction
179 } else {
180 1.0
181 };
182 set_segment(track_after, 0.0, end)?;
183 } else {
184 set_segment(track_after, 0.0, 0.0)?;
185 }
186 }
187
188 Ok(result.with_size(ComputedData {
189 width: self_width,
190 height: self_height,
191 }))
192 }
193}
194
195#[derive(Clone, PartialEq)]
196struct CircularProgressLayout {
197 progress: Option<f32>,
198 diameter: Dp,
199 stroke_width: Dp,
200 color: Color,
201 track_color: Color,
202 stroke_cap: ProgressStrokeCap,
203 gap_size: Dp,
204 animation_start_nanos: u64,
205 frame_tick: State<u64>,
206}
207
208impl tessera_ui::LayoutModifierNode for CircularProgressLayout {
209 fn measure_eq(&self, other: &Self) -> bool {
210 self.diameter == other.diameter
211 }
212
213 fn placement_eq(&self, other: &Self) -> bool {
214 self.measure_eq(other)
215 }
216
217 fn measure(&self, _input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
218 let diameter_px = self.diameter.to_px();
219 Ok(LayoutResult::new(ComputedData {
220 width: diameter_px,
221 height: diameter_px,
222 }))
223 }
224}
225
226impl tessera_ui::DrawModifierNode for CircularProgressLayout {
227 fn draw(
228 &self,
229 ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
230 content: &mut dyn tessera_ui::DrawModifierContent,
231 ) {
232 let input = &mut *ctx.render_input;
233 let diameter_px = self.diameter.to_px();
234 let stroke_px = self.stroke_width.to_px();
235
236 let is_butt = self.stroke_cap.effective_is_butt(diameter_px, diameter_px);
237 let cap = if is_butt {
238 ProgressArcCap::Butt
239 } else {
240 ProgressArcCap::Round
241 };
242
243 let gap_sweep =
244 circular_gap_sweep_degrees(self.diameter, self.stroke_width, self.gap_size, is_butt);
245 let (start, sweep) = if let Some(progress) = self.progress {
246 let progress = if progress.is_nan() {
247 0.0
248 } else {
249 progress.clamp(0.0, 1.0)
250 };
251 (270.0, progress * 360.0)
252 } else {
253 let elapsed_ms = self
254 .frame_tick
255 .get()
256 .saturating_sub(self.animation_start_nanos) as f32
257 / 1_000_000.0;
258 let cycle_ms = elapsed_ms % 6000.0;
259 let rotation =
260 (cycle_ms / 6000.0) * 1080.0 + circular_additional_rotation_degrees(cycle_ms);
261 (rotation, circular_indeterminate_progress(cycle_ms) * 360.0)
262 };
263 let gap = sweep.min(gap_sweep);
264 let track_start = start + sweep + gap;
265 let track_sweep = 360.0 - sweep - gap * 2.0;
266 let mut metadata = input.metadata_mut();
267 if self.track_color.a > 0.0 && track_sweep > 0.0 {
268 metadata
269 .fragment_mut()
270 .push_draw_command(ProgressArcCommand {
271 color: self.track_color,
272 stroke_width_px: stroke_px.to_f32(),
273 start_angle_degrees: track_start,
274 sweep_angle_degrees: track_sweep,
275 cap,
276 });
277 }
278 if self.color.a > 0.0 && sweep > 0.0 {
279 metadata
280 .fragment_mut()
281 .push_draw_command(ProgressArcCommand {
282 color: self.color,
283 stroke_width_px: stroke_px.to_f32(),
284 start_angle_degrees: start,
285 sweep_angle_degrees: sweep,
286 cap,
287 });
288 }
289 content.draw(input);
290 }
291}
292
293pub struct ProgressIndicatorDefaults;
295
296impl ProgressIndicatorDefaults {
297 pub const LINEAR_INDICATOR_WIDTH: Dp = Dp(240.0);
299 pub const LINEAR_INDICATOR_HEIGHT: Dp = Dp(4.0);
301 pub const LINEAR_TRACK_STOP_INDICATOR_SIZE: Dp = Dp(4.0);
303 pub const LINEAR_INDICATOR_TRACK_GAP_SIZE: Dp = Dp(4.0);
305 pub const STOP_INDICATOR_TRAILING_SPACE: Dp = Dp(6.0);
307
308 pub const CIRCULAR_INDICATOR_DIAMETER: Dp = Dp(40.0);
310 pub const CIRCULAR_STROKE_WIDTH: Dp = Dp(4.0);
312 pub const CIRCULAR_INDICATOR_TRACK_GAP_SIZE: Dp = Dp(4.0);
314}
315
316fn cubic_bezier(t: f32, a: f32, b: f32, c: f32, d: f32) -> f32 {
317 let u = 1.0 - t;
318 (u * u * u * a) + (3.0 * u * u * t * b) + (3.0 * u * t * t * c) + (t * t * t * d)
319}
320
321pub(crate) fn cubic_bezier_easing(progress: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
322 let x = progress.clamp(0.0, 1.0);
323 let mut lo = 0.0;
324 let mut hi = 1.0;
325 let mut t = x;
326
327 for _ in 0..16 {
328 let mid = (lo + hi) * 0.5;
329 let mid_x = cubic_bezier(mid, 0.0, x1, x2, 1.0);
330 if mid_x < x {
331 lo = mid;
332 } else {
333 hi = mid;
334 }
335 t = mid;
336 }
337
338 cubic_bezier(t, 0.0, y1, y2, 1.0).clamp(0.0, 1.0)
339}
340
341fn lerp(a: f32, b: f32, t: f32) -> f32 {
342 a + (b - a) * t
343}
344
345fn linear_cycle_progress(start_nanos: u64, frame_nanos: u64, duration_ms: u32) -> f32 {
346 let elapsed_ms = frame_nanos.saturating_sub(start_nanos) / 1_000_000;
347 let duration_ms = duration_ms.max(1) as u64;
348 (elapsed_ms % duration_ms) as f32 / duration_ms as f32
349}
350
351fn keyframe_0_to_1(
352 cycle_progress: f32,
353 delay_ms: u32,
354 duration_ms: u32,
355 total_ms: u32,
356 easing: fn(f32) -> f32,
357) -> f32 {
358 let t_ms = cycle_progress.clamp(0.0, 1.0) * total_ms as f32;
359 let delay = delay_ms as f32;
360 let duration = duration_ms.max(1) as f32;
361 if t_ms <= delay {
362 0.0
363 } else if t_ms >= delay + duration {
364 1.0
365 } else {
366 easing((t_ms - delay) / duration)
367 }
368}
369
370fn emphasized_accelerate(progress: f32) -> f32 {
371 cubic_bezier_easing(progress, 0.3, 0.0, 0.8, 0.15)
372}
373
374fn standard_easing(progress: f32) -> f32 {
375 cubic_bezier_easing(progress, 0.2, 0.0, 0.0, 1.0)
376}
377
378fn resolve_linear_size(parent: ParentConstraint<'_>) -> (Px, Px) {
379 let fallback_width = ProgressIndicatorDefaults::LINEAR_INDICATOR_WIDTH.to_px();
380 let fallback_height = ProgressIndicatorDefaults::LINEAR_INDICATOR_HEIGHT.to_px();
381 let width = parent.width().clamp(fallback_width);
382 let height = parent.height().clamp(fallback_height);
383 (width, height)
384}
385
386fn linear_segment_bounds(
387 start_fraction: f32,
388 end_fraction: f32,
389 width: Px,
390 height: Px,
391 is_butt: bool,
392) -> Option<(Px, Px)> {
393 if width.0 <= 0 || height.0 <= 0 {
394 return None;
395 }
396
397 let start = start_fraction;
398 let end = end_fraction;
399 let min_frac = start.min(end);
400 let max_frac = start.max(end);
401 if (max_frac - min_frac) <= 0.0 {
402 return None;
403 }
404
405 let w = width.to_f32();
406 let bar_start = (min_frac * w).clamp(0.0, w);
407 let bar_end = (max_frac * w).clamp(0.0, w);
408
409 if is_butt {
410 let seg_w = bar_end - bar_start;
411 if seg_w <= 0.0 {
412 None
413 } else {
414 Some((
415 Px::saturating_from_f32(bar_start),
416 Px::saturating_from_f32(seg_w),
417 ))
418 }
419 } else {
420 let stroke_cap_offset = height.to_f32() * 0.5;
421 let adjusted_start = bar_start.clamp(stroke_cap_offset, w - stroke_cap_offset);
422 let adjusted_end = bar_end.clamp(stroke_cap_offset, w - stroke_cap_offset);
423 let seg_w = (adjusted_end - adjusted_start) + stroke_cap_offset * 2.0;
424 if seg_w <= 0.0 {
425 None
426 } else {
427 Some((
428 Px::saturating_from_f32(adjusted_start - stroke_cap_offset),
429 Px::saturating_from_f32(seg_w),
430 ))
431 }
432 }
433}
434
435fn adjusted_linear_gap_fraction(width: Px, height: Px, gap_size: Dp, is_butt: bool) -> f32 {
436 if width.0 <= 0 {
437 return 0.0;
438 }
439 let adjusted_gap = if is_butt {
440 gap_size
441 } else {
442 Dp(gap_size.0 + Dp::from_pixels_f32(height.to_f32()).0)
443 };
444 adjusted_gap.to_pixels_f32() / width.to_f32()
445}
446
447fn stop_indicator_bounds(width: Px, height: Px) -> (PxPosition, Px) {
448 let stop_size_px = Px::saturating_from_f32(
449 ProgressIndicatorDefaults::LINEAR_TRACK_STOP_INDICATOR_SIZE
450 .to_pixels_f32()
451 .min(height.to_f32()),
452 );
453 let max_stop_offset = ProgressIndicatorDefaults::STOP_INDICATOR_TRAILING_SPACE.to_px();
454 let stop_offset =
455 ((height.to_f32() - stop_size_px.to_f32()) / 2.0).min(max_stop_offset.to_f32());
456 let x = width.to_f32() - stop_size_px.to_f32() - stop_offset;
457 let y = (height.to_f32() - stop_size_px.to_f32()) / 2.0;
458 (
459 PxPosition::new(Px::saturating_from_f32(x), Px::saturating_from_f32(y)),
460 stop_size_px,
461 )
462}
463
464#[tessera]
504pub fn linear_progress_indicator(
505 progress: Option<f32>,
506 modifier: Option<Modifier>,
507 color: Option<Color>,
508 track_color: Option<Color>,
509 stroke_cap: Option<ProgressStrokeCap>,
510 gap_size: Option<Dp>,
511 draw_stop_indicator: Option<bool>,
512 #[prop(into)] accessibility_label: Option<String>,
513 #[prop(into)] accessibility_description: Option<String>,
514) {
515 let scheme = use_context::<MaterialTheme>()
516 .expect("MaterialTheme must be provided")
517 .get()
518 .color_scheme;
519 let modifier = modifier.unwrap_or_else(|| {
520 Modifier::new().size(
521 ProgressIndicatorDefaults::LINEAR_INDICATOR_WIDTH,
522 ProgressIndicatorDefaults::LINEAR_INDICATOR_HEIGHT,
523 )
524 });
525 let color = color.unwrap_or(scheme.primary);
526 let track_color = track_color.unwrap_or(scheme.secondary_container);
527 let stroke_cap = stroke_cap.unwrap_or_default();
528 let gap_size = gap_size.unwrap_or(ProgressIndicatorDefaults::LINEAR_INDICATOR_TRACK_GAP_SIZE);
529 let draw_stop_indicator = draw_stop_indicator.unwrap_or(true);
530
531 layout().modifier(modifier).child(move || {
532 let animation_start = remember(current_frame_nanos);
533 let frame_tick = remember(current_frame_nanos);
534 let should_receive_frames = remember(|| progress.is_none());
535 should_receive_frames.set(progress.is_none());
536
537 if should_receive_frames.get() {
538 receive_frame_nanos(move |frame_nanos| {
539 if !should_receive_frames.get() {
540 return tessera_ui::FrameNanosControl::Stop;
541 }
542 frame_tick.set(frame_nanos);
543 tessera_ui::FrameNanosControl::Continue
544 });
545 }
546
547 let segment_shape = if stroke_cap == ProgressStrokeCap::Butt {
548 Shape::RECTANGLE
549 } else {
550 Shape::CAPSULE
551 };
552
553 let mut semantics = SemanticsArgs {
554 role: Some(Role::ProgressIndicator),
555 label: accessibility_label.clone(),
556 description: accessibility_description.clone(),
557 ..Default::default()
558 };
559 if let Some(progress) = progress {
560 let progress = if progress.is_nan() {
561 0.0
562 } else {
563 progress.clamp(0.0, 1.0)
564 };
565 semantics.numeric_range = Some((0.0, 1.0));
566 semantics.numeric_value = Some(progress as f64);
567 }
568
569 let stop_shape = if stroke_cap == ProgressStrokeCap::Butt {
570 Shape::RECTANGLE
571 } else {
572 Shape::Ellipse
573 };
574 let animation_cycle = if progress.is_some() {
575 None
576 } else {
577 Some(linear_cycle_progress(
578 animation_start.get(),
579 frame_tick.get(),
580 1750,
581 ))
582 };
583
584 layout()
585 .modifier(Modifier::new().semantics(semantics))
586 .policy(LinearProgressLayout {
587 progress,
588 stroke_cap,
589 gap_size,
590 draw_stop_indicator,
591 animation_cycle,
592 })
593 .child(move || {
594 if progress.is_some() {
595 surface()
596 .style(track_color.into())
597 .shape(segment_shape)
598 .modifier(Modifier::new().fill_max_size())
599 .child(|| {});
600 surface()
601 .style(color.into())
602 .shape(segment_shape)
603 .modifier(Modifier::new().fill_max_size())
604 .child(|| {});
605 if draw_stop_indicator {
606 surface()
607 .style(color.into())
608 .shape(stop_shape)
609 .modifier(Modifier::new().fill_max_size())
610 .child(|| {});
611 }
612 } else {
613 for (color, shape) in [
614 (track_color, segment_shape),
615 (color, segment_shape),
616 (track_color, segment_shape),
617 (color, segment_shape),
618 (track_color, segment_shape),
619 ] {
620 surface()
621 .style(color.into())
622 .shape(shape)
623 .modifier(Modifier::new().fill_max_size())
624 .child(|| {});
625 }
626 }
627 });
628 });
629}
630
631fn circular_gap_sweep_degrees(diameter: Dp, stroke_width: Dp, gap_size: Dp, is_butt: bool) -> f32 {
632 let adjusted_gap = if is_butt {
633 gap_size
634 } else {
635 Dp(gap_size.0 + stroke_width.0)
636 };
637 let diameter_dp = diameter.0 as f32;
638 let gap_dp = adjusted_gap.0 as f32;
639 let circumference = std::f32::consts::PI * diameter_dp.max(0.0001);
640 (gap_dp / circumference) * 360.0
641}
642
643pub(crate) fn circular_additional_rotation_degrees(cycle_ms: f32) -> f32 {
644 let quarter = (cycle_ms / 1500.0).floor().clamp(0.0, 3.0);
645 quarter * 90.0 + ((cycle_ms - quarter * 1500.0) / 300.0).clamp(0.0, 1.0) * 90.0
646}
647
648pub(crate) fn circular_indeterminate_progress(cycle_ms: f32) -> f32 {
649 const MIN_PROGRESS: f32 = 0.1;
650 const MAX_PROGRESS: f32 = 0.87;
651 if cycle_ms <= 3000.0 {
652 let t = (cycle_ms / 3000.0).clamp(0.0, 1.0);
653 lerp(MIN_PROGRESS, MAX_PROGRESS, t)
654 } else {
655 let t = ((cycle_ms - 3000.0) / 3000.0).clamp(0.0, 1.0);
656 lerp(MAX_PROGRESS, MIN_PROGRESS, standard_easing(t))
657 }
658}
659
660#[tessera]
700pub fn circular_progress_indicator(
701 progress: Option<f32>,
702 diameter: Option<Dp>,
703 stroke_width: Option<Dp>,
704 color: Option<Color>,
705 track_color: Option<Color>,
706 stroke_cap: Option<ProgressStrokeCap>,
707 gap_size: Option<Dp>,
708 #[prop(into)] accessibility_label: Option<String>,
709 #[prop(into)] accessibility_description: Option<String>,
710) {
711 let scheme = use_context::<MaterialTheme>()
712 .expect("MaterialTheme must be provided")
713 .get()
714 .color_scheme;
715 let diameter = diameter.unwrap_or(ProgressIndicatorDefaults::CIRCULAR_INDICATOR_DIAMETER);
716 let stroke_width = stroke_width.unwrap_or(ProgressIndicatorDefaults::CIRCULAR_STROKE_WIDTH);
717 let color = color.unwrap_or(scheme.primary);
718 let track_color = track_color.unwrap_or(scheme.secondary_container);
719 let stroke_cap = stroke_cap.unwrap_or_default();
720 let gap_size = gap_size.unwrap_or(ProgressIndicatorDefaults::CIRCULAR_INDICATOR_TRACK_GAP_SIZE);
721 let animation_start = remember(current_frame_nanos);
722 let frame_tick = remember(current_frame_nanos);
723 let should_receive_frames = remember(|| progress.is_none());
724 should_receive_frames.set(progress.is_none());
725
726 if should_receive_frames.get() {
727 receive_frame_nanos(move |frame_nanos| {
728 if !should_receive_frames.get() {
729 return tessera_ui::FrameNanosControl::Stop;
730 }
731 frame_tick.set(frame_nanos);
732 tessera_ui::FrameNanosControl::Continue
733 });
734 }
735
736 let mut semantics = SemanticsArgs {
737 role: Some(Role::ProgressIndicator),
738 label: accessibility_label.clone(),
739 description: accessibility_description.clone(),
740 ..Default::default()
741 };
742 if let Some(progress) = progress {
743 let progress = if progress.is_nan() {
744 0.0
745 } else {
746 progress.clamp(0.0, 1.0)
747 };
748 semantics.numeric_range = Some((0.0, 1.0));
749 semantics.numeric_value = Some(progress as f64);
750 }
751
752 let policy = CircularProgressLayout {
753 progress,
754 diameter,
755 stroke_width,
756 color,
757 track_color,
758 stroke_cap,
759 gap_size,
760 animation_start_nanos: animation_start.get(),
761 frame_tick,
762 };
763 layout()
764 .modifier(Modifier::new().semantics(semantics))
765 .policy(policy.clone())
766 .draw(policy);
767}
768
769#[tessera]
804pub fn progress(
805 value: Option<f32>,
806 modifier: Option<Modifier>,
807 progress_color: Option<Color>,
808 track_color: Option<Color>,
809) {
810 let value = value.unwrap_or(0.0);
811 let scheme = use_context::<MaterialTheme>()
812 .expect("MaterialTheme must be provided")
813 .get()
814 .color_scheme;
815 linear_progress_indicator()
816 .progress(value)
817 .modifier(modifier.unwrap_or_else(|| {
818 Modifier::new().size(
819 ProgressIndicatorDefaults::LINEAR_INDICATOR_WIDTH,
820 ProgressIndicatorDefaults::LINEAR_INDICATOR_HEIGHT,
821 )
822 }))
823 .color(progress_color.unwrap_or(scheme.primary))
824 .track_color(track_color.unwrap_or(scheme.surface_variant));
825}
826
827#[cfg(all(test, not(target_family = "wasm")))]
828mod tests {
829 use std::time::Duration;
830
831 use tessera_ui::{
832 Color, HeadlessConfig, HeadlessRenderer, State, remember, tessera, testing::TestProbe,
833 };
834
835 use crate::theme::material_theme;
836
837 use super::{circular_progress_indicator, linear_progress_indicator};
838
839 #[tessera]
840 fn animated_fixture(linear: bool, probe: TestProbe<Option<State<bool>>>) {
841 let determinate = remember(|| false);
842 probe.set(Some(determinate));
843 material_theme().child(move || {
844 if linear {
845 let indicator = linear_progress_indicator()
846 .color(Color::new(1.0, 0.0, 0.0, 1.0))
847 .track_color(Color::new(0.0, 0.0, 1.0, 1.0));
848 if determinate.get() {
849 indicator.progress(0.6);
850 }
851 } else {
852 let indicator = circular_progress_indicator()
853 .color(Color::new(1.0, 0.0, 0.0, 1.0))
854 .track_color(Color::new(0.0, 0.0, 1.0, 1.0));
855 if determinate.get() {
856 indicator.progress(0.6);
857 }
858 }
859 });
860 }
861
862 #[test]
863 #[ignore = "requires a native GPU adapter"]
864 fn indeterminate_progress_advances_with_the_frame_clock_and_determinate_progress_stays_still() {
865 crate::test_utils::run_isolated_test(
866 "progress::tests::indeterminate_progress_advances_with_the_frame_clock_and_determinate_progress_stays_still",
867 || {
868 for linear in [false, true] {
869 let probe = TestProbe::default();
870 let observed = probe.clone();
871 let mut renderer = HeadlessRenderer::new(
872 Box::new(move || {
873 animated_fixture(linear, probe.clone());
874 }),
875 vec![Box::new(crate::TesseraComponents)],
876 HeadlessConfig {
877 width: 256,
878 height: 80,
879 frame_time: Duration::from_millis(250),
880 ..Default::default()
881 },
882 )
883 .expect("renderer");
884 renderer.render_frames(1);
885 let first = renderer.read_pixels().expect("first pose");
886 assert!(first.chunks_exact(4).any(|pixel| pixel[3] > 0));
887 renderer.render_frames(2);
888 let moving = renderer.read_pixels().expect("later pose");
889 assert!(
890 first
891 .chunks_exact(4)
892 .zip(moving.chunks_exact(4))
893 .any(|(a, b)| !crate::test_utils::pixel_near(a, b)),
894 "indeterminate progress must subscribe to frame changes, linear={linear}"
895 );
896 observed.get().unwrap().set(true);
897 renderer.render_frames(2);
898 let stopped = renderer.read_pixels().expect("determinate pose");
899 renderer.render_frames(4);
900 crate::test_utils::assert_pixels_near(
901 &stopped,
902 &renderer.read_pixels().expect("retained determinate pose"),
903 256,
904 );
905 }
906 },
907 );
908 }
909}