1use 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
42pub const DRAG_EXTENT_FRACTION: f32 = 0.25;
46
47pub const DRAG_SIZE_FACTOR_LIMIT: f32 = 1.5;
49
50pub const ARM_AT: f32 = 1.0 / DRAG_SIZE_FACTOR_LIMIT;
53
54pub const SNAP_TIME: f32 = 0.150;
56
57pub const SCALE_TIME: f32 = 0.200;
60
61pub const DRAG_FILL: f32 = 0.75;
64
65#[derive(Copy, Clone, Debug, PartialEq, Eq)]
67pub enum RefreshPhase {
68 Drag,
70 Armed,
72 Snap,
74 RefreshIndicator,
76 Done,
78 Cancel,
80}
81
82impl RefreshPhase {
83 pub fn is_dragging(&self) -> bool {
85 matches!(self, RefreshPhase::Drag | RefreshPhase::Armed)
86 }
87}
88
89#[derive(Copy, Clone, Debug)]
91pub struct RefreshPull {
92 phase: RefreshPhase,
93 drag: f32,
95 position: f32,
97 scaled_out: f32,
99 from: f32,
101 elapsed: f32,
102 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 pub fn phase(&self) -> RefreshPhase {
124 self.phase
125 }
126
127 pub fn position(&self) -> f32 {
129 self.position
130 }
131
132 pub fn scale(&self) -> f32 {
134 1.0 - self.scaled_out
135 }
136
137 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 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 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 fn cancel(&mut self) {
171 if self.phase.is_dragging() {
172 self.begin(RefreshPhase::Cancel);
173 }
174 }
175
176 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 fn advance(&mut self, dt: f32, refreshing: bool) -> bool {
196 match self.phase {
197 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 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 fn begin(&mut self, phase: RefreshPhase) {
239 self.phase = phase;
240 self.from = self.position;
241 self.elapsed = 0.0;
242 }
243}
244
245const SPIN_SPEED: f32 = 1.1;
247
248#[derive(Copy, Clone, Debug, PartialEq)]
251pub struct RefreshSpec {
252 pub refreshing: bool,
254 pub displacement: f32,
256 pub size: f32,
258 pub color: Option<Color>,
260 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#[derive(Copy, Clone, Debug, PartialEq)]
278pub struct Refreshable {
279 pub id: WidgetId,
281 pub viewport: Rect,
283 pub spec: RefreshSpec,
285}
286
287pub 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 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 pub fn on_refresh(mut self, message: impl Into<Msg>) -> Self {
317 self.message = Some(message.into());
318 self
319 }
320
321 pub fn refreshing(mut self, refreshing: bool) -> Self {
324 self.spec.refreshing = refreshing;
325 self
326 }
327
328 pub fn displacement(mut self, displacement: f32) -> Self {
330 self.spec.displacement = displacement;
331 self
332 }
333
334 pub fn size(mut self, size: f32) -> Self {
336 self.spec.size = size;
337 self
338 }
339
340 pub fn color(mut self, color: Color) -> Self {
342 self.spec.color = Some(color);
343 self
344 }
345
346 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 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 }
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
382pub 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 let cy = area.viewport.y - radius + (area.spec.displacement + radius) * factor;
406
407 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 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 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
453pub(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 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
478pub(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 if overscroll < 0.0 && !pulls.contains_key(&id) {
493 return;
494 }
495 pulls.entry(id).or_default().pull(overscroll, extent);
496}
497
498pub(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
508pub(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 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 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 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 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 run(&mut pulls, true, 1.0);
649 assert_eq!(pull_of(&pulls).phase(), RefreshPhase::RefreshIndicator);
650
651 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 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 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 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 #[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}