Skip to main content

frust_material/
refresh_indicator.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/refresh_indicator/` — itself, per that package's own
3// attribution, a Rust-ward-facing port of Álvaro N's `expressive-refresh`
4// (https://github.com/alvaronp/expressive-refresh, MIT, © 2025 Álvaro N; see
5// `plugins/material/NOTICE`'s Additional Copyright Holders section). Porting
6// decisions below.
7
8//! M3E pull-to-refresh: a wrapper around a scrollable child that shows the
9//! [`mod@crate::loading_indicator`] spinner while the app's async refresh
10//! runs, triggered by pulling past the baseline's own overscroll trigger.
11//!
12//! # Architecture: this widget owns the [`frust::scroll_view`]
13//!
14//! Upstream wraps an *app-supplied* `Scrollable` descendant via
15//! `NotificationListener`, observing whatever scroll surface happens to sit
16//! underneath. The framework's public surface has no notification-bubbling
17//! seam like that — the two seams a scroll surface exposes are
18//! [`frust::ScrollView::on_scroll`] and
19//! [`frust::ScrollView::on_refresh_release`], both installed on the view at
20//! construction, not observed after the fact. So [`refresh_indicator`] takes
21//! the child *content* (not an already-built `ScrollView`) and builds the
22//! `scroll_view` itself, installing both callbacks internally — the same
23//! shape [`crate`]'s `frust_shadcn::scroll_area` precedent uses for the one
24//! other in-tree wrapper over this baseline.
25//!
26//! # Pull-progress mapping
27//!
28//! Upstream's drag mapping normalizes the raw drag delta against 25% of the
29//! scrollable's `containerExtent` (`_checkDragOffset`,
30//! `m3e_refresh_indicator_scroll.dart:248-279`) — a value `ScrollInfo` does
31//! not carry (no viewport dimension crosses this framework's seam). The port
32//! maps [`ScrollInfo::overscroll`] (already resistance-applied by
33//! `ScrollView`'s own rubber-band, negative past the top) directly in
34//! logical px instead: [`visual_pull`] clamps `-overscroll` to
35//! `displacement + indicator_height`, the same "resting refresh position"
36//! target upstream's own curve converges on. This is a **simpler curve, not
37//! a functionality loss** — document, not a `LIMITATIONS.md` entry.
38//!
39//! # Refresh lifecycle: controlled
40//!
41//! [`refresh_indicator`]'s `on_refresh` callback fires exactly once per pull
42//! released past the baseline's trigger — [`frust::ScrollView`]'s own
43//! `REFRESH_TRIGGER_PX` (64px, post-resistance) is the arming threshold, not
44//! a value this crate restates. Unlike upstream's `Future`-returning
45//! `onRefresh`, which the indicator itself awaits via a `Completer`, this
46//! port cannot hold an arbitrary async task inside a widget (there is no
47//! widget-local executor seam — see `docs/CORE_ARCHITECTURE.md`'s
48//! `spawn`/`spawn_blocking` routing). The lifecycle is **controlled**
49//! instead, matching this crate's `Checkbox`/`Slider` convention: `on_refresh`
50//! returns control to the app immediately; the app flips its own
51//! `refreshing` state to `true` (typically inside `on_refresh`, spawning the
52//! real async work alongside it) and feeds it back through
53//! [`RefreshIndicatorView::refreshing`] on the next rebuild. The indicator
54//! shows its spinner for as long as `refreshing` reads `true`, and only ends
55//! it (scaling away) on a rebuild where the app confirms `refreshing =
56//! false` — an app that never flips it back leaves the spinner showing
57//! forever, the same contract violation an unconfirmed `Checkbox` toggle is.
58//!
59//! A second release-past-trigger while a refresh is already in flight does
60//! **not** re-invoke `on_refresh` (`in_flight`, guarded at the callback
61//! itself) — the underlying scroll content stays interactive during a
62//! refresh (see the next section), so a fast second pull is a real,
63//! reachable case, not a hypothetical one.
64//!
65//! # Documented seam gap: the scroll offset is not held during a refresh
66//!
67//! Upstream visually "holds" the pulled content at the indicator's resting
68//! offset for the whole refresh, tied to the same `_positionController` that
69//! drives the indicator. `ScrollView`'s public seam has no "pin the offset
70//! until released" affordance — its own release-settle always eases back to
71//! the clamped edge once the pointer lifts, independent of whatever this
72//! wrapper is doing. The port accepts this: the indicator overlays its own
73//! resting position via [`Widget::paint`]-time compositing (translate +
74//! optional scale, [`frust::authoring::PaintScene::push_transform`]) while
75//! the scroll content underneath is free to settle and scroll normally
76//! during a refresh. **FINDING candidate**: a `ScrollView` seam to suppress
77//! the release-settle until a caller releases it would close this gap
78//! properly; per the plugin charter this crate does not patch
79//! `frust-widgets` to add it.
80//!
81//! A second, narrower gap: [`frust::ScrollView::on_scroll`] delivers an
82//! animation-driven change (the release-settle) **one event late**
83//! (documented on that method) rather than every paint frame, so this
84//! widget's own reveal never rides the baseline's settle motion — it runs
85//! its own `snap`/`retract` [`AnimationController`]s from the pull distance
86//! observed at release/cancel instead, independent of whatever the
87//! underlying surface does afterward.
88//!
89//! # Variants
90//!
91//! [`refresh_indicator`] is the expressive default
92//! ([`crate::LoadingIndicatorVariant::Default`]);
93//! [`RefreshIndicatorView::contained`] switches to
94//! [`crate::LoadingIndicatorVariant::Contained`] (both delegate their
95//! MaterialTokens color resolution to [`mod@crate::loading_indicator`]
96//! itself — this module never overrides a color). Upstream's `material`
97//! and `adaptive` variants (`RefreshProgressIndicator`,
98//! `CupertinoActivityIndicator`) are out of scope: only the two M3E-shaped
99//! ones plus [`RefreshIndicatorView::no_spinner`] (upstream's
100//! `M3ERefreshIndicator.noSpinner` — the trigger/lifecycle contract with no
101//! visual at all, for an app that draws its own affordance elsewhere) are
102//! ported.
103
104use std::cell::Cell;
105use std::rc::Rc;
106use std::time::Duration;
107
108use frust::authoring::{
109    Affine, AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult,
110    InputEvent, LayoutCtx, PaintCtx, PaintScene, Point, PointerPhase, SemanticsCtx, Size, View,
111    Widget, any, build_child, rebuild_child, route_event_single, teardown_child, visit_children,
112};
113use frust::{AnimationController, ScrollInfo, scroll_view};
114
115use crate::loading_indicator::loading_indicator;
116use crate::tokens::MaterialMotion;
117
118/// Default resting offset (logical px) between the scrollable's leading edge
119/// and the indicator once a refresh is showing — upstream's
120/// `kDefaultDisplacement` (`m3e_refresh_indicator_theme.dart:9-10`).
121const DEFAULT_DISPLACEMENT: f64 = 40.0;
122
123/// Duration of the release→resting snap: the indicator sliding in from
124/// wherever the pull left it while it starts spinning — upstream's
125/// `indicatorSnapDuration` (`m3e_refresh_indicator_theme.dart:32`, 150ms),
126/// which maps 1:1 onto this crate's own [`MaterialMotion::SHORT_3`] token.
127const SNAP_DURATION: Duration = MaterialMotion::SHORT_3;
128
129/// Duration of a retract — sliding back out (canceled) or shrinking away
130/// (done) — upstream's `indicatorScaleDuration`
131/// (`m3e_refresh_indicator_theme.dart:33`, 200ms), mapping onto
132/// [`MaterialMotion::SHORT_4`].
133const RETRACT_DURATION: Duration = MaterialMotion::SHORT_4;
134
135/// Fallback indicator diameter used only for the sliver of time between
136/// [`View::build`] and the first [`Widget::layout`] populating
137/// [`RefreshIndicatorWidget::indicator_size`] — matches
138/// [`mod@crate::loading_indicator`]'s own container size (its
139/// `LOADING_DIAMETER`, private to that module).
140const INDICATOR_FALLBACK_DIAMETER: f64 = 48.0;
141
142/// Maps the live pull distance to a px reveal, clamped to `max_pull` —
143/// upstream's `_visualPull` (`m3e_refresh_indicator_build.dart:19-21`). See
144/// the [module docs](self)'s Pull-progress mapping section for why this
145/// reads `overscroll` directly rather than a `containerExtent`-normalized
146/// value.
147fn visual_pull(overscroll: f64, max_pull: f64) -> f64 {
148    (-overscroll).clamp(0.0, max_pull.max(0.0))
149}
150
151/// The indicator's pull distance while [`Status::Refreshing`]'s snap-in ramp
152/// is running: an eased lerp from the pull distance captured at release
153/// (`from`) to the resting position (`max_pull`) — upstream's `_show`
154/// animating `_positionController` to `1.0 / kDragSizeFactorLimit`
155/// (`m3e_refresh_indicator_scroll.dart:354-358`), read back through
156/// `_pullDistance`'s snap/refresh/done/canceled branch
157/// (`m3e_refresh_indicator_build.dart:38-39`).
158fn snap_pull(from: f64, max_pull: f64, t: f64) -> f64 {
159    from + (max_pull - from) * t.clamp(0.0, 1.0)
160}
161
162/// The indicator's pull distance while [`RetractKind::Canceled`]'s retract
163/// ramp is running: an eased lerp from the pull distance captured at
164/// cancellation back to zero — upstream's `_animateDismiss`'s `canceled`
165/// branch animating `_positionController` to `0`
166/// (`m3e_refresh_indicator_scroll.dart:328-331`).
167fn cancel_retract_pull(from: f64, t: f64) -> f64 {
168    from * (1.0 - t.clamp(0.0, 1.0))
169}
170
171/// The indicator's scale while [`RetractKind::Done`]'s retract ramp is
172/// running — upstream's `_oneToZeroTween`/`_scaleFactor`
173/// (`m3e_refresh_indicator.dart:213-216,225`), animated to `1` over
174/// `indicatorScaleDuration` in `_animateDismiss`'s `done` branch.
175fn done_scale(t: f64) -> f64 {
176    1.0 - t.clamp(0.0, 1.0)
177}
178
179/// The indicator's lifecycle state — mirrors [upstream's
180/// `M3ERefreshStatus`](m3e_refresh_indicator/enums/m3e_refresh_status.dart),
181/// collapsed to what this port's simpler drag model needs (see the
182/// [module docs](self)). `Idle` (upstream's `null` status) paints nothing.
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184enum Status {
185    /// No pull in progress; nothing painted.
186    Idle,
187    /// A drag is pulling the surface past its top edge; the indicator
188    /// follows [`ScrollInfo::overscroll`] 1:1 (via [`visual_pull`]).
189    Pulling,
190    /// Released past the trigger: the indicator snaps to its resting
191    /// position and spins, holding there until the app confirms
192    /// [`RefreshIndicatorView::refreshing`]`(false)`.
193    Refreshing,
194    /// Retracting away, either because the pull was released under the
195    /// trigger (or abandoned) or because the app confirmed completion.
196    Retracting(RetractKind),
197}
198
199/// Which retract animation [`Status::Retracting`] is running — see
200/// [`cancel_retract_pull`]/[`done_scale`].
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202enum RetractKind {
203    /// Released under the trigger (or the gesture was abandoned): the
204    /// indicator slides back out, position animating to zero.
205    Canceled,
206    /// The app confirmed the refresh finished: the indicator shrinks away
207    /// in place, scale animating to zero.
208    Done,
209}
210
211/// A declarative M3E pull-to-refresh wrapper. See the [module docs](self).
212pub struct RefreshIndicatorView<State: 'static> {
213    /// The internally-built [`frust::scroll_view`], already carrying both
214    /// callbacks (see the [module docs](self)'s Architecture section).
215    scroll: AnyView<State>,
216    /// The spinner, or `None` for [`RefreshIndicatorView::no_spinner`].
217    indicator: Option<AnyView<State>>,
218    /// Shared with `scroll`'s installed `on_scroll` closure: the live
219    /// overscroll, written every scroll notification.
220    pull: Rc<Cell<f64>>,
221    /// Shared with `scroll`'s installed `on_refresh_release` closure:
222    /// edge-triggered, set `true` the instant a release past the trigger
223    /// occurs, drained by [`RefreshIndicatorWidget::event`] on the very next
224    /// event.
225    released: Rc<Cell<bool>>,
226    /// Shared with the same closure: guards against re-invoking the app's
227    /// `on_refresh` while a refresh is already in flight (see the
228    /// [module docs](self)).
229    in_flight: Rc<Cell<bool>>,
230    displacement: f64,
231    refreshing: bool,
232}
233
234/// Wrap `child` in a pull-to-refresh surface, firing `on_refresh` once per
235/// pull released past the baseline's trigger. See the [module docs](self)
236/// for the controlled `refreshing` lifecycle contract.
237pub fn refresh_indicator<State, V, R>(child: V, on_refresh: R) -> RefreshIndicatorView<State>
238where
239    State: 'static,
240    V: View<State>,
241    R: Fn(&mut State) + 'static,
242{
243    let pull = Rc::new(Cell::new(0.0_f64));
244    let released = Rc::new(Cell::new(false));
245    let in_flight = Rc::new(Cell::new(false));
246
247    let pull_for_scroll = pull.clone();
248    let released_for_scroll = released.clone();
249    let in_flight_for_scroll = in_flight.clone();
250    let on_refresh: Rc<dyn Fn(&mut State)> = Rc::new(on_refresh);
251
252    let scroll = any(scroll_view(child)
253        .on_scroll(move |_state: &mut State, info: ScrollInfo| {
254            pull_for_scroll.set(info.overscroll);
255        })
256        .on_refresh_release(move |state: &mut State| {
257            if in_flight_for_scroll.replace(true) {
258                // A refresh is already running — see the module docs'
259                // concurrency note; the app's callback fires at most once
260                // per in-flight refresh.
261                return;
262            }
263            released_for_scroll.set(true);
264            on_refresh(state);
265        }));
266
267    RefreshIndicatorView {
268        scroll,
269        indicator: Some(any(loading_indicator())),
270        pull,
271        released,
272        in_flight,
273        displacement: DEFAULT_DISPLACEMENT,
274        refreshing: false,
275    }
276}
277
278impl<State: 'static> RefreshIndicatorView<State> {
279    /// Switch to the contained spinner
280    /// ([`crate::LoadingIndicatorVariant::Contained`] — a filled, fully
281    /// rounded container behind the shape).
282    pub fn contained(mut self) -> Self {
283        self.indicator = Some(any(loading_indicator().contained()));
284        self
285    }
286
287    /// Drop the built-in spinner entirely — upstream's
288    /// `M3ERefreshIndicator.noSpinner`. The pull/trigger/`refreshing`
289    /// contract still runs; nothing is painted beyond the wrapped content.
290    pub fn no_spinner(mut self) -> Self {
291        self.indicator = None;
292        self
293    }
294
295    /// Override the resting offset (logical px) between the leading edge and
296    /// the indicator once it is showing — upstream's `displacement`.
297    /// Negative values clamp to `0`.
298    pub fn displacement(mut self, displacement: f64) -> Self {
299        self.displacement = displacement.max(0.0);
300        self
301    }
302
303    /// The controlled completion prop: `true` while the app's async refresh
304    /// is running. See the [module docs](self)'s Refresh lifecycle section —
305    /// flipping this back to `false` on a later rebuild is what ends the
306    /// spinner.
307    pub fn refreshing(mut self, refreshing: bool) -> Self {
308        self.refreshing = refreshing;
309        self
310    }
311}
312
313impl<State: 'static> View<State> for RefreshIndicatorView<State> {
314    type Element = RefreshIndicatorWidget;
315
316    fn build(&self, ctx: &mut BuildCtx<'_>) -> RefreshIndicatorWidget {
317        RefreshIndicatorWidget {
318            scroll: build_child(&self.scroll, ctx),
319            indicator: self.indicator.as_ref().map(|v| build_child(v, ctx)),
320            pull: self.pull.clone(),
321            released: self.released.clone(),
322            in_flight: self.in_flight.clone(),
323            displacement: self.displacement,
324            refreshing: self.refreshing,
325            status: Status::Idle,
326            pull_at_transition: 0.0,
327            snap: AnimationController::new(SNAP_DURATION),
328            retract: AnimationController::new(RETRACT_DURATION),
329            indicator_size: Size::new(INDICATOR_FALLBACK_DIAMETER, INDICATOR_FALLBACK_DIAMETER),
330        }
331    }
332
333    fn rebuild(
334        &self,
335        prev: &Self,
336        element: &mut RefreshIndicatorWidget,
337        ctx: &mut BuildCtx<'_>,
338    ) -> ChangeFlags {
339        let mut flags = rebuild_child(&prev.scroll, &self.scroll, &mut element.scroll, ctx);
340
341        // Closures are not comparable — always reinstall the shared cells
342        // (`ScrollWidget`'s own rebuild follows the identical discipline).
343        element.pull = self.pull.clone();
344        element.released = self.released.clone();
345        element.in_flight = self.in_flight.clone();
346        // A fresh cell defaults to `false`; re-sync it from the widget's own
347        // authoritative status rather than trust the fresh value, so an
348        // unrelated rebuild mid-refresh can never let a second pull re-fire
349        // `on_refresh` (see the module docs' concurrency note).
350        element
351            .in_flight
352            .set(matches!(element.status, Status::Refreshing));
353
354        if (element.displacement - self.displacement).abs() > f64::EPSILON {
355            element.displacement = self.displacement;
356            flags |= ChangeFlags::PAINT;
357        }
358
359        match (&prev.indicator, &self.indicator) {
360            (None, None) => {}
361            (Some(p), Some(n)) => {
362                let pod = element.indicator.as_mut().expect("indicator pod present");
363                flags |= rebuild_child(p, n, pod, ctx);
364            }
365            (None, Some(n)) => {
366                element.indicator = Some(build_child(n, ctx));
367                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
368            }
369            (Some(p), None) => {
370                let mut pod = element.indicator.take().expect("indicator pod present");
371                teardown_child(p, &mut pod, ctx);
372                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
373            }
374        }
375
376        // The controlled completion prop: a confirmed `refreshing: true ->
377        // false` transition, while still showing the spinner, begins the
378        // done-retract. Any other combination (still true, already
379        // retracting/idle) is a no-op here.
380        if !self.refreshing && element.refreshing && matches!(element.status, Status::Refreshing) {
381            element.begin_retract(RetractKind::Done);
382            flags |= ChangeFlags::PAINT;
383        }
384        element.refreshing = self.refreshing;
385
386        flags
387    }
388
389    fn teardown(&self, element: &mut RefreshIndicatorWidget, ctx: &mut BuildCtx<'_>) {
390        teardown_child(&self.scroll, &mut element.scroll, ctx);
391        if let (Some(view), Some(pod)) = (&self.indicator, element.indicator.as_mut()) {
392            teardown_child(view, pod, ctx);
393        }
394    }
395}
396
397/// The retained widget for a [`RefreshIndicatorView`]. See the
398/// [module docs](self).
399pub struct RefreshIndicatorWidget {
400    scroll: ChildPod,
401    indicator: Option<ChildPod>,
402    pull: Rc<Cell<f64>>,
403    released: Rc<Cell<bool>>,
404    in_flight: Rc<Cell<bool>>,
405    displacement: f64,
406    refreshing: bool,
407    status: Status,
408    /// The pull distance (px) captured at the moment [`Status::Refreshing`]
409    /// or [`Status::Retracting`]`(Canceled)` began — the `from` endpoint
410    /// [`snap_pull`]/[`cancel_retract_pull`] lerp away from.
411    pull_at_transition: f64,
412    /// Drives [`Status::Refreshing`]'s snap-to-resting position.
413    snap: AnimationController,
414    /// Drives [`Status::Retracting`]'s position (canceled) or scale (done).
415    retract: AnimationController,
416    /// The spinner's natural size, cached from the last [`Widget::layout`]
417    /// (defaults to [`INDICATOR_FALLBACK_DIAMETER`] before the first one).
418    indicator_size: Size,
419}
420
421impl RefreshIndicatorWidget {
422    fn max_pull(&self) -> f64 {
423        self.displacement + self.indicator_size.height
424    }
425
426    /// Begin [`Status::Refreshing`]: snap the indicator from wherever the
427    /// pull currently reads toward its resting position. The app's
428    /// `on_refresh` has already been invoked by the installed closure at
429    /// this point (see [`refresh_indicator`]) — this only starts the visual
430    /// ramp and marks the in-flight guard.
431    fn begin_refresh(&mut self) {
432        self.pull_at_transition = visual_pull(self.pull.get(), self.max_pull());
433        self.snap = AnimationController::new(SNAP_DURATION);
434        self.snap.forward();
435        self.status = Status::Refreshing;
436        self.in_flight.set(true);
437    }
438
439    /// Begin [`RetractKind::Canceled`]: slide the indicator back out from
440    /// wherever the pull currently reads.
441    fn begin_cancel(&mut self) {
442        self.pull_at_transition = visual_pull(self.pull.get(), self.max_pull());
443        self.retract = AnimationController::new(RETRACT_DURATION);
444        self.retract.forward();
445        self.status = Status::Retracting(RetractKind::Canceled);
446    }
447
448    /// Begin a retract of `kind` (called for [`RetractKind::Done`] from
449    /// [`RefreshIndicatorView::rebuild`] once the app confirms completion;
450    /// [`RefreshIndicatorWidget::begin_cancel`] is the `Canceled` entry
451    /// point instead, since that one starts from a live event rather than a
452    /// prop change).
453    fn begin_retract(&mut self, kind: RetractKind) {
454        self.retract = AnimationController::new(RETRACT_DURATION);
455        self.retract.forward();
456        self.status = Status::Retracting(kind);
457        self.in_flight.set(false);
458    }
459}
460
461impl Widget for RefreshIndicatorWidget {
462    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
463        let size = self.scroll.layout_child(ctx, bc);
464        self.scroll.set_origin(Point::ORIGIN);
465        if let Some(indicator) = self.indicator.as_mut() {
466            let d = indicator.layout_child(ctx, &BoxConstraints::loose(size));
467            self.indicator_size = d;
468            indicator.set_origin(Point::new((size.width - d.width) / 2.0, 0.0));
469        }
470        bc.constrain(size)
471    }
472
473    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
474        let (origin, size) = (ctx.origin(), ctx.size());
475        self.scroll.paint_child(ctx, scene);
476
477        let max_pull = self.max_pull();
478        let (pull_px, scale) = match self.status {
479            Status::Idle => return,
480            Status::Pulling => (visual_pull(self.pull.get(), max_pull), 1.0),
481            Status::Refreshing => {
482                if self.snap.advance(ctx.frame_time()) {
483                    ctx.request_frame();
484                }
485                (
486                    snap_pull(self.pull_at_transition, max_pull, self.snap.value_clamped()),
487                    1.0,
488                )
489            }
490            Status::Retracting(kind) => {
491                if self.retract.advance(ctx.frame_time()) {
492                    ctx.request_frame();
493                } else {
494                    // Settled: retire back to idle on this same, final frame
495                    // (the last one still paints the settled state below).
496                    self.status = Status::Idle;
497                    self.pull.set(0.0);
498                }
499                let t = self.retract.value_clamped();
500                match kind {
501                    RetractKind::Canceled => (cancel_retract_pull(self.pull_at_transition, t), 1.0),
502                    RetractKind::Done => (max_pull, done_scale(t)),
503                }
504            }
505        };
506
507        let Some(indicator) = self.indicator.as_mut() else {
508            return;
509        };
510        let indicator_h = self.indicator_size.height;
511        let inset = pull_px - indicator_h;
512
513        // A hard-edge clip over the whole box — the indicator's reveal slot,
514        // matching a `Stack`'s default clip behavior (upstream relies on its
515        // ancestor `Stack` for the same effect).
516        scene.push_clip(origin, size);
517        if scale < 1.0 {
518            let cx = origin.x + indicator.origin().x + indicator.size().width / 2.0;
519            let cy = origin.y + inset + indicator_h / 2.0;
520            scene.push_transform(
521                Affine::translate((cx, cy))
522                    * Affine::scale(scale)
523                    * Affine::translate((-cx, -cy))
524                    * Affine::translate((0.0, inset)),
525            );
526        } else {
527            scene.push_transform(Affine::translate((0.0, inset)));
528        }
529        indicator.paint_child(ctx, scene);
530        scene.pop_transform();
531        scene.pop_clip();
532    }
533
534    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
535        let result = route_event_single(&mut self.scroll, ctx, event);
536
537        if self.released.replace(false) && !matches!(self.status, Status::Refreshing) {
538            self.begin_refresh();
539        } else {
540            match self.status {
541                Status::Idle => {
542                    if self.pull.get() < 0.0 {
543                        self.status = Status::Pulling;
544                    }
545                }
546                Status::Pulling => {
547                    let released_gesture = matches!(
548                        event,
549                        InputEvent::Pointer(p)
550                            if matches!(p.phase, PointerPhase::Up | PointerPhase::Cancel)
551                    );
552                    if released_gesture {
553                        self.begin_cancel();
554                    }
555                }
556                Status::Refreshing | Status::Retracting(_) => {}
557            }
558        }
559
560        result
561    }
562
563    fn semantics(&self, ctx: &mut SemanticsCtx) {
564        // The scroll surface contributes the node; the indicator is
565        // decoration (the same call `frust_shadcn::scroll_area` makes for
566        // its own overlay).
567        self.scroll.semantics_child(ctx);
568    }
569
570    visit_children!(scroll, indicator);
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use frust::FrameTime;
577    use frust::authoring::{
578        BezPath, Brush, Color, PaintCtx as TestPaintCtx, PointerButton, PointerEvent,
579    };
580    use std::any::Any;
581
582    // -----------------------------------------------------------------------
583    // Pull-progress mapping (pure functions) — AC1.
584    // -----------------------------------------------------------------------
585
586    #[test]
587    fn visual_pull_tracks_overscroll_and_clamps_to_max_pull() {
588        assert_eq!(visual_pull(0.0, 88.0), 0.0, "in range: no reveal");
589        assert_eq!(visual_pull(-10.0, 88.0), 10.0);
590        assert_eq!(visual_pull(-64.0, 88.0), 64.0, "exactly at the trigger");
591        assert_eq!(
592            visual_pull(-500.0, 88.0),
593            88.0,
594            "clamps to the resting-position cap"
595        );
596        assert_eq!(
597            visual_pull(5.0, 88.0),
598            0.0,
599            "past-bottom overscroll is not a pull"
600        );
601    }
602
603    #[test]
604    fn snap_pull_lerps_from_the_captured_pull_to_the_resting_position() {
605        assert_eq!(snap_pull(20.0, 88.0, 0.0), 20.0, "t=0: still at the pull");
606        assert_eq!(snap_pull(20.0, 88.0, 1.0), 88.0, "t=1: fully at rest");
607        assert_eq!(snap_pull(20.0, 88.0, 0.5), 54.0, "t=0.5: halfway");
608        assert_eq!(snap_pull(0.0, 88.0, 2.0), 88.0, "t is clamped past 1");
609    }
610
611    #[test]
612    fn cancel_retract_pull_lerps_the_captured_pull_back_to_zero() {
613        assert_eq!(cancel_retract_pull(40.0, 0.0), 40.0);
614        assert_eq!(cancel_retract_pull(40.0, 1.0), 0.0);
615        assert_eq!(cancel_retract_pull(40.0, 0.5), 20.0);
616        assert_eq!(
617            cancel_retract_pull(40.0, -1.0),
618            40.0,
619            "t is clamped below 0"
620        );
621    }
622
623    #[test]
624    fn done_scale_shrinks_from_one_to_zero() {
625        assert_eq!(done_scale(0.0), 1.0);
626        assert_eq!(done_scale(1.0), 0.0);
627        assert_eq!(done_scale(0.25), 0.75);
628    }
629
630    // -----------------------------------------------------------------------
631    // Lifecycle fixtures — AC2/AC3.
632    // -----------------------------------------------------------------------
633
634    #[derive(Default)]
635    struct AppState {
636        refreshes: u32,
637        refreshing: bool,
638    }
639
640    #[derive(Clone, Copy)]
641    struct Content(Size);
642    struct ContentWidget(Size);
643
644    impl<S: 'static> View<S> for Content {
645        type Element = ContentWidget;
646        fn build(&self, _ctx: &mut BuildCtx<'_>) -> ContentWidget {
647            ContentWidget(self.0)
648        }
649        fn rebuild(
650            &self,
651            _prev: &Self,
652            _element: &mut ContentWidget,
653            _ctx: &mut BuildCtx<'_>,
654        ) -> ChangeFlags {
655            ChangeFlags::NONE
656        }
657    }
658    impl Widget for ContentWidget {
659        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
660            bc.constrain(self.0)
661        }
662        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
663    }
664
665    const VIEWPORT: Size = Size::new(200.0, 400.0);
666    const CONTENT: Size = Size::new(200.0, 1000.0);
667
668    fn build_view(on_refresh: impl Fn(&mut AppState) + 'static) -> RefreshIndicatorView<AppState> {
669        refresh_indicator(Content(CONTENT), on_refresh)
670    }
671
672    fn build(view: &RefreshIndicatorView<AppState>) -> RefreshIndicatorWidget {
673        let mut counter = 0u64;
674        let mut w = View::<AppState>::build(view, &mut BuildCtx::new(&mut counter));
675        let mut lctx = LayoutCtx::new();
676        w.layout(&mut lctx, &BoxConstraints::loose(VIEWPORT));
677        w
678    }
679
680    fn ptr(phase: PointerPhase, y: f64) -> InputEvent {
681        InputEvent::Pointer(PointerEvent {
682            phase,
683            position: Point::new(50.0, y),
684            button: PointerButton::Primary,
685        })
686    }
687
688    fn dispatch(w: &mut RefreshIndicatorWidget, state: &mut AppState, event: &InputEvent) {
689        let sa: &mut dyn Any = state;
690        let mut ctx = EventCtx::new(sa, Point::ZERO, VIEWPORT);
691        w.event(&mut ctx, event);
692    }
693
694    /// Drags past the baseline's refresh trigger (raw -200 -> resisted -100,
695    /// past the 64px threshold — the same sequence
696    /// `frust_widgets::scroll`'s own `on_refresh_release` test uses) and
697    /// releases.
698    fn drag_past_trigger(w: &mut RefreshIndicatorWidget, state: &mut AppState) {
699        dispatch(w, state, &ptr(PointerPhase::Down, 50.0));
700        dispatch(w, state, &ptr(PointerPhase::Move, 90.0)); // takeover
701        dispatch(w, state, &ptr(PointerPhase::Move, 290.0)); // past trigger
702        dispatch(w, state, &ptr(PointerPhase::Up, 290.0));
703    }
704
705    #[derive(Default)]
706    struct Recorder {
707        fills: usize,
708        rrects: usize,
709    }
710    impl PaintScene for Recorder {
711        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
712        fn draw_text(&mut self, _o: Point, _t: &str) {}
713        fn fill_path(&mut self, _o: Point, _p: &BezPath, _b: &Brush) {
714            self.fills += 1;
715        }
716        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {
717            self.rrects += 1;
718        }
719    }
720
721    fn paint_at(w: &mut RefreshIndicatorWidget, t_ms: u64) -> Recorder {
722        let mut rec = Recorder::default();
723        let mut ctx = TestPaintCtx::for_test(
724            Point::ZERO,
725            VIEWPORT,
726            FrameTime::from_nanos(t_ms * 1_000_000),
727        );
728        w.paint(&mut ctx, &mut rec);
729        rec
730    }
731
732    // -----------------------------------------------------------------------
733    // Release-trigger + refreshing-state lifecycle — AC2.
734    // -----------------------------------------------------------------------
735
736    #[test]
737    fn a_pull_under_the_trigger_does_not_refresh_and_retracts() {
738        let view = build_view(|_| {});
739        let mut w = build(&view);
740        let mut state = AppState::default();
741        dispatch(&mut w, &mut state, &ptr(PointerPhase::Down, 50.0));
742        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 90.0)); // takeover
743        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 180.0)); // raw -90 -> -45
744        assert_eq!(w.status, Status::Pulling);
745        dispatch(&mut w, &mut state, &ptr(PointerPhase::Up, 180.0));
746
747        assert_eq!(state.refreshes, 0, "released under the trigger");
748        assert_eq!(w.status, Status::Retracting(RetractKind::Canceled));
749
750        paint_at(&mut w, 0); // seed
751        paint_at(&mut w, 300); // past RETRACT_DURATION (200ms)
752        assert_eq!(w.status, Status::Idle, "settles back to idle");
753    }
754
755    #[test]
756    fn a_pull_past_the_trigger_fires_on_refresh_exactly_once_and_holds_while_refreshing() {
757        let view = build_view(|s: &mut AppState| {
758            s.refreshes += 1;
759            s.refreshing = true;
760        });
761        let mut w = build(&view);
762        let mut state = AppState::default();
763        drag_past_trigger(&mut w, &mut state);
764
765        assert_eq!(state.refreshes, 1);
766        assert!(state.refreshing, "on_refresh flipped the app's own flag");
767        assert_eq!(w.status, Status::Refreshing);
768
769        // The spinner holds at rest regardless of how much time passes,
770        // until the app confirms completion.
771        paint_at(&mut w, 0);
772        paint_at(&mut w, 1000);
773        assert_eq!(w.status, Status::Refreshing, "still holding");
774    }
775
776    #[test]
777    fn cancel_mid_pull_abandons_without_firing_refresh() {
778        let view = build_view(|s: &mut AppState| s.refreshes += 1);
779        let mut w = build(&view);
780        let mut state = AppState::default();
781        dispatch(&mut w, &mut state, &ptr(PointerPhase::Down, 50.0));
782        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 90.0));
783        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 290.0)); // past trigger
784        assert_eq!(w.status, Status::Pulling, "not yet released");
785        dispatch(&mut w, &mut state, &ptr(PointerPhase::Cancel, 290.0));
786
787        assert_eq!(
788            state.refreshes, 0,
789            "a Cancel never fires the refresh callback"
790        );
791        assert_eq!(w.status, Status::Retracting(RetractKind::Canceled));
792    }
793
794    #[test]
795    fn a_second_pull_while_refreshing_does_not_refire_on_refresh() {
796        let view = build_view(|s: &mut AppState| {
797            s.refreshes += 1;
798            s.refreshing = true;
799        });
800        let mut w = build(&view);
801        let mut state = AppState::default();
802        drag_past_trigger(&mut w, &mut state);
803        assert_eq!(state.refreshes, 1);
804        assert_eq!(w.status, Status::Refreshing);
805
806        // A second pull, released past the trigger again, while the app has
807        // not yet confirmed completion.
808        drag_past_trigger(&mut w, &mut state);
809        assert_eq!(
810            state.refreshes, 1,
811            "a refresh already in flight is not re-triggered"
812        );
813    }
814
815    #[test]
816    fn refreshing_confirmed_false_ends_the_spinner_and_settles_to_idle() {
817        let make = || {
818            build_view(|s: &mut AppState| {
819                s.refreshes += 1;
820                s.refreshing = true;
821            })
822        };
823        let view1 = make();
824        let mut w = build(&view1);
825        let mut state = AppState::default();
826        drag_past_trigger(&mut w, &mut state);
827        assert_eq!(w.status, Status::Refreshing);
828
829        // Next rebuild: the app's `refreshing = true` is fed straight back —
830        // no transition yet.
831        let view2 = make().refreshing(true);
832        let mut counter = 1u64;
833        View::<AppState>::rebuild(&view2, &view1, &mut w, &mut BuildCtx::new(&mut counter));
834        assert_eq!(w.status, Status::Refreshing, "still confirmed in-flight");
835
836        // The app's async work completes; the next rebuild confirms
837        // `refreshing = false`.
838        let view3 = make().refreshing(false);
839        View::<AppState>::rebuild(&view3, &view2, &mut w, &mut BuildCtx::new(&mut counter));
840        assert_eq!(w.status, Status::Retracting(RetractKind::Done));
841
842        paint_at(&mut w, 0);
843        paint_at(&mut w, 300); // past RETRACT_DURATION
844        assert_eq!(w.status, Status::Idle);
845
846        // The in-flight guard released too: a fresh pull can refresh again.
847        drag_past_trigger(&mut w, &mut state);
848        assert_eq!(state.refreshes, 2);
849    }
850
851    // -----------------------------------------------------------------------
852    // Variants render — AC3.
853    // -----------------------------------------------------------------------
854
855    #[test]
856    fn idle_paints_only_the_wrapped_content() {
857        let view = build_view(|_| {});
858        let mut w = build(&view);
859        let rec = paint_at(&mut w, 0);
860        assert_eq!(rec.fills, 0);
861        assert_eq!(rec.rrects, 0);
862    }
863
864    #[test]
865    fn pulling_reveals_the_default_expressive_spinner_with_no_container() {
866        let view = build_view(|_| {});
867        let mut w = build(&view);
868        let mut state = AppState::default();
869        dispatch(&mut w, &mut state, &ptr(PointerPhase::Down, 50.0));
870        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 90.0));
871        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 150.0));
872        assert_eq!(w.status, Status::Pulling);
873
874        let rec = paint_at(&mut w, 0);
875        assert_eq!(rec.fills, 1, "the morphing shape paints");
876        assert_eq!(rec.rrects, 0, "the default variant paints no container");
877    }
878
879    #[test]
880    fn contained_variant_paints_a_container_behind_the_shape() {
881        let view = build_view(|_| {}).contained();
882        let mut w = build(&view);
883        let mut state = AppState::default();
884        dispatch(&mut w, &mut state, &ptr(PointerPhase::Down, 50.0));
885        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 90.0));
886        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 150.0));
887
888        let rec = paint_at(&mut w, 0);
889        assert_eq!(rec.fills, 1);
890        assert_eq!(rec.rrects, 1, "the contained variant paints its container");
891    }
892
893    #[test]
894    fn no_spinner_paints_nothing_extra_through_the_whole_lifecycle() {
895        let view = build_view(|s: &mut AppState| s.refreshing = true).no_spinner();
896        let mut w = build(&view);
897        let mut state = AppState::default();
898
899        dispatch(&mut w, &mut state, &ptr(PointerPhase::Down, 50.0));
900        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 90.0));
901        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 150.0));
902        assert_eq!(w.status, Status::Pulling, "the lifecycle still tracks");
903        let rec = paint_at(&mut w, 0);
904        assert_eq!(rec.fills, 0);
905        assert_eq!(rec.rrects, 0);
906
907        dispatch(&mut w, &mut state, &ptr(PointerPhase::Move, 290.0));
908        dispatch(&mut w, &mut state, &ptr(PointerPhase::Up, 290.0));
909        assert_eq!(
910            w.status,
911            Status::Refreshing,
912            "the trigger contract still fires"
913        );
914        assert!(state.refreshing);
915        let rec = paint_at(&mut w, 0);
916        assert_eq!(rec.fills, 0, "still nothing painted");
917        assert_eq!(rec.rrects, 0);
918    }
919
920    #[test]
921    fn displacement_and_indicator_height_set_the_resting_pull_cap() {
922        let view = build_view(|_| {}).displacement(100.0);
923        let w = build(&view);
924        assert_eq!(w.max_pull(), 100.0 + INDICATOR_FALLBACK_DIAMETER_FOR_TEST);
925    }
926
927    /// Mirrors [`INDICATOR_FALLBACK_DIAMETER`] for the one geometry
928    /// assertion above — [`Widget::layout`] always runs before this test
929    /// reads `max_pull`, via [`build`], so `indicator_size` already reflects
930    /// the real (identical) 48×48 spinner size; kept as its own named
931    /// constant so the assertion doesn't silently drift if the fallback
932    /// value ever changes without the real spinner's size changing too.
933    const INDICATOR_FALLBACK_DIAMETER_FOR_TEST: f64 = 48.0;
934}