Skip to main content

azul_layout/managers/
drag_drop.rs

1//! **Node** drag-and-drop *view types* (`DragState` / `DragType`).
2//!
3//! There is no drag-drop *manager* any more. The single source of truth for an
4//! active drag is [`crate::managers::gesture::GestureAndDragManager::active_drag`]
5//! (an `azul_core::drag::DragContext`).
6//!
7//! The former `DragDropManager` held a SECOND `active_drag: Option<DragContext>`
8//! — a clone frozen at `InitDragVisualState` that never saw the later
9//! drop-target/position updates, and that nothing remapped on a DOM rebuild.
10//! Two sources of truth for one drag is a bug by construction, and the mirror
11//! was write-only in practice (every reader consulted `gesture_drag_manager`
12//! first, and the mirror was only ever populated *from* it), so it has been
13//! deleted (2026-07-13). What remains here is the stateless conversion into the
14//! public `DragState` API, which is built on demand from the live `DragContext`.
15
16use azul_core::dom::{DomNodeId, OptionDomNodeId};
17use azul_core::drag::{ActiveDragType, DragContext};
18use azul_css::{impl_option, impl_option_inner, OptionString};
19
20/// Type of drag operation
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[repr(C)]
23pub enum DragType {
24    /// Dragging a DOM node
25    Node,
26    /// Dragging a file from OS
27    File,
28}
29
30/// State of an active drag operation
31#[derive(Debug, Clone, PartialEq, Eq)]
32#[repr(C)]
33pub struct DragState {
34    /// Type of drag
35    pub drag_type: DragType,
36    /// Source node (for node dragging)
37    pub source_node: OptionDomNodeId,
38    /// Current drop target (if hovering over valid drop zone)
39    pub current_drop_target: OptionDomNodeId,
40    /// File path (for file dragging)
41    pub file_path: OptionString,
42}
43
44impl DragState {
45    /// Create `DragState` from a `DragContext` (for backwards compatibility)
46    #[must_use] pub fn from_context(ctx: &DragContext) -> Option<Self> {
47        match &ctx.drag_type {
48            ActiveDragType::Node(node_drag) => Some(Self {
49                drag_type: DragType::Node,
50                source_node: OptionDomNodeId::Some(DomNodeId {
51                    dom: node_drag.dom_id,
52                    node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(node_drag.node_id)),
53                }),
54                current_drop_target: node_drag.current_drop_target,
55                file_path: OptionString::None,
56            }),
57            ActiveDragType::FileDrop(file_drop) => Some(Self {
58                drag_type: DragType::File,
59                source_node: OptionDomNodeId::None,
60                current_drop_target: file_drop.drop_target,
61                file_path: file_drop.files.as_ref().first().cloned().into(),
62            }),
63            _ => None, // Other drag types don't map to the old API
64        }
65    }
66}
67
68impl_option!(
69    DragState,
70    OptionDragState,
71    copy = false,
72    [Debug, Clone, PartialEq, Eq]
73);
74
75#[cfg(test)]
76mod autotest_generated {
77    use azul_core::{
78        dom::{DomId, NodeId},
79        drag::{
80            ActiveDragType, DragData, DragEffect, DropEffect, FileDropDrag, NodeDrag,
81            ScrollbarAxis, WindowResizeDrag, WindowResizeEdge,
82        },
83        geom::LogicalPosition,
84        styled_dom::NodeHierarchyItemId,
85        window::WindowPosition,
86    };
87    use azul_css::AzString;
88
89    use super::*;
90
91    // ---------------------------------------------------------------------
92    // helpers
93    // ---------------------------------------------------------------------
94
95    fn s(text: &str) -> AzString {
96        AzString::from(String::from(text))
97    }
98
99    fn node_ctx(dom: usize, node: usize) -> DragContext {
100        DragContext::node_drag(
101            DomId { inner: dom },
102            NodeId::new(node),
103            LogicalPosition::new(1.0, 2.0),
104            DragData::new(),
105            7,
106        )
107    }
108
109    /// Mutable access to the `NodeDrag` inside a context built by [`node_ctx`].
110    fn node_drag_of(ctx: &mut DragContext) -> &mut NodeDrag {
111        match &mut ctx.drag_type {
112            ActiveDragType::Node(n) => n,
113            _ => unreachable!("node_ctx always builds ActiveDragType::Node"),
114        }
115    }
116
117    fn file_ctx(files: &[&str]) -> DragContext {
118        DragContext::file_drop(
119            files.iter().copied().map(s).collect(),
120            LogicalPosition::new(3.0, 4.0),
121            1,
122        )
123    }
124
125    fn file_drop_of(ctx: &mut DragContext) -> &mut FileDropDrag {
126        match &mut ctx.drag_type {
127            ActiveDragType::FileDrop(f) => f,
128            _ => unreachable!("file_ctx always builds ActiveDragType::FileDrop"),
129        }
130    }
131
132    fn dom_node(dom: usize, node: usize) -> DomNodeId {
133        DomNodeId {
134            dom: DomId { inner: dom },
135            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
136        }
137    }
138
139    /// The `NodeId` the produced `DragState` points at, decoded back out of the
140    /// 1-based `NodeHierarchyItemId` encoding.
141    fn source_node_id(state: &DragState) -> Option<NodeId> {
142        state
143            .source_node
144            .as_option()
145            .and_then(|d| d.node.into_crate_internal())
146    }
147
148    fn file_path_str(state: &DragState) -> Option<&str> {
149        state.file_path.as_option().map(AzString::as_str)
150    }
151
152    // ---------------------------------------------------------------------
153    // node drags: field mapping + id-encoding boundaries
154    // ---------------------------------------------------------------------
155
156    #[test]
157    fn node_drag_maps_every_field() {
158        let mut ctx = node_ctx(3, 42);
159        node_drag_of(&mut ctx).current_drop_target = OptionDomNodeId::Some(dom_node(3, 9));
160
161        let state = DragState::from_context(&ctx).expect("node drag must map to DragState");
162
163        assert_eq!(state.drag_type, DragType::Node);
164        assert_eq!(state.source_node.as_option().map(|d| d.dom.inner), Some(3));
165        assert_eq!(source_node_id(&state), Some(NodeId::new(42)));
166        assert_eq!(
167            state.current_drop_target,
168            OptionDomNodeId::Some(dom_node(3, 9))
169        );
170        // A node drag carries no file, ever.
171        assert!(state.file_path.is_none());
172    }
173
174    /// Node index 0 is a *real* node, not "absent". The 1-based encoding used by
175    /// `NodeHierarchyItemId` exists precisely so that these two cannot collide —
176    /// if `from_context` ever stored the raw index, node 0 would decode as `None`.
177    #[test]
178    fn node_id_zero_is_not_encoded_as_none() {
179        let ctx = node_ctx(0, 0);
180        let state = DragState::from_context(&ctx).expect("node 0 is a valid drag source");
181
182        let source = state.source_node.as_option().expect("source must be Some");
183        assert_ne!(source.node, NodeHierarchyItemId::NONE);
184        assert_eq!(source.node.into_raw(), 1, "0-based 0 encodes to 1-based 1");
185        assert_eq!(source_node_id(&state), Some(NodeId::new(0)));
186    }
187
188    /// The largest node index that the 1-based encoding can represent
189    /// (`usize::MAX - 1` → raw `usize::MAX`). Must round-trip exactly, with no
190    /// wrap to a small (aliasing) index.
191    #[test]
192    fn node_id_max_encodable_round_trips() {
193        let max = usize::MAX - 1;
194        let ctx = node_ctx(usize::MAX, max);
195        let state = DragState::from_context(&ctx).expect("extreme ids still map");
196
197        let source = state.source_node.as_option().expect("source must be Some");
198        assert_eq!(source.dom.inner, usize::MAX);
199        assert_eq!(source.node.into_raw(), usize::MAX);
200        assert_eq!(source_node_id(&state), Some(NodeId::new(max)));
201    }
202
203    /// `from_context` must read `current_drop_target`, never `previous_drop_target`
204    /// (the latter only exists to synthesize DragEnter/DragLeave events).
205    #[test]
206    fn node_drag_uses_current_not_previous_drop_target() {
207        let mut ctx = node_ctx(1, 5);
208        {
209            let drag = node_drag_of(&mut ctx);
210            drag.previous_drop_target = OptionDomNodeId::Some(dom_node(1, 100));
211            drag.current_drop_target = OptionDomNodeId::Some(dom_node(1, 200));
212        }
213
214        let state = DragState::from_context(&ctx).unwrap();
215        assert_eq!(
216            state.current_drop_target,
217            OptionDomNodeId::Some(dom_node(1, 200))
218        );
219    }
220
221    #[test]
222    fn node_drag_without_drop_target_stays_none() {
223        let ctx = node_ctx(1, 5);
224        let state = DragState::from_context(&ctx).unwrap();
225        assert!(state.current_drop_target.is_none());
226    }
227
228    /// A drop target living in a *different* DOM than the source must be carried
229    /// through verbatim — `from_context` must not "helpfully" rewrite the dom id.
230    #[test]
231    fn node_drag_cross_dom_drop_target_is_not_rewritten() {
232        let mut ctx = node_ctx(1, 5);
233        node_drag_of(&mut ctx).current_drop_target = OptionDomNodeId::Some(dom_node(9, 5));
234
235        let state = DragState::from_context(&ctx).unwrap();
236        assert_eq!(state.source_node.as_option().map(|d| d.dom.inner), Some(1));
237        assert_eq!(
238            state.current_drop_target.as_option().map(|d| d.dom.inner),
239            Some(9)
240        );
241    }
242
243    /// Non-finite / extreme drag coordinates are not part of `DragState`, so they
244    /// must neither panic nor leak into the conversion.
245    #[test]
246    fn node_drag_with_nan_and_infinite_positions_does_not_panic() {
247        for pos in [
248            LogicalPosition::new(f32::NAN, f32::NAN),
249            LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
250            LogicalPosition::new(f32::MAX, f32::MIN),
251            LogicalPosition::new(-0.0, f32::MIN_POSITIVE),
252        ] {
253            let mut ctx = DragContext::node_drag(
254                DomId::ROOT_ID,
255                NodeId::new(1),
256                pos,
257                DragData::new(),
258                0,
259            );
260            {
261                let drag = node_drag_of(&mut ctx);
262                drag.current_position = pos;
263                drag.drag_offset = pos;
264            }
265
266            let state = DragState::from_context(&ctx).expect("positions never gate the mapping");
267            assert_eq!(state.drag_type, DragType::Node);
268            assert_eq!(source_node_id(&state), Some(NodeId::new(1)));
269        }
270    }
271
272    /// Even when the payload *looks* like a file (a `text/uri-list` MIME entry),
273    /// a node drag must not populate `file_path` — that field is FileDrop-only.
274    #[test]
275    fn node_drag_with_file_like_payload_still_has_no_file_path() {
276        let mut data = DragData::new();
277        data.set_data("text/uri-list", b"file:///etc/passwd".to_vec());
278        data.set_text("/etc/passwd");
279        data.effect_allowed = DragEffect::All;
280
281        let ctx = DragContext::node_drag(
282            DomId::ROOT_ID,
283            NodeId::new(2),
284            LogicalPosition::zero(),
285            data,
286            0,
287        );
288
289        let state = DragState::from_context(&ctx).unwrap();
290        assert_eq!(state.drag_type, DragType::Node);
291        assert!(
292            state.file_path.is_none(),
293            "file_path must stay None for node drags regardless of payload"
294        );
295    }
296
297    /// `drop_accepted` / `drop_effect` have no representation in the old API and
298    /// must not change whether (or how) the drag maps.
299    #[test]
300    fn node_drag_drop_effect_flags_do_not_affect_mapping() {
301        let baseline = DragState::from_context(&node_ctx(1, 5)).unwrap();
302
303        for (accepted, effect) in [
304            (true, DropEffect::Move),
305            (true, DropEffect::Copy),
306            (false, DropEffect::Link),
307            (false, DropEffect::None),
308        ] {
309            let mut ctx = node_ctx(1, 5);
310            {
311                let drag = node_drag_of(&mut ctx);
312                drag.drop_accepted = accepted;
313                drag.drop_effect = effect;
314            }
315            assert_eq!(DragState::from_context(&ctx).unwrap(), baseline);
316        }
317    }
318
319    // ---------------------------------------------------------------------
320    // file drops: path handling
321    // ---------------------------------------------------------------------
322
323    #[test]
324    fn file_drop_maps_first_path_and_has_no_source_node() {
325        let ctx = file_ctx(&["/tmp/a.txt"]);
326        let state = DragState::from_context(&ctx).expect("file drop must map");
327
328        assert_eq!(state.drag_type, DragType::File);
329        assert!(
330            state.source_node.is_none(),
331            "a file drop has no source DOM node"
332        );
333        assert_eq!(file_path_str(&state), Some("/tmp/a.txt"));
334    }
335
336    /// Multi-file drops are lossy in the old API: only the *first* path survives.
337    /// Pin that down so a future "take the last one" regression is caught.
338    #[test]
339    fn file_drop_takes_the_first_path_not_the_last() {
340        let ctx = file_ctx(&["/first", "/second", "/third"]);
341        let state = DragState::from_context(&ctx).unwrap();
342        assert_eq!(file_path_str(&state), Some("/first"));
343    }
344
345    #[test]
346    fn file_drop_with_empty_file_list_yields_none_path() {
347        let ctx = file_ctx(&[]);
348        let state = DragState::from_context(&ctx).expect("an empty file drop still maps");
349
350        assert_eq!(state.drag_type, DragType::File);
351        assert!(state.source_node.is_none());
352        assert!(
353            state.file_path.is_none(),
354            "no files => no path (must not panic on first())"
355        );
356    }
357
358    /// An empty *string* is a present-but-empty path, which is a different thing
359    /// from "no file at all". `Option::first().cloned()` must not collapse them.
360    #[test]
361    fn file_drop_empty_string_path_is_some_not_none() {
362        let ctx = file_ctx(&["", "/ignored"]);
363        let state = DragState::from_context(&ctx).unwrap();
364
365        assert!(state.file_path.is_some());
366        assert_eq!(file_path_str(&state), Some(""));
367    }
368
369    /// Paths are opaque bytes to azul: emoji, RTL overrides, combining marks,
370    /// newlines and embedded NULs must survive byte-exactly, not be sanitized
371    /// or truncated at the first NUL (a classic C-string bug at this boundary).
372    #[test]
373    fn file_drop_unicode_and_control_characters_round_trip() {
374        for path in [
375            "/tmp/\u{1F600}\u{1F3F4}\u{E0067}.png",
376            "/tmp/\u{202E}gnp.exe",
377            "/tmp/e\u{0301}\u{0327}\u{0308}.txt",
378            "/tmp/\u{4F60}\u{597D}/\u{043C}\u{0438}\u{0440}.txt",
379            "/tmp/line\nbreak\ttab.txt",
380            "/tmp/nul\u{0000}after.txt",
381            "\u{FEFF}/tmp/bom.txt",
382        ] {
383            let ctx = file_ctx(&[path]);
384            let state = DragState::from_context(&ctx).unwrap();
385
386            assert_eq!(
387                file_path_str(&state),
388                Some(path),
389                "path must round-trip byte-exactly"
390            );
391            assert_eq!(
392                file_path_str(&state).unwrap().len(),
393                path.len(),
394                "no truncation (e.g. at an embedded NUL)"
395            );
396        }
397    }
398
399    #[test]
400    fn file_drop_with_huge_path_round_trips() {
401        let huge = format!("/tmp/{}.txt", "x".repeat(64 * 1024));
402        let ctx = file_ctx(&[huge.as_str()]);
403        let state = DragState::from_context(&ctx).unwrap();
404
405        assert_eq!(file_path_str(&state), Some(huge.as_str()));
406    }
407
408    #[test]
409    fn file_drop_with_many_files_still_returns_the_first() {
410        let paths: Vec<String> = (0..10_000).map(|i| format!("/tmp/f{i}")).collect();
411        let ctx = DragContext::file_drop(
412            paths.iter().map(|p| s(p)).collect(),
413            LogicalPosition::zero(),
414            0,
415        );
416
417        let state = DragState::from_context(&ctx).unwrap();
418        assert_eq!(file_path_str(&state), Some("/tmp/f0"));
419    }
420
421    #[test]
422    fn file_drop_drop_target_passes_through() {
423        let mut ctx = file_ctx(&["/tmp/a"]);
424        file_drop_of(&mut ctx).drop_target = OptionDomNodeId::Some(dom_node(2, 77));
425
426        let state = DragState::from_context(&ctx).unwrap();
427        assert_eq!(
428            state.current_drop_target,
429            OptionDomNodeId::Some(dom_node(2, 77))
430        );
431        // ...and the source node is still None: a file has no source node.
432        assert!(state.source_node.is_none());
433    }
434
435    #[test]
436    fn file_drop_with_nan_position_does_not_panic() {
437        let ctx = DragContext::file_drop(
438            vec![s("/tmp/a")],
439            LogicalPosition::new(f32::NAN, f32::INFINITY),
440            u64::MAX,
441        );
442
443        let state = DragState::from_context(&ctx).unwrap();
444        assert_eq!(state.drag_type, DragType::File);
445        assert_eq!(file_path_str(&state), Some("/tmp/a"));
446    }
447
448    // ---------------------------------------------------------------------
449    // drag types that deliberately do NOT map to the old API
450    // ---------------------------------------------------------------------
451
452    #[test]
453    fn text_selection_drag_maps_to_none() {
454        let ctx = DragContext::text_selection(
455            DomId::ROOT_ID,
456            NodeId::new(4),
457            LogicalPosition::new(10.0, 10.0),
458            1,
459        );
460        assert!(DragState::from_context(&ctx).is_none());
461    }
462
463    /// Degenerate scrollbar geometry (zero track, NaN content length) must still
464    /// take the `None` arm rather than dividing / panicking anywhere.
465    #[test]
466    fn scrollbar_thumb_drag_maps_to_none_even_with_degenerate_metrics() {
467        for (track, content, viewport, offset) in [
468            (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32),
469            (f32::NAN, f32::NAN, f32::NAN, f32::NAN),
470            (f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN),
471            (-1.0, -1.0, -1.0, -1.0),
472        ] {
473            for axis in [ScrollbarAxis::Vertical, ScrollbarAxis::Horizontal] {
474                let ctx = DragContext::scrollbar_thumb(
475                    DomId::ROOT_ID,
476                    NodeId::new(0),
477                    axis,
478                    LogicalPosition::zero(),
479                    offset,
480                    track,
481                    content,
482                    viewport,
483                    0,
484                );
485                assert!(DragState::from_context(&ctx).is_none());
486            }
487        }
488    }
489
490    #[test]
491    fn window_move_drag_maps_to_none() {
492        let ctx = DragContext::window_move(
493            LogicalPosition::zero(),
494            WindowPosition::Uninitialized,
495            0,
496        );
497        assert!(DragState::from_context(&ctx).is_none());
498    }
499
500    #[test]
501    fn window_resize_drag_maps_to_none_for_every_edge() {
502        for edge in [
503            WindowResizeEdge::Top,
504            WindowResizeEdge::Bottom,
505            WindowResizeEdge::Left,
506            WindowResizeEdge::Right,
507            WindowResizeEdge::TopLeft,
508            WindowResizeEdge::TopRight,
509            WindowResizeEdge::BottomLeft,
510            WindowResizeEdge::BottomRight,
511        ] {
512            let ctx = DragContext::new(
513                ActiveDragType::WindowResize(WindowResizeDrag {
514                    edge,
515                    start_position: LogicalPosition::zero(),
516                    current_position: LogicalPosition::new(f32::NAN, 0.0),
517                    initial_width: u32::MAX,
518                    initial_height: 0,
519                }),
520                u64::MAX,
521            );
522            assert!(DragState::from_context(&ctx).is_none());
523        }
524    }
525
526    // ---------------------------------------------------------------------
527    // conversion invariants
528    // ---------------------------------------------------------------------
529
530    /// `from_context` takes `&DragContext`: it must be a pure read. Converting
531    /// twice must yield equal states and leave the context untouched.
532    #[test]
533    fn from_context_is_pure_and_deterministic() {
534        for ctx in [node_ctx(1, 5), file_ctx(&["/tmp/a", "/tmp/b"])] {
535            let before = ctx.clone();
536
537            let first = DragState::from_context(&ctx);
538            let second = DragState::from_context(&ctx);
539
540            assert_eq!(first, second);
541            assert!(ctx == before, "from_context must not mutate the context");
542        }
543    }
544
545    /// Neither the session id nor the cancelled flag is representable in the old
546    /// API — a cancelled drag still converts. Pinned as *current* behaviour: any
547    /// caller that wants "no drag after Escape" must check `ctx.cancelled` itself.
548    #[test]
549    fn cancelled_flag_and_session_id_do_not_change_the_mapping() {
550        let mut ctx = node_ctx(1, 5);
551        let baseline = DragState::from_context(&ctx).unwrap();
552
553        ctx.cancelled = true;
554        ctx.session_id = u64::MAX;
555
556        let cancelled = DragState::from_context(&ctx)
557            .expect("cancelled drags still convert (DragState has no cancel bit)");
558        assert_eq!(cancelled, baseline);
559    }
560
561    /// Distinct sources must produce distinct states — i.e. the mapping is not
562    /// collapsing ids somewhere (which the 1-based encoding makes easy to get
563    /// wrong at 0 / 1).
564    #[test]
565    fn different_node_ids_produce_different_states() {
566        let a = DragState::from_context(&node_ctx(0, 0)).unwrap();
567        let b = DragState::from_context(&node_ctx(0, 1)).unwrap();
568        let c = DragState::from_context(&node_ctx(1, 0)).unwrap();
569
570        assert_ne!(a, b);
571        assert_ne!(a, c);
572        assert_ne!(b, c);
573    }
574
575    /// Node drags and file drops must never compare equal, even when both point
576    /// at the same drop target.
577    #[test]
578    fn node_and_file_states_never_collide() {
579        let mut node = node_ctx(0, 0);
580        node_drag_of(&mut node).current_drop_target = OptionDomNodeId::Some(dom_node(0, 3));
581        let mut file = file_ctx(&[]);
582        file_drop_of(&mut file).drop_target = OptionDomNodeId::Some(dom_node(0, 3));
583
584        let node_state = DragState::from_context(&node).unwrap();
585        let file_state = DragState::from_context(&file).unwrap();
586
587        assert_ne!(node_state, file_state);
588        assert_ne!(node_state.drag_type, file_state.drag_type);
589        assert_eq!(node_state.current_drop_target, file_state.current_drop_target);
590    }
591
592    /// A `DragState` is exactly its four fields: cloning is value-identical and
593    /// no field aliases another (a clone must not share the `file_path` buffer in
594    /// a way that shows up as inequality after drop).
595    #[test]
596    fn drag_state_clone_is_equal_and_independent() {
597        let ctx = file_ctx(&["/tmp/\u{1F600}.png"]);
598        let state = DragState::from_context(&ctx).unwrap();
599
600        let cloned = state.clone();
601        drop(state);
602
603        assert_eq!(file_path_str(&cloned), Some("/tmp/\u{1F600}.png"));
604        assert_eq!(cloned.drag_type, DragType::File);
605    }
606
607    /// `OptionDragState` round-trips through `Option<DragState>` in both
608    /// directions without changing the payload.
609    #[test]
610    fn option_drag_state_round_trips() {
611        let state = DragState::from_context(&node_ctx(2, 8)).unwrap();
612
613        let wrapped: OptionDragState = Some(state.clone()).into();
614        assert!(wrapped.is_some());
615        let unwrapped: Option<DragState> = wrapped.into();
616        assert_eq!(unwrapped, Some(state));
617
618        let empty: OptionDragState = None.into();
619        assert!(empty.is_none());
620        let none_back: Option<DragState> = empty.into();
621        assert_eq!(none_back, None);
622        assert_eq!(OptionDragState::default(), OptionDragState::None);
623    }
624}