1#![allow(non_snake_case)]
12
13use crate::composable;
14use crate::modifier::Modifier;
15use crate::widgets::Canvas;
16use cranpose_animation::{
17 infiniteRepeatable, rememberInfiniteTransition, AnimationSpec, Easing, RepeatMode, StartOffset,
18};
19use cranpose_core::NodeId;
20use cranpose_ui_graphics::{Brush, Color, Rect, VectorPath};
21
22pub const CIRCULAR_INDICATOR_DIAMETER: f32 = 20.0;
24
25pub const CIRCULAR_INDICATOR_STROKE_WIDTH: f32 = 2.0;
29
30pub const PROGRESS_INDICATOR_COLOR: Color = Color(0.101, 0.462, 0.909, 1.0);
32
33pub const LINEAR_INDICATOR_WIDTH: f32 = 240.0;
35pub const LINEAR_INDICATOR_HEIGHT: f32 = 4.0;
37
38const ROTATION_DURATION_MS: u64 = 1332;
40const SWEEP_DURATION_MS: u64 = 666;
42const MIN_SWEEP_DEGREES: f32 = 30.0;
44const MAX_SWEEP_DEGREES: f32 = 270.0;
46const LINEAR_SLIDE_DURATION_MS: u64 = 1200;
48const LINEAR_BAND_FRACTION: f32 = 0.4;
50const LINEAR_TRACK_ALPHA: f32 = 0.24;
52
53#[composable]
78pub fn CircularProgressIndicator(modifier: Modifier, color: Color, stroke_width: f32) -> NodeId {
79 let transition = rememberInfiniteTransition("circular_progress_indicator");
80 let rotation = transition.animateFloat(
81 0.0,
82 360.0,
83 infiniteRepeatable(
84 AnimationSpec::linear(ROTATION_DURATION_MS),
85 RepeatMode::Restart,
86 StartOffset::default(),
87 ),
88 "circular_progress_rotation",
89 );
90 let sweep = transition.animateFloat(
91 MIN_SWEEP_DEGREES,
92 MAX_SWEEP_DEGREES,
93 infiniteRepeatable(
94 AnimationSpec::tween(SWEEP_DURATION_MS, Easing::EaseInOut),
95 RepeatMode::Reverse,
96 StartOffset::default(),
97 ),
98 "circular_progress_sweep",
99 );
100
101 let sized = modifier.size_points(CIRCULAR_INDICATOR_DIAMETER, CIRCULAR_INDICATOR_DIAMETER);
102 Canvas(sized, move |scope| {
103 let size = scope.size();
104 let start_angle = rotation.get() - 90.0;
107 let sweep_angle = sweep.get();
108 if let Some(data) = circular_arc_path_data(
109 size.width,
110 size.height,
111 stroke_width,
112 start_angle,
113 sweep_angle,
114 ) {
115 if let Ok(path) = VectorPath::parse(&data) {
116 scope.draw_vector_path(&path, Brush::solid(color));
117 }
118 }
119 })
120}
121
122#[composable]
134pub fn LinearProgressIndicator(modifier: Modifier, color: Color) -> NodeId {
135 let transition = rememberInfiniteTransition("linear_progress_indicator");
136 let phase = transition.animateFloat(
137 0.0,
138 1.0,
139 infiniteRepeatable(
140 AnimationSpec::tween(LINEAR_SLIDE_DURATION_MS, Easing::FastOutSlowInEasing),
141 RepeatMode::Restart,
142 StartOffset::default(),
143 ),
144 "linear_progress_phase",
145 );
146
147 let sized = modifier.size_points(LINEAR_INDICATOR_WIDTH, LINEAR_INDICATOR_HEIGHT);
148 Canvas(sized, move |scope| {
149 let size = scope.size();
150 let track = Color(color.0, color.1, color.2, color.3 * LINEAR_TRACK_ALPHA);
151 scope.draw_rect(Brush::solid(track));
152 if let Some((x, width)) = linear_indicator_band(size.width, phase.get()) {
153 scope.draw_rect_at(
154 Rect {
155 x,
156 y: 0.0,
157 width,
158 height: size.height,
159 },
160 Brush::solid(color),
161 );
162 }
163 })
164}
165
166pub(crate) fn circular_arc_path_data(
173 width: f32,
174 height: f32,
175 stroke_width: f32,
176 start_angle_deg: f32,
177 sweep_angle_deg: f32,
178) -> Option<String> {
179 let outer_r = width.min(height) * 0.5;
180 if outer_r <= 0.0 {
181 return None;
182 }
183 let sweep = sweep_angle_deg.clamp(0.0, 359.9);
186 if sweep <= 0.0 {
187 return None;
188 }
189 let stroke = stroke_width.clamp(0.1, outer_r);
190 let inner_r = (outer_r - stroke).max(0.0);
191 let cx = width * 0.5;
192 let cy = height * 0.5;
193 let a0 = start_angle_deg.to_radians();
194 let a1 = (start_angle_deg + sweep).to_radians();
195 let (ox0, oy0) = (cx + outer_r * a0.cos(), cy + outer_r * a0.sin());
196 let (ox1, oy1) = (cx + outer_r * a1.cos(), cy + outer_r * a1.sin());
197 let (ix0, iy0) = (cx + inner_r * a0.cos(), cy + inner_r * a0.sin());
198 let (ix1, iy1) = (cx + inner_r * a1.cos(), cy + inner_r * a1.sin());
199 let large_arc = if sweep > 180.0 { 1 } else { 0 };
200 Some(format!(
201 "M {ox0:.4} {oy0:.4} \
202 A {outer_r:.4} {outer_r:.4} 0 {large_arc} 1 {ox1:.4} {oy1:.4} \
203 L {ix1:.4} {iy1:.4} \
204 A {inner_r:.4} {inner_r:.4} 0 {large_arc} 0 {ix0:.4} {iy0:.4} Z"
205 ))
206}
207
208pub(crate) fn linear_indicator_band(width: f32, phase: f32) -> Option<(f32, f32)> {
214 if width <= 0.0 {
215 return None;
216 }
217 let band_width = width * LINEAR_BAND_FRACTION;
218 let x = phase * (width + band_width) - band_width;
219 let x0 = x.max(0.0);
220 let x1 = (x + band_width).min(width);
221 (x1 > x0).then_some((x0, x1 - x0))
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use cranpose_core::{location_key, Composition, DefaultScheduler, MemoryApplier, Runtime};
228 use std::sync::Arc;
229
230 fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
231 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
232 f()
233 }
234
235 #[test]
236 fn circular_arc_path_parses_and_stays_in_bounds() {
237 for rotation in [0.0_f32, 45.0, 90.0, 200.0, 355.0] {
238 for sweep in [MIN_SWEEP_DEGREES, 120.0, MAX_SWEEP_DEGREES] {
239 let data = circular_arc_path_data(
240 CIRCULAR_INDICATOR_DIAMETER,
241 CIRCULAR_INDICATOR_DIAMETER,
242 CIRCULAR_INDICATOR_STROKE_WIDTH,
243 rotation - 90.0,
244 sweep,
245 )
246 .expect("arc path data");
247 let path = VectorPath::parse(&data).expect("valid SVG arc path");
248 assert!(!path.is_empty(), "arc path must produce geometry");
249 let bounds = path.bounds();
250 let eps = 0.51; assert!(
252 bounds.x >= -eps
253 && bounds.y >= -eps
254 && bounds.x + bounds.width <= CIRCULAR_INDICATOR_DIAMETER + eps
255 && bounds.y + bounds.height <= CIRCULAR_INDICATOR_DIAMETER + eps,
256 "arc (rotation {rotation}, sweep {sweep}) escapes indicator bounds: {bounds:?}"
257 );
258 }
259 }
260 }
261
262 #[test]
263 fn circular_arc_path_rotates_with_angle() {
264 let at = |start: f32| {
265 circular_arc_path_data(20.0, 20.0, 2.0, start, 120.0).expect("arc path data")
266 };
267 assert_ne!(at(0.0), at(90.0), "rotation must move the arc");
268 }
269
270 #[test]
271 fn circular_arc_path_rejects_degenerate_input() {
272 assert!(circular_arc_path_data(0.0, 0.0, 2.0, 0.0, 120.0).is_none());
273 assert!(circular_arc_path_data(20.0, 20.0, 2.0, 0.0, 0.0).is_none());
274 }
275
276 #[test]
277 fn linear_band_stays_inside_track() {
278 let width = 200.0;
279 let mut seen_band = false;
280 for step in 0..=20 {
281 let phase = step as f32 / 20.0;
282 if let Some((x, band_width)) = linear_indicator_band(width, phase) {
283 seen_band = true;
284 assert!(x >= 0.0, "band start below 0 at phase {phase}");
285 assert!(
286 x + band_width <= width + 1e-3,
287 "band escapes track at phase {phase}"
288 );
289 assert!(band_width > 0.0);
290 }
291 }
292 assert!(seen_band, "band must be visible for mid phases");
293 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]
333 fn circular_progress_indicator_animates_transition() {
334 use crate::layout::MeasureLayoutOptions;
335 use crate::measure_layout_with_options;
336
337 let _app_context = crate::render_state::app_context_test_scope();
338 let mut composition = Composition::new(MemoryApplier::new());
339 composition
340 .render(location_key(file!(), line!(), column!()), || {
341 CircularProgressIndicator(
342 Modifier::empty(),
343 PROGRESS_INDICATOR_COLOR,
344 CIRCULAR_INDICATOR_STROKE_WIDTH,
345 );
346 })
347 .expect("initial render");
348
349 let root = composition.root().expect("composition root");
351 let handle = composition.runtime_handle();
352
353 fn collect_draw_commands(
354 node: &crate::LayoutBox,
355 out: &mut Vec<(crate::DrawCommand, crate::modifier::Size)>,
356 ) {
357 for command in node.node_data.modifier_slices().draw_commands() {
358 out.push((
359 command.clone(),
360 crate::modifier::Size {
361 width: node.rect.width,
362 height: node.rect.height,
363 },
364 ));
365 }
366 for child in &node.children {
367 collect_draw_commands(child, out);
368 }
369 }
370
371 let commands = {
372 let mut applier = composition.applier_mut();
373 applier.set_runtime_handle(handle.clone());
374 let measurements = measure_layout_with_options(
375 &mut applier,
376 root,
377 crate::Size::new(200.0, 200.0),
378 MeasureLayoutOptions {
379 collect_semantics: false,
380 build_layout_tree: true,
381 },
382 )
383 .expect("measure spinner layout");
384 applier.clear_runtime_handle();
385
386 let tree = measurements.layout_tree().expect("layout tree");
387 let mut commands = Vec::new();
388 collect_draw_commands(tree.root(), &mut commands);
389 commands
390 };
391 assert!(!commands.is_empty(), "spinner must register draw commands");
392
393 let run_commands = |commands: &[(crate::DrawCommand, crate::modifier::Size)]| {
394 commands
395 .iter()
396 .flat_map(|(command, size)| match command {
397 crate::DrawCommand::Behind(func) => func(*size),
398 crate::DrawCommand::Overlay(func) => func(*size),
399 crate::DrawCommand::WithContent(func) => func(*size),
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;
412 for _ in 0..30 {
413 time += 16_666_667;
414 handle.drain_frame_callbacks(time);
415 composition
416 .process_invalid_scopes()
417 .expect("process invalid scopes");
418 }
419
420 let after = run_commands(&commands);
421 assert_ne!(
422 before, after,
423 "spinner draw primitives must change as the transition animates"
424 );
425 }
426}