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}