1#![doc = include_str!("../docs/api/sortable-lists.md")]
2
3use std::collections::HashMap;
4use std::rc::Rc;
5
6use dioxus::html::MountedData;
7use dioxus::prelude::*;
8
9use crate::a11y::use_reduced_motion_css;
10use crate::core::components::{overlay_style, touch_style, HoldTimer};
11use crate::core::hooks::use_rect_refresh_thunk;
12use crate::core::{
13 platform, transition_with, GestureEffect, GestureEvent, GesturePhase, Point, Promotion, Rect,
14 TouchSense,
15};
16
17fn pointer_client(evt: &PointerEvent) -> Point {
18 let c = evt.client_coordinates();
19 Point::new(c.x, c.y)
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[non_exhaustive]
28pub struct SortEvent {
29 pub from: usize,
30 pub to: usize,
31}
32
33impl SortEvent {
34 pub fn new(from: usize, to: usize) -> Self {
36 Self { from, to }
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum ReorderMode {
43 #[default]
45 Insert,
46 Swap,
48}
49
50pub fn apply_swap<T>(list: &mut [T], ev: SortEvent) {
52 if ev.from != ev.to && ev.from < list.len() && ev.to < list.len() {
53 list.swap(ev.from, ev.to);
54 }
55}
56
57pub fn displacement(ix: usize, from: usize, over: usize, step: f64) -> f64 {
68 if ix == from {
69 (over as f64 - from as f64) * step
70 } else if from < over && ix > from && ix <= over {
71 -step
72 } else if over < from && ix >= over && ix < from {
73 step
74 } else {
75 0.0
76 }
77}
78
79fn slot_pitch(rects: &HashMap<usize, Rect>, ix: usize, axis: Axis) -> Option<f64> {
81 let pos = |r: &Rect| match axis {
82 Axis::Vertical => r.y,
83 Axis::Horizontal => r.x,
84 };
85 let cur = rects.get(&ix)?;
86 if let Some(next) = rects.get(&(ix + 1)) {
87 return Some(pos(next) - pos(cur));
88 }
89 if let Some(prev) = ix.checked_sub(1).and_then(|p| rects.get(&p)) {
90 return Some(pos(cur) - pos(prev));
91 }
92 Some(match axis {
93 Axis::Vertical => cur.height,
94 Axis::Horizontal => cur.width,
95 })
96}
97
98pub(crate) fn refresh_rects(
99 mounteds: Signal<HashMap<usize, Rc<MountedData>>>,
100 rects: Signal<HashMap<usize, Rect>>,
101) {
102 for (i, m) in mounteds.peek().clone() {
103 let mut rects = rects;
104 spawn(async move {
105 if let Ok(r) = m.get_client_rect().await {
106 rects.write().insert(
107 i,
108 Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
109 );
110 }
111 });
112 }
113}
114
115fn shift_rects(rects: &mut HashMap<usize, Rect>, dx: f64, dy: f64) {
117 for rect in rects.values_mut() {
118 rect.x += dx;
119 rect.y += dy;
120 }
121}
122
123fn reanchor_rects(
136 container: Signal<Option<Rc<MountedData>>>,
137 anchor: Signal<Option<Point>>,
138 rects: Signal<HashMap<usize, Rect>>,
139 busy: Signal<bool>,
140 pending: Signal<bool>,
141) {
142 let Some(m) = container.peek().clone() else {
143 return;
144 };
145 if *busy.peek() {
146 let mut pending = pending;
147 pending.set(true);
148 return;
149 }
150 let mut busy = busy;
151 busy.set(true);
152 spawn(async move {
153 let mut anchor = anchor;
154 let mut rects = rects;
155 let mut pending = pending;
156 loop {
157 if let Ok(r) = m.get_client_rect().await {
158 let new = Point::new(r.origin.x, r.origin.y);
159 if let Some(old) = *anchor.peek() {
162 let (dx, dy) = (new.x - old.x, new.y - old.y);
163 if dx != 0.0 || dy != 0.0 {
164 shift_rects(&mut rects.write(), dx, dy);
165 }
166 }
167 anchor.set(Some(new));
168 }
169 if *pending.peek() {
170 pending.set(false);
171 } else {
172 break;
173 }
174 }
175 busy.set(false);
176 });
177}
178
179fn capture_anchor(container: Signal<Option<Rc<MountedData>>>, anchor: Signal<Option<Point>>) {
183 let Some(m) = container.peek().clone() else {
184 return;
185 };
186 let mut anchor = anchor;
187 spawn(async move {
188 if let Ok(r) = m.get_client_rect().await {
189 anchor.set(Some(Point::new(r.origin.x, r.origin.y)));
190 }
191 });
192}
193
194pub fn pointer_target(
201 rects: &HashMap<usize, Rect>,
202 from: usize,
203 current: Option<usize>,
204 at: Point,
205 axis: Axis,
206) -> Option<usize> {
207 let Some((&ix, rect)) = rects.iter().find(|(_, r)| r.contains(at)) else {
208 return current;
209 };
210 if ix == from || Some(ix) == current {
211 return current;
212 }
213 let (pos, size) = match axis {
214 Axis::Vertical => (at.y - rect.y, rect.height),
215 Axis::Horizontal => (at.x - rect.x, rect.width),
216 };
217 let crossed = if from < ix {
218 pos > size * 0.5
219 } else {
220 pos < size * 0.5
221 };
222 if crossed {
223 Some(ix)
224 } else {
225 current
226 }
227}
228
229pub(crate) fn list_bounds(rects: &HashMap<usize, Rect>) -> Option<Rect> {
233 let mut it = rects.values();
234 let first = it.next()?;
235 let (mut min_x, mut min_y) = (first.x, first.y);
236 let (mut max_x, mut max_y) = (first.x + first.width, first.y + first.height);
237 for r in it {
238 min_x = min_x.min(r.x);
239 min_y = min_y.min(r.y);
240 max_x = max_x.max(r.x + r.width);
241 max_y = max_y.max(r.y + r.height);
242 }
243 Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
249pub enum Axis {
250 #[default]
251 Vertical,
252 Horizontal,
253}
254
255pub fn apply_sort<T>(list: &mut Vec<T>, ev: SortEvent) {
257 if ev.from == ev.to || ev.from >= list.len() || ev.to >= list.len() {
258 return;
259 }
260 let item = list.remove(ev.from);
261 list.insert(ev.to, item);
262}
263
264#[component]
277pub fn SortableList(
278 len: usize,
280 render: Callback<usize, Element>,
282 on_sort: EventHandler<SortEvent>,
284 #[props(default)]
286 axis: Axis,
287 #[props(default = true)]
290 live_preview: bool,
291 #[props(default = 160)]
293 transition_ms: u32,
294 #[props(default)]
299 overlay: Option<Callback<usize, Element>>,
300 #[props(default = false)]
305 touch_handle: bool,
306 #[props(default)]
312 touch: TouchSense,
313 #[props(default)]
316 handle: Option<Callback<usize, Element>>,
317 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
318) -> Element {
319 let mut drag_from = use_signal(|| None::<usize>);
320 let mut over = use_signal(|| None::<usize>);
321 let mut press_from = use_signal(|| None::<usize>);
322 let mut press_at = use_signal(|| None::<Point>);
323 let mut pointer_at = use_signal(|| None::<Point>);
324 let rects = use_signal(HashMap::<usize, Rect>::new);
327 let mounteds = use_signal(HashMap::<usize, Rc<MountedData>>::new);
328 let mut rects_for_len = rects;
329 let mut mounteds_for_len = mounteds;
330 use_effect(use_reactive!(|len| {
331 rects_for_len.write().retain(|ix, _| *ix < len);
332 mounteds_for_len.write().retain(|ix, _| *ix < len);
333 }));
334 let size_of = move |ix: usize| {
335 rects
336 .peek()
337 .get(&ix)
338 .map(|r| match axis {
339 Axis::Vertical => r.height,
340 Axis::Horizontal => r.width,
341 })
342 .unwrap_or(40.0)
343 };
344
345 let container = use_signal(|| None::<Rc<MountedData>>);
350 let anchor = use_signal(|| None::<Point>);
351 let reanchor_busy = use_signal(|| false);
352 let reanchor_pending = use_signal(|| false);
353 use_rect_refresh_thunk(move |_| {
354 if drag_from.peek().is_some() {
355 reanchor_rects(container, anchor, rects, reanchor_busy, reanchor_pending);
356 }
357 });
358
359 let mut gesture = use_signal(|| GesturePhase::Idle);
363 let mut hold_pid = use_signal(|| None::<i32>);
366 let mut step = move |event: GestureEvent| -> GestureEffect {
367 let promotion = if hold_pid.peek().is_some() {
368 Promotion::HoldOrSideways
369 } else {
370 Promotion::Distance
371 };
372 let (next, fx) = transition_with(*gesture.peek(), event, 8.0, promotion);
373 gesture.set(next);
374 if hold_pid.peek().is_some() && !matches!(next, GesturePhase::Pressed { .. }) {
377 hold_pid.set(None);
378 }
379 fx
380 };
381 let mut feed = move |event: GestureEvent| {
385 match step(event) {
386 GestureEffect::Begin { at, .. } => {
387 let Some(ix) = *press_from.peek() else {
388 return;
389 };
390 drag_from.set(Some(ix));
391 pointer_at.set(Some(at));
392 over.set(pointer_target(&rects.peek(), ix, None, at, axis));
393 refresh_rects(mounteds, rects);
398 capture_anchor(container, anchor);
399 }
400 GestureEffect::Track { at } => {
401 let Some(from) = *drag_from.peek() else {
402 return;
403 };
404 pointer_at.set(Some(at));
405 let next = pointer_target(&rects.peek(), from, *over.peek(), at, axis);
406 if next != *over.peek() {
407 over.set(next);
408 }
409 }
410 GestureEffect::Drop { at } => {
411 let from_opt = *drag_from.peek();
412 let to = {
416 let rects_ref = rects.peek();
417 if list_bounds(&rects_ref)
418 .map(|b| b.contains(at))
419 .unwrap_or(false)
420 {
421 from_opt.and_then(|from| {
422 pointer_target(&rects_ref, from, *over.peek(), at, axis)
423 })
424 } else {
425 None
426 }
427 };
428 press_from.set(None);
433 press_at.set(None);
434 drag_from.set(None);
435 over.set(None);
436 pointer_at.set(None);
437 if let (Some(from), Some(to)) = (from_opt, to) {
438 if from != to {
439 on_sort.call(SortEvent { from, to });
440 }
441 }
442 }
443 GestureEffect::Abort => {
444 press_from.set(None);
445 press_at.set(None);
446 drag_from.set(None);
447 over.set(None);
448 pointer_at.set(None);
449 }
450 GestureEffect::Tap => {
451 press_from.set(None);
452 press_at.set(None);
453 pointer_at.set(None);
454 }
455 GestureEffect::None => {}
456 }
457 };
458
459 let reduced_motion_css = use_reduced_motion_css();
461
462 let primary_pointer = move |evt: &PointerEvent| crate::core::components::primary_press(evt);
463 let mut empty_held_moves = use_signal(|| 0u8);
465 let mut captured = use_signal(|| false);
468 let mut cancel_drag = move || {
469 feed(GestureEvent::Cancel);
470 press_from.set(None);
471 press_at.set(None);
472 drag_from.set(None);
473 over.set(None);
474 pointer_at.set(None);
475 };
476
477 let overlay_ghost: Option<(Callback<usize, Element>, usize, Point, Rect)> =
483 overlay.zip(drag_from()).and_then(|(cb, from)| {
484 let r = rects.peek().get(&from).copied()?;
485 let p0 = press_at()?;
486 let p1 = pointer_at()?;
487 Some((
488 cb,
489 from,
490 Point::new(r.x + (p1.x - p0.x), r.y + (p1.y - p0.y)),
491 r,
492 ))
493 });
494 let ghost_from = overlay_ghost.map(|(_, f, _, _)| f);
495
496 rsx! {
497 div {
498 onmounted: move |evt: Event<MountedData>| {
499 let mut container = container;
500 container.set(Some(evt.data()));
501 },
502 onpointermove: move |evt: PointerEvent| {
503 let at = pointer_client(&evt);
504 if drag_from.peek().is_some() && evt.held_buttons().is_empty() {
515 let streak = empty_held_moves.peek().saturating_add(1);
516 empty_held_moves.set(streak);
517 if streak >= crate::core::components::RELEASE_RECOVERY_MOVES {
518 if let Some(from) = *drag_from.peek() {
519 if let Some(n) = mounteds.peek().get(&from).cloned() {
520 platform::release_pointer(&n, evt.pointer_id());
521 }
522 }
523 feed(GestureEvent::Up { at, pointer_id: evt.pointer_id() });
524 return;
525 }
526 } else if *empty_held_moves.peek() != 0 {
527 empty_held_moves.set(0);
528 }
529 feed(GestureEvent::Move { at, pointer_id: evt.pointer_id() });
530 },
531 onpointerup: move |evt: PointerEvent| {
532 if let Some(from) = *drag_from.peek() {
533 if let Some(n) = mounteds.peek().get(&from).cloned() {
534 platform::release_pointer(&n, evt.pointer_id());
535 }
536 }
537 feed(GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
538 },
539 onpointercancel: move |evt: PointerEvent| {
544 if let Some(from) = *drag_from.peek() {
545 if let Some(n) = mounteds.peek().get(&from).cloned() {
546 platform::release_pointer(&n, evt.pointer_id());
547 }
548 }
549 cancel_drag();
550 },
551 onlostpointercapture: move |_| cancel_drag(),
552 ontouchmove: move |evt: TouchEvent| {
557 if matches!(*gesture.peek(), GesturePhase::Dragging { .. }) {
558 evt.prevent_default();
559 }
560 },
561 oncontextmenu: move |evt: Event<MouseData>| {
564 if !matches!(*gesture.peek(), GesturePhase::Idle) {
565 evt.prevent_default();
566 }
567 },
568 ..attributes,
569 {reduced_motion_css}
570 if drag_from().is_some() && !captured() {
576 div {
577 style: "position: fixed; inset: 0; z-index: 9998; touch-action: none;",
578 aria_hidden: true,
579 }
580 }
581 if let Some(pid) = hold_pid() {
584 HoldTimer {
585 pointer_id: pid,
586 on_hold: move |pid| feed(GestureEvent::Hold { pointer_id: pid }),
587 }
588 }
589 for ix in 0..len {
590 div {
591 key: "{ix}",
592 "data-dnd-motion": true,
593 "data-dragging": if drag_from() == Some(ix) { "true" },
594 "data-drop-target": if over() == Some(ix) && drag_from() != Some(ix) { "true" },
595 style: {
596 let base = match (live_preview, drag_from()) {
604 (true, Some(from)) => {
605 let step = slot_pitch(&rects.peek(), from, axis)
606 .unwrap_or_else(|| size_of(from));
607 let o = over().unwrap_or(from);
608 let d = displacement(ix, from, o, step);
609 let (x, y) = match axis {
610 Axis::Vertical => (0.0, d),
611 Axis::Horizontal => (d, 0.0),
612 };
613 let hidden = if ghost_from == Some(ix) {
614 " opacity: 0;"
615 } else {
616 ""
617 };
618 format!("transform: translate({x}px, {y}px); transition: transform {transition_ms}ms ease;{hidden}")
619 }
620 _ => format!(
621 "transform: translate(0px, 0px); transition: transform {transition_ms}ms; opacity: 1;"
622 ),
623 };
624 if touch_handle {
625 format!("display: flex; align-items: stretch; width: 100%; {base}")
626 } else {
627 format!("{} {base}", touch_style(touch))
628 }
629 },
630 onpointerdown: move |evt: PointerEvent| {
633 if touch_handle || !primary_pointer(&evt) {
634 return;
635 }
636 evt.prevent_default();
637 evt.stop_propagation();
638 refresh_rects(mounteds, rects);
639 capture_anchor(container, anchor);
640 press_from.set(Some(ix));
641 press_at.set(Some(pointer_client(&evt)));
642 captured.set(match mounteds.peek().get(&ix).cloned() {
647 Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
648 None => false,
649 });
650 let pid = evt.pointer_id();
651 feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: pid });
652 if touch == TouchSense::Auto
655 && evt.pointer_type() != "mouse"
656 && matches!(*gesture.peek(), GesturePhase::Pressed { pointer_id, .. } if pointer_id == pid)
657 {
658 hold_pid.set(Some(pid));
659 }
660 },
661 onmounted: move |evt: Event<MountedData>| {
662 let m: Rc<MountedData> = evt.data();
663 let mut mounteds = mounteds;
664 let mut rects = rects;
665 mounteds.write().insert(ix, m.clone());
666 spawn(async move {
667 if let Ok(r) = m.get_client_rect().await {
668 rects.write().insert(
669 ix,
670 Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
671 );
672 }
673 });
674 },
675 if touch_handle {
676 span {
677 "data-sort-handle": true,
678 aria_hidden: true,
679 style: "touch-action: none; user-select: none; -webkit-user-select: none; display: grid; place-items: center;",
680 onpointerdown: move |evt: PointerEvent| {
681 if !primary_pointer(&evt) {
682 return;
683 }
684 evt.prevent_default();
685 evt.stop_propagation();
686 refresh_rects(mounteds, rects);
687 capture_anchor(container, anchor);
688 press_from.set(Some(ix));
689 press_at.set(Some(pointer_client(&evt)));
690 captured.set(match mounteds.peek().get(&ix).cloned() {
694 Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
695 None => false,
696 });
697 feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
698 },
699 if let Some(h) = handle {
700 {h.call(ix)}
701 } else {
702 "⠿"
703 }
704 }
705 div {
706 "data-sort-content": true,
707 style: "flex: 1 1 auto; min-width: 0;",
708 {render.call(ix)}
709 }
710 } else {
711 {render.call(ix)}
712 }
713 }
714 }
715 if let Some((cb, from, pos, rect)) = overlay_ghost {
716 div {
717 style: format!(
718 "{} width: {}px; height: {}px;",
719 overlay_style(pos),
720 rect.width,
721 rect.height
722 ),
723 {cb.call(from)}
724 }
725 }
726 }
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733
734 #[test]
735 fn sort_moves_forward_and_back() {
736 let mut v = vec!["a", "b", "c", "d"];
737 apply_sort(&mut v, SortEvent { from: 0, to: 2 });
738 assert_eq!(v, vec!["b", "c", "a", "d"]);
739 apply_sort(&mut v, SortEvent { from: 3, to: 0 });
740 assert_eq!(v, vec!["d", "b", "c", "a"]);
741 }
742
743 #[test]
744 fn sort_ignores_out_of_bounds_and_noops() {
745 let mut v = vec![1, 2, 3];
746 apply_sort(&mut v, SortEvent { from: 1, to: 1 });
747 apply_sort(&mut v, SortEvent { from: 9, to: 0 });
748 apply_sort(&mut v, SortEvent { from: 0, to: 9 });
749 assert_eq!(v, vec![1, 2, 3]);
750 }
751}
752
753#[cfg(test)]
754mod pointer_target_tests {
755 use super::*;
756
757 fn rows() -> HashMap<usize, Rect> {
759 (0..3)
760 .map(|i| (i, Rect::new(0.0, i as f64 * 40.0, 200.0, 40.0)))
761 .collect()
762 }
763
764 #[test]
765 fn adopts_a_row_only_past_its_midpoint() {
766 let r = rows();
767 let t = pointer_target(&r, 0, None, Point::new(50.0, 45.0), Axis::Vertical);
769 assert_eq!(t, None);
770 let t = pointer_target(&r, 0, None, Point::new(50.0, 65.0), Axis::Vertical);
772 assert_eq!(t, Some(1));
773 let t = pointer_target(&r, 2, None, Point::new(50.0, 75.0), Axis::Vertical);
775 assert_eq!(t, None);
776 let t = pointer_target(&r, 2, None, Point::new(50.0, 55.0), Axis::Vertical);
777 assert_eq!(t, Some(1));
778 }
779
780 #[test]
781 fn keeps_current_over_source_row_and_outside_all_rects() {
782 let r = rows();
783 let t = pointer_target(&r, 0, Some(2), Point::new(50.0, 10.0), Axis::Vertical);
785 assert_eq!(t, Some(2));
786 let t = pointer_target(&r, 0, Some(2), Point::new(500.0, 500.0), Axis::Vertical);
788 assert_eq!(t, Some(2));
789 }
790
791 #[test]
792 fn horizontal_axis_uses_x() {
793 let r: HashMap<usize, Rect> = (0..3)
794 .map(|i| (i, Rect::new(i as f64 * 60.0, 0.0, 60.0, 40.0)))
795 .collect();
796 let t = pointer_target(&r, 0, None, Point::new(100.0, 20.0), Axis::Horizontal);
797 assert_eq!(t, Some(1)); }
799
800 #[test]
801 fn list_bounds_covers_all_rows_and_excludes_outside() {
802 let r = rows(); let b = list_bounds(&r).unwrap();
804 assert_eq!(b, Rect::new(0.0, 0.0, 200.0, 120.0));
805 assert!(b.contains(Point::new(50.0, 60.0)));
807 assert!(!b.contains(Point::new(500.0, 500.0)));
809 assert!(!b.contains(Point::new(50.0, 130.0)));
810 assert_eq!(list_bounds(&HashMap::new()), None);
812 }
813}
814
815#[cfg(test)]
816mod swap_tests {
817 use super::*;
818
819 #[test]
820 fn swap_exchanges_and_guards_bounds() {
821 let mut v = vec![1, 2, 3, 4];
822 apply_swap(&mut v, SortEvent { from: 0, to: 3 });
823 assert_eq!(v, vec![4, 2, 3, 1]);
824 apply_swap(&mut v, SortEvent { from: 9, to: 0 });
825 assert_eq!(v, vec![4, 2, 3, 1]);
826 }
827}
828
829#[cfg(test)]
830mod shift_rects_tests {
831 use super::*;
832
833 #[test]
837 fn shift_moves_all_slots_uniformly() {
838 let mut rects: HashMap<usize, Rect> = (0..3)
839 .map(|i| (i, Rect::new(10.0, i as f64 * 40.0, 200.0, 40.0)))
840 .collect();
841 shift_rects(&mut rects, 0.0, -130.0);
843 for i in 0..3 {
844 assert_eq!(
845 rects[&i],
846 Rect::new(10.0, i as f64 * 40.0 - 130.0, 200.0, 40.0)
847 );
848 }
849 assert_eq!(slot_pitch(&rects, 1, Axis::Vertical), Some(40.0));
851 }
852}
853
854#[cfg(test)]
855mod slot_pitch_tests {
856 use super::*;
857
858 #[test]
859 fn pitch_includes_spacing_between_rows() {
860 let rows: HashMap<usize, Rect> = (0..3)
861 .map(|i| (i, Rect::new(0.0, i as f64 * 46.0, 200.0, 42.0)))
862 .collect();
863
864 assert_eq!(slot_pitch(&rows, 0, Axis::Vertical), Some(46.0));
865 assert_eq!(slot_pitch(&rows, 1, Axis::Vertical), Some(46.0));
866 assert_eq!(slot_pitch(&rows, 2, Axis::Vertical), Some(46.0));
867 }
868
869 #[test]
870 fn pitch_falls_back_to_size_for_single_row() {
871 let rows: HashMap<usize, Rect> = [(0, Rect::new(0.0, 0.0, 200.0, 42.0))]
872 .into_iter()
873 .collect();
874
875 assert_eq!(slot_pitch(&rows, 0, Axis::Vertical), Some(42.0));
876 assert_eq!(slot_pitch(&rows, 9, Axis::Vertical), None);
877 }
878}
879
880#[cfg(test)]
881mod displacement_tests {
882 use super::*;
883
884 #[test]
885 fn displacement_moves_source_to_target_and_neighbors_aside() {
886 let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).collect();
889 assert_eq!(d, vec![0.0, 80.0, -40.0, -40.0, 0.0]);
890 let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 3, 1, 40.0)).collect();
892 assert_eq!(d, vec![0.0, 40.0, 40.0, -80.0, 0.0]);
893 assert!((0..5).all(|ix| displacement(ix, 2, 2, 40.0) == 0.0));
895 let sum: f64 = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).sum();
897 assert_eq!(sum, 0.0);
898 }
899}