Skip to main content

dioxus_dnd/
sortable_kernel.rs

1//! Stable-ID sortable primitives built on the shared drag-and-drop core.
2
3use 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    /// Construct an explicit group id from the namespace reserved for apps.
50    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    /// Item position at pickup time.
84    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    /// Target item, or `None` for the group background/append target.
162    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    /// Target item, or `None` to append after the last item.
172    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
218/// Project a stable-ID insertion by mapping each item to the measured slot
219/// it would occupy after the move. Because slots come from real rects, this
220/// does not assume a uniform row pitch.
221pub 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
269/// Apply a same-list stable-ID reorder.
270pub 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/// Shared provider for one or more sortable groups. Place sibling groups
310/// under the same provider to enable cross-group moves.
311#[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/// Provide one stable-ID sortable group and a layout boundary for its
323/// [`SortableItem`] children.
324#[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/// Render a complete stable-ID collection with one item wrapper per id.
392/// Use [`SortableGroup`] and [`SortableItem`] directly for custom layouts.
393#[component]
394pub fn SortableCollection<K: Clone + Eq + Hash + 'static>(
395    items: Vec<K>,
396    render: Callback<K, Element>,
397    /// Stable, unique Dioxus key for each semantic item id.
398    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/// One stable-ID sortable item. Use directly inside a custom group layout,
426/// or let [`SortableGroup`] create it for every item.
427#[component]
428pub fn SortableItem<K: Clone + PartialEq + 'static>(
429    id: K,
430    /// Current zero-based position in the group.
431    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
489/// Semantic alias for the general core drag handle in sortable UIs.
490pub 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}