Skip to main content

dioxus_dnd/core/
model.rs

1#![doc = include_str!("../../docs/api/drop-effects.md")]
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::fmt;
6use std::mem::ManuallyDrop;
7use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
8use std::rc::Rc;
9
10use dioxus::prelude::{provide_context, use_hook};
11use dioxus::signals::{AnyStorage, Owner, SyncStorage, UnsyncStorage};
12
13use super::{DropEffect, DropOutcome, ZoneId};
14
15/// A model helper refused to guess semantics for an unsupported effect.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum ApplyDropError {
19    UnsupportedEffect(DropEffect),
20}
21
22impl fmt::Display for ApplyDropError {
23    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::UnsupportedEffect(effect) => {
26                write!(formatter, "unsupported drop effect `{}`", effect.as_str())
27            }
28        }
29    }
30}
31
32impl std::error::Error for ApplyDropError {}
33
34thread_local! {
35    /// Owner pairs backing models created by [`use_dnd_model`]. App-wide
36    /// models deliberately outlive every window: a copyable signal/store
37    /// handle must never dangle because the window that created it closed.
38    /// Bounded in normal use to one scope per app-wide model. `ManuallyDrop`
39    /// is load-bearing: ordinary thread-local values are destroyed when their
40    /// creator thread exits, which would make a transferred `SyncSignal`
41    /// dangle before process exit.
42    static MODEL_OWNERS: RefCell<Vec<ManuallyDrop<DndScope>>> = const { RefCell::new(Vec::new()) };
43}
44
45/// An explicit lifetime for Dioxus signals and stores created outside a
46/// component scope.
47///
48/// A scope owns both storage flavors Dioxus state may allocate: ordinary
49/// signals use [`UnsyncStorage`], while a [`Store`](struct@dioxus::prelude::Store)
50/// keeps its subscription tree in [`SyncStorage`]. Create state inside
51/// [`with`](Self::with), then retain a clone of the scope for exactly as long
52/// as that state may be used. Storage is reclaimed when the last clone drops.
53///
54/// Use [`use_dnd_model`] instead for an app-wide model shared by windows. A
55/// `DndScope` is intended for dynamic state whose lifetime really should end,
56/// such as the contents owned by one spawned window.
57///
58/// `with` must run inside a Dioxus runtime, like the state constructors it
59/// contains.
60///
61/// Do not drop the last scope clone while any owned read or write guard is
62/// live. Unsynchronized storage cannot be recycled through an active
63/// `RefCell` borrow and synchronized storage must wait for its lock guard.
64///
65/// ```no_run
66/// use dioxus::prelude::*;
67/// use dioxus_dnd::prelude::DndScope;
68///
69/// fn app() -> Element {
70///     let scope = use_hook(DndScope::new);
71///     let count = use_hook(|| scope.with(|| Signal::new(0)));
72///     rsx! { "{count}" }
73/// }
74/// ```
75#[must_use = "keep a DndScope alive while using state created under it"]
76#[derive(Clone)]
77pub struct DndScope {
78    owners: Rc<DndScopeOwners>,
79}
80
81struct DndScopeOwners {
82    unsync: Owner<UnsyncStorage>,
83    sync: Owner<SyncStorage>,
84}
85
86impl DndScope {
87    /// Create an empty scope. Mint every signal or store it owns with
88    /// [`Self::with`].
89    pub fn new() -> Self {
90        Self {
91            owners: Rc::new(DndScopeOwners {
92                unsync: UnsyncStorage::owner(),
93                sync: SyncStorage::owner(),
94            }),
95        }
96    }
97
98    /// Run `init` with this scope as the current owner for both Dioxus
99    /// storage flavors.
100    ///
101    /// Owner restoration is unwind-safe: a panic from `init` is resumed only
102    /// after both Dioxus owner overrides have returned normally and restored
103    /// their previous values.
104    pub fn with<R>(&self, init: impl FnOnce() -> R) -> R {
105        let result = dioxus::core::with_owner(self.owners.unsync.clone(), || {
106            dioxus::core::with_owner(self.owners.sync.clone(), || {
107                catch_unwind(AssertUnwindSafe(init))
108            })
109        });
110        match result {
111            Ok(value) => value,
112            Err(panic) => resume_unwind(panic),
113        }
114    }
115}
116
117impl Default for DndScope {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123/// Create and provide an app-wide model whose Dioxus state survives every
124/// window close order.
125///
126/// `init` runs once for this component instance under a paired, process-lived
127/// [`DndScope`]. The returned model is also provided in context. Seed spawned
128/// windows with the model by chaining `with_root_context(model)` after
129/// [`DndWorld::vdom`](crate::core::DndWorld::vdom).
130/// The process lifetime is deliberate: copyable signal and store handles do
131/// not carry an ownership guard, so tying storage to a particular window (or
132/// to an `Rc` callers must remember to propagate) can leave a survivor holding
133/// a dangling handle.
134///
135/// Every signal, store, or other owner-backed value that needs this lifetime
136/// must be allocated synchronously inside `init`. Wrapping a handle created
137/// earlier does not reparent it, and an allocation performed later uses
138/// whichever owner is current then. For later app-lived allocations, mint
139/// them under a new [`DndScope`] and retain that scope in process-lived model
140/// state.
141///
142/// Call this once for each app-wide model. Use [`DndScope`] for dynamic state
143/// that should be reclaimed before process exit.
144///
145/// # Allocation boundary
146///
147/// The following compiles, but does **not** give `cards` process lifetime:
148/// it was already owned by the component before `use_dnd_model` ran.
149///
150/// ```no_run
151/// use dioxus::prelude::*;
152/// use dioxus_dnd::prelude::use_dnd_model;
153///
154/// #[derive(Clone, Copy)]
155/// struct Model {
156///     cards: Signal<Vec<String>>,
157/// }
158///
159/// fn app() -> Element {
160///     let cards = use_signal(Vec::<String>::new);
161///     let _model = use_dnd_model(|| Model { cards }); // not reparented
162///     rsx! {}
163/// }
164/// ```
165///
166/// Allocate the signal inside the initializer instead:
167///
168/// ```
169/// use dioxus::prelude::*;
170/// use dioxus_dnd::prelude::use_dnd_model;
171///
172/// #[derive(Clone, Copy)]
173/// struct Model {
174///     cards: Signal<Vec<String>>,
175/// }
176///
177/// fn app() -> Element {
178///     let model = use_dnd_model(|| Model {
179///         cards: Signal::new(Vec::new()),
180///     });
181///     rsx! { "{model.cards.read().len()} cards" }
182/// }
183/// ```
184pub fn use_dnd_model<M: Clone + 'static>(init: impl FnOnce() -> M) -> M {
185    use_hook(move || {
186        let scope = DndScope::new();
187        let model = scope.with(init);
188        MODEL_OWNERS.with_borrow_mut(|owners| owners.push(ManuallyDrop::new(scope)));
189        provide_context(model)
190    })
191}
192
193/// Apply a drop to a `HashMap<ZoneId, Vec<T>>` model.
194///
195/// `Move` removes the matching item from `outcome.from` before appending it
196/// to `outcome.to`. `Copy` leaves the source alone and passes the payload
197/// through `clone_item` first, which is where you should assign a fresh id.
198///
199/// Semantics worth knowing:
200///
201/// - Removal matches **every** item in the source whose key equals the
202///   payload's key. Keys are expected to be unique within a zone; if they
203///   are not, a single `Move` prunes all of them.
204/// - A `Move` where `from == Some(to)` removes and re-appends, so dropping
205///   an item back onto its own zone sends it to the **end of that list**.
206/// - A `Move` with `from: None` (payload from outside any zone, e.g. a
207///   palette) skips removal and just appends.
208/// - An unknown `to` zone is created on the fly rather than dropping the
209///   item on the floor.
210/// - For backwards compatibility, every non-`Copy` effect follows the legacy
211///   move path. Use [`try_apply_clone_or_move`] when unsupported effects must
212///   be rejected explicitly.
213pub fn apply_clone_or_move<T, K>(
214    zones: &mut HashMap<ZoneId, Vec<T>>,
215    outcome: DropOutcome<T>,
216    key: impl Fn(&T) -> K,
217    clone_item: impl FnMut(T) -> T,
218) where
219    K: PartialEq,
220{
221    let result = apply_clone_or_move_impl(zones, outcome, key, clone_item, false);
222    debug_assert!(
223        result.is_ok(),
224        "legacy model helper cannot reject an effect"
225    );
226}
227
228/// Checked form of [`apply_clone_or_move`].
229///
230/// `Move` and `Copy` use the same behavior as the compatibility helper.
231/// `Link` and `None` return [`ApplyDropError::UnsupportedEffect`] without
232/// mutating the model.
233pub fn try_apply_clone_or_move<T, K>(
234    zones: &mut HashMap<ZoneId, Vec<T>>,
235    outcome: DropOutcome<T>,
236    key: impl Fn(&T) -> K,
237    clone_item: impl FnMut(T) -> T,
238) -> Result<(), ApplyDropError>
239where
240    K: PartialEq,
241{
242    apply_clone_or_move_impl(zones, outcome, key, clone_item, true)
243}
244
245fn apply_clone_or_move_impl<T, K>(
246    zones: &mut HashMap<ZoneId, Vec<T>>,
247    outcome: DropOutcome<T>,
248    key: impl Fn(&T) -> K,
249    mut clone_item: impl FnMut(T) -> T,
250    checked: bool,
251) -> Result<(), ApplyDropError>
252where
253    K: PartialEq,
254{
255    let DropOutcome {
256        payload,
257        from,
258        to,
259        effect,
260        ..
261    } = outcome;
262    let item = match effect {
263        DropEffect::Copy => clone_item(payload),
264        DropEffect::Move => {
265            if let Some(from) = from {
266                let payload_key = key(&payload);
267                if let Some(source) = zones.get_mut(&from) {
268                    source.retain(|item| key(item) != payload_key);
269                }
270            }
271            payload
272        }
273        unsupported if checked => return Err(ApplyDropError::UnsupportedEffect(unsupported)),
274        _ => {
275            if let Some(from) = from {
276                let payload_key = key(&payload);
277                if let Some(source) = zones.get_mut(&from) {
278                    source.retain(|item| key(item) != payload_key);
279                }
280            }
281            payload
282        }
283    };
284
285    zones.entry(to).or_default().push(item);
286    Ok(())
287}
288
289/// Apply a drop between two plain `Vec<T>` lists.
290///
291/// `Move` removes the matching item from `source` before appending it to
292/// `target`. `Copy` leaves the source alone and passes the payload through
293/// `clone_item` first, which is where you should assign a fresh id.
294///
295/// You choose which lists to pass, so the outcome's `from` and `to` fields
296/// are **ignored** here; only `payload` and `effect` are consulted. Pass
297/// `None` for `source` when the payload came from outside any list. As with
298/// [`apply_clone_or_move`], removal matches every item whose key equals the
299/// payload's key. For backwards compatibility, every non-`Copy` effect uses
300/// the legacy move path. Use [`try_apply_list_clone_or_move`] to reject
301/// unsupported effects explicitly.
302pub fn apply_list_clone_or_move<T, K>(
303    source: Option<&mut Vec<T>>,
304    target: &mut Vec<T>,
305    outcome: DropOutcome<T>,
306    key: impl Fn(&T) -> K,
307    clone_item: impl FnMut(T) -> T,
308) where
309    K: PartialEq,
310{
311    let result = apply_list_clone_or_move_impl(source, target, outcome, key, clone_item, false);
312    debug_assert!(
313        result.is_ok(),
314        "legacy model helper cannot reject an effect"
315    );
316}
317
318/// Checked form of [`apply_list_clone_or_move`].
319///
320/// `Move` and `Copy` use the same behavior as the compatibility helper.
321/// `Link` and `None` return [`ApplyDropError::UnsupportedEffect`] without
322/// mutating either list.
323pub fn try_apply_list_clone_or_move<T, K>(
324    source: Option<&mut Vec<T>>,
325    target: &mut Vec<T>,
326    outcome: DropOutcome<T>,
327    key: impl Fn(&T) -> K,
328    clone_item: impl FnMut(T) -> T,
329) -> Result<(), ApplyDropError>
330where
331    K: PartialEq,
332{
333    apply_list_clone_or_move_impl(source, target, outcome, key, clone_item, true)
334}
335
336fn apply_list_clone_or_move_impl<T, K>(
337    source: Option<&mut Vec<T>>,
338    target: &mut Vec<T>,
339    outcome: DropOutcome<T>,
340    key: impl Fn(&T) -> K,
341    mut clone_item: impl FnMut(T) -> T,
342    checked: bool,
343) -> Result<(), ApplyDropError>
344where
345    K: PartialEq,
346{
347    let DropOutcome {
348        payload, effect, ..
349    } = outcome;
350    let item = match effect {
351        DropEffect::Copy => clone_item(payload),
352        DropEffect::Move => {
353            if let Some(source) = source {
354                let payload_key = key(&payload);
355                source.retain(|item| key(item) != payload_key);
356            }
357            payload
358        }
359        unsupported if checked => return Err(ApplyDropError::UnsupportedEffect(unsupported)),
360        _ => {
361            if let Some(source) = source {
362                let payload_key = key(&payload);
363                source.retain(|item| key(item) != payload_key);
364            }
365            payload
366        }
367    };
368
369    target.push(item);
370    Ok(())
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::core::{DragMode, Point};
377    use dioxus::prelude::*;
378    use dioxus::signals::SyncSignal;
379    use std::cell::Cell;
380    use std::sync::mpsc::Sender;
381
382    #[derive(Debug, Clone, PartialEq)]
383    struct Card {
384        id: u32,
385        title: &'static str,
386    }
387
388    #[derive(Clone, Copy, PartialEq)]
389    struct SignalModel {
390        value: Signal<i32>,
391    }
392
393    type SignalModelSlot = Rc<RefCell<Option<SignalModel>>>;
394
395    #[derive(Store, Clone, PartialEq)]
396    struct StoreState {
397        value: i32,
398    }
399
400    #[derive(Clone, Copy, PartialEq)]
401    struct StoreModel {
402        state: Store<StoreState>,
403    }
404
405    type StoreModelSlot = Rc<RefCell<Option<StoreModel>>>;
406
407    type ScopeSlot = Rc<RefCell<Option<(DndScope, Signal<i32>, Store<i32>, SyncSignal<i32>)>>>;
408
409    type PanicScopeSlot = Rc<RefCell<Option<(DndScope, DndScope, Signal<i32>)>>>;
410
411    type ScopePairSlot = Rc<RefCell<Option<(DndScope, Signal<i32>, DndScope, Signal<i32>)>>>;
412
413    #[derive(Clone, Copy, PartialEq)]
414    struct ThreadModel {
415        value: SyncSignal<i32>,
416    }
417
418    #[derive(Default)]
419    struct SurvivorProbe {
420        renders: Cell<usize>,
421        value: Cell<i32>,
422    }
423
424    fn signal_model_creator() -> Element {
425        let slot = use_context::<SignalModelSlot>();
426        let model = use_dnd_model(|| SignalModel {
427            value: Signal::new(0),
428        });
429        *slot.borrow_mut() = Some(model);
430        rsx! {}
431    }
432
433    fn signal_model_survivor() -> Element {
434        let model = use_context::<SignalModel>();
435        let probe = use_context::<Rc<SurvivorProbe>>();
436        let value = *model.value.read();
437        probe.value.set(value);
438        probe.renders.set(probe.renders.get() + 1);
439        rsx! { "{value}" }
440    }
441
442    fn store_model_creator() -> Element {
443        let slot = use_context::<StoreModelSlot>();
444        let model = use_dnd_model(|| StoreModel {
445            state: Store::new(StoreState { value: 0 }),
446        });
447        *slot.borrow_mut() = Some(model);
448        rsx! {}
449    }
450
451    fn store_model_survivor() -> Element {
452        let model = use_context::<StoreModel>();
453        let probe = use_context::<Rc<SurvivorProbe>>();
454        let value = *model.state.value().read();
455        probe.value.set(value);
456        probe.renders.set(probe.renders.get() + 1);
457        rsx! { "{value}" }
458    }
459
460    fn scoped_state_creator() -> Element {
461        let slot = use_context::<ScopeSlot>();
462        let state = use_hook(|| {
463            let scope = DndScope::new();
464            let (signal, store, sync_signal) =
465                scope.with(|| (Signal::new(1), Store::new(2), SyncSignal::new_maybe_sync(3)));
466            (scope, signal, store, sync_signal)
467        });
468        *slot.borrow_mut() = Some(state);
469        rsx! {}
470    }
471
472    fn panic_scope_creator() -> Element {
473        let slot = use_context::<PanicScopeSlot>();
474        let state = use_hook(|| {
475            let outer = DndScope::new();
476            let inner = DndScope::new();
477            let signal = outer.with(|| {
478                let panic = catch_unwind(AssertUnwindSafe(|| {
479                    inner.with(|| panic!("expected owner-restoration probe"));
480                }));
481                assert!(panic.is_err());
482                Signal::new(7)
483            });
484            (outer, inner, signal)
485        });
486        *slot.borrow_mut() = Some(state);
487        rsx! {}
488    }
489
490    fn scope_pair_creator() -> Element {
491        let slot = use_context::<ScopePairSlot>();
492        let state = use_hook(|| {
493            let first = DndScope::new();
494            let first_signal = first.with(|| Signal::new(1));
495            let second = DndScope::new();
496            let second_signal = second.with(|| Signal::new(2));
497            (first, first_signal, second, second_signal)
498        });
499        *slot.borrow_mut() = Some(state);
500        rsx! {}
501    }
502
503    fn scoped_survivor() -> Element {
504        let value = *use_context::<Signal<i32>>().read();
505        let probe = use_context::<Rc<SurvivorProbe>>();
506        probe.value.set(value);
507        probe.renders.set(probe.renders.get() + 1);
508        rsx! { "{value}" }
509    }
510
511    fn thread_model_creator() -> Element {
512        let sender = use_context::<Sender<ThreadModel>>();
513        let model = use_dnd_model(|| ThreadModel {
514            value: SyncSignal::new_maybe_sync(21),
515        });
516        sender.send(model).expect("receiver remains alive");
517        rsx! {}
518    }
519
520    #[test]
521    fn dnd_scope_reclaims_state_only_after_its_last_clone_drops() {
522        let slot = ScopeSlot::default();
523        let mut creator = VirtualDom::new(scoped_state_creator).with_root_context(slot.clone());
524        creator.rebuild_in_place();
525        let (scope, signal, mut store, sync_signal) = slot
526            .borrow_mut()
527            .take()
528            .expect("creator provided its scoped state");
529
530        // The hook-owned clone drops with the creator; this retained clone is
531        // now the sole lifetime guard.
532        drop(creator);
533        store.set(3);
534        assert_eq!(*signal.peek(), 1);
535        assert_eq!(*store.peek(), 3);
536
537        drop(scope);
538        assert!(signal.try_read().is_err());
539        assert!(sync_signal.try_read().is_err());
540    }
541
542    #[test]
543    fn dnd_scope_restores_outer_owners_before_resuming_a_panic() {
544        let slot = PanicScopeSlot::default();
545        let mut creator = VirtualDom::new(panic_scope_creator).with_root_context(slot.clone());
546        creator.rebuild_in_place();
547        let (outer, inner, signal) = slot
548            .borrow_mut()
549            .take()
550            .expect("creator provided its scopes");
551
552        drop(creator);
553        drop(inner);
554        assert_eq!(*signal.peek(), 7, "signal must remain owned by outer");
555        drop(outer);
556        assert!(signal.try_read().is_err());
557    }
558
559    #[test]
560    fn retiring_one_dynamic_scope_does_not_break_a_surviving_sibling() {
561        let slot = ScopePairSlot::default();
562        let mut creator = VirtualDom::new(scope_pair_creator).with_root_context(slot.clone());
563        creator.rebuild_in_place();
564        let (first, first_signal, second, second_signal) = slot
565            .borrow_mut()
566            .take()
567            .expect("creator provided both scopes");
568        let probe = Rc::new(SurvivorProbe::default());
569        let mut survivor = VirtualDom::new(scoped_survivor)
570            .with_root_context(second_signal)
571            .with_root_context(probe.clone());
572        survivor.rebuild_in_place();
573        let renders_before_close = probe.renders.get();
574
575        drop(creator);
576        drop(first);
577        assert!(first_signal.try_read().is_err());
578        survivor.in_runtime(|| {
579            let mut value = second_signal;
580            value.set(9);
581        });
582        survivor.render_immediate(&mut dioxus::core::NoOpMutations);
583        assert_eq!(probe.value.get(), 9);
584        assert!(probe.renders.get() > renders_before_close);
585
586        drop(survivor);
587        drop(second);
588        assert!(second_signal.try_read().is_err());
589    }
590
591    #[test]
592    fn model_sync_storage_survives_its_creator_thread() {
593        let (sender, receiver) = std::sync::mpsc::channel::<ThreadModel>();
594        std::thread::spawn(move || {
595            let mut creator =
596                VirtualDom::new(thread_model_creator).with_root_context(sender.clone());
597            creator.rebuild_in_place();
598        })
599        .join()
600        .expect("creator thread completed");
601
602        let model = receiver.recv().expect("creator published its model");
603        assert_eq!(*model.value.peek(), 21);
604        let mut value = model.value;
605        value.set(22);
606        assert_eq!(*model.value.peek(), 22);
607    }
608
609    #[test]
610    fn model_survives_its_creator_window() {
611        let slot = SignalModelSlot::default();
612        let mut creator = VirtualDom::new(signal_model_creator).with_root_context(slot.clone());
613        creator.rebuild_in_place();
614        let model = slot
615            .borrow_mut()
616            .take()
617            .expect("creator provided its model");
618        let probe = Rc::new(SurvivorProbe::default());
619        let mut survivor = VirtualDom::new(signal_model_survivor)
620            .with_root_context(model)
621            .with_root_context(probe.clone());
622        survivor.rebuild_in_place();
623        let renders_before_close = probe.renders.get();
624
625        drop(creator);
626        survivor.in_runtime(|| {
627            let mut value = model.value;
628            value.set(7);
629        });
630        survivor.render_immediate(&mut dioxus::core::NoOpMutations);
631
632        assert_eq!(probe.value.get(), 7);
633        assert!(probe.renders.get() > renders_before_close);
634    }
635
636    #[test]
637    fn store_model_keeps_its_sync_subscription_storage_after_creator_close() {
638        let slot = StoreModelSlot::default();
639        let mut creator = VirtualDom::new(store_model_creator).with_root_context(slot.clone());
640        creator.rebuild_in_place();
641        let model = slot
642            .borrow_mut()
643            .take()
644            .expect("creator provided its store model");
645        let probe = Rc::new(SurvivorProbe::default());
646        let mut survivor = VirtualDom::new(store_model_survivor)
647            .with_root_context(model)
648            .with_root_context(probe.clone());
649        survivor.rebuild_in_place();
650        let renders_before_close = probe.renders.get();
651
652        drop(creator);
653        survivor.in_runtime(|| model.state.value().set(11));
654        survivor.render_immediate(&mut dioxus::core::NoOpMutations);
655
656        assert_eq!(probe.value.get(), 11);
657        assert!(probe.renders.get() > renders_before_close);
658    }
659
660    fn outcome(
661        payload: Card,
662        from: Option<ZoneId>,
663        to: ZoneId,
664        effect: DropEffect,
665    ) -> DropOutcome<Card> {
666        DropOutcome {
667            payload,
668            from,
669            to,
670            effect,
671            mode: DragMode::Pointer,
672            client: Point::default(),
673            element: Point::default(),
674            grab: Point::default(),
675            edge: None,
676        }
677    }
678
679    #[test]
680    fn move_removes_from_source_and_appends_to_target() {
681        let a = ZoneId(1);
682        let b = ZoneId(2);
683        let mut zones = HashMap::from([
684            (
685                a,
686                vec![
687                    Card {
688                        id: 1,
689                        title: "one",
690                    },
691                    Card {
692                        id: 2,
693                        title: "two",
694                    },
695                ],
696            ),
697            (
698                b,
699                vec![Card {
700                    id: 3,
701                    title: "three",
702                }],
703            ),
704        ]);
705
706        apply_clone_or_move(
707            &mut zones,
708            outcome(
709                Card {
710                    id: 2,
711                    title: "two",
712                },
713                Some(a),
714                b,
715                DropEffect::Move,
716            ),
717            |card| card.id,
718            |card| card,
719        );
720
721        assert_eq!(
722            zones[&a],
723            vec![Card {
724                id: 1,
725                title: "one"
726            }]
727        );
728        assert_eq!(
729            zones[&b],
730            vec![
731                Card {
732                    id: 3,
733                    title: "three"
734                },
735                Card {
736                    id: 2,
737                    title: "two"
738                }
739            ]
740        );
741    }
742
743    #[test]
744    fn copy_leaves_source_and_allows_new_identity() {
745        let a = ZoneId(1);
746        let b = ZoneId(2);
747        let mut zones = HashMap::from([
748            (
749                a,
750                vec![Card {
751                    id: 1,
752                    title: "one",
753                }],
754            ),
755            (b, Vec::new()),
756        ]);
757
758        apply_clone_or_move(
759            &mut zones,
760            outcome(
761                Card {
762                    id: 1,
763                    title: "one",
764                },
765                Some(a),
766                b,
767                DropEffect::Copy,
768            ),
769            |card| card.id,
770            |mut card| {
771                card.id = 10;
772                card
773            },
774        );
775
776        assert_eq!(
777            zones[&a],
778            vec![Card {
779                id: 1,
780                title: "one"
781            }]
782        );
783        assert_eq!(
784            zones[&b],
785            vec![Card {
786                id: 10,
787                title: "one"
788            }]
789        );
790    }
791
792    /// Pins the self-drop semantics documented on `apply_clone_or_move`: a
793    /// `Move` back onto the source zone reorders the item to the end.
794    #[test]
795    fn move_onto_own_zone_reorders_to_end() {
796        let a = ZoneId(1);
797        let mut zones = HashMap::from([(
798            a,
799            vec![
800                Card {
801                    id: 1,
802                    title: "one",
803                },
804                Card {
805                    id: 2,
806                    title: "two",
807                },
808            ],
809        )]);
810
811        apply_clone_or_move(
812            &mut zones,
813            outcome(
814                Card {
815                    id: 1,
816                    title: "one",
817                },
818                Some(a),
819                a,
820                DropEffect::Move,
821            ),
822            |card| card.id,
823            |card| card,
824        );
825
826        assert_eq!(
827            zones[&a],
828            vec![
829                Card {
830                    id: 2,
831                    title: "two"
832                },
833                Card {
834                    id: 1,
835                    title: "one"
836                }
837            ]
838        );
839    }
840
841    /// A payload from outside any zone (palette, external drop) has no
842    /// source to prune; `Move` just appends.
843    #[test]
844    fn move_without_source_zone_just_appends() {
845        let b = ZoneId(2);
846        let mut zones = HashMap::from([(b, Vec::new())]);
847
848        apply_clone_or_move(
849            &mut zones,
850            outcome(
851                Card {
852                    id: 7,
853                    title: "seven",
854                },
855                None,
856                b,
857                DropEffect::Move,
858            ),
859            |card| card.id,
860            |card| card,
861        );
862
863        assert_eq!(
864            zones[&b],
865            vec![Card {
866                id: 7,
867                title: "seven"
868            }]
869        );
870    }
871
872    /// An unknown target zone is created rather than losing the item.
873    #[test]
874    fn unknown_target_zone_is_created() {
875        let a = ZoneId(1);
876        let ghost = ZoneId(99);
877        let mut zones = HashMap::from([(
878            a,
879            vec![Card {
880                id: 1,
881                title: "one",
882            }],
883        )]);
884
885        apply_clone_or_move(
886            &mut zones,
887            outcome(
888                Card {
889                    id: 1,
890                    title: "one",
891                },
892                Some(a),
893                ghost,
894                DropEffect::Move,
895            ),
896            |card| card.id,
897            |card| card,
898        );
899
900        assert!(zones[&a].is_empty());
901        assert_eq!(
902            zones[&ghost],
903            vec![Card {
904                id: 1,
905                title: "one"
906            }]
907        );
908    }
909
910    #[test]
911    fn list_move_removes_from_source_and_appends_to_target() {
912        let mut source = vec![
913            Card {
914                id: 1,
915                title: "one",
916            },
917            Card {
918                id: 2,
919                title: "two",
920            },
921        ];
922        let mut target = vec![Card {
923            id: 3,
924            title: "three",
925        }];
926
927        apply_list_clone_or_move(
928            Some(&mut source),
929            &mut target,
930            outcome(
931                Card {
932                    id: 2,
933                    title: "two",
934                },
935                Some(ZoneId(1)),
936                ZoneId(2),
937                DropEffect::Move,
938            ),
939            |card| card.id,
940            |card| card,
941        );
942
943        assert_eq!(
944            source,
945            vec![Card {
946                id: 1,
947                title: "one"
948            }]
949        );
950        assert_eq!(
951            target,
952            vec![
953                Card {
954                    id: 3,
955                    title: "three"
956                },
957                Card {
958                    id: 2,
959                    title: "two"
960                }
961            ]
962        );
963    }
964
965    #[test]
966    fn list_copy_leaves_source_and_allows_new_identity() {
967        let mut source = vec![Card {
968            id: 1,
969            title: "one",
970        }];
971        let mut target = Vec::new();
972
973        apply_list_clone_or_move(
974            Some(&mut source),
975            &mut target,
976            outcome(
977                Card {
978                    id: 1,
979                    title: "one",
980                },
981                Some(ZoneId(1)),
982                ZoneId(2),
983                DropEffect::Copy,
984            ),
985            |card| card.id,
986            |mut card| {
987                card.id = 10;
988                card
989            },
990        );
991
992        assert_eq!(
993            source,
994            vec![Card {
995                id: 1,
996                title: "one"
997            }]
998        );
999        assert_eq!(
1000            target,
1001            vec![Card {
1002                id: 10,
1003                title: "one"
1004            }]
1005        );
1006    }
1007
1008    /// `Move` into a list without a source (`None`) skips removal.
1009    #[test]
1010    fn list_move_without_source_just_appends() {
1011        let mut target = Vec::new();
1012
1013        apply_list_clone_or_move(
1014            None,
1015            &mut target,
1016            outcome(
1017                Card {
1018                    id: 7,
1019                    title: "seven",
1020                },
1021                None,
1022                ZoneId(2),
1023                DropEffect::Move,
1024            ),
1025            |card| card.id,
1026            |card| card,
1027        );
1028
1029        assert_eq!(
1030            target,
1031            vec![Card {
1032                id: 7,
1033                title: "seven"
1034            }]
1035        );
1036    }
1037
1038    #[test]
1039    fn unsupported_effect_is_explicit_and_does_not_mutate() {
1040        let zone = ZoneId(1);
1041        let original = vec![Card {
1042            id: 1,
1043            title: "one",
1044        }];
1045        let mut zones = HashMap::from([(zone, original.clone())]);
1046        let result = try_apply_clone_or_move(
1047            &mut zones,
1048            outcome(original[0].clone(), Some(zone), ZoneId(2), DropEffect::Link),
1049            |card| card.id,
1050            |card| card,
1051        );
1052
1053        assert_eq!(
1054            result,
1055            Err(ApplyDropError::UnsupportedEffect(DropEffect::Link))
1056        );
1057        assert_eq!(zones, HashMap::from([(zone, original)]));
1058    }
1059
1060    #[test]
1061    fn legacy_helpers_keep_their_unit_return_contract() {
1062        let zone = ZoneId(1);
1063        let mut zones = HashMap::new();
1064        let _: () = apply_clone_or_move(
1065            &mut zones,
1066            outcome(
1067                Card {
1068                    id: 1,
1069                    title: "one",
1070                },
1071                None,
1072                zone,
1073                DropEffect::Move,
1074            ),
1075            |card| card.id,
1076            |card| card,
1077        );
1078
1079        let mut target = Vec::new();
1080        let _: () = apply_list_clone_or_move(
1081            None,
1082            &mut target,
1083            outcome(
1084                Card {
1085                    id: 2,
1086                    title: "two",
1087                },
1088                None,
1089                zone,
1090                DropEffect::Move,
1091            ),
1092            |card| card.id,
1093            |card| card,
1094        );
1095    }
1096}