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]
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#[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
154pub(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
194pub(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
214fn 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;