Skip to main content

dioxus_dnd/
tree.rs

1//! Hierarchical drops — file explorers, nested menus, outliners.
2//!
3//! The classic tree problem: a drop on a node can mean three different things.
4//! [`DropIntent`] captures that trichotomy, [`intent_from_offset`] derives it
5//! from where inside the row the pointer sits (top quarter = before, bottom
6//! quarter = after, middle = into), and [`would_create_cycle`] guards against
7//! dropping a node into its own subtree.
8
9use std::rc::Rc;
10
11use dioxus::html::MountedData;
12use dioxus::prelude::*;
13
14use crate::core::{
15    element_point, use_dnd, use_zone_id, use_zone_registry, DragMode, DropOutcome, ParentZone,
16    Rect, ZoneRecord,
17};
18
19/// Identifies a tree node.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub struct NodeId(pub u64);
22
23impl From<u64> for NodeId {
24    fn from(v: u64) -> Self {
25        Self(v)
26    }
27}
28
29/// Where, relative to the target node, the payload should land.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum DropIntent {
32    /// Insert as the target's previous sibling.
33    Before,
34    /// Insert as the target's next sibling.
35    After,
36    /// Insert as the target's child.
37    Into,
38}
39
40/// A completed tree drop.
41#[derive(Debug, Clone, PartialEq)]
42pub struct TreeDropEvent<T> {
43    pub payload: T,
44    pub target: NodeId,
45    pub intent: DropIntent,
46}
47
48/// Derive a [`DropIntent`] from the pointer's Y offset within a row of the
49/// given height. Top 25% → `Before`, bottom 25% → `After`, middle → `Into`.
50///
51/// If your rows can't receive children (a flat outline), map `Into` to
52/// whichever sibling intent you prefer.
53pub fn intent_from_offset(y: f64, row_height: f64) -> DropIntent {
54    let h = row_height.max(1.0);
55    let ratio = (y / h).clamp(0.0, 1.0);
56    if ratio < 0.25 {
57        DropIntent::Before
58    } else if ratio > 0.75 {
59        DropIntent::After
60    } else {
61        DropIntent::Into
62    }
63}
64
65/// Would attaching `dragged` under `target` create a cycle? Walks `target`'s
66/// ancestry via the `parent_of` lookup you provide.
67pub fn would_create_cycle(
68    parent_of: impl Fn(NodeId) -> Option<NodeId>,
69    dragged: NodeId,
70    target: NodeId,
71) -> bool {
72    if dragged == target {
73        return true;
74    }
75    let mut cursor = Some(target);
76    // Bounded walk in case the caller's parent map itself has a cycle.
77    for _ in 0..10_000 {
78        match cursor {
79            Some(n) if n == dragged => return true,
80            Some(n) => cursor = parent_of(n),
81            None => return false,
82        }
83    }
84    true
85}
86
87/// A single tree row that acts as a drop target with intent detection.
88///
89/// The payload type `T` travels through the shared `DndContext<T>` (use the
90/// core `Draggable` or `PointerDraggable` on your rows to start drags).
91/// While hovered, the wrapper carries `data-intent="before" | "after" |
92/// "into"` for styling insertion indicators — for native mouse drags,
93/// touch/pen drags, and keyboard drags alike.
94///
95/// Every target also registers itself in the shared zone registry, which is
96/// what makes it reachable by touch hit-testing and keyboard navigation.
97/// Keyboard drops land with `Into` intent (the row's center band). At the
98/// registry level a target accepts a payload if your `accepts` passes for
99/// *any* intent; the exact intent is re-checked at drop time.
100#[component]
101pub fn TreeNodeTarget<T: Clone + PartialEq + 'static>(
102    /// The node this row represents.
103    node: NodeId,
104    /// Height of the row in pixels, used for the before/into/after bands.
105    #[props(default = 28.0)]
106    row_height: f64,
107    /// Reject drops (typically: cycle prevention). Receives `(payload, intent)`.
108    #[props(default)]
109    accepts: Option<Callback<(T, DropIntent), bool>>,
110    on_drop: EventHandler<TreeDropEvent<T>>,
111    /// Announced to screen readers during keyboard navigation.
112    #[props(default)]
113    label: Option<String>,
114    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
115    children: Element,
116) -> Element {
117    let mut dnd = use_dnd::<T>();
118    let mut registry = use_zone_registry::<T>();
119    let mut intent = use_signal(|| None::<DropIntent>);
120
121    // --- zone registration: makes this row a touch and keyboard target ----
122    let zone_id = use_zone_id();
123    let parent = try_use_context::<ParentZone>().map(|p| p.0);
124    let mounted = use_signal(|| None::<Rc<MountedData>>);
125    let rect = use_signal(|| None::<Rect>);
126    // Registry-level filter: would *any* intent be accepted? (Hover can't
127    // know the final band yet; the exact intent is re-checked at drop.)
128    let registered_accepts = accepts.map(|cb| {
129        Callback::new(move |p: T| {
130            cb.call((p.clone(), DropIntent::Before))
131                || cb.call((p.clone(), DropIntent::After))
132                || cb.call((p, DropIntent::Into))
133        })
134    });
135    let registered_drop = Callback::new(move |o: DropOutcome<T>| {
136        let it = intent_from_offset(o.element.y, row_height);
137        let ok = accepts.map_or(true, |cb| cb.call((o.payload.clone(), it)));
138        if ok {
139            on_drop.call(TreeDropEvent {
140                payload: o.payload,
141                target: node,
142                intent: it,
143            });
144        }
145    });
146    use_hook(|| {
147        registry.register(ZoneRecord {
148            id: zone_id,
149            parent,
150            label: label.clone(),
151            on_drop: registered_drop,
152            accepts: registered_accepts,
153            mounted,
154            rect,
155        });
156    });
157    use_drop(move || {
158        registry.unregister(zone_id);
159    });
160
161    // Native drags drive `intent` from dragover; pointer (touch/pen) drags
162    // derive a live band from the shared pointer position, so fingers see
163    // the same before/into/after feedback as mice.
164    let display_intent = move || -> Option<DropIntent> {
165        if let Some(it) = intent() {
166            return Some(it);
167        }
168        if dnd.dragging() && dnd.mode() == DragMode::Pointer && dnd.over() == Some(zone_id) {
169            let r = (*rect.peek())?;
170            return Some(intent_from_offset(dnd.pointer().y - r.y, row_height));
171        }
172        None
173    };
174    let intent_str = move || match display_intent() {
175        Some(DropIntent::Before) => "before",
176        Some(DropIntent::After) => "after",
177        Some(DropIntent::Into) => "into",
178        None => "",
179    };
180
181    rsx! {
182        div {
183            "data-intent": intent_str(),
184            onmounted: move |evt: Event<MountedData>| {
185                let m: Rc<MountedData> = evt.data();
186                let mut mounted = mounted;
187                let mut rect = rect;
188                mounted.set(Some(m.clone()));
189                spawn(async move {
190                    if let Ok(r) = m.get_client_rect().await {
191                        rect.set(Some(Rect::new(
192                            r.origin.x,
193                            r.origin.y,
194                            r.size.width,
195                            r.size.height,
196                        )));
197                    }
198                });
199            },
200            ondragover: move |evt: DragEvent| {
201                if !dnd.dragging() {
202                    return;
203                }
204                let it = intent_from_offset(element_point(&evt).y, row_height);
205                let ok = match (&accepts, dnd.payload()) {
206                    (Some(cb), Some(p)) => cb.call((p, it)),
207                    (None, Some(_)) => true,
208                    _ => false,
209                };
210                if ok {
211                    evt.prevent_default();
212                    if intent() != Some(it) {
213                        intent.set(Some(it));
214                    }
215                } else if intent().is_some() {
216                    intent.set(None);
217                }
218            },
219            ondragleave: move |_| {
220                intent.set(None);
221            },
222            ondrop: move |evt: DragEvent| {
223                evt.prevent_default();
224                evt.stop_propagation();
225                let it = intent_from_offset(element_point(&evt).y, row_height);
226                intent.set(None);
227                let ok = match (&accepts, dnd.payload()) {
228                    (Some(cb), Some(p)) => cb.call((p, it)),
229                    (None, Some(_)) => true,
230                    _ => false,
231                };
232                if !ok {
233                    return;
234                }
235                if let Some((payload, _)) = dnd.take() {
236                    on_drop.call(TreeDropEvent { payload, target: node, intent: it });
237                }
238            },
239            ..attributes,
240            {children}
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
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 cycle_detection() {
260        // 1 -> 2 -> 3 (3's parent is 2, 2's parent is 1)
261        let parent = |n: NodeId| match n.0 {
262            3 => Some(NodeId(2)),
263            2 => Some(NodeId(1)),
264            _ => None,
265        };
266        // dropping 1 into its grandchild 3 = cycle
267        assert!(would_create_cycle(parent, NodeId(1), NodeId(3)));
268        // dropping onto itself = cycle
269        assert!(would_create_cycle(parent, NodeId(2), NodeId(2)));
270        // dropping 3 into the root = fine
271        assert!(!would_create_cycle(parent, NodeId(3), NodeId(1)));
272    }
273}