#![doc = include_str!("../docs/api/sortable-lists.md")]
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::rc::Rc;
use dioxus::html::MountedData;
use dioxus::prelude::*;
use crate::a11y::use_reduced_motion_css;
use crate::core::components::{overlay_style, touch_style, HoldTimer};
use crate::core::hooks::use_rect_refresh_thunk;
use crate::core::{
platform, transition_with, GestureEffect, GestureEvent, GesturePhase, Point, Promotion, Rect,
TouchSense,
};
pub use crate::sortable_kernel::{
apply_reorder, project_layout, DropPlacement, ItemTransform, Placement, ReorderEvent,
SortStrategy, SortableCollection, SortableGroup, SortableGroupId, SortableHandle, SortableItem,
SortablePayload, SortableProvider,
};
fn pointer_client(evt: &PointerEvent) -> Point {
let c = evt.client_coordinates();
Point::new(c.x, c.y)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct RenderKey(String);
impl fmt::Display for RenderKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
pub(crate) fn build_render_keys(
len: usize,
item_key: Option<Callback<usize, String>>,
) -> Vec<RenderKey> {
let keys: Vec<_> = (0..len)
.map(|index| {
RenderKey(
item_key
.map(|key| key.call(index))
.unwrap_or_else(|| index.to_string()),
)
})
.collect();
if item_key.is_some() {
let mut unique = HashSet::with_capacity(keys.len());
for key in &keys {
assert!(
unique.insert(key.clone()),
"SortableList/Grid item_key returned the duplicate key `{key}`"
);
}
}
keys
}
pub(crate) fn project_rects(
keys: &[RenderKey],
rects: &HashMap<RenderKey, Rect>,
) -> HashMap<usize, Rect> {
keys.iter()
.enumerate()
.filter_map(|(index, key)| rects.get(key).copied().map(|rect| (index, rect)))
.collect()
}
pub(crate) fn current_rects(
keys: Signal<Vec<RenderKey>>,
rects: Signal<HashMap<RenderKey, Rect>>,
) -> HashMap<usize, Rect> {
project_rects(&keys.peek(), &rects.peek())
}
pub(crate) fn mounted_at(
index: usize,
keys: Signal<Vec<RenderKey>>,
mounteds: Signal<HashMap<RenderKey, Rc<MountedData>>>,
) -> Option<Rc<MountedData>> {
let key = keys.peek().get(index)?.clone();
mounteds.peek().get(&key).cloned()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct SortEvent {
pub from: usize,
pub to: usize,
}
impl SortEvent {
pub fn new(from: usize, to: usize) -> Self {
Self { from, to }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReorderMode {
#[default]
Insert,
Swap,
}
pub fn apply_swap<T>(list: &mut [T], ev: SortEvent) {
if ev.from != ev.to && ev.from < list.len() && ev.to < list.len() {
list.swap(ev.from, ev.to);
}
}
pub fn displacement(ix: usize, from: usize, over: usize, step: f64) -> f64 {
if ix == from {
(over as f64 - from as f64) * step
} else if from < over && ix > from && ix <= over {
-step
} else if over < from && ix >= over && ix < from {
step
} else {
0.0
}
}
fn slot_pitch(rects: &HashMap<usize, Rect>, ix: usize, axis: Axis) -> Option<f64> {
let pos = |r: &Rect| match axis {
Axis::Vertical => r.y,
Axis::Horizontal => r.x,
};
let cur = rects.get(&ix)?;
if let Some(next) = rects.get(&(ix + 1)) {
return Some(pos(next) - pos(cur));
}
if let Some(prev) = ix.checked_sub(1).and_then(|p| rects.get(&p)) {
return Some(pos(cur) - pos(prev));
}
Some(match axis {
Axis::Vertical => cur.height,
Axis::Horizontal => cur.width,
})
}
pub(crate) fn refresh_rects(
mounteds: Signal<HashMap<RenderKey, Rc<MountedData>>>,
rects: Signal<HashMap<RenderKey, Rect>>,
generations: Signal<HashMap<RenderKey, u64>>,
) {
for (key, mounted) in mounteds.peek().clone() {
measure_rect(key, mounted, mounteds, rects, generations);
}
}
pub(crate) fn measure_rect(
key: RenderKey,
mounted: Rc<MountedData>,
mounteds: Signal<HashMap<RenderKey, Rc<MountedData>>>,
mut rects: Signal<HashMap<RenderKey, Rect>>,
mut generations: Signal<HashMap<RenderKey, u64>>,
) {
let generation = {
let mut current = generations.write();
let next = current.get(&key).copied().unwrap_or(0).wrapping_add(1);
current.insert(key.clone(), next);
next
};
spawn(async move {
let Ok(measured) = mounted.get_client_rect().await else {
return;
};
let still_current = measurement_is_current(
&mounteds.peek(),
&generations.peek(),
&key,
&mounted,
generation,
);
if still_current {
rects.write().insert(
key,
Rect::new(
measured.origin.x,
measured.origin.y,
measured.size.width,
measured.size.height,
),
);
}
});
}
fn measurement_is_current<K: Eq + std::hash::Hash, T>(
mounteds: &HashMap<K, Rc<T>>,
generations: &HashMap<K, u64>,
key: &K,
mounted: &Rc<T>,
generation: u64,
) -> bool {
mounteds
.get(key)
.is_some_and(|current| Rc::ptr_eq(current, mounted))
&& generations.get(key) == Some(&generation)
}
fn shift_rects<K>(rects: &mut HashMap<K, Rect>, dx: f64, dy: f64) {
for rect in rects.values_mut() {
rect.x += dx;
rect.y += dy;
}
}
fn reanchor_rects(
container: Signal<Option<Rc<MountedData>>>,
anchor: Signal<Option<Point>>,
rects: Signal<HashMap<RenderKey, Rect>>,
busy: Signal<bool>,
pending: Signal<bool>,
) {
let Some(m) = container.peek().clone() else {
return;
};
if *busy.peek() {
let mut pending = pending;
pending.set(true);
return;
}
let mut busy = busy;
busy.set(true);
spawn(async move {
let mut anchor = anchor;
let mut rects = rects;
let mut pending = pending;
loop {
if let Ok(r) = m.get_client_rect().await {
let new = Point::new(r.origin.x, r.origin.y);
if let Some(old) = *anchor.peek() {
let (dx, dy) = (new.x - old.x, new.y - old.y);
if dx != 0.0 || dy != 0.0 {
shift_rects(&mut rects.write(), dx, dy);
}
}
anchor.set(Some(new));
}
if *pending.peek() {
pending.set(false);
} else {
break;
}
}
busy.set(false);
});
}
fn capture_anchor(container: Signal<Option<Rc<MountedData>>>, anchor: Signal<Option<Point>>) {
let Some(m) = container.peek().clone() else {
return;
};
let mut anchor = anchor;
spawn(async move {
if let Ok(r) = m.get_client_rect().await {
anchor.set(Some(Point::new(r.origin.x, r.origin.y)));
}
});
}
pub fn pointer_target(
rects: &HashMap<usize, Rect>,
from: usize,
current: Option<usize>,
at: Point,
axis: Axis,
) -> Option<usize> {
let Some((&ix, rect)) = rects.iter().find(|(_, r)| r.contains(at)) else {
return current;
};
if ix == from || Some(ix) == current {
return current;
}
let (pos, size) = match axis {
Axis::Vertical => (at.y - rect.y, rect.height),
Axis::Horizontal => (at.x - rect.x, rect.width),
};
let crossed = if from < ix {
pos > size * 0.5
} else {
pos < size * 0.5
};
if crossed {
Some(ix)
} else {
current
}
}
pub(crate) fn list_bounds(rects: &HashMap<usize, Rect>) -> Option<Rect> {
let mut it = rects.values();
let first = it.next()?;
let (mut min_x, mut min_y) = (first.x, first.y);
let (mut max_x, mut max_y) = (first.x + first.width, first.y + first.height);
for r in it {
min_x = min_x.min(r.x);
min_y = min_y.min(r.y);
max_x = max_x.max(r.x + r.width);
max_y = max_y.max(r.y + r.height);
}
Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Axis {
#[default]
Vertical,
Horizontal,
}
pub fn apply_sort<T>(list: &mut Vec<T>, ev: SortEvent) {
if ev.from == ev.to || ev.from >= list.len() || ev.to >= list.len() {
return;
}
let item = list.remove(ev.from);
list.insert(ev.to, item);
}
#[component]
pub fn SortableList(
len: usize,
render: Callback<usize, Element>,
on_sort: EventHandler<SortEvent>,
#[props(default)]
axis: Axis,
#[props(default = true)]
live_preview: bool,
#[props(default = 160)]
transition_ms: u32,
#[props(default)]
overlay: Option<Callback<usize, Element>>,
#[props(default = false)]
touch_handle: bool,
#[props(default)]
touch: TouchSense,
#[props(default)]
handle: Option<Callback<usize, Element>>,
#[props(default)]
item_key: Option<Callback<usize, String>>,
#[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let render_keys = build_render_keys(len, item_key);
let initial_render_keys = render_keys.clone();
let mut index_keys = use_signal(move || initial_render_keys);
let mut drag_from = use_signal(|| None::<usize>);
let mut over = use_signal(|| None::<usize>);
let mut press_from = use_signal(|| None::<usize>);
let mut press_at = use_signal(|| None::<Point>);
let mut pointer_at = use_signal(|| None::<Point>);
let rects = use_signal(HashMap::<RenderKey, Rect>::new);
let mounteds = use_signal(HashMap::<RenderKey, Rc<MountedData>>::new);
let generations = use_signal(HashMap::<RenderKey, u64>::new);
let mut rects_for_keys = rects;
let mut mounteds_for_keys = mounteds;
let mut generations_for_keys = generations;
use_effect(use_reactive!(|(render_keys)| {
let active: HashSet<_> = render_keys.iter().cloned().collect();
index_keys.set(render_keys);
rects_for_keys.write().retain(|key, _| active.contains(key));
mounteds_for_keys
.write()
.retain(|key, _| active.contains(key));
generations_for_keys
.write()
.retain(|key, _| active.contains(key));
}));
let size_of = move |ix: usize| {
current_rects(index_keys, rects)
.get(&ix)
.map(|r| match axis {
Axis::Vertical => r.height,
Axis::Horizontal => r.width,
})
.unwrap_or(40.0)
};
let container = use_signal(|| None::<Rc<MountedData>>);
let anchor = use_signal(|| None::<Point>);
let reanchor_busy = use_signal(|| false);
let reanchor_pending = use_signal(|| false);
use_rect_refresh_thunk(move |_| {
if drag_from.peek().is_some() {
reanchor_rects(container, anchor, rects, reanchor_busy, reanchor_pending);
}
});
let mut gesture = use_signal(|| GesturePhase::Idle);
let mut hold_pid = use_signal(|| None::<i32>);
let mut step = move |event: GestureEvent| -> GestureEffect {
let promotion = if hold_pid.peek().is_some() {
Promotion::HoldOrSideways
} else {
Promotion::Distance
};
let (next, fx) = transition_with(*gesture.peek(), event, 8.0, promotion);
gesture.set(next);
if hold_pid.peek().is_some() && !matches!(next, GesturePhase::Pressed { .. }) {
hold_pid.set(None);
}
fx
};
let mut feed = move |event: GestureEvent| {
match step(event) {
GestureEffect::Begin { at, .. } => {
let Some(ix) = *press_from.peek() else {
return;
};
drag_from.set(Some(ix));
pointer_at.set(Some(at));
over.set(pointer_target(
¤t_rects(index_keys, rects),
ix,
None,
at,
axis,
));
refresh_rects(mounteds, rects, generations);
capture_anchor(container, anchor);
}
GestureEffect::Track { at } => {
let Some(from) = *drag_from.peek() else {
return;
};
pointer_at.set(Some(at));
let next = pointer_target(
¤t_rects(index_keys, rects),
from,
*over.peek(),
at,
axis,
);
if next != *over.peek() {
over.set(next);
}
}
GestureEffect::Drop { at } => {
let from_opt = *drag_from.peek();
let to = {
let rects_ref = current_rects(index_keys, rects);
if list_bounds(&rects_ref)
.map(|b| b.contains(at))
.unwrap_or(false)
{
from_opt.and_then(|from| {
pointer_target(&rects_ref, from, *over.peek(), at, axis)
})
} else {
None
}
};
press_from.set(None);
press_at.set(None);
drag_from.set(None);
over.set(None);
pointer_at.set(None);
if let (Some(from), Some(to)) = (from_opt, to) {
if from != to {
on_sort.call(SortEvent { from, to });
}
}
}
GestureEffect::Abort => {
press_from.set(None);
press_at.set(None);
drag_from.set(None);
over.set(None);
pointer_at.set(None);
}
GestureEffect::Tap => {
press_from.set(None);
press_at.set(None);
pointer_at.set(None);
}
GestureEffect::None => {}
}
};
let reduced_motion_css = use_reduced_motion_css();
let primary_pointer = move |evt: &PointerEvent| crate::core::components::primary_press(evt);
let mut empty_held_moves = use_signal(|| 0u8);
let mut captured = use_signal(|| false);
let mut cancel_drag = move || {
feed(GestureEvent::Cancel);
press_from.set(None);
press_at.set(None);
drag_from.set(None);
over.set(None);
pointer_at.set(None);
};
let overlay_ghost: Option<(Callback<usize, Element>, usize, Point, Rect)> =
overlay.zip(drag_from()).and_then(|(cb, from)| {
let r = current_rects(index_keys, rects).get(&from).copied()?;
let p0 = press_at()?;
let p1 = pointer_at()?;
Some((
cb,
from,
Point::new(r.x + (p1.x - p0.x), r.y + (p1.y - p0.y)),
r,
))
});
let ghost_from = overlay_ghost.map(|(_, f, _, _)| f);
let mut attributes = attributes;
crate::core::components::protect_attributes(
&mut attributes,
&[
"onmounted",
"onpointermove",
"onpointerup",
"onpointercancel",
"onlostpointercapture",
"ontouchmove",
"oncontextmenu",
],
);
rsx! {
div {
onmounted: move |evt: Event<MountedData>| {
let mut container = container;
container.set(Some(evt.data()));
},
onpointermove: move |evt: PointerEvent| {
let at = pointer_client(&evt);
if drag_from.peek().is_some() && evt.held_buttons().is_empty() {
let streak = empty_held_moves.peek().saturating_add(1);
empty_held_moves.set(streak);
if streak >= crate::core::components::RELEASE_RECOVERY_MOVES {
if let Some(from) = *drag_from.peek() {
if let Some(n) = mounted_at(from, index_keys, mounteds) {
platform::release_pointer(&n, evt.pointer_id());
}
}
feed(GestureEvent::Up { at, pointer_id: evt.pointer_id() });
return;
}
} else if *empty_held_moves.peek() != 0 {
empty_held_moves.set(0);
}
feed(GestureEvent::Move { at, pointer_id: evt.pointer_id() });
},
onpointerup: move |evt: PointerEvent| {
if let Some(from) = *drag_from.peek() {
if let Some(n) = mounted_at(from, index_keys, mounteds) {
platform::release_pointer(&n, evt.pointer_id());
}
}
feed(GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
},
onpointercancel: move |evt: PointerEvent| {
if let Some(from) = *drag_from.peek() {
if let Some(n) = mounted_at(from, index_keys, mounteds) {
platform::release_pointer(&n, evt.pointer_id());
}
}
cancel_drag();
},
onlostpointercapture: move |_| cancel_drag(),
ontouchmove: move |evt: TouchEvent| {
if matches!(*gesture.peek(), GesturePhase::Dragging { .. }) {
evt.prevent_default();
}
},
oncontextmenu: move |evt: Event<MouseData>| {
if !matches!(*gesture.peek(), GesturePhase::Idle) {
evt.prevent_default();
}
},
..attributes,
{reduced_motion_css}
if drag_from().is_some() && !captured() {
div {
style: "position: fixed; inset: 0; z-index: 9998; touch-action: none;",
aria_hidden: true,
}
}
if let Some(pid) = hold_pid() {
HoldTimer {
pointer_id: pid,
on_hold: move |pid| feed(GestureEvent::Hold { pointer_id: pid }),
}
}
for (ix, render_key) in (0..len).zip(render_keys) {
div {
key: "{render_key}",
"data-dnd-motion": true,
"data-dragging": if drag_from() == Some(ix) { "true" },
"data-drop-target": if over() == Some(ix) && drag_from() != Some(ix) { "true" },
style: {
let base = match (live_preview, drag_from()) {
(true, Some(from)) => {
let step = slot_pitch(
¤t_rects(index_keys, rects),
from,
axis,
)
.unwrap_or_else(|| size_of(from));
let o = over().unwrap_or(from);
let d = displacement(ix, from, o, step);
let (x, y) = match axis {
Axis::Vertical => (0.0, d),
Axis::Horizontal => (d, 0.0),
};
let hidden = if ghost_from == Some(ix) {
" opacity: 0;"
} else {
""
};
format!("transform: translate({x}px, {y}px); transition: transform {transition_ms}ms ease;{hidden}")
}
_ => format!(
"transform: translate(0px, 0px); transition: transform {transition_ms}ms; opacity: 1;"
),
};
if touch_handle {
format!("display: flex; align-items: stretch; width: 100%; {base}")
} else {
format!("{} {base}", touch_style(touch))
}
},
onpointerdown: move |evt: PointerEvent| {
if touch_handle || !primary_pointer(&evt) {
return;
}
evt.prevent_default();
evt.stop_propagation();
refresh_rects(mounteds, rects, generations);
capture_anchor(container, anchor);
press_from.set(Some(ix));
press_at.set(Some(pointer_client(&evt)));
captured.set(match mounted_at(ix, index_keys, mounteds) {
Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
None => false,
});
let pid = evt.pointer_id();
feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: pid });
if touch == TouchSense::Auto
&& evt.pointer_type() != "mouse"
&& matches!(*gesture.peek(), GesturePhase::Pressed { pointer_id, .. } if pointer_id == pid)
{
hold_pid.set(Some(pid));
}
},
onmounted: move |evt: Event<MountedData>| {
let m: Rc<MountedData> = evt.data();
let mut mounteds = mounteds;
mounteds.write().insert(render_key.clone(), m.clone());
measure_rect(render_key.clone(), m, mounteds, rects, generations);
},
if touch_handle {
span {
"data-sort-handle": true,
aria_hidden: true,
style: "touch-action: none; user-select: none; -webkit-user-select: none; display: grid; place-items: center;",
onpointerdown: move |evt: PointerEvent| {
if !primary_pointer(&evt) {
return;
}
evt.prevent_default();
evt.stop_propagation();
refresh_rects(mounteds, rects, generations);
capture_anchor(container, anchor);
press_from.set(Some(ix));
press_at.set(Some(pointer_client(&evt)));
captured.set(match mounted_at(ix, index_keys, mounteds) {
Some(n) => platform::capture_pointer(&n, evt.pointer_id()),
None => false,
});
feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
},
if let Some(h) = handle {
{h.call(ix)}
} else {
"â ¿"
}
}
div {
"data-sort-content": true,
style: "flex: 1 1 auto; min-width: 0;",
{render.call(ix)}
}
} else {
{render.call(ix)}
}
}
}
if let Some((cb, from, pos, rect)) = overlay_ghost {
div {
style: format!(
"{} width: {}px; height: {}px;",
overlay_style(pos),
rect.width,
rect.height
),
{cb.call(from)}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sort_moves_forward_and_back() {
let mut v = vec!["a", "b", "c", "d"];
apply_sort(&mut v, SortEvent { from: 0, to: 2 });
assert_eq!(v, vec!["b", "c", "a", "d"]);
apply_sort(&mut v, SortEvent { from: 3, to: 0 });
assert_eq!(v, vec!["d", "b", "c", "a"]);
}
#[test]
fn sort_ignores_out_of_bounds_and_noops() {
let mut v = vec![1, 2, 3];
apply_sort(&mut v, SortEvent { from: 1, to: 1 });
apply_sort(&mut v, SortEvent { from: 9, to: 0 });
apply_sort(&mut v, SortEvent { from: 0, to: 9 });
assert_eq!(v, vec![1, 2, 3]);
}
#[test]
fn async_measurements_require_the_current_node_and_generation() {
let old = Rc::new(());
let replacement = Rc::new(());
let mut mounteds = HashMap::from([(2, old.clone())]);
let mut generations = HashMap::from([(2, 4)]);
assert!(measurement_is_current(&mounteds, &generations, &2, &old, 4));
assert!(!measurement_is_current(
&mounteds,
&generations,
&2,
&old,
3
));
mounteds.insert(2, replacement);
generations.insert(2, 5);
assert!(!measurement_is_current(
&mounteds,
&generations,
&2,
&old,
4
));
mounteds.remove(&2);
generations.remove(&2);
assert!(!measurement_is_current(
&mounteds,
&generations,
&2,
&old,
4
));
}
#[test]
fn stable_keys_project_mounted_geometry_to_current_indices() {
let a = RenderKey("a".to_string());
let b = RenderKey("b".to_string());
let rects = HashMap::from([
(a.clone(), Rect::new(0.0, 0.0, 100.0, 20.0)),
(b.clone(), Rect::new(0.0, 20.0, 100.0, 20.0)),
]);
let projected = project_rects(&[b, a], &rects);
assert_eq!(projected[&0], Rect::new(0.0, 20.0, 100.0, 20.0));
assert_eq!(projected[&1], Rect::new(0.0, 0.0, 100.0, 20.0));
}
}
#[cfg(test)]
mod pointer_target_tests {
use super::*;
fn rows() -> HashMap<usize, Rect> {
(0..3)
.map(|i| (i, Rect::new(0.0, i as f64 * 40.0, 200.0, 40.0)))
.collect()
}
#[test]
fn adopts_a_row_only_past_its_midpoint() {
let r = rows();
let t = pointer_target(&r, 0, None, Point::new(50.0, 45.0), Axis::Vertical);
assert_eq!(t, None);
let t = pointer_target(&r, 0, None, Point::new(50.0, 65.0), Axis::Vertical);
assert_eq!(t, Some(1));
let t = pointer_target(&r, 2, None, Point::new(50.0, 75.0), Axis::Vertical);
assert_eq!(t, None);
let t = pointer_target(&r, 2, None, Point::new(50.0, 55.0), Axis::Vertical);
assert_eq!(t, Some(1));
}
#[test]
fn keeps_current_over_source_row_and_outside_all_rects() {
let r = rows();
let t = pointer_target(&r, 0, Some(2), Point::new(50.0, 10.0), Axis::Vertical);
assert_eq!(t, Some(2));
let t = pointer_target(&r, 0, Some(2), Point::new(500.0, 500.0), Axis::Vertical);
assert_eq!(t, Some(2));
}
#[test]
fn horizontal_axis_uses_x() {
let r: HashMap<usize, Rect> = (0..3)
.map(|i| (i, Rect::new(i as f64 * 60.0, 0.0, 60.0, 40.0)))
.collect();
let t = pointer_target(&r, 0, None, Point::new(100.0, 20.0), Axis::Horizontal);
assert_eq!(t, Some(1)); }
#[test]
fn list_bounds_covers_all_rows_and_excludes_outside() {
let r = rows(); let b = list_bounds(&r).unwrap();
assert_eq!(b, Rect::new(0.0, 0.0, 200.0, 120.0));
assert!(b.contains(Point::new(50.0, 60.0)));
assert!(!b.contains(Point::new(500.0, 500.0)));
assert!(!b.contains(Point::new(50.0, 130.0)));
assert_eq!(list_bounds(&HashMap::new()), None);
}
}
#[cfg(test)]
mod swap_tests {
use super::*;
#[test]
fn swap_exchanges_and_guards_bounds() {
let mut v = vec![1, 2, 3, 4];
apply_swap(&mut v, SortEvent { from: 0, to: 3 });
assert_eq!(v, vec![4, 2, 3, 1]);
apply_swap(&mut v, SortEvent { from: 9, to: 0 });
assert_eq!(v, vec![4, 2, 3, 1]);
}
}
#[cfg(test)]
mod shift_rects_tests {
use super::*;
#[test]
fn shift_moves_all_slots_uniformly() {
let mut rects: HashMap<usize, Rect> = (0..3)
.map(|i| (i, Rect::new(10.0, i as f64 * 40.0, 200.0, 40.0)))
.collect();
shift_rects(&mut rects, 0.0, -130.0);
for i in 0..3 {
assert_eq!(
rects[&i],
Rect::new(10.0, i as f64 * 40.0 - 130.0, 200.0, 40.0)
);
}
assert_eq!(slot_pitch(&rects, 1, Axis::Vertical), Some(40.0));
}
}
#[cfg(test)]
mod slot_pitch_tests {
use super::*;
#[test]
fn pitch_includes_spacing_between_rows() {
let rows: HashMap<usize, Rect> = (0..3)
.map(|i| (i, Rect::new(0.0, i as f64 * 46.0, 200.0, 42.0)))
.collect();
assert_eq!(slot_pitch(&rows, 0, Axis::Vertical), Some(46.0));
assert_eq!(slot_pitch(&rows, 1, Axis::Vertical), Some(46.0));
assert_eq!(slot_pitch(&rows, 2, Axis::Vertical), Some(46.0));
}
#[test]
fn pitch_falls_back_to_size_for_single_row() {
let rows: HashMap<usize, Rect> = [(0, Rect::new(0.0, 0.0, 200.0, 42.0))]
.into_iter()
.collect();
assert_eq!(slot_pitch(&rows, 0, Axis::Vertical), Some(42.0));
assert_eq!(slot_pitch(&rows, 9, Axis::Vertical), None);
}
}
#[cfg(test)]
mod displacement_tests {
use super::*;
#[test]
fn displacement_moves_source_to_target_and_neighbors_aside() {
let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).collect();
assert_eq!(d, vec![0.0, 80.0, -40.0, -40.0, 0.0]);
let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 3, 1, 40.0)).collect();
assert_eq!(d, vec![0.0, 40.0, 40.0, -80.0, 0.0]);
assert!((0..5).all(|ix| displacement(ix, 2, 2, 40.0) == 0.0));
let sum: f64 = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).sum();
assert_eq!(sum, 0.0);
}
}