1use 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
34pub const PULL_TO_REFRESH_THRESHOLD: f32 = 80.;
37pub const PULL_TO_REFRESH_DRAG_MULTIPLIER: f32 = 0.5;
39pub const PULL_TO_REFRESH_MAX_DISTANCE: f32 = 80.;
42pub const PULL_TO_REFRESH_INDICATOR_SIZE: f32 = 40.;
44pub const PULL_TO_REFRESH_LOADING_INDICATOR_SIZE: f32 = 48.;
46pub const PULL_TO_REFRESH_SPINNER_SIZE: f32 = 16.;
48pub const PULL_TO_REFRESH_MIN_ALPHA: f32 = 0.3;
50const 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.;
61const MOUSE_SLOP: f32 = 8.;
64const GESTURE_GAP: Duration = Duration::from_millis(250);
67
68fn 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
75pub 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#[derive(Clone, Copy, Debug, PartialEq)]
86pub struct PullModel {
87 pub threshold: f32,
89 pub distance_pulled: f32,
91 pub vertical_offset: f32,
93}
94
95impl PullModel {
96 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 pub fn adjusted_distance(&self) -> f32 {
111 self.distance_pulled * PULL_TO_REFRESH_DRAG_MULTIPLIER
112 }
113
114 pub fn distance_fraction(&self) -> f32 {
116 self.vertical_offset / self.threshold
117 }
118
119 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 pub fn release(&self, refreshing: bool) -> bool {
134 !refreshing && self.adjusted_distance() > self.threshold
135 }
136
137 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#[derive(Clone, Copy, Debug, PartialEq)]
147pub struct PullArrow {
148 pub size: f32,
150 pub stroke: f32,
152 pub rotation: f32,
155 pub arc: [f32; 4],
157 pub start_angle: f32,
159 pub sweep_angle: f32,
161 pub points: [f32; 6],
163 pub arrow_rotation: f32,
165}
166
167pub fn pull_arrow(progress: f32, density: f32) -> PullArrow {
169 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 arrow_rotation: end_angle - stroke,
200 }
201}
202
203pub fn pull_loading_rotation(fraction: f32) -> f32 {
205 if fraction > 1. {
206 -(fraction - 1.) * 180.
207 } else {
208 0.
209 }
210}
211
212#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub enum PullToRefreshIndicator {
215 #[default]
217 Default,
218 LoadingIndicator,
220}
221
222#[derive(Clone, Copy)]
224struct Settle {
225 from: f32,
226 to: f32,
227 started: Instant,
228}
229
230struct PullState {
232 model: PullModel,
233 resting: f32,
235 settle: Option<Settle>,
236 pulling: bool,
237 was_refreshing: bool,
239 mouse: Option<(f32, f32)>,
241 gesture: bool,
243 last_packet: Option<Instant>,
244 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
286fn 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#[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 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 pub fn refreshing(mut self, refreshing: bool) -> Self {
362 self.refreshing = refreshing;
363 self
364 }
365
366 pub fn disabled(mut self, disabled: bool) -> Self {
368 self.disabled = disabled;
369 self
370 }
371
372 pub fn indicator(mut self, indicator: PullToRefreshIndicator) -> Self {
374 self.indicator = indicator;
375 self
376 }
377
378 pub fn threshold(mut self, threshold: f32) -> Self {
380 self.threshold = threshold;
381 self
382 }
383
384 pub fn mouse_drag(mut self, mouse_drag: bool) -> Self {
386 self.mouse_drag = mouse_drag;
387 self
388 }
389
390 pub fn track_scroll(mut self, scroll: &ScrollHandle) -> Self {
393 self.scroll = Some(scroll.clone());
394 self
395 }
396
397 pub fn child(mut self, child: impl IntoElement) -> Self {
399 self.content.push(child.into_any_element());
400 self
401 }
402
403 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#[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 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
434fn 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 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 .when(pulling, |root| {
687 root.child(div().absolute().inset_0().occlude())
688 })
689 .children(indicator)
690 }
691}
692
693#[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 let ScrollDelta::Pixels(delta) = event.delta else {
747 return;
748 };
749 let dy = f32::from(delta.y);
750 let current = state.read(cx);
751 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 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 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 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 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}