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/// When an OS-exportable in-app drag is handed to the platform as a native
50/// drag. Set on the payload with [`DragPayload::with_os_handoff`], or on a data
51/// view with its `export_os_handoff` builder.
52///
53/// Has no effect on a payload that carries nothing an OS drag could export
54/// ([`DragPayload::is_os_exportable`] is `false`): such a drag stays in the
55/// application whatever this says.
56#[non_exhaustive]
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum OsHandoff {
59 /// Hand the drag to the platform when the pointer leaves the source
60 /// window. Until then it is an in-app drag, with its preview, and a drop
61 /// inside the window is an in-app drop. The default.
62 #[default]
63 AtWindowEdge,
64 /// Hand the drag to the platform on the first pointer move after the drag
65 /// starts, even while the pointer is still over the source window, so the
66 /// platform decides which window is under it.
67 ///
68 /// This is what a file export needs when the destination window can lie
69 /// wholly inside the source window's rectangle (a file manager in front
70 /// of a virtual machine's screen): the source window keeps receiving the
71 /// pointer while the drag is in-app, so it can never see the pointer leave,
72 /// and only the platform knows that another window is in front.
73 ///
74 /// What it costs:
75 ///
76 /// - No in-app drag preview once the platform has the drag: the platform
77 /// draws its own drag image.
78 /// - A drop on the source window, or on another window of the
79 /// application, goes through the platform and back, so in-app hover
80 /// feedback during the drag depends on the platform delivering the
81 /// application's own drag back to it.
82 /// - The handoff is asked for on the first real pointer move, and commits
83 /// only once the platform has started the native drag. A release before
84 /// that ends the drag as [`DropOutcome::Cancelled`], with no drop.
85 ///
86 /// A drop back on the source window reports [`DropOutcome::InApp`] to the
87 /// source, so a data view exporting with
88 /// `DragTransferMode::Move` still removes the rows it moved to another view
89 /// of the same window. A drop on another window of the application reports
90 /// the platform's outcome ([`DropOutcome::OsCopy`]).
91 ///
92 /// Verification status: headless tests cover the widget tree's side, with
93 /// a memory backend; the native paths have not been verified on any
94 /// desktop yet. The desktop passes are listed in `docs/drag-and-drop.md`
95 /// §11.5.
96 Eager,
97}
98
99/// Identifies one native drag a platform backend starts on its own loop, so
100/// that the events it posts about it later (its start, its end) are not
101/// credited to another drag: one released before its start, whose late
102/// events arrive while the next drag is waiting for its own.
103///
104/// A backend takes a fresh one with [`OsDragId::next`] when it answers
105/// [`OsDragStart::Pending`], and tags the drag's events with it.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107pub struct OsDragId(u64);
108
109impl OsDragId {
110 /// A fresh id, unique in the process.
111 pub fn next() -> Self {
112 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
113 Self(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
114 }
115
116 /// The number behind the id, for logs.
117 pub fn raw(self) -> u64 {
118 self.0
119 }
120}
121
122/// What the platform did with a request to start a native OS drag
123/// ([`WindowOps::begin_os_drag`](crate::window::WindowOps::begin_os_drag)).
124///
125/// Some platforms start a native drag synchronously, inside the request
126/// (macOS); others start it a little later on their own loop (Wayland, X11, and
127/// Windows, whose drag loop is modal and cannot run inside the dispatch that
128/// asked for it). The in-app drag session must stay alive until the platform
129/// really has the pointer, or a release in between finds no session and the
130/// drop is lost; this is how a backend says which case it is in.
131#[non_exhaustive]
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum OsDragStart {
134 /// Nothing started. The in-app drag continues, and a later pointer move may
135 /// ask again.
136 Declined,
137 /// The native drag is running: the platform has the pointer now. The
138 /// framework hands the in-app session over at once.
139 Started,
140 /// The platform accepted the request and will start the native drag on its
141 /// own loop. The in-app session stays alive until the platform confirms the
142 /// start ([`WidgetTree::confirm_os_drag`](crate::WidgetTree::confirm_os_drag)),
143 /// or reports that the drag ended without starting
144 /// ([`WidgetTree::handle_os_drag_ended_for`](crate::WidgetTree::handle_os_drag_ended_for)),
145 /// each naming the drag by this id.
146 Pending(OsDragId),
147}
148
149/// Flattened, OS-exportable view of a [`DragPayload`], handed to the platform
150/// backend when an in-app drag escalates to an OS drag at the window boundary.
151///
152/// Plain data with no GUI dependency so [`crate::window::WindowOps`] can name
153/// it without teksilo-core depending on teksilo-platform.
154#[derive(Debug, Clone, Default)]
155pub struct OutboundDragData {
156 /// MIME-typed byte representations to advertise to the OS.
157 pub mime: HashMap<String, Vec<u8>>,
158 /// Filesystem paths, if the payload represents files.
159 pub files: Vec<PathBuf>,
160 /// Plain text, if any.
161 pub text: Option<String>,
162 /// Non-file URLs, if any.
163 pub uris: Vec<String>,
164}
165
166impl OutboundDragData {
167 /// True when there is nothing to hand the OS.
168 pub fn is_empty(&self) -> bool {
169 self.mime.is_empty() && self.files.is_empty() && self.text.is_none() && self.uris.is_empty()
170 }
171
172 /// Render [`Self::files`] and [`Self::uris`] as a `text/uri-list` payload
173 /// (RFC 2483) — the inverse of [`ExternalDropData::from_uri_list`].
174 ///
175 /// Paths are percent-encoded, which is not cosmetic: an un-encoded `#`
176 /// starts a comment line, an un-encoded CR/LF splits one path into two,
177 /// and a filename that genuinely contains `%20` would come back as a
178 /// space. Lines are CRLF-terminated including the last, as the RFC
179 /// specifies and as GTK and Qt both expect.
180 pub fn to_uri_list(&self) -> String {
181 let mut list = String::new();
182 for path in &self.files {
183 list.push_str("file://");
184 list.push_str(&percent_encode_path(&path.to_string_lossy()));
185 list.push_str("\r\n");
186 }
187 for uri in &self.uris {
188 // Already a URI: the caller encoded it (or it came from the OS
189 // that way), so re-encoding would double-escape every `%`.
190 list.push_str(uri);
191 list.push_str("\r\n");
192 }
193 list
194 }
195}
196
197/// Percent-encode a filesystem path for a `file:` URI. `/` stays a separator;
198/// everything outside RFC 3986's unreserved set is escaped byte-wise, so
199/// non-ASCII names encode as UTF-8 and decode back through
200/// [`percent_decode`] unchanged.
201fn percent_encode_path(path: &str) -> String {
202 let mut out = String::with_capacity(path.len());
203 for byte in path.bytes() {
204 match byte {
205 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
206 out.push(byte as char);
207 }
208 _ => out.push_str(&format!("%{byte:02X}")),
209 }
210 }
211 out
212}
213
214/// Optional drag image for an OS drag (RGBA8, top-left origin, premultiplied
215/// alpha not assumed). `None` lets the platform draw a default. `hot_x` /
216/// `hot_y` are the cursor hotspot in logical pixels from the image's top-left.
217#[derive(Debug, Clone)]
218pub struct DragImageData {
219 /// Pixel data, `width * height * 4` bytes (RGBA8, row-major, top-left).
220 pub rgba: Vec<u8>,
221 /// Image width in pixels.
222 pub width: u32,
223 /// Image height in pixels.
224 pub height: u32,
225 /// Cursor hotspot X in logical pixels from the top-left.
226 pub hot_x: f32,
227 /// Cursor hotspot Y in logical pixels from the top-left.
228 pub hot_y: f32,
229}
230
231/// Data delivered by an external (OS) drag-and-drop.
232///
233/// Platform backends populate the fields they can extract from the native
234/// drag payload. `files` are real filesystem paths, `text` is plain UTF-8
235/// text, `uris` are non-`file://` URLs (e.g. `https://…`). `mime` holds any
236/// additional raw representations keyed by MIME type, for consumers that want
237/// the bytes verbatim.
238#[derive(Debug, Clone, Default)]
239pub struct ExternalDropData {
240 /// Dropped filesystem paths.
241 pub files: Vec<PathBuf>,
242 /// Dropped plain text, if any.
243 pub text: Option<String>,
244 /// Dropped non-file URLs (http, https, mailto, …).
245 pub uris: Vec<String>,
246 /// Additional raw MIME representations, keyed by MIME type.
247 pub mime: HashMap<String, Vec<u8>>,
248 /// Advertised data formats (platform MIME types / type identifiers),
249 /// available at drag-*enter* time even before the bytes are transferred.
250 /// On Wayland the actual `files` / `text` / `uris` are only filled at drop
251 /// (received over a pipe), so a drop target validates on hover from these
252 /// format strings (e.g. `"text/uri-list"` ⇒ a file drag). macOS / Windows
253 /// read the full payload at enter, so they fill the data directly and may
254 /// leave this empty.
255 pub formats: Vec<String>,
256}
257
258impl ExternalDropData {
259 /// Build from a `text/uri-list` payload (RFC 2483): one URI per line,
260 /// `#`-prefixed comment lines ignored, CRLF line endings, percent-encoded.
261 /// `file://` URIs become [`Self::files`]; everything else becomes
262 /// [`Self::uris`]. The raw list is also retained under the
263 /// `text/uri-list` MIME key.
264 pub fn from_uri_list(list: &str) -> Self {
265 let mut files = Vec::new();
266 let mut uris = Vec::new();
267 for line in list.lines() {
268 let line = line.trim();
269 if line.is_empty() || line.starts_with('#') {
270 continue;
271 }
272 if let Some(rest) = line.strip_prefix("file://") {
273 files.push(uri_path_to_pathbuf(rest));
274 } else {
275 uris.push(percent_decode(line));
276 }
277 }
278 let mut mime = HashMap::new();
279 mime.insert("text/uri-list".to_string(), list.as_bytes().to_vec());
280 Self {
281 files,
282 text: None,
283 uris,
284 mime,
285 formats: vec!["text/uri-list".to_string()],
286 }
287 }
288
289 /// True when there is nothing usable in this payload.
290 pub fn is_empty(&self) -> bool {
291 self.files.is_empty() && self.text.is_none() && self.uris.is_empty()
292 }
293}
294
295/// Decode a `file://` URI tail (everything after `file://`) into a `PathBuf`.
296///
297/// Handles the optional `//host` authority (UNC on Windows, dropped on Unix
298/// for the local host), strips it, percent-decodes the path, and on Windows
299/// turns any leading drive letter into a native `C:\…` path.
300fn uri_path_to_pathbuf(after_scheme: &str) -> PathBuf {
301 // `after_scheme` is what followed `file://`. A leading authority segment
302 // ends at the next `/`. The common local form is `file:///path` →
303 // authority empty → `after_scheme` starts with `/`.
304 let (authority, path) = match after_scheme.find('/') {
305 Some(idx) => (&after_scheme[..idx], &after_scheme[idx..]),
306 None => (after_scheme, ""),
307 };
308 let decoded = percent_decode(path);
309
310 #[cfg(windows)]
311 {
312 // Windows `file://` URIs come in several shapes: the RFC-correct
313 // `file:///C:/path` (empty authority), plus the common naive forms
314 // `file://C:/path` / `file://C:\path` — produced by apps that just
315 // splice a native path after `file://` — which put the drive letter
316 // where a UNC authority would go. Detect a leading drive letter (`X:`),
317 // after an optional slash, and treat the whole tail as a drive path,
318 // never a UNC host. Otherwise a naive `file://C:\dir\f` would decode to
319 // the invalid `\\C:\dir\f`.
320 let whole = percent_decode(after_scheme);
321 let candidate = whole.strip_prefix('/').unwrap_or(&whole);
322 let b = candidate.as_bytes();
323 if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
324 return PathBuf::from(candidate.replace('/', r"\"));
325 }
326 // UNC share: file://server/share → \\server\share
327 if !authority.is_empty() {
328 let mut s = String::from(r"\\");
329 s.push_str(authority);
330 s.push_str(&decoded.replace('/', r"\"));
331 return PathBuf::from(s);
332 }
333 // Local absolute: file:///path → strip the leading slash.
334 let trimmed = decoded.strip_prefix('/').unwrap_or(&decoded);
335 PathBuf::from(trimmed.replace('/', r"\"))
336 }
337 #[cfg(not(windows))]
338 {
339 let _ = authority; // non-local authorities are rare; keep the path
340 PathBuf::from(decoded)
341 }
342}
343
344/// Percent-decode a URI component (`%20` → space, etc.). Invalid escapes are
345/// left verbatim. Operates on UTF-8 bytes so multi-byte sequences decode
346/// correctly.
347fn percent_decode(s: &str) -> String {
348 let bytes = s.as_bytes();
349 let mut out = Vec::with_capacity(bytes.len());
350 let mut i = 0;
351 while i < bytes.len() {
352 if bytes[i] == b'%' && i + 2 < bytes.len() {
353 let hi = (bytes[i + 1] as char).to_digit(16);
354 let lo = (bytes[i + 2] as char).to_digit(16);
355 if let (Some(hi), Some(lo)) = (hi, lo) {
356 out.push((hi * 16 + lo) as u8);
357 i += 3;
358 continue;
359 }
360 }
361 out.push(bytes[i]);
362 i += 1;
363 }
364 String::from_utf8_lossy(&out).into_owned()
365}
366
367/// A drag payload carrying data from a drag source to a drop target.
368///
369/// For intra-application transfers, use `DragPayload::typed(data)` to store
370/// a typed Rust value. Drop targets extract it via `get_typed::<T>()`.
371///
372/// For drops from the OS, use `DragPayload::external(data)`; targets read
373/// `files()` / `text()` / `uris()` (and `origin()` / `is_external()` to
374/// distinguish the source).
375pub struct DragPayload {
376 /// Typed intra-app payload (fast path, no serialization).
377 typed: Option<Box<dyn Any>>,
378 /// MIME-typed byte data (cross-app DnD, populated for external drags).
379 mime_data: HashMap<String, Vec<u8>>,
380 /// Where this drag came from.
381 origin: DragOrigin,
382 /// Structured external-drop data (only for `DragOrigin::External`).
383 external: Option<ExternalDropData>,
384 /// When an exportable drag is handed to the platform.
385 os_handoff: OsHandoff,
386}
387
388impl DragPayload {
389 /// Create a payload from a typed Rust value.
390 pub fn typed<T: 'static>(data: T) -> Self {
391 Self {
392 typed: Some(Box::new(data)),
393 mime_data: HashMap::new(),
394 origin: DragOrigin::Internal,
395 external: None,
396 os_handoff: OsHandoff::AtWindowEdge,
397 }
398 }
399
400 /// Create an empty payload (for MIME-only transfers).
401 pub fn empty() -> Self {
402 Self {
403 typed: None,
404 mime_data: HashMap::new(),
405 origin: DragOrigin::Internal,
406 external: None,
407 os_handoff: OsHandoff::AtWindowEdge,
408 }
409 }
410
411 /// Create a payload from OS-delivered external drop data.
412 ///
413 /// Synthesizes canonical MIME entries (`text/plain` from `text`,
414 /// `text/uri-list` from `files` + `uris` when not already present) so the
415 /// generic `get_mime` API and the typed `files()` / `text()` / `uris()`
416 /// accessors stay consistent.
417 pub fn external(data: ExternalDropData) -> Self {
418 let mut mime_data = data.mime.clone();
419 if let Some(text) = &data.text {
420 mime_data
421 .entry("text/plain".to_string())
422 .or_insert_with(|| text.clone().into_bytes());
423 }
424 if (!data.files.is_empty() || !data.uris.is_empty())
425 && !mime_data.contains_key("text/uri-list")
426 {
427 let mut list = String::new();
428 for f in &data.files {
429 list.push_str("file://");
430 list.push_str(&f.to_string_lossy());
431 list.push_str("\r\n");
432 }
433 for u in &data.uris {
434 list.push_str(u);
435 list.push_str("\r\n");
436 }
437 mime_data.insert("text/uri-list".to_string(), list.into_bytes());
438 }
439 Self {
440 typed: None,
441 mime_data,
442 origin: DragOrigin::External,
443 external: Some(data),
444 os_handoff: OsHandoff::AtWindowEdge,
445 }
446 }
447
448 /// Where this drag originated.
449 pub fn origin(&self) -> DragOrigin {
450 self.origin
451 }
452
453 /// Whether this payload came from outside the application (an OS drop).
454 pub fn is_external(&self) -> bool {
455 self.origin == DragOrigin::External
456 }
457
458 /// Dropped filesystem paths (empty for non-file drops, and for internal
459 /// drags unless [`Self::enrich_external_from_mime`] has filled the
460 /// external view).
461 pub fn files(&self) -> &[PathBuf] {
462 self.external.as_ref().map_or(&[], |e| &e.files)
463 }
464
465 /// Dropped plain text, if any.
466 pub fn text(&self) -> Option<&str> {
467 self.external.as_ref().and_then(|e| e.text.as_deref())
468 }
469
470 /// Dropped non-file URLs (empty for file-only drops, and for internal
471 /// drags unless [`Self::enrich_external_from_mime`] has filled the
472 /// external view).
473 pub fn uris(&self) -> &[String] {
474 self.external.as_ref().map_or(&[], |e| &e.uris)
475 }
476
477 /// Advertised data formats (platform MIME types / type identifiers) for an
478 /// external drag. Available at drag-enter even before the bytes transfer —
479 /// the basis for hover-time accept/reject when `files()` / `text()` /
480 /// `uris()` aren't populated yet (Wayland). See
481 /// [`ExternalDropData::formats`].
482 pub fn formats(&self) -> &[String] {
483 self.external.as_ref().map_or(&[], |e| &e.formats)
484 }
485
486 /// Add a MIME-typed byte representation.
487 ///
488 /// A payload with one is OS-exportable ([`Self::is_os_exportable`]): an
489 /// in-app drag carrying it is handed to the platform as a native drag when
490 /// [`Self::with_os_handoff`] says so.
491 pub fn with_mime(mut self, mime_type: &str, data: Vec<u8>) -> Self {
492 self.mime_data.insert(mime_type.to_string(), data);
493 self
494 }
495
496 /// Choose when this drag is handed to the platform as a native drag, if it
497 /// is OS-exportable. The default is [`OsHandoff::AtWindowEdge`]. No effect
498 /// on a payload [`Self::is_os_exportable`] rejects.
499 pub fn with_os_handoff(mut self, handoff: OsHandoff) -> Self {
500 self.os_handoff = handoff;
501 self
502 }
503
504 /// When this drag is handed to the platform. See [`OsHandoff`].
505 pub fn os_handoff(&self) -> OsHandoff {
506 self.os_handoff
507 }
508
509 /// Populate the structured external view (`files` / `text` / `uris`) from
510 /// this payload's MIME data, so a drag that round-tripped through the OS
511 /// and re-entered the app satisfies file/text drop targets (e.g. a
512 /// `DropZone`) **as well as** typed in-app targets. The typed value is
513 /// preserved, so `get_typed::<T>()` still works.
514 ///
515 /// **Origin is deliberately left `Internal`.** A round-tripped drag still
516 /// *originated from this app*, so `is_external()` stays `false` — an in-app
517 /// reorder target that rejects external drags via `!is_external()` must
518 /// still accept its own drag coming back. `is_external()` means "came from
519 /// another application", not "carries file/text data". Consumers that want
520 /// content should test `files()` / `text()` / `uris()` (or `has_typed`),
521 /// which is what `DropZone` does. So a payload here can legitimately have
522 /// `origin == Internal` *and* a populated external view.
523 ///
524 /// No-op if an external view is already present or no recognizable MIME is
525 /// carried.
526 pub fn enrich_external_from_mime(&mut self) {
527 if self.external.is_some() {
528 return;
529 }
530 let mut ext = ExternalDropData::default();
531 if let Some(bytes) = self.mime_data.get("text/uri-list") {
532 let parsed = ExternalDropData::from_uri_list(&String::from_utf8_lossy(bytes));
533 ext.files = parsed.files;
534 ext.uris = parsed.uris;
535 }
536 if let Some(bytes) = self
537 .mime_data
538 .get("text/plain")
539 .or_else(|| self.mime_data.get("text/plain;charset=utf-8"))
540 {
541 ext.text = Some(String::from_utf8_lossy(bytes).into_owned());
542 }
543 ext.formats = self.mime_data.keys().cloned().collect();
544 if !ext.is_empty() {
545 self.external = Some(ext);
546 }
547 }
548
549 /// Extract the typed payload by type. Returns `None` if the type doesn't match
550 /// or no typed payload was set.
551 pub fn get_typed<T: 'static>(&self) -> Option<&T> {
552 self.typed.as_ref().and_then(|v| v.downcast_ref::<T>())
553 }
554
555 /// Take the typed payload, consuming it from the DragPayload.
556 pub fn take_typed<T: 'static>(&mut self) -> Option<T> {
557 let boxed = self.typed.take()?;
558 match boxed.downcast::<T>() {
559 Ok(value) => Some(*value),
560 Err(boxed) => {
561 // Put it back if the type didn't match
562 self.typed = Some(boxed);
563 None
564 }
565 }
566 }
567
568 /// Whether this payload has a typed value of the given type.
569 pub fn has_typed<T: 'static>(&self) -> bool {
570 self.typed
571 .as_ref()
572 .is_some_and(|v| v.downcast_ref::<T>().is_some())
573 }
574
575 /// Whether this payload has data for the given MIME type.
576 pub fn has_mime(&self, mime_type: &str) -> bool {
577 self.mime_data.contains_key(mime_type)
578 }
579
580 /// Get MIME-typed byte data.
581 pub fn get_mime(&self, mime_type: &str) -> Option<&[u8]> {
582 self.mime_data.get(mime_type).map(|v| v.as_slice())
583 }
584
585 /// List all MIME types in this payload.
586 pub fn mime_types(&self) -> Vec<&str> {
587 self.mime_data.keys().map(|s| s.as_str()).collect()
588 }
589
590 /// Whether this payload carries anything an OS drag could export. A drag
591 /// becomes OS-exportable simply by populating `mime_data` (via
592 /// [`Self::with_mime`]) or by carrying external file / text / URI data.
593 /// When it is handed to the platform is [`Self::os_handoff`].
594 pub fn is_os_exportable(&self) -> bool {
595 !self.mime_data.is_empty()
596 || self
597 .external
598 .as_ref()
599 .is_some_and(|e| !e.files.is_empty() || e.text.is_some() || !e.uris.is_empty())
600 }
601
602 /// Extract the flattened, OS-exportable view used when an in-app drag is
603 /// handed to the platform as an OS drag (at the window boundary, or
604 /// earlier with [`OsHandoff::Eager`]).
605 ///
606 /// Internal drags populate only `mime_data` (via [`Self::with_mime`]); the
607 /// `files` / `text` / `uris` fields come from [`ExternalDropData`] and are
608 /// empty for them. So when those structured fields are absent, derive them
609 /// from the canonical `text/uri-list` / `text/plain` MIME entries — the
610 /// platform backends (NSURL items, etc.) need the structured form.
611 pub fn to_outbound(&self) -> OutboundDragData {
612 let ext = self.external.as_ref();
613 let mut files = ext.map(|e| e.files.clone()).unwrap_or_default();
614 let mut uris = ext.map(|e| e.uris.clone()).unwrap_or_default();
615 let mut text = ext.and_then(|e| e.text.clone());
616
617 if files.is_empty()
618 && uris.is_empty()
619 && let Some(bytes) = self.mime_data.get("text/uri-list")
620 {
621 let parsed = ExternalDropData::from_uri_list(&String::from_utf8_lossy(bytes));
622 files = parsed.files;
623 uris = parsed.uris;
624 }
625 if text.is_none()
626 && let Some(bytes) = self
627 .mime_data
628 .get("text/plain")
629 .or_else(|| self.mime_data.get("text/plain;charset=utf-8"))
630 {
631 text = Some(String::from_utf8_lossy(bytes).into_owned());
632 }
633
634 OutboundDragData {
635 mime: self.mime_data.clone(),
636 files,
637 text,
638 uris,
639 }
640 }
641}
642
643impl std::fmt::Debug for DragPayload {
644 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
645 f.debug_struct("DragPayload")
646 .field("origin", &self.origin)
647 .field("has_typed", &self.typed.is_some())
648 .field("mime_types", &self.mime_types())
649 .field("files", &self.files())
650 .field("os_handoff", &self.os_handoff)
651 .finish()
652 }
653}
654
655/// Trait for typed drag data with an associated MIME type.
656///
657/// Implementing this trait allows a type to be used as both an intra-application
658/// typed payload and (in the future) a cross-application MIME-serialized payload.
659pub trait DragData: Any + std::fmt::Debug + 'static {
660 /// The canonical MIME type for this data type.
661 fn mime_type(&self) -> &'static str;
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 #[test]
669 fn every_constructor_hands_off_at_the_window_edge() {
670 assert_eq!(
671 DragPayload::typed(1_u8).os_handoff(),
672 OsHandoff::AtWindowEdge
673 );
674 assert_eq!(DragPayload::empty().os_handoff(), OsHandoff::AtWindowEdge);
675 assert_eq!(
676 DragPayload::external(ExternalDropData::default()).os_handoff(),
677 OsHandoff::AtWindowEdge
678 );
679 let eager = DragPayload::typed(1_u8)
680 .with_mime("text/plain", b"x".to_vec())
681 .with_os_handoff(OsHandoff::Eager);
682 assert_eq!(eager.os_handoff(), OsHandoff::Eager);
683 }
684
685 #[derive(Debug, Clone, PartialEq)]
686 struct ChapterDrag {
687 chapter_id: u32,
688 title: String,
689 }
690
691 impl DragData for ChapterDrag {
692 fn mime_type(&self) -> &'static str {
693 "application/x-skribisto-chapter"
694 }
695 }
696
697 #[test]
698 fn typed_roundtrip() {
699 let payload = DragPayload::typed(ChapterDrag {
700 chapter_id: 42,
701 title: "Introduction".into(),
702 });
703
704 assert!(payload.has_typed::<ChapterDrag>());
705 assert!(!payload.has_typed::<String>());
706
707 let extracted = payload.get_typed::<ChapterDrag>().unwrap();
708 assert_eq!(extracted.chapter_id, 42);
709 assert_eq!(extracted.title, "Introduction");
710 }
711
712 #[test]
713 fn take_typed() {
714 let mut payload = DragPayload::typed(42_u32);
715 assert!(payload.has_typed::<u32>());
716 let val = payload.take_typed::<u32>().unwrap();
717 assert_eq!(val, 42);
718 assert!(!payload.has_typed::<u32>());
719 }
720
721 #[test]
722 fn take_typed_wrong_type_preserves() {
723 let mut payload = DragPayload::typed(42_u32);
724 assert!(payload.take_typed::<String>().is_none());
725 assert!(payload.has_typed::<u32>()); // still there
726 }
727
728 #[test]
729 fn mime_data() {
730 let payload = DragPayload::empty()
731 .with_mime("text/plain", b"hello".to_vec())
732 .with_mime("text/html", b"<b>hello</b>".to_vec());
733
734 assert!(payload.has_mime("text/plain"));
735 assert!(payload.has_mime("text/html"));
736 assert!(!payload.has_mime("image/png"));
737
738 assert_eq!(payload.get_mime("text/plain"), Some(b"hello".as_slice()));
739 assert_eq!(payload.mime_types().len(), 2);
740 }
741
742 #[test]
743 fn typed_with_mime() {
744 let payload = DragPayload::typed(ChapterDrag {
745 chapter_id: 1,
746 title: "Ch1".into(),
747 })
748 .with_mime("text/plain", b"Ch1".to_vec());
749
750 assert!(payload.has_typed::<ChapterDrag>());
751 assert!(payload.has_mime("text/plain"));
752 }
753
754 #[test]
755 fn debug_format() {
756 let payload = DragPayload::typed(42_u32);
757 let s = format!("{:?}", payload);
758 assert!(s.contains("DragPayload"));
759 assert!(s.contains("has_typed: true"));
760 }
761
762 #[test]
763 fn external_payload_origin_and_accessors() {
764 let data = ExternalDropData {
765 files: vec![PathBuf::from("/tmp/a.png")],
766 text: Some("hello".into()),
767 uris: vec!["https://example.com".into()],
768 mime: HashMap::new(),
769 formats: Vec::new(),
770 };
771 let payload = DragPayload::external(data);
772
773 assert!(payload.is_external());
774 assert_eq!(payload.origin(), DragOrigin::External);
775 assert!(!payload.has_typed::<u32>());
776 assert_eq!(payload.files(), &[PathBuf::from("/tmp/a.png")]);
777 assert_eq!(payload.text(), Some("hello"));
778 assert_eq!(payload.uris(), &["https://example.com".to_string()]);
779 // Canonical MIME entries are synthesized.
780 assert!(payload.has_mime("text/plain"));
781 assert!(payload.has_mime("text/uri-list"));
782 }
783
784 #[test]
785 fn internal_payload_has_no_external_data() {
786 let payload = DragPayload::typed(7_u32);
787 assert!(!payload.is_external());
788 assert_eq!(payload.origin(), DragOrigin::Internal);
789 assert!(payload.files().is_empty());
790 assert_eq!(payload.text(), None);
791 assert!(payload.uris().is_empty());
792 }
793
794 #[test]
795 fn uri_list_parses_files_and_urls() {
796 let list = "#comment\r\nfile:///tmp/My%20File.txt\r\nhttps://example.com/a%2Bb\r\n";
797 let data = ExternalDropData::from_uri_list(list);
798 // `file://` local-path decoding is OS-specific (see `uri_path_to_pathbuf`):
799 // on Windows a driveless `/tmp/…` loses its leading slash and uses `\`
800 // separators, so it lands as a relative `tmp\…`.
801 #[cfg(windows)]
802 let expected_file = PathBuf::from(r"tmp\My File.txt");
803 #[cfg(not(windows))]
804 let expected_file = PathBuf::from("/tmp/My File.txt");
805 assert_eq!(data.files, vec![expected_file]);
806 assert_eq!(data.uris, vec!["https://example.com/a+b".to_string()]);
807 assert!(data.mime.contains_key("text/uri-list"));
808 }
809
810 #[test]
811 fn percent_decode_handles_utf8_and_invalid() {
812 // "café" encoded; plus an invalid trailing % left verbatim.
813 assert_eq!(percent_decode("caf%C3%A9"), "café");
814 assert_eq!(percent_decode("100%"), "100%");
815 assert_eq!(percent_decode("a%2"), "a%2");
816 }
817
818 #[test]
819 fn uri_list_uses_crlf_and_terminates_the_last_line() {
820 let data = OutboundDragData {
821 files: vec![PathBuf::from("/tmp/a.txt")],
822 ..Default::default()
823 };
824 assert_eq!(data.to_uri_list(), "file:///tmp/a.txt\r\n");
825 }
826
827 /// The characters that would otherwise corrupt the list format itself:
828 /// `#` starts a comment line, CR/LF split one path into two.
829 #[cfg(not(windows))]
830 #[test]
831 fn uri_list_escapes_characters_that_would_break_the_format() {
832 let data = OutboundDragData {
833 files: vec![PathBuf::from("/tmp/a#b c.txt")],
834 ..Default::default()
835 };
836 let list = data.to_uri_list();
837 assert_eq!(list, "file:///tmp/a%23b%20c.txt\r\n");
838 // ...and it survives the round trip as one file with its real name.
839 let parsed = ExternalDropData::from_uri_list(&list);
840 assert_eq!(parsed.files, vec![PathBuf::from("/tmp/a#b c.txt")]);
841 }
842
843 #[cfg(not(windows))]
844 #[test]
845 fn uri_list_round_trips_non_ascii_and_literal_percent() {
846 // A filename containing a literal "%20" must not decode back to a
847 // space — that only round-trips if the encoder escaped the `%`.
848 let files = vec![
849 PathBuf::from("/tmp/café.txt"),
850 PathBuf::from("/tmp/100%20.txt"),
851 ];
852 let data = OutboundDragData {
853 files: files.clone(),
854 ..Default::default()
855 };
856 let parsed = ExternalDropData::from_uri_list(&data.to_uri_list());
857 assert_eq!(parsed.files, files);
858 }
859
860 #[test]
861 fn uri_list_passes_urls_through_without_re_encoding() {
862 // Re-encoding an already-encoded URI would double every `%`.
863 let data = OutboundDragData {
864 uris: vec!["https://example.com/a%2Bb".to_string()],
865 ..Default::default()
866 };
867 assert_eq!(data.to_uri_list(), "https://example.com/a%2Bb\r\n");
868 }
869
870 #[cfg(not(windows))]
871 #[test]
872 fn file_uri_to_pathbuf_unix() {
873 assert_eq!(uri_path_to_pathbuf("/tmp/a%20b"), PathBuf::from("/tmp/a b"));
874 }
875
876 #[cfg(windows)]
877 #[test]
878 fn file_uri_to_pathbuf_windows_drive_letters() {
879 // RFC-correct, plus the naive forms apps actually produce — all must
880 // resolve to the drive path, never a UNC host.
881 assert_eq!(
882 uri_path_to_pathbuf("/C:/Users/a/main.rs"),
883 PathBuf::from(r"C:\Users\a\main.rs")
884 );
885 assert_eq!(
886 uri_path_to_pathbuf("C:/Users/a/main.rs"),
887 PathBuf::from(r"C:\Users\a\main.rs")
888 );
889 // Native separators + mixed — what `file://{CARGO_MANIFEST_DIR}/src/main.rs`
890 // yields on Windows (the file-drop demo's outbound file drag).
891 assert_eq!(
892 uri_path_to_pathbuf(r"C:\Users\a\proj/src/main.rs"),
893 PathBuf::from(r"C:\Users\a\proj\src\main.rs")
894 );
895 // A genuine UNC path still parses as UNC.
896 assert_eq!(
897 uri_path_to_pathbuf("server/share/f.txt"),
898 PathBuf::from(r"\\server\share\f.txt")
899 );
900 }
901}