1#![doc = include_str!("../docs/api/sortable-lists.md")]
2
3use std::collections::{HashMap, HashSet};
4use std::fmt;
5use std::rc::Rc;
6
7use dioxus::html::MountedData;
8use dioxus::prelude::*;
9
10use crate::a11y::use_reduced_motion_css;
11use crate::core::components::{overlay_style, touch_style, HoldTimer};
12use crate::core::hooks::use_rect_refresh_thunk;
13use crate::core::{
14 platform, transition_with, GestureEffect, GestureEvent, GesturePhase, Point, Promotion, Rect,
15 TouchSense,
16};
17
18pub use crate::sortable_kernel::{
19 apply_reorder, project_layout, DropPlacement, ItemTransform, Placement, ReorderEvent,
20 SortStrategy, SortableCollection, SortableGroup, SortableGroupId, SortableHandle, SortableItem,
21 SortablePayload, SortableProvider,
22};
23
24fn pointer_client(evt: &PointerEvent) -> Point {
25 let c = evt.client_coordinates();
26 Point::new(c.x, c.y)
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub(crate) struct RenderKey(String);
31
32impl fmt::Display for RenderKey {
33 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34 self.0.fmt(formatter)
35 }
36}
37
38pub(crate) fn build_render_keys(
39 len: usize,
40 item_key: Option<Callback<usize, String>>,
41) -> Vec<RenderKey> {
42 let keys: Vec<_> = (0..len)
43 .map(|index| {
44 RenderKey(
45 item_key
46 .map(|key| key.call(index))
47 .unwrap_or_else(|| index.to_string()),
48 )
49 })
50 .collect();
51 if item_key.is_some() {
52 let mut unique = HashSet::with_capacity(keys.len());
53 for key in &keys {
54 assert!(
55 unique.insert(key.clone()),
56 "SortableList/Grid item_key returned the duplicate key `{key}`"
57 );
58 }
59 }
60 keys
61}
62
63pub(crate) fn project_rects(
64 keys: &[RenderKey],
65 rects: &HashMap<RenderKey, Rect>,
66) -> HashMap<usize, Rect> {
67 keys.iter()
68 .enumerate()
69 .filter_map(|(index, key)| rects.get(key).copied().map(|rect| (index, rect)))
70 .collect()
71}
72
73pub(crate) fn current_rects(
74 keys: Signal<Vec<RenderKey>>,
75 rects: Signal<HashMap<RenderKey, Rect>>,
76) -> HashMap<usize, Rect> {
77 project_rects(&keys.peek(), &rects.peek())
78}
79
80pub(crate) fn mounted_at(
81 index: usize,
82 keys: Signal<Vec<RenderKey>>,
83 mounteds: Signal<HashMap<RenderKey, Rc<MountedData>>>,
84) -> Option<Rc<MountedData>> {
85 let key = keys.peek().get(index)?.clone();
86 mounteds.peek().get(&key).cloned()
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct SortEvent {
96 pub from: usize,
97 pub to: usize,
98}
99
100impl SortEvent {
101 pub fn new(from: usize, to: usize) -> Self {
103 Self { from, to }
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum ReorderMode {
110 #[default]
112 Insert,
113 Swap,
115}
116
117pub fn apply_swap<T>(list: &mut [T], ev: SortEvent) {
119 if ev.from != ev.to && ev.from < list.len() && ev.to < list.len() {
120 list.swap(ev.from, ev.to);
121 }
122}
123
124pub fn displacement(ix: usize, from: usize, over: usize, step: f64) -> f64 {
135 if ix == from {
136 (over as f64 - from as f64) * step
137 } else if from < over && ix > from && ix <= over {
138 -step
139 } else if over < from && ix >= over && ix < from {
140 step
141 } else {
142 0.0
143 }
144}
145
146fn slot_pitch(rects: &HashMap<usize, Rect>, ix: usize, axis: Axis) -> Option<f64> {
148 let pos = |r: &Rect| match axis {
149 Axis::Vertical => r.y,
150 Axis::Horizontal => r.x,
151 };
152 let cur = rects.get(&ix)?;
153 if let Some(next) = rects.get(&(ix + 1)) {
154 return Some(pos(next) - pos(cur));
155 }
156 if let Some(prev) = ix.checked_sub(1).and_then(|p| rects.get(&p)) {
157 return Some(pos(cur) - pos(prev));
158 }
159 Some(match axis {
160 Axis::Vertical => cur.height,
161 Axis::Horizontal => cur.width,
162 })
163}
164
165pub(crate) fn refresh_rects(
166 mounteds: Signal<HashMap<RenderKey, Rc<MountedData>>>,
167 rects: Signal<HashMap<RenderKey, Rect>>,
168 generations: Signal<HashMap<RenderKey, u64>>,
169) {
170 for (key, mounted) in mounteds.peek().clone() {
171 measure_rect(key, mounted, mounteds, rects, generations);
172 }
173}
174
175pub(crate) fn measure_rect(
176 key: RenderKey,
177 mounted: Rc<MountedData>,
178 mounteds: Signal<HashMap<RenderKey, Rc<MountedData>>>,
179 mut rects: Signal<HashMap<RenderKey, Rect>>,
180 mut generations: Signal<HashMap<RenderKey, u64>>,
181) {
182 let generation = {
183 let mut current = generations.write();
184 let next = current.get(&key).copied().unwrap_or(0).wrapping_add(1);
185 current.insert(key.clone(), next);
186 next
187 };
188 spawn(async move {
189 let Ok(measured) = mounted.get_client_rect().await else {
190 return;
191 };
192 let still_current = measurement_is_current(
193 &mounteds.peek(),
194 &generations.peek(),
195 &key,
196 &mounted,
197 generation,
198 );
199 if still_current {
200 rects.write().insert(
201 key,
202 Rect::new(
203 measured.origin.x,
204 measured.origin.y,
205 measured.size.width,
206 measured.size.height,
207 ),
208 );
209 }
210 });
211}
212
213fn measurement_is_current<K: Eq + std::hash::Hash, T>(
214 mounteds: &HashMap<K, Rc<T>>,
215 generations: &HashMap<K, u64>,
216 key: &K,
217 mounted: &Rc<T>,
218 generation: u64,
219) -> bool {
220 mounteds
221 .get(key)
222 .is_some_and(|current| Rc::ptr_eq(current, mounted))
223 && generations.get(key) == Some(&generation)
224}
225
226fn shift_rects<K>(rects: &mut HashMap<K, Rect>, dx: f64, dy: f64) {
228 for rect in rects.values_mut() {
229 rect.x += dx;
230 rect.y += dy;
231 }
232}
233
234fn reanchor_rects(
247 container: Signal<Option<Rc<MountedData>>>,
248 anchor: Signal<Option<Point>>,
249 rects: Signal<HashMap<RenderKey, Rect>>,
250 busy: Signal<bool>,
251 pending: Signal<bool>,
252) {
253 let Some(m) = container.peek().clone() else {
254 return;
255 };
256 if *busy.peek() {
257 let mut pending = pending;
258 pending.set(true);
259 return;
260 }
261 let mut busy = busy;
262 busy.set(true);
263 spawn(async move {
264 let mut anchor = anchor;
265 let mut rects = rects;
266 let mut pending = pending;
267 loop {
268 if let Ok(r) = m.get_client_rect().await {
269 let new = Point::new(r.origin.x, r.origin.y);
270 if let Some(old) = *anchor.peek() {
273 let (dx, dy) = (new.x - old.x, new.y - old.y);
274 if dx != 0.0 || dy != 0.0 {
275 shift_rects(&mut rects.write(), dx, dy);
276 }
277 }
278 anchor.set(Some(new));
279 }
280 if *pending.peek() {
281 pending.set(false);
282 } else {
283 break;
284 }
285 }
286 busy.set(false);
287 });
288}
289
290fn capture_anchor(container: Signal<Option<Rc<MountedData>>>, anchor: Signal<Option<Point>>) {
294 let Some(m) = container.peek().clone() else {
295 return;
296 };
297 let mut anchor = anchor;
298 spawn(async move {
299 if let Ok(r) = m.get_client_rect().await {
300 anchor.set(Some(Point::new(r.origin.x, r.origin.y)));
301 }
302 });
303}
304
305pub fn pointer_target(
312 rects: &HashMap<usize, Rect>,
313 from: usize,
314 current: Option<usize>,
315 at: Point,
316 axis: Axis,
317) -> Option<usize> {
318 let Some((&ix, rect)) = rects.iter().find(|(_, r)| r.contains(at)) else {
319 return current;
320 };
321 if ix == from || Some(ix) == current {
322 return current;
323 }
324 let (pos, size) = match axis {
325 Axis::Vertical => (at.y - rect.y, rect.height),
326 Axis::Horizontal => (at.x - rect.x, rect.width),
327 };
328 let crossed = if from < ix {
329 pos > size * 0.5
330 } else {
331 pos < size * 0.5
332 };
333 if crossed {
334 Some(ix)
335 } else {
336 current
337 }
338}
339
340pub(crate) fn list_bounds(rects: &HashMap<usize, Rect>) -> Option<Rect> {
344 let mut it = rects.values();
345 let first = it.next()?;
346 let (mut min_x, mut min_y) = (first.x, first.y);
347 let (mut max_x, mut max_y) = (first.x + first.width, first.y + first.height);
348 for r in it {
349 min_x = min_x.min(r.x);
350 min_y = min_y.min(r.y);
351 max_x = max_x.max(r.x + r.width);
352 max_y = max_y.max(r.y + r.height);
353 }
354 Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
360pub enum Axis {
361 #[default]
362 Vertical,
363 Horizontal,
364}
365
366pub fn apply_sort<T>(list: &mut Vec<T>, ev: SortEvent) {
368 if ev.from == ev.to || ev.from >= list.len() || ev.to >= list.len() {
369 return;
370 }
371 let item = list.remove(ev.from);
372 list.insert(ev.to, item);
373}
374
375#[component]
388pub fn SortableList(
389 len: usize,
391 render: Callback<usize, Element>,
393 on_sort: EventHandler<SortEvent>,
395 #[props(default)]
397 axis: Axis,
398 #[props(default = true)]
401 live_preview: bool,
402 #[props(default = 160)]
404 transition_ms: u32,
405 #[props(default)]
410 overlay: Option<Callback<usize, Element>>,
411 #[props(default = false)]
416 touch_handle: bool,
417 #[props(default)]
423 touch: TouchSense,
424 #[props(default)]
427 handle: Option<Callback<usize, Element>>,
428 #[props(default)]
431 item_key: Option<Callback<usize, String>>,
432 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
433) -> Element {
434 let render_keys = build_render_keys(len, item_key);
435 let initial_render_keys = render_keys.clone();
436 let mut index_keys = use_signal(move || initial_render_keys);
437 let mut drag_from = use_signal(|| None::<usize>);
438 let mut over = use_signal(|| None::<usize>);
439 let mut press_from = use_signal(|| None::<usize>);
440 let mut press_at = use_signal(|| None::<Point>);
441 let mut pointer_at = use_signal(|| None::<Point>);
442 let rects = use_signal(HashMap::<RenderKey, Rect>::new);
445 let mounteds = use_signal(HashMap::<RenderKey, Rc<MountedData>>::new);
446 let generations = use_signal(HashMap::<RenderKey, u64>::new);
447 let mut rects_for_keys = rects;
448 let mut mounteds_for_keys = mounteds;
449 let mut generations_for_keys = generations;
450 use_effect(use_reactive!(|(render_keys)| {
451 let active: HashSet<_> = render_keys.iter().cloned().collect();
452 index_keys.set(render_keys);
453 rects_for_keys.write().retain(|key, _| active.contains(key));
454 mounteds_for_keys
455 .write()
456 .retain(|key, _| active.contains(key));
457 generations_for_keys
458 .write()
459 .retain(|key, _| active.contains(key));
460 }));
461 let size_of = move |ix: usize| {
462 current_rects(index_keys, rects)
463 .get(&ix)
464 .map(|r| match axis {
465 Axis::Vertical => r.height,
466 Axis::Horizontal => r.width,
467 })
468 .unwrap_or(40.0)
469 };
470
471 let container = use_signal(|| None::<Rc<MountedData>>);
476 let anchor = use_signal(|| None::<Point>);
477 let reanchor_busy = use_signal(|| false);
478 let reanchor_pending = use_signal(|| false);
479 use_rect_refresh_thunk(move |_| {
480 if drag_from.peek().is_some() {
481 reanchor_rects(container, anchor, rects, reanchor_busy, reanchor_pending);
482 }
483 });
484
485 let mut gesture = use_signal(|| GesturePhase::Idle);
489 let mut hold_pid = use_signal(|| None::<i32>);
492 let mut step = move |event: GestureEvent| -> GestureEffect {
493 let promotion = if hold_pid.peek().is_some() {
494 Promotion::HoldOrSideways
495 } else {
496 Promotion::Distance
497 };
498 let (next, fx) = transition_with(*gesture.peek(), event, 8.0, promotion);
499 gesture.set(next);
500 if hold_pid.peek().is_some() && !matches!(next, GesturePhase::Pressed { .. }) {
503 hold_pid.set(None);
504 }
505 fx
506 };
507 let mut feed = move |event: GestureEvent| {
511 match step(event) {
512 GestureEffect::Begin { at, .. } => {
513 let Some(ix) = *press_from.peek() else {
514 return;
515 };
516 drag_from.set(Some(ix));
517 pointer_at.set(Some(at));
518 over.set(pointer_target(
519 ¤t_rects(index_keys, rects),
520 ix,
521 None,
522 at,
523 axis,
524 ));
525 refresh_rects(mounteds, rects, generations);
530 capture_anchor(container, anchor);
531 }
532 GestureEffect::Track { at } => {
533 let Some(from) = *drag_from.peek() else {
534 return;
535 };
536 pointer_at.set(Some(at));
537 let next = pointer_target(
538 ¤t_rects(index_keys, rects),
539 from,
540 *over.peek(),
541 at,
542 axis,
543 );
544 if next != *over.peek() {
545 over.set(next);
546 }
547 }
548 GestureEffect::Drop { at } => {
549 let from_opt = *drag_from.peek();
550 let to = {
554 let rects_ref = current_rects(index_keys, rects);
555 if list_bounds(&rects_ref)
556 .map(|b| b.contains(at))
557 .unwrap_or(false)
558 {
559 from_opt.and_then(|from| {
560 pointer_target(&rects_ref, from, *over.peek(), at, axis)
561 })
562 } else {
563 None
564 }
565 };
566 press_from.set(None);
571 press_at.set(None);
572 drag_from.set(None);
573 over.set(None);
574 pointer_at.set(None);
575 if let (Some(from), Some(to)) = (from_opt, to) {
576 if from != to {
577 on_sort.call(SortEvent { from, to });
578 }
579 }
580 }
581 GestureEffect::Abort => {
582 press_from.set(None);
583 press_at.set(None);
584 drag_from.set(None);
585 over.set(None);
586 pointer_at.set(None);
587 }
588 GestureEffect::Tap => {
589 press_from.set(None);
590 press_at.set(None);
591 pointer_at.set(None);
592 }
593 GestureEffect::None => {}
594 }
595 };
596
597 let reduced_motion_css = use_reduced_motion_css();
599
600 let primary_pointer = move |evt: &PointerEvent| crate::core::components::primary_press(evt);
601 let mut empty_held_moves = use_signal(|| 0u8);
603 let mut captured = use_signal(|| false);
606 let mut cancel_drag = move || {
607 feed(GestureEvent::Cancel);
608 press_from.set(None);
609 press_at.set(None);
610 drag_from.set(None);
611 over.set(None);
612 pointer_at.set(None);
613 };
614
615 let overlay_ghost: Option<(Callback<usize, Element>, usize, Point, Rect)> =
621 overlay.zip(drag_from()).and_then(|(cb, from)| {
622 let r = current_rects(index_keys, rects).get(&from).copied()?;
623 let p0 = press_at()?;
624 let p1 = pointer_at()?;
625 Some((
626 cb,
627 from,
628 Point::new(r.x + (p1.x - p0.x), r.y + (p1.y - p0.y)),
629 r,
630 ))
631 });
632 let ghost_from = overlay_ghost.map(|(_, f, _, _)| f);
633 let mut attributes = attributes;
634 crate::core::components::protect_attributes(
635 &mut attributes,
636 &[
637 "onmounted",
638 "onpointermove",
639 "onpointerup",
640 "onpointercancel",
641 "onlostpointercapture",
642 "ontouchmove",
643 "oncontextmenu",
644 ],
645 );
646
647 rsx! {
648 div {
649 onmounted: move |evt: Event<MountedData>| {
650 let mut container = container;
651 container.set(Some(evt.data()));
652 },
653 onpointermove: move |evt: PointerEvent| {
654 let at = pointer_client(&evt);
655 if drag_from.peek().is_some() && evt.held_buttons().is_empty() {
666 let streak = empty_held_moves.peek().saturating_add(1);
667 empty_held_moves.set(streak);
668 if streak >= crate::core::components::RELEASE_RECOVERY_MOVES {
669 if let Some(from) = *drag_from.peek() {
670 if let Some(n) = mounted_at(from, index_keys, mounteds) {
671 platform::release_pointer(&n, evt.pointer_id());
672 }
673 }
674 feed(GestureEvent::Up { at, pointer_id: evt.pointer_id() });
675 return;
676 }
677 } else if *empty_held_moves.peek() != 0 {
678 empty_held_moves.set(0);
679 }
680 feed(GestureEvent::Move { at, pointer_id: evt.pointer_id() });
681 },
682 onpointerup: move |evt: PointerEvent| {
683 if let Some(from) = *drag_from.peek() {
684 if let Some(n) = mounted_at(from, index_keys, mounteds) {
685 platform::release_pointer(&n, evt.pointer_id());
686 }
687 }
688 feed(GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
689 },
690 onpointercancel: move |evt: PointerEvent| {
695 if let Some(from) = *drag_from.peek() {
696 if let Some(n) = mounted_at(from, index_keys, mounteds) {
697 platform::release_pointer(&n, evt.pointer_id());
698 }
699 }
700 cancel_drag();
701 },
702 onlostpointercapture: move |_| cancel_drag(),
703 ontouchmove: move |evt: TouchEvent| {
708 if matches!(*gesture.peek(), GesturePhase::Dragging { .. }) {
709 evt.prevent_default();
710 }
711 },
712 oncontextmenu: move |evt: Event<MouseData>| {
715 if !matches!(*gesture.peek(), GesturePhase::Idle) {
716 evt.prevent_default();
717 }
718 },
719 ..attributes,
720 {reduced_motion_css}
721 if drag_from().is_some() && !captured() {
727 div {
728 style: "position: fixed; inset: 0; z-index: 9998; touch-action: none;",
729 aria_hidden: true,
730 }
731 }
732 if let Some(pid) = hold_pid() {
735 HoldTimer {
736 pointer_id: pid,
737 on_hold: move |pid| feed(GestureEvent::Hold { pointer_id: pid }),
738 }
739 }
740 for (ix, render_key) in (0..len).zip(render_keys) {
741 div {
742 key: "{render_key}",
743 "data-dnd-motion": true,
744 "data-dragging": if drag_from() == Some(ix) { "true" },
745 "data-drop-target": if over() == Some(ix) && drag_from() != Some(ix) { "true" },
746 style: {
747 let base = match (live_preview, drag_from()) {
755 (true, Some(from)) => {
756 let step = slot_pitch(
757 ¤t_rects(index_keys, rects),
758 from,
759 axis,
760 )
761 .unwrap_or_else(|| size_of(from));
762 let o = over().unwrap_or(from);
763 let d = displacement(ix, from, o, step);
764 let (x, y) = match axis {
765 Axis::Vertical => (0.0, d),
766 Axis::Horizontal => (d, 0.0),
767 };
768 let hidden = if ghost_from == Some(ix) {
769 " opacity: 0;"
770 } else {
771 ""
772 };
773 format!("transform: translate({x}px, {y}px); transition: transform {transition_ms}ms ease;{hidden}")
774 }
775 _ => format!(
776 "transform: translate(0px, 0px); transition: transform {transition_ms}ms; opacity: 1;"
777 ),
778 };
779 if touch_handle {
780 format!("display: flex; align-items: stretch; width: 100%; {base}")
781 } else {
782 format!("{} {base}", touch_style(touch))
783 }
784 },
785 onpointerdown: move |evt: PointerEvent| {
788 if touch_handle || !primary_pointer(&evt) {
789 return;
790 }
791 evt.prevent_default();
792 evt.stop_propagation();
793 refresh_rects(mounteds, rects, generations);
794 capture_anchor(container, anchor);
795 press_from.set(Some(ix));
796 press_at.set(Some(pointer_client(&evt)));
797 captured.set(match mounted_at(ix, index_keys, mounteds) {
802 Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
803 None => false,
804 });
805 let pid = evt.pointer_id();
806 feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: pid });
807 if touch == TouchSense::Auto
810 && evt.pointer_type() != "mouse"
811 && matches!(*gesture.peek(), GesturePhase::Pressed { pointer_id, .. } if pointer_id == pid)
812 {
813 hold_pid.set(Some(pid));
814 }
815 },
816 onmounted: move |evt: Event<MountedData>| {
817 let m: Rc<MountedData> = evt.data();
818 let mut mounteds = mounteds;
819 mounteds.write().insert(render_key.clone(), m.clone());
820 measure_rect(render_key.clone(), m, mounteds, rects, generations);
821 },
822 if touch_handle {
823 span {
824 "data-sort-handle": true,
825 aria_hidden: true,
826 style: "touch-action: none; user-select: none; -webkit-user-select: none; display: grid; place-items: center;",
827 onpointerdown: move |evt: PointerEvent| {
828 if !primary_pointer(&evt) {
829 return;
830 }
831 evt.prevent_default();
832 evt.stop_propagation();
833 refresh_rects(mounteds, rects, generations);
834 capture_anchor(container, anchor);
835 press_from.set(Some(ix));
836 press_at.set(Some(pointer_client(&evt)));
837 captured.set(match mounted_at(ix, index_keys, mounteds) {
841 Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
842 None => false,
843 });
844 feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
845 },
846 if let Some(h) = handle {
847 {h.call(ix)}
848 } else {
849 "⠿"
850 }
851 }
852 div {
853 "data-sort-content": true,
854 style: "flex: 1 1 auto; min-width: 0;",
855 {render.call(ix)}
856 }
857 } else {
858 {render.call(ix)}
859 }
860 }
861 }
862 if let Some((cb, from, pos, rect)) = overlay_ghost {
863 div {
864 style: format!(
865 "{} width: {}px; height: {}px;",
866 overlay_style(pos),
867 rect.width,
868 rect.height
869 ),
870 {cb.call(from)}
871 }
872 }
873 }
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880
881 #[test]
882 fn sort_moves_forward_and_back() {
883 let mut v = vec!["a", "b", "c", "d"];
884 apply_sort(&mut v, SortEvent { from: 0, to: 2 });
885 assert_eq!(v, vec!["b", "c", "a", "d"]);
886 apply_sort(&mut v, SortEvent { from: 3, to: 0 });
887 assert_eq!(v, vec!["d", "b", "c", "a"]);
888 }
889
890 #[test]
891 fn sort_ignores_out_of_bounds_and_noops() {
892 let mut v = vec![1, 2, 3];
893 apply_sort(&mut v, SortEvent { from: 1, to: 1 });
894 apply_sort(&mut v, SortEvent { from: 9, to: 0 });
895 apply_sort(&mut v, SortEvent { from: 0, to: 9 });
896 assert_eq!(v, vec![1, 2, 3]);
897 }
898
899 #[test]
900 fn async_measurements_require_the_current_node_and_generation() {
901 let old = Rc::new(());
902 let replacement = Rc::new(());
903 let mut mounteds = HashMap::from([(2, old.clone())]);
904 let mut generations = HashMap::from([(2, 4)]);
905
906 assert!(measurement_is_current(&mounteds, &generations, &2, &old, 4));
907 assert!(!measurement_is_current(
908 &mounteds,
909 &generations,
910 &2,
911 &old,
912 3
913 ));
914
915 mounteds.insert(2, replacement);
916 generations.insert(2, 5);
917 assert!(!measurement_is_current(
918 &mounteds,
919 &generations,
920 &2,
921 &old,
922 4
923 ));
924
925 mounteds.remove(&2);
926 generations.remove(&2);
927 assert!(!measurement_is_current(
928 &mounteds,
929 &generations,
930 &2,
931 &old,
932 4
933 ));
934 }
935
936 #[test]
937 fn stable_keys_project_mounted_geometry_to_current_indices() {
938 let a = RenderKey("a".to_string());
939 let b = RenderKey("b".to_string());
940 let rects = HashMap::from([
941 (a.clone(), Rect::new(0.0, 0.0, 100.0, 20.0)),
942 (b.clone(), Rect::new(0.0, 20.0, 100.0, 20.0)),
943 ]);
944
945 let projected = project_rects(&[b, a], &rects);
946 assert_eq!(projected[&0], Rect::new(0.0, 20.0, 100.0, 20.0));
947 assert_eq!(projected[&1], Rect::new(0.0, 0.0, 100.0, 20.0));
948 }
949}
950
951#[cfg(test)]
952mod pointer_target_tests {
953 use super::*;
954
955 fn rows() -> HashMap<usize, Rect> {
957 (0..3)
958 .map(|i| (i, Rect::new(0.0, i as f64 * 40.0, 200.0, 40.0)))
959 .collect()
960 }
961
962 #[test]
963 fn adopts_a_row_only_past_its_midpoint() {
964 let r = rows();
965 let t = pointer_target(&r, 0, None, Point::new(50.0, 45.0), Axis::Vertical);
967 assert_eq!(t, None);
968 let t = pointer_target(&r, 0, None, Point::new(50.0, 65.0), Axis::Vertical);
970 assert_eq!(t, Some(1));
971 let t = pointer_target(&r, 2, None, Point::new(50.0, 75.0), Axis::Vertical);
973 assert_eq!(t, None);
974 let t = pointer_target(&r, 2, None, Point::new(50.0, 55.0), Axis::Vertical);
975 assert_eq!(t, Some(1));
976 }
977
978 #[test]
979 fn keeps_current_over_source_row_and_outside_all_rects() {
980 let r = rows();
981 let t = pointer_target(&r, 0, Some(2), Point::new(50.0, 10.0), Axis::Vertical);
983 assert_eq!(t, Some(2));
984 let t = pointer_target(&r, 0, Some(2), Point::new(500.0, 500.0), Axis::Vertical);
986 assert_eq!(t, Some(2));
987 }
988
989 #[test]
990 fn horizontal_axis_uses_x() {
991 let r: HashMap<usize, Rect> = (0..3)
992 .map(|i| (i, Rect::new(i as f64 * 60.0, 0.0, 60.0, 40.0)))
993 .collect();
994 let t = pointer_target(&r, 0, None, Point::new(100.0, 20.0), Axis::Horizontal);
995 assert_eq!(t, Some(1)); }
997
998 #[test]
999 fn list_bounds_covers_all_rows_and_excludes_outside() {
1000 let r = rows(); let b = list_bounds(&r).unwrap();
1002 assert_eq!(b, Rect::new(0.0, 0.0, 200.0, 120.0));
1003 assert!(b.contains(Point::new(50.0, 60.0)));
1005 assert!(!b.contains(Point::new(500.0, 500.0)));
1007 assert!(!b.contains(Point::new(50.0, 130.0)));
1008 assert_eq!(list_bounds(&HashMap::new()), None);
1010 }
1011}
1012
1013#[cfg(test)]
1014mod swap_tests {
1015 use super::*;
1016
1017 #[test]
1018 fn swap_exchanges_and_guards_bounds() {
1019 let mut v = vec![1, 2, 3, 4];
1020 apply_swap(&mut v, SortEvent { from: 0, to: 3 });
1021 assert_eq!(v, vec![4, 2, 3, 1]);
1022 apply_swap(&mut v, SortEvent { from: 9, to: 0 });
1023 assert_eq!(v, vec![4, 2, 3, 1]);
1024 }
1025}
1026
1027#[cfg(test)]
1028mod shift_rects_tests {
1029 use super::*;
1030
1031 #[test]
1035 fn shift_moves_all_slots_uniformly() {
1036 let mut rects: HashMap<usize, Rect> = (0..3)
1037 .map(|i| (i, Rect::new(10.0, i as f64 * 40.0, 200.0, 40.0)))
1038 .collect();
1039 shift_rects(&mut rects, 0.0, -130.0);
1041 for i in 0..3 {
1042 assert_eq!(
1043 rects[&i],
1044 Rect::new(10.0, i as f64 * 40.0 - 130.0, 200.0, 40.0)
1045 );
1046 }
1047 assert_eq!(slot_pitch(&rects, 1, Axis::Vertical), Some(40.0));
1049 }
1050}
1051
1052#[cfg(test)]
1053mod slot_pitch_tests {
1054 use super::*;
1055
1056 #[test]
1057 fn pitch_includes_spacing_between_rows() {
1058 let rows: HashMap<usize, Rect> = (0..3)
1059 .map(|i| (i, Rect::new(0.0, i as f64 * 46.0, 200.0, 42.0)))
1060 .collect();
1061
1062 assert_eq!(slot_pitch(&rows, 0, Axis::Vertical), Some(46.0));
1063 assert_eq!(slot_pitch(&rows, 1, Axis::Vertical), Some(46.0));
1064 assert_eq!(slot_pitch(&rows, 2, Axis::Vertical), Some(46.0));
1065 }
1066
1067 #[test]
1068 fn pitch_falls_back_to_size_for_single_row() {
1069 let rows: HashMap<usize, Rect> = [(0, Rect::new(0.0, 0.0, 200.0, 42.0))]
1070 .into_iter()
1071 .collect();
1072
1073 assert_eq!(slot_pitch(&rows, 0, Axis::Vertical), Some(42.0));
1074 assert_eq!(slot_pitch(&rows, 9, Axis::Vertical), None);
1075 }
1076}
1077
1078#[cfg(test)]
1079mod displacement_tests {
1080 use super::*;
1081
1082 #[test]
1083 fn displacement_moves_source_to_target_and_neighbors_aside() {
1084 let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).collect();
1087 assert_eq!(d, vec![0.0, 80.0, -40.0, -40.0, 0.0]);
1088 let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 3, 1, 40.0)).collect();
1090 assert_eq!(d, vec![0.0, 40.0, 40.0, -80.0, 0.0]);
1091 assert!((0..5).all(|ix| displacement(ix, 2, 2, 40.0) == 0.0));
1093 let sum: f64 = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).sum();
1095 assert_eq!(sum, 0.0);
1096 }
1097}