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
11use cranpose_animation::{
12    AnimationSpec, Easing, RepeatMode, StartOffset, infiniteRepeatable, rememberInfiniteTransition,
13};
14use cranpose_core::NodeId;
15use cranpose_ui_graphics::{Brush, Color, Rect, VectorPath};
16
17use crate::{composable, modifier::Modifier, widgets::Canvas};
18
19/// Default diameter of [`CircularProgressIndicator`] in dp.
20pub const CIRCULAR_INDICATOR_DIAMETER: f32 = 20.0;
21
22/// Default stroke width of [`CircularProgressIndicator`] in dp.
23///
24/// Matches the Material proportion (4dp stroke at 40dp diameter).
25pub const CIRCULAR_INDICATOR_STROKE_WIDTH: f32 = 2.0;
26
27/// Default color for progress indicators (Material blue).
28pub const PROGRESS_INDICATOR_COLOR: Color = Color(0.101, 0.462, 0.909, 1.0);
29
30/// Default size of [`LinearProgressIndicator`] in dp.
31pub const LINEAR_INDICATOR_WIDTH: f32 = 240.0;
32/// Default height of [`LinearProgressIndicator`] in dp.
33pub const LINEAR_INDICATOR_HEIGHT: f32 = 4.0;
34
35/// Duration of one full rotation of the circular indicator, in ms.
36const ROTATION_DURATION_MS: u64 = 1332;
37/// Duration of one grow/shrink cycle of the arc sweep, in ms.
38const SWEEP_DURATION_MS: u64 = 666;
39/// Minimum sweep of the arc in degrees.
40const MIN_SWEEP_DEGREES: f32 = 30.0;
41/// Maximum sweep of the arc in degrees.
42const MAX_SWEEP_DEGREES: f32 = 270.0;
43/// Duration of one slide of the linear indicator band, in ms.
44const LINEAR_SLIDE_DURATION_MS: u64 = 1200;
45/// Fraction of the track occupied by the moving band.
46const LINEAR_BAND_FRACTION: f32 = 0.4;
47/// Track alpha relative to the indicator color.
48const LINEAR_TRACK_ALPHA: f32 = 0.24;
49
50/// An indeterminate circular progress indicator (spinner).
51///
52/// Follows Jetpack Compose's `CircularProgressIndicator`: an arc sweeps
53/// around a circle forever, rotating while its length pulses between
54/// `MIN_SWEEP_DEGREES` and `MAX_SWEEP_DEGREES`.
55///
56/// # Arguments
57///
58/// * `modifier` - Modifiers for styling and layout. The indicator applies a
59///   default size of [`CIRCULAR_INDICATOR_DIAMETER`] dp which outer size
60///   modifiers can override.
61/// * `color` - Arc color (see [`PROGRESS_INDICATOR_COLOR`] for the default).
62/// * `stroke_width` - Arc thickness in dp
63///   (see [`CIRCULAR_INDICATOR_STROKE_WIDTH`] for the default).
64///
65/// # Example
66///
67/// ```rust,ignore
68/// CircularProgressIndicator(
69///     Modifier::empty(),
70///     PROGRESS_INDICATOR_COLOR,
71///     CIRCULAR_INDICATOR_STROKE_WIDTH,
72/// );
73/// ```
74#[composable]
75pub fn CircularProgressIndicator(modifier: Modifier, color: Color, stroke_width: f32) -> NodeId {
76    let transition = rememberInfiniteTransition("circular_progress_indicator");
77    let rotation = transition.animateFloat(
78        0.0,
79        360.0,
80        infiniteRepeatable(
81            AnimationSpec::linear(ROTATION_DURATION_MS),
82            RepeatMode::Restart,
83            StartOffset::default(),
84        ),
85        "circular_progress_rotation",
86    );
87    let sweep = transition.animateFloat(
88        MIN_SWEEP_DEGREES,
89        MAX_SWEEP_DEGREES,
90        infiniteRepeatable(
91            AnimationSpec::tween(SWEEP_DURATION_MS, Easing::EaseInOut),
92            RepeatMode::Reverse,
93            StartOffset::default(),
94        ),
95        "circular_progress_sweep",
96    );
97
98    let sized = modifier
99        .size_points(CIRCULAR_INDICATOR_DIAMETER, CIRCULAR_INDICATOR_DIAMETER)
100        .semantics(busy_semantics);
101    Canvas(sized, move |scope| {
102        let size = scope.size();
103        let start_angle = rotation.get() - 90.0;
104        let sweep_angle = sweep.get();
105        if let Some(data) = circular_arc_path_data(
106            size.width,
107            size.height,
108            stroke_width,
109            start_angle,
110            sweep_angle,
111        ) {
112            if let Ok(path) = VectorPath::parse(&data) {
113                scope.draw_vector_path(&path, Brush::solid(color));
114            }
115        }
116    })
117}
118
119/// An indeterminate linear progress indicator.
120///
121/// A band slides repeatedly across a dimmed track, following Jetpack
122/// Compose's `LinearProgressIndicator` (simplified single-band variant).
123///
124/// # Arguments
125///
126/// * `modifier` - Modifiers for styling and layout. The indicator applies a
127///   default size of [`LINEAR_INDICATOR_WIDTH`] x [`LINEAR_INDICATOR_HEIGHT`]
128///   dp which outer size modifiers can override.
129/// * `color` - Band color; the track uses the same color dimmed.
130#[composable]
131pub fn LinearProgressIndicator(modifier: Modifier, color: Color) -> NodeId {
132    let transition = rememberInfiniteTransition("linear_progress_indicator");
133    let phase = transition.animateFloat(
134        0.0,
135        1.0,
136        infiniteRepeatable(
137            AnimationSpec::tween(LINEAR_SLIDE_DURATION_MS, Easing::FastOutSlowInEasing),
138            RepeatMode::Restart,
139            StartOffset::default(),
140        ),
141        "linear_progress_phase",
142    );
143
144    let sized = modifier
145        .size_points(LINEAR_INDICATOR_WIDTH, LINEAR_INDICATOR_HEIGHT)
146        .semantics(busy_semantics);
147    Canvas(sized, move |scope| {
148        let size = scope.size();
149        let track = Color(color.0, color.1, color.2, color.3 * LINEAR_TRACK_ALPHA);
150        scope.draw_rect(Brush::solid(track));
151        if let Some((x, width)) = linear_indicator_band(size.width, phase.get()) {
152            scope.draw_rect_at(
153                Rect {
154                    x,
155                    y: 0.0,
156                    width,
157                    height: size.height,
158                },
159                Brush::solid(color),
160            );
161        }
162    })
163}
164
165/// Builds SVG path data for a filled annular arc (donut segment) centered in
166/// a `width` x `height` box.
167///
168/// Angles are in degrees; 0 degrees points right (+X) and angles grow
169/// clockwise in screen coordinates. Returns `None` when there is nothing to
170/// draw (degenerate size or sweep).
171pub(crate) fn circular_arc_path_data(
172    width: f32,
173    height: f32,
174    stroke_width: f32,
175    start_angle_deg: f32,
176    sweep_angle_deg: f32,
177) -> Option<String> {
178    let outer_r = width.min(height) * 0.5;
179    if outer_r <= 0.0 {
180        return None;
181    }
182    let sweep = sweep_angle_deg.clamp(0.0, 359.9);
183    if sweep <= 0.0 {
184        return None;
185    }
186    let stroke = stroke_width.clamp(0.1, outer_r);
187    let inner_r = (outer_r - stroke).max(0.0);
188    let cx = width * 0.5;
189    let cy = height * 0.5;
190    let a0 = start_angle_deg.to_radians();
191    let a1 = (start_angle_deg + sweep).to_radians();
192    let (ox0, oy0) = (cx + outer_r * a0.cos(), cy + outer_r * a0.sin());
193    let (ox1, oy1) = (cx + outer_r * a1.cos(), cy + outer_r * a1.sin());
194    let (ix0, iy0) = (cx + inner_r * a0.cos(), cy + inner_r * a0.sin());
195    let (ix1, iy1) = (cx + inner_r * a1.cos(), cy + inner_r * a1.sin());
196    let large_arc = if sweep > 180.0 { 1 } else { 0 };
197    Some(format!(
198        "M {ox0:.4} {oy0:.4} \
199         A {outer_r:.4} {outer_r:.4} 0 {large_arc} 1 {ox1:.4} {oy1:.4} \
200         L {ix1:.4} {iy1:.4} \
201         A {inner_r:.4} {inner_r:.4} 0 {large_arc} 0 {ix0:.4} {iy0:.4} Z"
202    ))
203}
204
205/// Returns `(x, width)` of the indeterminate linear band clamped inside
206/// `[0, width]`, or `None` when the band is fully off-track.
207///
208/// `phase` runs from 0.0 (band fully off the left edge) to 1.0 (band fully
209/// off the right edge).
210pub(crate) fn linear_indicator_band(width: f32, phase: f32) -> Option<(f32, f32)> {
211    if width <= 0.0 {
212        return None;
213    }
214    let band_width = width * LINEAR_BAND_FRACTION;
215    let x = phase * (width + band_width) - band_width;
216    let x0 = x.max(0.0);
217    let x1 = (x + band_width).min(width);
218    (x1 > x0).then_some((x0, x1 - x0))
219}
220
221#[cfg(test)]
222#[path = "tests/progress_indicator_tests.rs"]
223mod tests;
224
225/// What a screen reader says at an indicator with no value: that the app is
226/// busy. Compose's `progressSemantics()` with no arguments does the same, and
227/// without it a spinner is a silent drawing a blind user walks past.
228fn busy_semantics(config: &mut cranpose_foundation::SemanticsConfiguration) {
229    config.content_description = Some("Loading".into());
230    config.role = Some(cranpose_foundation::SemanticsWidgetRole::ProgressBar);
231}
232
233#[cfg(test)]
234#[path = "tests/progress_indicator_busy_semantics_tests.rs"]
235mod busy_semantics_tests;