Skip to main content

tessera_components/
progress.rs

1//! Material progress indicators.
2//!
3//! ## Usage
4//!
5//! Use to indicate the completion of a task or a specific value in a range.
6use 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/// Stroke cap for progress indicators.
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub enum ProgressStrokeCap {
26    /// Rounded stroke ends.
27    #[default]
28    Round,
29    /// Flat stroke ends.
30    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
293/// Material Design 3 defaults for progress indicators.
294pub struct ProgressIndicatorDefaults;
295
296impl ProgressIndicatorDefaults {
297    /// Default width for linear progress indicators.
298    pub const LINEAR_INDICATOR_WIDTH: Dp = Dp(240.0);
299    /// Default height for linear progress indicators.
300    pub const LINEAR_INDICATOR_HEIGHT: Dp = Dp(4.0);
301    /// Default stop indicator size for linear progress indicators.
302    pub const LINEAR_TRACK_STOP_INDICATOR_SIZE: Dp = Dp(4.0);
303    /// Default gap between the indicator and the track for linear indicators.
304    pub const LINEAR_INDICATOR_TRACK_GAP_SIZE: Dp = Dp(4.0);
305    /// Maximum trailing inset for the stop indicator.
306    pub const STOP_INDICATOR_TRAILING_SPACE: Dp = Dp(6.0);
307
308    /// Default diameter for circular progress indicators.
309    pub const CIRCULAR_INDICATOR_DIAMETER: Dp = Dp(40.0);
310    /// Default stroke width for circular progress indicators.
311    pub const CIRCULAR_STROKE_WIDTH: Dp = Dp(4.0);
312    /// Default gap between the indicator and the track for circular indicators.
313    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/// # linear_progress_indicator
465///
466/// Renders a Material Design progress indicator in a horizontal, linear form.
467///
468/// ## Usage
469///
470/// Display completion state for determinate work, or show ongoing activity when
471/// the remaining duration is unknown.
472///
473/// ## Parameters
474///
475/// - `progress` — current progress in the range `0.0..=1.0`; `None` renders the
476///   indeterminate indicator.
477/// - `modifier` — optional modifier chain for size and layout.
478/// - `color` — optional active indicator color.
479/// - `track_color` — optional inactive track color.
480/// - `stroke_cap` — optional stroke cap style.
481/// - `gap_size` — optional gap size between the active indicator and the track.
482/// - `draw_stop_indicator` — optional stop-indicator toggle.
483/// - `accessibility_label` — optional accessibility label.
484/// - `accessibility_description` — optional accessibility description.
485///
486/// ## Examples
487///
488/// ```
489/// # use tessera_ui::tessera;
490/// # #[tessera]
491/// # fn component() {
492/// use tessera_components::progress::linear_progress_indicator;
493/// # use tessera_components::theme::{MaterialTheme, material_theme};
494///
495/// # material_theme()
496/// #     .theme(|| MaterialTheme::default())
497/// #     .child(|| {
498/// linear_progress_indicator().progress(0.75);
499/// # });
500/// # }
501/// # tessera_ui::testing::layout_test(component).run();
502/// ```
503#[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/// # circular_progress_indicator
661///
662/// Renders a Material Design progress indicator in a circular form.
663///
664/// ## Usage
665///
666/// Show ongoing activity or a completion fraction for tasks such as loading,
667/// syncing, or background work.
668///
669/// ## Parameters
670///
671/// - `progress` — current progress in the range `0.0..=1.0`; `None` renders the
672///   indeterminate indicator.
673/// - `diameter` — optional indicator diameter.
674/// - `stroke_width` — optional stroke width.
675/// - `color` — optional active indicator color.
676/// - `track_color` — optional track color.
677/// - `stroke_cap` — optional stroke cap style.
678/// - `gap_size` — optional gap size between the indicator and the track.
679/// - `accessibility_label` — optional accessibility label.
680/// - `accessibility_description` — optional accessibility description.
681///
682/// ## Examples
683///
684/// ```
685/// # use tessera_ui::tessera;
686/// # #[tessera]
687/// # fn component() {
688/// use tessera_components::progress::circular_progress_indicator;
689/// # use tessera_components::theme::{MaterialTheme, material_theme};
690///
691/// # material_theme()
692/// #     .theme(|| MaterialTheme::default())
693/// #     .child(|| {
694/// circular_progress_indicator().progress(0.6);
695/// # });
696/// # }
697/// # tessera_ui::testing::layout_test(component).run();
698/// ```
699#[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/// # progress
770///
771/// Renders a linear progress indicator that visualizes a value from 0.0 to 1.0.
772///
773/// ## Usage
774///
775/// Display the status of an ongoing operation, such as a download or a setup
776/// process.
777///
778/// ## Parameters
779///
780/// - `value` — current progress value in the range `0.0..=1.0`.
781/// - `modifier` — optional modifier chain for size and layout.
782/// - `progress_color` — optional active track color.
783/// - `track_color` — optional inactive track color.
784///
785/// ## Examples
786///
787/// ```
788/// # use tessera_ui::tessera;
789/// # #[tessera]
790/// # fn component() {
791/// use tessera_components::progress::progress;
792/// # use tessera_components::theme::{MaterialTheme, material_theme};
793///
794/// // Creates a progress bar that is 75% complete.
795/// # material_theme()
796/// #     .theme(|| MaterialTheme::default())
797/// #     .child(|| {
798/// progress().value(0.75);
799/// # });
800/// # }
801/// # tessera_ui::testing::layout_test(component).run();
802/// ```
803#[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}