Skip to main content

dioxus_dnd/core/
registry.rs

1//! The zone registry: every mounted [`crate::core::DropZone`] records itself
2//! here (id, label, drop callback, acceptance filter, and its mounted DOM
3//! handle). Pointer drags hit-test against cached client rects; keyboard
4//! navigation walks the zones in spatial order (top-to-bottom, left-to-right,
5//! with unmeasured zones last in registration order).
6
7use std::rc::Rc;
8
9use dioxus::html::MountedData;
10use dioxus::prelude::*;
11
12use super::types::{DropOutcome, Point, Rect, ZoneId};
13
14/// One registered drop zone.
15pub struct ZoneRecord<T: Clone + 'static> {
16    pub id: ZoneId,
17    /// The enclosing zone, when this zone is nested inside another
18    /// `DropZone` (discovered automatically via context).
19    pub parent: Option<ZoneId>,
20    /// Human label used in screen-reader announcements.
21    pub label: Option<String>,
22    /// Delivers a completed drop to the zone's owner.
23    pub on_drop: Callback<DropOutcome<T>>,
24    /// The zone's acceptance filter, if any.
25    pub accepts: Option<Callback<T, bool>>,
26    /// The zone's mounted element, once available.
27    pub mounted: Signal<Option<Rc<MountedData>>>,
28    /// Cached client rect (refreshed via [`ZoneRegistry::refresh_rects`]).
29    pub rect: Signal<Option<Rect>>,
30}
31
32impl<T: Clone + 'static> Clone for ZoneRecord<T> {
33    fn clone(&self) -> Self {
34        Self {
35            id: self.id,
36            parent: self.parent,
37            label: self.label.clone(),
38            on_drop: self.on_drop,
39            accepts: self.accepts,
40            mounted: self.mounted,
41            rect: self.rect,
42        }
43    }
44}
45
46impl<T: Clone + 'static> ZoneRecord<T> {
47    /// Does this zone accept the payload?
48    pub fn accepts_payload(&self, payload: &T) -> bool {
49        match self.accepts {
50            Some(cb) => cb.call(payload.clone()),
51            None => true,
52        }
53    }
54}
55
56/// Registry of the currently mounted drop zones, in mount order.
57pub struct ZoneRegistry<T: Clone + 'static> {
58    zones: Signal<Vec<ZoneRecord<T>>>,
59}
60
61impl<T: Clone + 'static> Copy for ZoneRegistry<T> {}
62impl<T: Clone + 'static> Clone for ZoneRegistry<T> {
63    fn clone(&self) -> Self {
64        *self
65    }
66}
67impl<T: Clone + 'static> PartialEq for ZoneRegistry<T> {
68    fn eq(&self, other: &Self) -> bool {
69        self.zones == other.zones
70    }
71}
72
73impl<T: Clone + 'static> ZoneRegistry<T> {
74    /// Wrap an existing signal. Prefer [`crate::core::hooks::use_dnd_provider`].
75    pub fn from_signal(zones: Signal<Vec<ZoneRecord<T>>>) -> Self {
76        Self { zones }
77    }
78
79    /// Add (or replace, by id) a zone.
80    pub fn register(&mut self, record: ZoneRecord<T>) {
81        let mut zones = self.zones.write();
82        if let Some(existing) = zones.iter_mut().find(|z| z.id == record.id) {
83            *existing = record;
84        } else {
85            zones.push(record);
86        }
87    }
88
89    /// Update a zone's label in place (no-op if unchanged or unknown).
90    pub fn sync_label(&mut self, id: ZoneId, label: Option<String>) {
91        let needs = self
92            .zones
93            .peek()
94            .iter()
95            .any(|z| z.id == id && z.label != label);
96        if needs {
97            if let Some(z) = self.zones.write().iter_mut().find(|z| z.id == id) {
98                z.label = label;
99            }
100        }
101    }
102
103    /// Remove a zone (call when its component unmounts).
104    pub fn unregister(&mut self, id: ZoneId) {
105        self.zones.write().retain(|z| z.id != id);
106    }
107
108    /// Look up a zone by id.
109    pub fn get(&self, id: ZoneId) -> Option<ZoneRecord<T>> {
110        self.zones.peek().iter().find(|z| z.id == id).cloned()
111    }
112
113    /// Is a zone with this id registered *here*? The parent-zone context is
114    /// shared across payload types, so a record's `parent` can name a zone
115    /// living in another type's registry - check before navigating to one.
116    pub fn contains(&self, id: ZoneId) -> bool {
117        self.zones.peek().iter().any(|z| z.id == id)
118    }
119
120    /// The zone keyboard navigation should enter when ascending from
121    /// `current`: its parent, but only when that parent is registered in
122    /// this registry. A `DropZone<A>` nested inside a `DropZone<B>` records
123    /// B's id as its parent, and entering an id this registry can't resolve
124    /// would leave the drag hovering a zone that can never receive it.
125    pub fn ascend(&self, current: ZoneId) -> Option<ZoneId> {
126        self.parent_of(current).filter(|pid| self.contains(*pid))
127    }
128
129    /// All zones accepting `payload`, in registration order.
130    pub fn acceptable(&self, payload: &T) -> Vec<ZoneRecord<T>> {
131        self.zones
132            .peek()
133            .iter()
134            .filter(|z| z.accepts_payload(payload))
135            .cloned()
136            .collect()
137    }
138
139    /// The next/previous zone (cyclic) relative to `current` among zones that
140    /// accept `payload`. `step` is `+1` or `-1`.
141    ///
142    /// Order is **spatial** (top-to-bottom, then left-to-right) for zones
143    /// with measured rects - call [`Self::refresh_rects`] first, as the
144    /// built-in keyboard interaction does on pickup. Unmeasured zones keep
145    /// registration order, after the measured ones.
146    pub fn step_zone(&self, current: Option<ZoneId>, payload: &T, step: isize) -> Option<ZoneId> {
147        let mut zones = self.acceptable(payload);
148        spatial_sort(&mut zones);
149        let current_ix = current.and_then(|c| zones.iter().position(|z| z.id == c));
150        cycle(zones.len(), current_ix, step).map(|ix| zones[ix].id)
151    }
152
153    /// The parent of a zone, if it's nested.
154    pub fn parent_of(&self, id: ZoneId) -> Option<ZoneId> {
155        self.zones.peek().iter().find(|z| z.id == id)?.parent
156    }
157
158    /// Zones directly inside `parent` (`None` = root level) that accept
159    /// `payload`, in spatial order (top-to-bottom, left-to-right; unmeasured
160    /// zones keep registration order at the end).
161    pub fn children_of(&self, parent: Option<ZoneId>, payload: &T) -> Vec<ZoneRecord<T>> {
162        let mut zones: Vec<_> = self
163            .zones
164            .peek()
165            .iter()
166            .filter(|z| z.parent == parent && z.accepts_payload(payload))
167            .cloned()
168            .collect();
169        spatial_sort(&mut zones);
170        zones
171    }
172
173    /// Next/previous zone (cyclic) among the *siblings* of `current` -
174    /// zones sharing its parent. With no `current`, cycles the root level.
175    pub fn step_sibling(
176        &self,
177        current: Option<ZoneId>,
178        payload: &T,
179        step: isize,
180    ) -> Option<ZoneId> {
181        let parent = current.and_then(|c| self.parent_of(c));
182        let siblings = self.children_of(parent, payload);
183        let current_ix = current.and_then(|c| siblings.iter().position(|z| z.id == c));
184        cycle(siblings.len(), current_ix, step).map(|ix| siblings[ix].id)
185    }
186
187    /// The first (spatially) acceptable zone nested inside `id`.
188    pub fn first_child(&self, id: ZoneId, payload: &T) -> Option<ZoneId> {
189        self.children_of(Some(id), payload).first().map(|z| z.id)
190    }
191
192    /// Topmost zone containing `point` (client coordinates), using cached
193    /// rects - call [`Self::refresh_rects`] when a drag starts. Later-mounted
194    /// zones win, approximating DOM paint order.
195    pub fn hit_test(&self, point: Point) -> Option<ZoneId> {
196        self.zones
197            .peek()
198            .iter()
199            .rev()
200            .find(|z| (*z.rect.peek()).map(|r| r.contains(point)).unwrap_or(false))
201            .map(|z| z.id)
202    }
203
204    /// Like [`Self::hit_test`], but acceptance-aware: it returns the topmost
205    /// zone that both contains the point **and** accepts `payload`, and when no
206    /// such zone contains the point, falls back to the acceptable zone whose
207    /// center is nearest - within `max_distance` CSS px. Skipping zones that
208    /// reject the payload lets a drop land on an accepting zone sitting *under*
209    /// a rejecting (or decorative) one, and is friendlier for imprecise (touch)
210    /// drops that land in the gutter between zones.
211    pub fn hit_test_closest(&self, point: Point, payload: &T, max_distance: f64) -> Option<ZoneId> {
212        if let Some(hit) = self
213            .zones
214            .peek()
215            .iter()
216            .rev()
217            .find(|z| {
218                z.accepts_payload(payload)
219                    && (*z.rect.peek()).map(|r| r.contains(point)).unwrap_or(false)
220            })
221            .map(|z| z.id)
222        {
223            return Some(hit);
224        }
225        let mut best: Option<(ZoneId, f64)> = None;
226        for z in self.acceptable(payload) {
227            let Some(r) = *z.rect.peek() else { continue };
228            let c = r.center();
229            let (dx, dy) = (c.x - point.x, c.y - point.y);
230            let d = (dx * dx + dy * dy).sqrt();
231            if d <= max_distance && best.map(|(_, bd)| d < bd).unwrap_or(true) {
232                best = Some((z.id, d));
233            }
234        }
235        best.map(|(id, _)| id)
236    }
237
238    /// Re-measure every mounted zone's client rect and **wait** for the
239    /// measurements to land - unlike [`Self::refresh_rects`], which fires
240    /// and forgets. Use before a hit-test that must see fresh geometry
241    /// (e.g. retrying a missed touch drop after a layout change).
242    pub async fn measure_all(&self) {
243        let zones: Vec<_> = self
244            .zones
245            .peek()
246            .iter()
247            .map(|z| (z.mounted.peek().clone(), z.rect))
248            .collect();
249        for (mounted, mut rect) in zones {
250            if let Some(m) = mounted {
251                if let Ok(r) = m.get_client_rect().await {
252                    rect.set(Some(Rect::new(
253                        r.origin.x,
254                        r.origin.y,
255                        r.size.width,
256                        r.size.height,
257                    )));
258                }
259            }
260        }
261    }
262
263    /// Re-measure every mounted zone's client rect (async, spawned).
264    pub fn refresh_rects(&self) {
265        for zone in self.zones.peek().iter() {
266            let mounted = zone.mounted.peek().clone();
267            let mut rect = zone.rect;
268            if let Some(m) = mounted {
269                spawn(async move {
270                    if let Ok(r) = m.get_client_rect().await {
271                        rect.set(Some(Rect::new(
272                            r.origin.x,
273                            r.origin.y,
274                            r.size.width,
275                            r.size.height,
276                        )));
277                    }
278                });
279            }
280        }
281    }
282}
283
284/// Sort zones spatially: measured rects by (top, left), unmeasured last in
285/// their original relative order.
286fn spatial_sort<T: Clone + 'static>(zones: &mut [ZoneRecord<T>]) {
287    zones.sort_by(|a, b| match (*a.rect.peek(), *b.rect.peek()) {
288        (Some(ra), Some(rb)) => (ra.y, ra.x)
289            .partial_cmp(&(rb.y, rb.x))
290            .unwrap_or(std::cmp::Ordering::Equal),
291        (Some(_), None) => std::cmp::Ordering::Less,
292        (None, Some(_)) => std::cmp::Ordering::Greater,
293        (None, None) => std::cmp::Ordering::Equal,
294    });
295}
296
297/// Cyclic index stepping: `None` current starts at the first (or last)
298/// element depending on direction. Pure, for testability.
299pub(crate) fn cycle(len: usize, current: Option<usize>, step: isize) -> Option<usize> {
300    if len == 0 {
301        return None;
302    }
303    Some(match current {
304        None => {
305            if step >= 0 {
306                0
307            } else {
308                len - 1
309            }
310        }
311        Some(ix) => (ix as isize + step).rem_euclid(len as isize) as usize,
312    })
313}
314
315#[cfg(test)]
316mod tests {
317    use super::cycle;
318
319    #[test]
320    fn cycle_steps_and_wraps() {
321        assert_eq!(cycle(0, None, 1), None);
322        assert_eq!(cycle(3, None, 1), Some(0));
323        assert_eq!(cycle(3, None, -1), Some(2));
324        assert_eq!(cycle(3, Some(2), 1), Some(0));
325        assert_eq!(cycle(3, Some(0), -1), Some(2));
326        assert_eq!(cycle(3, Some(1), 1), Some(2));
327    }
328}