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

            
16
use azul_core::dom::{DomNodeId, OptionDomNodeId};
17
use azul_core::drag::{ActiveDragType, DragContext};
18
use azul_css::{impl_option, impl_option_inner, OptionString};
19

            
20
/// Type of drag operation
21
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22
#[repr(C)]
23
pub 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)]
33
pub 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

            
44
impl DragState {
45
    /// Create `DragState` from a `DragContext` (for backwards compatibility)
46
62
    #[must_use] pub fn from_context(ctx: &DragContext) -> Option<Self> {
47
62
        match &ctx.drag_type {
48
25
            ActiveDragType::Node(node_drag) => Some(Self {
49
25
                drag_type: DragType::Node,
50
25
                source_node: OptionDomNodeId::Some(DomNodeId {
51
25
                    dom: node_drag.dom_id,
52
25
                    node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(node_drag.node_id)),
53
25
                }),
54
25
                current_drop_target: node_drag.current_drop_target,
55
25
                file_path: OptionString::None,
56
25
            }),
57
19
            ActiveDragType::FileDrop(file_drop) => Some(Self {
58
19
                drag_type: DragType::File,
59
19
                source_node: OptionDomNodeId::None,
60
19
                current_drop_target: file_drop.drop_target,
61
19
                file_path: file_drop.files.as_ref().first().cloned().into(),
62
19
            }),
63
18
            _ => None, // Other drag types don't map to the old API
64
        }
65
62
    }
66
}
67

            
68
impl_option!(
69
    DragState,
70
    OptionDragState,
71
    copy = false,
72
    [Debug, Clone, PartialEq, Eq]
73
);
74

            
75
#[cfg(test)]
76
mod 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
}