use cranpose_animation::{
AnimationSpec, Easing, RepeatMode, StartOffset, infiniteRepeatable, rememberInfiniteTransition,
};
use cranpose_core::NodeId;
use cranpose_ui_graphics::{Brush, Color, Rect, VectorPath};
use crate::{composable, modifier::Modifier, widgets::Canvas};
pub const CIRCULAR_INDICATOR_DIAMETER: f32 = 20.0;
pub const CIRCULAR_INDICATOR_STROKE_WIDTH: f32 = 2.0;
pub const PROGRESS_INDICATOR_COLOR: Color = Color(0.101, 0.462, 0.909, 1.0);
pub const LINEAR_INDICATOR_WIDTH: f32 = 240.0;
pub const LINEAR_INDICATOR_HEIGHT: f32 = 4.0;
const ROTATION_DURATION_MS: u64 = 1332;
const SWEEP_DURATION_MS: u64 = 666;
const MIN_SWEEP_DEGREES: f32 = 30.0;
const MAX_SWEEP_DEGREES: f32 = 270.0;
const LINEAR_SLIDE_DURATION_MS: u64 = 1200;
const LINEAR_BAND_FRACTION: f32 = 0.4;
const LINEAR_TRACK_ALPHA: f32 = 0.24;
#[composable]
pub fn CircularProgressIndicator(modifier: Modifier, color: Color, stroke_width: f32) -> NodeId {
let transition = rememberInfiniteTransition("circular_progress_indicator");
let rotation = transition.animateFloat(
0.0,
360.0,
infiniteRepeatable(
AnimationSpec::linear(ROTATION_DURATION_MS),
RepeatMode::Restart,
StartOffset::default(),
),
"circular_progress_rotation",
);
let sweep = transition.animateFloat(
MIN_SWEEP_DEGREES,
MAX_SWEEP_DEGREES,
infiniteRepeatable(
AnimationSpec::tween(SWEEP_DURATION_MS, Easing::EaseInOut),
RepeatMode::Reverse,
StartOffset::default(),
),
"circular_progress_sweep",
);
let sized = modifier
.size_points(CIRCULAR_INDICATOR_DIAMETER, CIRCULAR_INDICATOR_DIAMETER)
.semantics(busy_semantics);
Canvas(sized, move |scope| {
let size = scope.size();
let start_angle = rotation.get() - 90.0;
let sweep_angle = sweep.get();
if let Some(data) = circular_arc_path_data(
size.width,
size.height,
stroke_width,
start_angle,
sweep_angle,
) {
if let Ok(path) = VectorPath::parse(&data) {
scope.draw_vector_path(&path, Brush::solid(color));
}
}
})
}
#[composable]
pub fn LinearProgressIndicator(modifier: Modifier, color: Color) -> NodeId {
let transition = rememberInfiniteTransition("linear_progress_indicator");
let phase = transition.animateFloat(
0.0,
1.0,
infiniteRepeatable(
AnimationSpec::tween(LINEAR_SLIDE_DURATION_MS, Easing::FastOutSlowInEasing),
RepeatMode::Restart,
StartOffset::default(),
),
"linear_progress_phase",
);
let sized = modifier
.size_points(LINEAR_INDICATOR_WIDTH, LINEAR_INDICATOR_HEIGHT)
.semantics(busy_semantics);
Canvas(sized, move |scope| {
let size = scope.size();
let track = Color(color.0, color.1, color.2, color.3 * LINEAR_TRACK_ALPHA);
scope.draw_rect(Brush::solid(track));
if let Some((x, width)) = linear_indicator_band(size.width, phase.get()) {
scope.draw_rect_at(
Rect {
x,
y: 0.0,
width,
height: size.height,
},
Brush::solid(color),
);
}
})
}
pub(crate) fn circular_arc_path_data(
width: f32,
height: f32,
stroke_width: f32,
start_angle_deg: f32,
sweep_angle_deg: f32,
) -> Option<String> {
let outer_r = width.min(height) * 0.5;
if outer_r <= 0.0 {
return None;
}
let sweep = sweep_angle_deg.clamp(0.0, 359.9);
if sweep <= 0.0 {
return None;
}
let stroke = stroke_width.clamp(0.1, outer_r);
let inner_r = (outer_r - stroke).max(0.0);
let cx = width * 0.5;
let cy = height * 0.5;
let a0 = start_angle_deg.to_radians();
let a1 = (start_angle_deg + sweep).to_radians();
let (ox0, oy0) = (cx + outer_r * a0.cos(), cy + outer_r * a0.sin());
let (ox1, oy1) = (cx + outer_r * a1.cos(), cy + outer_r * a1.sin());
let (ix0, iy0) = (cx + inner_r * a0.cos(), cy + inner_r * a0.sin());
let (ix1, iy1) = (cx + inner_r * a1.cos(), cy + inner_r * a1.sin());
let large_arc = if sweep > 180.0 { 1 } else { 0 };
Some(format!(
"M {ox0:.4} {oy0:.4} \
A {outer_r:.4} {outer_r:.4} 0 {large_arc} 1 {ox1:.4} {oy1:.4} \
L {ix1:.4} {iy1:.4} \
A {inner_r:.4} {inner_r:.4} 0 {large_arc} 0 {ix0:.4} {iy0:.4} Z"
))
}
pub(crate) fn linear_indicator_band(width: f32, phase: f32) -> Option<(f32, f32)> {
if width <= 0.0 {
return None;
}
let band_width = width * LINEAR_BAND_FRACTION;
let x = phase * (width + band_width) - band_width;
let x0 = x.max(0.0);
let x1 = (x + band_width).min(width);
(x1 > x0).then_some((x0, x1 - x0))
}
#[cfg(test)]
#[path = "tests/progress_indicator_tests.rs"]
mod tests;
fn busy_semantics(config: &mut cranpose_foundation::SemanticsConfiguration) {
config.content_description = Some("Loading".into());
config.role = Some(cranpose_foundation::SemanticsWidgetRole::ProgressBar);
}
#[cfg(test)]
#[path = "tests/progress_indicator_busy_semantics_tests.rs"]
mod busy_semantics_tests;