Skip to main content

frust_material/progress/
mod.rs

1//! The Material 3 Expressive progress indicators: linear and circular, each
2//! flat or wavy, each determinate or indeterminate — the reference's four
3//! named constructors over two widgets.
4//!
5//! Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
6//! `lib/components/progress_indicators/` (retrieved 2026-08-19).
7//! Upstream: <https://github.com/paadevelopments/material_3_expressive>
8//!
9//! This module owns the `View`/`Widget` wiring, colour resolution, and every
10//! paint call; [`motion`](mod@self::motion) owns the five animation
11//! controllers and the curve tables that drive them, and
12//! [`linear`](mod@self::linear)/[`circular`](mod@self::circular) own the
13//! geometry those numbers feed.
14//!
15//! # Constructors: the reference's named-constructor split, kept
16//!
17//! `M3EProgressIndicator` ships four Dart named constructors over one widget
18//! class (`m3e_progress_indicators.dart:29-100`). All four are sibling
19//! functions here, so the mapping stays one-to-one:
20//!
21//! | Reference | Here |
22//! |---|---|
23//! | `M3EProgressIndicator.linear(...)` | [`linear_progress`] (alias [`LinearProgress`]) |
24//! | `M3EProgressIndicator.linearWavy(...)` | [`linear_wavy_progress`] (alias [`LinearWavyProgress`]) |
25//! | `M3EProgressIndicator.circular(...)` | [`circular_progress`] (alias [`CircularProgress`]) |
26//! | `M3EProgressIndicator.circularWavy(...)` | [`circular_wavy_progress`] (alias [`CircularWavyProgress`]) |
27//!
28//! Every knob a constructor only accepts when it is meaningful — `amplitude`/
29//! `amplitudeForProgress`/`wavelength`/`waveSpeed`/`trackStrokeWidth`/
30//! `gapSize` on the wavy ones, `stopSize` on `linearWavy` — is a builder
31//! method here instead, documented as inert where it has no use (the same
32//! shape [`crate::slider`]'s port takes, for the same reason: a
33//! constructor-gated builder would need a view type per variant).
34//! [`LinearProgressView::wavy`]/[`CircularProgressView::wavy`] flip a flat
35//! view to its wavy sibling, so `linear_progress(v).wavy()` and
36//! `linear_wavy_progress(v)` are the same thing.
37//!
38//! # Controlled, like every other value-carrying widget here
39//!
40//! Both views carry the app's [`ProgressValue`] every frame —
41//! `Determinate(f)` for a known `0.0..=1.0` fraction, `Indeterminate` for an
42//! unknown-duration loop — and neither ever writes its own value
43//! (`docs/CODE_STANDARDS.md`'s Interaction Semantics). There is no callback: a
44//! progress indicator has no user interaction to report.
45//!
46//! # Indeterminate motion is the reference choreography, transcribed
47//!
48//! Indeterminate progress is **not** a single sweeping segment. Upstream runs
49//! five independent animation controllers, and this port transcribes their
50//! durations, curves, and timing tables exactly
51//! ([`motion`](mod@self::motion) carries the per-constant citations):
52//!
53//! - **Linear** — one 1750 ms cycle drives *four* separately-delayed,
54//!   separately-eased fractions (`firstHead`/`firstTail`/`secondHead`/
55//!   `secondTail`, all on `Cubic(0.3, 0, 0.8, 0.15)`), painting **two**
56//!   traveling lines with track and gaps between and around them.
57//! - **Circular** — a 6000 ms global rotation (1080°) and a 4500 ms
58//!   additional rotation (360°) sum into the arc's start angle, while a
59//!   1300 ms controller ping-pongs the arc's own sweep between 10% and 87% of
60//!   the circle. The classic and wavy circular variants share all three.
61//!
62//! Every controller advances from the frame clock during paint
63//! (`docs/WIDGETS_CODE_STANDARDS.md`), never from stacked implicit animations
64//! and never from a wall clock.
65//!
66//! # Wavy variants
67//!
68//! A wavy indicator replaces the flat active track with a sine path: linear
69//! oscillates the track's centreline, circular oscillates the ring's radius.
70//! Both take their amplitude as a *factor* in `0..=1` scaled by the family's
71//! own peak offset (linear 3dp, circular 1.6dp), resolved in the reference's
72//! precedence order — an app-supplied curve of progress, then an app-supplied
73//! constant, then the default ramp, which flattens the wave entirely below
74//! `0.1` and at/above `0.95` progress so it never fights the round caps.
75//!
76//! The phase itself never gates on progress upstream (`_needsWavePhase` is
77//! `_isWavy` alone); the amplitude ramp is what stills the wave near either
78//! end. This port keeps that rule and adds one documented divergence: when the
79//! resolved amplitude is `0` the phase clock is not advanced and no frame is
80//! requested, since a flat line's phase is unobservable and a perpetual
81//! repaint for no visible motion is exactly what the paced-frame convention
82//! exists to avoid.
83//!
84//! **Reduce-motion**: every loop here is perpetual and decorative, so it asks
85//! for paced frames (`TickClass::CosmeticLoop`) and, under
86//! `MotionScheme::reduce_motion`, freezes wherever it sits and stops
87//! requesting frames — the crate convention, matching
88//! [`crate::slider`]/[`crate::loading_indicator`].
89//!
90//! # Round caps are load-bearing
91//!
92//! Every span here is a *stroke*, not a filled rect: the reference paints all
93//! of them with `StrokeCap.round`, and its gap arithmetic already budgets for
94//! the half-stroke each cap adds past its endpoint (`_visualGap` inflates the
95//! gap by a whole stroke; the wavy indeterminate arm insets by another half).
96//! `PaintScene::stroke_line`/`stroke_path` reach `kurbo::Stroke::new`, whose
97//! caps and joins are round by default — a backend that squared them would
98//! shorten every span by a half stroke and reopen the gaps this module
99//! deliberately closes.
100
101mod circular;
102mod linear;
103mod motion;
104
105use frust::Theme;
106use frust::authoring::scene::arc_path;
107use frust::authoring::{
108    BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, Role, SemanticsCtx,
109    View, Widget,
110};
111use kurbo::{Point, Size};
112use peniko::{Brush, Color};
113
114pub use self::motion::AmplitudeCurve;
115
116/// Unthemed active-indicator fallback (a theme resolves this from
117/// `colors.primary`, the reference's `activeColor`).
118const FALLBACK_ACTIVE: Color = Color::from_rgb8(0x3B, 0x82, 0xF6);
119/// Unthemed linear-track fallback — the M3 baseline light
120/// `surface_container_highest`, which is the role the reference's linear
121/// builder resolves its track from.
122const FALLBACK_LINEAR_TRACK: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
123/// Unthemed circular-track fallback — the M3 baseline light
124/// `secondary_container`, the role `M3ECircularProgressTheme.trackColor`
125/// resolves.
126const FALLBACK_CIRCULAR_TRACK: Color = Color::from_rgb8(0xE8, 0xDE, 0xF8);
127
128/// A progress indicator's controlled value (see the [module docs](self)).
129#[derive(Clone, Copy, Debug, PartialEq)]
130pub enum ProgressValue {
131    /// A known progress fraction. Clamped to `0.0..=1.0` wherever it is read
132    /// (layout/paint/semantics), so an out-of-range app value never panics or
133    /// paints outside the track.
134    Determinate(f64),
135    /// An unknown-duration, looping progress animation.
136    Indeterminate,
137}
138
139impl ProgressValue {
140    /// The clamped `0.0..=1.0` fraction, or `None` for
141    /// [`ProgressValue::Indeterminate`].
142    fn fraction(self) -> Option<f64> {
143        match self {
144            ProgressValue::Determinate(v) if v.is_nan() => Some(0.0),
145            ProgressValue::Determinate(v) => Some(v.clamp(0.0, 1.0)),
146            ProgressValue::Indeterminate => None,
147        }
148    }
149}
150
151/// Track-thickness variant of a **flat** linear indicator
152/// (`M3EProgressIndicatorSize`). Inert on every other variant: the wavy linear
153/// indicator sizes from its own stroke/amplitude, and the circular ones from
154/// their diameter.
155#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
156pub enum ProgressSize {
157    /// A 4dp track (`M3EProgressIndicatorSize.s`).
158    S,
159    /// An 8dp track (`M3EProgressIndicatorSize.m`), the default.
160    #[default]
161    M,
162}
163
164// -- Linear ---------------------------------------------------------------
165
166/// The diffable half of a linear indicator's props — everything a `rebuild`
167/// can compare by value. The amplitude curve is the one field a diff cannot
168/// reach and is adopted every pass instead.
169#[derive(Clone, Copy, Debug, PartialEq)]
170struct LinearProps {
171    value: ProgressValue,
172    wavy: bool,
173    size: ProgressSize,
174    color: Option<Color>,
175    track_color: Option<Color>,
176    stroke_width: Option<f64>,
177    track_stroke_width: Option<f64>,
178    gap_size: Option<f64>,
179    stop_size: Option<f64>,
180    amplitude: Option<f64>,
181    wavelength: Option<f64>,
182    wave_speed: Option<f64>,
183}
184
185impl LinearProps {
186    fn new(value: ProgressValue, wavy: bool) -> Self {
187        LinearProps {
188            value,
189            wavy,
190            size: ProgressSize::default(),
191            color: None,
192            track_color: None,
193            stroke_width: None,
194            track_stroke_width: None,
195            gap_size: None,
196            stop_size: None,
197            amplitude: None,
198            wavelength: None,
199            wave_speed: None,
200        }
201    }
202
203    fn fraction(&self) -> Option<f64> {
204        self.value.fraction()
205    }
206
207    /// Active-indicator stroke: the wavy variant's own token, or the flat
208    /// variant's track height (which upstream passes as its `strokeWidth`).
209    fn stroke(&self) -> f64 {
210        if self.wavy {
211            self.stroke_width.unwrap_or(linear::STROKE_WIDTH)
212        } else {
213            linear::flat_layout(self.size).track_height
214        }
215    }
216
217    /// Inactive-track stroke, resolved the same way as [`Self::stroke`].
218    fn track_stroke(&self) -> f64 {
219        if self.wavy {
220            self.track_stroke_width
221                .unwrap_or(linear::TRACK_STROKE_WIDTH)
222        } else {
223            linear::flat_layout(self.size).track_height
224        }
225    }
226
227    /// Wavelength in logical px, defaulting per determinacy the way
228    /// `_buildLinearWavy` does (40 determinate, 20 indeterminate).
229    fn wavelength(&self) -> f64 {
230        self.wavelength.unwrap_or(if self.fraction().is_none() {
231            linear::INDETERMINATE_WAVELENGTH
232        } else {
233            linear::DETERMINATE_WAVELENGTH
234        })
235    }
236
237    /// Travel speed in logical px per second, defaulting to the wavelength —
238    /// one cycle per second (`waveSpeed ?? wavelength`).
239    fn wave_speed(&self) -> f64 {
240        self.wave_speed.unwrap_or_else(|| self.wavelength())
241    }
242
243    fn wavy_spec(&self) -> linear::WavySpec {
244        linear::WavySpec {
245            stroke: self.stroke(),
246            track_stroke: self.track_stroke(),
247            gap: self.gap_size.unwrap_or(linear::GAP_SIZE),
248            stop_size: self.stop_size.unwrap_or(linear::STOP_SIZE),
249        }
250    }
251}
252
253/// A linear progress indicator, flat or wavy. See the [module docs](self).
254pub struct LinearProgressView {
255    props: LinearProps,
256    amplitude_for_progress: Option<AmplitudeCurve>,
257}
258
259/// Create a flat linear progress indicator driven by `value`
260/// (`M3EProgressIndicator.linear`).
261pub fn linear_progress(value: ProgressValue) -> LinearProgressView {
262    LinearProgressView {
263        props: LinearProps::new(value, false),
264        amplitude_for_progress: None,
265    }
266}
267
268/// Create a wavy linear progress indicator driven by `value`
269/// (`M3EProgressIndicator.linearWavy`).
270pub fn linear_wavy_progress(value: ProgressValue) -> LinearProgressView {
271    LinearProgressView {
272        props: LinearProps::new(value, true),
273        amplitude_for_progress: None,
274    }
275}
276
277/// PascalCase alias for [`linear_progress`], matching the widget-fn
278/// vocabulary (`Button`/`Image`/…).
279#[allow(non_snake_case)]
280pub fn LinearProgress(value: ProgressValue) -> LinearProgressView {
281    linear_progress(value)
282}
283
284/// PascalCase alias for [`linear_wavy_progress`].
285#[allow(non_snake_case)]
286pub fn LinearWavyProgress(value: ProgressValue) -> LinearProgressView {
287    linear_wavy_progress(value)
288}
289
290impl LinearProgressView {
291    /// Switch to the wavy variant — the builder form of
292    /// [`linear_wavy_progress`].
293    pub fn wavy(mut self) -> Self {
294        self.props.wavy = true;
295        self
296    }
297
298    /// Track thickness variant (`M3EProgressIndicator.linearSize`). Inert on
299    /// the wavy variant, which sizes from its stroke and amplitude instead.
300    pub fn size(mut self, size: ProgressSize) -> Self {
301        self.props.size = size;
302        self
303    }
304
305    /// Active-indicator colour (`M3EProgressIndicator.color`), overriding the
306    /// theme's `colors.primary`.
307    pub fn color(mut self, color: Color) -> Self {
308        self.props.color = Some(color);
309        self
310    }
311
312    /// Inactive-track colour (`M3EProgressIndicator.trackColor`), overriding
313    /// the theme's `colors.surface_container_highest`.
314    pub fn track_color(mut self, color: Color) -> Self {
315        self.props.track_color = Some(color);
316        self
317    }
318
319    /// Active-indicator stroke thickness in logical px
320    /// (`M3EProgressIndicator.strokeWidth`). Inert on the flat variant, whose
321    /// stroke is its size matrix's track height.
322    pub fn stroke_width(mut self, stroke: f64) -> Self {
323        self.props.stroke_width = Some(stroke);
324        self
325    }
326
327    /// Inactive-track stroke thickness in logical px
328    /// (`M3EProgressIndicator.trackStrokeWidth`). Inert on the flat variant.
329    pub fn track_stroke_width(mut self, stroke: f64) -> Self {
330        self.props.track_stroke_width = Some(stroke);
331        self
332    }
333
334    /// Clear space between the active indicator and the track, in logical px
335    /// (`M3EProgressIndicator.gapSize`). Inert on the flat variant, which
336    /// takes its gap from the size matrix.
337    pub fn gap_size(mut self, gap: f64) -> Self {
338        self.props.gap_size = Some(gap);
339        self
340    }
341
342    /// End stop indicator diameter in logical px
343    /// (`M3EProgressIndicator.stopSize`). Inert on the flat variant, which
344    /// takes its dot diameter from the size matrix; the dot itself is painted
345    /// by every determinate indicator and by none of the indeterminate ones.
346    pub fn stop_size(mut self, size: f64) -> Self {
347        self.props.stop_size = Some(size);
348        self
349    }
350
351    /// Fix the wave's amplitude factor (`0..=1`,
352    /// `M3EProgressIndicator.amplitude`), replacing the default end-of-track
353    /// ramp. Inert unless the indicator is wavy; outranked by
354    /// [`Self::amplitude_for_progress`].
355    pub fn amplitude(mut self, amplitude: f64) -> Self {
356        self.props.amplitude = Some(amplitude);
357        self
358    }
359
360    /// Drive the wave's amplitude factor from the current progress
361    /// (`M3EProgressIndicator.amplitudeForProgress`), the highest-precedence
362    /// of the three amplitude sources. The return is clamped to `0..=1`.
363    /// Consulted only by a **determinate** wavy indicator — upstream's
364    /// indeterminate arm takes the constant (or `1`) and never calls the
365    /// curve.
366    pub fn amplitude_for_progress<F: Fn(f64) -> f64 + 'static>(mut self, curve: F) -> Self {
367        self.amplitude_for_progress = Some(std::rc::Rc::new(curve));
368        self
369    }
370
371    /// Length of one full wave cycle in logical px
372    /// (`M3EProgressIndicator.wavelength`, defaulting to 40 determinate / 20
373    /// indeterminate). Inert unless the indicator is wavy.
374    pub fn wavelength(mut self, wavelength: f64) -> Self {
375        self.props.wavelength = Some(wavelength);
376        self
377    }
378
379    /// How fast the wave travels, in logical px per second
380    /// (`M3EProgressIndicator.waveSpeed`, defaulting to the wavelength — one
381    /// cycle per second). Inert unless the indicator is wavy.
382    pub fn wave_speed(mut self, wave_speed: f64) -> Self {
383        self.props.wave_speed = Some(wave_speed);
384        self
385    }
386}
387
388impl<State: 'static> View<State> for LinearProgressView {
389    type Element = LinearProgressWidget;
390
391    fn build(&self, _ctx: &mut BuildCtx<'_>) -> LinearProgressWidget {
392        LinearProgressWidget {
393            props: self.props,
394            amplitude_for_progress: self.amplitude_for_progress.clone(),
395            cycle: motion::LinearClock::new(),
396            wave: motion::WaveClock::new(),
397        }
398    }
399
400    fn rebuild(
401        &self,
402        prev: &Self,
403        element: &mut LinearProgressWidget,
404        _ctx: &mut BuildCtx<'_>,
405    ) -> ChangeFlags {
406        // A closure a diff cannot compare: adopted every pass, like
407        // `crate::slider`'s. A wavy indicator repaints every frame anyway, so
408        // an adopted curve is on screen by the next one.
409        element.amplitude_for_progress = self.amplitude_for_progress.clone();
410        if prev.props == self.props {
411            return ChangeFlags::NONE;
412        }
413        let resized = prev.props.wavy != self.props.wavy
414            || prev.props.size != self.props.size
415            || prev.props.stroke_width != self.props.stroke_width
416            // A wavy indicator's box height follows its amplitude, which
417            // follows its value — so a value change is layout-affecting there
418            // and paint-only everywhere else.
419            || (self.props.wavy && prev.props.value != self.props.value);
420        element.props = self.props;
421        if resized {
422            ChangeFlags::LAYOUT | ChangeFlags::PAINT
423        } else {
424            ChangeFlags::PAINT
425        }
426    }
427}
428
429/// The retained widget for a [`LinearProgressView`]. See the
430/// [module docs](self).
431pub struct LinearProgressWidget {
432    props: LinearProps,
433    amplitude_for_progress: Option<AmplitudeCurve>,
434    /// The 1750 ms two-segment indeterminate cycle. Idle while determinate.
435    cycle: motion::LinearClock,
436    /// The wave phase clock. Idle while flat or at zero amplitude.
437    wave: motion::WaveClock,
438}
439
440impl LinearProgressWidget {
441    /// The resolved amplitude factor for this frame, in the reference's own
442    /// precedence order.
443    fn amplitude_factor(&self) -> f64 {
444        motion::amplitude_factor(
445            self.props.fraction(),
446            self.props.amplitude,
447            self.amplitude_for_progress.as_deref(),
448        )
449    }
450
451    /// The `(active, track)` colours: themed `colors.primary`/
452    /// `colors.surface_container_highest`, an explicit builder override, or
453    /// the unthemed fallbacks.
454    fn colors(&self, theme: Option<&Theme>) -> (Color, Color) {
455        (
456            self.props
457                .color
458                .or_else(|| theme.map(|t| t.scheme().primary))
459                .unwrap_or(FALLBACK_ACTIVE),
460            self.props
461                .track_color
462                .or_else(|| theme.map(|t| t.scheme().surface_container_highest))
463                .unwrap_or(FALLBACK_LINEAR_TRACK),
464        )
465    }
466}
467
468impl Widget for LinearProgressWidget {
469    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
470        // Upstream is a full-width `SizedBox`; take every finite pixel offered
471        // and fall back to the minimum under an unbounded constraint.
472        let width = if bc.max().width.is_finite() {
473            bc.max().width
474        } else {
475            bc.min().width
476        };
477        let height = if self.props.wavy {
478            linear::wavy_height(self.props.stroke(), self.amplitude_factor())
479        } else {
480            linear::flat_layout(self.props.size).track_height
481        };
482        bc.constrain(Size::new(width, height))
483    }
484
485    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
486        let theme = Theme::from_paint_ctx(ctx);
487        let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
488        let (active, track) = self.colors(theme);
489        let size = ctx.size();
490        let origin = ctx.origin();
491        let fraction = self.props.fraction();
492        let factor = self.amplitude_factor();
493        let amplitude = if self.props.wavy {
494            linear::WAVE_AMPLITUDE * factor
495        } else {
496            0.0
497        };
498
499        // Advance-during-paint, one clock per live loop.
500        let mut animating = false;
501        if fraction.is_none() {
502            if !reduce_motion {
503                self.cycle.step(ctx.frame_time());
504            }
505            animating = true;
506        }
507        if amplitude > 0.0 {
508            if !reduce_motion {
509                self.wave.step(ctx.frame_time());
510            }
511            animating = true;
512        }
513        if animating && !reduce_motion {
514            ctx.request_frame_paced();
515        }
516
517        let segments = self.cycle.segments();
518        let ops = if self.props.wavy {
519            linear::wavy_ops(self.props.wavy_spec(), size.width, fraction, segments)
520        } else {
521            linear::flat_ops(
522                linear::flat_layout(self.props.size),
523                size.width,
524                fraction,
525                segments,
526            )
527        };
528
529        let cy = origin.y + size.height / 2.0;
530        let stroke = self.props.stroke();
531        let track_stroke = self.props.track_stroke();
532        let phase = self
533            .wave
534            .radians(self.props.wavelength(), self.props.wave_speed());
535        for op in ops {
536            match op {
537                linear::LinearOp::Track { x0, x1 } => scene.stroke_line(
538                    Point::new(origin.x + x0, cy),
539                    Point::new(origin.x + x1, cy),
540                    track_stroke,
541                    track,
542                ),
543                linear::LinearOp::Active { x0, x1 } => scene.stroke_line(
544                    Point::new(origin.x + x0, cy),
545                    Point::new(origin.x + x1, cy),
546                    stroke,
547                    active,
548                ),
549                linear::LinearOp::Wave { x0, x1 } => {
550                    if let Some(path) = linear::wave_path(
551                        origin.x + x0,
552                        origin.x + x1,
553                        cy,
554                        amplitude,
555                        self.props.wavelength(),
556                        phase,
557                    ) {
558                        scene.stroke_path(Point::ZERO, &path, stroke, &Brush::Solid(active));
559                    }
560                }
561                linear::LinearOp::Stop { center_x, diameter } => scene.fill_rounded_rect(
562                    Point::new(origin.x + center_x - diameter / 2.0, cy - diameter / 2.0),
563                    Size::new(diameter, diameter),
564                    diameter / 2.0,
565                    active,
566                ),
567            }
568        }
569    }
570
571    fn semantics(&self, ctx: &mut SemanticsCtx) {
572        push_progress_node(ctx, self.props.fraction());
573    }
574}
575
576// -- Circular -------------------------------------------------------------
577
578/// The diffable half of a circular indicator's props. See [`LinearProps`].
579#[derive(Clone, Copy, Debug, PartialEq)]
580struct CircularProps {
581    value: ProgressValue,
582    wavy: bool,
583    diameter: Option<f64>,
584    color: Option<Color>,
585    track_color: Option<Color>,
586    stroke_width: Option<f64>,
587    track_stroke_width: Option<f64>,
588    gap_size: Option<f64>,
589    amplitude: Option<f64>,
590    wavelength: Option<f64>,
591    wave_speed: Option<f64>,
592}
593
594impl CircularProps {
595    fn new(value: ProgressValue, wavy: bool) -> Self {
596        CircularProps {
597            value,
598            wavy,
599            diameter: None,
600            color: None,
601            track_color: None,
602            stroke_width: None,
603            track_stroke_width: None,
604            gap_size: None,
605            amplitude: None,
606            wavelength: None,
607            wave_speed: None,
608        }
609    }
610
611    fn fraction(&self) -> Option<f64> {
612        self.value.fraction()
613    }
614
615    /// Box diameter: the wavy variant is 48dp, the classic one 40dp
616    /// (`circular.wavySize` / `circular.defaultSize`).
617    fn diameter(&self) -> f64 {
618        self.diameter.unwrap_or(if self.wavy {
619            circular::WAVY_SIZE
620        } else {
621            circular::DEFAULT_SIZE
622        })
623    }
624
625    fn stroke(&self) -> f64 {
626        self.stroke_width.unwrap_or(circular::STROKE_WIDTH)
627    }
628
629    fn track_stroke(&self) -> f64 {
630        self.track_stroke_width
631            .unwrap_or(circular::TRACK_STROKE_WIDTH)
632    }
633
634    fn gap(&self) -> f64 {
635        self.gap_size.unwrap_or(circular::GAP_SIZE)
636    }
637
638    fn wavelength(&self) -> f64 {
639        self.wavelength.unwrap_or(circular::WAVELENGTH)
640    }
641
642    fn wave_speed(&self) -> f64 {
643        self.wave_speed.unwrap_or_else(|| self.wavelength())
644    }
645}
646
647/// A circular progress indicator, classic or wavy. See the
648/// [module docs](self).
649pub struct CircularProgressView {
650    props: CircularProps,
651    amplitude_for_progress: Option<AmplitudeCurve>,
652}
653
654/// Create a classic circular progress indicator driven by `value`
655/// (`M3EProgressIndicator.circular`).
656pub fn circular_progress(value: ProgressValue) -> CircularProgressView {
657    CircularProgressView {
658        props: CircularProps::new(value, false),
659        amplitude_for_progress: None,
660    }
661}
662
663/// Create a wavy circular progress indicator driven by `value`
664/// (`M3EProgressIndicator.circularWavy`).
665pub fn circular_wavy_progress(value: ProgressValue) -> CircularProgressView {
666    CircularProgressView {
667        props: CircularProps::new(value, true),
668        amplitude_for_progress: None,
669    }
670}
671
672/// PascalCase alias for [`circular_progress`], matching the widget-fn
673/// vocabulary (`Button`/`Image`/…).
674#[allow(non_snake_case)]
675pub fn CircularProgress(value: ProgressValue) -> CircularProgressView {
676    circular_progress(value)
677}
678
679/// PascalCase alias for [`circular_wavy_progress`].
680#[allow(non_snake_case)]
681pub fn CircularWavyProgress(value: ProgressValue) -> CircularProgressView {
682    circular_wavy_progress(value)
683}
684
685impl CircularProgressView {
686    /// Switch to the wavy variant — the builder form of
687    /// [`circular_wavy_progress`].
688    pub fn wavy(mut self) -> Self {
689        self.props.wavy = true;
690        self
691    }
692
693    /// Outer diameter in logical px (`M3EProgressIndicator.size`), overriding
694    /// the 40dp classic / 48dp wavy default.
695    pub fn diameter(mut self, diameter: f64) -> Self {
696        self.props.diameter = Some(diameter);
697        self
698    }
699
700    /// Active-arc colour (`M3EProgressIndicator.color`), overriding the
701    /// theme's `colors.primary`.
702    pub fn color(mut self, color: Color) -> Self {
703        self.props.color = Some(color);
704        self
705    }
706
707    /// Track-arc colour (`M3EProgressIndicator.trackColor`), overriding the
708    /// theme's `colors.secondary_container`.
709    pub fn track_color(mut self, color: Color) -> Self {
710        self.props.track_color = Some(color);
711        self
712    }
713
714    /// Active-arc stroke thickness in logical px
715    /// (`M3EProgressIndicator.strokeWidth`).
716    pub fn stroke_width(mut self, stroke: f64) -> Self {
717        self.props.stroke_width = Some(stroke);
718        self
719    }
720
721    /// Track-arc stroke thickness in logical px
722    /// (`M3EProgressIndicator.trackStrokeWidth`).
723    pub fn track_stroke_width(mut self, stroke: f64) -> Self {
724        self.props.track_stroke_width = Some(stroke);
725        self
726    }
727
728    /// Clear space between the active arc and the track, in logical px
729    /// (`M3EProgressIndicator.gapSize`). Applied twice — once at each end of
730    /// the active arc — so the ring reads as two separated arcs.
731    pub fn gap_size(mut self, gap: f64) -> Self {
732        self.props.gap_size = Some(gap);
733        self
734    }
735
736    /// Fix the wave's amplitude factor (`0..=1`,
737    /// `M3EProgressIndicator.amplitude`). Inert unless the indicator is wavy;
738    /// outranked by [`Self::amplitude_for_progress`].
739    pub fn amplitude(mut self, amplitude: f64) -> Self {
740        self.props.amplitude = Some(amplitude);
741        self
742    }
743
744    /// Drive the wave's amplitude factor from the current progress
745    /// (`M3EProgressIndicator.amplitudeForProgress`) — see
746    /// [`LinearProgressView::amplitude_for_progress`] for the full contract.
747    pub fn amplitude_for_progress<F: Fn(f64) -> f64 + 'static>(mut self, curve: F) -> Self {
748        self.amplitude_for_progress = Some(std::rc::Rc::new(curve));
749        self
750    }
751
752    /// Length of one full wave cycle along the ring, in logical px
753    /// (`M3EProgressIndicator.wavelength`, defaulting to 15). Rounded to a
754    /// whole number of cycles around the circumference so the wave closes on
755    /// itself. Inert unless the indicator is wavy.
756    pub fn wavelength(mut self, wavelength: f64) -> Self {
757        self.props.wavelength = Some(wavelength);
758        self
759    }
760
761    /// How fast the wave travels, in logical px per second
762    /// (`M3EProgressIndicator.waveSpeed`, defaulting to the wavelength).
763    /// Inert unless the indicator is wavy.
764    pub fn wave_speed(mut self, wave_speed: f64) -> Self {
765        self.props.wave_speed = Some(wave_speed);
766        self
767    }
768}
769
770impl<State: 'static> View<State> for CircularProgressView {
771    type Element = CircularProgressWidget;
772
773    fn build(&self, _ctx: &mut BuildCtx<'_>) -> CircularProgressWidget {
774        CircularProgressWidget {
775            props: self.props,
776            amplitude_for_progress: self.amplitude_for_progress.clone(),
777            spin: motion::CircularClock::new(),
778            wave: motion::WaveClock::new(),
779        }
780    }
781
782    fn rebuild(
783        &self,
784        prev: &Self,
785        element: &mut CircularProgressWidget,
786        _ctx: &mut BuildCtx<'_>,
787    ) -> ChangeFlags {
788        element.amplitude_for_progress = self.amplitude_for_progress.clone();
789        if prev.props == self.props {
790            return ChangeFlags::NONE;
791        }
792        let resized =
793            prev.props.diameter() != self.props.diameter() || prev.props.wavy != self.props.wavy;
794        element.props = self.props;
795        if resized {
796            ChangeFlags::LAYOUT | ChangeFlags::PAINT
797        } else {
798            ChangeFlags::PAINT
799        }
800    }
801}
802
803/// The retained widget for a [`CircularProgressView`]. See the
804/// [module docs](self).
805pub struct CircularProgressWidget {
806    props: CircularProps,
807    amplitude_for_progress: Option<AmplitudeCurve>,
808    /// The rotation/sweep choreography. Idle while determinate.
809    spin: motion::CircularClock,
810    /// The wave phase clock. Idle while classic or at zero amplitude.
811    wave: motion::WaveClock,
812}
813
814impl CircularProgressWidget {
815    fn amplitude_factor(&self) -> f64 {
816        motion::amplitude_factor(
817            self.props.fraction(),
818            self.props.amplitude,
819            self.amplitude_for_progress.as_deref(),
820        )
821    }
822
823    /// The `(active, track)` colours: themed `colors.primary`/
824    /// `colors.secondary_container`, an explicit builder override, or the
825    /// unthemed fallbacks.
826    fn colors(&self, theme: Option<&Theme>) -> (Color, Color) {
827        (
828            self.props
829                .color
830                .or_else(|| theme.map(|t| t.scheme().primary))
831                .unwrap_or(FALLBACK_ACTIVE),
832            self.props
833                .track_color
834                .or_else(|| theme.map(|t| t.scheme().secondary_container))
835                .unwrap_or(FALLBACK_CIRCULAR_TRACK),
836        )
837    }
838}
839
840impl Widget for CircularProgressWidget {
841    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
842        let diameter = self.props.diameter();
843        bc.constrain(Size::new(diameter, diameter))
844    }
845
846    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
847        let theme = Theme::from_paint_ctx(ctx);
848        let reduce_motion = theme.is_some_and(|t| t.motion.reduce_motion);
849        let (active, track) = self.colors(theme);
850        let fraction = self.props.fraction();
851        let amplitude = if self.props.wavy {
852            circular::WAVE_AMPLITUDE * self.amplitude_factor()
853        } else {
854            0.0
855        };
856
857        let mut animating = false;
858        if fraction.is_none() {
859            if !reduce_motion {
860                self.spin.step(ctx.frame_time());
861            }
862            animating = true;
863        }
864        if amplitude > 0.0 {
865            if !reduce_motion {
866                self.wave.step(ctx.frame_time());
867            }
868            animating = true;
869        }
870        if animating && !reduce_motion {
871            ctx.request_frame_paced();
872        }
873
874        let Some(geometry) = circular::geometry(
875            ctx.origin(),
876            ctx.size(),
877            self.props.stroke(),
878            self.props.track_stroke(),
879            self.props.gap(),
880            amplitude,
881            self.props.wavelength(),
882        ) else {
883            // A box too small to hold a positive radius paints nothing rather
884            // than dividing by it — see `circular`'s divergence note.
885            return;
886        };
887
888        let (rotation, sweep) = if fraction.is_none() {
889            (self.spin.rotation(), self.spin.sweep())
890        } else {
891            (0.0, 0.0)
892        };
893        let spans = circular::arc_spans(
894            fraction,
895            circular::START_ANGLE + rotation,
896            sweep,
897            geometry.gap_angle,
898        );
899
900        if let Some((start, span)) = spans.track {
901            let path = arc_path(geometry.center, geometry.radius, start, span);
902            scene.stroke_path(
903                Point::ZERO,
904                &path,
905                self.props.track_stroke(),
906                &Brush::Solid(track),
907            );
908        }
909        if let Some((start, span)) = spans.active {
910            let path = if self.props.wavy {
911                let phase = self
912                    .wave
913                    .radians(self.props.wavelength(), self.props.wave_speed());
914                circular::wavy_arc_path(&geometry, start, span, amplitude, phase)
915            } else {
916                arc_path(geometry.center, geometry.radius, start, span)
917            };
918            scene.stroke_path(
919                Point::ZERO,
920                &path,
921                self.props.stroke(),
922                &Brush::Solid(active),
923            );
924        }
925    }
926
927    fn semantics(&self, ctx: &mut SemanticsCtx) {
928        push_progress_node(ctx, self.props.fraction());
929    }
930}
931
932/// Report the indicator to the accessibility tree.
933///
934/// A determinate indicator reports its numeric value/range; an indeterminate
935/// one leaves `numeric_value` unset entirely — the ARIA/accesskit convention
936/// for "progress with no known completion fraction".
937fn push_progress_node(ctx: &mut SemanticsCtx, fraction: Option<f64>) {
938    ctx.push_node(Role::ProgressIndicator, |node| {
939        if let Some(fraction) = fraction {
940            node.set_numeric_value(fraction);
941            node.set_min_numeric_value(0.0);
942            node.set_max_numeric_value(1.0);
943        }
944    });
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use frust::FrameTime;
951    use kurbo::{BezPath, PathEl};
952
953    const LINEAR_WIDTH: f64 = 200.0;
954
955    fn secs(t: f64) -> FrameTime {
956        FrameTime::from_nanos((t * 1e9) as u64)
957    }
958
959    fn build_linear(view: LinearProgressView) -> LinearProgressWidget {
960        let mut counter = 0u64;
961        <LinearProgressView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
962    }
963
964    fn build_circular(view: CircularProgressView) -> CircularProgressWidget {
965        let mut counter = 0u64;
966        <CircularProgressView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
967    }
968
969    // -- recording scene ----------------------------------------------------
970
971    #[derive(Default)]
972    struct RecordingScene {
973        lines: Vec<(Point, Point, f64, Color)>,
974        rrects: Vec<(Point, Size, f64, Color)>,
975        strokes: Vec<(BezPath, f64)>,
976    }
977
978    impl PaintScene for RecordingScene {
979        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
980        fn draw_text(&mut self, _o: Point, _t: &str) {}
981        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
982            self.rrects.push((o, s, radius, color));
983        }
984        fn stroke_line(&mut self, p0: Point, p1: Point, width: f64, color: Color) {
985            self.lines.push((p0, p1, width, color));
986        }
987        fn stroke_path(&mut self, _origin: Point, path: &BezPath, width: f64, _brush: &Brush) {
988            self.strokes.push((path.clone(), width));
989        }
990    }
991
992    /// Paint at the widget's *current* clock state. `PaintCtx::for_test` lives
993    /// behind `frust-core`'s `test-support` feature, which this crate's
994    /// dev-dependency does not enable, so a clock-dependent test steps the
995    /// widget's own controllers first (`crate::slider`'s convention) — a
996    /// `FrameTime::ZERO` paint then yields a zero delta and leaves them where
997    /// they were put.
998    fn paint(w: &mut dyn Widget, size: Size) -> RecordingScene {
999        let mut ctx = PaintCtx::new(Point::ZERO, size);
1000        let mut scene = RecordingScene::default();
1001        w.paint(&mut ctx, &mut scene);
1002        scene
1003    }
1004
1005    fn linear_size(w: &LinearProgressWidget) -> Size {
1006        Size::new(LINEAR_WIDTH, w.props.stroke())
1007    }
1008
1009    fn paint_linear(w: &mut LinearProgressWidget) -> RecordingScene {
1010        let size = linear_size(w);
1011        paint(w, size)
1012    }
1013
1014    fn paint_circular(w: &mut CircularProgressWidget) -> RecordingScene {
1015        let size = circular_size(w);
1016        paint(w, size)
1017    }
1018
1019    fn actives(scene: &RecordingScene, active: Color) -> Vec<(f64, f64)> {
1020        scene
1021            .lines
1022            .iter()
1023            .filter(|(_, _, _, c)| *c == active)
1024            .map(|(p0, p1, _, _)| (p0.x, p1.x))
1025            .collect()
1026    }
1027
1028    fn tracks(scene: &RecordingScene, track: Color) -> Vec<(f64, f64)> {
1029        scene
1030            .lines
1031            .iter()
1032            .filter(|(_, _, _, c)| *c == track)
1033            .map(|(p0, p1, _, _)| (p0.x, p1.x))
1034            .collect()
1035    }
1036
1037    // -- linear: determinate ------------------------------------------------
1038
1039    #[test]
1040    fn linear_determinate_fills_proportionally_to_value() {
1041        let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.25)));
1042        let scene = paint_linear(&mut w);
1043        // left = 4, trackRight = 200 - 8 = 192, span = 188.
1044        let active = actives(&scene, FALLBACK_ACTIVE);
1045        assert_eq!(active.len(), 1);
1046        assert!((active[0].1 - (4.0 + 188.0 * 0.25)).abs() < 1e-9);
1047        assert_eq!(tracks(&scene, FALLBACK_LINEAR_TRACK).len(), 1);
1048    }
1049
1050    #[test]
1051    fn linear_determinate_clamps_out_of_range_values() {
1052        let mut over = build_linear(linear_progress(ProgressValue::Determinate(1.5)));
1053        let scene = paint_linear(&mut over);
1054        assert_eq!(actives(&scene, FALLBACK_ACTIVE), vec![(4.0, 192.0)]);
1055        assert!(tracks(&scene, FALLBACK_LINEAR_TRACK).is_empty());
1056
1057        let mut under = build_linear(linear_progress(ProgressValue::Determinate(-0.5)));
1058        let scene = paint_linear(&mut under);
1059        assert!(
1060            actives(&scene, FALLBACK_ACTIVE).is_empty(),
1061            "a clamped-to-zero fraction paints no active span"
1062        );
1063    }
1064
1065    #[test]
1066    fn linear_determinate_paints_the_stop_indicator() {
1067        let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.5)));
1068        let scene = paint_linear(&mut w);
1069        assert_eq!(scene.rrects.len(), 1, "exactly one stop dot");
1070        let (origin, size, radius, color) = scene.rrects[0];
1071        assert_eq!(size, Size::new(4.0, 4.0));
1072        assert_eq!(radius, 2.0, "a rounded rect at half its side is a circle");
1073        assert_eq!(color, FALLBACK_ACTIVE);
1074        // trackStroke 8 → placement centre 192 + 4 - 2 - 2 = 192.
1075        assert!((origin.x + 2.0 - 192.0).abs() < 1e-9);
1076    }
1077
1078    #[test]
1079    fn linear_indeterminate_paints_no_stop_indicator() {
1080        let mut w = build_linear(linear_progress(ProgressValue::Indeterminate));
1081        let scene = paint_linear(&mut w);
1082        assert!(scene.rrects.is_empty());
1083    }
1084
1085    #[test]
1086    fn linear_flat_size_matrix_drives_the_box_height() {
1087        let mut small =
1088            build_linear(linear_progress(ProgressValue::Determinate(0.5)).size(ProgressSize::S));
1089        let mut medium = build_linear(linear_progress(ProgressValue::Determinate(0.5)));
1090        let bc = BoxConstraints::new(Size::ZERO, Size::new(LINEAR_WIDTH, 100.0));
1091        assert_eq!(small.layout(&mut LayoutCtx::new(), &bc).height, 4.0);
1092        assert_eq!(medium.layout(&mut LayoutCtx::new(), &bc).height, 8.0);
1093    }
1094
1095    // -- linear: the two-segment indeterminate choreography ------------------
1096
1097    #[test]
1098    fn linear_indeterminate_paints_two_traveling_lines_not_one() {
1099        let mut w = build_linear(linear_progress(ProgressValue::Indeterminate));
1100        w.cycle.step(secs(0.0));
1101        w.cycle.step(secs(1.0));
1102        let scene = paint_linear(&mut w);
1103        let active = actives(&scene, FALLBACK_ACTIVE);
1104        assert_eq!(
1105            active.len(),
1106            2,
1107            "the reference choreography runs two segments at once"
1108        );
1109        assert!(
1110            active[0].0 > active[1].1,
1111            "the leading line is painted first, with track between the two"
1112        );
1113    }
1114
1115    #[test]
1116    fn linear_indeterminate_segment_positions_match_the_curve_table() {
1117        // Drive the cycle to t = 1000/1750 and check the painted spans against
1118        // the transcribed segment table evaluated independently.
1119        let mut w = build_linear(linear_progress(ProgressValue::Indeterminate));
1120        w.cycle.step(secs(0.0));
1121        w.cycle.step(secs(1.0));
1122        let t = 1000.0 / 1750.0;
1123        assert!((w.cycle.cycle() - t).abs() < 1e-9);
1124        let expected = motion::segments(t);
1125
1126        let scene = paint_linear(&mut w);
1127        let active = actives(&scene, FALLBACK_ACTIVE);
1128        let span = 188.0;
1129        let to_fraction = |x: f64| (x - 4.0) / span;
1130        // Painted in draw order: the first (leading) line, then the second.
1131        assert!((to_fraction(active[0].0) - expected.first_tail).abs() < 1e-9);
1132        assert!((to_fraction(active[0].1) - expected.first_head).abs() < 1e-9);
1133        assert!((to_fraction(active[1].0) - expected.second_tail).abs() < 1e-9);
1134        assert!((to_fraction(active[1].1) - expected.second_head).abs() < 1e-9);
1135    }
1136
1137    #[test]
1138    fn linear_indeterminate_requests_a_paced_frame_every_paint() {
1139        let mut w = build_linear(linear_progress(ProgressValue::Indeterminate));
1140        for _ in 0..3 {
1141            let mut ctx = PaintCtx::new(Point::ZERO, linear_size(&w));
1142            let mut scene = RecordingScene::default();
1143            w.paint(&mut ctx, &mut scene);
1144            assert!(ctx.needs_frame());
1145            assert!(
1146                ctx.needs_frame_paced_only(),
1147                "a perpetual decorative loop must be pace-able by the frame gate"
1148            );
1149        }
1150    }
1151
1152    #[test]
1153    fn linear_determinate_flat_requests_no_frame_at_all() {
1154        let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.4)));
1155        let mut ctx = PaintCtx::new(Point::ZERO, linear_size(&w));
1156        let mut scene = RecordingScene::default();
1157        w.paint(&mut ctx, &mut scene);
1158        assert!(!ctx.needs_frame(), "nothing is animating");
1159    }
1160
1161    #[test]
1162    fn linear_indeterminate_reduce_motion_freezes_and_stops_requesting_frames() {
1163        let mut theme = crate::baseline();
1164        theme.motion.reduce_motion = true;
1165        let mut w = build_linear(linear_progress(ProgressValue::Indeterminate));
1166        w.cycle.step(secs(0.0));
1167        w.cycle.step(secs(1.0));
1168        let frozen = w.cycle.cycle();
1169        for _ in 0..3 {
1170            let mut ctx = PaintCtx::new(Point::ZERO, linear_size(&w)).with_theme(&theme);
1171            let mut scene = RecordingScene::default();
1172            w.paint(&mut ctx, &mut scene);
1173            assert!(!ctx.needs_frame());
1174        }
1175        assert_eq!(w.cycle.cycle(), frozen, "and the cycle stands still");
1176    }
1177
1178    // -- linear: wavy --------------------------------------------------------
1179
1180    #[test]
1181    fn linear_wavy_determinate_strokes_a_wave_instead_of_a_flat_line() {
1182        let mut w = build_linear(linear_wavy_progress(ProgressValue::Determinate(0.5)));
1183        let scene = paint(&mut w, Size::new(LINEAR_WIDTH, 10.0));
1184        assert_eq!(scene.strokes.len(), 1, "the active span is a stroked path");
1185        assert_eq!(
1186            actives(&scene, FALLBACK_ACTIVE).len(),
1187            0,
1188            "and not a flat line"
1189        );
1190        assert_eq!(tracks(&scene, FALLBACK_LINEAR_TRACK).len(), 1);
1191        assert_eq!(scene.rrects.len(), 1, "the stop dot still lands");
1192    }
1193
1194    #[test]
1195    fn linear_wavy_flattens_near_both_ends_of_the_track() {
1196        // The default ramp is 0 below 0.1 and at/above 0.95, so the wave is a
1197        // straight line there — every sampled vertex sits on the centreline.
1198        for value in [0.05, 0.99] {
1199            let mut w = build_linear(linear_wavy_progress(ProgressValue::Determinate(value)));
1200            let scene = paint(&mut w, Size::new(LINEAR_WIDTH, 10.0));
1201            let cy = 5.0;
1202            for (path, _) in &scene.strokes {
1203                for el in path.elements() {
1204                    if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
1205                        assert!(
1206                            (p.y - cy).abs() < 1e-9,
1207                            "the ramp must flatten the wave at value {value}"
1208                        );
1209                    }
1210                }
1211            }
1212        }
1213    }
1214
1215    #[test]
1216    fn linear_wavy_amplitude_overrides_beat_the_ramp() {
1217        let mut w =
1218            build_linear(linear_wavy_progress(ProgressValue::Determinate(0.99)).amplitude(1.0));
1219        let scene = paint(&mut w, Size::new(LINEAR_WIDTH, 10.0));
1220        let off_centre = scene.strokes[0]
1221            .0
1222            .elements()
1223            .iter()
1224            .any(|el| matches!(el, PathEl::LineTo(p) if (p.y - 5.0).abs() > 0.5));
1225        assert!(off_centre, "an explicit amplitude replaces the ramp");
1226    }
1227
1228    #[test]
1229    fn linear_wavy_amplitude_curve_outranks_the_constant() {
1230        let mut w = build_linear(
1231            linear_wavy_progress(ProgressValue::Determinate(0.5))
1232                .amplitude(1.0)
1233                .amplitude_for_progress(|_| 0.0),
1234        );
1235        let scene = paint(&mut w, Size::new(LINEAR_WIDTH, 10.0));
1236        for el in scene.strokes[0].0.elements() {
1237            if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
1238                assert!((p.y - 5.0).abs() < 1e-9, "the curve flattened the wave");
1239            }
1240        }
1241    }
1242
1243    #[test]
1244    fn linear_wavy_box_height_follows_the_amplitude() {
1245        let bc = BoxConstraints::new(Size::ZERO, Size::new(LINEAR_WIDTH, 100.0));
1246        let mut waving = build_linear(linear_wavy_progress(ProgressValue::Determinate(0.5)));
1247        assert_eq!(
1248            waving.layout(&mut LayoutCtx::new(), &bc).height,
1249            linear::WAVY_CONTAINER_HEIGHT
1250        );
1251        let mut tall =
1252            build_linear(linear_wavy_progress(ProgressValue::Determinate(0.5)).stroke_width(8.0));
1253        assert_eq!(tall.layout(&mut LayoutCtx::new(), &bc).height, 14.0);
1254    }
1255
1256    #[test]
1257    fn linear_wavy_advances_its_phase_and_freezes_under_reduce_motion() {
1258        let mut w = build_linear(linear_wavy_progress(ProgressValue::Determinate(0.5)));
1259        w.wave.step(secs(0.0));
1260        w.wave.step(secs(0.25));
1261        let moved = w.wave.seconds();
1262        assert!(moved > 0.0);
1263
1264        let mut theme = crate::baseline();
1265        theme.motion.reduce_motion = true;
1266        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LINEAR_WIDTH, 10.0)).with_theme(&theme);
1267        let mut scene = RecordingScene::default();
1268        w.paint(&mut ctx, &mut scene);
1269        assert!(!ctx.needs_frame());
1270        assert_eq!(w.wave.seconds(), moved, "the phase stands still");
1271        assert_eq!(
1272            scene.strokes.len(),
1273            1,
1274            "the wave is still drawn, just still"
1275        );
1276    }
1277
1278    #[test]
1279    fn linear_wavy_at_zero_amplitude_requests_no_frame() {
1280        // The one documented divergence: a flat wave has no observable phase,
1281        // so the clock and its frame request are both skipped.
1282        let mut w =
1283            build_linear(linear_wavy_progress(ProgressValue::Determinate(0.5)).amplitude(0.0));
1284        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LINEAR_WIDTH, 10.0));
1285        let mut scene = RecordingScene::default();
1286        w.paint(&mut ctx, &mut scene);
1287        assert!(!ctx.needs_frame());
1288    }
1289
1290    #[test]
1291    fn linear_wavy_indeterminate_runs_both_clocks() {
1292        let mut w = build_linear(linear_wavy_progress(ProgressValue::Indeterminate));
1293        w.cycle.step(secs(0.0));
1294        w.cycle.step(secs(1.0));
1295        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(LINEAR_WIDTH, 10.0));
1296        let mut scene = RecordingScene::default();
1297        w.paint(&mut ctx, &mut scene);
1298        assert!(ctx.needs_frame_paced_only());
1299        assert_eq!(scene.strokes.len(), 2, "two traveling waves, as flat does");
1300    }
1301
1302    // -- linear: controlled contract ----------------------------------------
1303
1304    #[test]
1305    fn rebuild_reconciles_the_view_supplied_value() {
1306        let prev = linear_progress(ProgressValue::Determinate(0.1));
1307        let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.1)));
1308        let next = linear_progress(ProgressValue::Determinate(0.9));
1309        let mut counter = 0u64;
1310        let flags = <LinearProgressView as View<()>>::rebuild(
1311            &next,
1312            &prev,
1313            &mut w,
1314            &mut BuildCtx::new(&mut counter),
1315        );
1316        assert!(flags.contains(ChangeFlags::PAINT));
1317        assert!(
1318            !flags.needs_layout(),
1319            "a flat indicator's box does not depend on its value"
1320        );
1321        let scene = paint_linear(&mut w);
1322        let active = actives(&scene, FALLBACK_ACTIVE);
1323        assert!((active[0].1 - (4.0 + 188.0 * 0.9)).abs() < 1e-9);
1324    }
1325
1326    #[test]
1327    fn rebuild_of_a_wavy_value_relayouts_because_the_box_can_change() {
1328        let prev = linear_wavy_progress(ProgressValue::Determinate(0.05));
1329        let mut w = build_linear(linear_wavy_progress(ProgressValue::Determinate(0.05)));
1330        let next = linear_wavy_progress(ProgressValue::Determinate(0.5));
1331        let mut counter = 0u64;
1332        let flags = <LinearProgressView as View<()>>::rebuild(
1333            &next,
1334            &prev,
1335            &mut w,
1336            &mut BuildCtx::new(&mut counter),
1337        );
1338        assert!(flags.needs_layout());
1339    }
1340
1341    #[test]
1342    fn rebuild_with_identical_props_reports_nothing() {
1343        let prev = linear_progress(ProgressValue::Determinate(0.4));
1344        let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.4)));
1345        let next = linear_progress(ProgressValue::Determinate(0.4));
1346        let mut counter = 0u64;
1347        let flags = <LinearProgressView as View<()>>::rebuild(
1348            &next,
1349            &prev,
1350            &mut w,
1351            &mut BuildCtx::new(&mut counter),
1352        );
1353        assert_eq!(flags, ChangeFlags::NONE);
1354    }
1355
1356    // -- linear: colours -----------------------------------------------------
1357
1358    #[test]
1359    fn linear_colours_follow_the_theme_then_an_explicit_override() {
1360        let theme = crate::baseline();
1361        let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.5)));
1362        let mut ctx = PaintCtx::new(Point::ZERO, linear_size(&w)).with_theme(&theme);
1363        let mut scene = RecordingScene::default();
1364        w.paint(&mut ctx, &mut scene);
1365        assert!(!actives(&scene, theme.scheme().primary).is_empty());
1366        assert!(!tracks(&scene, theme.scheme().surface_container_highest).is_empty());
1367
1368        let pink = Color::from_rgb8(0xFF, 0x00, 0x99);
1369        let mut w = build_linear(linear_progress(ProgressValue::Determinate(0.5)).color(pink));
1370        let mut ctx = PaintCtx::new(Point::ZERO, linear_size(&w)).with_theme(&theme);
1371        let mut scene = RecordingScene::default();
1372        w.paint(&mut ctx, &mut scene);
1373        assert!(!actives(&scene, pink).is_empty(), "an override wins");
1374    }
1375
1376    // -- circular ------------------------------------------------------------
1377
1378    fn circular_size(w: &CircularProgressWidget) -> Size {
1379        let d = w.props.diameter();
1380        Size::new(d, d)
1381    }
1382
1383    #[test]
1384    fn circular_default_diameters_differ_by_variant() {
1385        let bc = BoxConstraints::new(Size::ZERO, Size::new(200.0, 200.0));
1386        let mut classic = build_circular(circular_progress(ProgressValue::Indeterminate));
1387        let mut wavy = build_circular(circular_wavy_progress(ProgressValue::Indeterminate));
1388        assert_eq!(
1389            classic.layout(&mut LayoutCtx::new(), &bc),
1390            Size::new(circular::DEFAULT_SIZE, circular::DEFAULT_SIZE)
1391        );
1392        assert_eq!(
1393            wavy.layout(&mut LayoutCtx::new(), &bc),
1394            Size::new(circular::WAVY_SIZE, circular::WAVY_SIZE)
1395        );
1396    }
1397
1398    #[test]
1399    fn circular_determinate_paints_a_track_and_an_active_arc() {
1400        let mut w = build_circular(circular_progress(ProgressValue::Determinate(0.5)));
1401        let scene = paint_circular(&mut w);
1402        assert_eq!(scene.strokes.len(), 2, "track first, then the active arc");
1403    }
1404
1405    #[test]
1406    fn circular_complete_is_a_single_full_ring() {
1407        let mut w = build_circular(circular_progress(ProgressValue::Determinate(1.0)));
1408        let scene = paint_circular(&mut w);
1409        assert_eq!(scene.strokes.len(), 1, "no track survives a complete ring");
1410    }
1411
1412    #[test]
1413    fn circular_zero_progress_is_all_track() {
1414        let mut w = build_circular(circular_progress(ProgressValue::Determinate(0.0)));
1415        let scene = paint_circular(&mut w);
1416        assert_eq!(scene.strokes.len(), 1);
1417    }
1418
1419    #[test]
1420    fn circular_indeterminate_rotation_and_sweep_match_the_clock() {
1421        let mut w = build_circular(circular_progress(ProgressValue::Indeterminate));
1422        w.spin.step(secs(0.0));
1423        w.spin.step(secs(0.65));
1424        // Pinned in `motion`: 169 deg of rotation and a 0.485-turn sweep.
1425        assert!((w.spin.rotation() - 169.0_f64.to_radians()).abs() < 1e-9);
1426        assert!((w.spin.sweep() - 0.485 * std::f64::consts::TAU).abs() < 1e-9);
1427
1428        let scene = paint_circular(&mut w);
1429        assert_eq!(scene.strokes.len(), 2, "a rotating arc plus its track");
1430        // The active arc's first vertex must sit on the rotated start angle.
1431        let geometry = circular::geometry(
1432            Point::ZERO,
1433            circular_size(&w),
1434            circular::STROKE_WIDTH,
1435            circular::TRACK_STROKE_WIDTH,
1436            circular::GAP_SIZE,
1437            0.0,
1438            circular::WAVELENGTH,
1439        )
1440        .expect("a 40dp ring");
1441        let start = circular::START_ANGLE + w.spin.rotation();
1442        let expected = Point::new(
1443            geometry.center.x + geometry.radius * start.cos(),
1444            geometry.center.y + geometry.radius * start.sin(),
1445        );
1446        let Some(PathEl::MoveTo(actual)) = scene.strokes[1].0.elements().first() else {
1447            panic!("an arc path opens with a MoveTo");
1448        };
1449        assert!((actual.x - expected.x).abs() < 1e-6);
1450        assert!((actual.y - expected.y).abs() < 1e-6);
1451    }
1452
1453    #[test]
1454    fn circular_indeterminate_requests_a_paced_frame_every_paint() {
1455        let mut w = build_circular(circular_progress(ProgressValue::Indeterminate));
1456        for _ in 0..3 {
1457            let mut ctx = PaintCtx::new(Point::ZERO, circular_size(&w));
1458            let mut scene = RecordingScene::default();
1459            w.paint(&mut ctx, &mut scene);
1460            assert!(ctx.needs_frame());
1461            assert!(ctx.needs_frame_paced_only());
1462        }
1463    }
1464
1465    #[test]
1466    fn circular_indeterminate_reduce_motion_freezes_and_stops_requesting_frames() {
1467        let mut theme = crate::baseline();
1468        theme.motion.reduce_motion = true;
1469        let mut w = build_circular(circular_progress(ProgressValue::Indeterminate));
1470        w.spin.step(secs(0.0));
1471        w.spin.step(secs(0.65));
1472        let frozen = w.spin.rotation();
1473        for _ in 0..3 {
1474            let mut ctx = PaintCtx::new(Point::ZERO, circular_size(&w)).with_theme(&theme);
1475            let mut scene = RecordingScene::default();
1476            w.paint(&mut ctx, &mut scene);
1477            assert!(!ctx.needs_frame());
1478            assert_eq!(scene.strokes.len(), 2, "still paints a frozen ring");
1479        }
1480        assert_eq!(w.spin.rotation(), frozen);
1481    }
1482
1483    #[test]
1484    fn circular_wavy_determinate_modulates_its_radius() {
1485        let mut w = build_circular(circular_wavy_progress(ProgressValue::Determinate(0.5)));
1486        let size = circular_size(&w);
1487        let scene = paint(&mut w, size);
1488        let geometry = circular::geometry(
1489            Point::ZERO,
1490            size,
1491            circular::STROKE_WIDTH,
1492            circular::TRACK_STROKE_WIDTH,
1493            circular::GAP_SIZE,
1494            circular::WAVE_AMPLITUDE,
1495            circular::WAVELENGTH,
1496        )
1497        .expect("a 48dp ring");
1498        let radii: Vec<f64> = scene.strokes[1]
1499            .0
1500            .elements()
1501            .iter()
1502            .filter_map(|el| match el {
1503                PathEl::MoveTo(p) | PathEl::LineTo(p) => Some((*p - geometry.center).hypot()),
1504                _ => None,
1505            })
1506            .collect();
1507        let max = radii.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1508        let min = radii.iter().cloned().fold(f64::INFINITY, f64::min);
1509        assert!(
1510            max - min > circular::WAVE_AMPLITUDE,
1511            "a wavy arc must reach both a crest and a trough"
1512        );
1513    }
1514
1515    #[test]
1516    fn circular_wavy_flattens_near_both_ends_of_the_ring() {
1517        let mut w = build_circular(circular_wavy_progress(ProgressValue::Determinate(0.99)));
1518        let size = circular_size(&w);
1519        let scene = paint(&mut w, size);
1520        let geometry = circular::geometry(
1521            Point::ZERO,
1522            size,
1523            circular::STROKE_WIDTH,
1524            circular::TRACK_STROKE_WIDTH,
1525            circular::GAP_SIZE,
1526            0.0,
1527            circular::WAVELENGTH,
1528        )
1529        .expect("a 48dp ring");
1530        for el in scene.strokes[0].0.elements() {
1531            if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
1532                assert!(((*p - geometry.center).hypot() - geometry.radius).abs() < 1e-6);
1533            }
1534        }
1535    }
1536
1537    #[test]
1538    fn circular_wavy_indeterminate_paints_a_wavy_arc_and_a_flat_track() {
1539        let mut w = build_circular(circular_wavy_progress(ProgressValue::Indeterminate));
1540        w.spin.step(secs(0.0));
1541        w.spin.step(secs(0.65));
1542        let scene = paint_circular(&mut w);
1543        assert_eq!(scene.strokes.len(), 2);
1544        // The wavy helper walks a fixed 121 vertices; the track is a real arc.
1545        assert_eq!(scene.strokes[1].0.elements().len(), 121);
1546        assert!(scene.strokes[0].0.elements().len() < 121);
1547    }
1548
1549    #[test]
1550    fn circular_in_a_box_too_small_for_its_stroke_paints_nothing() {
1551        let mut w = build_circular(circular_progress(ProgressValue::Determinate(0.5)));
1552        let scene = paint(&mut w, Size::new(2.0, 2.0));
1553        assert!(scene.strokes.is_empty());
1554    }
1555
1556    #[test]
1557    fn circular_colours_follow_the_theme_then_an_explicit_override() {
1558        let theme = crate::baseline();
1559        let mut w = build_circular(circular_progress(ProgressValue::Determinate(0.5)));
1560        let mut ctx = PaintCtx::new(Point::ZERO, circular_size(&w)).with_theme(&theme);
1561        let mut scene = RecordingScene::default();
1562        w.paint(&mut ctx, &mut scene);
1563        assert_eq!(scene.strokes.len(), 2);
1564        // The colour rides the brush, not the recorder's tuple, so re-resolve
1565        // it through the widget's own ladder instead.
1566        assert_eq!(
1567            w.colors(Some(&theme)),
1568            (theme.scheme().primary, theme.scheme().secondary_container)
1569        );
1570        assert_eq!(
1571            w.colors(None),
1572            (FALLBACK_ACTIVE, FALLBACK_CIRCULAR_TRACK),
1573            "unthemed falls back to the M3 baseline roles"
1574        );
1575    }
1576
1577    // -- semantics -----------------------------------------------------------
1578    //
1579    // `SemanticsCtx::new`/`finish` are `pub(crate)` to `frust-core` (only
1580    // `RenderRoot::semantics` — a public API — can produce a real
1581    // `SemanticsUpdate`), so these drive a single-widget tree through a real
1582    // `RenderRoot` rebuild + layout + semantics pass.
1583
1584    fn semantics_root_node<V>(logic: impl FnMut(&mut ()) -> V) -> frust::authoring::Node
1585    where
1586        V: View<()> + 'static,
1587    {
1588        let mut root: frust_core::RenderRoot<(), V> = frust_core::RenderRoot::new();
1589        let mut state = ();
1590        let mut logic = logic;
1591        root.rebuild(&mut logic, &mut state);
1592        root.layout(Size::new(200.0, 200.0));
1593        let update = root.semantics();
1594        let (_, node) = update
1595            .nodes
1596            .iter()
1597            .find(|(id, _)| *id != update.root)
1598            .expect("the progress indicator contributes exactly one node");
1599        node.clone()
1600    }
1601
1602    #[test]
1603    fn linear_determinate_semantics_reports_numeric_value_and_range() {
1604        let node = semantics_root_node(|_| linear_progress(ProgressValue::Determinate(0.42)));
1605        assert_eq!(node.role(), Role::ProgressIndicator);
1606        assert_eq!(node.numeric_value(), Some(0.42));
1607        assert_eq!(node.min_numeric_value(), Some(0.0));
1608        assert_eq!(node.max_numeric_value(), Some(1.0));
1609    }
1610
1611    #[test]
1612    fn indeterminate_semantics_omits_numeric_value() {
1613        let node = semantics_root_node(|_| linear_progress(ProgressValue::Indeterminate));
1614        assert_eq!(node.role(), Role::ProgressIndicator);
1615        assert_eq!(node.numeric_value(), None);
1616    }
1617
1618    #[test]
1619    fn circular_semantics_role_and_value() {
1620        let node = semantics_root_node(|_| circular_progress(ProgressValue::Determinate(0.75)));
1621        assert_eq!(node.role(), Role::ProgressIndicator);
1622        assert_eq!(node.numeric_value(), Some(0.75));
1623    }
1624}