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_zone_id, use_zone_registry, DragMode, DropOutcome, ParentZone,
10    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    let mut label_now = use_signal(|| label.clone());
136    let mut accepts_now = use_signal(|| accepts);
137    let mut row_height_now = use_signal(|| row_height);
138    let mut on_drop_now = use_signal(|| on_drop);
139    let mut node_now = use_signal(|| node);
140
141    if *label_now.peek() != label {
142        label_now.set(label.clone());
143    }
144    if *accepts_now.peek() != accepts {
145        accepts_now.set(accepts);
146    }
147    if *row_height_now.peek() != row_height {
148        row_height_now.set(row_height);
149    }
150    if *on_drop_now.peek() != on_drop {
151        on_drop_now.set(on_drop);
152    }
153    if *node_now.peek() != node {
154        node_now.set(node);
155    }
156
157    // --- zone registration: makes this row a touch and keyboard target ----
158    let zone_id = use_zone_id();
159    let parent = try_use_context::<ParentZone>().map(|p| p.0);
160    // Registry-level filter: would *any* intent be accepted? (Hover can't
161    // know the final band yet; the exact intent is re-checked at drop.)
162    let registered_accepts = Callback::new(move |p: T| match *accepts_now.peek() {
163        Some(cb) => {
164            cb.call((p.clone(), DropIntent::Before))
165                || cb.call((p.clone(), DropIntent::After))
166                || cb.call((p, DropIntent::Into))
167        }
168        None => true,
169    });
170    let registered_drop = Callback::new(move |o: DropOutcome<T>| {
171        let it = intent_from_offset(o.element.y, *row_height_now.peek());
172        let ok = match *accepts_now.peek() {
173            Some(cb) => cb.call((o.payload.clone(), it)),
174            None => true,
175        };
176        if ok {
177            on_drop_now.peek().call(TreeDropEvent {
178                payload: o.payload,
179                target: *node_now.peek(),
180                intent: it,
181            });
182        }
183    });
184    let registration = use_hook(move || {
185        registry.register(ZoneRecord {
186            id: zone_id,
187            parent,
188            label: label_now.peek().clone(),
189            on_drop: registered_drop,
190            accepts: Some(registered_accepts),
191            mounted: None,
192            rect: None,
193        })
194    });
195    use_drop(move || {
196        registry.unregister(zone_id);
197    });
198    // Keep the registered label in sync if the prop changes across renders.
199    // Registry readers only `peek`, so this render-time write can't loop.
200    registry.sync_label(zone_id, label.clone());
201
202    // Pointer drags derive a live band from the shared pointer position, so
203    // fingers see the same before/into/after feedback as mice.
204    let display_intent = move || -> Option<DropIntent> {
205        let over = match joined {
206            Some(joined) => joined.is_over(zone_id),
207            None => dnd.over() == Some(zone_id),
208        };
209        if dnd.dragging() && dnd.mode() == DragMode::Pointer && over {
210            let r = registry.cached_rect(zone_id)?;
211            let pointer = joined
212                .and_then(|joined| joined.local_pointer())
213                .unwrap_or_else(|| dnd.pointer());
214            return Some(intent_from_offset(pointer.y - r.y, row_height));
215        }
216        None
217    };
218    let intent_str = move || -> Option<&'static str> {
219        match display_intent() {
220            Some(DropIntent::Before) => Some("before"),
221            Some(DropIntent::After) => Some("after"),
222            Some(DropIntent::Into) => Some("into"),
223            None => None,
224        }
225    };
226    rsx! {
227        div {
228            "data-intent": intent_str(),
229            onmounted: move |evt: Event<MountedData>| {
230                let m: Rc<MountedData> = evt.data();
231                let mut registry = registry;
232                registry.set_mounted(registration, m.clone());
233                spawn(async move {
234                    if let Ok(r) = m.get_client_rect().await {
235                        registry.set_rect_if_present(registration, Rect::new(
236                            r.origin.x,
237                            r.origin.y,
238                            r.size.width,
239                            r.size.height,
240                        ));
241                    }
242                });
243            },
244            ..attributes,
245            {children}
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn node_id_from_u64() {
256        assert_eq!(NodeId::from(42), NodeId(42));
257    }
258
259    #[test]
260    fn intent_bands() {
261        assert_eq!(intent_from_offset(2.0, 28.0), DropIntent::Before);
262        assert_eq!(intent_from_offset(14.0, 28.0), DropIntent::Into);
263        assert_eq!(intent_from_offset(26.0, 28.0), DropIntent::After);
264        // degenerate height doesn't divide by zero
265        assert_eq!(intent_from_offset(0.0, 0.0), DropIntent::Before);
266    }
267
268    #[test]
269    fn intent_bands_use_quarter_boundaries() {
270        assert_eq!(intent_from_offset(24.9, 100.0), DropIntent::Before);
271        assert_eq!(intent_from_offset(25.0, 100.0), DropIntent::Into);
272        assert_eq!(intent_from_offset(75.0, 100.0), DropIntent::Into);
273        assert_eq!(intent_from_offset(75.1, 100.0), DropIntent::After);
274    }
275
276    #[test]
277    fn intent_bands_clamp_out_of_range_offsets() {
278        assert_eq!(intent_from_offset(-20.0, 100.0), DropIntent::Before);
279        assert_eq!(intent_from_offset(120.0, 100.0), DropIntent::After);
280        assert_eq!(intent_from_offset(50.0, -10.0), DropIntent::After);
281    }
282
283    #[test]
284    fn cycle_detection() {
285        // 1 -> 2 -> 3 (3's parent is 2, 2's parent is 1)
286        let parent = |n: NodeId| match n.0 {
287            3 => Some(NodeId(2)),
288            2 => Some(NodeId(1)),
289            _ => None,
290        };
291        // dropping 1 into its grandchild 3 = cycle
292        assert!(would_create_cycle(parent, NodeId(1), NodeId(3)));
293        // dropping onto itself = cycle
294        assert!(would_create_cycle(parent, NodeId(2), NodeId(2)));
295        // dropping 3 into the root = fine
296        assert!(!would_create_cycle(parent, NodeId(3), NodeId(1)));
297    }
298
299    #[test]
300    fn cycle_detection_handles_missing_parents() {
301        let parent = |n: NodeId| match n.0 {
302            9 => Some(NodeId(8)),
303            _ => None,
304        };
305
306        assert!(!would_create_cycle(parent, NodeId(1), NodeId(9)));
307        assert!(!would_create_cycle(parent, NodeId(9), NodeId(1)));
308    }
309
310    #[test]
311    fn cycle_detection_treats_parent_map_cycles_as_unsafe() {
312        let parent = |n: NodeId| match n.0 {
313            2 => Some(NodeId(3)),
314            3 => Some(NodeId(2)),
315            _ => None,
316        };
317
318        assert!(would_create_cycle(parent, NodeId(1), NodeId(2)));
319    }
320}