1use std::collections::{HashMap, HashSet};
4use std::fmt;
5use std::hash::Hash;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use dioxus::prelude::*;
9
10use crate::core::components::FlatDropZone;
11use crate::core::{
12 ActivationPolicy, DndProvider, DragMode, Draggable, DropOutcome, DropQuery, DropZone, Edge,
13 EdgeSet, Rect, ZoneId,
14};
15use crate::sortable::Axis;
16
17const AUTO_GROUP_BASE: u64 = 1 << 32;
18static NEXT_GROUP: AtomicU64 = AtomicU64::new(AUTO_GROUP_BASE);
19
20fn checked_render_keys<K, F>(items: &[K], mut render_key: F) -> Vec<String>
21where
22 K: Clone + Eq + Hash,
23 F: FnMut(K) -> String,
24{
25 let mut ids = HashSet::with_capacity(items.len());
26 let mut keys = HashSet::with_capacity(items.len());
27 items
28 .iter()
29 .cloned()
30 .map(|item| {
31 assert!(
32 ids.insert(item.clone()),
33 "SortableCollection item ids must be unique"
34 );
35 let key = render_key(item);
36 assert!(
37 keys.insert(key.clone()),
38 "SortableCollection render keys must be unique"
39 );
40 key
41 })
42 .collect()
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
46pub struct SortableGroupId(u64);
47
48impl SortableGroupId {
49 pub const fn new(value: u64) -> Self {
51 assert!(
52 value < AUTO_GROUP_BASE,
53 "explicit sortable group ids must be below 2^32"
54 );
55 Self(value)
56 }
57
58 pub fn auto() -> Self {
59 let value = NEXT_GROUP
60 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
61 current.checked_add(1)
62 })
63 .unwrap_or_else(|_| panic!("automatic sortable group id space exhausted"));
64 Self(value)
65 }
66
67 pub const fn get(self) -> u64 {
68 self.0
69 }
70}
71
72impl fmt::Display for SortableGroupId {
73 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74 self.0.fmt(formatter)
75 }
76}
77
78#[derive(Debug, Clone, PartialEq)]
79#[non_exhaustive]
80pub struct SortablePayload<K> {
81 pub group: SortableGroupId,
82 pub item: K,
83 pub position: usize,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum Placement {
90 Before,
91 After,
92 On,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96#[non_exhaustive]
97pub enum SortStrategy {
98 #[default]
99 LinearVertical,
100 LinearHorizontal,
101 GridInsert,
102 GridSwap,
103}
104
105impl SortStrategy {
106 pub fn linear(axis: Axis) -> Self {
107 match axis {
108 Axis::Vertical => Self::LinearVertical,
109 Axis::Horizontal => Self::LinearHorizontal,
110 }
111 }
112
113 fn edges(self) -> EdgeSet {
114 match self {
115 Self::LinearVertical | Self::GridInsert => EdgeSet::Vertical,
116 Self::LinearHorizontal => EdgeSet::Horizontal,
117 Self::GridSwap => EdgeSet::All,
118 }
119 }
120
121 fn placement(self, edge: Option<Edge>) -> Placement {
122 if self == Self::GridSwap {
123 return Placement::On;
124 }
125 match edge {
126 Some(Edge::Top | Edge::Left) => Placement::Before,
127 Some(Edge::Bottom | Edge::Right) | None => Placement::After,
128 }
129 }
130}
131
132fn item_drop_placement(
133 strategy: SortStrategy,
134 mode: DragMode,
135 active_group: SortableGroupId,
136 target_group: SortableGroupId,
137 active_position: usize,
138 target_position: usize,
139 edge: Option<Edge>,
140) -> Placement {
141 if strategy == SortStrategy::GridSwap {
142 return Placement::On;
143 }
144 if mode != DragMode::Keyboard {
145 return strategy.placement(edge);
146 }
147 if active_group != target_group {
148 return Placement::Before;
149 }
150 if active_position > target_position {
151 Placement::Before
152 } else {
153 Placement::After
154 }
155}
156
157#[derive(Debug, Clone, PartialEq)]
158#[non_exhaustive]
159pub struct ReorderEvent<K> {
160 pub active: K,
161 pub over: Option<K>,
163 pub from_group: SortableGroupId,
164 pub to_group: SortableGroupId,
165 pub placement: Placement,
166}
167
168#[derive(Debug, Clone, PartialEq)]
169#[non_exhaustive]
170pub struct DropPlacement<K> {
171 pub over: Option<K>,
173 pub placement: Placement,
174}
175
176#[derive(Debug, Clone, PartialEq)]
177#[non_exhaustive]
178pub struct ItemTransform<K> {
179 pub id: K,
180 pub x: f64,
181 pub y: f64,
182}
183
184impl<K> SortablePayload<K> {
185 pub fn new(group: SortableGroupId, item: K, position: usize) -> Self {
186 Self {
187 group,
188 item,
189 position,
190 }
191 }
192}
193
194impl<K> ReorderEvent<K> {
195 pub fn new(
196 active: K,
197 over: Option<K>,
198 from_group: SortableGroupId,
199 to_group: SortableGroupId,
200 placement: Placement,
201 ) -> Self {
202 Self {
203 active,
204 over,
205 from_group,
206 to_group,
207 placement,
208 }
209 }
210}
211
212impl<K> DropPlacement<K> {
213 pub fn new(over: Option<K>, placement: Placement) -> Self {
214 Self { over, placement }
215 }
216}
217
218pub fn project_layout<K>(
222 items: &[K],
223 rects: &HashMap<K, Rect>,
224 active: &K,
225 target: &DropPlacement<K>,
226) -> Vec<ItemTransform<K>>
227where
228 K: Clone + Eq + Hash,
229{
230 let Some(active_index) = items.iter().position(|item| item == active) else {
231 return Vec::new();
232 };
233 let mut projected = items.to_vec();
234 let moved = projected.remove(active_index);
235 let mut insertion = match target.over.as_ref() {
236 Some(over) => {
237 let Some(over_index) = items.iter().position(|item| item == over) else {
238 return Vec::new();
239 };
240 match target.placement {
241 Placement::Before | Placement::On => over_index,
242 Placement::After => over_index + 1,
243 }
244 }
245 None => items.len(),
246 };
247 if active_index < insertion {
248 insertion = insertion.saturating_sub(1);
249 }
250 insertion = insertion.min(projected.len());
251 projected.insert(insertion, moved);
252
253 projected
254 .iter()
255 .enumerate()
256 .filter_map(|(new_index, item)| {
257 let old = rects.get(item)?;
258 let slot_id = items.get(new_index)?;
259 let slot = rects.get(slot_id)?;
260 Some(ItemTransform {
261 id: item.clone(),
262 x: slot.x - old.x,
263 y: slot.y - old.y,
264 })
265 })
266 .collect()
267}
268
269pub fn apply_reorder<K: PartialEq>(items: &mut Vec<K>, event: &ReorderEvent<K>) -> bool {
271 if event.from_group != event.to_group {
272 return false;
273 }
274 let Some(from) = items.iter().position(|item| item == &event.active) else {
275 return false;
276 };
277 let item = items.remove(from);
278 let mut to = match event.over.as_ref() {
279 Some(over_item) => {
280 let Some(over) = items.iter().position(|candidate| candidate == over_item) else {
281 items.insert(from, item);
282 return false;
283 };
284 match event.placement {
285 Placement::Before | Placement::On => over,
286 Placement::After => over + 1,
287 }
288 }
289 None => items.len(),
290 };
291 to = to.min(items.len());
292 if to == from {
293 items.insert(from, item);
294 return false;
295 }
296 items.insert(to, item);
297 true
298}
299
300#[derive(Clone)]
301struct GroupContext<K: 'static> {
302 id: SortableGroupId,
303 zone: ZoneId,
304 strategy: Memo<SortStrategy>,
305 activation: Memo<Option<ActivationPolicy>>,
306 on_reorder: Callback<ReorderEvent<K>>,
307}
308
309#[component]
312pub fn SortableProvider<K: Clone + PartialEq + 'static>(
313 #[props(default)] phantom: std::marker::PhantomData<K>,
314 children: Element,
315) -> Element {
316 let _ = phantom;
317 rsx! {
318 DndProvider::<SortablePayload<K>> { {children} }
319 }
320}
321
322#[component]
325pub fn SortableGroup<K: Clone + PartialEq + 'static>(
326 on_reorder: EventHandler<ReorderEvent<K>>,
327 #[props(default)] id: Option<SortableGroupId>,
328 #[props(default)] strategy: SortStrategy,
329 #[props(default)] activation: Option<ActivationPolicy>,
330 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
331 children: Element,
332) -> Element {
333 let auto_group_id = use_hook(SortableGroupId::auto);
334 let group_id = id.unwrap_or(auto_group_id);
335 rsx! {
336 for keyed_group_id in [group_id] {
337 SortableGroupInstance::<K> {
338 key: "{keyed_group_id}",
339 group_id: keyed_group_id,
340 strategy,
341 activation: activation.clone(),
342 on_reorder,
343 attributes: attributes.clone(),
344 {children.clone()}
345 }
346 }
347 }
348}
349
350#[component]
351fn SortableGroupInstance<K: Clone + PartialEq + 'static>(
352 group_id: SortableGroupId,
353 strategy: SortStrategy,
354 activation: Option<ActivationPolicy>,
355 on_reorder: EventHandler<ReorderEvent<K>>,
356 attributes: Vec<Attribute>,
357 children: Element,
358) -> Element {
359 let zone = use_hook(ZoneId::auto);
360 let strategy_state = use_memo(use_reactive!(|strategy| strategy));
361 let activation_state = use_memo(use_reactive!(|activation| activation));
362 let reorder = use_callback(move |event| on_reorder.call(event));
363 use_context_provider(|| GroupContext {
364 id: group_id,
365 zone,
366 strategy: strategy_state,
367 activation: activation_state,
368 on_reorder: reorder,
369 });
370
371 rsx! {
372 FlatDropZone::<SortablePayload<K>> {
373 zone_id: zone,
374 on_drop: move |outcome: DropOutcome<SortablePayload<K>>| {
375 let active = outcome.payload;
376 reorder.call(ReorderEvent {
377 active: active.item,
378 over: None,
379 from_group: active.group,
380 to_group: group_id,
381 placement: Placement::After,
382 });
383 },
384 attributes,
385 "data-sortable-group": "true",
386 {children}
387 }
388 }
389}
390
391#[component]
394pub fn SortableCollection<K: Clone + Eq + Hash + 'static>(
395 items: Vec<K>,
396 render: Callback<K, Element>,
397 item_key: Callback<K, String>,
399 on_reorder: EventHandler<ReorderEvent<K>>,
400 #[props(default)] id: Option<SortableGroupId>,
401 #[props(default)] strategy: SortStrategy,
402 #[props(default)] activation: Option<ActivationPolicy>,
403 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
404) -> Element {
405 let render_keys = checked_render_keys(&items, |item| item_key.call(item));
406 rsx! {
407 SortableGroup::<K> {
408 id,
409 strategy,
410 activation,
411 on_reorder,
412 attributes,
413 for (position, (item, render_key)) in items.into_iter().zip(render_keys).enumerate() {
414 SortableItem::<K> {
415 key: "{render_key}",
416 id: item.clone(),
417 position,
418 {render.call(item)}
419 }
420 }
421 }
422 }
423}
424
425#[component]
428pub fn SortableItem<K: Clone + PartialEq + 'static>(
429 id: K,
430 position: usize,
432 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
433 children: Element,
434) -> Element {
435 let group = use_context::<GroupContext<K>>();
436 let payload = SortablePayload {
437 group: group.id,
438 item: id.clone(),
439 position,
440 };
441 let target = id.clone();
442 let accepts_target = id.clone();
443 let strategy = *group.strategy.read();
444 let activation = group.activation.read().clone();
445
446 rsx! {
447 div {
448 "data-sortable-item": "true",
449 ..attributes,
450 DropZone::<SortablePayload<K>> {
451 edge: strategy.edges(),
452 accepts_query: move |query: DropQuery<SortablePayload<K>>| {
453 query.mode == DragMode::Pointer || query.payload.item != accepts_target
454 },
455 on_drop: move |outcome: DropOutcome<SortablePayload<K>>| {
456 let active = outcome.payload;
457 if active.group == group.id && active.item == target {
458 return;
459 }
460 let strategy = *group.strategy.peek();
461 let placement = item_drop_placement(
462 strategy,
463 outcome.mode,
464 active.group,
465 group.id,
466 active.position,
467 position,
468 outcome.edge,
469 );
470 group.on_reorder.call(ReorderEvent {
471 active: active.item,
472 over: Some(target.clone()),
473 from_group: active.group,
474 to_group: group.id,
475 placement,
476 });
477 },
478 Draggable::<SortablePayload<K>> {
479 payload,
480 zone: group.zone,
481 activation,
482 {children}
483 }
484 }
485 }
486 }
487}
488
489pub use crate::core::DragHandle as SortableHandle;
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn render_keys_come_directly_from_item_identity() {
498 let initial = checked_render_keys(&["a", "b", "c"], str::to_owned);
499 let reordered = checked_render_keys(&["c", "a", "b"], str::to_owned);
500
501 assert_eq!(initial, ["a", "b", "c"]);
502 assert_eq!(reordered, ["c", "a", "b"]);
503 }
504
505 #[test]
506 #[should_panic(expected = "SortableCollection item ids must be unique")]
507 fn duplicate_item_identity_is_rejected() {
508 let _ = checked_render_keys(&["same", "same"], str::to_owned);
509 }
510
511 #[test]
512 #[should_panic(expected = "SortableCollection render keys must be unique")]
513 fn duplicate_render_key_is_rejected() {
514 let _ = checked_render_keys(&["a", "b"], |_| "same".to_string());
515 }
516
517 #[test]
518 fn stable_reorder_uses_identity_and_placement() {
519 let group = SortableGroupId::new(1);
520 let mut items = vec!["a", "b", "c", "d"];
521 assert!(apply_reorder(
522 &mut items,
523 &ReorderEvent {
524 active: "b",
525 over: Some("d"),
526 from_group: group,
527 to_group: group,
528 placement: Placement::After,
529 },
530 ));
531 assert_eq!(items, ["a", "c", "d", "b"]);
532 }
533
534 #[test]
535 fn dropping_after_self_is_a_no_op() {
536 let group = SortableGroupId::new(1);
537 let mut items = vec!["a", "b", "c"];
538 let original = items.clone();
539
540 assert!(!apply_reorder(
541 &mut items,
542 &ReorderEvent::new("b", Some("b"), group, group, Placement::After),
543 ));
544 assert_eq!(items, original);
545 }
546
547 #[test]
548 fn projection_uses_measured_slots_for_variable_rows() {
549 let items = vec![1, 2, 3];
550 let rects = HashMap::from([
551 (1, Rect::new(0.0, 0.0, 100.0, 20.0)),
552 (2, Rect::new(0.0, 24.0, 100.0, 60.0)),
553 (3, Rect::new(0.0, 88.0, 100.0, 30.0)),
554 ]);
555 let projected = project_layout(
556 &items,
557 &rects,
558 &1,
559 &DropPlacement {
560 over: Some(3),
561 placement: Placement::After,
562 },
563 );
564 assert_eq!(
565 projected[0],
566 ItemTransform {
567 id: 2,
568 x: 0.0,
569 y: -24.0
570 }
571 );
572 assert_eq!(
573 projected[2],
574 ItemTransform {
575 id: 1,
576 x: 0.0,
577 y: 88.0
578 }
579 );
580 }
581
582 #[test]
583 fn automatic_and_explicit_group_ids_are_disjoint() {
584 let explicit = SortableGroupId::new(1);
585 let automatic = SortableGroupId::auto();
586 assert_ne!(explicit, automatic);
587 assert!(automatic.get() >= AUTO_GROUP_BASE);
588 }
589
590 #[test]
591 fn keyboard_placement_uses_pickup_and_target_positions() {
592 let group = SortableGroupId::new(1);
593 assert_eq!(
594 item_drop_placement(
595 SortStrategy::LinearVertical,
596 DragMode::Keyboard,
597 group,
598 group,
599 3,
600 0,
601 None,
602 ),
603 Placement::Before
604 );
605 assert_eq!(
606 item_drop_placement(
607 SortStrategy::LinearVertical,
608 DragMode::Keyboard,
609 group,
610 group,
611 0,
612 3,
613 None,
614 ),
615 Placement::After
616 );
617 assert_eq!(
618 item_drop_placement(
619 SortStrategy::LinearVertical,
620 DragMode::Keyboard,
621 group,
622 SortableGroupId::new(2),
623 0,
624 0,
625 None,
626 ),
627 Placement::Before
628 );
629 }
630
631 #[test]
632 fn group_background_appends() {
633 let group = SortableGroupId::new(1);
634 let mut items = vec!["a", "b", "c"];
635 assert!(apply_reorder(
636 &mut items,
637 &ReorderEvent::new("a", None, group, group, Placement::After),
638 ));
639 assert_eq!(items, ["b", "c", "a"]);
640 }
641}