Skip to main content

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}