Skip to main content

frus_widgets/
refresh.rs

1//! [`RefreshIndicator`] — pull a list past its top edge to ask for fresh data.
2//!
3//! ```ignore
4//! RefreshIndicator::new(list)
5//!     .on_refresh(Msg::Reload)
6//!     .refreshing(self.loading)
7//! ```
8//!
9//! ## What drives it
10//!
11//! Nothing new is measured. Milestone 279 established that
12//! `ScrollPhysics::apply_boundary_conditions` returns the movement the physics
13//! **refused** — the distance the finger asked for and did not get. The overscroll glow
14//! acknowledges that distance; a refresh area *accumulates* it, and past a threshold
15//! turns it into a message.
16//!
17//! Where a `RefreshIndicator` is listening, the glow on that edge stands down. Two answers to
18//! one gesture would say the same thing twice, and the indicator already is the
19//! acknowledgement.
20//!
21//! ## Who decides when it is over
22//!
23//! The framework reports that the user asked; the application decides when the answer
24//! has arrived. `on_refresh` is dispatched on release, and the indicator keeps spinning
25//! for exactly as long as the tree is rebuilt with `refreshing(true)`. There is no
26//! future to await and no callback to complete: the flag in the application's own state
27//! is the single source of truth, which is also what makes the whole thing testable
28//! without a clock.
29//!
30//! The message fires **on release** rather than when the indicator finishes snapping
31//! into place. The snap takes 150 ms, and there is no reason to make a network request
32//! wait for an animation.
33
34use frus_core::{Color, Rect, Scene};
35use frus_layout::Style;
36
37use crate::interaction::{Status, WidgetId};
38use crate::spinner::{paint_activity_ring, RingMode};
39use crate::theme::Theme;
40use crate::widget::Widget;
41
42/// How far the finger must drag to fill the indicator, as a fraction of the
43/// scrollable's own extent. A long list and a short one therefore ask for the same
44/// *proportional* gesture rather than the same number of pixels.
45pub const DRAG_EXTENT_FRACTION: f32 = 0.25;
46
47/// How far past the resting displacement the drag may push the indicator.
48pub const DRAG_SIZE_FACTOR_LIMIT: f32 = 1.5;
49
50/// The fill at which the pull is **armed** — releasing now triggers a refresh — and
51/// equally the fill the indicator settles back to while it spins.
52pub const ARM_AT: f32 = 1.0 / DRAG_SIZE_FACTOR_LIMIT;
53
54/// Seconds for a released, armed pull to settle to its resting place.
55pub const SNAP_TIME: f32 = 0.150;
56
57/// Seconds for the indicator to scale away when the refresh is over, and equally for
58/// an unarmed pull to slide back out of sight.
59pub const SCALE_TIME: f32 = 0.200;
60
61/// How much of the ring a full drag fills: three quarters, so that a completed pull is
62/// visibly *not* a completed circle — the circle is what spinning means.
63pub const DRAG_FILL: f32 = 0.75;
64
65/// Where a refresh area is in its cycle.
66#[derive(Copy, Clone, Debug, PartialEq, Eq)]
67pub enum RefreshPhase {
68    /// A finger is pulling, but not far enough yet.
69    Drag,
70    /// A finger is pulling past the threshold: releasing now refreshes.
71    Armed,
72    /// Released while armed: settling to the resting displacement.
73    Snap,
74    /// The application is working, and says so.
75    RefreshIndicator,
76    /// The work is over: scaling away.
77    Done,
78    /// Released short of the threshold: sliding back out of sight.
79    Cancel,
80}
81
82impl RefreshPhase {
83    /// `true` while a finger still owns the pull.
84    pub fn is_dragging(&self) -> bool {
85        matches!(self, RefreshPhase::Drag | RefreshPhase::Armed)
86    }
87}
88
89/// The retained state of one refresh area, held by the [`crate::Runtime`].
90#[derive(Copy, Clone, Debug)]
91pub struct RefreshPull {
92    phase: RefreshPhase,
93    /// Accumulated overscroll, in px — the raw thing the finger did.
94    drag: f32,
95    /// How full the indicator is, `0..=1`. `ARM_AT` is its resting place.
96    position: f32,
97    /// How far the indicator has scaled away, `0..=1`; `1` is gone.
98    scaled_out: f32,
99    /// Where an animated phase started from, and how far through it is (seconds).
100    from: f32,
101    elapsed: f32,
102    /// Turns of the spinning ring — kept here rather than read from the global clock,
103    /// so the ring starts from rest each time rather than from wherever the clock was.
104    turns: f32,
105}
106
107impl Default for RefreshPull {
108    fn default() -> Self {
109        Self {
110            phase: RefreshPhase::Drag,
111            drag: 0.0,
112            position: 0.0,
113            scaled_out: 0.0,
114            from: 0.0,
115            elapsed: 0.0,
116            turns: 0.0,
117        }
118    }
119}
120
121impl RefreshPull {
122    /// Where the area is in its cycle.
123    pub fn phase(&self) -> RefreshPhase {
124        self.phase
125    }
126
127    /// How full the indicator is, `0..=1`.
128    pub fn position(&self) -> f32 {
129        self.position
130    }
131
132    /// How much of the indicator is still there, `0..=1` — `Done` shrinks it to zero.
133    pub fn scale(&self) -> f32 {
134        1.0 - self.scaled_out
135    }
136
137    /// `true` once the area has nothing left to show, so the runtime can forget it.
138    pub fn is_idle(&self) -> bool {
139        (self.phase == RefreshPhase::Cancel && self.position <= 0.0)
140            || (self.phase == RefreshPhase::Done && self.scaled_out >= 1.0)
141    }
142
143    /// Accumulates `overscroll` px of refused movement over a scrollable of
144    /// `extent` px, and arms the pull once it is full enough.
145    ///
146    /// A pull that has already been released is not resumed: the finger that started
147    /// it is gone, and a second finger arriving mid-animation should start its own.
148    fn pull(&mut self, overscroll: f32, extent: f32) {
149        if !self.phase.is_dragging() || extent <= 0.0 {
150            return;
151        }
152        self.drag = (self.drag + overscroll).max(0.0);
153        let full = extent * DRAG_EXTENT_FRACTION;
154        let mut value = self.drag / full;
155        // An armed pull cannot fall back below the threshold by easing off; only
156        // letting go ends it. Otherwise the indicator would flicker in and out of
157        // "release me now" as the finger wavers around the line.
158        if self.phase == RefreshPhase::Armed {
159            value = value.max(ARM_AT);
160        }
161        self.position = value.clamp(0.0, 1.0);
162        if self.phase == RefreshPhase::Drag && self.position >= ARM_AT {
163            self.phase = RefreshPhase::Armed;
164        }
165    }
166
167    /// The pull is called off without a release — the list scrolled away from its top
168    /// edge, or the gesture was cancelled outright. It slides back as an unarmed
169    /// release would, and asks for nothing.
170    fn cancel(&mut self) {
171        if self.phase.is_dragging() {
172            self.begin(RefreshPhase::Cancel);
173        }
174    }
175
176    /// The finger lets go. Returns `true` when the pull was armed, and so when the
177    /// application should be told to refresh.
178    fn release(&mut self) -> bool {
179        if !self.phase.is_dragging() {
180            return false;
181        }
182        let armed = self.phase == RefreshPhase::Armed;
183        self.phase = if armed {
184            RefreshPhase::Snap
185        } else {
186            RefreshPhase::Cancel
187        };
188        self.from = self.position;
189        self.elapsed = 0.0;
190        armed
191    }
192
193    /// Advances by `dt` seconds, told whether the application still says it is
194    /// working. Returns `true` while anything is still moving.
195    fn advance(&mut self, dt: f32, refreshing: bool) -> bool {
196        match self.phase {
197            // A finger owns the pull; nothing moves on its own.
198            RefreshPhase::Drag | RefreshPhase::Armed => false,
199            RefreshPhase::Snap => {
200                self.elapsed += dt;
201                let t = (self.elapsed / SNAP_TIME).clamp(0.0, 1.0);
202                self.position = self.from + (ARM_AT - self.from) * t;
203                if t >= 1.0 {
204                    // By now the application has had many frames to answer the
205                    // message; its flag is authoritative. A refresh that needed no
206                    // work simply plays snap → done, which reads as "already up to
207                    // date" rather than as a spinner that never spun.
208                    self.begin(if refreshing {
209                        RefreshPhase::RefreshIndicator
210                    } else {
211                        RefreshPhase::Done
212                    });
213                }
214                true
215            }
216            RefreshPhase::RefreshIndicator => {
217                self.turns += dt * SPIN_SPEED;
218                if !refreshing {
219                    self.begin(RefreshPhase::Done);
220                }
221                true
222            }
223            RefreshPhase::Done => {
224                self.elapsed += dt;
225                self.scaled_out = (self.elapsed / SCALE_TIME).clamp(0.0, 1.0);
226                self.scaled_out < 1.0
227            }
228            RefreshPhase::Cancel => {
229                self.elapsed += dt;
230                let t = (self.elapsed / SCALE_TIME).clamp(0.0, 1.0);
231                self.position = self.from * (1.0 - t);
232                t < 1.0
233            }
234        }
235    }
236
237    /// Enters `phase`, remembering where the animated quantities start from.
238    fn begin(&mut self, phase: RefreshPhase) {
239        self.phase = phase;
240        self.from = self.position;
241        self.elapsed = 0.0;
242    }
243}
244
245/// Turns per second of the ring while the application is working.
246const SPIN_SPEED: f32 = 1.1;
247
248/// What a [`RefreshIndicator`] widget tells the frame about itself: everything the shell and the
249/// paint need, without either of them holding on to the widget.
250#[derive(Copy, Clone, Debug, PartialEq)]
251pub struct RefreshSpec {
252    /// The application says it is working.
253    pub refreshing: bool,
254    /// Where the indicator rests, in px below the edge, while it spins.
255    pub displacement: f32,
256    /// The indicator's diameter, in px.
257    pub size: f32,
258    /// The ring's colour; `None` takes the theme's primary.
259    pub color: Option<Color>,
260    /// The disc behind the ring; `None` takes the theme's surface.
261    pub background: Option<Color>,
262}
263
264impl Default for RefreshSpec {
265    fn default() -> Self {
266        Self {
267            refreshing: false,
268            displacement: 40.0,
269            size: 36.0,
270            color: None,
271            background: None,
272        }
273    }
274}
275
276/// One refresh area of the frame: where it is, and what it was configured with.
277#[derive(Copy, Clone, Debug, PartialEq)]
278pub struct Refreshable {
279    /// The area's identity, which is also the key of its retained pull.
280    pub id: WidgetId,
281    /// The box the indicator is drawn in — the child's, on screen.
282    pub viewport: Rect,
283    /// The widget's configuration this frame.
284    pub spec: RefreshSpec,
285}
286
287/// Wraps a scrollable so that pulling it past its top edge asks for fresh data.
288///
289/// ```ignore
290/// RefreshIndicator::new(SingleChildScrollView::new().child(rows))
291///     .on_refresh(Msg::Reload)
292///     .refreshing(self.loading)
293///     .color(theme.scheme.tertiary)     // every part is overridable
294/// ```
295///
296/// The child does not have to be a `SingleChildScrollView` directly — any scrollable anywhere inside
297/// feeds the pull, which is what lets a screen keep its own layout around the list.
298pub struct RefreshIndicator<Msg = crate::callback::Callback> {
299    spec: RefreshSpec,
300    message: Option<Msg>,
301    children: Vec<Box<dyn Widget<Msg>>>,
302}
303
304impl<Msg> RefreshIndicator<Msg> {
305    /// Makes `child` refreshable. Nothing happens until
306    /// [`on_refresh`](Self::on_refresh) gives it a message to send.
307    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
308        Self {
309            spec: RefreshSpec::default(),
310            message: None,
311            children: vec![Box::new(child)],
312        }
313    }
314
315    /// The message dispatched when an armed pull is released.
316    pub fn on_refresh(mut self, message: impl Into<Msg>) -> Self {
317        self.message = Some(message.into());
318        self
319    }
320
321    /// Whether the application is currently working. The indicator spins for exactly
322    /// as long as this is `true`.
323    pub fn refreshing(mut self, refreshing: bool) -> Self {
324        self.spec.refreshing = refreshing;
325        self
326    }
327
328    /// Where the indicator rests while it spins, in px below the top edge.
329    pub fn displacement(mut self, displacement: f32) -> Self {
330        self.spec.displacement = displacement;
331        self
332    }
333
334    /// The indicator's diameter, in px.
335    pub fn size(mut self, size: f32) -> Self {
336        self.spec.size = size;
337        self
338    }
339
340    /// The ring's colour, overriding the theme's primary.
341    pub fn color(mut self, color: Color) -> Self {
342        self.spec.color = Some(color);
343        self
344    }
345
346    /// The disc behind the ring, overriding the theme's surface.
347    pub fn background(mut self, color: Color) -> Self {
348        self.spec.background = Some(color);
349        self
350    }
351}
352
353impl<Msg: Clone> Widget<Msg> for RefreshIndicator<Msg> {
354    fn style(&self) -> Style {
355        // A pass-through: the child decides the box, and the indicator is drawn over
356        // it rather than laid out beside it.
357        Style::default()
358    }
359
360    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
361        &self.children
362    }
363
364    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
365        // The indicator is painted *after* the subtree, by the walk — otherwise the
366        // list would be drawn on top of it.
367    }
368
369    fn on_click(&self) -> Option<Msg> {
370        None
371    }
372
373    fn refresh(&self) -> Option<RefreshSpec> {
374        Some(self.spec)
375    }
376
377    fn on_refresh(&self) -> Option<Msg> {
378        self.message.clone()
379    }
380}
381
382/// Draws the indicator of one refresh area over its viewport.
383///
384/// The geometry is a single factor: `position × DRAG_SIZE_FACTOR_LIMIT` runs from 0 to
385/// 1.5, and carries the indicator from just above the edge (invisible) to `displacement`
386/// px below it (at rest) and half as far again (a drag that overshoots).
387pub fn paint_refresh(
388    scene: &mut Scene,
389    area: &Refreshable,
390    pull: &RefreshPull,
391    theme: &Theme,
392    clip: Rect,
393) {
394    let scale = pull.scale();
395    if scale <= 0.0 || pull.position() <= 0.0 {
396        return;
397    }
398    let size = area.spec.size * scale;
399    let radius = size * 0.5;
400    let factor = pull.position() * DRAG_SIZE_FACTOR_LIMIT;
401
402    let cx = area.viewport.x + area.viewport.width * 0.5;
403    // At `factor == 0` the disc sits entirely above the edge, hidden by the clip; at
404    // `1` its centre is exactly `displacement` below it.
405    let cy = area.viewport.y - radius + (area.spec.displacement + radius) * factor;
406
407    // The colour fades in over the same span that arms the pull, so "fully coloured"
408    // and "release me now" are the same moment — the one signal the eye can read
409    // without counting pixels.
410    let opacity = (pull.position() / ARM_AT).clamp(0.0, 1.0);
411    let ring = area.spec.color.unwrap_or(theme.primary);
412    let disc = area.spec.background.unwrap_or(theme.surface);
413
414    // The indicator belongs to its area: it must not spill over whatever is above.
415    let outer = clip.intersect(area.viewport);
416    if outer.width <= 0.0 || outer.height <= 0.0 {
417        return;
418    }
419    let previous = scene.current_clip();
420    scene.set_clip(outer);
421
422    scene.draw_rect(
423        Rect::new(cx - radius, cy - radius, size, size),
424        disc.fade(opacity),
425        radius,
426        0.0,
427        Color::TRANSPARENT,
428    );
429
430    let mode = match pull.phase() {
431        // Spinning is what "working" looks like; a filled ring is what "pull further"
432        // looks like. They must not be confusable, so a drag never completes the ring.
433        RefreshPhase::RefreshIndicator | RefreshPhase::Done => {
434            RingMode::Spinning { head: pull.turns }
435        }
436        _ => RingMode::Filling {
437            progress: pull.position() * DRAG_FILL,
438        },
439    };
440    paint_activity_ring(
441        scene,
442        cx,
443        cy,
444        radius * 0.62,
445        (radius * 0.16).max(1.0),
446        ring.fade(opacity),
447        mode,
448    );
449
450    scene.set_clip(previous);
451}
452
453/// Advances every refresh area of the frame by `dt`, and drops those that have gone
454/// quiet. Returns `true` while any is still moving.
455///
456/// Kept here rather than in `runtime.rs` so that the state machine and the thing that
457/// steps it stay in one file.
458pub(crate) fn advance_all(
459    pulls: &mut std::collections::HashMap<WidgetId, RefreshPull>,
460    areas: &[Refreshable],
461    dt: f32,
462) -> bool {
463    if pulls.is_empty() {
464        return false;
465    }
466    let mut animating = false;
467    pulls.retain(|id, pull| {
468        // An area that has left the tree keeps no claim on the frame.
469        let Some(area) = areas.iter().find(|a| a.id == *id) else {
470            return false;
471        };
472        animating |= pull.advance(dt, area.spec.refreshing);
473        !pull.is_idle()
474    });
475    animating
476}
477
478/// Feeds overscroll into the pull of `id`, creating it on the first push.
479///
480/// `overscroll` is signed: a bouncing edge hands back what the rubber band gives up as
481/// the finger returns, and the indicator follows it in.
482pub(crate) fn pull_into(
483    pulls: &mut std::collections::HashMap<WidgetId, RefreshPull>,
484    id: WidgetId,
485    overscroll: f32,
486    extent: f32,
487) {
488    if overscroll == 0.0 {
489        return;
490    }
491    // A negative push has nothing to create: there is no pull to give back to.
492    if overscroll < 0.0 && !pulls.contains_key(&id) {
493        return;
494    }
495    pulls.entry(id).or_default().pull(overscroll, extent);
496}
497
498/// Calls off the pull of `id` without asking for anything.
499pub(crate) fn cancel_of(
500    pulls: &mut std::collections::HashMap<WidgetId, RefreshPull>,
501    id: WidgetId,
502) {
503    if let Some(pull) = pulls.get_mut(&id) {
504        pull.cancel();
505    }
506}
507
508/// Ends the pull of `id`, returning `true` when it was armed and the application
509/// should be told.
510pub(crate) fn release_of(
511    pulls: &mut std::collections::HashMap<WidgetId, RefreshPull>,
512    id: WidgetId,
513) -> bool {
514    pulls
515        .get_mut(&id)
516        .map(RefreshPull::release)
517        .unwrap_or(false)
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use std::collections::HashMap;
524
525    /// A 800 px viewport: a full drag is 800 × 0.25 = 200 px, and the pull arms at
526    /// two thirds of that.
527    const EXTENT: f32 = 800.0;
528    const FULL: f32 = EXTENT * DRAG_EXTENT_FRACTION;
529
530    fn area(refreshing: bool) -> Refreshable {
531        Refreshable {
532            id: WidgetId::ROOT,
533            viewport: Rect::new(0.0, 0.0, 400.0, EXTENT),
534            spec: RefreshSpec {
535                refreshing,
536                ..RefreshSpec::default()
537            },
538        }
539    }
540
541    /// Runs `seconds` of frames at 60 Hz.
542    fn run(pulls: &mut HashMap<WidgetId, RefreshPull>, refreshing: bool, seconds: f32) {
543        let areas = [area(refreshing)];
544        let mut left = seconds;
545        while left > 0.0 {
546            let dt = left.min(1.0 / 60.0);
547            advance_all(pulls, &areas, dt);
548            left -= dt;
549        }
550    }
551
552    fn pull_of(pulls: &HashMap<WidgetId, RefreshPull>) -> RefreshPull {
553        *pulls.get(&WidgetId::ROOT).expect("a pull")
554    }
555
556    #[test]
557    fn a_short_pull_does_not_arm() {
558        let mut pulls = HashMap::new();
559        pull_into(&mut pulls, WidgetId::ROOT, FULL * 0.3, EXTENT);
560        assert_eq!(pull_of(&pulls).phase(), RefreshPhase::Drag);
561        assert!(!release_of(&mut pulls, WidgetId::ROOT));
562        assert_eq!(pull_of(&pulls).phase(), RefreshPhase::Cancel);
563    }
564
565    #[test]
566    fn a_long_enough_pull_arms_and_fires() {
567        let mut pulls = HashMap::new();
568        pull_into(&mut pulls, WidgetId::ROOT, FULL * ARM_AT + 1.0, EXTENT);
569        assert_eq!(pull_of(&pulls).phase(), RefreshPhase::Armed);
570        assert!(
571            release_of(&mut pulls, WidgetId::ROOT),
572            "releasing an armed pull asks for a refresh"
573        );
574        assert_eq!(pull_of(&pulls).phase(), RefreshPhase::Snap);
575    }
576
577    #[test]
578    fn the_threshold_scales_with_the_viewport() {
579        // The same 60 px on a short viewport arms; on a tall one it does not.
580        let mut short = HashMap::new();
581        pull_into(&mut short, WidgetId::ROOT, 60.0, 300.0);
582        assert_eq!(pull_of(&short).phase(), RefreshPhase::Armed);
583
584        let mut tall = HashMap::new();
585        pull_into(&mut tall, WidgetId::ROOT, 60.0, EXTENT);
586        assert_eq!(pull_of(&tall).phase(), RefreshPhase::Drag);
587    }
588
589    #[test]
590    fn an_armed_pull_does_not_disarm_when_the_finger_eases_off() {
591        let mut pulls = HashMap::new();
592        pull_into(&mut pulls, WidgetId::ROOT, FULL, EXTENT);
593        assert_eq!(pull_of(&pulls).phase(), RefreshPhase::Armed);
594        // The finger comes most of the way back.
595        pull_into(&mut pulls, WidgetId::ROOT, -FULL * 0.9, EXTENT);
596        assert_eq!(
597            pull_of(&pulls).phase(),
598            RefreshPhase::Armed,
599            "only letting go ends an armed pull"
600        );
601        assert!(pull_of(&pulls).position() >= ARM_AT);
602    }
603
604    #[test]
605    fn the_indicator_cannot_be_dragged_past_the_limit() {
606        let mut pulls = HashMap::new();
607        pull_into(&mut pulls, WidgetId::ROOT, FULL * 10.0, EXTENT);
608        assert_eq!(pull_of(&pulls).position(), 1.0);
609    }
610
611    #[test]
612    fn a_released_pull_is_not_resumed_by_more_overscroll() {
613        let mut pulls = HashMap::new();
614        pull_into(&mut pulls, WidgetId::ROOT, FULL, EXTENT);
615        release_of(&mut pulls, WidgetId::ROOT);
616        let settling = pull_of(&pulls).position();
617        pull_into(&mut pulls, WidgetId::ROOT, FULL, EXTENT);
618        assert_eq!(
619            pull_of(&pulls).position(),
620            settling,
621            "the finger that started it is gone"
622        );
623    }
624
625    #[test]
626    fn a_cancelled_pull_slides_away_and_is_forgotten() {
627        let mut pulls = HashMap::new();
628        pull_into(&mut pulls, WidgetId::ROOT, FULL * 0.3, EXTENT);
629        release_of(&mut pulls, WidgetId::ROOT);
630        run(&mut pulls, false, SCALE_TIME + 0.05);
631        assert!(pulls.is_empty(), "nothing left to draw, nothing retained");
632    }
633
634    #[test]
635    fn a_refresh_spins_for_as_long_as_the_application_says() {
636        let mut pulls = HashMap::new();
637        pull_into(&mut pulls, WidgetId::ROOT, FULL, EXTENT);
638        release_of(&mut pulls, WidgetId::ROOT);
639
640        run(&mut pulls, true, SNAP_TIME + 0.05);
641        assert_eq!(pull_of(&pulls).phase(), RefreshPhase::RefreshIndicator);
642        assert!(
643            (pull_of(&pulls).position() - ARM_AT).abs() < 1e-3,
644            "it rests exactly where the snap put it"
645        );
646
647        // Still working, a whole second later.
648        run(&mut pulls, true, 1.0);
649        assert_eq!(pull_of(&pulls).phase(), RefreshPhase::RefreshIndicator);
650
651        // The application clears its flag: the indicator scales away and is dropped.
652        run(&mut pulls, false, SCALE_TIME + 0.05);
653        assert!(pulls.is_empty());
654    }
655
656    #[test]
657    fn the_ring_turns_while_it_spins() {
658        let mut pulls = HashMap::new();
659        pull_into(&mut pulls, WidgetId::ROOT, FULL, EXTENT);
660        release_of(&mut pulls, WidgetId::ROOT);
661        run(&mut pulls, true, SNAP_TIME + 0.05);
662        let before = pull_of(&pulls).turns;
663        run(&mut pulls, true, 0.5);
664        assert!(pull_of(&pulls).turns > before);
665    }
666
667    #[test]
668    fn a_refresh_that_needed_no_work_still_goes_away() {
669        // The application never raises its flag — a cache hit, say.
670        let mut pulls = HashMap::new();
671        pull_into(&mut pulls, WidgetId::ROOT, FULL, EXTENT);
672        release_of(&mut pulls, WidgetId::ROOT);
673        run(&mut pulls, false, SNAP_TIME + SCALE_TIME + 0.1);
674        assert!(
675            pulls.is_empty(),
676            "snap then done, rather than a spinner that never spun"
677        );
678    }
679
680    #[test]
681    fn an_area_that_leaves_the_tree_takes_its_pull_with_it() {
682        let mut pulls = HashMap::new();
683        pull_into(&mut pulls, WidgetId::ROOT, FULL, EXTENT);
684        release_of(&mut pulls, WidgetId::ROOT);
685        advance_all(&mut pulls, &[], 1.0 / 60.0);
686        assert!(pulls.is_empty());
687    }
688
689    #[test]
690    fn a_pull_that_is_still_held_moves_on_its_own_not_at_all() {
691        let mut pulls = HashMap::new();
692        pull_into(&mut pulls, WidgetId::ROOT, FULL * 0.5, EXTENT);
693        let held = pull_of(&pulls).position();
694        run(&mut pulls, false, 0.5);
695        assert_eq!(
696            pull_of(&pulls).position(),
697            held,
698            "the finger owns it, exactly as it owns the scroll offset"
699        );
700    }
701
702    // --- Through a real frame -------------------------------------------------
703
704    /// A refresh area wrapping a scrollable taller than its box, as the root of the
705    /// tree — so the area's identity is `WidgetId::ROOT` and the fixtures above key on
706    /// the same thing.
707    fn tree(refreshing: bool) -> RefreshIndicator<i32> {
708        RefreshIndicator::new(
709            crate::SingleChildScrollView::<i32>::new()
710                .width(400.0)
711                .height(EXTENT)
712                .child(crate::Container::new().width(400.0).height(EXTENT * 4.0)),
713        )
714        .on_refresh(1)
715        .refreshing(refreshing)
716    }
717
718    fn frame(runtime: &crate::Runtime, refreshing: bool) -> crate::Ui<i32> {
719        crate::build_ui(
720            &tree(refreshing),
721            frus_core::Size::new(400.0, EXTENT),
722            runtime,
723            &crate::Theme::dark(),
724        )
725    }
726
727    /// The ring is the only thing the indicator draws that is round; counting those
728    /// primitives is how these tests see it without asserting on exact pixels.
729    fn ring_dots(ui: &crate::Ui<i32>) -> usize {
730        ui.scene()
731            .primitives()
732            .iter()
733            .filter(|p| {
734                matches!(p, frus_core::Primitive::Rect { rect, .. }
735                    if (rect.width - rect.height).abs() < 0.01 && rect.width < 12.0)
736            })
737            .count()
738    }
739
740    #[test]
741    fn a_scrollable_inside_a_refresh_area_names_its_host() {
742        let runtime = crate::Runtime::default();
743        let ui = frame(&runtime, false);
744        let area = ui.scroll_regions().first().expect("a scrollable");
745        assert_eq!(
746            area.refresh,
747            Some(WidgetId::ROOT),
748            "so the shell knows where to send what the physics refuses"
749        );
750    }
751
752    #[test]
753    fn a_scrollable_outside_one_names_nothing() {
754        let runtime = crate::Runtime::default();
755        let bare = crate::SingleChildScrollView::<i32>::new()
756            .width(400.0)
757            .height(EXTENT)
758            .child(crate::Container::new().width(400.0).height(EXTENT * 4.0));
759        let ui = crate::build_ui(
760            &bare,
761            frus_core::Size::new(400.0, EXTENT),
762            &runtime,
763            &crate::Theme::dark(),
764        );
765        assert_eq!(
766            ui.scroll_regions().first().expect("a scrollable").refresh,
767            None
768        );
769    }
770
771    #[test]
772    fn the_area_reports_itself_with_the_flag_it_was_built_with() {
773        let runtime = crate::Runtime::default();
774        let ui = frame(&runtime, true);
775        let areas = ui.refresh_areas();
776        assert_eq!(areas.len(), 1);
777        assert_eq!(areas[0].id, WidgetId::ROOT);
778        assert!(areas[0].spec.refreshing);
779        assert_eq!(areas[0].viewport.height, EXTENT);
780    }
781
782    #[test]
783    fn nothing_is_drawn_until_something_is_pulled() {
784        let runtime = crate::Runtime::default();
785        assert_eq!(ring_dots(&frame(&runtime, false)), 0);
786    }
787
788    #[test]
789    fn a_pull_draws_the_indicator_over_its_area() {
790        let mut runtime = crate::Runtime::default();
791        runtime.refresh_pull(WidgetId::ROOT, FULL, EXTENT);
792        let ui = frame(&runtime, false);
793        assert!(ring_dots(&ui) > 0, "a filled ring is showing");
794        assert!(
795            ui.wants_animation(),
796            "a live pull owes the next frame to itself"
797        );
798    }
799
800    #[test]
801    fn the_indicator_comes_down_as_the_pull_grows() {
802        let centre_y = |drag: f32| {
803            let mut runtime = crate::Runtime::default();
804            runtime.refresh_pull(WidgetId::ROOT, drag, EXTENT);
805            let ui = frame(&runtime, false);
806            ui.scene()
807                .primitives()
808                .iter()
809                .filter_map(|p| match p {
810                    frus_core::Primitive::Rect { rect, .. }
811                        if (rect.width - rect.height).abs() < 0.01 =>
812                    {
813                        Some(rect.y + rect.height * 0.5)
814                    }
815                    _ => None,
816                })
817                .fold(f32::MIN, f32::max)
818        };
819        assert!(
820            centre_y(FULL) > centre_y(FULL * 0.4),
821            "further pulled, further down"
822        );
823    }
824
825    /// And it says so **whatever else the process is doing**.
826    ///
827    /// This used to flake about one run in ten. `Ui::wants_animation` folded in
828    /// `images_in_flight()`, a process-global count, so in a suite where every test shares
829    /// one process a refresh area with nothing pulled asked for frames whenever an
830    /// unrelated test happened to be loading an image. The sentence was true of the widget
831    /// and false of the value being asked (milestone 411).
832    #[test]
833    fn an_area_with_no_pull_asks_for_no_frames() {
834        let runtime = crate::Runtime::default();
835        assert!(!frame(&runtime, false).wants_animation());
836    }
837
838    #[test]
839    fn the_widget_reports_what_it_was_configured_with() {
840        let widget = RefreshIndicator::<i32>::new(crate::Container::new())
841            .on_refresh(7)
842            .refreshing(true)
843            .displacement(64.0)
844            .size(20.0);
845        let spec = Widget::<i32>::refresh(&widget).expect("a refresh area");
846        assert!(spec.refreshing);
847        assert_eq!(spec.displacement, 64.0);
848        assert_eq!(spec.size, 20.0);
849        assert_eq!(Widget::<i32>::on_refresh(&widget), Some(7));
850    }
851}