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/// 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}