1
//! **File** drag & drop management
2
//!
3
//! Manages hovered files (drag-and-drop).
4

            
5
use alloc::vec::Vec;
6

            
7
use azul_css::AzString;
8

            
9
/// Manager for file drop state and hovered file tracking.
10
///
11
/// MWA-B7: stores ALL files of a drag/drop (multi-file drops were silently
12
/// truncated to the first path at every OS ingress site — the manager could
13
/// only hold one). The single-file accessors remain as first-element views
14
/// for existing callers.
15
#[derive(Debug, Clone, Default, PartialEq, Eq)]
16
pub struct FileDropManager {
17
    /// Files being hovered during the drag operation (empty = no hover).
18
    hovered_files: Vec<AzString>,
19
    /// Files that were dropped (cleared after one frame).
20
    dropped_files: Vec<AzString>,
21
    /// One-shot flag set when a hover ends without a drop (a non-empty →
22
    /// empty transition). Read by `determine_all_events` to emit
23
    /// `EventType::FileHoverCancel`, then cleared by the platform drag
24
    /// handler (one-shot, mirrors the dropped-files reset).
25
    hover_cancelled: bool,
26
}
27

            
28
impl FileDropManager {
29
    /// Create a new file drop manager
30
5713
    #[must_use] pub const fn new() -> Self {
31
5713
        Self {
32
5713
            hovered_files: Vec::new(),
33
5713
            dropped_files: Vec::new(),
34
5713
            hover_cancelled: false,
35
5713
        }
36
5713
    }
37

            
38
    /// Set ALL currently hovered files (MWA-B7). An empty vec behaves like a
39
    /// drag-leave (latches the hover-cancel flag).
40
2102
    pub fn set_hovered_files(&mut self, files: Vec<AzString>) {
41
2102
        if files.is_empty() {
42
1015
            if !self.hovered_files.is_empty() {
43
1011
                self.hover_cancelled = true;
44
1011
            }
45
1015
            self.hovered_files.clear();
46
1087
        } else {
47
1087
            self.hovered_files = files;
48
1087
        }
49
2102
    }
50

            
51
    /// Single-file compatibility shim over [`set_hovered_files`](Self::set_hovered_files).
52
    ///
53
    /// Platform backends call this with `Some(path)` on drag-enter
54
    /// (macOS `draggingEntered`, Windows OLE `IDropTarget::DragEnter`) and
55
    /// `None` on drag-leave (`draggingExited` / `DragLeave`). A `Some` -> `None`
56
    /// transition latches [`FileDropManager::hover_was_cancelled`] so the
57
    /// `FileHoverCancel` event can fire.
58
15
    pub fn set_hovered_file(&mut self, file: Option<AzString>) {
59
15
        match file {
60
10
            Some(f) => self.set_hovered_files(alloc::vec![f]),
61
5
            None => self.set_hovered_files(Vec::new()),
62
        }
63
15
    }
64

            
65
    /// Whether a hover ended without a drop since the last
66
    /// [`FileDropManager::clear_hover_cancelled`] (one-shot).
67
2280
    #[must_use] pub const fn hover_was_cancelled(&self) -> bool {
68
2280
        self.hover_cancelled
69
2280
    }
70

            
71
    /// Clear the one-shot hover-cancel flag. Called by the platform drag
72
    /// handler after `determine_all_events` has emitted the `FileHoverCancel`
73
    /// event (mirrors the dropped-files reset after `FileDrop`).
74
1006
    pub const fn clear_hover_cancelled(&mut self) {
75
1006
        self.hover_cancelled = false;
76
1006
    }
77

            
78
    /// First hovered file (single-file view; use
79
    /// [`get_hovered_files`](Self::get_hovered_files) for the full list).
80
1207
    #[must_use] pub fn get_hovered_file(&self) -> Option<&AzString> {
81
1207
        self.hovered_files.first()
82
1207
    }
83

            
84
    /// ALL currently hovered files (MWA-B7).
85
2059
    #[must_use] pub fn get_hovered_files(&self) -> &[AzString] {
86
2059
        &self.hovered_files
87
2059
    }
88

            
89
    /// First dropped file (single-file view; use
90
    /// [`get_dropped_files`](Self::get_dropped_files) for the full list).
91
206
    #[must_use] pub fn get_dropped_file(&self) -> Option<&AzString> {
92
206
        self.dropped_files.first()
93
206
    }
94

            
95
    /// ALL files of the drop this frame (MWA-B7; one-shot, cleared by the
96
    /// platform handler after event processing).
97
50
    #[must_use] pub fn get_dropped_files(&self) -> &[AzString] {
98
50
        &self.dropped_files
99
50
    }
100

            
101
    /// Set ALL dropped files (MWA-B7; cleared after one frame).
102
16
    pub fn set_dropped_files(&mut self, files: Vec<AzString>) {
103
16
        self.dropped_files = files;
104
16
    }
105

            
106
    /// Single-file compatibility shim over [`set_dropped_files`](Self::set_dropped_files).
107
10
    pub fn set_dropped_file(&mut self, file: Option<AzString>) {
108
10
        match file {
109
5
            Some(f) => self.dropped_files = alloc::vec![f],
110
5
            None => self.dropped_files.clear(),
111
        }
112
10
    }
113
}
114

            
115
#[cfg(test)]
116
mod tests {
117
    use super::*;
118

            
119
5
    fn s(v: &str) -> AzString {
120
5
        AzString::from(v.to_string())
121
5
    }
122

            
123
    #[test]
124
1
    fn multi_file_drop_keeps_every_path() {
125
1
        let mut m = FileDropManager::new();
126
1
        m.set_dropped_files(vec![s("/a"), s("/b"), s("/c")]);
127
1
        assert_eq!(m.get_dropped_files().len(), 3);
128
1
        assert_eq!(m.get_dropped_file().map(AzString::as_str), Some("/a"));
129
1
        m.set_dropped_file(None);
130
1
        assert!(m.get_dropped_files().is_empty());
131
1
    }
132

            
133
    #[test]
134
1
    fn hover_cancel_latches_on_empty_transition() {
135
1
        let mut m = FileDropManager::new();
136
1
        m.set_hovered_files(vec![s("/a"), s("/b")]);
137
1
        assert_eq!(m.get_hovered_files().len(), 2);
138
1
        assert!(!m.hover_was_cancelled());
139
1
        m.set_hovered_files(Vec::new());
140
1
        assert!(m.hover_was_cancelled());
141
1
        m.clear_hover_cancelled();
142
1
        assert!(!m.hover_was_cancelled());
143
1
    }
144
}
145

            
146
#[cfg(test)]
147
mod autotest_generated {
148
    use super::*;
149

            
150
    fn s(v: &str) -> AzString {
151
        AzString::from(v.to_string())
152
    }
153

            
154
    /// Every getter is consistent with the manager's internal vecs: the
155
    /// single-file views are exactly `.first()` of the corresponding slice.
156
    fn assert_invariants(m: &FileDropManager) {
157
        assert_eq!(m.get_hovered_file(), m.get_hovered_files().first());
158
        assert_eq!(m.get_dropped_file(), m.get_dropped_files().first());
159
        assert_eq!(
160
            m.get_hovered_file().is_none(),
161
            m.get_hovered_files().is_empty()
162
        );
163
        assert_eq!(
164
            m.get_dropped_file().is_none(),
165
            m.get_dropped_files().is_empty()
166
        );
167
    }
168

            
169
    // ---------------------------------------------------------------
170
    // constructor
171
    // ---------------------------------------------------------------
172

            
173
    #[test]
174
    fn new_is_empty_and_matches_default() {
175
        let m = FileDropManager::new();
176
        assert!(m.get_hovered_files().is_empty());
177
        assert!(m.get_dropped_files().is_empty());
178
        assert_eq!(m.get_hovered_file(), None);
179
        assert_eq!(m.get_dropped_file(), None);
180
        assert!(!m.hover_was_cancelled());
181
        assert_eq!(m, FileDropManager::default());
182
        assert_invariants(&m);
183
    }
184

            
185
    #[test]
186
    fn new_is_usable_in_const_context() {
187
        const M: FileDropManager = FileDropManager::new();
188
        assert!(!M.hover_was_cancelled());
189
        assert!(M.get_hovered_files().is_empty());
190
        assert!(M.get_dropped_files().is_empty());
191
    }
192

            
193
    #[test]
194
    fn getters_on_default_instance_do_not_panic() {
195
        let m = FileDropManager::default();
196
        assert_invariants(&m);
197
        // Repeated reads of the one-shot flag are pure (no state change).
198
        assert!(!m.hover_was_cancelled());
199
        assert!(!m.hover_was_cancelled());
200
    }
201

            
202
    // ---------------------------------------------------------------
203
    // hover-cancel latch semantics (the one-shot flag)
204
    // ---------------------------------------------------------------
205

            
206
    #[test]
207
    fn empty_hover_set_on_fresh_manager_does_not_latch_cancel() {
208
        // No non-empty -> empty transition happened, so nothing to cancel.
209
        let mut m = FileDropManager::new();
210
        m.set_hovered_files(Vec::new());
211
        assert!(!m.hover_was_cancelled());
212
        m.set_hovered_files(Vec::new());
213
        assert!(!m.hover_was_cancelled());
214
        m.set_hovered_file(None);
215
        assert!(!m.hover_was_cancelled());
216
        assert_invariants(&m);
217
    }
218

            
219
    #[test]
220
    fn second_empty_hover_set_after_a_latch_keeps_the_flag() {
221
        let mut m = FileDropManager::new();
222
        m.set_hovered_files(vec![s("/a")]);
223
        m.set_hovered_files(Vec::new()); // latches
224
        assert!(m.hover_was_cancelled());
225
        // The vec is already empty, so no *new* transition occurs — but the
226
        // latched flag must survive until it is explicitly cleared.
227
        m.set_hovered_files(Vec::new());
228
        assert!(m.hover_was_cancelled());
229
        m.clear_hover_cancelled();
230
        assert!(!m.hover_was_cancelled());
231
    }
232

            
233
    #[test]
234
    fn nonempty_to_nonempty_hover_never_latches_cancel() {
235
        let mut m = FileDropManager::new();
236
        for i in 0..64 {
237
            m.set_hovered_files(vec![s(&format!("/f{i}"))]);
238
            assert!(!m.hover_was_cancelled());
239
        }
240
        assert_eq!(m.get_hovered_files().len(), 1, "sets replace, never append");
241
        assert_eq!(m.get_hovered_file().map(AzString::as_str), Some("/f63"));
242
    }
243

            
244
    #[test]
245
    fn re_entering_hover_does_not_clear_a_pending_cancel() {
246
        // Documented one-shot semantics: only `clear_hover_cancelled` clears
247
        // the flag. A leave -> re-enter sequence within one frame therefore
248
        // leaves the cancel pending *and* a live hover.
249
        let mut m = FileDropManager::new();
250
        m.set_hovered_file(Some(s("/a")));
251
        m.set_hovered_file(None);
252
        assert!(m.hover_was_cancelled());
253
        m.set_hovered_file(Some(s("/b")));
254
        assert!(m.hover_was_cancelled());
255
        assert_eq!(m.get_hovered_file().map(AzString::as_str), Some("/b"));
256
    }
257

            
258
    #[test]
259
    fn clear_hover_cancelled_is_idempotent_and_leaves_files_alone() {
260
        let mut m = FileDropManager::new();
261
        m.set_hovered_files(vec![s("/a"), s("/b")]);
262
        m.set_dropped_files(vec![s("/d")]);
263
        m.clear_hover_cancelled();
264
        m.clear_hover_cancelled();
265
        assert!(!m.hover_was_cancelled());
266
        assert_eq!(m.get_hovered_files().len(), 2);
267
        assert_eq!(m.get_dropped_files().len(), 1);
268
        assert_invariants(&m);
269
    }
270

            
271
    #[test]
272
    fn dropped_file_setters_never_touch_the_hover_cancel_flag() {
273
        let mut m = FileDropManager::new();
274
        m.set_dropped_files(vec![s("/a")]);
275
        m.set_dropped_files(Vec::new());
276
        m.set_dropped_file(Some(s("/b")));
277
        m.set_dropped_file(None);
278
        assert!(!m.hover_was_cancelled());
279
        // ... and a latched flag is not cleared by drop activity either.
280
        m.set_hovered_files(vec![s("/h")]);
281
        m.set_hovered_files(Vec::new());
282
        assert!(m.hover_was_cancelled());
283
        m.set_dropped_files(vec![s("/c")]);
284
        m.set_dropped_file(None);
285
        assert!(m.hover_was_cancelled());
286
    }
287

            
288
    #[test]
289
    fn drop_does_not_implicitly_end_the_hover() {
290
        // A real OS sequence: drag-enter, drop, then drag-exit. The manager
291
        // keeps the hovered paths across the drop; only the explicit exit
292
        // (empty hover set) latches the cancel.
293
        let mut m = FileDropManager::new();
294
        m.set_hovered_files(vec![s("/a")]);
295
        m.set_dropped_files(vec![s("/a")]);
296
        assert_eq!(m.get_hovered_files().len(), 1);
297
        assert!(!m.hover_was_cancelled());
298
        m.set_hovered_files(Vec::new());
299
        assert!(m.hover_was_cancelled());
300
        assert!(m.get_hovered_files().is_empty());
301
        assert_eq!(m.get_dropped_files().len(), 1, "drop survives the exit");
302
    }
303

            
304
    // ---------------------------------------------------------------
305
    // round-trips: set == get, order + duplicates preserved
306
    // ---------------------------------------------------------------
307

            
308
    #[test]
309
    fn hovered_round_trip_preserves_order_and_duplicates() {
310
        let files = vec![s("/z"), s("/a"), s("/z"), s(""), s("/a")];
311
        let mut m = FileDropManager::new();
312
        m.set_hovered_files(files.clone());
313
        assert_eq!(m.get_hovered_files(), &files[..]);
314
        assert_eq!(m.get_hovered_file(), Some(&files[0]));
315
        assert_invariants(&m);
316
    }
317

            
318
    #[test]
319
    fn dropped_round_trip_preserves_order_and_duplicates() {
320
        let files = vec![s("/z"), s("/a"), s("/z"), s(""), s("/a")];
321
        let mut m = FileDropManager::new();
322
        m.set_dropped_files(files.clone());
323
        assert_eq!(m.get_dropped_files(), &files[..]);
324
        assert_eq!(m.get_dropped_file(), Some(&files[0]));
325
        assert_invariants(&m);
326
    }
327

            
328
    #[test]
329
    fn single_file_shims_agree_with_the_multi_file_setters() {
330
        let mut shim = FileDropManager::new();
331
        let mut multi = FileDropManager::new();
332

            
333
        shim.set_hovered_file(Some(s("/p")));
334
        multi.set_hovered_files(vec![s("/p")]);
335
        shim.set_dropped_file(Some(s("/q")));
336
        multi.set_dropped_files(vec![s("/q")]);
337
        assert_eq!(shim, multi);
338

            
339
        shim.set_hovered_file(None);
340
        multi.set_hovered_files(Vec::new());
341
        shim.set_dropped_file(None);
342
        multi.set_dropped_files(Vec::new());
343
        assert_eq!(shim, multi);
344
        assert!(shim.hover_was_cancelled() && multi.hover_was_cancelled());
345
    }
346

            
347
    #[test]
348
    fn hovered_and_dropped_state_are_independent() {
349
        let mut m = FileDropManager::new();
350
        m.set_hovered_files(vec![s("/h1"), s("/h2")]);
351
        m.set_dropped_files(vec![s("/d1")]);
352
        assert_eq!(m.get_hovered_files().len(), 2);
353
        assert_eq!(m.get_dropped_files().len(), 1);
354

            
355
        m.set_dropped_file(None); // one-shot drop reset
356
        assert!(m.get_dropped_files().is_empty());
357
        assert_eq!(m.get_hovered_files().len(), 2, "hover untouched by reset");
358
        assert_invariants(&m);
359
    }
360

            
361
    // ---------------------------------------------------------------
362
    // adversarial payloads: empty / unicode / NUL / huge / many
363
    // ---------------------------------------------------------------
364

            
365
    #[test]
366
    fn a_single_empty_path_is_a_hover_not_a_drag_leave() {
367
        // `vec![""]` is NON-empty, so it must NOT latch the cancel flag — an
368
        // empty *path* and an empty *list* are different things.
369
        let mut m = FileDropManager::new();
370
        m.set_hovered_files(vec![s("")]);
371
        assert!(!m.hover_was_cancelled());
372
        assert_eq!(m.get_hovered_files().len(), 1);
373
        assert_eq!(m.get_hovered_file().map(AzString::as_str), Some(""));
374

            
375
        let mut shim = FileDropManager::new();
376
        shim.set_hovered_file(Some(s("")));
377
        assert!(!shim.hover_was_cancelled());
378
        assert_eq!(shim.get_hovered_file().map(AzString::as_str), Some(""));
379
        assert_eq!(m, shim);
380
    }
381

            
382
    #[test]
383
    fn unicode_paths_round_trip_byte_exact() {
384
        let paths = [
385
            "/tmp/файл.txt",                       // Cyrillic
386
            "/tmp/文件/資料.csv",                  // CJK
387
            "/tmp/🦀/emoji 💾.bin",                // astral plane
388
            "/tmp/\u{202E}gpj.exe",                // RTL override (spoofing)
389
            "/tmp/e\u{0301}\u{0301}\u{0301}.txt",  // stacked combining marks
390
            "/tmp/zero\u{200B}width.txt",          // zero-width space
391
            "/tmp/\u{FFFD}replacement.txt",        // U+FFFD
392
            "  /tmp/ leading and trailing  ",      // whitespace kept verbatim
393
            "/tmp/newline\nand\ttab.txt",          // control chars
394
        ];
395
        let files: Vec<AzString> = paths.iter().map(|p| s(p)).collect();
396

            
397
        let mut m = FileDropManager::new();
398
        m.set_hovered_files(files.clone());
399
        m.set_dropped_files(files.clone());
400
        for (i, p) in paths.iter().enumerate() {
401
            assert_eq!(m.get_hovered_files()[i].as_str(), *p);
402
            assert_eq!(m.get_dropped_files()[i].as_str(), *p);
403
            assert_eq!(m.get_hovered_files()[i].as_str().len(), p.len());
404
        }
405
        assert_invariants(&m);
406
    }
407

            
408
    #[test]
409
    fn path_with_interior_nul_byte_is_preserved() {
410
        // Rust strings may contain NUL; nothing here goes through a C string,
411
        // so the byte must survive the round-trip untruncated.
412
        let path = "/tmp/a\0b/c\0.txt";
413
        let mut m = FileDropManager::new();
414
        m.set_dropped_file(Some(s(path)));
415
        let got = m.get_dropped_file().expect("dropped file present");
416
        assert_eq!(got.as_str(), path);
417
        assert_eq!(got.as_str().len(), path.len());
418
        assert_eq!(got.as_str().matches('\0').count(), 2);
419
    }
420

            
421
    #[test]
422
    fn huge_path_round_trips_without_truncation() {
423
        // 256 KiB of path, mixed ASCII + multi-byte.
424
        let mut path = String::from("/tmp/");
425
        for _ in 0..32_768 {
426
            path.push_str("aä🦀/"); // 1 + 2 + 4 + 1 = 8 bytes per iteration
427
        }
428
        path.push_str("end.txt");
429
        let byte_len = path.len();
430
        assert!(byte_len > 256 * 1024);
431

            
432
        let mut m = FileDropManager::new();
433
        m.set_hovered_file(Some(AzString::from(path.clone())));
434
        let got = m.get_hovered_file().expect("hovered file present");
435
        assert_eq!(got.as_str().len(), byte_len);
436
        assert!(got.as_str().ends_with("end.txt"));
437
        assert_eq!(got.as_str(), path.as_str());
438
        assert!(!m.hover_was_cancelled());
439
    }
440

            
441
    #[test]
442
    fn ten_thousand_dropped_files_keep_length_and_order() {
443
        let n = 10_000usize;
444
        let files: Vec<AzString> = (0..n).map(|i| s(&format!("/f/{i}"))).collect();
445
        let mut m = FileDropManager::new();
446
        m.set_dropped_files(files);
447

            
448
        assert_eq!(m.get_dropped_files().len(), n);
449
        assert_eq!(m.get_dropped_file().map(AzString::as_str), Some("/f/0"));
450
        assert_eq!(m.get_dropped_files()[n - 1].as_str(), "/f/9999");
451
        assert_invariants(&m);
452

            
453
        m.set_dropped_files(Vec::new());
454
        assert!(m.get_dropped_files().is_empty());
455
        assert_eq!(m.get_dropped_file(), None);
456
        assert!(!m.hover_was_cancelled(), "drops never latch hover-cancel");
457
    }
458

            
459
    #[test]
460
    fn many_hover_enter_leave_cycles_stay_consistent() {
461
        let mut m = FileDropManager::new();
462
        for i in 0..1_000 {
463
            m.set_hovered_files(vec![s(&format!("/f{i}")), s("/other")]);
464
            assert_eq!(m.get_hovered_files().len(), 2, "no accumulation");
465
            assert!(!m.hover_was_cancelled(), "cleared at the end of each cycle");
466
            m.set_hovered_files(Vec::new());
467
            assert!(m.hover_was_cancelled());
468
            assert!(m.get_hovered_files().is_empty());
469
            assert_eq!(m.get_hovered_file(), None);
470
            m.clear_hover_cancelled();
471
        }
472
        assert_eq!(m, FileDropManager::new());
473
    }
474

            
475
    // ---------------------------------------------------------------
476
    // derived-trait invariants
477
    // ---------------------------------------------------------------
478

            
479
    #[test]
480
    fn clone_equals_original_and_is_independent() {
481
        let mut m = FileDropManager::new();
482
        m.set_hovered_files(vec![s("/a"), s("/b")]);
483
        m.set_dropped_files(vec![s("/c")]);
484
        m.set_hovered_files(Vec::new()); // latch the flag too
485

            
486
        let c = m.clone();
487
        assert_eq!(c, m);
488

            
489
        // Mutating the clone must not touch the original.
490
        let mut c2 = c.clone();
491
        c2.clear_hover_cancelled();
492
        c2.set_dropped_files(Vec::new());
493
        assert!(m.hover_was_cancelled());
494
        assert_eq!(m.get_dropped_files().len(), 1);
495
        assert_ne!(c2, m);
496
    }
497

            
498
    #[test]
499
    fn equality_accounts_for_the_hover_cancel_flag() {
500
        let mut latched = FileDropManager::new();
501
        latched.set_hovered_files(vec![s("/a")]);
502
        latched.set_hovered_files(Vec::new());
503

            
504
        let fresh = FileDropManager::new();
505
        // Same (empty) file lists, different flag => not equal.
506
        assert_eq!(latched.get_hovered_files(), fresh.get_hovered_files());
507
        assert_eq!(latched.get_dropped_files(), fresh.get_dropped_files());
508
        assert_ne!(latched, fresh);
509

            
510
        let mut cleared = latched.clone();
511
        cleared.clear_hover_cancelled();
512
        assert_eq!(cleared, fresh);
513
    }
514

            
515
    #[test]
516
    fn debug_output_contains_the_paths() {
517
        let mut m = FileDropManager::new();
518
        m.set_hovered_files(vec![s("/tmp/🦀.txt")]);
519
        let dbg = alloc::format!("{m:?}");
520
        assert!(dbg.contains("/tmp/🦀.txt"), "unexpected Debug output: {dbg}");
521
        assert!(dbg.contains("hover_cancelled"));
522
    }
523
}