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