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}