Skip to main content

dioxus_dnd/
test.rs

1#![doc = include_str!("../docs/api/testing.md")]
2
3use std::any::{Any, TypeId};
4use std::cell::RefCell;
5use std::collections::HashMap;
6
7use dioxus::prelude::*;
8
9use crate::core::components::{deliver_drop, DropCompletion, SettleRoute};
10use crate::core::hooks::SettleFlag;
11use crate::core::world::{JoinedWindow, WorldHit, WorldMembership};
12use crate::core::{
13    use_dnd, use_zone_registry, DndContext, DropEffect, Point, Rect, WindowKey, ZoneId,
14    ZoneRegistry,
15};
16
17thread_local! {
18    /// Handles captured by [`DragSimProbe`], keyed by payload type. One
19    /// slot per type per thread: the most recently mounted probe wins,
20    /// which is exactly right for one `VirtualDom` per test.
21    static SIMS: RefCell<HashMap<TypeId, Box<dyn Any>>> = RefCell::new(HashMap::new());
22}
23
24/// Captures a [`DragSim`] for the enclosing provider. Mount one inside the
25/// `DndProvider<T>` of your *test* app (it renders nothing), then retrieve
26/// the handle with [`drag_sim`] after `rebuild_in_place`.
27#[component]
28pub fn DragSimProbe<T: Clone + PartialEq + 'static>(
29    /// Internal marker; never set this.
30    #[props(default)]
31    phantom: std::marker::PhantomData<T>,
32) -> Element {
33    let _ = phantom;
34    let completions = use_signal(Vec::<bool>::new);
35    let completion = use_callback(move |dropped| {
36        let mut completions = completions;
37        completions.write().push(dropped);
38    });
39    let sim = DragSim {
40        dnd: use_dnd::<T>(),
41        registry: use_zone_registry::<T>(),
42        settle: try_use_context::<SettleFlag<T>>(),
43        membership: try_use_context::<WorldMembership<T>>().and_then(|m| m.0),
44        completion,
45        completions,
46    };
47    use_hook(move || {
48        SIMS.with_borrow_mut(|m| {
49            m.insert(TypeId::of::<T>(), Box::new(sim));
50        });
51    });
52    rsx! {}
53}
54
55/// The handle the most recent [`DragSimProbe<T>`] captured.
56///
57/// # Panics
58/// Panics when no probe for `T` has mounted - add `DragSimProbe::<T> {}`
59/// inside the provider and `rebuild_in_place` first.
60pub fn drag_sim<T: Clone + PartialEq + 'static>() -> DragSim<T> {
61    SIMS.with_borrow(|m| {
62        m.get(&TypeId::of::<T>())
63            .and_then(|b| b.downcast_ref::<DragSim<T>>())
64            .copied()
65    })
66    .expect("no DragSim captured: mount DragSimProbe::<T> inside the provider and rebuild first")
67}
68
69/// Headless driver for one provider's drag world. Every method takes the
70/// `VirtualDom` so the underlying signal operations run inside its runtime;
71/// call [`rerender`] between actions and markup assertions.
72pub struct DragSim<T: Clone + 'static> {
73    dnd: DndContext<T>,
74    registry: ZoneRegistry<T>,
75    settle: Option<SettleFlag<T>>,
76    /// The provider's world membership, when it joined a `DndWorld` -
77    /// moves and releases then resolve across windows, like the gesture.
78    membership: Option<JoinedWindow<T>>,
79    completion: Callback<bool>,
80    completions: Signal<Vec<bool>>,
81}
82
83impl<T: Clone + 'static> Copy for DragSim<T> {}
84impl<T: Clone + 'static> Clone for DragSim<T> {
85    fn clone(&self) -> Self {
86        *self
87    }
88}
89
90impl<T: Clone + PartialEq + 'static> DragSim<T> {
91    /// Give a zone its client rect - the headless stand-in for layout.
92    ///
93    /// # Panics
94    /// Panics when no zone with this id is registered.
95    pub fn place(&self, dom: &VirtualDom, zone: ZoneId, rect: Rect) {
96        dom.in_runtime(|| {
97            assert!(
98                self.registry.contains(zone),
99                "place: no zone {} registered",
100                zone.0
101            );
102            let mut registry = self.registry;
103            registry.set_rect(zone, rect);
104        });
105    }
106
107    /// The key this sim's provider joined its world under, when it did.
108    pub fn window_key(&self) -> Option<WindowKey> {
109        self.membership.map(|j| j.key)
110    }
111
112    /// [`Self::place`] for a zone living in another joined window's
113    /// registry - `rect` is in **that window's** client px.
114    ///
115    /// # Panics
116    /// Panics when this sim's provider joined no world, the window is
117    /// unknown, or the zone isn't registered there.
118    pub fn place_in(&self, dom: &VirtualDom, window: WindowKey, zone: ZoneId, rect: Rect) {
119        let world = self
120            .membership
121            .expect("place_in: this provider joined no DndWorld")
122            .world;
123        dom.in_runtime(|| {
124            let rec = world
125                .record(window)
126                .unwrap_or_else(|| panic!("place_in: no window {} joined", window.0));
127            assert!(
128                rec.registry.contains(zone),
129                "place_in: no zone {} in window {}",
130                zone.0,
131                window.0
132            );
133            let mut registry = rec.registry;
134            registry.set_rect(zone, rect);
135        });
136    }
137
138    /// Begin a pointer drag carrying `payload`, from no particular zone.
139    pub fn pick_up(&mut self, dom: &VirtualDom, payload: T) {
140        self.pick_up_from(dom, payload, None);
141    }
142
143    /// Begin a pointer drag, reporting `from` as the source zone
144    /// (arrives in `DropOutcome::from`).
145    pub fn pick_up_from(&mut self, dom: &VirtualDom, payload: T, from: Option<ZoneId>) {
146        let mut dnd = self.dnd;
147        let membership = self.membership;
148        dom.in_runtime(|| {
149            dnd.start_tracked(
150                payload,
151                from,
152                Point::default(),
153                Point::default(),
154                DropEffect::Move,
155                self.completion,
156            );
157            // Like the gesture: a world drag anchors to this window.
158            if let Some(j) = membership {
159                j.world.begin_from(j.key);
160            }
161        });
162    }
163
164    /// Move the pointer: updates the tracked position and enters/leaves
165    /// zones by hit-testing the placed rects - the same logic the pointer
166    /// gesture runs per `pointermove`.
167    pub fn move_to(&mut self, dom: &VirtualDom, point: Point) {
168        let mut dnd = self.dnd;
169        let registry = self.registry;
170        let membership = self.membership;
171        dom.in_runtime(|| {
172            dnd.update_pointer(point);
173            // Same resolution order as the gesture: world hits (any
174            // window) are authoritative, unresolved points fall back to
175            // the local registry.
176            match membership {
177                Some(joined) => match joined.zone_under(point) {
178                    WorldHit::Zone(location) => joined.enter(location),
179                    WorldHit::Window => joined.clear_hover(),
180                    WorldHit::Unresolved => match registry.hit_test(point) {
181                        Some(zone) => joined.enter(joined.location(zone)),
182                        None => joined.clear_hover(),
183                    },
184                },
185                None => match registry.hit_test(point) {
186                    Some(zone) => dnd.enter(zone),
187                    None => {
188                        if let Some(over) = dnd.over() {
189                            dnd.leave(over);
190                        }
191                    }
192                },
193            }
194        });
195    }
196
197    /// Release at the current pointer position. Returns the zone that
198    /// received the drop, or `None` when the drag cancelled (no acceptable
199    /// zone under the pointer, and none with an edge within the 48px
200    /// snap).
201    pub fn release(&mut self, dom: &VirtualDom) -> Option<ZoneId> {
202        self.release_as(dom, DropEffect::Move)
203    }
204
205    /// [`Self::release`] with an explicit effect - simulate the Ctrl-held
206    /// copy drop with `DropEffect::Copy`.
207    pub fn release_as(&mut self, dom: &VirtualDom, effect: DropEffect) -> Option<ZoneId> {
208        let mut dnd = self.dnd;
209        let registry = self.registry;
210        let settle = self.settle;
211        let membership = self.membership;
212        dom.in_runtime(|| {
213            let point = dnd.pointer();
214            let session = dnd.active_session();
215            // A release the world resolves into a foreign window delivers
216            // there, mirroring the gesture (the snap runs in the target
217            // window's own CSS px). Headless rects are placed, so the
218            // gesture's pre-snap re-measure is skipped as documented.
219            if let Some(j) = membership {
220                let _ = j.zone_under(point);
221                if let Some((rec, local)) = j.foreign_window_under(point) {
222                    let target = rec.registry.hit_test(local).or_else(|| {
223                        dnd.payload()
224                            .and_then(|p| rec.registry.hit_test_closest(local, &p, 48.0))
225                    });
226                    let delivered = target
227                        .filter(|t| {
228                            deliver_drop(
229                                rec.registry,
230                                &mut dnd,
231                                SettleRoute {
232                                    flag: Some(rec.settle),
233                                    owner: Some((&j.world, rec.key)),
234                                },
235                                DropCompletion::World {
236                                    world: &j.world,
237                                    session,
238                                },
239                                *t,
240                                local,
241                                effect,
242                            )
243                        })
244                        .is_some();
245                    if !delivered {
246                        match session {
247                            Some(session) => {
248                                j.world.finish_session(session, false);
249                            }
250                            None => j.world.finish_untracked(false),
251                        }
252                        return None;
253                    }
254                    return target;
255                }
256            }
257            let target = registry.hit_test(point).or_else(|| {
258                dnd.payload()
259                    .and_then(|p| registry.hit_test_closest(point, &p, 48.0))
260            });
261            let delivered = target
262                .filter(|t| match membership {
263                    Some(j) => deliver_drop(
264                        registry,
265                        &mut dnd,
266                        SettleRoute {
267                            flag: settle,
268                            owner: Some((&j.world, j.key)),
269                        },
270                        DropCompletion::World {
271                            world: &j.world,
272                            session,
273                        },
274                        *t,
275                        point,
276                        effect,
277                    ),
278                    None => deliver_drop(
279                        registry,
280                        &mut dnd,
281                        SettleRoute {
282                            flag: settle,
283                            owner: None,
284                        },
285                        match session {
286                            Some(session) => DropCompletion::Local(session),
287                            None => DropCompletion::None,
288                        },
289                        *t,
290                        point,
291                        effect,
292                    ),
293                })
294                .is_some();
295            if !delivered {
296                match membership {
297                    Some(j) => match session {
298                        Some(session) => {
299                            j.world.finish_session(session, false);
300                        }
301                        None => j.world.finish_untracked(false),
302                    },
303                    None => match session {
304                        Some(session) => {
305                            dnd.cancel_session(session);
306                        }
307                        None => dnd.cancel(),
308                    },
309                }
310                return None;
311            }
312            target
313        })
314    }
315
316    /// Abort the drag, as Escape or a pointer cancel would.
317    pub fn cancel(&mut self, dom: &VirtualDom) {
318        let mut dnd = self.dnd;
319        let membership = self.membership;
320        dom.in_runtime(|| {
321            let session = dnd.active_session();
322            match membership {
323                Some(j) => match session {
324                    Some(session) => {
325                        j.world.finish_session(session, false);
326                    }
327                    None => j.world.finish_untracked(false),
328                },
329                None => match session {
330                    Some(session) => {
331                        dnd.cancel_session(session);
332                    }
333                    None => dnd.cancel(),
334                },
335            }
336        });
337    }
338
339    /// Exactly-once source completion results observed by the simulated
340    /// source (`true` for delivered, `false` for cancelled).
341    pub fn completions(&self, dom: &VirtualDom) -> Vec<bool> {
342        dom.in_runtime(|| self.completions.read().clone())
343    }
344
345    /// The zone currently hovered.
346    pub fn over(&self, dom: &VirtualDom) -> Option<ZoneId> {
347        dom.in_runtime(|| self.dnd.over())
348    }
349
350    /// Is a drag in flight?
351    pub fn dragging(&self, dom: &VirtualDom) -> bool {
352        dom.in_runtime(|| self.dnd.dragging())
353    }
354
355    /// The in-flight payload, if any.
356    pub fn payload(&self, dom: &VirtualDom) -> Option<T> {
357        dom.in_runtime(|| self.dnd.payload())
358    }
359
360    /// The latest screen-reader announcement.
361    pub fn announcement(&self, dom: &VirtualDom) -> String {
362        dom.in_runtime(|| self.dnd.announcement())
363    }
364}
365
366/// Flush pending reactivity so the tree reflects the simulated state -
367/// call between driver actions and markup assertions
368/// (`dioxus_ssr::render`).
369pub fn rerender(dom: &mut VirtualDom) {
370    dom.process_events();
371    dom.render_immediate(&mut dioxus::core::NoOpMutations);
372}
373
374/// One whole pointer drag: pick `payload` up (from `from`), glide through
375/// `path`, release at its last point, re-rendering between steps so zone
376/// reactions run just as they would live. Returns the receiving zone, or
377/// `None` when the drag cancelled. Needs a mounted [`DragSimProbe<T>`];
378/// an empty `path` releases at the pickup point.
379pub fn simulate_drag<T: Clone + PartialEq + 'static>(
380    dom: &mut VirtualDom,
381    payload: T,
382    from: Option<ZoneId>,
383    path: &[Point],
384) -> Option<ZoneId> {
385    let mut sim = drag_sim::<T>();
386    sim.pick_up_from(dom, payload, from);
387    rerender(dom);
388    for p in path {
389        sim.move_to(dom, *p);
390        rerender(dom);
391    }
392    let delivered = sim.release(dom);
393    rerender(dom);
394    delivered
395}