frust_material/loading_indicator.rs
1//! The Material 3 Expressive loading indicator: a continuously-morphing,
2//! continuously-rotating sequence of seven Material shapes, driven by the
3//! crate's [`Morph`] feature-point engine.
4//!
5//! # Attribution
6//!
7//! Ported from `paadevelopments/material_3_expressive` v1.0.8 (MIT, © 2026
8//! Paa Developments), `lib/components/loading_indicator/` — whose
9//! `components/m3e_expressive_loading_indicator.dart` is itself, per that
10//! file's own header, vendored **verbatim** from the `loading_indicator_m3e`
11//! package, a Dart port of AndroidX Compose Material3's
12//! `LoadingIndicator.kt`
13//! (`androidx/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt`,
14//! © 2024 The Android Open Source Project, Apache License 2.0). The shape
15//! sequence, timing constants, spring, and rotation choreography below are
16//! transcribed from that Apache-2.0-licensed logic, retargeted onto this
17//! crate's own `shapes::Morph`/`RoundedPolygon` engine rather than Flutter's.
18//! The container variant and its theme defaults
19//! (`lib/components/loading_indicator/{m3e_loading_indicator,styles/m3e_loading_indicator_theme}.dart`)
20//! are the MIT-licensed `material_3_expressive` wrapper around it. See
21//! `plugins/material/NOTICE`'s Apache License, Version 2.0 — The Android
22//! Open Source Project section (this module activates its
23//! previously-`planned` loading-indicator entry).
24//!
25//! # Shape sequence
26//!
27//! [`SHAPE_CYCLE`] is upstream's `_defaultPolygons`
28//! (`m3e_expressive_loading_indicator.dart:57-65`): `softBurst`,
29//! `cookie9Sided`, `pentagon`, `pill`, `sunny`, `cookie4Sided`, `oval`, each
30//! resolved through this crate's 35-shape [`ShapeKind`] catalog. [`morph_sequence`]
31//! builds the circular run of [`Morph`]s between consecutive shapes
32//! (`_createMorphSequence(polygons, circularSequence: true)`,
33//! `m3e_expressive_loading_indicator.dart:195-211`) once and shares it across
34//! every indicator instance — building a `Morph` runs the expensive
35//! corner-measure/match pipeline (see [`Morph`]'s own cost note).
36//!
37//! # Choreography
38//!
39//! Two independent clocks drive the paint, matching
40//! `m3e_expressive_loading_indicator.dart:96-166`'s `build`:
41//!
42//! - **Per-cycle morph.** Every [`PER_SHAPE_PERIOD`] (650ms — upstream's
43//! `_morphIntervalMs`), the indicator advances to the next morph in
44//! [`morph_sequence`] and restarts a spring-driven progress from `0` toward
45//! `1` — [`LOADING_SPRING`] (`M3EMotion.expressiveSpatialDefault`,
46//! `m3e_motion.dart:104-107`, promoted to this crate's shared
47//! [`MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`] token) released with
48//! [`MORPH_INITIAL_VELOCITY`] (`SpringSimulation(spring, 0, 1, 5,
49//! snapToEnd: true)`, `m3e_expressive_loading_indicator.dart:85-91`).
50//! - **Continuous background rotation.** A separate `0.0..1.0` loop repeats
51//! every [`GLOBAL_ROTATION_PERIOD`] (4666ms — upstream's
52//! `_globalRotationDurationMs`), contributing up to a full turn.
53//!
54//! The paint reads both, plus a per-cycle accumulator
55//! (`rotation_target_deg`, upstream's `_morphRotationTargetAngle`) that steps
56//! by [`QUARTER_ROTATION_DEG`] (90°) modulo [`FULL_ROTATION_DEG`] every
57//! cycle, and sums them exactly as upstream's `totalRotationDegrees` does —
58//! so the shape visibly spins a quarter-turn per morph on top of the slow
59//! continuous background spin.
60//!
61//! # Initial cycle
62//!
63//! Upstream's `initState` calls `_startMorphCycle()` once, synchronously,
64//! before the first frame ever paints (`m3e_expressive_loading_indicator.dart:192,259`).
65//! That call already advances `_currentMorphIndex` from `0` to `1` and
66//! `_morphRotationTargetAngle` from `90°` to `180°` — so the very first shape
67//! ever shown is [`SHAPE_CYCLE`]`[1]` (`cookie9Sided`), not `[0]`
68//! (`softBurst`); `[0]` only reappears later in the loop. [`LoadingIndicatorWidget::advance_cycle`]
69//! replays this by running once at the end of `build`, matching the quirk
70//! exactly rather than "fixing" it.
71//!
72//! # Geometry
73//!
74//! Every catalog shape is normalized into `(0,0)..(1,1)`
75//! ([`crate::shapes::RoundedPolygon::normalized`]), so a raw [`Morph::path`] output needs
76//! scaling and centering before it paints. [`shape_cycle_scale_factor`] is
77//! upstream's `_calculateScaleFactor`
78//! (`m3e_expressive_loading_indicator.dart:213-247`): since the shape
79//! rotates, sizing it to fit only its own (unrotated) bounds would clip a
80//! corner as it turns, so the scale is instead derived from the *worst-case*
81//! ratio between each shape's axis-aligned bounds and its maximum
82//! any-rotation bounds ([`crate::shapes::RoundedPolygon::max_bounds`]) across the whole
83//! cycle. [`process_path`] applies that scale (further scaled by
84//! [`ACTIVE_INDICATOR_SIZE`] against the container box — upstream's
85//! `activeIndicatorScale`), centers the result, and rotates it about that
86//! same center — the upstream `_MorphPainter._processPath` scale+center
87//! composed with the enclosing `Transform.rotate`
88//! (`m3e_expressive_loading_indicator.dart:144-159`, `319-340`).
89//!
90//! # Variants
91//!
92//! [`LoadingIndicatorVariant::Default`] paints only the floating shape
93//! (`containerColorDefault`, fully transparent — no background is drawn at
94//! all). [`LoadingIndicatorVariant::Contained`] additionally fills a
95//! fully-rounded container the same size as the indicator box behind it,
96//! `colors.primary_container`/`colors.on_primary_container` in place of
97//! `colors.primary` — upstream's `M3ELoadingIndicatorTheme`
98//! (`m3e_loading_indicator_theme.dart`). The container's radius (`999`,
99//! i.e. always fully rounded at this box size) and its zero default padding
100//! mean the indicator's own geometry is unchanged between variants.
101//!
102//! # Dropped frames
103//!
104//! The per-cycle boundary is detected the same way the pre-rework module
105//! detected it: a linear `0.0..1.0` repeat whose *wrap* (this frame's phase <
106//! last frame's) triggers [`LoadingIndicatorWidget::advance_cycle`], rather
107//! than a counted timer callback. A dropped frame longer than one full period
108//! therefore skips a cycle visually rather than catching every intermediate
109//! one — acceptable for a decorative loading spinner, and it always
110//! self-corrects on the next frame.
111
112use std::sync::OnceLock;
113use std::time::Duration;
114
115use frust::authoring::{
116 BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, View, Widget,
117};
118use frust::{AnimationController, Curve, FrameTime, SpringDesc, Theme};
119use kurbo::{Affine, BezPath, Point, Shape, Size};
120use peniko::{Brush, Color};
121
122use crate::shapes::{Morph, ShapeKind};
123use crate::tokens::MaterialSpring;
124
125/// Overall diameter of the indicator/container, in logical px — upstream's
126/// `M3ELoadingIndicatorTheme.containerWidth`/`containerHeight` defaults
127/// (`m3e_loading_indicator_theme.dart:12-13`).
128const LOADING_DIAMETER: f64 = 48.0;
129
130/// Upstream's `_activeSize` — the shapes' target diameter within the
131/// container box, "based on source spec"
132/// (`m3e_expressive_loading_indicator.dart:74`).
133const ACTIVE_INDICATOR_SIZE: f64 = 38.0;
134
135/// Upstream's `_morphIntervalMs` — how long the indicator dwells on/morphs
136/// through each shape pair before advancing to the next
137/// (`m3e_expressive_loading_indicator.dart:70`).
138const PER_SHAPE_PERIOD: Duration = Duration::from_millis(650);
139
140/// Upstream's `_globalRotationDurationMs` — the period of the continuous
141/// background rotation layered under the per-cycle morph rotation
142/// (`m3e_expressive_loading_indicator.dart:69`).
143const GLOBAL_ROTATION_PERIOD: Duration = Duration::from_millis(4666);
144
145/// Upstream's `_quarterRotation` (`_fullRotation / 4`) — the rotation, in
146/// degrees, a single morph cycle contributes: both the in-flight morph's own
147/// sweep and the fixed step `_morphRotationTargetAngle` accumulates every
148/// cycle (`m3e_expressive_loading_indicator.dart:71,73`).
149const QUARTER_ROTATION_DEG: f64 = 90.0;
150
151/// Upstream's `_fullRotation` (`m3e_expressive_loading_indicator.dart:71`).
152const FULL_ROTATION_DEG: f64 = 360.0;
153
154/// The morph spring's initial release velocity — upstream's
155/// `SpringSimulation(spring, 0, 1, 5, snapToEnd: true)`
156/// (`m3e_expressive_loading_indicator.dart:85-91`).
157const MORPH_INITIAL_VELOCITY: f64 = 5.0;
158
159/// The per-morph spring — `M3EMotion.expressiveSpatialDefault` (stiffness
160/// `380`, damping ratio `0.8` — `m3e_motion.dart:104-107`), the same preset
161/// [`MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`] promotes to a shared crate
162/// token; `m3e_expressive_loading_indicator.dart:85-91` names it directly.
163/// Mass `1.0` (every M3 spring preset is mass-1; see `frust::MotionSpring`).
164const LOADING_SPRING: SpringDesc = SpringDesc {
165 mass: 1.0,
166 stiffness: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness,
167 damping_ratio: MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.damping_ratio,
168};
169
170/// Unthemed indicator fill fallback for [`LoadingIndicatorVariant::Default`]
171/// (shares [`super::progress`]'s unthemed primary; a theme resolves this
172/// from `colors.primary`).
173const FALLBACK_FILL: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
174
175/// Unthemed fallback for `colors.primary_container` — the
176/// [`LoadingIndicatorVariant::Contained`] background (the baseline
177/// light-scheme literal, `tokens/color.rs`).
178const FALLBACK_PRIMARY_CONTAINER: Color = Color::from_rgb8(0xEA, 0xDD, 0xFF);
179
180/// Unthemed fallback for `colors.on_primary_container` — the
181/// [`LoadingIndicatorVariant::Contained`] shape fill (the baseline
182/// light-scheme literal, `tokens/color.rs`).
183const FALLBACK_ON_PRIMARY_CONTAINER: Color = Color::from_rgb8(0x21, 0x00, 0x5D);
184
185/// The number of shapes the indicator cycles through — upstream's
186/// `_defaultPolygons.length` (`m3e_expressive_loading_indicator.dart:57-65`).
187const SHAPE_CYCLE_LEN: usize = 7;
188
189/// The default shape sequence — see the [module docs](self)'s Shape sequence
190/// section.
191const SHAPE_CYCLE: [ShapeKind; SHAPE_CYCLE_LEN] = [
192 ShapeKind::SoftBurst,
193 ShapeKind::Cookie9Sided,
194 ShapeKind::Pentagon,
195 ShapeKind::Pill,
196 ShapeKind::Sunny,
197 ShapeKind::Cookie4Sided,
198 ShapeKind::Oval,
199];
200
201/// The circular morph sequence over [`SHAPE_CYCLE`] — see the
202/// [module docs](self)'s Shape sequence section. Built once and shared by
203/// every [`LoadingIndicatorWidget`] instance.
204fn morph_sequence() -> &'static [Morph; SHAPE_CYCLE_LEN] {
205 static CACHE: OnceLock<[Morph; SHAPE_CYCLE_LEN]> = OnceLock::new();
206 CACHE.get_or_init(|| {
207 std::array::from_fn(|i| {
208 let start = SHAPE_CYCLE[i].polygon().clone();
209 let end = SHAPE_CYCLE[(i + 1) % SHAPE_CYCLE_LEN].polygon().clone();
210 Morph::new(start, end)
211 })
212 })
213}
214
215/// The uniform scale applied to every morph's normalized outline so the
216/// shape never clips its container while it rotates — see the
217/// [module docs](self)'s Geometry section.
218fn shape_cycle_scale_factor() -> f64 {
219 static CACHE: OnceLock<f64> = OnceLock::new();
220 *CACHE.get_or_init(|| {
221 SHAPE_CYCLE.iter().fold(1.0_f64, |acc, kind| {
222 let polygon = kind.polygon();
223 let bounds = polygon.bounds();
224 let max_bounds = polygon.max_bounds();
225 let scale_x = bounds.width() / max_bounds.width();
226 let scale_y = bounds.height() / max_bounds.height();
227 acc.min(scale_x.max(scale_y))
228 })
229 })
230}
231
232/// Scales `path` (a [`Morph::path`] output, normalized into `(0,0)..(1,1)`)
233/// by `size * scale_factor`, centers its bounds in `size`, and rotates it
234/// about that same center by `rotation_rad` — see the [module docs](self)'s
235/// Geometry section.
236fn process_path(path: &BezPath, size: Size, scale_factor: f64, rotation_rad: f64) -> BezPath {
237 let scale = Affine::new([
238 size.width * scale_factor,
239 0.0,
240 0.0,
241 size.height * scale_factor,
242 0.0,
243 0.0,
244 ]);
245 let scaled = scale * path;
246 let bounds = scaled.bounding_box();
247 let center = Point::new(size.width / 2.0, size.height / 2.0);
248 let centered = Affine::translate(center - bounds.center()) * scaled;
249 Affine::rotate_about(rotation_rad, center) * centered
250}
251
252/// The indicator's visual variant — upstream's `M3ELoadingIndicatorVariant`
253/// (`m3e_loading_indicator_variant.dart`).
254#[derive(Clone, Copy, Debug, PartialEq, Eq)]
255pub enum LoadingIndicatorVariant {
256 /// A floating morphing shape drawn directly on the surface — no
257 /// container background is painted (upstream's `containerColorDefault`,
258 /// fully transparent).
259 Default,
260 /// The morphing shape inside a filled, fully-rounded container —
261 /// `colors.primary_container` background, `colors.on_primary_container`
262 /// shape.
263 Contained,
264}
265
266/// A Material 3 Expressive loading indicator. See the [module docs](self).
267pub struct LoadingIndicatorView {
268 variant: LoadingIndicatorVariant,
269}
270
271/// Create a loading indicator ([`LoadingIndicatorVariant::Default`]).
272pub fn loading_indicator() -> LoadingIndicatorView {
273 LoadingIndicatorView {
274 variant: LoadingIndicatorVariant::Default,
275 }
276}
277
278/// PascalCase alias for [`loading_indicator`], matching the widget-fn
279/// vocabulary (`Button`/`Image`/…).
280#[allow(non_snake_case)]
281pub fn LoadingIndicator() -> LoadingIndicatorView {
282 loading_indicator()
283}
284
285impl LoadingIndicatorView {
286 /// Switch to the [`LoadingIndicatorVariant::Contained`] variant: the
287 /// shape paints inside a filled, fully-rounded container. See the
288 /// [module docs](self)'s Variants section.
289 pub fn contained(mut self) -> Self {
290 self.variant = LoadingIndicatorVariant::Contained;
291 self
292 }
293}
294
295impl<State: 'static> View<State> for LoadingIndicatorView {
296 type Element = LoadingIndicatorWidget;
297
298 fn build(&self, _ctx: &mut BuildCtx<'_>) -> LoadingIndicatorWidget {
299 let mut cycle_timer = AnimationController::new(PER_SHAPE_PERIOD).with_curve(Curve::Linear);
300 cycle_timer.repeat();
301 let mut rotation =
302 AnimationController::new(GLOBAL_ROTATION_PERIOD).with_curve(Curve::Linear);
303 rotation.repeat();
304
305 let mut widget = LoadingIndicatorWidget {
306 variant: self.variant,
307 cycle_timer,
308 last_cycle_phase: 0.0,
309 morph_index: 0,
310 morph: AnimationController::new(Duration::ZERO),
311 rotation,
312 rotation_target_deg: QUARTER_ROTATION_DEG,
313 };
314 // Upstream's `initState` calls `_startMorphCycle()` once, before the
315 // first frame — see the module docs' Initial cycle section.
316 widget.advance_cycle();
317 widget
318 }
319
320 fn rebuild(
321 &self,
322 prev: &Self,
323 element: &mut LoadingIndicatorWidget,
324 _ctx: &mut BuildCtx<'_>,
325 ) -> ChangeFlags {
326 if prev.variant != self.variant {
327 element.variant = self.variant;
328 ChangeFlags::PAINT
329 } else {
330 ChangeFlags::NONE
331 }
332 }
333}
334
335/// The retained widget for a [`LoadingIndicatorView`]. See the [module docs](self).
336pub struct LoadingIndicatorWidget {
337 variant: LoadingIndicatorVariant,
338 /// Linear 650ms-period repeat marking every shape-cycle boundary — the
339 /// same wrap-detection idiom the pre-rework module used
340 /// ([`Self::step`]), now advancing a [`Morph`] sequence instead of a raw
341 /// radial interpolation.
342 cycle_timer: AnimationController,
343 /// Last frame's [`Self::cycle_timer`] phase, to detect a wrap.
344 last_cycle_phase: f64,
345 /// Index into [`morph_sequence`] of the morph currently in progress.
346 morph_index: usize,
347 /// The current morph's spring-driven progress — reset and re-flung every
348 /// cycle (upstream's `_morphController`).
349 morph: AnimationController,
350 /// Continuous background rotation, one full turn per
351 /// [`GLOBAL_ROTATION_PERIOD`] (upstream's `_globalRotationController`).
352 rotation: AnimationController,
353 /// Accumulated per-cycle rotation target, degrees (upstream's
354 /// `_morphRotationTargetAngle`), advancing by [`QUARTER_ROTATION_DEG`]
355 /// modulo [`FULL_ROTATION_DEG`] every cycle.
356 rotation_target_deg: f64,
357}
358
359impl LoadingIndicatorWidget {
360 /// Advances to the next morph in the cycle: bumps [`Self::morph_index`]
361 /// and [`Self::rotation_target_deg`], and restarts [`Self::morph`] with a
362 /// fresh spring fling — upstream's `_startMorphCycle`
363 /// (`m3e_expressive_loading_indicator.dart:262-278`).
364 fn advance_cycle(&mut self) {
365 self.morph_index = (self.morph_index + 1) % SHAPE_CYCLE_LEN;
366 self.rotation_target_deg =
367 (self.rotation_target_deg + QUARTER_ROTATION_DEG) % FULL_ROTATION_DEG;
368 self.morph = AnimationController::new(Duration::ZERO);
369 self.morph.fling(MORPH_INITIAL_VELOCITY, LOADING_SPRING);
370 }
371
372 /// The indicator's active shape color and (for
373 /// [`LoadingIndicatorVariant::Contained`]) its container background —
374 /// upstream's `resolveActiveColor`/`resolveContainerColor`
375 /// (`m3e_loading_indicator_theme.dart:53-75`). `None` for the default
376 /// variant's fully-transparent `containerColorDefault`.
377 fn resolve_colors(&self, theme: Option<&Theme>) -> (Color, Option<Color>) {
378 let scheme = theme.map(Theme::scheme);
379 match self.variant {
380 LoadingIndicatorVariant::Default => {
381 let active = scheme.map(|s| s.primary).unwrap_or(FALLBACK_FILL);
382 (active, None)
383 }
384 LoadingIndicatorVariant::Contained => {
385 let active = scheme
386 .map(|s| s.on_primary_container)
387 .unwrap_or(FALLBACK_ON_PRIMARY_CONTAINER);
388 let container = scheme
389 .map(|s| s.primary_container)
390 .unwrap_or(FALLBACK_PRIMARY_CONTAINER);
391 (active, Some(container))
392 }
393 }
394 }
395
396 /// Advance every clock to frame time `now` and, on a
397 /// [`Self::cycle_timer`] wrap, start the next morph cycle (see the
398 /// [module docs](self)'s Dropped frames section). Split out of `paint`
399 /// so the wrap logic is drivable in a unit test without threading a
400 /// shell clock through `PaintCtx` (whose frame-time setter is
401 /// crate-private to `frust-core`).
402 fn step(&mut self, now: FrameTime) {
403 self.cycle_timer.advance(now);
404 let phase = self.cycle_timer.value_clamped();
405 if phase < self.last_cycle_phase {
406 self.advance_cycle();
407 }
408 self.last_cycle_phase = phase;
409 self.morph.advance(now);
410 self.rotation.advance(now);
411 }
412}
413
414impl Widget for LoadingIndicatorWidget {
415 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
416 bc.constrain(Size::new(LOADING_DIAMETER, LOADING_DIAMETER))
417 }
418
419 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
420 let theme = Theme::from_paint_ctx(ctx);
421 let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
422 let (active_color, container_color) = self.resolve_colors(theme);
423 let size = ctx.size();
424
425 // `reduce_motion` freezes the morph/rotation wherever they currently
426 // sit and stops requesting frames — the same skip-animation shape
427 // `glyph::skeleton`'s shimmer uses.
428 if !reduce_motion {
429 self.step(ctx.frame_time());
430 }
431
432 if let Some(container_color) = container_color {
433 let radius = size.width.min(size.height) / 2.0;
434 scene.fill_rounded_rect(ctx.origin(), size, radius, container_color);
435 }
436
437 let morph_progress = self.morph.value_clamped();
438 let global_rotation_deg = self.rotation.value() * FULL_ROTATION_DEG;
439 let total_rotation_deg =
440 morph_progress * QUARTER_ROTATION_DEG + self.rotation_target_deg + global_rotation_deg;
441
442 let morph = &morph_sequence()[self.morph_index];
443 let raw_path = morph.path(morph_progress);
444 let scale_factor =
445 shape_cycle_scale_factor() * (ACTIVE_INDICATOR_SIZE / size.width.min(size.height));
446 let path = process_path(
447 &raw_path,
448 size,
449 scale_factor,
450 total_rotation_deg.to_radians(),
451 );
452
453 scene.fill_path(ctx.origin(), &path, &Brush::Solid(active_color));
454
455 if !reduce_motion {
456 // A perpetually looping morph is a decorative loop — its exact
457 // cadence is imperceptible, so the mobile frame gate may pace it.
458 ctx.request_frame_paced();
459 }
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use frust::FrameTime;
467 use kurbo::PathEl;
468
469 fn build() -> LoadingIndicatorWidget {
470 let view = loading_indicator();
471 let mut counter = 0u64;
472 <LoadingIndicatorView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
473 }
474
475 fn build_contained() -> LoadingIndicatorWidget {
476 let view = loading_indicator().contained();
477 let mut counter = 0u64;
478 <LoadingIndicatorView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
479 }
480
481 #[derive(Default)]
482 struct RecordingScene {
483 fills: Vec<BezPath>,
484 rounded_rects: Vec<(Size, f64, Color)>,
485 }
486
487 impl PaintScene for RecordingScene {
488 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
489 fn draw_text(&mut self, _o: Point, _t: &str) {}
490 fn fill_path(&mut self, _origin: Point, path: &BezPath, _brush: &Brush) {
491 self.fills.push(path.clone());
492 }
493 fn fill_rounded_rect(&mut self, _o: Point, s: Size, radius: f64, color: Color) {
494 self.rounded_rects.push((s, radius, color));
495 }
496 }
497
498 fn ft_secs(s: f64) -> FrameTime {
499 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
500 }
501
502 // -----------------------------------------------------------------------
503 // Constants transcribed from the reference (citations in each doc
504 // comment above).
505 // -----------------------------------------------------------------------
506
507 #[test]
508 fn shape_cycle_matches_the_reference_default_polygons() {
509 // m3e_expressive_loading_indicator.dart:57-65.
510 assert_eq!(
511 SHAPE_CYCLE,
512 [
513 ShapeKind::SoftBurst,
514 ShapeKind::Cookie9Sided,
515 ShapeKind::Pentagon,
516 ShapeKind::Pill,
517 ShapeKind::Sunny,
518 ShapeKind::Cookie4Sided,
519 ShapeKind::Oval,
520 ]
521 );
522 }
523
524 #[test]
525 fn timing_constants_match_the_reference() {
526 assert_eq!(PER_SHAPE_PERIOD, Duration::from_millis(650)); // :70
527 assert_eq!(GLOBAL_ROTATION_PERIOD, Duration::from_millis(4666)); // :69
528 assert_eq!(QUARTER_ROTATION_DEG, 90.0); // :71,73
529 assert_eq!(FULL_ROTATION_DEG, 360.0); // :71
530 assert_eq!(ACTIVE_INDICATOR_SIZE, 38.0); // :74
531 assert_eq!(LOADING_DIAMETER, 48.0); // m3e_loading_indicator_theme.dart:12-13
532 }
533
534 #[test]
535 fn morph_initial_velocity_matches_the_reference() {
536 // SpringSimulation(spring, 0, 1, 5, snapToEnd: true) —
537 // m3e_expressive_loading_indicator.dart:85-91.
538 assert_eq!(MORPH_INITIAL_VELOCITY, 5.0);
539 }
540
541 #[test]
542 fn morph_spring_matches_expressive_spatial_default() {
543 // m3e_motion.dart:104-107.
544 assert_eq!(LOADING_SPRING.mass, 1.0);
545 assert_eq!(LOADING_SPRING.stiffness, 380.0);
546 assert_eq!(LOADING_SPRING.damping_ratio, 0.8);
547 assert_eq!(
548 LOADING_SPRING.stiffness,
549 MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.stiffness
550 );
551 assert_eq!(
552 LOADING_SPRING.damping_ratio,
553 MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT.damping_ratio
554 );
555 }
556
557 // -----------------------------------------------------------------------
558 // Morph sequence
559 // -----------------------------------------------------------------------
560
561 #[test]
562 fn morph_sequence_is_built_once_and_wraps_the_cycle() {
563 let a = morph_sequence();
564 let b = morph_sequence();
565 assert!(std::ptr::eq(a, b), "the shared cache must not rebuild");
566 assert_eq!(a.len(), SHAPE_CYCLE_LEN);
567 // The last morph wraps back to the first shape (circularSequence:
568 // true).
569 assert_eq!(*a[SHAPE_CYCLE_LEN - 1].end(), *SHAPE_CYCLE[0].polygon());
570 assert_eq!(*a[0].start(), *SHAPE_CYCLE[0].polygon());
571 }
572
573 // -----------------------------------------------------------------------
574 // Paint
575 // -----------------------------------------------------------------------
576
577 #[test]
578 fn paints_a_filled_path_and_keeps_requesting_frames() {
579 let mut w = build();
580 for _ in 0..5 {
581 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LOADING_DIAMETER, LOADING_DIAMETER));
582 let mut scene = RecordingScene::default();
583 w.paint(&mut ctx, &mut scene);
584 assert_eq!(scene.fills.len(), 1, "paints exactly one morphing shape");
585 assert!(
586 scene.rounded_rects.is_empty(),
587 "the default variant paints no container background"
588 );
589 assert!(matches!(
590 scene.fills[0].elements().last(),
591 Some(PathEl::ClosePath)
592 ));
593 assert!(ctx.needs_frame(), "a loop must keep requesting frames");
594 assert!(
595 ctx.needs_frame_paced_only(),
596 "the morph loop is a CosmeticLoop request — the frame gate must be able to pace it"
597 );
598 }
599 }
600
601 #[test]
602 fn painted_shape_stays_within_the_container_box_and_is_centered() {
603 let mut w = build();
604 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LOADING_DIAMETER, LOADING_DIAMETER));
605 let mut scene = RecordingScene::default();
606 w.paint(&mut ctx, &mut scene);
607
608 let bounds = scene.fills[0].bounding_box();
609 assert!(bounds.x0 >= -1.0 && bounds.y0 >= -1.0, "{bounds:?}");
610 assert!(
611 bounds.x1 <= LOADING_DIAMETER + 1.0 && bounds.y1 <= LOADING_DIAMETER + 1.0,
612 "{bounds:?}"
613 );
614 let center = bounds.center();
615 assert!((center.x - LOADING_DIAMETER / 2.0).abs() < 1.0);
616 assert!((center.y - LOADING_DIAMETER / 2.0).abs() < 1.0);
617 }
618
619 #[test]
620 fn reduce_motion_freezes_the_morph_and_stops_requesting_frames() {
621 let mut theme = crate::baseline();
622 theme.motion.reduce_motion = true;
623 let mut w = build();
624 let frozen_index = w.morph_index;
625 for _ in 0..3 {
626 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LOADING_DIAMETER, LOADING_DIAMETER))
627 .with_theme(&theme);
628 let mut scene = RecordingScene::default();
629 w.paint(&mut ctx, &mut scene);
630 assert_eq!(scene.fills.len(), 1, "still paints a frozen shape");
631 assert!(
632 !ctx.needs_frame(),
633 "reduce_motion must not request a continuation frame"
634 );
635 }
636 assert_eq!(
637 w.morph_index, frozen_index,
638 "reduce_motion never advances the morph cycle"
639 );
640 }
641
642 #[test]
643 fn contained_variant_paints_a_rounded_container_behind_the_shape() {
644 let mut w = build_contained();
645 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LOADING_DIAMETER, LOADING_DIAMETER));
646 let mut scene = RecordingScene::default();
647 w.paint(&mut ctx, &mut scene);
648
649 assert_eq!(scene.fills.len(), 1, "still paints the morphing shape");
650 assert_eq!(
651 scene.rounded_rects.len(),
652 1,
653 "paints one container behind it"
654 );
655 let (size, radius, color) = scene.rounded_rects[0];
656 assert_eq!(size, Size::new(LOADING_DIAMETER, LOADING_DIAMETER));
657 assert_eq!(
658 radius,
659 LOADING_DIAMETER / 2.0,
660 "fully rounded (999-radius spec)"
661 );
662 assert_eq!(color, FALLBACK_PRIMARY_CONTAINER);
663 }
664
665 // -----------------------------------------------------------------------
666 // Cycle advance
667 // -----------------------------------------------------------------------
668
669 #[test]
670 fn initial_build_starts_one_cycle_ahead_matching_the_reference() {
671 // See the module docs' Initial cycle section:
672 // m3e_expressive_loading_indicator.dart:192,259,262-278.
673 let w = build();
674 assert_eq!(w.morph_index, 1);
675 assert_eq!(w.rotation_target_deg, 180.0);
676 assert!(
677 w.morph.is_animating(),
678 "the initial cycle already flung the morph spring"
679 );
680 }
681
682 #[test]
683 fn cycle_wraps_after_one_period_and_advances_index_and_rotation_target() {
684 let mut w = build();
685 assert_eq!(w.morph_index, 1);
686 w.step(ft_secs(0.0)); // seed the clock (zero delta)
687 // Just before one full 650ms period: still on the first cycle.
688 w.step(ft_secs(0.6));
689 assert_eq!(w.morph_index, 1);
690 assert_eq!(w.rotation_target_deg, 180.0);
691 // Past one full period: the phase wrapped, advancing to the next
692 // cycle.
693 w.step(ft_secs(0.7));
694 assert_eq!(w.morph_index, 2);
695 assert_eq!(w.rotation_target_deg, 270.0);
696 }
697
698 #[test]
699 fn morph_index_wraps_around_the_shape_cycle() {
700 let mut w = build();
701 w.morph_index = SHAPE_CYCLE_LEN - 1;
702 w.rotation_target_deg = 360.0 - QUARTER_ROTATION_DEG;
703 w.step(ft_secs(0.0)); // seed
704 w.step(ft_secs(0.6));
705 w.step(ft_secs(0.7)); // wrap from the last cycle -> back to 0
706 assert_eq!(w.morph_index, 0);
707 assert_eq!(w.rotation_target_deg, 0.0);
708 assert!(w.morph_index < SHAPE_CYCLE_LEN);
709 }
710}