Skip to main content

cranpose_ui/widgets/
progress_indicator.rs

1//! Progress indicators following Jetpack Compose's
2//! `androidx.compose.material3.CircularProgressIndicator` and
3//! `LinearProgressIndicator` (indeterminate variants).
4//!
5//! The circular indicator draws an arc that continuously sweeps around a
6//! circle: the arc rotates at a constant speed while its sweep angle grows
7//! and shrinks, driven by [`rememberInfiniteTransition`]. The arc itself is
8//! rendered as a filled annular sector via [`VectorPath`] (there is no stroke
9//! primitive in the draw pipeline).
10
11#![allow(non_snake_case)]
12
13use crate::composable;
14use crate::modifier::Modifier;
15use crate::widgets::Canvas;
16use cranpose_animation::{
17    infiniteRepeatable, rememberInfiniteTransition, AnimationSpec, Easing, RepeatMode, StartOffset,
18};
19use cranpose_core::NodeId;
20use cranpose_ui_graphics::{Brush, Color, Rect, VectorPath};
21
22/// Default diameter of [`CircularProgressIndicator`] in dp.
23pub const CIRCULAR_INDICATOR_DIAMETER: f32 = 20.0;
24
25/// Default stroke width of [`CircularProgressIndicator`] in dp.
26///
27/// Matches the Material proportion (4dp stroke at 40dp diameter).
28pub const CIRCULAR_INDICATOR_STROKE_WIDTH: f32 = 2.0;
29
30/// Default color for progress indicators (Material blue).
31pub const PROGRESS_INDICATOR_COLOR: Color = Color(0.101, 0.462, 0.909, 1.0);
32
33/// Default size of [`LinearProgressIndicator`] in dp.
34pub const LINEAR_INDICATOR_WIDTH: f32 = 240.0;
35/// Default height of [`LinearProgressIndicator`] in dp.
36pub const LINEAR_INDICATOR_HEIGHT: f32 = 4.0;
37
38/// Duration of one full rotation of the circular indicator, in ms.
39const ROTATION_DURATION_MS: u64 = 1332;
40/// Duration of one grow/shrink cycle of the arc sweep, in ms.
41const SWEEP_DURATION_MS: u64 = 666;
42/// Minimum sweep of the arc in degrees.
43const MIN_SWEEP_DEGREES: f32 = 30.0;
44/// Maximum sweep of the arc in degrees.
45const MAX_SWEEP_DEGREES: f32 = 270.0;
46/// Duration of one slide of the linear indicator band, in ms.
47const LINEAR_SLIDE_DURATION_MS: u64 = 1200;
48/// Fraction of the track occupied by the moving band.
49const LINEAR_BAND_FRACTION: f32 = 0.4;
50/// Track alpha relative to the indicator color.
51const LINEAR_TRACK_ALPHA: f32 = 0.24;
52
53/// An indeterminate circular progress indicator (spinner).
54///
55/// Follows Jetpack Compose's `CircularProgressIndicator`: an arc sweeps
56/// around a circle forever, rotating while its length pulses between
57/// [`MIN_SWEEP_DEGREES`] and [`MAX_SWEEP_DEGREES`].
58///
59/// # Arguments
60///
61/// * `modifier` - Modifiers for styling and layout. The indicator applies a
62///   default size of [`CIRCULAR_INDICATOR_DIAMETER`] dp which outer size
63///   modifiers can override.
64/// * `color` - Arc color (see [`PROGRESS_INDICATOR_COLOR`] for the default).
65/// * `stroke_width` - Arc thickness in dp
66///   (see [`CIRCULAR_INDICATOR_STROKE_WIDTH`] for the default).
67///
68/// # Example
69///
70/// ```rust,ignore
71/// CircularProgressIndicator(
72///     Modifier::empty(),
73///     PROGRESS_INDICATOR_COLOR,
74///     CIRCULAR_INDICATOR_STROKE_WIDTH,
75/// );
76/// ```
77#[composable]
78pub fn CircularProgressIndicator(modifier: Modifier, color: Color, stroke_width: f32) -> NodeId {
79    let transition = rememberInfiniteTransition("circular_progress_indicator");
80    let rotation = transition.animateFloat(
81        0.0,
82        360.0,
83        infiniteRepeatable(
84            AnimationSpec::linear(ROTATION_DURATION_MS),
85            RepeatMode::Restart,
86            StartOffset::default(),
87        ),
88        "circular_progress_rotation",
89    );
90    let sweep = transition.animateFloat(
91        MIN_SWEEP_DEGREES,
92        MAX_SWEEP_DEGREES,
93        infiniteRepeatable(
94            AnimationSpec::tween(SWEEP_DURATION_MS, Easing::EaseInOut),
95            RepeatMode::Reverse,
96            StartOffset::default(),
97        ),
98        "circular_progress_sweep",
99    );
100
101    let sized = modifier.size_points(CIRCULAR_INDICATOR_DIAMETER, CIRCULAR_INDICATOR_DIAMETER);
102    Canvas(sized, move |scope| {
103        let size = scope.size();
104        // Start at 12 o'clock like Compose (0 degrees points right in
105        // screen coordinates, so shift back by 90 degrees).
106        let start_angle = rotation.get() - 90.0;
107        let sweep_angle = sweep.get();
108        if let Some(data) = circular_arc_path_data(
109            size.width,
110            size.height,
111            stroke_width,
112            start_angle,
113            sweep_angle,
114        ) {
115            if let Ok(path) = VectorPath::parse(&data) {
116                scope.draw_vector_path(&path, Brush::solid(color));
117            }
118        }
119    })
120}
121
122/// An indeterminate linear progress indicator.
123///
124/// A band slides repeatedly across a dimmed track, following Jetpack
125/// Compose's `LinearProgressIndicator` (simplified single-band variant).
126///
127/// # Arguments
128///
129/// * `modifier` - Modifiers for styling and layout. The indicator applies a
130///   default size of [`LINEAR_INDICATOR_WIDTH`] x [`LINEAR_INDICATOR_HEIGHT`]
131///   dp which outer size modifiers can override.
132/// * `color` - Band color; the track uses the same color dimmed.
133#[composable]
134pub fn LinearProgressIndicator(modifier: Modifier, color: Color) -> NodeId {
135    let transition = rememberInfiniteTransition("linear_progress_indicator");
136    let phase = transition.animateFloat(
137        0.0,
138        1.0,
139        infiniteRepeatable(
140            AnimationSpec::tween(LINEAR_SLIDE_DURATION_MS, Easing::FastOutSlowInEasing),
141            RepeatMode::Restart,
142            StartOffset::default(),
143        ),
144        "linear_progress_phase",
145    );
146
147    let sized = modifier.size_points(LINEAR_INDICATOR_WIDTH, LINEAR_INDICATOR_HEIGHT);
148    Canvas(sized, move |scope| {
149        let size = scope.size();
150        let track = Color(color.0, color.1, color.2, color.3 * LINEAR_TRACK_ALPHA);
151        scope.draw_rect(Brush::solid(track));
152        if let Some((x, width)) = linear_indicator_band(size.width, phase.get()) {
153            scope.draw_rect_at(
154                Rect {
155                    x,
156                    y: 0.0,
157                    width,
158                    height: size.height,
159                },
160                Brush::solid(color),
161            );
162        }
163    })
164}
165
166/// Builds SVG path data for a filled annular arc (donut segment) centered in
167/// a `width` x `height` box.
168///
169/// Angles are in degrees; 0 degrees points right (+X) and angles grow
170/// clockwise in screen coordinates. Returns `None` when there is nothing to
171/// draw (degenerate size or sweep).
172pub(crate) fn circular_arc_path_data(
173    width: f32,
174    height: f32,
175    stroke_width: f32,
176    start_angle_deg: f32,
177    sweep_angle_deg: f32,
178) -> Option<String> {
179    let outer_r = width.min(height) * 0.5;
180    if outer_r <= 0.0 {
181        return None;
182    }
183    // Cap the sweep just below a full turn so the arc endpoints never
184    // coincide (a 360-degree SVG arc collapses to nothing).
185    let sweep = sweep_angle_deg.clamp(0.0, 359.9);
186    if sweep <= 0.0 {
187        return None;
188    }
189    let stroke = stroke_width.clamp(0.1, outer_r);
190    let inner_r = (outer_r - stroke).max(0.0);
191    let cx = width * 0.5;
192    let cy = height * 0.5;
193    let a0 = start_angle_deg.to_radians();
194    let a1 = (start_angle_deg + sweep).to_radians();
195    let (ox0, oy0) = (cx + outer_r * a0.cos(), cy + outer_r * a0.sin());
196    let (ox1, oy1) = (cx + outer_r * a1.cos(), cy + outer_r * a1.sin());
197    let (ix0, iy0) = (cx + inner_r * a0.cos(), cy + inner_r * a0.sin());
198    let (ix1, iy1) = (cx + inner_r * a1.cos(), cy + inner_r * a1.sin());
199    let large_arc = if sweep > 180.0 { 1 } else { 0 };
200    Some(format!(
201        "M {ox0:.4} {oy0:.4} \
202         A {outer_r:.4} {outer_r:.4} 0 {large_arc} 1 {ox1:.4} {oy1:.4} \
203         L {ix1:.4} {iy1:.4} \
204         A {inner_r:.4} {inner_r:.4} 0 {large_arc} 0 {ix0:.4} {iy0:.4} Z"
205    ))
206}
207
208/// Returns `(x, width)` of the indeterminate linear band clamped inside
209/// `[0, width]`, or `None` when the band is fully off-track.
210///
211/// `phase` runs from 0.0 (band fully off the left edge) to 1.0 (band fully
212/// off the right edge).
213pub(crate) fn linear_indicator_band(width: f32, phase: f32) -> Option<(f32, f32)> {
214    if width <= 0.0 {
215        return None;
216    }
217    let band_width = width * LINEAR_BAND_FRACTION;
218    let x = phase * (width + band_width) - band_width;
219    let x0 = x.max(0.0);
220    let x1 = (x + band_width).min(width);
221    (x1 > x0).then_some((x0, x1 - x0))
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
228    use std::sync::Arc;
229
230    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
231        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
232        f()
233    }
234
235    #[test]
236    fn circular_arc_path_parses_and_stays_in_bounds() {
237        for rotation in [0.0_f32, 45.0, 90.0, 200.0, 355.0] {
238            for sweep in [MIN_SWEEP_DEGREES, 120.0, MAX_SWEEP_DEGREES] {
239                let data = circular_arc_path_data(
240                    CIRCULAR_INDICATOR_DIAMETER,
241                    CIRCULAR_INDICATOR_DIAMETER,
242                    CIRCULAR_INDICATOR_STROKE_WIDTH,
243                    rotation - 90.0,
244                    sweep,
245                )
246                .expect("arc path data");
247                let path = VectorPath::parse(&data).expect("valid SVG arc path");
248                assert!(!path.is_empty(), "arc path must produce geometry");
249                let bounds = path.bounds();
250                let eps = 0.51; // arc flattening tolerance
251                assert!(
252                    bounds.x >= -eps
253                        && bounds.y >= -eps
254                        && bounds.x + bounds.width <= CIRCULAR_INDICATOR_DIAMETER + eps
255                        && bounds.y + bounds.height <= CIRCULAR_INDICATOR_DIAMETER + eps,
256                    "arc (rotation {rotation}, sweep {sweep}) escapes indicator bounds: {bounds:?}"
257                );
258            }
259        }
260    }
261
262    #[test]
263    fn circular_arc_path_rotates_with_angle() {
264        let at = |start: f32| {
265            circular_arc_path_data(20.0, 20.0, 2.0, start, 120.0).expect("arc path data")
266        };
267        assert_ne!(at(0.0), at(90.0), "rotation must move the arc");
268    }
269
270    #[test]
271    fn circular_arc_path_rejects_degenerate_input() {
272        assert!(circular_arc_path_data(0.0, 0.0, 2.0, 0.0, 120.0).is_none());
273        assert!(circular_arc_path_data(20.0, 20.0, 2.0, 0.0, 0.0).is_none());
274    }
275
276    #[test]
277    fn linear_band_stays_inside_track() {
278        let width = 200.0;
279        let mut seen_band = false;
280        for step in 0..=20 {
281            let phase = step as f32 / 20.0;
282            if let Some((x, band_width)) = linear_indicator_band(width, phase) {
283                seen_band = true;
284                assert!(x >= 0.0, "band start below 0 at phase {phase}");
285                assert!(
286                    x + band_width <= width + 1e-3,
287                    "band escapes track at phase {phase}"
288                );
289                assert!(band_width > 0.0);
290            }
291        }
292        assert!(seen_band, "band must be visible for mid phases");
293        // Fully off-track at both extremes.
294        assert!(linear_indicator_band(width, 0.0).is_none());
295        assert!(linear_indicator_band(width, 1.0).is_none());
296    }
297
298    #[test]
299    fn circular_progress_indicator_composes() {
300        let _app_context = crate::render_state::app_context_test_scope();
301        with_test_runtime(|| {
302            let mut composition = Composition::new(MemoryApplier::new());
303            let result = composition.render(location_key(file!(), line!(), column!()), || {
304                CircularProgressIndicator(
305                    Modifier::empty(),
306                    PROGRESS_INDICATOR_COLOR,
307                    CIRCULAR_INDICATOR_STROKE_WIDTH,
308                );
309            });
310            assert!(result.is_ok());
311            assert!(composition.root().is_some());
312        });
313    }
314
315    #[test]
316    fn linear_progress_indicator_composes() {
317        let _app_context = crate::render_state::app_context_test_scope();
318        with_test_runtime(|| {
319            let mut composition = Composition::new(MemoryApplier::new());
320            let result = composition.render(location_key(file!(), line!(), column!()), || {
321                LinearProgressIndicator(Modifier::empty(), PROGRESS_INDICATOR_COLOR);
322            });
323            assert!(result.is_ok());
324            assert!(composition.root().is_some());
325        });
326    }
327
328    /// The spinner's draw output must change as its infinite transition is
329    /// ticked by the frame clock: mount the widget, capture the Canvas draw
330    /// primitives, advance the animation clock, and require different
331    /// primitives from the same draw closure.
332    #[test]
333    fn circular_progress_indicator_animates_transition() {
334        use crate::layout::MeasureLayoutOptions;
335        use crate::measure_layout_with_options;
336
337        let _app_context = crate::render_state::app_context_test_scope();
338        let mut composition = Composition::new(MemoryApplier::new());
339        composition
340            .render(location_key(file!(), line!(), column!()), || {
341                CircularProgressIndicator(
342                    Modifier::empty(),
343                    PROGRESS_INDICATOR_COLOR,
344                    CIRCULAR_INDICATOR_STROKE_WIDTH,
345                );
346            })
347            .expect("initial render");
348
349        // Collect the spinner's draw commands from the laid-out tree.
350        let root = composition.root().expect("composition root");
351        let handle = composition.runtime_handle();
352
353        fn collect_draw_commands(
354            node: &crate::LayoutBox,
355            out: &mut Vec<(crate::DrawCommand, crate::modifier::Size)>,
356        ) {
357            for command in node.node_data.modifier_slices().draw_commands() {
358                out.push((
359                    command.clone(),
360                    crate::modifier::Size {
361                        width: node.rect.width,
362                        height: node.rect.height,
363                    },
364                ));
365            }
366            for child in &node.children {
367                collect_draw_commands(child, out);
368            }
369        }
370
371        let commands = {
372            let mut applier = composition.applier_mut();
373            applier.set_runtime_handle(handle.clone());
374            let measurements = measure_layout_with_options(
375                &mut applier,
376                root,
377                crate::Size::new(200.0, 200.0),
378                MeasureLayoutOptions {
379                    collect_semantics: false,
380                    build_layout_tree: true,
381                },
382            )
383            .expect("measure spinner layout");
384            applier.clear_runtime_handle();
385
386            let tree = measurements.layout_tree().expect("layout tree");
387            let mut commands = Vec::new();
388            collect_draw_commands(tree.root(), &mut commands);
389            commands
390        };
391        assert!(!commands.is_empty(), "spinner must register draw commands");
392
393        let run_commands = |commands: &[(crate::DrawCommand, crate::modifier::Size)]| {
394            use cranpose_ui_graphics::DrawScope as _;
395            commands
396                .iter()
397                .flat_map(|(command, size)| {
398                    let func = match command {
399                        crate::DrawCommand::Behind(func) => func,
400                        crate::DrawCommand::Overlay(func) => func,
401                        crate::DrawCommand::WithContent(func) => func,
402                    };
403                    let mut scope = crate::draw::command_draw_scope(*size);
404                    func(&mut scope);
405                    scope.into_primitives()
406                })
407                .collect::<Vec<_>>()
408        };
409
410        let before = run_commands(&commands);
411        assert!(
412            !before.is_empty(),
413            "spinner draw closure must emit primitives"
414        );
415
416        // Advance the animation clock by a few frames (~1/3 of a rotation).
417        let mut time = 0u64;
418        for _ in 0..30 {
419            time += 16_666_667;
420            handle.drain_frame_callbacks(time);
421            composition
422                .process_invalid_scopes()
423                .expect("process invalid scopes");
424        }
425
426        let after = run_commands(&commands);
427        assert_ne!(
428            before, after,
429            "spinner draw primitives must change as the transition animates"
430        );
431    }
432}