Skip to main content

gpui_kit_material/components/
pull_to_refresh.rs

1//! Pull to refresh: a scrolling area whose top, pulled down, brings in an
2//! indicator and calls for a refresh when released past a threshold.
3//!
4//! Source: only m3e lists this component (the Figma kit and md3.io do not),
5//! and there is no m3.material.io page. Values and drag rules are m3e's port
6//! of Compose `PullToRefresh.kt` (`packages/m3e-motion/src/pull-to-refresh.ts`,
7//! `packages/m3e-css/src/pull-to-refresh.css`,
8//! `pull-to-refresh-defaults.css`, from androidx
9//! `19991f2c0858aadfcb442ce02b2c8e4743c96898`). The arithmetic is held to m3e's
10//! JVM reference, `conformance/fixtures/m3e-pull-to-refresh.json`.
11//!
12//! Copyright 2023 The Android Open Source Project. Licensed under the Apache
13//! License, Version 2.0: see `LICENSE-androidx` in this directory.
14//!
15//! Input differs from m3e, which pulls on touch and pen only because a browser
16//! mouse drag selects text. Here touch pans and trackpads pull through scroll
17//! events, and a mouse drag pulls too ([`PullToRefresh::mouse_drag`]) so desktop
18//! windows without a touch surface can use it. A mouse wheel never pulls.
19use super::{LoadingIndicator, indeterminate_circular_progress};
20use crate::{
21    color::ColorRole,
22    motion::{self, MotionToken, Spring},
23    theme::color,
24    tokens::{elevation, typography::rem},
25};
26use gpui::{prelude::*, *};
27use std::{rc::Rc, time::Duration};
28
29#[cfg(not(target_family = "wasm"))]
30use std::time::Instant;
31#[cfg(target_family = "wasm")]
32use web_time::Instant;
33
34/// How far (dp, after the drag multiplier) a pull must pass to refresh
35/// (Compose `PositionalThreshold`).
36pub const PULL_TO_REFRESH_THRESHOLD: f32 = 80.;
37/// The drag distance counts half (Compose `DragMultiplier`).
38pub const PULL_TO_REFRESH_DRAG_MULTIPLIER: f32 = 0.5;
39/// How far the indicator travels at a fraction of 1 (Compose
40/// `IndicatorMaxDistance`), in dp.
41pub const PULL_TO_REFRESH_MAX_DISTANCE: f32 = 80.;
42/// The default indicator's container (Compose `SpinnerContainerSize`), in dp.
43pub const PULL_TO_REFRESH_INDICATOR_SIZE: f32 = 40.;
44/// The loading indicator variant's container, in dp.
45pub const PULL_TO_REFRESH_LOADING_INDICATOR_SIZE: f32 = 48.;
46/// The arrow's canvas and the spinner's size (Compose `SpinnerSize`), in dp.
47pub const PULL_TO_REFRESH_SPINNER_SIZE: f32 = 16.;
48/// The arrow's alpha below the threshold (Compose `MinAlpha`); it is 1 from it.
49pub const PULL_TO_REFRESH_MIN_ALPHA: f32 = 0.3;
50/// Settling to rest or to the threshold (m3e's
51/// `--m3e-comp-pull-to-refresh-state-*`, from Compose's `animateTo` spec).
52const SETTLE_SPRING: Spring = Spring {
53    damping: 1.,
54    stiffness: 1500.,
55};
56const MAX_PROGRESS_ARC: f32 = 0.8;
57const STROKE_WIDTH: f32 = 2.5;
58const ARC_RADIUS: f32 = 5.5;
59const ARROW_WIDTH: f32 = 10.;
60const ARROW_HEIGHT: f32 = 5.;
61/// Mouse travel before a press becomes a pull, so clicks and small movements
62/// stay with the content.
63const MOUSE_SLOP: f32 = 8.;
64/// A pause without packets ends a stream that has no phases: the same time
65/// `gpui_base`'s overscroll waits, for the same reason.
66const GESTURE_GAP: Duration = Duration::from_millis(250);
67
68/// `linearTension - linearTension.pow(2) / 4` of a progress.
69fn tension_percent(progress: f32) -> f32 {
70    let overshoot = progress.abs() - 1.;
71    let linear = overshoot.clamp(0., 2.);
72    linear - linear.powi(2) / 4.
73}
74
75/// The indicator offset for an adjusted distance pulled: linear to the
76/// threshold, then on the tension curve, at most twice the threshold.
77pub fn pull_vertical_offset(adjusted_distance: f32, threshold: f32) -> f32 {
78    if adjusted_distance <= threshold {
79        return adjusted_distance;
80    }
81    threshold + threshold * tension_percent(adjusted_distance / threshold)
82}
83
84/// A pull's drag bookkeeping (Compose `PullToRefreshModifierNode`), in px.
85#[derive(Clone, Copy, Debug, PartialEq)]
86pub struct PullModel {
87    /// The threshold, rounded to whole pixels.
88    pub threshold: f32,
89    /// The drag distance the pull took.
90    pub distance_pulled: f32,
91    /// Where the indicator is, with the tension applied.
92    pub vertical_offset: f32,
93}
94
95impl PullModel {
96    /// An idle pull. A threshold that is not positive and finite uses the default.
97    pub fn new(threshold: f32) -> Self {
98        Self {
99            threshold: if threshold.is_finite() && threshold > 0. {
100                threshold
101            } else {
102                PULL_TO_REFRESH_THRESHOLD
103            },
104            distance_pulled: 0.,
105            vertical_offset: 0.,
106        }
107    }
108
109    /// The drag distance after the multiplier.
110    pub fn adjusted_distance(&self) -> f32 {
111        self.distance_pulled * PULL_TO_REFRESH_DRAG_MULTIPLIER
112    }
113
114    /// The indicator's position as a fraction of the threshold.
115    pub fn distance_fraction(&self) -> f32 {
116        self.vertical_offset / self.threshold
117    }
118
119    /// Take `available` px of drag (positive down) and return what the pull
120    /// consumed. Nothing is consumed while refreshing.
121    pub fn consume(&mut self, available: f32, refreshing: bool) -> f32 {
122        if refreshing {
123            return 0.;
124        }
125        let offset = (self.distance_pulled + available).max(0.);
126        let consumed = offset - self.distance_pulled;
127        self.distance_pulled = offset;
128        self.vertical_offset = pull_vertical_offset(self.adjusted_distance(), self.threshold);
129        consumed
130    }
131
132    /// Whether letting go now refreshes: past the threshold, not refreshing.
133    pub fn release(&self, refreshing: bool) -> bool {
134        !refreshing && self.adjusted_distance() > self.threshold
135    }
136
137    /// Rest: at 0, or at the threshold while refreshing.
138    pub fn reset(&mut self, refreshing: bool) {
139        self.distance_pulled = if refreshing { self.threshold } else { 0. };
140        self.vertical_offset = self.distance_pulled;
141    }
142}
143
144/// The arrow's drawing on its canvas at a distance fraction: arc and head,
145/// with the rotations Compose applies about the canvas center.
146#[derive(Clone, Copy, Debug, PartialEq)]
147pub struct PullArrow {
148    /// The canvas's side, in whole pixels.
149    pub size: f32,
150    /// The stroke width.
151    pub stroke: f32,
152    /// Degrees the whole drawing turns. Compose passes a fraction of a turn
153    /// here as is, so this stays near zero; the value keeps that parity.
154    pub rotation: f32,
155    /// The arc's bounds as left, top, width, height.
156    pub arc: [f32; 4],
157    /// Where the arc starts, in degrees clockwise from three o'clock.
158    pub start_angle: f32,
159    /// How far the arc sweeps, in degrees.
160    pub sweep_angle: f32,
161    /// The arrow head's open triangle as x, y pairs.
162    pub points: [f32; 6],
163    /// Degrees the head turns further about the center.
164    pub arrow_rotation: f32,
165}
166
167/// The arrow for a distance fraction at `density` px per dp.
168pub fn pull_arrow(progress: f32, density: f32) -> PullArrow {
169    // Discard the first 40% of progress and scale the rest to the full range.
170    let adjusted = (progress.min(1.) - 0.4).max(0.) * 5. / 3.;
171    let tension = tension_percent(progress);
172    let end_trim = adjusted * MAX_PROGRESS_ARC;
173    let rotation = (-0.25 + 0.4 * adjusted + tension) * 0.5;
174    let start_angle = rotation * 360.;
175    let end_angle = (rotation + end_trim) * 360.;
176    let scale = adjusted.min(1.);
177
178    let size = (PULL_TO_REFRESH_SPINNER_SIZE * density).round();
179    let center = size / 2.;
180    let stroke = STROKE_WIDTH * density;
181    let arc_radius = ARC_RADIUS * density + stroke / 2.;
182    let left = center - arc_radius;
183    let width = arc_radius * 2.;
184    let arrow_width = ARROW_WIDTH * density * scale;
185    let tip_x = arrow_width / 2.;
186    let tip_y = ARROW_HEIGHT * density * scale;
187    let dx = arc_radius + center - arrow_width / 2.;
188    let dy = center - stroke;
189    PullArrow {
190        size,
191        stroke,
192        rotation,
193        arc: [left, left, width, width],
194        start_angle,
195        sweep_angle: end_angle - start_angle,
196        points: [dx, dy, tip_x + dx, tip_y + dy, arrow_width + dx, dy],
197        // Compose subtracts the stroke in px from degrees; the head sits just
198        // behind the arc's end because of it.
199        arrow_rotation: end_angle - stroke,
200    }
201}
202
203/// The loading indicator variant's extra turn past the threshold, in degrees.
204pub fn pull_loading_rotation(fraction: f32) -> f32 {
205    if fraction > 1. {
206        -(fraction - 1.) * 180.
207    } else {
208        0.
209    }
210}
211
212/// The indicator a [`PullToRefresh`] shows.
213#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub enum PullToRefreshIndicator {
215    /// Compose's arrow, then circular progress, on a 40dp surface.
216    #[default]
217    Default,
218    /// Contained loading indicators on a 48dp primary container.
219    LoadingIndicator,
220}
221
222/// A settle from one fraction to another.
223#[derive(Clone, Copy)]
224struct Settle {
225    from: f32,
226    to: f32,
227    started: Instant,
228}
229
230/// Transient gesture state. The refreshing flag stays with the caller.
231struct PullState {
232    model: PullModel,
233    /// The fraction shown while no pull is active.
234    resting: f32,
235    settle: Option<Settle>,
236    pulling: bool,
237    /// The refreshing flag last rendered.
238    was_refreshing: bool,
239    /// The mouse's press point and last position, while pressed at the top.
240    mouse: Option<(f32, f32)>,
241    /// A scroll gesture began with a phase, so its packets may pull.
242    gesture: bool,
243    last_packet: Option<Instant>,
244    /// A timer will end this phaseless stream's pull.
245    timer: bool,
246}
247
248impl PullState {
249    fn settled_fraction(&self, now: Instant) -> f32 {
250        match self.settle {
251            Some(settle) => {
252                let elapsed = now.saturating_duration_since(settle.started).as_secs_f32();
253                let duration = SETTLE_SPRING.duration().as_secs_f32();
254                if elapsed >= duration {
255                    settle.to
256                } else {
257                    let t = (SETTLE_SPRING.ease())(elapsed / duration);
258                    settle.from + (settle.to - settle.from) * t
259                }
260            }
261            None => self.resting,
262        }
263    }
264
265    fn fraction(&self, now: Instant) -> f32 {
266        if self.pulling {
267            self.model.distance_fraction()
268        } else {
269            self.settled_fraction(now)
270        }
271    }
272
273    fn settle_to(&mut self, to: f32, now: Instant, reduced: bool) {
274        let from = self.fraction(now);
275        self.resting = to;
276        self.settle = (!reduced && from != to).then_some(Settle {
277            from,
278            to,
279            started: now,
280        });
281    }
282}
283
284type Refresh = Rc<dyn Fn(&mut Window, &mut App)>;
285
286/// Let go of an active pull: refresh when `commit` and past the threshold.
287fn finish_pull(
288    state: &Entity<PullState>,
289    commit: bool,
290    refreshing: bool,
291    on_refresh: Option<&Refresh>,
292    window: &mut Window,
293    cx: &mut App,
294) {
295    let now = cx.background_executor().now();
296    let reduced = cx.reduce_motion();
297    let refresh = state.update(cx, |state, cx| {
298        let refresh = commit && state.model.release(refreshing);
299        let from = state.model.distance_fraction();
300        state.pulling = false;
301        state.resting = from;
302        state.settle = None;
303        state.settle_to(0., now, reduced);
304        state.model.reset(false);
305        cx.notify();
306        refresh
307    });
308    if refresh && let Some(on_refresh) = on_refresh {
309        on_refresh(window, cx);
310    }
311}
312
313/// Pull to refresh around caller-owned scrolling content.
314///
315/// The refreshing flag is controlled: [`Self::on_refresh`] reports a release
316/// past the threshold, and the caller sets [`Self::refreshing`] until its work
317/// is done. The gesture has no keyboard form, so give the screen a visible
318/// refresh control that calls the same handler.
319///
320/// ```
321/// use gpui_kit_material::prelude::*;
322///
323/// let list = PullToRefresh::new("inbox", "Refreshing the inbox")
324///     .indicator(PullToRefreshIndicator::LoadingIndicator)
325///     .on_refresh(|_, _| { /* start the work and set `refreshing` */ });
326/// # let _ = list;
327/// ```
328#[derive(gpui::IntoElement)]
329pub struct PullToRefresh {
330    id: ElementId,
331    label: SharedString,
332    refreshing: bool,
333    disabled: bool,
334    indicator: PullToRefreshIndicator,
335    threshold: f32,
336    mouse_drag: bool,
337    scroll: Option<ScrollHandle>,
338    content: Vec<AnyElement>,
339    on_refresh: Option<Refresh>,
340}
341
342impl PullToRefresh {
343    /// `label` names the indicator for assistive technology.
344    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
345        Self {
346            id: id.into(),
347            label: label.into(),
348            refreshing: false,
349            disabled: false,
350            indicator: PullToRefreshIndicator::Default,
351            threshold: PULL_TO_REFRESH_THRESHOLD,
352            mouse_drag: true,
353            scroll: None,
354            content: Vec::new(),
355            on_refresh: None,
356        }
357    }
358
359    /// Whether a refresh is running: the indicator rests at the threshold,
360    /// spinning, and pulls are ignored.
361    pub fn refreshing(mut self, refreshing: bool) -> Self {
362        self.refreshing = refreshing;
363        self
364    }
365
366    /// Ignore pulls.
367    pub fn disabled(mut self, disabled: bool) -> Self {
368        self.disabled = disabled;
369        self
370    }
371
372    /// Which indicator to show.
373    pub fn indicator(mut self, indicator: PullToRefreshIndicator) -> Self {
374        self.indicator = indicator;
375        self
376    }
377
378    /// The distance (dp, after the drag multiplier) a pull must pass.
379    pub fn threshold(mut self, threshold: f32) -> Self {
380        self.threshold = threshold;
381        self
382    }
383
384    /// Whether a mouse drag pulls (on by default).
385    pub fn mouse_drag(mut self, mouse_drag: bool) -> Self {
386        self.mouse_drag = mouse_drag;
387        self
388    }
389
390    /// The scroll position pulls start from. By default the element keeps its
391    /// own; pass one to read or move the content's position.
392    pub fn track_scroll(mut self, scroll: &ScrollHandle) -> Self {
393        self.scroll = Some(scroll.clone());
394        self
395    }
396
397    /// Add to the scrolling content.
398    pub fn child(mut self, child: impl IntoElement) -> Self {
399        self.content.push(child.into_any_element());
400        self
401    }
402
403    /// Called when a pull is released past the threshold.
404    pub fn on_refresh(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
405        self.on_refresh = Some(Rc::new(handler));
406        self
407    }
408}
409
410/// The arrow's built paths and what they were built from. Owned by the
411/// indicator's keyed state: any change of fraction or bounds replaces them.
412#[derive(Default)]
413struct ArrowPaths {
414    key: Option<(u32, Bounds<Pixels>)>,
415    arc: Option<Path<Pixels>>,
416    head: Option<Path<Pixels>>,
417}
418
419impl ArrowPaths {
420    /// Build the paths unless they are already current.
421    fn update(&mut self, fraction: f32, bounds: Bounds<Pixels>) {
422        let key = (fraction.to_bits(), bounds);
423        if self.key != Some(key) {
424            let (arc, head) = arrow_paths(fraction, bounds);
425            *self = Self {
426                key: Some(key),
427                arc,
428                head,
429            };
430        }
431    }
432}
433
434/// Build the arrow's arc and head for `bounds`.
435fn arrow_paths(
436    fraction: f32,
437    bounds: Bounds<Pixels>,
438) -> (Option<Path<Pixels>>, Option<Path<Pixels>>) {
439    let scale = f32::from(bounds.size.width) / PULL_TO_REFRESH_SPINNER_SIZE;
440    let arrow = pull_arrow(fraction, 1.);
441    let center = arrow.size / 2.;
442    let at = |x: f32, y: f32, degrees: f32| {
443        let (sin, cos) = degrees.to_radians().sin_cos();
444        let (dx, dy) = (x - center, y - center);
445        point(
446            bounds.origin.x + px((center + dx * cos - dy * sin) * scale),
447            bounds.origin.y + px((center + dx * sin + dy * cos) * scale),
448        )
449    };
450    let mut arc = None;
451    if arrow.sweep_angle > 0. {
452        const STEPS: usize = 32;
453        let [left, top, width, height] = arrow.arc;
454        let (rx, ry) = (width / 2., height / 2.);
455        let mut path = PathBuilder::stroke(px(arrow.stroke * scale));
456        for step in 0..=STEPS {
457            let angle =
458                (arrow.start_angle + arrow.sweep_angle * step as f32 / STEPS as f32).to_radians();
459            let point = at(
460                left + rx + rx * angle.cos(),
461                top + ry + ry * angle.sin(),
462                arrow.rotation,
463            );
464            if step == 0 {
465                path.move_to(point);
466            } else {
467                path.line_to(point);
468            }
469        }
470        arc = path.build().ok();
471    }
472    let mut head = None;
473    let [ax, ay, bx, by, cx, cy] = arrow.points;
474    if ax != cx || ay != by {
475        let turn = arrow.rotation + arrow.arrow_rotation;
476        let mut path = PathBuilder::stroke(px(arrow.stroke * scale));
477        path.move_to(at(ax, ay, turn));
478        path.line_to(at(bx, by, turn));
479        path.line_to(at(cx, cy, turn));
480        head = path.build().ok();
481    }
482    (arc, head)
483}
484
485impl RenderOnce for PullToRefresh {
486    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
487        let now = cx.background_executor().now();
488        let reduced = cx.reduce_motion();
489        let refreshing = self.refreshing;
490        let threshold = self.threshold;
491        let state = window.use_keyed_state((self.id.clone(), "pull-state"), cx, |_, _| PullState {
492            model: PullModel::new(threshold),
493            resting: if refreshing { 1. } else { 0. },
494            settle: None,
495            pulling: false,
496            was_refreshing: refreshing,
497            mouse: None,
498            gesture: false,
499            last_packet: None,
500            timer: false,
501        });
502        let scroll = match self.scroll.clone() {
503            Some(scroll) => scroll,
504            None => window
505                .use_keyed_state((self.id.clone(), "pull-scroll"), cx, |_, _| {
506                    ScrollHandle::default()
507                })
508                .read(cx)
509                .clone(),
510        };
511        let (fraction, pulling, settling) = state.update(cx, |state, _| {
512            state.model.threshold = PullModel::new(threshold).threshold;
513            if state.was_refreshing != refreshing {
514                state.was_refreshing = refreshing;
515                state.pulling = false;
516                state.settle_to(if refreshing { 1. } else { 0. }, now, reduced);
517                state.model.reset(refreshing);
518            }
519            if let Some(settle) = state.settle {
520                let elapsed = now.saturating_duration_since(settle.started);
521                if elapsed >= SETTLE_SPRING.duration() {
522                    state.settle = None;
523                }
524            }
525            (state.fraction(now), state.pulling, state.settle.is_some())
526        });
527        if settling {
528            window.request_animation_frame();
529        }
530
531        let enabled = !self.disabled && !refreshing;
532        let on_refresh = self.on_refresh;
533        let mouse_drag = self.mouse_drag;
534        // Nothing to listen for when no pull can start or is running.
535        let events = (enabled || pulling).then(|| {
536            let state = state.clone();
537            let scroll = scroll.clone();
538            canvas(
539                move |bounds, window, _| window.insert_hitbox(bounds, HitboxBehavior::Normal),
540                move |_, hitbox, window, cx| {
541                    let pressed = state.read(cx).mouse.is_some();
542                    pull_listeners(
543                        hitbox, state, scroll, enabled, refreshing, mouse_drag, pressed,
544                        on_refresh, window,
545                    );
546                },
547            )
548            .absolute()
549            .inset_0()
550        });
551
552        let visible = pulling || refreshing || fraction > 0.;
553        let loading = self.indicator == PullToRefreshIndicator::LoadingIndicator;
554        let box_size = if loading {
555            PULL_TO_REFRESH_LOADING_INDICATOR_SIZE
556        } else {
557            PULL_TO_REFRESH_INDICATOR_SIZE
558        };
559        let id = self.id.clone();
560        let label = self.label.clone();
561        let indicator = visible.then(|| {
562            let spinner_alpha = motion::token_value(
563                (id.clone(), "pull-crossfade"),
564                if refreshing { 1. } else { 0. },
565                MotionToken::DefaultEffects,
566                window,
567                cx,
568            )
569            .clamp(0., 1.);
570            let arrow_alpha = motion::token_value(
571                (id.clone(), "pull-arrow-alpha"),
572                if fraction >= 1. {
573                    1.
574                } else {
575                    PULL_TO_REFRESH_MIN_ALPHA
576                },
577                MotionToken::DefaultEffects,
578                window,
579                cx,
580            );
581            let face: AnyElement = if loading {
582                if refreshing {
583                    LoadingIndicator::new((id.clone(), "pull-refreshing"), label.clone())
584                        .contained(true)
585                        .into_any_element()
586                } else {
587                    LoadingIndicator::new((id.clone(), "pull-progress"), label.clone())
588                        .contained(true)
589                        .progress(Some(fraction.min(1.)))
590                        .turn(pull_loading_rotation(fraction))
591                        .into_any_element()
592                }
593            } else {
594                let ink = color(ColorRole::ON_SURFACE_VARIANT);
595                let spinner_size = size(
596                    rem(PULL_TO_REFRESH_SPINNER_SIZE),
597                    rem(PULL_TO_REFRESH_SPINNER_SIZE),
598                );
599                div()
600                    .relative()
601                    .size(rem(PULL_TO_REFRESH_SPINNER_SIZE))
602                    .when(spinner_alpha < 1., |face| {
603                        let alpha = arrow_alpha * (1. - spinner_alpha);
604                        let paths =
605                            window.use_keyed_state((id.clone(), "pull-arrow-paths"), cx, |_, _| {
606                                ArrowPaths::default()
607                            });
608
609                        face.child(
610                            gpui_base::Progress::new((id.clone(), "pull-arrow"))
611                                .accessibility_label(label.clone())
612                                .value(fraction.min(1.) * 100.)
613                                .absolute()
614                                .inset_0()
615                                .child(
616                                    canvas(
617                                        |_, _, _| {},
618                                        move |bounds, _, window, cx| {
619                                            let ink = ink.opacity(alpha);
620                                            paths.update(cx, |paths, _| {
621                                                paths.update(fraction, bounds);
622                                                for path in
623                                                    [&paths.arc, &paths.head].into_iter().flatten()
624                                                {
625                                                    window.paint_path(path.clone(), ink);
626                                                }
627                                            });
628                                        },
629                                    )
630                                    .size_full(),
631                                ),
632                        )
633                    })
634                    .when(spinner_alpha > 0., |face| {
635                        face.child(indeterminate_circular_progress(
636                            (id.clone(), "pull-spinner"),
637                            label.clone(),
638                            spinner_size,
639                            spinner_alpha,
640                            window,
641                            cx,
642                        ))
643                    })
644                    .into_any_element()
645            };
646            div()
647                .debug_selector(|| "pull-to-refresh-indicator".into())
648                .absolute()
649                .top(rem(fraction * PULL_TO_REFRESH_MAX_DISTANCE - box_size))
650                .left_0()
651                .right_0()
652                .flex()
653                .justify_center()
654                .child(
655                    div()
656                        .size(rem(box_size))
657                        .flex()
658                        .items_center()
659                        .justify_center()
660                        .rounded_full()
661                        .bg(color(if loading {
662                            ColorRole::PRIMARY_CONTAINER
663                        } else {
664                            ColorRole::SURFACE_CONTAINER_HIGH
665                        }))
666                        .when(!loading, |surface| surface.shadow(elevation::level(2)))
667                        .child(face),
668                )
669        });
670        div()
671            .id((self.id.clone(), "pull"))
672            .relative()
673            .size_full()
674            .overflow_hidden()
675            .child(
676                div()
677                    .id((self.id, "pull-content"))
678                    .debug_selector(|| "pull-to-refresh-content".into())
679                    .size_full()
680                    .overflow_y_scroll()
681                    .track_scroll(&scroll)
682                    .children(self.content),
683            )
684            .children(events)
685            // During a pull nothing beneath takes a hover or the release's click.
686            .when(pulling, |root| {
687                root.child(div().absolute().inset_0().occlude())
688            })
689            .children(indicator)
690    }
691}
692
693/// Install the gesture's window listeners for this frame. They run in the
694/// capture phase, because the scroller would otherwise take a packet that
695/// should first unwind the pull.
696#[allow(clippy::too_many_arguments)]
697fn pull_listeners(
698    hitbox: Hitbox,
699    state: Entity<PullState>,
700    scroll: ScrollHandle,
701    enabled: bool,
702    refreshing: bool,
703    mouse_drag: bool,
704    pressed: bool,
705    on_refresh: Option<Refresh>,
706    window: &mut Window,
707) {
708    {
709        let state = state.clone();
710        let scroll = scroll.clone();
711        let on_refresh = on_refresh.clone();
712        let hitbox = hitbox.clone();
713        window.on_mouse_event(move |event: &ScrollEvent, phase, window, cx| {
714            if phase != DispatchPhase::Capture {
715                return;
716            }
717            let now = cx.background_executor().now();
718            let current = state.read(cx);
719            let pulling = current.pulling;
720            if !pulling && !hitbox.should_handle_scroll(window) {
721                return;
722            }
723            match event.touch_phase {
724                TouchPhase::Started => {
725                    state.update(cx, |state, _| {
726                        state.gesture = true;
727                        state.last_packet = Some(now);
728                    });
729                    return;
730                }
731                TouchPhase::Ended | TouchPhase::Cancelled => {
732                    state.update(cx, |state, _| {
733                        state.gesture = false;
734                        state.last_packet = Some(now);
735                    });
736                    if pulling {
737                        let commit = event.touch_phase == TouchPhase::Ended;
738                        finish_pull(&state, commit, refreshing, on_refresh.as_ref(), window, cx);
739                        cx.stop_propagation();
740                    }
741                    return;
742                }
743                TouchPhase::Moved => {}
744            }
745            // A wheel notch never pulls.
746            let ScrollDelta::Pixels(delta) = event.delta else {
747                return;
748            };
749            let dy = f32::from(delta.y);
750            let current = state.read(cx);
751            // Packets after a phased gesture ended are momentum: they may
752            // scroll, but a fling arriving at the top is not a pull. A stream
753            // with no phases starts fresh after a pause.
754            let fresh = current.gesture
755                || current
756                    .last_packet
757                    .is_none_or(|last| now.saturating_duration_since(last) > GESTURE_GAP);
758            let phaseless = !current.gesture;
759            let at_top = scroll.offset().y >= px(0.);
760            let start = !pulling && enabled && at_top && dy > 0. && fresh;
761            state.update(cx, |state, _| state.last_packet = Some(now));
762            if !pulling && !start {
763                return;
764            }
765            let arm_timer = state.update(cx, |state, cx| {
766                state.model.consume(dy, refreshing);
767                state.pulling = state.model.distance_pulled > 0.;
768                state.settle = None;
769                cx.notify();
770                let arm = state.pulling && phaseless && !state.timer;
771                if arm {
772                    state.timer = true;
773                }
774                arm
775            });
776            cx.stop_propagation();
777            if arm_timer {
778                let state = state.clone();
779                let on_refresh = on_refresh.clone();
780                window
781                    .spawn(cx, async move |cx| {
782                        loop {
783                            cx.background_executor().timer(GESTURE_GAP).await;
784                            let done = cx
785                                .update(|window, cx| {
786                                    let current = state.read(cx);
787                                    let now = cx.background_executor().now();
788                                    let idle = current.last_packet.is_none_or(|last| {
789                                        now.saturating_duration_since(last) >= GESTURE_GAP
790                                    });
791                                    if !current.pulling || current.gesture {
792                                        state.update(cx, |state, _| state.timer = false);
793                                        return true;
794                                    }
795                                    if idle {
796                                        state.update(cx, |state, _| state.timer = false);
797                                        finish_pull(
798                                            &state,
799                                            true,
800                                            refreshing,
801                                            on_refresh.as_ref(),
802                                            window,
803                                            cx,
804                                        );
805                                    }
806                                    idle
807                                })
808                                .unwrap_or(true);
809                            if done {
810                                break;
811                            }
812                        }
813                    })
814                    .detach();
815            }
816        });
817    }
818    if !mouse_drag {
819        return;
820    }
821    {
822        let state = state.clone();
823        window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
824            if phase == DispatchPhase::Capture
825                && event.button == MouseButton::Left
826                && enabled
827                && scroll.offset().y >= px(0.)
828                && hitbox.is_hovered(window)
829            {
830                let y = f32::from(event.position.y);
831                state.update(cx, |state, _| state.mouse = Some((y, y)));
832            }
833        });
834    }
835    // Only a press in progress listens for its moves and release. A release
836    // that beats this frame is cleaned up by the next move or press.
837    if !pressed {
838        return;
839    }
840    {
841        let state = state.clone();
842        window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
843            if phase != DispatchPhase::Capture {
844                return;
845            }
846            let Some((press, last)) = state.read(cx).mouse else {
847                return;
848            };
849            if !event.dragging() {
850                state.update(cx, |state, _| state.mouse = None);
851                return;
852            }
853            let y = f32::from(event.position.y);
854            let pulling = state.read(cx).pulling;
855            if !pulling && y - press < MOUSE_SLOP {
856                return;
857            }
858            state.update(cx, |state, cx| {
859                state.model.consume(y - last, refreshing);
860                state.pulling = state.model.distance_pulled > 0.;
861                state.settle = None;
862                state.mouse = Some((press, y));
863                cx.notify();
864            });
865            cx.stop_propagation();
866        });
867    }
868    window.on_mouse_event(move |_: &MouseUpEvent, phase, window, cx| {
869        if phase != DispatchPhase::Capture {
870            return;
871        }
872        let current = state.read(cx);
873        if current.mouse.is_none() {
874            return;
875        }
876        let pulling = current.pulling;
877        state.update(cx, |state, _| state.mouse = None);
878        if pulling {
879            finish_pull(&state, true, refreshing, on_refresh.as_ref(), window, cx);
880        }
881    });
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use core::prelude::v1::test;
888    use serde_json::Value;
889    use std::cell::Cell;
890
891    const REFERENCE: &str =
892        include_str!("../../../../conformance/fixtures/m3e-pull-to-refresh.json");
893
894    fn number(value: &Value) -> f32 {
895        value.as_f64().expect("a number") as f32
896    }
897
898    #[track_caller]
899    fn close(actual: f32, expected: f32, what: &str) {
900        // The port rounds to f32 as Kotlin does; allow for operation order.
901        let tolerance = 2e-4_f32.max(expected.abs() * 2e-6);
902        assert!(
903            (actual - expected).abs() <= tolerance,
904            "{what}: {actual} != {expected}"
905        );
906    }
907
908    #[test]
909    fn the_constants_are_the_references() {
910        let reference: Value = serde_json::from_str(REFERENCE).unwrap();
911        close(
912            PULL_TO_REFRESH_DRAG_MULTIPLIER,
913            number(&reference["dragMultiplier"]),
914            "multiplier",
915        );
916        close(
917            PULL_TO_REFRESH_MIN_ALPHA,
918            number(&reference["minAlpha"]),
919            "alpha",
920        );
921        close(
922            PULL_TO_REFRESH_INDICATOR_SIZE,
923            number(&reference["spinnerContainerSize"]),
924            "container",
925        );
926    }
927
928    #[test]
929    fn the_arrow_matches_every_reference_sample() {
930        let reference: Value = serde_json::from_str(REFERENCE).unwrap();
931        let arrows = reference["arrows"].as_array().unwrap();
932        assert!(arrows.len() > 40, "{}", arrows.len());
933        for sample in arrows {
934            let progress = number(&sample["progress"]);
935            let density = number(&sample["density"]);
936            let arrow = pull_arrow(progress, density);
937            let what = format!("arrow at {progress} x{density}");
938            close(arrow.size, number(&sample["size"]), &what);
939            close(arrow.rotation, number(&sample["rotation"]), &what);
940            let arc = sample["arc"].as_array().unwrap();
941            for (index, expected) in arc.iter().take(4).enumerate() {
942                close(arrow.arc[index], number(expected), &what);
943            }
944            close(arrow.start_angle, number(&arc[4]), &what);
945            close(arrow.sweep_angle, number(&arc[5]), &what);
946            close(arrow.stroke, number(&arc[6]), &what);
947            for (index, expected) in sample["points"].as_array().unwrap().iter().enumerate() {
948                close(arrow.points[index], number(expected), &what);
949            }
950            close(
951                arrow.arrow_rotation,
952                number(&sample["arrowRotations"][1]),
953                &what,
954            );
955        }
956    }
957
958    #[test]
959    fn drags_match_every_reference_sequence() {
960        let reference: Value = serde_json::from_str(REFERENCE).unwrap();
961        let drags = reference["drags"].as_array().unwrap();
962        assert!(drags.len() >= 7, "{}", drags.len());
963        for drag in drags {
964            let name = drag["name"].as_str().unwrap();
965            let refreshing = drag["refreshing"].as_bool().unwrap();
966            let mut model = PullModel::new(number(&drag["thresholdPx"]));
967            for step in drag["steps"].as_array().unwrap() {
968                let consumed = model.consume(number(&step["available"]), refreshing);
969                close(consumed, number(&step["consumed"]), name);
970                close(model.distance_pulled, number(&step["distancePulled"]), name);
971                close(model.vertical_offset, number(&step["verticalOffset"]), name);
972                close(model.distance_fraction(), number(&step["fraction"]), name);
973            }
974            assert_eq!(
975                model.release(refreshing),
976                drag["refreshed"].as_bool().unwrap(),
977                "{name}"
978            );
979        }
980    }
981
982    #[test]
983    fn a_bad_threshold_uses_the_default_and_nothing_divides_by_zero() {
984        for threshold in [0., -5., f32::NAN, f32::INFINITY] {
985            let mut model = PullModel::new(threshold);
986            assert_eq!(model.threshold, PULL_TO_REFRESH_THRESHOLD);
987            model.consume(40., false);
988            assert!(model.distance_fraction().is_finite());
989        }
990    }
991
992    #[test]
993    fn the_loading_indicator_turns_back_only_past_the_threshold() {
994        assert_eq!(pull_loading_rotation(0.5), 0.);
995        assert_eq!(pull_loading_rotation(1.), 0.);
996        assert_eq!(pull_loading_rotation(1.5), -90.);
997    }
998
999    struct Screen {
1000        refreshing: bool,
1001        disabled: bool,
1002        refreshes: Rc<Cell<usize>>,
1003        clicks: Rc<Cell<usize>>,
1004        scroll: ScrollHandle,
1005    }
1006
1007    impl Render for Screen {
1008        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1009            window.set_rem_size(px(crate::tokens::typography::BASE_PX));
1010            let refreshes = self.refreshes.clone();
1011            let clicks = self.clicks.clone();
1012            div().size(px(400.)).child(
1013                PullToRefresh::new("ptr", "Refreshing")
1014                    .refreshing(self.refreshing)
1015                    .disabled(self.disabled)
1016                    .track_scroll(&self.scroll)
1017                    .on_refresh(move |_, _| refreshes.set(refreshes.get() + 1))
1018                    .child(
1019                        div()
1020                            .id("row")
1021                            .debug_selector(|| "row".into())
1022                            .h(px(2000.))
1023                            .w_full()
1024                            .on_click(move |_, _, _| clicks.set(clicks.get() + 1)),
1025                    ),
1026            )
1027        }
1028    }
1029
1030    fn open(cx: &mut gpui::TestAppContext) -> (Entity<Screen>, &mut gpui::VisualTestContext) {
1031        cx.update(|cx| cx.set_reduce_motion(true));
1032        let (view, cx) = cx.add_window_view(|_, _| Screen {
1033            refreshing: false,
1034            disabled: false,
1035            refreshes: Rc::default(),
1036            clicks: Rc::default(),
1037            scroll: ScrollHandle::default(),
1038        });
1039        draw(cx);
1040        (view, cx)
1041    }
1042
1043    fn draw(cx: &mut gpui::VisualTestContext) {
1044        for _ in 0..2 {
1045            cx.update(|window, cx| {
1046                window.refresh();
1047                window.draw(cx).clear(cx);
1048            });
1049            cx.run_until_parked();
1050        }
1051    }
1052
1053    fn scroll(cx: &mut gpui::VisualTestContext, phase: TouchPhase, dy: f32) {
1054        cx.simulate_event(ScrollEvent {
1055            position: point(px(200.), px(200.)),
1056            delta: ScrollDelta::Pixels(point(px(0.), px(dy))),
1057            touch_phase: phase,
1058            ..Default::default()
1059        });
1060        draw(cx);
1061    }
1062
1063    fn indicator_top(cx: &mut gpui::VisualTestContext) -> Option<f32> {
1064        cx.debug_bounds("pull-to-refresh-indicator")
1065            .map(|bounds| f32::from(bounds.top()))
1066    }
1067
1068    #[gpui::test]
1069    fn a_pull_past_the_threshold_refreshes_once_and_a_short_one_does_not(
1070        cx: &mut gpui::TestAppContext,
1071    ) {
1072        let (view, cx) = open(cx);
1073        assert_eq!(indicator_top(cx), None, "nothing shows at rest");
1074        scroll(cx, TouchPhase::Started, 0.);
1075        scroll(cx, TouchPhase::Moved, 80.);
1076        // 80px of drag counts half: a fraction of 0.5 moves the box 40dp from
1077        // just above the top, which leaves it level with the top edge.
1078        assert_eq!(indicator_top(cx), Some(0.));
1079        scroll(cx, TouchPhase::Ended, 0.);
1080        let refreshes = view.read_with(cx, |view, _| view.refreshes.clone());
1081        assert_eq!(refreshes.get(), 0);
1082        assert_eq!(indicator_top(cx), None, "it returns to rest");
1083
1084        scroll(cx, TouchPhase::Started, 0.);
1085        scroll(cx, TouchPhase::Moved, 200.);
1086        scroll(cx, TouchPhase::Ended, 0.);
1087        assert_eq!(refreshes.get(), 1);
1088    }
1089
1090    #[gpui::test]
1091    fn refreshing_holds_the_indicator_at_the_threshold_and_ignores_pulls(
1092        cx: &mut gpui::TestAppContext,
1093    ) {
1094        let (view, cx) = open(cx);
1095        cx.update(|_, cx| view.update(cx, |view, _| view.refreshing = true));
1096        draw(cx);
1097        let at_threshold = PULL_TO_REFRESH_MAX_DISTANCE - PULL_TO_REFRESH_INDICATOR_SIZE;
1098        assert_eq!(indicator_top(cx), Some(at_threshold));
1099        scroll(cx, TouchPhase::Started, 0.);
1100        scroll(cx, TouchPhase::Moved, 300.);
1101        scroll(cx, TouchPhase::Ended, 0.);
1102        assert_eq!(indicator_top(cx), Some(at_threshold));
1103        let refreshes = view.read_with(cx, |view, _| view.refreshes.clone());
1104        assert_eq!(refreshes.get(), 0);
1105        cx.update(|_, cx| view.update(cx, |view, _| view.refreshing = false));
1106        draw(cx);
1107        assert_eq!(indicator_top(cx), None);
1108    }
1109
1110    #[gpui::test]
1111    fn a_scrolled_list_scrolls_instead_of_pulling(cx: &mut gpui::TestAppContext) {
1112        let (view, cx) = open(cx);
1113        let handle = view.read_with(cx, |view, _| view.scroll.clone());
1114        scroll(cx, TouchPhase::Started, 0.);
1115        scroll(cx, TouchPhase::Moved, -300.);
1116        assert!(handle.offset().y < px(0.), "the content scrolled");
1117        scroll(cx, TouchPhase::Moved, 100.);
1118        assert_eq!(indicator_top(cx), None, "not at the top: no pull");
1119        assert!(handle.offset().y < px(0.));
1120    }
1121
1122    #[gpui::test]
1123    fn pulling_back_unwinds_the_pull_before_the_content_scrolls(cx: &mut gpui::TestAppContext) {
1124        let (view, cx) = open(cx);
1125        let handle = view.read_with(cx, |view, _| view.scroll.clone());
1126        scroll(cx, TouchPhase::Started, 0.);
1127        scroll(cx, TouchPhase::Moved, 100.);
1128        scroll(cx, TouchPhase::Moved, -40.);
1129        assert_eq!(handle.offset().y, px(0.), "the pull took the packet");
1130        assert!(indicator_top(cx).is_some());
1131    }
1132
1133    #[gpui::test]
1134    fn momentum_after_a_gesture_is_not_a_pull(cx: &mut gpui::TestAppContext) {
1135        let (_, cx) = open(cx);
1136        scroll(cx, TouchPhase::Started, 0.);
1137        scroll(cx, TouchPhase::Ended, 0.);
1138        scroll(cx, TouchPhase::Moved, 50.);
1139        assert_eq!(indicator_top(cx), None);
1140    }
1141
1142    #[gpui::test]
1143    fn a_phaseless_stream_pulls_and_a_pause_releases_it(cx: &mut gpui::TestAppContext) {
1144        let (view, cx) = open(cx);
1145        scroll(cx, TouchPhase::Moved, 200.);
1146        assert!(indicator_top(cx).is_some());
1147        cx.executor().advance_clock(GESTURE_GAP * 2);
1148        draw(cx);
1149        let refreshes = view.read_with(cx, |view, _| view.refreshes.clone());
1150        assert_eq!(refreshes.get(), 1);
1151        assert_eq!(indicator_top(cx), None);
1152    }
1153
1154    #[gpui::test]
1155    fn a_mouse_drag_pulls_and_its_release_clicks_nothing(cx: &mut gpui::TestAppContext) {
1156        let (view, cx) = open(cx);
1157        let start = point(px(200.), px(100.));
1158        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::none());
1159        draw(cx);
1160        let end = point(px(200.), px(320.));
1161        cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::none());
1162        draw(cx);
1163        assert!(indicator_top(cx).is_some());
1164        cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::none());
1165        draw(cx);
1166        let (refreshes, clicks) =
1167            view.read_with(cx, |view, _| (view.refreshes.clone(), view.clicks.clone()));
1168        assert_eq!(refreshes.get(), 1);
1169        assert_eq!(clicks.get(), 0, "a pull's release is not a click");
1170        // A plain click still reaches the content.
1171        cx.simulate_click(start, Modifiers::none());
1172        assert_eq!(clicks.get(), 1);
1173    }
1174
1175    #[gpui::test]
1176    fn disabled_ignores_pulls(cx: &mut gpui::TestAppContext) {
1177        let (view, cx) = open(cx);
1178        cx.update(|_, cx| view.update(cx, |view, _| view.disabled = true));
1179        draw(cx);
1180        scroll(cx, TouchPhase::Started, 0.);
1181        scroll(cx, TouchPhase::Moved, 300.);
1182        assert_eq!(indicator_top(cx), None);
1183    }
1184
1185    #[gpui::test]
1186    fn the_settle_animates_with_motion_and_jumps_under_reduced_motion(
1187        cx: &mut gpui::TestAppContext,
1188    ) {
1189        let (view, cx) = open(cx);
1190        cx.update(|_, cx| cx.set_reduce_motion(false));
1191        cx.update(|_, cx| view.update(cx, |view, _| view.refreshing = true));
1192        draw(cx);
1193        let target = PULL_TO_REFRESH_MAX_DISTANCE - PULL_TO_REFRESH_INDICATOR_SIZE;
1194        let first = indicator_top(cx).expect("the settle starts");
1195        assert!(first < target, "{first}");
1196        cx.executor().advance_clock(SETTLE_SPRING.duration());
1197        draw(cx);
1198        assert_eq!(indicator_top(cx), Some(target));
1199    }
1200}