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}