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 internal drags or non-file drops).
354    pub fn files(&self) -> &[PathBuf] {
355        self.external.as_ref().map_or(&[], |e| &e.files)
356    }
357
358    /// Dropped plain text, if any.
359    pub fn text(&self) -> Option<&str> {
360        self.external.as_ref().and_then(|e| e.text.as_deref())
361    }
362
363    /// Dropped non-file URLs (empty for internal drags or file-only drops).
364    pub fn uris(&self) -> &[String] {
365        self.external.as_ref().map_or(&[], |e| &e.uris)
366    }
367
368    /// Advertised data formats (platform MIME types / type identifiers) for an
369    /// external drag. Available at drag-enter even before the bytes transfer —
370    /// the basis for hover-time accept/reject when `files()` / `text()` /
371    /// `uris()` aren't populated yet (Wayland). See
372    /// [`ExternalDropData::formats`].
373    pub fn formats(&self) -> &[String] {
374        self.external.as_ref().map_or(&[], |e| &e.formats)
375    }
376
377    /// Add a MIME-typed byte representation.
378    pub fn with_mime(mut self, mime_type: &str, data: Vec<u8>) -> Self {
379        self.mime_data.insert(mime_type.to_string(), data);
380        self
381    }
382
383    /// Populate the structured external view (`files` / `text` / `uris`) from
384    /// this payload's MIME data, so a drag that round-tripped through the OS
385    /// and re-entered the app satisfies file/text drop targets (e.g. a
386    /// `DropZone`) **as well as** typed in-app targets. The typed value is
387    /// preserved, so `get_typed::<T>()` still works.
388    ///
389    /// **Origin is deliberately left `Internal`.** A round-tripped drag still
390    /// *originated from this app*, so `is_external()` stays `false` — an in-app
391    /// reorder target that rejects external drags via `!is_external()` must
392    /// still accept its own drag coming back. `is_external()` means "came from
393    /// another application", not "carries file/text data". Consumers that want
394    /// content should test `files()` / `text()` / `uris()` (or `has_typed`),
395    /// which is what `DropZone` does. So a payload here can legitimately have
396    /// `origin == Internal` *and* a populated external view.
397    ///
398    /// No-op if an external view is already present or no recognizable MIME is
399    /// carried.
400    pub fn enrich_external_from_mime(&mut self) {
401        if self.external.is_some() {
402            return;
403        }
404        let mut ext = ExternalDropData::default();
405        if let Some(bytes) = self.mime_data.get("text/uri-list") {
406            let parsed = ExternalDropData::from_uri_list(&String::from_utf8_lossy(bytes));
407            ext.files = parsed.files;
408            ext.uris = parsed.uris;
409        }
410        if let Some(bytes) = self
411            .mime_data
412            .get("text/plain")
413            .or_else(|| self.mime_data.get("text/plain;charset=utf-8"))
414        {
415            ext.text = Some(String::from_utf8_lossy(bytes).into_owned());
416        }
417        ext.formats = self.mime_data.keys().cloned().collect();
418        if !ext.is_empty() {
419            self.external = Some(ext);
420        }
421    }
422
423    /// Extract the typed payload by type. Returns `None` if the type doesn't match
424    /// or no typed payload was set.
425    pub fn get_typed<T: 'static>(&self) -> Option<&T> {
426        self.typed.as_ref().and_then(|v| v.downcast_ref::<T>())
427    }
428
429    /// Take the typed payload, consuming it from the DragPayload.
430    pub fn take_typed<T: 'static>(&mut self) -> Option<T> {
431        let boxed = self.typed.take()?;
432        match boxed.downcast::<T>() {
433            Ok(value) => Some(*value),
434            Err(boxed) => {
435                // Put it back if the type didn't match
436                self.typed = Some(boxed);
437                None
438            }
439        }
440    }
441
442    /// Whether this payload has a typed value of the given type.
443    pub fn has_typed<T: 'static>(&self) -> bool {
444        self.typed
445            .as_ref()
446            .is_some_and(|v| v.downcast_ref::<T>().is_some())
447    }
448
449    /// Whether this payload has data for the given MIME type.
450    pub fn has_mime(&self, mime_type: &str) -> bool {
451        self.mime_data.contains_key(mime_type)
452    }
453
454    /// Get MIME-typed byte data.
455    pub fn get_mime(&self, mime_type: &str) -> Option<&[u8]> {
456        self.mime_data.get(mime_type).map(|v| v.as_slice())
457    }
458
459    /// List all MIME types in this payload.
460    pub fn mime_types(&self) -> Vec<&str> {
461        self.mime_data.keys().map(|s| s.as_str()).collect()
462    }
463
464    /// Whether this payload carries anything an OS drag could export. A drag
465    /// becomes OS-exportable simply by populating `mime_data` (via
466    /// [`Self::with_mime`]) or by carrying external file / text / URI data.
467    pub fn is_os_exportable(&self) -> bool {
468        !self.mime_data.is_empty()
469            || self
470                .external
471                .as_ref()
472                .is_some_and(|e| !e.files.is_empty() || e.text.is_some() || !e.uris.is_empty())
473    }
474
475    /// Extract the flattened, OS-exportable view used when an in-app drag
476    /// escalates to an OS drag at the window boundary.
477    ///
478    /// Internal drags populate only `mime_data` (via [`Self::with_mime`]); the
479    /// `files` / `text` / `uris` fields come from [`ExternalDropData`] and are
480    /// empty for them. So when those structured fields are absent, derive them
481    /// from the canonical `text/uri-list` / `text/plain` MIME entries — the
482    /// platform backends (NSURL items, etc.) need the structured form.
483    pub fn to_outbound(&self) -> OutboundDragData {
484        let ext = self.external.as_ref();
485        let mut files = ext.map(|e| e.files.clone()).unwrap_or_default();
486        let mut uris = ext.map(|e| e.uris.clone()).unwrap_or_default();
487        let mut text = ext.and_then(|e| e.text.clone());
488
489        if files.is_empty()
490            && uris.is_empty()
491            && let Some(bytes) = self.mime_data.get("text/uri-list")
492        {
493            let parsed = ExternalDropData::from_uri_list(&String::from_utf8_lossy(bytes));
494            files = parsed.files;
495            uris = parsed.uris;
496        }
497        if text.is_none()
498            && let Some(bytes) = self
499                .mime_data
500                .get("text/plain")
501                .or_else(|| self.mime_data.get("text/plain;charset=utf-8"))
502        {
503            text = Some(String::from_utf8_lossy(bytes).into_owned());
504        }
505
506        OutboundDragData {
507            mime: self.mime_data.clone(),
508            files,
509            text,
510            uris,
511        }
512    }
513}
514
515impl std::fmt::Debug for DragPayload {
516    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517        f.debug_struct("DragPayload")
518            .field("origin", &self.origin)
519            .field("has_typed", &self.typed.is_some())
520            .field("mime_types", &self.mime_types())
521            .field("files", &self.files())
522            .finish()
523    }
524}
525
526/// Trait for typed drag data with an associated MIME type.
527///
528/// Implementing this trait allows a type to be used as both an intra-application
529/// typed payload and (in the future) a cross-application MIME-serialized payload.
530pub trait DragData: Any + std::fmt::Debug + 'static {
531    /// The canonical MIME type for this data type.
532    fn mime_type(&self) -> &'static str;
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[derive(Debug, Clone, PartialEq)]
540    struct ChapterDrag {
541        chapter_id: u32,
542        title: String,
543    }
544
545    impl DragData for ChapterDrag {
546        fn mime_type(&self) -> &'static str {
547            "application/x-skribisto-chapter"
548        }
549    }
550
551    #[test]
552    fn typed_roundtrip() {
553        let payload = DragPayload::typed(ChapterDrag {
554            chapter_id: 42,
555            title: "Introduction".into(),
556        });
557
558        assert!(payload.has_typed::<ChapterDrag>());
559        assert!(!payload.has_typed::<String>());
560
561        let extracted = payload.get_typed::<ChapterDrag>().unwrap();
562        assert_eq!(extracted.chapter_id, 42);
563        assert_eq!(extracted.title, "Introduction");
564    }
565
566    #[test]
567    fn take_typed() {
568        let mut payload = DragPayload::typed(42_u32);
569        assert!(payload.has_typed::<u32>());
570        let val = payload.take_typed::<u32>().unwrap();
571        assert_eq!(val, 42);
572        assert!(!payload.has_typed::<u32>());
573    }
574
575    #[test]
576    fn take_typed_wrong_type_preserves() {
577        let mut payload = DragPayload::typed(42_u32);
578        assert!(payload.take_typed::<String>().is_none());
579        assert!(payload.has_typed::<u32>()); // still there
580    }
581
582    #[test]
583    fn mime_data() {
584        let payload = DragPayload::empty()
585            .with_mime("text/plain", b"hello".to_vec())
586            .with_mime("text/html", b"<b>hello</b>".to_vec());
587
588        assert!(payload.has_mime("text/plain"));
589        assert!(payload.has_mime("text/html"));
590        assert!(!payload.has_mime("image/png"));
591
592        assert_eq!(payload.get_mime("text/plain"), Some(b"hello".as_slice()));
593        assert_eq!(payload.mime_types().len(), 2);
594    }
595
596    #[test]
597    fn typed_with_mime() {
598        let payload = DragPayload::typed(ChapterDrag {
599            chapter_id: 1,
600            title: "Ch1".into(),
601        })
602        .with_mime("text/plain", b"Ch1".to_vec());
603
604        assert!(payload.has_typed::<ChapterDrag>());
605        assert!(payload.has_mime("text/plain"));
606    }
607
608    #[test]
609    fn debug_format() {
610        let payload = DragPayload::typed(42_u32);
611        let s = format!("{:?}", payload);
612        assert!(s.contains("DragPayload"));
613        assert!(s.contains("has_typed: true"));
614    }
615
616    #[test]
617    fn external_payload_origin_and_accessors() {
618        let data = ExternalDropData {
619            files: vec![PathBuf::from("/tmp/a.png")],
620            text: Some("hello".into()),
621            uris: vec!["https://example.com".into()],
622            mime: HashMap::new(),
623            formats: Vec::new(),
624        };
625        let payload = DragPayload::external(data);
626
627        assert!(payload.is_external());
628        assert_eq!(payload.origin(), DragOrigin::External);
629        assert!(!payload.has_typed::<u32>());
630        assert_eq!(payload.files(), &[PathBuf::from("/tmp/a.png")]);
631        assert_eq!(payload.text(), Some("hello"));
632        assert_eq!(payload.uris(), &["https://example.com".to_string()]);
633        // Canonical MIME entries are synthesized.
634        assert!(payload.has_mime("text/plain"));
635        assert!(payload.has_mime("text/uri-list"));
636    }
637
638    #[test]
639    fn internal_payload_has_no_external_data() {
640        let payload = DragPayload::typed(7_u32);
641        assert!(!payload.is_external());
642        assert_eq!(payload.origin(), DragOrigin::Internal);
643        assert!(payload.files().is_empty());
644        assert_eq!(payload.text(), None);
645        assert!(payload.uris().is_empty());
646    }
647
648    #[test]
649    fn uri_list_parses_files_and_urls() {
650        let list = "#comment\r\nfile:///tmp/My%20File.txt\r\nhttps://example.com/a%2Bb\r\n";
651        let data = ExternalDropData::from_uri_list(list);
652        // `file://` local-path decoding is OS-specific (see `uri_path_to_pathbuf`):
653        // on Windows a driveless `/tmp/…` loses its leading slash and uses `\`
654        // separators, so it lands as a relative `tmp\…`.
655        #[cfg(windows)]
656        let expected_file = PathBuf::from(r"tmp\My File.txt");
657        #[cfg(not(windows))]
658        let expected_file = PathBuf::from("/tmp/My File.txt");
659        assert_eq!(data.files, vec![expected_file]);
660        assert_eq!(data.uris, vec!["https://example.com/a+b".to_string()]);
661        assert!(data.mime.contains_key("text/uri-list"));
662    }
663
664    #[test]
665    fn percent_decode_handles_utf8_and_invalid() {
666        // "café" encoded; plus an invalid trailing % left verbatim.
667        assert_eq!(percent_decode("caf%C3%A9"), "café");
668        assert_eq!(percent_decode("100%"), "100%");
669        assert_eq!(percent_decode("a%2"), "a%2");
670    }
671
672    #[test]
673    fn uri_list_uses_crlf_and_terminates_the_last_line() {
674        let data = OutboundDragData {
675            files: vec![PathBuf::from("/tmp/a.txt")],
676            ..Default::default()
677        };
678        assert_eq!(data.to_uri_list(), "file:///tmp/a.txt\r\n");
679    }
680
681    /// The characters that would otherwise corrupt the list format itself:
682    /// `#` starts a comment line, CR/LF split one path into two.
683    #[cfg(not(windows))]
684    #[test]
685    fn uri_list_escapes_characters_that_would_break_the_format() {
686        let data = OutboundDragData {
687            files: vec![PathBuf::from("/tmp/a#b c.txt")],
688            ..Default::default()
689        };
690        let list = data.to_uri_list();
691        assert_eq!(list, "file:///tmp/a%23b%20c.txt\r\n");
692        // ...and it survives the round trip as one file with its real name.
693        let parsed = ExternalDropData::from_uri_list(&list);
694        assert_eq!(parsed.files, vec![PathBuf::from("/tmp/a#b c.txt")]);
695    }
696
697    #[cfg(not(windows))]
698    #[test]
699    fn uri_list_round_trips_non_ascii_and_literal_percent() {
700        // A filename containing a literal "%20" must not decode back to a
701        // space — that only round-trips if the encoder escaped the `%`.
702        let files = vec![
703            PathBuf::from("/tmp/café.txt"),
704            PathBuf::from("/tmp/100%20.txt"),
705        ];
706        let data = OutboundDragData {
707            files: files.clone(),
708            ..Default::default()
709        };
710        let parsed = ExternalDropData::from_uri_list(&data.to_uri_list());
711        assert_eq!(parsed.files, files);
712    }
713
714    #[test]
715    fn uri_list_passes_urls_through_without_re_encoding() {
716        // Re-encoding an already-encoded URI would double every `%`.
717        let data = OutboundDragData {
718            uris: vec!["https://example.com/a%2Bb".to_string()],
719            ..Default::default()
720        };
721        assert_eq!(data.to_uri_list(), "https://example.com/a%2Bb\r\n");
722    }
723
724    #[cfg(not(windows))]
725    #[test]
726    fn file_uri_to_pathbuf_unix() {
727        assert_eq!(uri_path_to_pathbuf("/tmp/a%20b"), PathBuf::from("/tmp/a b"));
728    }
729
730    #[cfg(windows)]
731    #[test]
732    fn file_uri_to_pathbuf_windows_drive_letters() {
733        // RFC-correct, plus the naive forms apps actually produce — all must
734        // resolve to the drive path, never a UNC host.
735        assert_eq!(
736            uri_path_to_pathbuf("/C:/Users/a/main.rs"),
737            PathBuf::from(r"C:\Users\a\main.rs")
738        );
739        assert_eq!(
740            uri_path_to_pathbuf("C:/Users/a/main.rs"),
741            PathBuf::from(r"C:\Users\a\main.rs")
742        );
743        // Native separators + mixed — what `file://{CARGO_MANIFEST_DIR}/src/main.rs`
744        // yields on Windows (the file-drop demo's outbound file drag).
745        assert_eq!(
746            uri_path_to_pathbuf(r"C:\Users\a\proj/src/main.rs"),
747            PathBuf::from(r"C:\Users\a\proj\src\main.rs")
748        );
749        // A genuine UNC path still parses as UNC.
750        assert_eq!(
751            uri_path_to_pathbuf("server/share/f.txt"),
752            PathBuf::from(r"\\server\share\f.txt")
753        );
754    }
755}