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/// Draws an animated circular indicator for work with an unknown completion fraction.
51/// `color` and `stroke_width` control the arc appearance.
52///
53/// # Example
54///
55/// ```rust
56/// use cranpose_ui::{widgets::CircularProgressIndicator, *};
57///
58/// #[composable]
59/// fn Progress() {
60///     CircularProgressIndicator(Modifier::empty(), Color(0.1, 0.3, 0.9, 1.0), 4.0);
61/// }
62/// ```
63#[composable]
64pub fn CircularProgressIndicator(modifier: Modifier, color: Color, stroke_width: f32) -> NodeId {
65    let transition = rememberInfiniteTransition("circular_progress_indicator");
66    let rotation = transition.animateFloat(
67        0.0,
68        360.0,
69        infiniteRepeatable(
70            AnimationSpec::linear(ROTATION_DURATION_MS),
71            RepeatMode::Restart,
72            StartOffset::default(),
73        ),
74        "circular_progress_rotation",
75    );
76    let sweep = transition.animateFloat(
77        MIN_SWEEP_DEGREES,
78        MAX_SWEEP_DEGREES,
79        infiniteRepeatable(
80            AnimationSpec::tween(SWEEP_DURATION_MS, Easing::EaseInOut),
81            RepeatMode::Reverse,
82            StartOffset::default(),
83        ),
84        "circular_progress_sweep",
85    );
86
87    let sized = modifier
88        .size_points(CIRCULAR_INDICATOR_DIAMETER, CIRCULAR_INDICATOR_DIAMETER)
89        .stable_semantics(busy_semantics);
90    Canvas(sized, move |scope| {
91        let size = scope.size();
92        let start_angle = rotation.get() - 90.0;
93        let sweep_angle = sweep.get();
94        if let Some(data) = circular_arc_path_data(
95            size.width,
96            size.height,
97            stroke_width,
98            start_angle,
99            sweep_angle,
100        ) {
101            if let Ok(path) = VectorPath::parse(&data) {
102                scope.draw_vector_path(&path, Brush::solid(color));
103            }
104        }
105    })
106}
107
108/// An indeterminate linear progress indicator.
109///
110/// A band slides repeatedly across a dimmed track, following Jetpack
111/// Compose's `LinearProgressIndicator` (simplified single-band variant).
112///
113/// # Arguments
114///
115/// * `modifier` - Modifiers for styling and layout. The indicator applies a
116///   default size of [`LINEAR_INDICATOR_WIDTH`] x [`LINEAR_INDICATOR_HEIGHT`]
117///   dp which outer size modifiers can override.
118/// * `color` - Band color; the track uses the same color dimmed.
119#[composable]
120pub fn LinearProgressIndicator(modifier: Modifier, color: Color) -> NodeId {
121    let transition = rememberInfiniteTransition("linear_progress_indicator");
122    let phase = transition.animateFloat(
123        0.0,
124        1.0,
125        infiniteRepeatable(
126            AnimationSpec::tween(LINEAR_SLIDE_DURATION_MS, Easing::FastOutSlowInEasing),
127            RepeatMode::Restart,
128            StartOffset::default(),
129        ),
130        "linear_progress_phase",
131    );
132
133    let sized = modifier
134        .size_points(LINEAR_INDICATOR_WIDTH, LINEAR_INDICATOR_HEIGHT)
135        .stable_semantics(busy_semantics);
136    Canvas(sized, move |scope| {
137        let size = scope.size();
138        let track = Color(color.0, color.1, color.2, color.3 * LINEAR_TRACK_ALPHA);
139        scope.draw_rect(Brush::solid(track));
140        if let Some((x, width)) = linear_indicator_band(size.width, phase.get()) {
141            scope.draw_rect_at(
142                Rect {
143                    x,
144                    y: 0.0,
145                    width,
146                    height: size.height,
147                },
148                Brush::solid(color),
149            );
150        }
151    })
152}
153
154/// Builds SVG path data for a filled annular arc (donut segment) centered in
155/// a `width` x `height` box.
156///
157/// Angles are in degrees; 0 degrees points right (+X) and angles grow
158/// clockwise in screen coordinates. Returns `None` when there is nothing to
159/// draw (degenerate size or sweep).
160pub(crate) fn circular_arc_path_data(
161    width: f32,
162    height: f32,
163    stroke_width: f32,
164    start_angle_deg: f32,
165    sweep_angle_deg: f32,
166) -> Option<String> {
167    let outer_r = width.min(height) * 0.5;
168    if outer_r <= 0.0 {
169        return None;
170    }
171    let sweep = sweep_angle_deg.clamp(0.0, 359.9);
172    if sweep <= 0.0 {
173        return None;
174    }
175    let stroke = stroke_width.clamp(0.1, outer_r);
176    let inner_r = (outer_r - stroke).max(0.0);
177    let cx = width * 0.5;
178    let cy = height * 0.5;
179    let a0 = start_angle_deg.to_radians();
180    let a1 = (start_angle_deg + sweep).to_radians();
181    let (ox0, oy0) = (cx + outer_r * a0.cos(), cy + outer_r * a0.sin());
182    let (ox1, oy1) = (cx + outer_r * a1.cos(), cy + outer_r * a1.sin());
183    let (ix0, iy0) = (cx + inner_r * a0.cos(), cy + inner_r * a0.sin());
184    let (ix1, iy1) = (cx + inner_r * a1.cos(), cy + inner_r * a1.sin());
185    let large_arc = if sweep > 180.0 { 1 } else { 0 };
186    Some(format!(
187        "M {ox0:.4} {oy0:.4} \
188         A {outer_r:.4} {outer_r:.4} 0 {large_arc} 1 {ox1:.4} {oy1:.4} \
189         L {ix1:.4} {iy1:.4} \
190         A {inner_r:.4} {inner_r:.4} 0 {large_arc} 0 {ix0:.4} {iy0:.4} Z"
191    ))
192}
193
194/// Returns `(x, width)` of the indeterminate linear band clamped inside
195/// `[0, width]`, or `None` when the band is fully off-track.
196///
197/// `phase` runs from 0.0 (band fully off the left edge) to 1.0 (band fully
198/// off the right edge).
199pub(crate) fn linear_indicator_band(width: f32, phase: f32) -> Option<(f32, f32)> {
200    if width <= 0.0 {
201        return None;
202    }
203    let band_width = width * LINEAR_BAND_FRACTION;
204    let x = phase * (width + band_width) - band_width;
205    let x0 = x.max(0.0);
206    let x1 = (x + band_width).min(width);
207    (x1 > x0).then_some((x0, x1 - x0))
208}
209
210#[cfg(test)]
211#[path = "tests/progress_indicator_tests.rs"]
212mod tests;
213
214/// What a screen reader says at an indicator with no value: that the app is
215/// busy. Compose's `progressSemantics()` with no arguments does the same, and
216/// without it a spinner is a silent drawing a blind user walks past.
217fn busy_semantics(config: &mut cranpose_foundation::SemanticsConfiguration) {
218    config.content_description = Some("Loading".into());
219    config.role = Some(cranpose_foundation::SemanticsWidgetRole::ProgressBar);
220}
221
222#[cfg(test)]
223#[path = "tests/progress_indicator_busy_semantics_tests.rs"]
224mod busy_semantics_tests;