Skip to main content

dioxus_dnd/
tree.rs

1#![doc = include_str!("../docs/api/trees.md")]
2
3use std::rc::Rc;
4
5use dioxus::html::MountedData;
6use dioxus::prelude::*;
7
8use crate::core::{
9    use_dnd, use_joined_window, use_parent_zone, use_zone_id, use_zone_registry, DragMode,
10    DropOutcome, Rect, ZoneRecord,
11};
12
13/// Identifies a tree node.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
15pub struct NodeId(pub u64);
16
17impl From<u64> for NodeId {
18    fn from(v: u64) -> Self {
19        Self(v)
20    }
21}
22
23/// Where, relative to the target node, the payload should land.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum DropIntent {
26    /// Insert as the target's previous sibling.
27    Before,
28    /// Insert as the target's next sibling.
29    After,
30    /// Insert as the target's child.
31    Into,
32}
33
34/// A completed tree drop.
35///
36/// Non-exhaustive so drop context can be added without a major release;
37/// synthesize your own (tests, programmatic moves) via [`TreeDropEvent::new`].
38#[derive(Debug, Clone, PartialEq)]
39#[non_exhaustive]
40pub struct TreeDropEvent<T> {
41    pub payload: T,
42    pub target: NodeId,
43    pub intent: DropIntent,
44}
45
46impl<T> TreeDropEvent<T> {
47    /// A drop of `payload` landing relative to `target` per `intent`.
48    pub fn new(payload: T, target: NodeId, intent: DropIntent) -> Self {
49        Self {
50            payload,
51            target,
52            intent,
53        }
54    }
55}
56
57/// Derive a [`DropIntent`] from the pointer's Y offset within a row of the
58/// given height. Top 25% → `Before`, bottom 25% → `After`, middle → `Into`.
59///
60/// If your rows can't receive children (a flat outline), map `Into` to
61/// whichever sibling intent you prefer.
62pub fn intent_from_offset(y: f64, row_height: f64) -> DropIntent {
63    let h = row_height.max(1.0);
64    let ratio = (y / h).clamp(0.0, 1.0);
65    if ratio < 0.25 {
66        DropIntent::Before
67    } else if ratio > 0.75 {
68        DropIntent::After
69    } else {
70        DropIntent::Into
71    }
72}
73
74/// Would attaching `dragged` under `target` create a cycle? Walks `target`'s
75/// ancestry via the `parent_of` lookup you provide.
76pub fn would_create_cycle(
77    parent_of: impl Fn(NodeId) -> Option<NodeId>,
78    dragged: NodeId,
79    target: NodeId,
80) -> bool {
81    if dragged == target {
82        return true;
83    }
84    let mut cursor = Some(target);
85    // Bounded walk in case the caller's parent map itself has a cycle.
86    for _ in 0..10_000 {
87        match cursor {
88            Some(n) if n == dragged => return true,
89            Some(n) => cursor = parent_of(n),
90            None => return false,
91        }
92    }
93    true
94}
95
96/// A single tree row that acts as a drop target with intent detection.
97///
98/// The payload type `T` travels through the shared `DndContext<T>` (use the
99/// core `Draggable` on your rows to start drags).
100/// While a pointer drag (mouse, touch, pen) hovers, the wrapper carries
101/// `data-intent="before" | "after" | "into"` for styling insertion
102/// indicators. The attribute is absent when not hovered,
103/// so both value selectors (Tailwind `data-[intent=into]:bg-blue-50`) and
104/// presence selectors (`data-intent:outline`) work.
105///
106/// Every target also registers itself in the shared zone registry, which is
107/// what makes it reachable by pointer hit-testing and keyboard navigation.
108/// Keyboard drops land with `Into` intent (the row's center band). At the
109/// registry level a target accepts a payload if your `accepts` passes for
110/// *any* intent; the exact intent is re-checked at drop time.
111#[component]
112pub fn TreeNodeTarget<T: Clone + PartialEq + 'static>(
113    /// The node this row represents.
114    node: NodeId,
115    /// Height of the row in pixels, used for the before/into/after bands.
116    /// Keep this close to the actual rendered row height: keyboard drops resolve
117    /// their intent from the measured row center against this value, so a large
118    /// mismatch (e.g. wrapped/custom content taller than the default) can bias a
119    /// keyboard drop toward `After`/`Before` instead of `Into`.
120    #[props(default = 28.0)]
121    row_height: f64,
122    /// Reject drops (typically: cycle prevention). Receives `(payload, intent)`.
123    #[props(default)]
124    accepts: Option<Callback<(T, DropIntent), bool>>,
125    on_drop: EventHandler<TreeDropEvent<T>>,
126    /// Announced to screen readers during keyboard navigation.
127    #[props(default)]
128    label: Option<String>,
129    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
130    children: Element,
131) -> Element {
132    let dnd = use_dnd::<T>();
133    let joined = use_joined_window::<T>();
134    let mut registry = use_zone_registry::<T>();
135
136    // --- zone registration: makes this row a touch and keyboard target ----
137    let zone_id = use_zone_id();
138    let parent = use_parent_zone();
139    // Registry-level filter: would *any* intent be accepted? (Hover can't
140    // know the final band yet; the exact intent is re-checked at drop.)
141    let registered_accepts = use_callback(move |p: T| match accepts {
142        Some(cb) => {
143            cb.call((p.clone(), DropIntent::Before))
144                || cb.call((p.clone(), DropIntent::After))
145                || cb.call((p, DropIntent::Into))
146        }
147        None => true,
148    });
149    let registered_drop = use_callback(move |o: DropOutcome<T>| {
150        let it = intent_from_offset(o.element.y, row_height);
151        let ok = match accepts {
152            Some(cb) => cb.call((o.payload.clone(), it)),
153            None => true,
154        };
155        if ok {
156            on_drop.call(TreeDropEvent {
157                payload: o.payload,
158                target: node,
159                intent: it,
160            });
161        }
162    });
163    let registered_label = label.clone();
164    let registration = use_hook(move || {
165        registry.register(ZoneRecord {
166            id: zone_id,
167            parent,
168            label: registered_label,
169            on_drop: registered_drop,
170            accepts: Some(registered_accepts),
171            mounted: None,
172            rect: None,
173        })
174    });
175    use_drop(move || {
176        registry.unregister_registration(registration);
177    });
178    let label_for_sync = label.clone();
179    use_effect(use_reactive!(|(label_for_sync)| {
180        registry.sync_label(zone_id, label_for_sync);
181    }));
182    use_effect(use_reactive!(|(parent)| {
183        registry.sync_parent(registration, parent);
184    }));
185
186    // Pointer drags derive a live band from the shared pointer position, so
187    // fingers see the same before/into/after feedback as mice.
188    let display_intent = move || -> Option<DropIntent> {
189        let over = match joined {
190            Some(joined) => joined.is_over(zone_id),
191            None => dnd.over() == Some(zone_id),
192        };
193        if dnd.dragging()
194            && dnd.proposed_effect() != crate::core::DropEffect::None
195            && dnd.mode() == DragMode::Pointer
196            && over
197        {
198            let r = registry.cached_rect(zone_id)?;
199            let pointer = joined
200                .and_then(|joined| joined.local_pointer())
201                .unwrap_or_else(|| dnd.pointer());
202            return Some(intent_from_offset(pointer.y - r.y, row_height));
203        }
204        None
205    };
206    let intent_str = move || -> Option<&'static str> {
207        match display_intent() {
208            Some(DropIntent::Before) => Some("before"),
209            Some(DropIntent::After) => Some("after"),
210            Some(DropIntent::Into) => Some("into"),
211            None => None,
212        }
213    };
214    let mut attributes = attributes;
215    crate::core::components::protect_attributes(&mut attributes, &["data-intent", "onmounted"]);
216    rsx! {
217        div {
218            "data-intent": intent_str(),
219            onmounted: move |evt: Event<MountedData>| {
220                let m: Rc<MountedData> = evt.data();
221                let mut registry = registry;
222                registry.set_mounted(registration, m.clone());
223                spawn(async move {
224                    if let Ok(r) = m.get_client_rect().await {
225                        registry.set_rect_if_present(registration, Rect::new(
226                            r.origin.x,
227                            r.origin.y,
228                            r.size.width,
229                            r.size.height,
230                        ));
231                    }
232                });
233            },
234            ..attributes,
235            {children}
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn node_id_from_u64() {
246        assert_eq!(NodeId::from(42), NodeId(42));
247    }
248
249    #[test]
250    fn intent_bands() {
251        assert_eq!(intent_from_offset(2.0, 28.0), DropIntent::Before);
252        assert_eq!(intent_from_offset(14.0, 28.0), DropIntent::Into);
253        assert_eq!(intent_from_offset(26.0, 28.0), DropIntent::After);
254        // degenerate height doesn't divide by zero
255        assert_eq!(intent_from_offset(0.0, 0.0), DropIntent::Before);
256    }
257
258    #[test]
259    fn intent_bands_use_quarter_boundaries() {
260        assert_eq!(intent_from_offset(24.9, 100.0), DropIntent::Before);
261        assert_eq!(intent_from_offset(25.0, 100.0), DropIntent::Into);
262        assert_eq!(intent_from_offset(75.0, 100.0), DropIntent::Into);
263        assert_eq!(intent_from_offset(75.1, 100.0), DropIntent::After);
264    }
265
266    #[test]
267    fn intent_bands_clamp_out_of_range_offsets() {
268        assert_eq!(intent_from_offset(-20.0, 100.0), DropIntent::Before);
269        assert_eq!(intent_from_offset(120.0, 100.0), DropIntent::After);
270        assert_eq!(intent_from_offset(50.0, -10.0), DropIntent::After);
271    }
272
273    #[test]
274    fn cycle_detection() {
275        // 1 -> 2 -> 3 (3's parent is 2, 2's parent is 1)
276        let parent = |n: NodeId| match n.0 {
277            3 => Some(NodeId(2)),
278            2 => Some(NodeId(1)),
279            _ => None,
280        };
281        // dropping 1 into its grandchild 3 = cycle
282        assert!(would_create_cycle(parent, NodeId(1), NodeId(3)));
283        // dropping onto itself = cycle
284        assert!(would_create_cycle(parent, NodeId(2), NodeId(2)));
285        // dropping 3 into the root = fine
286        assert!(!would_create_cycle(parent, NodeId(3), NodeId(1)));
287    }
288
289    #[test]
290    fn cycle_detection_handles_missing_parents() {
291        let parent = |n: NodeId| match n.0 {
292            9 => Some(NodeId(8)),
293            _ => None,
294        };
295
296        assert!(!would_create_cycle(parent, NodeId(1), NodeId(9)));
297        assert!(!would_create_cycle(parent, NodeId(9), NodeId(1)));
298    }
299
300    #[test]
301    fn cycle_detection_treats_parent_map_cycles_as_unsafe() {
302        let parent = |n: NodeId| match n.0 {
303            2 => Some(NodeId(3)),
304            3 => Some(NodeId(2)),
305            _ => None,
306        };
307
308        assert!(would_create_cycle(parent, NodeId(1), NodeId(2)));
309    }
310}