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