use std::collections::HashMap;
use std::rc::Rc;
use dioxus::html::MountedData;
use dioxus::prelude::*;
use crate::core::components::overlay_style;
use crate::core::{platform, transition, GestureEffect, GestureEvent, GesturePhase, Point, Rect};
fn pointer_client(evt: &PointerEvent) -> Point {
let c = evt.client_coordinates();
Point::new(c.x, c.y)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SortEvent {
pub from: usize,
pub to: usize,
}
#[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,
})
}
fn refresh_rects(
mounteds: Signal<HashMap<usize, Rc<MountedData>>>,
rects: Signal<HashMap<usize, Rect>>,
) {
for (i, m) in mounteds.peek().clone() {
let mut rects = rects;
spawn(async move {
if let Ok(r) = m.get_client_rect().await {
rects.write().insert(
i,
Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
);
}
});
}
}
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)]
handle: Option<Callback<usize, Element>>,
#[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
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::<usize, Rect>::new);
let mounteds = use_signal(HashMap::<usize, Rc<MountedData>>::new);
let mut rects_for_len = rects;
let mut mounteds_for_len = mounteds;
use_effect(use_reactive!(|len| {
rects_for_len.write().retain(|ix, _| *ix < len);
mounteds_for_len.write().retain(|ix, _| *ix < len);
}));
let size_of = move |ix: usize| {
rects
.peek()
.get(&ix)
.map(|r| match axis {
Axis::Vertical => r.height,
Axis::Horizontal => r.width,
})
.unwrap_or(40.0)
};
let mut gesture = use_signal(|| GesturePhase::Idle);
let mut step = move |event: GestureEvent| -> GestureEffect {
let (next, fx) = transition(*gesture.peek(), event, 8.0);
gesture.set(next);
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(&rects.peek(), ix, None, at, axis));
refresh_rects(mounteds, rects);
}
GestureEffect::Track { at } => {
let Some(from) = *drag_from.peek() else {
return;
};
pointer_at.set(Some(at));
let next = pointer_target(&rects.peek(), 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 = rects.peek();
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 primary_pointer = move |evt: &PointerEvent| evt.is_primary();
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 = rects.peek().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);
rsx! {
div {
onpointermove: move |evt: PointerEvent| {
let at = pointer_client(&evt);
if drag_from.peek().is_some() && evt.held_buttons().is_empty() {
if let Some(from) = *drag_from.peek() {
if let Some(n) = mounteds.peek().get(&from).cloned() {
platform::release_pointer(&n, evt.pointer_id());
}
}
feed(GestureEvent::Up { at, pointer_id: evt.pointer_id() });
return;
}
feed(GestureEvent::Move { at, pointer_id: evt.pointer_id() });
},
onpointerup: move |evt: PointerEvent| {
if let Some(from) = *drag_from.peek() {
if let Some(n) = mounteds.peek().get(&from).cloned() {
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) = mounteds.peek().get(&from).cloned() {
platform::release_pointer(&n, evt.pointer_id());
}
}
cancel_drag();
},
onlostpointercapture: move |_| cancel_drag(),
..attributes,
for ix in 0..len {
div {
key: "{ix}",
"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(&rects.peek(), 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!("touch-action: none; {base}")
}
},
onpointerdown: move |evt: PointerEvent| {
if touch_handle || !primary_pointer(&evt) {
return;
}
evt.prevent_default();
evt.stop_propagation();
refresh_rects(mounteds, rects);
press_from.set(Some(ix));
press_at.set(Some(pointer_client(&evt)));
if let Some(n) = mounteds.peek().get(&ix).cloned() {
platform::capture_pointer(&n, evt.pointer_id());
}
feed(GestureEvent::Down { at: pointer_client(&evt), pointer_id: evt.pointer_id() });
},
onmounted: move |evt: Event<MountedData>| {
let m: Rc<MountedData> = evt.data();
let mut mounteds = mounteds;
let mut rects = rects;
mounteds.write().insert(ix, m.clone());
spawn(async move {
if let Ok(r) = m.get_client_rect().await {
rects.write().insert(
ix,
Rect::new(r.origin.x, r.origin.y, r.size.width, r.size.height),
);
}
});
},
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);
press_from.set(Some(ix));
press_at.set(Some(pointer_client(&evt)));
if let Some(n) = mounteds.peek().get(&ix).cloned() {
platform::capture_pointer(&n, evt.pointer_id());
}
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]);
}
}
#[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 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);
}
}