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/// Determinate (`Some(0..1)`): active track + gap + stop indicator (M3).
234/// Indeterminate (`None`): sliding indicator matching Compose Material3 timing.
235pub fn LinearProgressIndicator(value: Option<f32>, config: LinearProgressIndicatorConfig) -> View {
236    let (head, tail) = if value.is_none() {
237        // Compose M3 indeterminate linear: ~1800 ms cycle, head/tail with different phases.
238        let shared = remember_state_with_key("lin_ind_shared", || {
239            let mut a = AnimatedValue::new(
240                0.0f32,
241                AnimationSpec::tween(Duration::from_millis(1800), Easing::Linear)
242                    .repeated(RepeatableSpec::infinite()),
243            );
244            a.set_target(1.0);
245            a
246        });
247        let mut s = shared.borrow_mut();
248        s.update();
249        let t = *s.get();
250        drop(s);
251        // HACK: Simplified but visually close to M3 (two overlapping segments).
252        let head = (t * 1.5).fract();
253        let tail = ((t * 1.5) - 0.4).fract().max(0.0);
254        (head, tail)
255    } else {
256        (0.0, 0.0)
257    };
258
259    Box(Modifier::new()
260        .fill_max_width()
261        .height(ProgressIndicatorDefaults::LINEAR_INDICATOR_HEIGHT)
262        .then(config.modifier)
263        .painter(move |scene: &mut Scene, rect: Rect, alpha: f32| {
264            let mul_c = |c: Color| {
265                Color(
266                    c.0,
267                    c.1,
268                    c.2,
269                    ((c.3 as f32) * alpha).clamp(0.0, 255.0) as u8,
270                )
271            };
272            let track_h = rect.h;
273            let corner = track_h * 0.5;
274            let cy = rect.y + rect.h * 0.5;
275            let cap_radius = if config.stroke_cap == StrokeCap::Butt {
276                0.0
277            } else {
278                corner
279            };
280            let dot_r = dp_to_px(config.stop_size) * 0.5;
281
282            // Full track background
283            scene.nodes.push(SceneNode::Rect {
284                rect: Rect {
285                    x: rect.x,
286                    y: cy - corner,
287                    w: rect.w,
288                    h: track_h,
289                },
290                brush: Brush::Solid(mul_c(config.track_color)),
291                radius: [cap_radius; 4],
292            });
293
294            if let Some(t) = value {
295                let t = t.clamp(0.0, 1.0);
296                let cap_ofs = cap_radius;
297                let ind_end = (t * rect.w).clamp(cap_ofs, rect.w - cap_ofs);
298                let ind_w = (ind_end - cap_ofs).max(0.0);
299
300                if t > 0.0 && ind_w > 0.0 {
301                    scene.nodes.push(SceneNode::Rect {
302                        rect: Rect {
303                            x: rect.x + cap_ofs,
304                            y: cy - corner,
305                            w: ind_w,
306                            h: track_h,
307                        },
308                        brush: Brush::Solid(mul_c(config.color)),
309                        radius: [cap_radius; 4],
310                    });
311                }
312
313                // Stop indicator (M3 determinate)
314                let sx = rect.x + rect.w - dot_r;
315                scene.nodes.push(SceneNode::Ellipse {
316                    rect: Rect {
317                        x: sx - dot_r,
318                        y: cy - dot_r,
319                        w: dot_r * 2.0,
320                        h: dot_r * 2.0,
321                    },
322                    brush: Brush::Solid(mul_c(config.color)),
323                });
324            } else {
325                // Indeterminate: two sliding segments (head leading, tail trailing)
326                let w = rect.w.max(1.0);
327                for (start_frac, end_frac) in
328                    [(tail, head), ((tail + 0.5).fract(), (head + 0.5).fract())]
329                {
330                    let a = start_frac.min(end_frac);
331                    let b = start_frac.max(end_frac);
332                    if b - a < 0.05 {
333                        continue; // too small
334                    }
335                    let x0 = rect.x + a * w;
336                    let x1 = rect.x + b * w;
337                    let ww = (x1 - x0).max(0.0);
338                    if ww > 1.0 {
339                        scene.nodes.push(SceneNode::Rect {
340                            rect: Rect {
341                                x: x0,
342                                y: cy - corner,
343                                w: ww,
344                                h: track_h,
345                            },
346                            brush: Brush::Solid(mul_c(config.color)),
347                            radius: [cap_radius; 4],
348                        });
349                    }
350                }
351            }
352        }))
353    .semantics(Semantics {
354        role: Role::ProgressBar,
355        label: None,
356        focused: false,
357        enabled: true,
358        selectable_group: false,
359    })
360}