use std::sync::OnceLock;
use std::time::Duration;
use frust::authoring::{
BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, View, Widget,
};
use frust::{AnimationController, Curve, FrameTime, SpringDesc, Theme};
use kurbo::{Affine, BezPath, Point, Shape, Size};
use peniko::{Brush, Color};
use crate::shapes::{Morph, ShapeKind};
use crate::tokens::MaterialSpring;
const LOADING_DIAMETER: f64 = 48.0;
const ACTIVE_INDICATOR_SIZE: f64 = 38.0;
const PER_SHAPE_PERIOD: Duration = Duration::from_millis(650);
const GLOBAL_ROTATION_PERIOD: Duration = Duration::from_millis(4666);
const QUARTER_ROTATION_DEG: f64 = 90.0;
const FULL_ROTATION_DEG: f64 = 360.0;
const MORPH_INITIAL_VELOCITY: f64 = 5.0;
const LOADING_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
damping_ratio: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.damping_ratio,
};
const FALLBACK_FILL: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
const FALLBACK_PRIMARY_CONTAINER: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
const FALLBACK_ON_PRIMARY_CONTAINER: Color = Color::from_rgb8(0x21, 0x00, 0x5D);
const SHAPE_CYCLE_LEN: usize = 7;
const SHAPE_CYCLE: [ShapeKind; SHAPE_CYCLE_LEN] = [
ShapeKind::SoftBurst,
ShapeKind::Cookie9Sided,
ShapeKind::Pentagon,
ShapeKind::Pill,
ShapeKind::Sunny,
ShapeKind::Cookie4Sided,
ShapeKind::Oval,
];
fn morph_sequence() -> &'static [Morph; SHAPE_CYCLE_LEN] {
static CACHE: OnceLock<[Morph; SHAPE_CYCLE_LEN]> = OnceLock::new();
CACHE.get_or_init(|| {
std::array::from_fn(|i| {
let start = SHAPE_CYCLE[i].polygon().clone();
let end = SHAPE_CYCLE[(i + 1) % SHAPE_CYCLE_LEN].polygon().clone();
Morph::new(start, end)
})
})
}
fn shape_cycle_scale_factor() -> f64 {
static CACHE: OnceLock<f64> = OnceLock::new();
*CACHE.get_or_init(|| {
SHAPE_CYCLE.iter().fold(1.0_f64, |acc, kind| {
let polygon = kind.polygon();
let bounds = polygon.bounds();
let max_bounds = polygon.max_bounds();
let scale_x = bounds.width() / max_bounds.width();
let scale_y = bounds.height() / max_bounds.height();
acc.min(scale_x.max(scale_y))
})
})
}
fn process_path(path: &BezPath, size: Size, scale_factor: f64, rotation_rad: f64) -> BezPath {
let scale = Affine::new([
size.width * scale_factor,
0.0,
0.0,
size.height * scale_factor,
0.0,
0.0,
]);
let scaled = scale * path;
let bounds = scaled.bounding_box();
let center = Point::new(size.width / 2.0, size.height / 2.0);
let centered = Affine::translate(center - bounds.center()) * scaled;
Affine::rotate_about(rotation_rad, center) * centered
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LoadingIndicatorVariant {
Default,
Contained,
}
pub struct LoadingIndicatorView {
variant: LoadingIndicatorVariant,
}
pub fn loading_indicator() -> LoadingIndicatorView {
LoadingIndicatorView {
variant: LoadingIndicatorVariant::Default,
}
}
#[allow(non_snake_case)]
pub fn LoadingIndicator() -> LoadingIndicatorView {
loading_indicator()
}
impl LoadingIndicatorView {
pub fn contained(mut self) -> Self {
self.variant = LoadingIndicatorVariant::Contained;
self
}
}
impl<State: 'static> View<State> for LoadingIndicatorView {
type Element = LoadingIndicatorWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> LoadingIndicatorWidget {
let mut cycle_timer = AnimationController::new(PER_SHAPE_PERIOD).with_curve(Curve::Linear);
cycle_timer.repeat();
let mut rotation =
AnimationController::new(GLOBAL_ROTATION_PERIOD).with_curve(Curve::Linear);
rotation.repeat();
let mut widget = LoadingIndicatorWidget {
variant: self.variant,
cycle_timer,
last_cycle_phase: 0.0,
morph_index: 0,
morph: AnimationController::new(Duration::ZERO),
rotation,
rotation_target_deg: QUARTER_ROTATION_DEG,
};
widget.advance_cycle();
widget
}
fn rebuild(
&self,
prev: &Self,
element: &mut LoadingIndicatorWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
if prev.variant != self.variant {
element.variant = self.variant;
ChangeFlags::PAINT
} else {
ChangeFlags::NONE
}
}
}
pub struct LoadingIndicatorWidget {
variant: LoadingIndicatorVariant,
cycle_timer: AnimationController,
last_cycle_phase: f64,
morph_index: usize,
morph: AnimationController,
rotation: AnimationController,
rotation_target_deg: f64,
}
impl LoadingIndicatorWidget {
fn advance_cycle(&mut self) {
self.morph_index = (self.morph_index + 1) % SHAPE_CYCLE_LEN;
self.rotation_target_deg =
(self.rotation_target_deg + QUARTER_ROTATION_DEG) % FULL_ROTATION_DEG;
self.morph = AnimationController::new(Duration::ZERO);
self.morph.fling(MORPH_INITIAL_VELOCITY, LOADING_SPRING);
}
fn resolve_colors(&self, theme: Option<&Theme>) -> (Color, Option<Color>) {
let scheme = theme.map(Theme::scheme);
match self.variant {
LoadingIndicatorVariant::Default => {
let active = scheme.map(|s| s.primary).unwrap_or(FALLBACK_FILL);
(active, None)
}
LoadingIndicatorVariant::Contained => {
let active = scheme
.map(|s| s.on_primary_container)
.unwrap_or(FALLBACK_ON_PRIMARY_CONTAINER);
let container = scheme
.map(|s| s.primary_container)
.unwrap_or(FALLBACK_PRIMARY_CONTAINER);
(active, Some(container))
}
}
}
fn step(&mut self, now: FrameTime) {
self.cycle_timer.advance(now);
let phase = self.cycle_timer.value_clamped();
if phase < self.last_cycle_phase {
self.advance_cycle();
}
self.last_cycle_phase = phase;
self.morph.advance(now);
self.rotation.advance(now);
}
}
impl Widget for LoadingIndicatorWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(Size::new(LOADING_DIAMETER, LOADING_DIAMETER))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
let (active_color, container_color) = self.resolve_colors(theme);
let size = ctx.size();
if !reduce_motion {
self.step(ctx.frame_time());
}
if let Some(container_color) = container_color {
let radius = size.width.min(size.height) / 2.0;
scene.fill_rounded_rect(ctx.origin(), size, radius, container_color);
}
let morph_progress = self.morph.value_clamped();
let global_rotation_deg = self.rotation.value() * FULL_ROTATION_DEG;
let total_rotation_deg =
morph_progress * QUARTER_ROTATION_DEG + self.rotation_target_deg + global_rotation_deg;
let morph = &morph_sequence()[self.morph_index];
let raw_path = morph.path(morph_progress);
let scale_factor =
shape_cycle_scale_factor() * (ACTIVE_INDICATOR_SIZE / size.width.min(size.height));
let path = process_path(
&raw_path,
size,
scale_factor,
total_rotation_deg.to_radians(),
);
scene.fill_path(ctx.origin(), &path, &Brush::Solid(active_color));
if !reduce_motion {
ctx.request_frame_paced();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust::FrameTime;
use kurbo::PathEl;
fn build() -> LoadingIndicatorWidget {
let view = loading_indicator();
let mut counter = 0u64;
<LoadingIndicatorView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
}
fn build_contained() -> LoadingIndicatorWidget {
let view = loading_indicator().contained();
let mut counter = 0u64;
<LoadingIndicatorView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
}
#[derive(Default)]
struct RecordingScene {
fills: Vec<BezPath>,
rounded_rects: Vec<(Size, f64, Color)>,
}
impl PaintScene for RecordingScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_path(&mut self, _origin: Point, path: &BezPath, _brush: &Brush) {
self.fills.push(path.clone());
}
fn fill_rounded_rect(&mut self, _o: Point, s: Size, radius: f64, color: Color) {
self.rounded_rects.push((s, radius, color));
}
}
fn ft_secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
#[test]
fn shape_cycle_matches_the_reference_default_polygons() {
assert_eq!(
SHAPE_CYCLE,
[
ShapeKind::SoftBurst,
ShapeKind::Cookie9Sided,
ShapeKind::Pentagon,
ShapeKind::Pill,
ShapeKind::Sunny,
ShapeKind::Cookie4Sided,
ShapeKind::Oval,
]
);
}
#[test]
fn timing_constants_match_the_reference() {
assert_eq!(PER_SHAPE_PERIOD, Duration::from_millis(650)); assert_eq!(GLOBAL_ROTATION_PERIOD, Duration::from_millis(4666)); assert_eq!(QUARTER_ROTATION_DEG, 90.0); assert_eq!(FULL_ROTATION_DEG, 360.0); assert_eq!(ACTIVE_INDICATOR_SIZE, 38.0); assert_eq!(LOADING_DIAMETER, 48.0); }
#[test]
fn morph_initial_velocity_matches_the_reference() {
assert_eq!(MORPH_INITIAL_VELOCITY, 5.0);
}
#[test]
fn morph_spring_matches_expressive_spatial_default() {
assert_eq!(LOADING_SPRING.mass, 1.0);
assert_eq!(LOADING_SPRING.stiffness, 380.0);
assert_eq!(LOADING_SPRING.damping_ratio, 0.8);
assert_eq!(
LOADING_SPRING.stiffness,
MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness
);
assert_eq!(
LOADING_SPRING.damping_ratio,
MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.damping_ratio
);
}
#[test]
fn morph_sequence_is_built_once_and_wraps_the_cycle() {
let a = morph_sequence();
let b = morph_sequence();
assert!(std::ptr::eq(a, b), "the shared cache must not rebuild");
assert_eq!(a.len(), SHAPE_CYCLE_LEN);
assert_eq!(*a[SHAPE_CYCLE_LEN - 1].end(), *SHAPE_CYCLE[0].polygon());
assert_eq!(*a[0].start(), *SHAPE_CYCLE[0].polygon());
}
#[test]
fn paints_a_filled_path_and_keeps_requesting_frames() {
let mut w = build();
for _ in 0..5 {
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LOADING_DIAMETER, LOADING_DIAMETER));
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert_eq!(scene.fills.len(), 1, "paints exactly one morphing shape");
assert!(
scene.rounded_rects.is_empty(),
"the default variant paints no container background"
);
assert!(matches!(
scene.fills[0].elements().last(),
Some(PathEl::ClosePath)
));
assert!(ctx.needs_frame(), "a loop must keep requesting frames");
assert!(
ctx.needs_frame_paced_only(),
"the morph loop is a CosmeticLoop request — the frame gate must be able to pace it"
);
}
}
#[test]
fn painted_shape_stays_within_the_container_box_and_is_centered() {
let mut w = build();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LOADING_DIAMETER, LOADING_DIAMETER));
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
let bounds = scene.fills[0].bounding_box();
assert!(bounds.x0 >= -1.0 && bounds.y0 >= -1.0, "{bounds:?}");
assert!(
bounds.x1 <= LOADING_DIAMETER + 1.0 && bounds.y1 <= LOADING_DIAMETER + 1.0,
"{bounds:?}"
);
let center = bounds.center();
assert!((center.x - LOADING_DIAMETER / 2.0).abs() < 1.0);
assert!((center.y - LOADING_DIAMETER / 2.0).abs() < 1.0);
}
#[test]
fn reduce_motion_freezes_the_morph_and_stops_requesting_frames() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let mut w = build();
let frozen_index = w.morph_index;
for _ in 0..3 {
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LOADING_DIAMETER, LOADING_DIAMETER))
.with_theme(&theme);
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert_eq!(scene.fills.len(), 1, "still paints a frozen shape");
assert!(
!ctx.needs_frame(),
"reduce_motion must not request a continuation frame"
);
}
assert_eq!(
w.morph_index, frozen_index,
"reduce_motion never advances the morph cycle"
);
}
#[test]
fn contained_variant_paints_a_rounded_container_behind_the_shape() {
let mut w = build_contained();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LOADING_DIAMETER, LOADING_DIAMETER));
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert_eq!(scene.fills.len(), 1, "still paints the morphing shape");
assert_eq!(
scene.rounded_rects.len(),
1,
"paints one container behind it"
);
let (size, radius, color) = scene.rounded_rects[0];
assert_eq!(size, Size::new(LOADING_DIAMETER, LOADING_DIAMETER));
assert_eq!(
radius,
LOADING_DIAMETER / 2.0,
"fully rounded (999-radius spec)"
);
assert_eq!(color, FALLBACK_PRIMARY_CONTAINER);
}
#[test]
fn initial_build_starts_one_cycle_ahead_matching_the_reference() {
let w = build();
assert_eq!(w.morph_index, 1);
assert_eq!(w.rotation_target_deg, 180.0);
assert!(
w.morph.is_animating(),
"the initial cycle already flung the morph spring"
);
}
#[test]
fn cycle_wraps_after_one_period_and_advances_index_and_rotation_target() {
let mut w = build();
assert_eq!(w.morph_index, 1);
w.step(ft_secs(0.0)); w.step(ft_secs(0.6));
assert_eq!(w.morph_index, 1);
assert_eq!(w.rotation_target_deg, 180.0);
w.step(ft_secs(0.7));
assert_eq!(w.morph_index, 2);
assert_eq!(w.rotation_target_deg, 270.0);
}
#[test]
fn morph_index_wraps_around_the_shape_cycle() {
let mut w = build();
w.morph_index = SHAPE_CYCLE_LEN - 1;
w.rotation_target_deg = 360.0 - QUARTER_ROTATION_DEG;
w.step(ft_secs(0.0)); w.step(ft_secs(0.6));
w.step(ft_secs(0.7)); assert_eq!(w.morph_index, 0);
assert_eq!(w.rotation_target_deg, 0.0);
assert!(w.morph_index < SHAPE_CYCLE_LEN);
}
}