Skip to main content

repose_material/material3/
progress.rs

1#![allow(non_snake_case)]
2
3use web_time::Duration;
4
5use repose_core::animation::{AnimationSpec, CubicBezier, Easing, KeyframesSpec, RepeatableSpec};
6use repose_core::*;
7use repose_ui::Box;
8
9use super::*;
10
11/// Configuration for [`CircularProgressIndicator`].
12#[derive(Clone, Debug)]
13pub struct CircularProgressIndicatorConfig {
14    pub modifier: Modifier,
15    pub color: Color,
16    pub track_color: Color,
17    pub stroke_width: f32,
18    pub stroke_cap: StrokeCap,
19    pub gap_size: f32,
20}
21
22impl Default for CircularProgressIndicatorConfig {
23    fn default() -> Self {
24        Self {
25            modifier: Modifier::new(),
26            color: ProgressIndicatorDefaults::circular_color(),
27            track_color: ProgressIndicatorDefaults::circular_track_color(),
28            stroke_width: ProgressIndicatorDefaults::CIRCULAR_STROKE_WIDTH,
29            stroke_cap: StrokeCap::Round,
30            gap_size: 0.0,
31        }
32    }
33}
34
35/// M3 Circular Progress Indicator.
36///
37/// Determinate (`Some(0..1)`): draws arc from 12 o'clock clockwise.
38/// Indeterminate (`None`): animates a spinning 270° arc.
39pub fn CircularProgressIndicator(
40    value: Option<f32>,
41    config: CircularProgressIndicatorConfig,
42) -> View {
43    let sz = dp_to_px(ProgressIndicatorDefaults::CIRCULAR_INDICATOR_SIZE);
44    let stroke_px = dp_to_px(config.stroke_width);
45    let val = value.map(|v| v.clamp(0.0, 1.0));
46
47    // Three concurrent animations matching Compose Material3 indeterminate spec:
48    //   1. Global rotation -> 1080° linear over 6000ms
49    //   2. Additional rotation -> 90° stepped jumps with EmphasizedDecelerate
50    //   3. Sweep -> oscillates 0.1 -> 0.87 -> 0.1 over 6000ms
51    let (global_rotation, additional_rotation, sweep_val) = if value.is_none() {
52        let shared = remember_state_with_key("circ_ind_shared", || {
53            let mut a = AnimatedValue::new(
54                0.0f32,
55                AnimationSpec::tween(Duration::from_millis(6000), Easing::Linear)
56                    .repeated(RepeatableSpec::infinite()),
57            );
58            a.set_target(1.0);
59            a
60        });
61        let mut s = shared.borrow_mut();
62        s.update();
63        let t = *s.get();
64        drop(s);
65
66        let gv = t * 1080.0;
67
68        let emph = Easing::Custom(CubicBezier::new(0.05, 0.7, 0.1, 1.0));
69        let add_kf = remember_state_with_key("circ_ind_add_kf", || KeyframesSpec {
70            keyframes: vec![
71                (0.0, 0.0, None),
72                (0.05, 90.0, Some(emph)),
73                (0.25, 90.0, None),
74                (0.30, 180.0, None),
75                (0.50, 180.0, None),
76                (0.55, 270.0, None),
77                (0.75, 270.0, None),
78                (0.80, 360.0, None),
79                (1.0, 360.0, None),
80            ],
81        });
82        let av = add_kf.borrow().evaluate(t);
83
84        let std_dec = Easing::Custom(CubicBezier::new(0.2, 0.0, 0.0, 1.0));
85        let sweep_kf = remember_state_with_key("circ_ind_sweep_kf", || KeyframesSpec {
86            keyframes: vec![
87                (0.0, 0.1, None),
88                (0.5, 0.87, Some(std_dec)),
89                (1.0, 0.1, None),
90            ],
91        });
92        let sv = sweep_kf.borrow().evaluate(t);
93
94        (gv, av, sv)
95    } else {
96        (0.0, 0.0, 0.0)
97    };
98
99    // Pre-compute gap angular size in radians
100    let indicator_size_dp = ProgressIndicatorDefaults::CIRCULAR_INDICATOR_SIZE;
101    let adjusted_gap_dp = if config.stroke_cap == StrokeCap::Butt {
102        config.gap_size
103    } else {
104        config.gap_size + config.stroke_width
105    };
106    let circle_dia_dp = indicator_size_dp - config.stroke_width;
107    let gap_sweep_rad = 2.0 * adjusted_gap_dp / circle_dia_dp;
108
109    Box(Modifier::new().size(sz, sz).then(config.modifier).painter(
110        move |scene: &mut Scene, rect: Rect, alpha: f32| {
111            let mul_c = |c: Color| {
112                Color(
113                    c.0,
114                    c.1,
115                    c.2,
116                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
117                )
118            };
119            let cx = rect.x + rect.w * 0.5;
120            let cy = rect.y + rect.h * 0.5;
121            let r = (rect.w.min(rect.h)) * 0.5 - stroke_px * 0.5;
122            let circle = Rect {
123                x: cx - r,
124                y: cy - r,
125                w: r * 2.0,
126                h: r * 2.0,
127            };
128
129            match val {
130                Some(p) => {
131                    let sweep_rad = p * std::f32::consts::TAU;
132                    let start_angle = -std::f32::consts::FRAC_PI_2;
133                    let effective_gap = gap_sweep_rad.min(sweep_rad);
134
135                    // Indicator arc
136                    if p > 0.0 {
137                        scene.nodes.push(SceneNode::Arc {
138                            rect: circle,
139                            start_angle,
140                            sweep_angle: sweep_rad,
141                            stroke_width: stroke_px,
142                            color: mul_c(config.color),
143                            cap: config.stroke_cap,
144                        });
145                    }
146
147                    // Track arc (with gap from indicator)
148                    let track_start = start_angle + sweep_rad + effective_gap;
149                    let track_sweep = std::f32::consts::TAU - sweep_rad - 2.0 * effective_gap;
150                    if track_sweep > 0.0 {
151                        scene.nodes.push(SceneNode::Arc {
152                            rect: circle,
153                            start_angle: track_start,
154                            sweep_angle: track_sweep,
155                            stroke_width: stroke_px,
156                            color: mul_c(config.track_color),
157                            cap: config.stroke_cap,
158                        });
159                    }
160                }
161                None => {
162                    let radians =
163                        (global_rotation + additional_rotation) * std::f32::consts::PI / 180.0;
164                    let start_angle = -std::f32::consts::FRAC_PI_2 + radians;
165                    let sweep_rad = sweep_val * std::f32::consts::TAU;
166                    let effective_gap = gap_sweep_rad.min(sweep_rad);
167
168                    // Indicator arc
169                    scene.nodes.push(SceneNode::Arc {
170                        rect: circle,
171                        start_angle,
172                        sweep_angle: sweep_rad,
173                        stroke_width: stroke_px,
174                        color: mul_c(config.color),
175                        cap: config.stroke_cap,
176                    });
177
178                    // Track arc (with gap from indicator)
179                    let track_start = start_angle + sweep_rad + effective_gap;
180                    let track_sweep = std::f32::consts::TAU - sweep_rad - 2.0 * effective_gap;
181                    if track_sweep > 0.0 {
182                        scene.nodes.push(SceneNode::Arc {
183                            rect: circle,
184                            start_angle: track_start,
185                            sweep_angle: track_sweep,
186                            stroke_width: stroke_px,
187                            color: mul_c(config.track_color),
188                            cap: config.stroke_cap,
189                        });
190                    }
191                }
192            }
193        },
194    ))
195    .semantics(Semantics {
196        role: Role::ProgressBar,
197        ..Default::default()
198    })
199}
200
201/// Configuration for [`LinearProgressIndicator`].
202#[derive(Clone, Debug)]
203pub struct LinearProgressIndicatorConfig {
204    pub modifier: Modifier,
205    pub color: Color,
206    pub track_color: Color,
207    /// Stroke cap style for the indicator ends. Default: `StrokeCap::Round`
208    pub stroke_cap: StrokeCap,
209    /// Gap between indicator and track, in dp.
210    pub gap_size: f32,
211    /// Diameter of the stop indicator dot, in dp.
212    pub stop_size: f32,
213}
214
215impl Default for LinearProgressIndicatorConfig {
216    fn default() -> Self {
217        Self {
218            modifier: Modifier::new(),
219            color: ProgressIndicatorDefaults::linear_color(),
220            track_color: ProgressIndicatorDefaults::linear_track_color(),
221            stroke_cap: StrokeCap::Round,
222            gap_size: ProgressIndicatorDefaults::LINEAR_INDICATOR_GAP_SIZE,
223            stop_size: ProgressIndicatorDefaults::LINEAR_TRACK_STOP_SIZE,
224        }
225    }
226}
227
228/// M3 Linear Progress Indicator.
229///
230/// Determinate (`Some(0..1)`): active track + gap + stop indicator (M3).
231/// Indeterminate (`None`): sliding indicator matching Compose Material3 timing.
232pub fn LinearProgressIndicator(value: Option<f32>, config: LinearProgressIndicatorConfig) -> View {
233    let (head, tail) = if value.is_none() {
234        // Compose M3 indeterminate linear: ~1800 ms cycle, head/tail with different phases.
235        let shared = remember_state_with_key("lin_ind_shared", || {
236            let mut a = AnimatedValue::new(
237                0.0f32,
238                AnimationSpec::tween(Duration::from_millis(1800), Easing::Linear)
239                    .repeated(RepeatableSpec::infinite()),
240            );
241            a.set_target(1.0);
242            a
243        });
244        let mut s = shared.borrow_mut();
245        s.update();
246        let t = *s.get();
247        drop(s);
248        // HACK: Simplified but visually close to M3 (two overlapping segments).
249        let head = (t * 1.5).fract();
250        let tail = ((t * 1.5) - 0.4).fract().max(0.0);
251        (head, tail)
252    } else {
253        (0.0, 0.0)
254    };
255
256    Box(Modifier::new()
257        .fill_max_width()
258        .height(ProgressIndicatorDefaults::LINEAR_INDICATOR_HEIGHT)
259        .then(config.modifier)
260        .painter(move |scene: &mut Scene, rect: Rect, alpha: f32| {
261            let mul_c = |c: Color| {
262                Color(
263                    c.0,
264                    c.1,
265                    c.2,
266                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
267                )
268            };
269            let track_h = rect.h;
270            let corner = track_h * 0.5;
271            let cy = rect.y + rect.h * 0.5;
272            let cap_radius = if config.stroke_cap == StrokeCap::Butt {
273                0.0
274            } else {
275                corner
276            };
277            let dot_r = dp_to_px(config.stop_size) * 0.5;
278
279            // Full track background
280            scene.nodes.push(SceneNode::Rect {
281                rect: Rect {
282                    x: rect.x,
283                    y: cy - corner,
284                    w: rect.w,
285                    h: track_h,
286                },
287                brush: Brush::Solid(mul_c(config.track_color)),
288                radius: [cap_radius; 4],
289            });
290
291            if let Some(t) = value {
292                let t = t.clamp(0.0, 1.0);
293                let cap_ofs = cap_radius;
294                let ind_end = (t * rect.w).clamp(cap_ofs, rect.w - cap_ofs);
295                let ind_w = (ind_end - cap_ofs).max(0.0);
296
297                if t > 0.0 && ind_w > 0.0 {
298                    scene.nodes.push(SceneNode::Rect {
299                        rect: Rect {
300                            x: rect.x + cap_ofs,
301                            y: cy - corner,
302                            w: ind_w,
303                            h: track_h,
304                        },
305                        brush: Brush::Solid(mul_c(config.color)),
306                        radius: [cap_radius; 4],
307                    });
308                }
309
310                // Stop indicator (M3 determinate)
311                let sx = rect.x + rect.w - dot_r;
312                scene.nodes.push(SceneNode::Ellipse {
313                    rect: Rect {
314                        x: sx - dot_r,
315                        y: cy - dot_r,
316                        w: dot_r * 2.0,
317                        h: dot_r * 2.0,
318                    },
319                    brush: Brush::Solid(mul_c(config.color)),
320                });
321            } else {
322                // Indeterminate: two sliding segments (head leading, tail trailing)
323                let w = rect.w.max(1.0);
324                for (start_frac, end_frac) in
325                    [(tail, head), ((tail + 0.5).fract(), (head + 0.5).fract())]
326                {
327                    let a = start_frac.min(end_frac);
328                    let b = start_frac.max(end_frac);
329                    if b - a < 0.05 {
330                        continue; // too small
331                    }
332                    let x0 = rect.x + a * w;
333                    let x1 = rect.x + b * w;
334                    let ww = (x1 - x0).max(0.0);
335                    if ww > 1.0 {
336                        scene.nodes.push(SceneNode::Rect {
337                            rect: Rect {
338                                x: x0,
339                                y: cy - corner,
340                                w: ww,
341                                h: track_h,
342                            },
343                            brush: Brush::Solid(mul_c(config.color)),
344                            radius: [cap_radius; 4],
345                        });
346                    }
347                }
348            }
349        }))
350    .semantics(Semantics {
351        role: Role::ProgressBar,
352        ..Default::default()
353    })
354}