Skip to main content

teksilo_core/
drag_payload.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Typed drag payload for intra-application drag and drop.
5//!
6//! `DragPayload` carries the data being dragged. For intra-application DnD,
7//! the fast path stores a typed Rust value (via `Any`). For drops originating
8//! outside the application (files / text / URLs dragged from the OS), the
9//! payload carries an [`ExternalDropData`] plus MIME-typed byte
10//! representations.
11
12use std::any::Any;
13use std::collections::HashMap;
14use std::path::PathBuf;
15
16/// Where a drag originated.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DragOrigin {
19    /// Started inside the application via `EventContext::start_drag`.
20    Internal,
21    /// Delivered by the OS — files / text / URLs dragged from another
22    /// application or the file manager into a window.
23    External,
24}
25
26/// How a drag the source widget started ended. Delivered to the source's
27/// `on_drag_ended` handler so it can react (e.g. remove the item on a move).
28///
29/// One unified completion outcome for *every* drag a widget starts — whether
30/// it dropped on an in-app target, was exported to another application via the
31/// OS, or was cancelled.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DropOutcome {
34    /// Dropped on an in-app target. `accepted` is what the target's `on_drop`
35    /// returned.
36    InApp {
37        /// Whether the drop target accepted the payload.
38        accepted: bool,
39    },
40    /// Exported to another application via the OS as a copy.
41    OsCopy,
42    /// Exported to another application via the OS as a move — the source
43    /// should remove the dragged item.
44    OsMove,
45    /// No drop happened: Escape, dropped on nothing, or the OS rejected it.
46    Cancelled,
47}
48
49/// Flattened, OS-exportable view of a [`DragPayload`], handed to the platform
50/// backend when an in-app drag escalates to an OS drag at the window boundary.
51///
52/// Plain data with no GUI dependency so [`crate::window::WindowOps`] can name
53/// it without teksilo-core depending on teksilo-platform.
54#[derive(Debug, Clone, Default)]
55pub struct OutboundDragData {
56    /// MIME-typed byte representations to advertise to the OS.
57    pub mime: HashMap<String, Vec<u8>>,
58    /// Filesystem paths, if the payload represents files.
59    pub files: Vec<PathBuf>,
60    /// Plain text, if any.
61    pub text: Option<String>,
62    /// Non-file URLs, if any.
63    pub uris: Vec<String>,
64}
65
66impl OutboundDragData {
67    /// True when there is nothing to hand the OS.
68    pub fn is_empty(&self) -> bool {
69        self.mime.is_empty() && self.files.is_empty() && self.text.is_none() && self.uris.is_empty()
70    }
71
72    /// Render [`Self::files`] and [`Self::uris`] as a `text/uri-list` payload
73    /// (RFC 2483) — the inverse of [`ExternalDropData::from_uri_list`].
74    ///
75    /// Paths are percent-encoded, which is not cosmetic: an un-encoded `#`
76    /// starts a comment line, an un-encoded CR/LF splits one path into two,
77    /// and a filename that genuinely contains `%20` would come back as a
78    /// space. Lines are CRLF-terminated including the last, as the RFC
79    /// specifies and as GTK and Qt both expect.
80    pub fn to_uri_list(&self) -> String {
81        let mut list = String::new();
82        for path in &self.files {
83            list.push_str("file://");
84            list.push_str(&percent_encode_path(&path.to_string_lossy()));
85            list.push_str("\r\n");
86        }
87        for uri in &self.uris {
88            // Already a URI: the caller encoded it (or it came from the OS
89            // that way), so re-encoding would double-escape every `%`.
90            list.push_str(uri);
91            list.push_str("\r\n");
92        }
93        list
94    }
95}
96
97/// Percent-encode a filesystem path for a `file:` URI. `/` stays a separator;
98/// everything outside RFC 3986's unreserved set is escaped byte-wise, so
99/// non-ASCII names encode as UTF-8 and decode back through
100/// [`percent_decode`] unchanged.
101fn percent_encode_path(path: &str) -> String {
102    let mut out = String::with_capacity(path.len());
103    for byte in path.bytes() {
104        match byte {
105            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
106                out.push(byte as char);
107            }
108            _ => out.push_str(&format!("%{byte:02X}")),
109        }
110    }
111    out
112}
113
114/// Optional drag image for an OS drag (RGBA8, top-left origin, premultiplied
115/// alpha not assumed). `None` lets the platform draw a default. `hot_x` /
116/// `hot_y` are the cursor hotspot in logical pixels from the image's top-left.
117#[derive(Debug, Clone)]
118pub struct DragImageData {
119    /// Pixel data, `width * height * 4` bytes (RGBA8, row-major, top-left).
120    pub rgba: Vec<u8>,
121    /// Image width in pixels.
122    pub width: u32,
123    /// Image height in pixels.
124    pub height: u32,
125    /// Cursor hotspot X in logical pixels from the top-left.
126    pub hot_x: f32,
127    /// Cursor hotspot Y in logical pixels from the top-left.
128    pub hot_y: f32,
129}
130
131/// Data delivered by an external (OS) drag-and-drop.
132///
133/// Platform backends populate the fields they can extract from the native
134/// drag payload. `files` are real filesystem paths, `text` is plain UTF-8
135/// text, `uris` are non-`file://` URLs (e.g. `https://…`). `mime` holds any
136/// additional raw representations keyed by MIME type, for consumers that want
137/// the bytes verbatim.
138#[derive(Debug, Clone, Default)]
139pub struct ExternalDropData {
140    /// Dropped filesystem paths.
141    pub files: Vec<PathBuf>,
142    /// Dropped plain text, if any.
143    pub text: Option<String>,
144    /// Dropped non-file URLs (http, https, mailto, …).
145    pub uris: Vec<String>,
146    /// Additional raw MIME representations, keyed by MIME type.
147    pub mime: HashMap<String, Vec<u8>>,
148    /// Advertised data formats (platform MIME types / type identifiers),
149    /// available at drag-*enter* time even before the bytes are transferred.
150    /// On Wayland the actual `files` / `text` / `uris` are only filled at drop
151    /// (received over a pipe), so a drop target validates on hover from these
152    /// format strings (e.g. `"text/uri-list"` ⇒ a file drag). macOS / Windows
153    /// read the full payload at enter, so they fill the data directly and may
154    /// leave this empty.
155    pub formats: Vec<String>,
156}
157
158impl ExternalDropData {
159    /// Build from a `text/uri-list` payload (RFC 2483): one URI per line,
160    /// `#`-prefixed comment lines ignored, CRLF line endings, percent-encoded.
161    /// `file://` URIs become [`Self::files`]; everything else becomes
162    /// [`Self::uris`]. The raw list is also retained under the
163    /// `text/uri-list` MIME key.
164    pub fn from_uri_list(list: &str) -> Self {
165        let mut files = Vec::new();
166        let mut uris = Vec::new();
167        for line in list.lines() {
168            let line = line.trim();
169            if line.is_empty() || line.starts_with('#') {
170                continue;
171            }
172            if let Some(rest) = line.strip_prefix("file://") {
173                files.push(uri_path_to_pathbuf(rest));
174            } else {
175                uris.push(percent_decode(line));
176            }
177        }
178        let mut mime = HashMap::new();
179        mime.insert("text/uri-list".to_string(), list.as_bytes().to_vec());
180        Self {
181            files,
182            text: None,
183            uris,
184            mime,
185            formats: vec!["text/uri-list".to_string()],
186        }
187    }
188
189    /// True when there is nothing usable in this payload.
190    pub fn is_empty(&self) -> bool {
191        self.files.is_empty() && self.text.is_none() && self.uris.is_empty()
192    }
193}
194
195/// Decode a `file://` URI tail (everything after `file://`) into a `PathBuf`.
196///
197/// Handles the optional `//host` authority (UNC on Windows, dropped on Unix
198/// for the local host), strips it, percent-decodes the path, and on Windows
199/// turns any leading drive letter into a native `C:\…` path.
200fn uri_path_to_pathbuf(after_scheme: &str) -> PathBuf {
201    // `after_scheme` is what followed `file://`. A leading authority segment
202    // ends at the next `/`. The common local form is `file:///path` →
203    // authority empty → `after_scheme` starts with `/`.
204    let (authority, path) = match after_scheme.find('/') {
205        Some(idx) => (&after_scheme[..idx], &after_scheme[idx..]),
206        None => (after_scheme, ""),
207    };
208    let decoded = percent_decode(path);
209
210    #[cfg(windows)]
211    {
212        // Windows `file://` URIs come in several shapes: the RFC-correct
213        // `file:///C:/path` (empty authority), plus the common naive forms
214        // `file://C:/path` / `file://C:\path` — produced by apps that just
215        // splice a native path after `file://` — which put the drive letter
216        // where a UNC authority would go. Detect a leading drive letter (`X:`),
217        // after an optional slash, and treat the whole tail as a drive path,
218        // never a UNC host. Otherwise a naive `file://C:\dir\f` would decode to
219        // the invalid `\\C:\dir\f`.
220        let whole = percent_decode(after_scheme);
221        let candidate = whole.strip_prefix('/').unwrap_or(&whole);
222        let b = candidate.as_bytes();
223        if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
224            return PathBuf::from(candidate.replace('/', r"\"));
225        }
226        // UNC share: file://server/share → \\server\share
227        if !authority.is_empty() {
228            let mut s = String::from(r"\\");
229            s.push_str(authority);
230            s.push_str(&decoded.replace('/', r"\"));
231            return PathBuf::from(s);
232        }
233        // Local absolute: file:///path → strip the leading slash.
234        let trimmed = decoded.strip_prefix('/').unwrap_or(&decoded);
235        PathBuf::from(trimmed.replace('/', r"\"))
236    }
237    #[cfg(not(windows))]
238    {
239        let _ = authority; // non-local authorities are rare; keep the path
240        PathBuf::from(decoded)
241    }
242}
243
244/// Percent-decode a URI component (`%20` → space, etc.). Invalid escapes are
245/// left verbatim. Operates on UTF-8 bytes so multi-byte sequences decode
246/// correctly.
247fn percent_decode(s: &str) -> String {
248    let bytes = s.as_bytes();
249    let mut out = Vec::with_capacity(bytes.len());
250    let mut i = 0;
251    while i < bytes.len() {
252        if bytes[i] == b'%' && i + 2 < bytes.len() {
253            let hi = (bytes[i + 1] as char).to_digit(16);
254            let lo = (bytes[i + 2] as char).to_digit(16);
255            if let (Some(hi), Some(lo)) = (hi, lo) {
256                out.push((hi * 16 + lo) as u8);
257                i += 3;
258                continue;
259            }
260        }
261        out.push(bytes[i]);
262        i += 1;
263    }
264    String::from_utf8_lossy(&out).into_owned()
265}
266
267/// A drag payload carrying data from a drag source to a drop target.
268///
269/// For intra-application transfers, use `DragPayload::typed(data)` to store
270/// a typed Rust value. Drop targets extract it via `get_typed::<T>()`.
271///
272/// For drops from the OS, use `DragPayload::external(data)`; targets read
273/// `files()` / `text()` / `uris()` (and `origin()` / `is_external()` to
274/// distinguish the source).
275pub struct DragPayload {
276    /// Typed intra-app payload (fast path, no serialization).
277    typed: Option<Box<dyn Any>>,
278    /// MIME-typed byte data (cross-app DnD, populated for external drags).
279    mime_data: HashMap<String, Vec<u8>>,
280    /// Where this drag came from.
281    origin: DragOrigin,
282    /// Structured external-drop data (only for `DragOrigin::External`).
283    external: Option<ExternalDropData>,
284}
285
286impl DragPayload {
287    /// Create a payload from a typed Rust value.
288    pub fn typed<T: 'static>(data: T) -> Self {
289        Self {
290            typed: Some(Box::new(data)),
291            mime_data: HashMap::new(),
292            origin: DragOrigin::Internal,
293            external: None,
294        }
295    }
296
297    /// Create an empty payload (for MIME-only transfers).
298    pub fn empty() -> Self {
299        Self {
300            typed: None,
301            mime_data: HashMap::new(),
302            origin: DragOrigin::Internal,
303            external: None,
304        }
305    }
306
307    /// Create a payload from OS-delivered external drop data.
308    ///
309    /// Synthesizes canonical MIME entries (`text/plain` from `text`,
310    /// `text/uri-list` from `files` + `uris` when not already present) so the
311    /// generic `get_mime` API and the typed `files()` / `text()` / `uris()`
312    /// accessors stay consistent.
313    pub fn external(data: ExternalDropData) -> Self {
314        let mut mime_data = data.mime.clone();
315        if let Some(text) = &data.text {
316            mime_data
317                .entry("text/plain".to_string())
318                .or_insert_with(|| text.clone().into_bytes());
319        }
320        if (!data.files.is_empty() || !data.uris.is_empty())
321            && !mime_data.contains_key("text/uri-list")
322        {
323            let mut list = String::new();
324            for f in &data.files {
325                list.push_str("file://");
326                list.push_str(&f.to_string_lossy());
327                list.push_str("\r\n");
328            }
329            for u in &data.uris {
330                list.push_str(u);
331                list.push_str("\r\n");
332            }
333            mime_data.insert("text/uri-list".to_string(), list.into_bytes());
334        }
335        Self {
336            typed: None,
337            mime_data,
338            origin: DragOrigin::External,
339            external: Some(data),
340        }
341    }
342
343    /// Where this drag originated.
344    pub fn origin(&self) -> DragOrigin {
345        self.origin
346    }
347
348    /// Whether this payload came from outside the application (an OS drop).
349    pub fn is_external(&self) -> bool {
350        self.origin == DragOrigin::External
351    }
352
353    /// Dropped filesystem paths (empty for non-file drops, and for internal
354    /// drags unless [`Self::enrich_external_from_mime`] has filled the
355    /// external view).
356    pub fn files(&self) -> &[PathBuf] {
357        self.external.as_ref().map_or(&[], |e| &e.files)
358    }
359
360    /// Dropped plain text, if any.
361    pub fn text(&self) -> Option<&str> {
362        self.external.as_ref().and_then(|e| e.text.as_deref())
363    }
364
365    /// Dropped non-file URLs (empty for file-only drops, and for internal
366    /// drags unless [`Self::enrich_external_from_mime`] has filled the
367    /// external view).
368    pub fn uris(&self) -> &[String] {
369        self.external.as_ref().map_or(&[], |e| &e.uris)
370    }
371
372    /// Advertised data formats (platform MIME types / type identifiers) for an
373    /// external drag. Available at drag-enter even before the bytes transfer —
374    /// the basis for hover-time accept/reject when `files()` / `text()` /
375    /// `uris()` aren't populated yet (Wayland). See
376    /// [`ExternalDropData::formats`].
377    pub fn formats(&self) -> &[String] {
378        self.external.as_ref().map_or(&[], |e| &e.formats)
379    }
380
381    /// Add a MIME-typed byte representation.
382    pub fn with_mime(mut self, mime_type: &str, data: Vec<u8>) -> Self {
383        self.mime_data.insert(mime_type.to_string(), data);
384        self
385    }
386
387    /// Populate the structured external view (`files` / `text` / `uris`) from
388    /// this payload's MIME data, so a drag that round-tripped through the OS
389    /// and re-entered the app satisfies file/text drop targets (e.g. a
390    /// `DropZone`) **as well as** typed in-app targets. The typed value is
391    /// preserved, so `get_typed::<T>()` still works.
392    ///
393    /// **Origin is deliberately left `Internal`.** A round-tripped drag still
394    /// *originated from this app*, so `is_external()` stays `false` — an in-app
395    /// reorder target that rejects external drags via `!is_external()` must
396    /// still accept its own drag coming back. `is_external()` means "came from
397    /// another application", not "carries file/text data". Consumers that want
398    /// content should test `files()` / `text()` / `uris()` (or `has_typed`),
399    /// which is what `DropZone` does. So a payload here can legitimately have
400    /// `origin == Internal` *and* a populated external view.
401    ///
402    /// No-op if an external view is already present or no recognizable MIME is
403    /// carried.
404    pub fn enrich_external_from_mime(&mut self) {
405        if self.external.is_some() {
406            return;
407        }
408        let mut ext = ExternalDropData::default();
409        if let Some(bytes) = self.mime_data.get("text/uri-list") {
410            let parsed = ExternalDropData::from_uri_list(&String::from_utf8_lossy(bytes));
411            ext.files = parsed.files;
412            ext.uris = parsed.uris;
413        }
414        if let Some(bytes) = self
415            .mime_data
416            .get("text/plain")
417            .or_else(|| self.mime_data.get("text/plain;charset=utf-8"))
418        {
419            ext.text = Some(String::from_utf8_lossy(bytes).into_owned());
420        }
421        ext.formats = self.mime_data.keys().cloned().collect();
422        if !ext.is_empty() {
423            self.external = Some(ext);
424        }
425    }
426
427    /// Extract the typed payload by type. Returns `None` if the type doesn't match
428    /// or no typed payload was set.
429    pub fn get_typed<T: 'static>(&self) -> Option<&T> {
430        self.typed.as_ref().and_then(|v| v.downcast_ref::<T>())
431    }
432
433    /// Take the typed payload, consuming it from the DragPayload.
434    pub fn take_typed<T: 'static>(&mut self) -> Option<T> {
435        let boxed = self.typed.take()?;
436        match boxed.downcast::<T>() {
437            Ok(value) => Some(*value),
438            Err(boxed) => {
439                // Put it back if the type didn't match
440                self.typed = Some(boxed);
441                None
442            }
443        }
444    }
445
446    /// Whether this payload has a typed value of the given type.
447    pub fn has_typed<T: 'static>(&self) -> bool {
448        self.typed
449            .as_ref()
450            .is_some_and(|v| v.downcast_ref::<T>().is_some())
451    }
452
453    /// Whether this payload has data for the given MIME type.
454    pub fn has_mime(&self, mime_type: &str) -> bool {
455        self.mime_data.contains_key(mime_type)
456    }
457
458    /// Get MIME-typed byte data.
459    pub fn get_mime(&self, mime_type: &str) -> Option<&[u8]> {
460        self.mime_data.get(mime_type).map(|v| v.as_slice())
461    }
462
463    /// List all MIME types in this payload.
464    pub fn mime_types(&self) -> Vec<&str> {
465        self.mime_data.keys().map(|s| s.as_str()).collect()
466    }
467
468    /// Whether this payload carries anything an OS drag could export. A drag
469    /// becomes OS-exportable simply by populating `mime_data` (via
470    /// [`Self::with_mime`]) or by carrying external file / text / URI data.
471    pub fn is_os_exportable(&self) -> bool {
472        !self.mime_data.is_empty()
473            || self
474                .external
475                .as_ref()
476                .is_some_and(|e| !e.files.is_empty() || e.text.is_some() || !e.uris.is_empty())
477    }
478
479    /// Extract the flattened, OS-exportable view used when an in-app drag
480    /// escalates to an OS drag at the window boundary.
481    ///
482    /// Internal drags populate only `mime_data` (via [`Self::with_mime`]); the
483    /// `files` / `text` / `uris` fields come from [`ExternalDropData`] and are
484    /// empty for them. So when those structured fields are absent, derive them
485    /// from the canonical `text/uri-list` / `text/plain` MIME entries — the
486    /// platform backends (NSURL items, etc.) need the structured form.
487    pub fn to_outbound(&self) -> OutboundDragData {
488        let ext = self.external.as_ref();
489        let mut files = ext.map(|e| e.files.clone()).unwrap_or_default();
490        let mut uris = ext.map(|e| e.uris.clone()).unwrap_or_default();
491        let mut text = ext.and_then(|e| e.text.clone());
492
493        if files.is_empty()
494            && uris.is_empty()
495            && let Some(bytes) = self.mime_data.get("text/uri-list")
496        {
497            let parsed = ExternalDropData::from_uri_list(&String::from_utf8_lossy(bytes));
498            files = parsed.files;
499            uris = parsed.uris;
500        }
501        if text.is_none()
502            && let Some(bytes) = self
503                .mime_data
504                .get("text/plain")
505                .or_else(|| self.mime_data.get("text/plain;charset=utf-8"))
506        {
507            text = Some(String::from_utf8_lossy(bytes).into_owned());
508        }
509
510        OutboundDragData {
511            mime: self.mime_data.clone(),
512            files,
513            text,
514            uris,
515        }
516    }
517}
518
519impl std::fmt::Debug for DragPayload {
520    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521        f.debug_struct("DragPayload")
522            .field("origin", &self.origin)
523            .field("has_typed", &self.typed.is_some())
524            .field("mime_types", &self.mime_types())
525            .field("files", &self.files())
526            .finish()
527    }
528}
529
530/// Trait for typed drag data with an associated MIME type.
531///
532/// Implementing this trait allows a type to be used as both an intra-application
533/// typed payload and (in the future) a cross-application MIME-serialized payload.
534pub trait DragData: Any + std::fmt::Debug + 'static {
535    /// The canonical MIME type for this data type.
536    fn mime_type(&self) -> &'static str;
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[derive(Debug, Clone, PartialEq)]
544    struct ChapterDrag {
545        chapter_id: u32,
546        title: String,
547    }
548
549    impl DragData for ChapterDrag {
550        fn mime_type(&self) -> &'static str {
551            "application/x-skribisto-chapter"
552        }
553    }
554
555    #[test]
556    fn typed_roundtrip() {
557        let payload = DragPayload::typed(ChapterDrag {
558            chapter_id: 42,
559            title: "Introduction".into(),
560        });
561
562        assert!(payload.has_typed::<ChapterDrag>());
563        assert!(!payload.has_typed::<String>());
564
565        let extracted = payload.get_typed::<ChapterDrag>().unwrap();
566        assert_eq!(extracted.chapter_id, 42);
567        assert_eq!(extracted.title, "Introduction");
568    }
569
570    #[test]
571    fn take_typed() {
572        let mut payload = DragPayload::typed(42_u32);
573        assert!(payload.has_typed::<u32>());
574        let val = payload.take_typed::<u32>().unwrap();
575        assert_eq!(val, 42);
576        assert!(!payload.has_typed::<u32>());
577    }
578
579    #[test]
580    fn take_typed_wrong_type_preserves() {
581        let mut payload = DragPayload::typed(42_u32);
582        assert!(payload.take_typed::<String>().is_none());
583        assert!(payload.has_typed::<u32>()); // still there
584    }
585
586    #[test]
587    fn mime_data() {
588        let payload = DragPayload::empty()
589            .with_mime("text/plain", b"hello".to_vec())
590            .with_mime("text/html", b"<b>hello</b>".to_vec());
591
592        assert!(payload.has_mime("text/plain"));
593        assert!(payload.has_mime("text/html"));
594        assert!(!payload.has_mime("image/png"));
595
596        assert_eq!(payload.get_mime("text/plain"), Some(b"hello".as_slice()));
597        assert_eq!(payload.mime_types().len(), 2);
598    }
599
600    #[test]
601    fn typed_with_mime() {
602        let payload = DragPayload::typed(ChapterDrag {
603            chapter_id: 1,
604            title: "Ch1".into(),
605        })
606        .with_mime("text/plain", b"Ch1".to_vec());
607
608        assert!(payload.has_typed::<ChapterDrag>());
609        assert!(payload.has_mime("text/plain"));
610    }
611
612    #[test]
613    fn debug_format() {
614        let payload = DragPayload::typed(42_u32);
615        let s = format!("{:?}", payload);
616        assert!(s.contains("DragPayload"));
617        assert!(s.contains("has_typed: true"));
618    }
619
620    #[test]
621    fn external_payload_origin_and_accessors() {
622        let data = ExternalDropData {
623            files: vec![PathBuf::from("/tmp/a.png")],
624            text: Some("hello".into()),
625            uris: vec!["https://example.com".into()],
626            mime: HashMap::new(),
627            formats: Vec::new(),
628        };
629        let payload = DragPayload::external(data);
630
631        assert!(payload.is_external());
632        assert_eq!(payload.origin(), DragOrigin::External);
633        assert!(!payload.has_typed::<u32>());
634        assert_eq!(payload.files(), &[PathBuf::from("/tmp/a.png")]);
635        assert_eq!(payload.text(), Some("hello"));
636        assert_eq!(payload.uris(), &["https://example.com".to_string()]);
637        // Canonical MIME entries are synthesized.
638        assert!(payload.has_mime("text/plain"));
639        assert!(payload.has_mime("text/uri-list"));
640    }
641
642    #[test]
643    fn internal_payload_has_no_external_data() {
644        let payload = DragPayload::typed(7_u32);
645        assert!(!payload.is_external());
646        assert_eq!(payload.origin(), DragOrigin::Internal);
647        assert!(payload.files().is_empty());
648        assert_eq!(payload.text(), None);
649        assert!(payload.uris().is_empty());
650    }
651
652    #[test]
653    fn uri_list_parses_files_and_urls() {
654        let list = "#comment\r\nfile:///tmp/My%20File.txt\r\nhttps://example.com/a%2Bb\r\n";
655        let data = ExternalDropData::from_uri_list(list);
656        // `file://` local-path decoding is OS-specific (see `uri_path_to_pathbuf`):
657        // on Windows a driveless `/tmp/…` loses its leading slash and uses `\`
658        // separators, so it lands as a relative `tmp\…`.
659        #[cfg(windows)]
660        let expected_file = PathBuf::from(r"tmp\My File.txt");
661        #[cfg(not(windows))]
662        let expected_file = PathBuf::from("/tmp/My File.txt");
663        assert_eq!(data.files, vec![expected_file]);
664        assert_eq!(data.uris, vec!["https://example.com/a+b".to_string()]);
665        assert!(data.mime.contains_key("text/uri-list"));
666    }
667
668    #[test]
669    fn percent_decode_handles_utf8_and_invalid() {
670        // "café" encoded; plus an invalid trailing % left verbatim.
671        assert_eq!(percent_decode("caf%C3%A9"), "café");
672        assert_eq!(percent_decode("100%"), "100%");
673        assert_eq!(percent_decode("a%2"), "a%2");
674    }
675
676    #[test]
677    fn uri_list_uses_crlf_and_terminates_the_last_line() {
678        let data = OutboundDragData {
679            files: vec![PathBuf::from("/tmp/a.txt")],
680            ..Default::default()
681        };
682        assert_eq!(data.to_uri_list(), "file:///tmp/a.txt\r\n");
683    }
684
685    /// The characters that would otherwise corrupt the list format itself:
686    /// `#` starts a comment line, CR/LF split one path into two.
687    #[cfg(not(windows))]
688    #[test]
689    fn uri_list_escapes_characters_that_would_break_the_format() {
690        let data = OutboundDragData {
691            files: vec![PathBuf::from("/tmp/a#b c.txt")],
692            ..Default::default()
693        };
694        let list = data.to_uri_list();
695        assert_eq!(list, "file:///tmp/a%23b%20c.txt\r\n");
696        // ...and it survives the round trip as one file with its real name.
697        let parsed = ExternalDropData::from_uri_list(&list);
698        assert_eq!(parsed.files, vec![PathBuf::from("/tmp/a#b c.txt")]);
699    }
700
701    #[cfg(not(windows))]
702    #[test]
703    fn uri_list_round_trips_non_ascii_and_literal_percent() {
704        // A filename containing a literal "%20" must not decode back to a
705        // space — that only round-trips if the encoder escaped the `%`.
706        let files = vec![
707            PathBuf::from("/tmp/café.txt"),
708            PathBuf::from("/tmp/100%20.txt"),
709        ];
710        let data = OutboundDragData {
711            files: files.clone(),
712            ..Default::default()
713        };
714        let parsed = ExternalDropData::from_uri_list(&data.to_uri_list());
715        assert_eq!(parsed.files, files);
716    }
717
718    #[test]
719    fn uri_list_passes_urls_through_without_re_encoding() {
720        // Re-encoding an already-encoded URI would double every `%`.
721        let data = OutboundDragData {
722            uris: vec!["https://example.com/a%2Bb".to_string()],
723            ..Default::default()
724        };
725        assert_eq!(data.to_uri_list(), "https://example.com/a%2Bb\r\n");
726    }
727
728    #[cfg(not(windows))]
729    #[test]
730    fn file_uri_to_pathbuf_unix() {
731        assert_eq!(uri_path_to_pathbuf("/tmp/a%20b"), PathBuf::from("/tmp/a b"));
732    }
733
734    #[cfg(windows)]
735    #[test]
736    fn file_uri_to_pathbuf_windows_drive_letters() {
737        // RFC-correct, plus the naive forms apps actually produce — all must
738        // resolve to the drive path, never a UNC host.
739        assert_eq!(
740            uri_path_to_pathbuf("/C:/Users/a/main.rs"),
741            PathBuf::from(r"C:\Users\a\main.rs")
742        );
743        assert_eq!(
744            uri_path_to_pathbuf("C:/Users/a/main.rs"),
745            PathBuf::from(r"C:\Users\a\main.rs")
746        );
747        // Native separators + mixed — what `file://{CARGO_MANIFEST_DIR}/src/main.rs`
748        // yields on Windows (the file-drop demo's outbound file drag).
749        assert_eq!(
750            uri_path_to_pathbuf(r"C:\Users\a\proj/src/main.rs"),
751            PathBuf::from(r"C:\Users\a\proj\src\main.rs")
752        );
753        // A genuine UNC path still parses as UNC.
754        assert_eq!(
755            uri_path_to_pathbuf("server/share/f.txt"),
756            PathBuf::from(r"\\server\share\f.txt")
757        );
758    }
759}