Skip to main content

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