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        label: None,
198        focused: false,
199        enabled: true,
200        selectable_group: false,
201    })
202}
203
204/// Configuration for [`LinearProgressIndicator`].
205#[derive(Clone, Debug)]
206pub struct LinearProgressIndicatorConfig {
207    pub modifier: Modifier,
208    pub color: Color,
209    pub track_color: Color,
210    /// Stroke cap style for the indicator ends. Default: `StrokeCap::Round`
211    pub stroke_cap: StrokeCap,
212    /// Gap between indicator and track, in dp.
213    pub gap_size: f32,
214    /// Diameter of the stop indicator dot, in dp.
215    pub stop_size: f32,
216}
217
218impl Default for LinearProgressIndicatorConfig {
219    fn default() -> Self {
220        Self {
221            modifier: Modifier::new(),
222            color: ProgressIndicatorDefaults::linear_color(),
223            track_color: ProgressIndicatorDefaults::linear_track_color(),
224            stroke_cap: StrokeCap::Round,
225            gap_size: ProgressIndicatorDefaults::LINEAR_INDICATOR_GAP_SIZE,
226            stop_size: ProgressIndicatorDefaults::LINEAR_TRACK_STOP_SIZE,
227        }
228    }
229}
230
231/// M3 Linear Progress Indicator.
232///
233/// Pass `LinearProgressIndicatorConfig::default()` for standard M3 appearance,
234/// or override individual fields via struct-update syntax.
235pub fn LinearProgressIndicator(value: Option<f32>, config: LinearProgressIndicatorConfig) -> View {
236    Box(Modifier::new()
237        .fill_max_width()
238        .height(ProgressIndicatorDefaults::LINEAR_INDICATOR_HEIGHT)
239        .then(config.modifier)
240        .painter(move |scene: &mut Scene, rect: Rect, alpha: f32| {
241            let mul_c = |c: Color| {
242                Color(
243                    c.0,
244                    c.1,
245                    c.2,
246                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
247                )
248            };
249            let track_h = rect.h;
250            let corner = track_h * 0.5;
251            let dot_r = dp_to_px(config.stop_size) * 0.5;
252            let cy = rect.y + rect.h * 0.5;
253            let t = value.unwrap_or(0.0).clamp(0.0, 1.0);
254
255            let cap_radius = if config.stroke_cap == StrokeCap::Butt {
256                0.0
257            } else {
258                corner
259            };
260
261            let gap = dp_to_px(config.gap_size)
262                - if config.stroke_cap == StrokeCap::Butt {
263                    0.0
264                } else {
265                    cap_radius
266                };
267
268            let cap_ofs = cap_radius;
269            let ind_end = (t * rect.w).clamp(cap_ofs, rect.w - cap_ofs);
270            let ind_w = (ind_end - cap_ofs).max(0.0);
271
272            // Indicator (active portion from left)
273            if t > 0.0 && ind_w > 0.0 {
274                scene.nodes.push(SceneNode::Rect {
275                    rect: Rect {
276                        x: rect.x + cap_ofs,
277                        y: cy - corner,
278                        w: ind_w,
279                        h: track_h,
280                    },
281                    brush: Brush::Solid(mul_c(config.color)),
282                    radius: [cap_radius; 4],
283                });
284            }
285
286            // Track (inactive portion after gap)
287            let track_start = (rect.x + ind_end + gap).min(rect.x + rect.w);
288            let track_w = (rect.x + rect.w - track_start).max(0.0);
289            if t < 1.0 && track_w > 0.0 {
290                let track_left = track_start + cap_ofs;
291                let track_right = rect.x + rect.w;
292                if track_right > track_left {
293                    scene.nodes.push(SceneNode::Rect {
294                        rect: Rect {
295                            x: track_left,
296                            y: cy - corner,
297                            w: track_right - track_left,
298                            h: track_h,
299                        },
300                        brush: Brush::Solid(mul_c(config.track_color)),
301                        radius: [cap_radius; 4],
302                    });
303                }
304            }
305
306            // Stop indicator at right end circle
307            {
308                let sx = rect.x + rect.w - dot_r;
309                scene.nodes.push(SceneNode::Ellipse {
310                    rect: Rect {
311                        x: sx - dot_r,
312                        y: cy - dot_r,
313                        w: dot_r * 2.0,
314                        h: dot_r * 2.0,
315                    },
316                    brush: Brush::Solid(mul_c(config.color)),
317                });
318            }
319        }))
320    .semantics(Semantics {
321        role: Role::ProgressBar,
322        label: None,
323        focused: false,
324        enabled: true,
325        selectable_group: false,
326    })
327}