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}