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