dioxus_dnd/sortable.rs
1//! Reorderable lists - drag a row and the list tells you where it landed.
2//!
3//! Self-contained: `SortableList` manages its own drag state (indices only),
4//! so it needs no `DndProvider`. You keep ownership of the data - the
5//! component just tells you *"move index 3 to index 0"* and you apply it
6//! (usually with [`apply_sort`]).
7//!
8//! Mouse, touch and pen all drive the same pointer-event gesture machine, so
9//! the browser never creates a native drag image. By default the whole row
10//! is the touch target, which sets `touch-action: none` on it - fine for
11//! short lists, but it stops finger-scrolling through the rows. Inside a
12//! scrollable list, set `touch_handle: true` to confine pointer drags to a
13//! leading grip (style it via `[data-sort-handle]`) so the rows themselves
14//! still scroll.
15//!
16//! Headless: the component ships behavior plus a couple of `data-*` styling
17//! hooks; you compose the looks. Rows slide to preview the drop by default -
18//! opt into a floating, caller-composed ghost with `overlay`.
19//!
20//! ```text
21//! let mut items = use_signal(|| vec!["a".to_string(), "b".into(), "c".into()]);
22//! rsx! {
23//! SortableList {
24//! len: items.read().len(),
25//! on_sort: move |ev: SortEvent| apply_sort(&mut items.write(), ev),
26//! render: move |ix: usize| rsx! { li { "{items.read()[ix]}" } },
27//! }
28//! }
29//! ```
30
31use std::collections::HashMap;
32use std::rc::Rc;
33
34use dioxus::html::MountedData;
35use dioxus::prelude::*;
36
37use crate::core::components::overlay_style;
38use crate::core::{platform, transition, GestureEffect, GestureEvent, GesturePhase, Point, Rect};
39
40fn pointer_client(evt: &PointerEvent) -> Point {
41 let c = evt.client_coordinates();
42 Point::new(c.x, c.y)
43}
44
45/// "Move the item at `from` so it ends up at index `to`."
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct SortEvent {
48 pub from: usize,
49 pub to: usize,
50}
51
52/// What a completed reorder gesture means.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum ReorderMode {
55 /// Remove the item and insert it at the target index (list reorder).
56 #[default]
57 Insert,
58 /// Exchange the two items' positions (grid/tile swap).
59 Swap,
60}
61
62/// Apply a [`SortEvent`] as a swap: the two items exchange positions.
63pub fn apply_swap<T>(list: &mut [T], ev: SortEvent) {
64 if ev.from != ev.to && ev.from < list.len() && ev.to < list.len() {
65 list.swap(ev.from, ev.to);
66 }
67}
68
69/// The live-preview offset (CSS px along the list axis) for the row at `ix`
70/// while row `from` is dragged over row `over` - the mid-drag preview
71/// dnd-kit and react-beautiful-dnd made the baseline expectation.
72///
73/// Two moves happen at once: rows between the two indices shift by `step`
74/// (the dragged row's size) to close the source slot, and the **source row
75/// itself translates to the target slot** - without that second part the
76/// shifted neighbors would overlap the source, which still occupies its
77/// slot during a drag. Assumes uniform row sizes for the source's
78/// travel distance. Pure, for testability.
79pub fn displacement(ix: usize, from: usize, over: usize, step: f64) -> f64 {
80 if ix == from {
81 (over as f64 - from as f64) * step
82 } else if from < over && ix > from && ix <= over {
83 -step
84 } else if over < from && ix >= over && ix < from {
85 step
86 } else {
87 0.0
88 }
89}
90
91/// Distance between consecutive row origins, including CSS margin/gap.
92fn slot_pitch(rects: &HashMap<usize, Rect>, ix: usize, axis: Axis) -> Option<f64> {
93 let pos = |r: &Rect| match axis {
94 Axis::Vertical => r.y,
95 Axis::Horizontal => r.x,
96 };
97 let cur = rects.get(&ix)?;
98 if let Some(next) = rects.get(&(ix + 1)) {
99 return Some(pos(next) - pos(cur));
100 }
101 if let Some(prev) = ix.checked_sub(1).and_then(|p| rects.get(&p)) {
102 return Some(pos(cur) - pos(prev));
103 }
104 Some(match axis {
105 Axis::Vertical => cur.height,
106 Axis::Horizontal => cur.width,
107 })
108}
109
110fn refresh_rects(
111 mounteds: Signal<HashMap<usize, Rc<MountedData>>>,
112 rects: Signal<HashMap<usize, Rect>>,
113) {
114 for (i, m) in mounteds.peek().clone() {
115 let mut rects = rects;
116 spawn(async move {
117 if let Ok(r) = m.get_client_rect().await {
118 rects.write().insert(
119 i,
120 Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
121 );
122 }
123 });
124 }
125}
126
127/// Which row should be the drop target while a pointer drag from row `from`
128/// hovers at `at`, given per-row rects measured at drag start (so the test
129/// runs against the stable, pre-displacement layout). A row is adopted only once the pointer
130/// crosses its center in the travel direction, and while the pointer is
131/// over the source row or outside every rect, the previous target is kept.
132/// Pure, for testability.
133pub fn pointer_target(
134 rects: &HashMap<usize, Rect>,
135 from: usize,
136 current: Option<usize>,
137 at: Point,
138 axis: Axis,
139) -> Option<usize> {
140 let Some((&ix, rect)) = rects.iter().find(|(_, r)| r.contains(at)) else {
141 return current;
142 };
143 if ix == from || Some(ix) == current {
144 return current;
145 }
146 let (pos, size) = match axis {
147 Axis::Vertical => (at.y - rect.y, rect.height),
148 Axis::Horizontal => (at.x - rect.x, rect.width),
149 };
150 let crossed = if from < ix {
151 pos > size * 0.5
152 } else {
153 pos < size * 0.5
154 };
155 if crossed {
156 Some(ix)
157 } else {
158 current
159 }
160}
161
162/// The bounding box of all measured rows - the list's occupied area. Used to
163/// decide whether a pointer release landed on the list at all: a drop outside
164/// this box commits no reorder. `None` when no rows are measured yet.
165pub(crate) fn list_bounds(rects: &HashMap<usize, Rect>) -> Option<Rect> {
166 let mut it = rects.values();
167 let first = it.next()?;
168 let (mut min_x, mut min_y) = (first.x, first.y);
169 let (mut max_x, mut max_y) = (first.x + first.width, first.y + first.height);
170 for r in it {
171 min_x = min_x.min(r.x);
172 min_y = min_y.min(r.y);
173 max_x = max_x.max(r.x + r.width);
174 max_y = max_y.max(r.y + r.height);
175 }
176 Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
177}
178
179/// Layout direction of the list - decides whether the midpoint test uses
180/// the Y axis (vertical lists) or the X axis (horizontal ones).
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
182pub enum Axis {
183 #[default]
184 Vertical,
185 Horizontal,
186}
187
188/// Apply a [`SortEvent`] to a `Vec` in place.
189pub fn apply_sort<T>(list: &mut Vec<T>, ev: SortEvent) {
190 if ev.from == ev.to || ev.from >= list.len() || ev.to >= list.len() {
191 return;
192 }
193 let item = list.remove(ev.from);
194 list.insert(ev.to, item);
195}
196
197/// A list whose items can be dragged to reorder.
198///
199/// Data-agnostic: give it a `len` and a `render` callback keyed by index. It
200/// renders one wrapper per item and emits a [`SortEvent`] on drop. The hovered
201/// drop target gets `data-drop-target` on its wrapper and the dragged item gets
202/// `data-dragging`; both are absent while inactive.
203///
204/// Headless by default - the component ships behavior, you compose the looks.
205/// With `overlay` set, the picked-up row is hidden in place and *your* overlay
206/// element floats at the pointer (the dnd-kit feel); keep it lightweight, it is
207/// your content, not a clone of the row. Without it, the row stays visible and
208/// slides.
209#[component]
210pub fn SortableList(
211 /// Number of items.
212 len: usize,
213 /// Renders the item at the given index.
214 render: Callback<usize, Element>,
215 /// Fired when the user drops an item at a new position.
216 on_sort: EventHandler<SortEvent>,
217 /// List direction: which axis rows are laid out (and shifted) along.
218 #[props(default)]
219 axis: Axis,
220 /// Open a live gap where the drop would land, by translating the rows
221 /// in between. Set `false` for the plain highlight-only behavior.
222 #[props(default = true)]
223 live_preview: bool,
224 /// Duration (ms) of the row-slide transition during live preview.
225 #[props(default = 160)]
226 transition_ms: u32,
227 /// Opt-in floating ghost: renders `overlay(index)` pinned to the pointer
228 /// while dragging, and hides the picked-up row in place so its slot reads
229 /// as the drop gap. Absent → the row itself slides. Keep the ghost
230 /// lightweight; it is your content, not a clone of the row.
231 #[props(default)]
232 overlay: Option<Callback<usize, Element>>,
233 /// Confine touch/pen drags to a leading grip element instead of the
234 /// whole row. The grip carries `touch-action: none` so the rest of the
235 /// row keeps scrolling by finger - use this inside scrollable lists.
236 /// Style it via `[data-sort-handle]`.
237 #[props(default = false)]
238 touch_handle: bool,
239 /// Content for the `touch_handle` grip, keyed by index. Defaults to a
240 /// braille-dots glyph when unset.
241 #[props(default)]
242 handle: Option<Callback<usize, Element>>,
243 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
244) -> Element {
245 let mut drag_from = use_signal(|| None::<usize>);
246 let mut over = use_signal(|| None::<usize>);
247 let mut press_from = use_signal(|| None::<usize>);
248 let mut press_at = use_signal(|| None::<Point>);
249 let mut pointer_at = use_signal(|| None::<Point>);
250 // Per-row client rects (measured on mount, re-measured at pointer-drag
251 // start) drive both the displacement step and hit-testing.
252 let rects = use_signal(HashMap::<usize, Rect>::new);
253 let mounteds = use_signal(HashMap::<usize, Rc<MountedData>>::new);
254 let mut rects_for_len = rects;
255 let mut mounteds_for_len = mounteds;
256 use_effect(use_reactive!(|len| {
257 rects_for_len.write().retain(|ix, _| *ix < len);
258 mounteds_for_len.write().retain(|ix, _| *ix < len);
259 }));
260 let size_of = move |ix: usize| {
261 rects
262 .peek()
263 .get(&ix)
264 .map(|r| match axis {
265 Axis::Vertical => r.height,
266 Axis::Horizontal => r.width,
267 })
268 .unwrap_or(40.0)
269 };
270
271 // Drags run the same formal gesture machine as `Draggable`. `over` (the
272 // drop target) is resolved synchronously on every tracked move - no
273 // derived effect - so the gap never lags the pointer.
274 let mut gesture = use_signal(|| GesturePhase::Idle);
275 let mut step = move |event: GestureEvent| -> GestureEffect {
276 let (next, fx) = transition(*gesture.peek(), event, 8.0);
277 gesture.set(next);
278 fx
279 };
280 // Feed one pointer event and act on the machine's effect. The source row is
281 // latched on pointerdown because mouse move/up events bubble through the
282 // list container rather than staying on the pressed row.
283 let mut feed = move |event: GestureEvent| {
284 match step(event) {
285 GestureEffect::Begin { at, .. } => {
286 let Some(ix) = *press_from.peek() else {
287 return;
288 };
289 drag_from.set(Some(ix));
290 pointer_at.set(Some(at));
291 over.set(pointer_target(&rects.peek(), ix, None, at, axis));
292 // Client rects go stale when the list scrolls or layout shifts;
293 // re-measure every row at drag start so hit-testing runs against
294 // the current (pre-displacement) slots.
295 refresh_rects(mounteds, rects);
296 }
297 GestureEffect::Track { at } => {
298 let Some(from) = *drag_from.peek() else {
299 return;
300 };
301 pointer_at.set(Some(at));
302 let next = pointer_target(&rects.peek(), from, *over.peek(), at, axis);
303 if next != *over.peek() {
304 over.set(next);
305 }
306 }
307 GestureEffect::Drop { at } => {
308 let from_opt = *drag_from.peek();
309 // A release outside the list's bounds cancels rather than
310 // committing a reorder - dropping a row "nowhere" shouldn't
311 // move it. Inside the bounds, snap to the hovered target.
312 let to = {
313 let rects_ref = rects.peek();
314 if list_bounds(&rects_ref)
315 .map(|b| b.contains(at))
316 .unwrap_or(false)
317 {
318 from_opt.and_then(|from| {
319 pointer_target(&rects_ref, from, *over.peek(), at, axis)
320 })
321 } else {
322 None
323 }
324 };
325 // Clear ALL drag state BEFORE notifying: `on_sort` mutates the
326 // caller's list, which re-renders this component; observing a
327 // still-active drag would re-apply the preview to the already-
328 // reordered rows.
329 press_from.set(None);
330 press_at.set(None);
331 drag_from.set(None);
332 over.set(None);
333 pointer_at.set(None);
334 if let (Some(from), Some(to)) = (from_opt, to) {
335 if from != to {
336 on_sort.call(SortEvent { from, to });
337 }
338 }
339 }
340 GestureEffect::Abort => {
341 press_from.set(None);
342 press_at.set(None);
343 drag_from.set(None);
344 over.set(None);
345 pointer_at.set(None);
346 }
347 GestureEffect::Tap => {
348 press_from.set(None);
349 press_at.set(None);
350 pointer_at.set(None);
351 }
352 GestureEffect::None => {}
353 }
354 };
355
356 let primary_pointer = move |evt: &PointerEvent| evt.is_primary();
357 let mut cancel_drag = move || {
358 feed(GestureEvent::Cancel);
359 press_from.set(None);
360 press_at.set(None);
361 drag_from.set(None);
362 over.set(None);
363 pointer_at.set(None);
364 };
365
366 // Opt-in floating ghost (caller-composed). Only computed when we have a
367 // measured source rect and both pointer positions, so the in-flow original
368 // is hidden *only* when a replacement is guaranteed to render - no rect, no
369 // ghost, no disappearing row (it just slides). Carries the callback so the
370 // render below needs nothing else.
371 let overlay_ghost: Option<(Callback<usize, Element>, usize, Point, Rect)> =
372 overlay.zip(drag_from()).and_then(|(cb, from)| {
373 let r = rects.peek().get(&from).copied()?;
374 let p0 = press_at()?;
375 let p1 = pointer_at()?;
376 Some((
377 cb,
378 from,
379 Point::new(r.x + (p1.x - p0.x), r.y + (p1.y - p0.y)),
380 r,
381 ))
382 });
383 let ghost_from = overlay_ghost.map(|(_, f, _, _)| f);
384
385 rsx! {
386 div {
387 onpointermove: move |evt: PointerEvent| {
388 let at = pointer_client(&evt);
389 // Recovery for a mouse released while the cursor sat outside the
390 // list. With the `web` feature, `platform::capture_pointer`
391 // routes the release back here; without it (feature off, or a
392 // non-web renderer) no `pointerup` arrives, so when the pointer
393 // returns over the list with no button held we finish the drop
394 // rather than track a phantom drag. Touch/pen hold a button
395 // throughout contact, so this only trips for a released mouse.
396 if drag_from.peek().is_some() && evt.held_buttons().is_empty() {
397 if let Some(from) = *drag_from.peek() {
398 if let Some(n) = mounteds.peek().get(&from).cloned() {
399 platform::release_pointer(&n, evt.pointer_id());
400 }
401 }
402 feed(GestureEvent::Up { at, pointer_id: evt.pointer_id() });
403 return;
404 }
405 feed(GestureEvent::Move { at, pointer_id: evt.pointer_id() });
406 },
407 onpointerup: move |evt: PointerEvent| {
408 if let Some(from) = *drag_from.peek() {
409 if let Some(n) = mounteds.peek().get(&from).cloned() {
410 platform::release_pointer(&n, evt.pointer_id());
411 }
412 }
413 feed(GestureEvent::Up { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
414 },
415 // Genuine interruptions (touch cancelled, browser stole capture)
416 // abort the drag. Merely leaving the list does NOT: without pointer
417 // capture, cancelling on `pointerleave` would kill every drag that
418 // strays a pixel past an edge.
419 onpointercancel: move |evt: PointerEvent| {
420 if let Some(from) = *drag_from.peek() {
421 if let Some(n) = mounteds.peek().get(&from).cloned() {
422 platform::release_pointer(&n, evt.pointer_id());
423 }
424 }
425 cancel_drag();
426 },
427 onlostpointercapture: move |_| cancel_drag(),
428 ..attributes,
429 for ix in 0..len {
430 div {
431 key: "{ix}",
432 "data-dragging": if drag_from() == Some(ix) { "true" },
433 "data-drop-target": if over() == Some(ix) && drag_from() != Some(ix) { "true" },
434 style: {
435 // Live preview: rows slide so every slot stays filled by
436 // exactly one box. When `overlay` is set, the picked-up
437 // row is drawn by the floating ghost, so its in-flow
438 // original is hidden (opacity 0) while still translating
439 // to the target slot - that invisible slot is the gap the
440 // neighbours part around. Without `overlay` the row stays
441 // visible and slides.
442 let base = match (live_preview, drag_from()) {
443 (true, Some(from)) => {
444 let step = slot_pitch(&rects.peek(), from, axis)
445 .unwrap_or_else(|| size_of(from));
446 let o = over().unwrap_or(from);
447 let d = displacement(ix, from, o, step);
448 let (x, y) = match axis {
449 Axis::Vertical => (0.0, d),
450 Axis::Horizontal => (d, 0.0),
451 };
452 let hidden = if ghost_from == Some(ix) {
453 " opacity: 0;"
454 } else {
455 ""
456 };
457 format!("transform: translate({x}px, {y}px); transition: transform {transition_ms}ms ease;{hidden}")
458 }
459 _ => format!(
460 "transform: translate(0px, 0px); transition: transform {transition_ms}ms; opacity: 1;"
461 ),
462 };
463 if touch_handle {
464 format!("display: flex; align-items: stretch; width: 100%; {base}")
465 } else {
466 format!("touch-action: none; {base}")
467 }
468 },
469 // Pointer path (whole-row mode). With `touch_handle` these
470 // are inert and the grip below owns the gesture.
471 onpointerdown: move |evt: PointerEvent| {
472 if touch_handle || !primary_pointer(&evt) {
473 return;
474 }
475 evt.prevent_default();
476 evt.stop_propagation();
477 refresh_rects(mounteds, rects);
478 press_from.set(Some(ix));
479 press_at.set(Some(pointer_client(&evt)));
480 // Capture on the stable row wrapper so a mouse drag
481 // survives the cursor leaving the list (real capture with
482 // the `web` feature; a no-op otherwise, backed by the
483 // button-release recovery above). Move/up still bubble to
484 // the container handlers.
485 if let Some(n) = mounteds.peek().get(&ix).cloned() {
486 platform::capture_pointer(&n, evt.pointer_id());
487 }
488 feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
489 },
490 onmounted: move |evt: Event<MountedData>| {
491 let m: Rc<MountedData> = evt.data();
492 let mut mounteds = mounteds;
493 let mut rects = rects;
494 mounteds.write().insert(ix, m.clone());
495 spawn(async move {
496 if let Ok(r) = m.get_client_rect().await {
497 rects.write().insert(
498 ix,
499 Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
500 );
501 }
502 });
503 },
504 if touch_handle {
505 span {
506 "data-sort-handle": true,
507 aria_hidden: true,
508 style: "touch-action: none; user-select: none; -webkit-user-select: none; display: grid; place-items: center;",
509 onpointerdown: move |evt: PointerEvent| {
510 if !primary_pointer(&evt) {
511 return;
512 }
513 evt.prevent_default();
514 evt.stop_propagation();
515 refresh_rects(mounteds, rects);
516 press_from.set(Some(ix));
517 press_at.set(Some(pointer_client(&evt)));
518 // Capture on the row wrapper (not the grip): it is
519 // stable across live-preview re-renders, and
520 // captured events still bubble to the container.
521 if let Some(n) = mounteds.peek().get(&ix).cloned() {
522 platform::capture_pointer(&n, evt.pointer_id());
523 }
524 feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
525 },
526 if let Some(h) = handle {
527 {h.call(ix)}
528 } else {
529 "⠿"
530 }
531 }
532 div {
533 "data-sort-content": true,
534 style: "flex: 1 1 auto; min-width: 0;",
535 {render.call(ix)}
536 }
537 } else {
538 {render.call(ix)}
539 }
540 }
541 }
542 if let Some((cb, from, pos, rect)) = overlay_ghost {
543 div {
544 style: format!(
545 "{} width: {}px; height: {}px;",
546 overlay_style(pos),
547 rect.width,
548 rect.height
549 ),
550 {cb.call(from)}
551 }
552 }
553 }
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn sort_moves_forward_and_back() {
563 let mut v = vec!["a", "b", "c", "d"];
564 apply_sort(&mut v, SortEvent { from: 0, to: 2 });
565 assert_eq!(v, vec!["b", "c", "a", "d"]);
566 apply_sort(&mut v, SortEvent { from: 3, to: 0 });
567 assert_eq!(v, vec!["d", "b", "c", "a"]);
568 }
569
570 #[test]
571 fn sort_ignores_out_of_bounds_and_noops() {
572 let mut v = vec![1, 2, 3];
573 apply_sort(&mut v, SortEvent { from: 1, to: 1 });
574 apply_sort(&mut v, SortEvent { from: 9, to: 0 });
575 apply_sort(&mut v, SortEvent { from: 0, to: 9 });
576 assert_eq!(v, vec![1, 2, 3]);
577 }
578}
579
580#[cfg(test)]
581mod pointer_target_tests {
582 use super::*;
583
584 /// Three 40px rows stacked at y = 0, 40, 80.
585 fn rows() -> HashMap<usize, Rect> {
586 (0..3)
587 .map(|i| (i, Rect::new(0.0, i as f64 * 40.0, 200.0, 40.0)))
588 .collect()
589 }
590
591 #[test]
592 fn adopts_a_row_only_past_its_midpoint() {
593 let r = rows();
594 // dragging row 0 downward into row 1's top half: not yet crossed
595 let t = pointer_target(&r, 0, None, Point::new(50.0, 45.0), Axis::Vertical);
596 assert_eq!(t, None);
597 // past row 1's midpoint (y = 60): adopted
598 let t = pointer_target(&r, 0, None, Point::new(50.0, 65.0), Axis::Vertical);
599 assert_eq!(t, Some(1));
600 // dragging row 2 upward into row 1's bottom half: not yet crossed
601 let t = pointer_target(&r, 2, None, Point::new(50.0, 75.0), Axis::Vertical);
602 assert_eq!(t, None);
603 let t = pointer_target(&r, 2, None, Point::new(50.0, 55.0), Axis::Vertical);
604 assert_eq!(t, Some(1));
605 }
606
607 #[test]
608 fn keeps_current_over_source_row_and_outside_all_rects() {
609 let r = rows();
610 // hovering the source row keeps the previous target
611 let t = pointer_target(&r, 0, Some(2), Point::new(50.0, 10.0), Axis::Vertical);
612 assert_eq!(t, Some(2));
613 // finger wandered off the list entirely: previous target survives
614 let t = pointer_target(&r, 0, Some(2), Point::new(500.0, 500.0), Axis::Vertical);
615 assert_eq!(t, Some(2));
616 }
617
618 #[test]
619 fn horizontal_axis_uses_x() {
620 let r: HashMap<usize, Rect> = (0..3)
621 .map(|i| (i, Rect::new(i as f64 * 60.0, 0.0, 60.0, 40.0)))
622 .collect();
623 let t = pointer_target(&r, 0, None, Point::new(100.0, 20.0), Axis::Horizontal);
624 assert_eq!(t, Some(1)); // past x = 90 midpoint of tile 1
625 }
626
627 #[test]
628 fn list_bounds_covers_all_rows_and_excludes_outside() {
629 let r = rows(); // three 40px rows spanning y 0..120, x 0..200
630 let b = list_bounds(&r).unwrap();
631 assert_eq!(b, Rect::new(0.0, 0.0, 200.0, 120.0));
632 // a release inside the span (even in a gap) is on the list
633 assert!(b.contains(Point::new(50.0, 60.0)));
634 // a release well outside is not - the Drop arm cancels there
635 assert!(!b.contains(Point::new(500.0, 500.0)));
636 assert!(!b.contains(Point::new(50.0, 130.0)));
637 // no measured rows: no bounds
638 assert_eq!(list_bounds(&HashMap::new()), None);
639 }
640}
641
642#[cfg(test)]
643mod swap_tests {
644 use super::*;
645
646 #[test]
647 fn swap_exchanges_and_guards_bounds() {
648 let mut v = vec![1, 2, 3, 4];
649 apply_swap(&mut v, SortEvent { from: 0, to: 3 });
650 assert_eq!(v, vec![4, 2, 3, 1]);
651 apply_swap(&mut v, SortEvent { from: 9, to: 0 });
652 assert_eq!(v, vec![4, 2, 3, 1]);
653 }
654}
655
656#[cfg(test)]
657mod slot_pitch_tests {
658 use super::*;
659
660 #[test]
661 fn pitch_includes_spacing_between_rows() {
662 let rows: HashMap<usize, Rect> = (0..3)
663 .map(|i| (i, Rect::new(0.0, i as f64 * 46.0, 200.0, 42.0)))
664 .collect();
665
666 assert_eq!(slot_pitch(&rows, 0, Axis::Vertical), Some(46.0));
667 assert_eq!(slot_pitch(&rows, 1, Axis::Vertical), Some(46.0));
668 assert_eq!(slot_pitch(&rows, 2, Axis::Vertical), Some(46.0));
669 }
670
671 #[test]
672 fn pitch_falls_back_to_size_for_single_row() {
673 let rows: HashMap<usize, Rect> = [(0, Rect::new(0.0, 0.0, 200.0, 42.0))]
674 .into_iter()
675 .collect();
676
677 assert_eq!(slot_pitch(&rows, 0, Axis::Vertical), Some(42.0));
678 assert_eq!(slot_pitch(&rows, 9, Axis::Vertical), None);
679 }
680}
681
682#[cfg(test)]
683mod displacement_tests {
684 use super::*;
685
686 #[test]
687 fn displacement_moves_source_to_target_and_neighbors_aside() {
688 // dragging row 1 down over row 3, rows are 40px:
689 // source travels +2 slots; rows 2..=3 shift up into the freed space
690 let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).collect();
691 assert_eq!(d, vec![0.0, 80.0, -40.0, -40.0, 0.0]);
692 // dragging row 3 up over row 1: source travels -2 slots
693 let d: Vec<f64> = (0..5).map(|ix| displacement(ix, 3, 1, 40.0)).collect();
694 assert_eq!(d, vec![0.0, 40.0, 40.0, -80.0, 0.0]);
695 // hovering the source itself: nothing moves
696 assert!((0..5).all(|ix| displacement(ix, 2, 2, 40.0) == 0.0));
697 // slot occupancy is conserved: offsets sum to zero
698 let sum: f64 = (0..5).map(|ix| displacement(ix, 1, 3, 40.0)).sum();
699 assert_eq!(sum, 0.0);
700 }
701}