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::{Direction, 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    /// Layout direction for spatial ordering (keyboard navigation).
60    dir: Signal<Direction>,
61}
62
63impl<T: Clone + 'static> Copy for ZoneRegistry<T> {}
64impl<T: Clone + 'static> Clone for ZoneRegistry<T> {
65    fn clone(&self) -> Self {
66        *self
67    }
68}
69impl<T: Clone + 'static> PartialEq for ZoneRegistry<T> {
70    fn eq(&self, other: &Self) -> bool {
71        self.zones == other.zones && self.dir == other.dir
72    }
73}
74
75impl<T: Clone + 'static> ZoneRegistry<T> {
76    /// Wrap an existing signal. Prefer [`crate::core::hooks::use_dnd_provider`].
77    pub fn from_signal(zones: Signal<Vec<ZoneRecord<T>>>) -> Self {
78        Self {
79            zones,
80            dir: Signal::new(Direction::default()),
81        }
82    }
83
84    /// Layout direction spatial ordering follows.
85    pub fn direction(&self) -> Direction {
86        *self.dir.peek()
87    }
88
89    /// Set the layout direction (no-op if unchanged; safe to call every
90    /// render). `DndProvider`'s `dir` prop calls this for you.
91    pub fn set_direction(&mut self, dir: Direction) {
92        if *self.dir.peek() != dir {
93            self.dir.set(dir);
94        }
95    }
96
97    /// Add (or replace, by id) a zone.
98    pub fn register(&mut self, record: ZoneRecord<T>) {
99        let mut zones = self.zones.write();
100        if let Some(existing) = zones.iter_mut().find(|z| z.id == record.id) {
101            *existing = record;
102        } else {
103            zones.push(record);
104        }
105    }
106
107    /// Update a zone's label in place (no-op if unchanged or unknown).
108    pub fn sync_label(&mut self, id: ZoneId, label: Option<String>) {
109        let needs = self
110            .zones
111            .peek()
112            .iter()
113            .any(|z| z.id == id && z.label != label);
114        if needs {
115            if let Some(z) = self.zones.write().iter_mut().find(|z| z.id == id) {
116                z.label = label;
117            }
118        }
119    }
120
121    /// Remove a zone (call when its component unmounts).
122    pub fn unregister(&mut self, id: ZoneId) {
123        self.zones.write().retain(|z| z.id != id);
124    }
125
126    /// Look up a zone by id.
127    pub fn get(&self, id: ZoneId) -> Option<ZoneRecord<T>> {
128        self.zones.peek().iter().find(|z| z.id == id).cloned()
129    }
130
131    /// Every registered zone, in registration order. Unlike the peeking
132    /// lookups around it this is a *subscribing* read - a component
133    /// rendering from it re-renders when zones mount or unmount - because
134    /// its consumers (the debug overlay, your own devtools) are renderers.
135    pub fn records(&self) -> Vec<ZoneRecord<T>> {
136        self.zones.read().to_vec()
137    }
138
139    /// Is a zone with this id registered *here*? The parent-zone context is
140    /// shared across payload types, so a record's `parent` can name a zone
141    /// living in another type's registry - check before navigating to one.
142    pub fn contains(&self, id: ZoneId) -> bool {
143        self.zones.peek().iter().any(|z| z.id == id)
144    }
145
146    /// The zone keyboard navigation should enter when ascending from
147    /// `current`: its parent, but only when that parent is registered in
148    /// this registry. A `DropZone<A>` nested inside a `DropZone<B>` records
149    /// B's id as its parent, and entering an id this registry can't resolve
150    /// would leave the drag hovering a zone that can never receive it.
151    pub fn ascend(&self, current: ZoneId) -> Option<ZoneId> {
152        self.parent_of(current).filter(|pid| self.contains(*pid))
153    }
154
155    /// All zones accepting `payload`, in registration order.
156    pub fn acceptable(&self, payload: &T) -> Vec<ZoneRecord<T>> {
157        self.zones
158            .peek()
159            .iter()
160            .filter(|z| z.accepts_payload(payload))
161            .cloned()
162            .collect()
163    }
164
165    /// The next/previous zone (cyclic) relative to `current` among zones that
166    /// accept `payload`. `step` is `+1` or `-1`.
167    ///
168    /// Order is **spatial** (top-to-bottom, then left-to-right) for zones
169    /// with measured rects - call [`Self::refresh_rects`] first, as the
170    /// built-in keyboard interaction does on pickup. Unmeasured zones keep
171    /// registration order, after the measured ones.
172    pub fn step_zone(&self, current: Option<ZoneId>, payload: &T, step: isize) -> Option<ZoneId> {
173        let mut zones = self.acceptable(payload);
174        spatial_sort(&mut zones, self.direction());
175        let current_ix = current.and_then(|c| zones.iter().position(|z| z.id == c));
176        cycle(zones.len(), current_ix, step).map(|ix| zones[ix].id)
177    }
178
179    /// The parent of a zone, if it's nested.
180    pub fn parent_of(&self, id: ZoneId) -> Option<ZoneId> {
181        self.zones.peek().iter().find(|z| z.id == id)?.parent
182    }
183
184    /// Zones directly inside `parent` (`None` = root level) that accept
185    /// `payload`, in spatial order (top-to-bottom, left-to-right; unmeasured
186    /// zones keep registration order at the end).
187    pub fn children_of(&self, parent: Option<ZoneId>, payload: &T) -> Vec<ZoneRecord<T>> {
188        let mut zones: Vec<_> = self
189            .zones
190            .peek()
191            .iter()
192            .filter(|z| z.parent == parent && z.accepts_payload(payload))
193            .cloned()
194            .collect();
195        spatial_sort(&mut zones, self.direction());
196        zones
197    }
198
199    /// Next/previous zone (cyclic) among the *siblings* of `current` -
200    /// zones sharing its parent. With no `current`, cycles the root level.
201    pub fn step_sibling(
202        &self,
203        current: Option<ZoneId>,
204        payload: &T,
205        step: isize,
206    ) -> Option<ZoneId> {
207        let parent = current.and_then(|c| self.parent_of(c));
208        let siblings = self.children_of(parent, payload);
209        let current_ix = current.and_then(|c| siblings.iter().position(|z| z.id == c));
210        cycle(siblings.len(), current_ix, step).map(|ix| siblings[ix].id)
211    }
212
213    /// The first (spatially) acceptable zone nested inside `id`.
214    pub fn first_child(&self, id: ZoneId, payload: &T) -> Option<ZoneId> {
215        self.children_of(Some(id), payload).first().map(|z| z.id)
216    }
217
218    /// Topmost zone containing `point` (client coordinates), using cached
219    /// rects - call [`Self::refresh_rects`] when a drag starts. Later-mounted
220    /// zones win, approximating DOM paint order.
221    pub fn hit_test(&self, point: Point) -> Option<ZoneId> {
222        self.zones
223            .peek()
224            .iter()
225            .rev()
226            .find(|z| (*z.rect.peek()).map(|r| r.contains(point)).unwrap_or(false))
227            .map(|z| z.id)
228    }
229
230    /// Like [`Self::hit_test`], but acceptance-aware: it returns the topmost
231    /// zone that both contains the point **and** accepts `payload`, and when
232    /// no such zone contains the point, falls back to the acceptable zone
233    /// whose *rect* is nearest - within `max_distance` CSS px of its closest
234    /// edge, not its center, so a large zone snaps a release right beside it
235    /// even though its center sits far away. Skipping zones that reject the
236    /// payload lets a drop land on an accepting zone sitting *under* a
237    /// rejecting (or decorative) one, and is friendlier for imprecise
238    /// (touch) drops that land in the gutter between zones.
239    pub fn hit_test_closest(&self, point: Point, payload: &T, max_distance: f64) -> Option<ZoneId> {
240        if let Some(hit) = self
241            .zones
242            .peek()
243            .iter()
244            .rev()
245            .find(|z| {
246                z.accepts_payload(payload)
247                    && (*z.rect.peek()).map(|r| r.contains(point)).unwrap_or(false)
248            })
249            .map(|z| z.id)
250        {
251            return Some(hit);
252        }
253        let mut best: Option<(ZoneId, f64)> = None;
254        for z in self.acceptable(payload) {
255            let Some(r) = *z.rect.peek() else { continue };
256            // Distance to the rect's nearest point (zero on either axis the
257            // point already overlaps), not to its center.
258            let dx = (r.x - point.x).max(point.x - (r.x + r.width)).max(0.0);
259            let dy = (r.y - point.y).max(point.y - (r.y + r.height)).max(0.0);
260            let d = (dx * dx + dy * dy).sqrt();
261            if d <= max_distance && best.map(|(_, bd)| d < bd).unwrap_or(true) {
262                best = Some((z.id, d));
263            }
264        }
265        best.map(|(id, _)| id)
266    }
267
268    /// Re-measure every mounted zone's client rect and **wait** for the
269    /// measurements to land - unlike [`Self::refresh_rects`], which fires
270    /// and forgets. Use before a hit-test that must see fresh geometry
271    /// (e.g. retrying a missed touch drop after a layout change).
272    pub async fn measure_all(&self) {
273        let zones: Vec<_> = self
274            .zones
275            .peek()
276            .iter()
277            .map(|z| (z.mounted.peek().clone(), z.rect))
278            .collect();
279        for (mounted, mut rect) in zones {
280            if let Some(m) = mounted {
281                if let Ok(r) = m.get_client_rect().await {
282                    rect.set(Some(Rect::new(
283                        r.origin.x,
284                        r.origin.y,
285                        r.size.width,
286                        r.size.height,
287                    )));
288                }
289            }
290        }
291    }
292
293    /// Re-measure every mounted zone's client rect (async, spawned).
294    pub fn refresh_rects(&self) {
295        for zone in self.zones.peek().iter() {
296            let mounted = zone.mounted.peek().clone();
297            let mut rect = zone.rect;
298            if let Some(m) = mounted {
299                spawn(async move {
300                    if let Ok(r) = m.get_client_rect().await {
301                        rect.set(Some(Rect::new(
302                            r.origin.x,
303                            r.origin.y,
304                            r.size.width,
305                            r.size.height,
306                        )));
307                    }
308                });
309            }
310        }
311    }
312}
313
314/// A payload-type-erased "re-measure your zones" channel, shared by every
315/// registry under one provider tree.
316///
317/// Cached client rects go stale the moment layout moves under a live drag -
318/// scrolling being the everyday case. Registries are per payload type, but
319/// the things that move layout (an auto-scrolling container, your own
320/// scroll surface, a collapsing panel) shouldn't need to know any payload
321/// type to say "geometry changed". Each provider registers a thunk here
322/// that re-measures its own registry **only while it has a drag in
323/// flight**, so pinging the channel from every scroll event costs nothing
324/// while idle.
325///
326/// [`crate::autoscroll::AutoScroll`] pings this automatically after every
327/// scroll it performs (and on any other scroll of its container); grab the
328/// channel with [`crate::core::hooks::use_rect_refresh`] to wire up custom
329/// layout mutators.
330pub struct RectRefresh {
331    thunks: Signal<Vec<(u64, Callback<()>)>>,
332}
333
334impl Copy for RectRefresh {}
335impl Clone for RectRefresh {
336    fn clone(&self) -> Self {
337        *self
338    }
339}
340impl PartialEq for RectRefresh {
341    fn eq(&self, other: &Self) -> bool {
342        self.thunks == other.thunks
343    }
344}
345
346impl RectRefresh {
347    /// Wrap an existing signal. Prefer [`crate::core::hooks::use_dnd_provider`],
348    /// which creates one per provider *tree* (nested providers inherit and
349    /// re-provide the outermost channel).
350    pub fn from_signal(thunks: Signal<Vec<(u64, Callback<()>)>>) -> Self {
351        Self { thunks }
352    }
353
354    /// Ask every provider in the tree to re-measure its zones. Providers
355    /// without a drag in flight ignore the ping, so this is safe to call
356    /// from high-frequency sources like scroll events.
357    pub fn refresh_all(&self) {
358        for (_, thunk) in self.thunks.peek().iter() {
359            thunk.call(());
360        }
361    }
362
363    /// Number of registered providers. Diagnostics and tests.
364    pub fn len(&self) -> usize {
365        self.thunks.peek().len()
366    }
367
368    /// Whether any provider is registered.
369    pub fn is_empty(&self) -> bool {
370        self.len() == 0
371    }
372
373    /// Add (or replace, by key) a provider's re-measure thunk.
374    pub(crate) fn register(&mut self, key: u64, thunk: Callback<()>) {
375        let mut thunks = self.thunks.write();
376        if let Some(existing) = thunks.iter_mut().find(|(k, _)| *k == key) {
377            existing.1 = thunk;
378        } else {
379            thunks.push((key, thunk));
380        }
381    }
382
383    /// Remove a provider's thunk (call when the provider unmounts).
384    pub(crate) fn unregister(&mut self, key: u64) {
385        self.thunks.write().retain(|(k, _)| *k != key);
386    }
387}
388
389/// Sort zones spatially: measured rects by (top, reading order), unmeasured
390/// last in their original relative order. Reading order within a row is
391/// left-to-right in LTR and right-to-left in RTL, so keyboard traversal
392/// follows what the user sees either way.
393fn spatial_sort<T: Clone + 'static>(zones: &mut [ZoneRecord<T>], dir: Direction) {
394    let reading_x = move |x: f64| match dir {
395        Direction::Ltr => x,
396        Direction::Rtl => -x,
397    };
398    zones.sort_by(|a, b| match (*a.rect.peek(), *b.rect.peek()) {
399        (Some(ra), Some(rb)) => (ra.y, reading_x(ra.x))
400            .partial_cmp(&(rb.y, reading_x(rb.x)))
401            .unwrap_or(std::cmp::Ordering::Equal),
402        (Some(_), None) => std::cmp::Ordering::Less,
403        (None, Some(_)) => std::cmp::Ordering::Greater,
404        (None, None) => std::cmp::Ordering::Equal,
405    });
406}
407
408/// Cyclic index stepping: `None` current starts at the first (or last)
409/// element depending on direction. Pure, for testability.
410pub(crate) fn cycle(len: usize, current: Option<usize>, step: isize) -> Option<usize> {
411    if len == 0 {
412        return None;
413    }
414    Some(match current {
415        None => {
416            if step >= 0 {
417                0
418            } else {
419                len - 1
420            }
421        }
422        Some(ix) => (ix as isize + step).rem_euclid(len as isize) as usize,
423    })
424}
425
426#[cfg(test)]
427mod tests {
428    use super::cycle;
429
430    #[test]
431    fn cycle_steps_and_wraps() {
432        assert_eq!(cycle(0, None, 1), None);
433        assert_eq!(cycle(3, None, 1), Some(0));
434        assert_eq!(cycle(3, None, -1), Some(2));
435        assert_eq!(cycle(3, Some(2), 1), Some(0));
436        assert_eq!(cycle(3, Some(0), -1), Some(2));
437        assert_eq!(cycle(3, Some(1), 1), Some(2));
438    }
439}