Skip to main content

azul_layout/managers/
file_drop.rs

1//! **File** drag & drop management
2//!
3//! Manages hovered files (drag-and-drop).
4
5use alloc::vec::Vec;
6
7use 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)]
16pub 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
28impl FileDropManager {
29    /// Create a new file drop manager
30    #[must_use] pub const fn new() -> Self {
31        Self {
32            hovered_files: Vec::new(),
33            dropped_files: Vec::new(),
34            hover_cancelled: false,
35        }
36    }
37
38    /// Set ALL currently hovered files (MWA-B7). An empty vec behaves like a
39    /// drag-leave (latches the hover-cancel flag).
40    pub fn set_hovered_files(&mut self, files: Vec<AzString>) {
41        if files.is_empty() {
42            if !self.hovered_files.is_empty() {
43                self.hover_cancelled = true;
44            }
45            self.hovered_files.clear();
46        } else {
47            self.hovered_files = files;
48        }
49    }
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    pub fn set_hovered_file(&mut self, file: Option<AzString>) {
59        match file {
60            Some(f) => self.set_hovered_files(alloc::vec![f]),
61            None => self.set_hovered_files(Vec::new()),
62        }
63    }
64
65    /// Whether a hover ended without a drop since the last
66    /// [`FileDropManager::clear_hover_cancelled`] (one-shot).
67    #[must_use] pub const fn hover_was_cancelled(&self) -> bool {
68        self.hover_cancelled
69    }
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    pub const fn clear_hover_cancelled(&mut self) {
75        self.hover_cancelled = false;
76    }
77
78    /// First hovered file (single-file view; use
79    /// [`get_hovered_files`](Self::get_hovered_files) for the full list).
80    #[must_use] pub fn get_hovered_file(&self) -> Option<&AzString> {
81        self.hovered_files.first()
82    }
83
84    /// ALL currently hovered files (MWA-B7).
85    #[must_use] pub fn get_hovered_files(&self) -> &[AzString] {
86        &self.hovered_files
87    }
88
89    /// First dropped file (single-file view; use
90    /// [`get_dropped_files`](Self::get_dropped_files) for the full list).
91    #[must_use] pub fn get_dropped_file(&self) -> Option<&AzString> {
92        self.dropped_files.first()
93    }
94
95    /// ALL files of the drop this frame (MWA-B7; one-shot, cleared by the
96    /// platform handler after event processing).
97    #[must_use] pub fn get_dropped_files(&self) -> &[AzString] {
98        &self.dropped_files
99    }
100
101    /// Set ALL dropped files (MWA-B7; cleared after one frame).
102    pub fn set_dropped_files(&mut self, files: Vec<AzString>) {
103        self.dropped_files = files;
104    }
105
106    /// Single-file compatibility shim over [`set_dropped_files`](Self::set_dropped_files).
107    pub fn set_dropped_file(&mut self, file: Option<AzString>) {
108        match file {
109            Some(f) => self.dropped_files = alloc::vec![f],
110            None => self.dropped_files.clear(),
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn s(v: &str) -> AzString {
120        AzString::from(v.to_string())
121    }
122
123    #[test]
124    fn multi_file_drop_keeps_every_path() {
125        let mut m = FileDropManager::new();
126        m.set_dropped_files(vec![s("/a"), s("/b"), s("/c")]);
127        assert_eq!(m.get_dropped_files().len(), 3);
128        assert_eq!(m.get_dropped_file().map(AzString::as_str), Some("/a"));
129        m.set_dropped_file(None);
130        assert!(m.get_dropped_files().is_empty());
131    }
132
133    #[test]
134    fn hover_cancel_latches_on_empty_transition() {
135        let mut m = FileDropManager::new();
136        m.set_hovered_files(vec![s("/a"), s("/b")]);
137        assert_eq!(m.get_hovered_files().len(), 2);
138        assert!(!m.hover_was_cancelled());
139        m.set_hovered_files(Vec::new());
140        assert!(m.hover_was_cancelled());
141        m.clear_hover_cancelled();
142        assert!(!m.hover_was_cancelled());
143    }
144}
145
146#[cfg(test)]
147mod 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}