cranpose_ui/widgets/
progress_indicator.rs1use 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
19pub const CIRCULAR_INDICATOR_DIAMETER: f32 = 20.0;
21
22pub const CIRCULAR_INDICATOR_STROKE_WIDTH: f32 = 2.0;
26
27pub const PROGRESS_INDICATOR_COLOR: Color = Color(0.101, 0.462, 0.909, 1.0);
29
30pub const LINEAR_INDICATOR_WIDTH: f32 = 240.0;
32pub const LINEAR_INDICATOR_HEIGHT: f32 = 4.0;
34
35const ROTATION_DURATION_MS: u64 = 1332;
37const SWEEP_DURATION_MS: u64 = 666;
39const MIN_SWEEP_DEGREES: f32 = 30.0;
41const MAX_SWEEP_DEGREES: f32 = 270.0;
43const LINEAR_SLIDE_DURATION_MS: u64 = 1200;
45const LINEAR_BAND_FRACTION: f32 = 0.4;
47const LINEAR_TRACK_ALPHA: f32 = 0.24;
49
50#[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#[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
165pub(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
205pub(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
225fn 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;