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