teksilo-core 0.9.0

Core of the Teksilo GUI framework — widget trait, arena, layout engine, event dispatch, focus, signals and theming.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Typed drag payload for intra-application drag and drop.
//!
//! `DragPayload` carries the data being dragged. For intra-application DnD,
//! the fast path stores a typed Rust value (via `Any`). For drops originating
//! outside the application (files / text / URLs dragged from the OS), the
//! payload carries an [`ExternalDropData`] plus MIME-typed byte
//! representations.

use std::any::Any;
use std::collections::HashMap;
use std::path::PathBuf;

/// Where a drag originated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DragOrigin {
    /// Started inside the application via `EventContext::start_drag`.
    Internal,
    /// Delivered by the OS — files / text / URLs dragged from another
    /// application or the file manager into a window.
    External,
}

/// How a drag the source widget started ended. Delivered to the source's
/// `on_drag_ended` handler so it can react (e.g. remove the item on a move).
///
/// One unified completion outcome for *every* drag a widget starts — whether
/// it dropped on an in-app target, was exported to another application via the
/// OS, or was cancelled.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DropOutcome {
    /// Dropped on an in-app target. `accepted` is what the target's `on_drop`
    /// returned.
    InApp {
        /// Whether the drop target accepted the payload.
        accepted: bool,
    },
    /// Exported to another application via the OS as a copy.
    OsCopy,
    /// Exported to another application via the OS as a move — the source
    /// should remove the dragged item.
    OsMove,
    /// No drop happened: Escape, dropped on nothing, or the OS rejected it.
    Cancelled,
}

/// Flattened, OS-exportable view of a [`DragPayload`], handed to the platform
/// backend when an in-app drag escalates to an OS drag at the window boundary.
///
/// Plain data with no GUI dependency so [`crate::window::WindowOps`] can name
/// it without teksilo-core depending on teksilo-platform.
#[derive(Debug, Clone, Default)]
pub struct OutboundDragData {
    /// MIME-typed byte representations to advertise to the OS.
    pub mime: HashMap<String, Vec<u8>>,
    /// Filesystem paths, if the payload represents files.
    pub files: Vec<PathBuf>,
    /// Plain text, if any.
    pub text: Option<String>,
    /// Non-file URLs, if any.
    pub uris: Vec<String>,
}

impl OutboundDragData {
    /// True when there is nothing to hand the OS.
    pub fn is_empty(&self) -> bool {
        self.mime.is_empty() && self.files.is_empty() && self.text.is_none() && self.uris.is_empty()
    }

    /// Render [`Self::files`] and [`Self::uris`] as a `text/uri-list` payload
    /// (RFC 2483) — the inverse of [`ExternalDropData::from_uri_list`].
    ///
    /// Paths are percent-encoded, which is not cosmetic: an un-encoded `#`
    /// starts a comment line, an un-encoded CR/LF splits one path into two,
    /// and a filename that genuinely contains `%20` would come back as a
    /// space. Lines are CRLF-terminated including the last, as the RFC
    /// specifies and as GTK and Qt both expect.
    pub fn to_uri_list(&self) -> String {
        let mut list = String::new();
        for path in &self.files {
            list.push_str("file://");
            list.push_str(&percent_encode_path(&path.to_string_lossy()));
            list.push_str("\r\n");
        }
        for uri in &self.uris {
            // Already a URI: the caller encoded it (or it came from the OS
            // that way), so re-encoding would double-escape every `%`.
            list.push_str(uri);
            list.push_str("\r\n");
        }
        list
    }
}

/// Percent-encode a filesystem path for a `file:` URI. `/` stays a separator;
/// everything outside RFC 3986's unreserved set is escaped byte-wise, so
/// non-ASCII names encode as UTF-8 and decode back through
/// [`percent_decode`] unchanged.
fn percent_encode_path(path: &str) -> String {
    let mut out = String::with_capacity(path.len());
    for byte in path.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
                out.push(byte as char);
            }
            _ => out.push_str(&format!("%{byte:02X}")),
        }
    }
    out
}

/// Optional drag image for an OS drag (RGBA8, top-left origin, premultiplied
/// alpha not assumed). `None` lets the platform draw a default. `hot_x` /
/// `hot_y` are the cursor hotspot in logical pixels from the image's top-left.
#[derive(Debug, Clone)]
pub struct DragImageData {
    /// Pixel data, `width * height * 4` bytes (RGBA8, row-major, top-left).
    pub rgba: Vec<u8>,
    /// Image width in pixels.
    pub width: u32,
    /// Image height in pixels.
    pub height: u32,
    /// Cursor hotspot X in logical pixels from the top-left.
    pub hot_x: f32,
    /// Cursor hotspot Y in logical pixels from the top-left.
    pub hot_y: f32,
}

/// Data delivered by an external (OS) drag-and-drop.
///
/// Platform backends populate the fields they can extract from the native
/// drag payload. `files` are real filesystem paths, `text` is plain UTF-8
/// text, `uris` are non-`file://` URLs (e.g. `https://…`). `mime` holds any
/// additional raw representations keyed by MIME type, for consumers that want
/// the bytes verbatim.
#[derive(Debug, Clone, Default)]
pub struct ExternalDropData {
    /// Dropped filesystem paths.
    pub files: Vec<PathBuf>,
    /// Dropped plain text, if any.
    pub text: Option<String>,
    /// Dropped non-file URLs (http, https, mailto, …).
    pub uris: Vec<String>,
    /// Additional raw MIME representations, keyed by MIME type.
    pub mime: HashMap<String, Vec<u8>>,
    /// Advertised data formats (platform MIME types / type identifiers),
    /// available at drag-*enter* time even before the bytes are transferred.
    /// On Wayland the actual `files` / `text` / `uris` are only filled at drop
    /// (received over a pipe), so a drop target validates on hover from these
    /// format strings (e.g. `"text/uri-list"` ⇒ a file drag). macOS / Windows
    /// read the full payload at enter, so they fill the data directly and may
    /// leave this empty.
    pub formats: Vec<String>,
}

impl ExternalDropData {
    /// Build from a `text/uri-list` payload (RFC 2483): one URI per line,
    /// `#`-prefixed comment lines ignored, CRLF line endings, percent-encoded.
    /// `file://` URIs become [`Self::files`]; everything else becomes
    /// [`Self::uris`]. The raw list is also retained under the
    /// `text/uri-list` MIME key.
    pub fn from_uri_list(list: &str) -> Self {
        let mut files = Vec::new();
        let mut uris = Vec::new();
        for line in list.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            if let Some(rest) = line.strip_prefix("file://") {
                files.push(uri_path_to_pathbuf(rest));
            } else {
                uris.push(percent_decode(line));
            }
        }
        let mut mime = HashMap::new();
        mime.insert("text/uri-list".to_string(), list.as_bytes().to_vec());
        Self {
            files,
            text: None,
            uris,
            mime,
            formats: vec!["text/uri-list".to_string()],
        }
    }

    /// True when there is nothing usable in this payload.
    pub fn is_empty(&self) -> bool {
        self.files.is_empty() && self.text.is_none() && self.uris.is_empty()
    }
}

/// Decode a `file://` URI tail (everything after `file://`) into a `PathBuf`.
///
/// Handles the optional `//host` authority (UNC on Windows, dropped on Unix
/// for the local host), strips it, percent-decodes the path, and on Windows
/// turns any leading drive letter into a native `C:\…` path.
fn uri_path_to_pathbuf(after_scheme: &str) -> PathBuf {
    // `after_scheme` is what followed `file://`. A leading authority segment
    // ends at the next `/`. The common local form is `file:///path` →
    // authority empty → `after_scheme` starts with `/`.
    let (authority, path) = match after_scheme.find('/') {
        Some(idx) => (&after_scheme[..idx], &after_scheme[idx..]),
        None => (after_scheme, ""),
    };
    let decoded = percent_decode(path);

    #[cfg(windows)]
    {
        // Windows `file://` URIs come in several shapes: the RFC-correct
        // `file:///C:/path` (empty authority), plus the common naive forms
        // `file://C:/path` / `file://C:\path` — produced by apps that just
        // splice a native path after `file://` — which put the drive letter
        // where a UNC authority would go. Detect a leading drive letter (`X:`),
        // after an optional slash, and treat the whole tail as a drive path,
        // never a UNC host. Otherwise a naive `file://C:\dir\f` would decode to
        // the invalid `\\C:\dir\f`.
        let whole = percent_decode(after_scheme);
        let candidate = whole.strip_prefix('/').unwrap_or(&whole);
        let b = candidate.as_bytes();
        if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
            return PathBuf::from(candidate.replace('/', r"\"));
        }
        // UNC share: file://server/share → \\server\share
        if !authority.is_empty() {
            let mut s = String::from(r"\\");
            s.push_str(authority);
            s.push_str(&decoded.replace('/', r"\"));
            return PathBuf::from(s);
        }
        // Local absolute: file:///path → strip the leading slash.
        let trimmed = decoded.strip_prefix('/').unwrap_or(&decoded);
        PathBuf::from(trimmed.replace('/', r"\"))
    }
    #[cfg(not(windows))]
    {
        let _ = authority; // non-local authorities are rare; keep the path
        PathBuf::from(decoded)
    }
}

/// Percent-decode a URI component (`%20` → space, etc.). Invalid escapes are
/// left verbatim. Operates on UTF-8 bytes so multi-byte sequences decode
/// correctly.
fn percent_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            let hi = (bytes[i + 1] as char).to_digit(16);
            let lo = (bytes[i + 2] as char).to_digit(16);
            if let (Some(hi), Some(lo)) = (hi, lo) {
                out.push((hi * 16 + lo) as u8);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// A drag payload carrying data from a drag source to a drop target.
///
/// For intra-application transfers, use `DragPayload::typed(data)` to store
/// a typed Rust value. Drop targets extract it via `get_typed::<T>()`.
///
/// For drops from the OS, use `DragPayload::external(data)`; targets read
/// `files()` / `text()` / `uris()` (and `origin()` / `is_external()` to
/// distinguish the source).
pub struct DragPayload {
    /// Typed intra-app payload (fast path, no serialization).
    typed: Option<Box<dyn Any>>,
    /// MIME-typed byte data (cross-app DnD, populated for external drags).
    mime_data: HashMap<String, Vec<u8>>,
    /// Where this drag came from.
    origin: DragOrigin,
    /// Structured external-drop data (only for `DragOrigin::External`).
    external: Option<ExternalDropData>,
}

impl DragPayload {
    /// Create a payload from a typed Rust value.
    pub fn typed<T: 'static>(data: T) -> Self {
        Self {
            typed: Some(Box::new(data)),
            mime_data: HashMap::new(),
            origin: DragOrigin::Internal,
            external: None,
        }
    }

    /// Create an empty payload (for MIME-only transfers).
    pub fn empty() -> Self {
        Self {
            typed: None,
            mime_data: HashMap::new(),
            origin: DragOrigin::Internal,
            external: None,
        }
    }

    /// Create a payload from OS-delivered external drop data.
    ///
    /// Synthesizes canonical MIME entries (`text/plain` from `text`,
    /// `text/uri-list` from `files` + `uris` when not already present) so the
    /// generic `get_mime` API and the typed `files()` / `text()` / `uris()`
    /// accessors stay consistent.
    pub fn external(data: ExternalDropData) -> Self {
        let mut mime_data = data.mime.clone();
        if let Some(text) = &data.text {
            mime_data
                .entry("text/plain".to_string())
                .or_insert_with(|| text.clone().into_bytes());
        }
        if (!data.files.is_empty() || !data.uris.is_empty())
            && !mime_data.contains_key("text/uri-list")
        {
            let mut list = String::new();
            for f in &data.files {
                list.push_str("file://");
                list.push_str(&f.to_string_lossy());
                list.push_str("\r\n");
            }
            for u in &data.uris {
                list.push_str(u);
                list.push_str("\r\n");
            }
            mime_data.insert("text/uri-list".to_string(), list.into_bytes());
        }
        Self {
            typed: None,
            mime_data,
            origin: DragOrigin::External,
            external: Some(data),
        }
    }

    /// Where this drag originated.
    pub fn origin(&self) -> DragOrigin {
        self.origin
    }

    /// Whether this payload came from outside the application (an OS drop).
    pub fn is_external(&self) -> bool {
        self.origin == DragOrigin::External
    }

    /// Dropped filesystem paths (empty for internal drags or non-file drops).
    pub fn files(&self) -> &[PathBuf] {
        self.external.as_ref().map_or(&[], |e| &e.files)
    }

    /// Dropped plain text, if any.
    pub fn text(&self) -> Option<&str> {
        self.external.as_ref().and_then(|e| e.text.as_deref())
    }

    /// Dropped non-file URLs (empty for internal drags or file-only drops).
    pub fn uris(&self) -> &[String] {
        self.external.as_ref().map_or(&[], |e| &e.uris)
    }

    /// Advertised data formats (platform MIME types / type identifiers) for an
    /// external drag. Available at drag-enter even before the bytes transfer —
    /// the basis for hover-time accept/reject when `files()` / `text()` /
    /// `uris()` aren't populated yet (Wayland). See
    /// [`ExternalDropData::formats`].
    pub fn formats(&self) -> &[String] {
        self.external.as_ref().map_or(&[], |e| &e.formats)
    }

    /// Add a MIME-typed byte representation.
    pub fn with_mime(mut self, mime_type: &str, data: Vec<u8>) -> Self {
        self.mime_data.insert(mime_type.to_string(), data);
        self
    }

    /// Populate the structured external view (`files` / `text` / `uris`) from
    /// this payload's MIME data, so a drag that round-tripped through the OS
    /// and re-entered the app satisfies file/text drop targets (e.g. a
    /// `DropZone`) **as well as** typed in-app targets. The typed value is
    /// preserved, so `get_typed::<T>()` still works.
    ///
    /// **Origin is deliberately left `Internal`.** A round-tripped drag still
    /// *originated from this app*, so `is_external()` stays `false` — an in-app
    /// reorder target that rejects external drags via `!is_external()` must
    /// still accept its own drag coming back. `is_external()` means "came from
    /// another application", not "carries file/text data". Consumers that want
    /// content should test `files()` / `text()` / `uris()` (or `has_typed`),
    /// which is what `DropZone` does. So a payload here can legitimately have
    /// `origin == Internal` *and* a populated external view.
    ///
    /// No-op if an external view is already present or no recognizable MIME is
    /// carried.
    pub fn enrich_external_from_mime(&mut self) {
        if self.external.is_some() {
            return;
        }
        let mut ext = ExternalDropData::default();
        if let Some(bytes) = self.mime_data.get("text/uri-list") {
            let parsed = ExternalDropData::from_uri_list(&String::from_utf8_lossy(bytes));
            ext.files = parsed.files;
            ext.uris = parsed.uris;
        }
        if let Some(bytes) = self
            .mime_data
            .get("text/plain")
            .or_else(|| self.mime_data.get("text/plain;charset=utf-8"))
        {
            ext.text = Some(String::from_utf8_lossy(bytes).into_owned());
        }
        ext.formats = self.mime_data.keys().cloned().collect();
        if !ext.is_empty() {
            self.external = Some(ext);
        }
    }

    /// Extract the typed payload by type. Returns `None` if the type doesn't match
    /// or no typed payload was set.
    pub fn get_typed<T: 'static>(&self) -> Option<&T> {
        self.typed.as_ref().and_then(|v| v.downcast_ref::<T>())
    }

    /// Take the typed payload, consuming it from the DragPayload.
    pub fn take_typed<T: 'static>(&mut self) -> Option<T> {
        let boxed = self.typed.take()?;
        match boxed.downcast::<T>() {
            Ok(value) => Some(*value),
            Err(boxed) => {
                // Put it back if the type didn't match
                self.typed = Some(boxed);
                None
            }
        }
    }

    /// Whether this payload has a typed value of the given type.
    pub fn has_typed<T: 'static>(&self) -> bool {
        self.typed
            .as_ref()
            .is_some_and(|v| v.downcast_ref::<T>().is_some())
    }

    /// Whether this payload has data for the given MIME type.
    pub fn has_mime(&self, mime_type: &str) -> bool {
        self.mime_data.contains_key(mime_type)
    }

    /// Get MIME-typed byte data.
    pub fn get_mime(&self, mime_type: &str) -> Option<&[u8]> {
        self.mime_data.get(mime_type).map(|v| v.as_slice())
    }

    /// List all MIME types in this payload.
    pub fn mime_types(&self) -> Vec<&str> {
        self.mime_data.keys().map(|s| s.as_str()).collect()
    }

    /// Whether this payload carries anything an OS drag could export. A drag
    /// becomes OS-exportable simply by populating `mime_data` (via
    /// [`Self::with_mime`]) or by carrying external file / text / URI data.
    pub fn is_os_exportable(&self) -> bool {
        !self.mime_data.is_empty()
            || self
                .external
                .as_ref()
                .is_some_and(|e| !e.files.is_empty() || e.text.is_some() || !e.uris.is_empty())
    }

    /// Extract the flattened, OS-exportable view used when an in-app drag
    /// escalates to an OS drag at the window boundary.
    ///
    /// Internal drags populate only `mime_data` (via [`Self::with_mime`]); the
    /// `files` / `text` / `uris` fields come from [`ExternalDropData`] and are
    /// empty for them. So when those structured fields are absent, derive them
    /// from the canonical `text/uri-list` / `text/plain` MIME entries — the
    /// platform backends (NSURL items, etc.) need the structured form.
    pub fn to_outbound(&self) -> OutboundDragData {
        let ext = self.external.as_ref();
        let mut files = ext.map(|e| e.files.clone()).unwrap_or_default();
        let mut uris = ext.map(|e| e.uris.clone()).unwrap_or_default();
        let mut text = ext.and_then(|e| e.text.clone());

        if files.is_empty()
            && uris.is_empty()
            && let Some(bytes) = self.mime_data.get("text/uri-list")
        {
            let parsed = ExternalDropData::from_uri_list(&String::from_utf8_lossy(bytes));
            files = parsed.files;
            uris = parsed.uris;
        }
        if text.is_none()
            && let Some(bytes) = self
                .mime_data
                .get("text/plain")
                .or_else(|| self.mime_data.get("text/plain;charset=utf-8"))
        {
            text = Some(String::from_utf8_lossy(bytes).into_owned());
        }

        OutboundDragData {
            mime: self.mime_data.clone(),
            files,
            text,
            uris,
        }
    }
}

impl std::fmt::Debug for DragPayload {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DragPayload")
            .field("origin", &self.origin)
            .field("has_typed", &self.typed.is_some())
            .field("mime_types", &self.mime_types())
            .field("files", &self.files())
            .finish()
    }
}

/// Trait for typed drag data with an associated MIME type.
///
/// Implementing this trait allows a type to be used as both an intra-application
/// typed payload and (in the future) a cross-application MIME-serialized payload.
pub trait DragData: Any + std::fmt::Debug + 'static {
    /// The canonical MIME type for this data type.
    fn mime_type(&self) -> &'static str;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(Debug, Clone, PartialEq)]
    struct ChapterDrag {
        chapter_id: u32,
        title: String,
    }

    impl DragData for ChapterDrag {
        fn mime_type(&self) -> &'static str {
            "application/x-skribisto-chapter"
        }
    }

    #[test]
    fn typed_roundtrip() {
        let payload = DragPayload::typed(ChapterDrag {
            chapter_id: 42,
            title: "Introduction".into(),
        });

        assert!(payload.has_typed::<ChapterDrag>());
        assert!(!payload.has_typed::<String>());

        let extracted = payload.get_typed::<ChapterDrag>().unwrap();
        assert_eq!(extracted.chapter_id, 42);
        assert_eq!(extracted.title, "Introduction");
    }

    #[test]
    fn take_typed() {
        let mut payload = DragPayload::typed(42_u32);
        assert!(payload.has_typed::<u32>());
        let val = payload.take_typed::<u32>().unwrap();
        assert_eq!(val, 42);
        assert!(!payload.has_typed::<u32>());
    }

    #[test]
    fn take_typed_wrong_type_preserves() {
        let mut payload = DragPayload::typed(42_u32);
        assert!(payload.take_typed::<String>().is_none());
        assert!(payload.has_typed::<u32>()); // still there
    }

    #[test]
    fn mime_data() {
        let payload = DragPayload::empty()
            .with_mime("text/plain", b"hello".to_vec())
            .with_mime("text/html", b"<b>hello</b>".to_vec());

        assert!(payload.has_mime("text/plain"));
        assert!(payload.has_mime("text/html"));
        assert!(!payload.has_mime("image/png"));

        assert_eq!(payload.get_mime("text/plain"), Some(b"hello".as_slice()));
        assert_eq!(payload.mime_types().len(), 2);
    }

    #[test]
    fn typed_with_mime() {
        let payload = DragPayload::typed(ChapterDrag {
            chapter_id: 1,
            title: "Ch1".into(),
        })
        .with_mime("text/plain", b"Ch1".to_vec());

        assert!(payload.has_typed::<ChapterDrag>());
        assert!(payload.has_mime("text/plain"));
    }

    #[test]
    fn debug_format() {
        let payload = DragPayload::typed(42_u32);
        let s = format!("{:?}", payload);
        assert!(s.contains("DragPayload"));
        assert!(s.contains("has_typed: true"));
    }

    #[test]
    fn external_payload_origin_and_accessors() {
        let data = ExternalDropData {
            files: vec![PathBuf::from("/tmp/a.png")],
            text: Some("hello".into()),
            uris: vec!["https://example.com".into()],
            mime: HashMap::new(),
            formats: Vec::new(),
        };
        let payload = DragPayload::external(data);

        assert!(payload.is_external());
        assert_eq!(payload.origin(), DragOrigin::External);
        assert!(!payload.has_typed::<u32>());
        assert_eq!(payload.files(), &[PathBuf::from("/tmp/a.png")]);
        assert_eq!(payload.text(), Some("hello"));
        assert_eq!(payload.uris(), &["https://example.com".to_string()]);
        // Canonical MIME entries are synthesized.
        assert!(payload.has_mime("text/plain"));
        assert!(payload.has_mime("text/uri-list"));
    }

    #[test]
    fn internal_payload_has_no_external_data() {
        let payload = DragPayload::typed(7_u32);
        assert!(!payload.is_external());
        assert_eq!(payload.origin(), DragOrigin::Internal);
        assert!(payload.files().is_empty());
        assert_eq!(payload.text(), None);
        assert!(payload.uris().is_empty());
    }

    #[test]
    fn uri_list_parses_files_and_urls() {
        let list = "#comment\r\nfile:///tmp/My%20File.txt\r\nhttps://example.com/a%2Bb\r\n";
        let data = ExternalDropData::from_uri_list(list);
        // `file://` local-path decoding is OS-specific (see `uri_path_to_pathbuf`):
        // on Windows a driveless `/tmp/…` loses its leading slash and uses `\`
        // separators, so it lands as a relative `tmp\…`.
        #[cfg(windows)]
        let expected_file = PathBuf::from(r"tmp\My File.txt");
        #[cfg(not(windows))]
        let expected_file = PathBuf::from("/tmp/My File.txt");
        assert_eq!(data.files, vec![expected_file]);
        assert_eq!(data.uris, vec!["https://example.com/a+b".to_string()]);
        assert!(data.mime.contains_key("text/uri-list"));
    }

    #[test]
    fn percent_decode_handles_utf8_and_invalid() {
        // "café" encoded; plus an invalid trailing % left verbatim.
        assert_eq!(percent_decode("caf%C3%A9"), "café");
        assert_eq!(percent_decode("100%"), "100%");
        assert_eq!(percent_decode("a%2"), "a%2");
    }

    #[test]
    fn uri_list_uses_crlf_and_terminates_the_last_line() {
        let data = OutboundDragData {
            files: vec![PathBuf::from("/tmp/a.txt")],
            ..Default::default()
        };
        assert_eq!(data.to_uri_list(), "file:///tmp/a.txt\r\n");
    }

    /// The characters that would otherwise corrupt the list format itself:
    /// `#` starts a comment line, CR/LF split one path into two.
    #[cfg(not(windows))]
    #[test]
    fn uri_list_escapes_characters_that_would_break_the_format() {
        let data = OutboundDragData {
            files: vec![PathBuf::from("/tmp/a#b c.txt")],
            ..Default::default()
        };
        let list = data.to_uri_list();
        assert_eq!(list, "file:///tmp/a%23b%20c.txt\r\n");
        // ...and it survives the round trip as one file with its real name.
        let parsed = ExternalDropData::from_uri_list(&list);
        assert_eq!(parsed.files, vec![PathBuf::from("/tmp/a#b c.txt")]);
    }

    #[cfg(not(windows))]
    #[test]
    fn uri_list_round_trips_non_ascii_and_literal_percent() {
        // A filename containing a literal "%20" must not decode back to a
        // space — that only round-trips if the encoder escaped the `%`.
        let files = vec![
            PathBuf::from("/tmp/café.txt"),
            PathBuf::from("/tmp/100%20.txt"),
        ];
        let data = OutboundDragData {
            files: files.clone(),
            ..Default::default()
        };
        let parsed = ExternalDropData::from_uri_list(&data.to_uri_list());
        assert_eq!(parsed.files, files);
    }

    #[test]
    fn uri_list_passes_urls_through_without_re_encoding() {
        // Re-encoding an already-encoded URI would double every `%`.
        let data = OutboundDragData {
            uris: vec!["https://example.com/a%2Bb".to_string()],
            ..Default::default()
        };
        assert_eq!(data.to_uri_list(), "https://example.com/a%2Bb\r\n");
    }

    #[cfg(not(windows))]
    #[test]
    fn file_uri_to_pathbuf_unix() {
        assert_eq!(uri_path_to_pathbuf("/tmp/a%20b"), PathBuf::from("/tmp/a b"));
    }

    #[cfg(windows)]
    #[test]
    fn file_uri_to_pathbuf_windows_drive_letters() {
        // RFC-correct, plus the naive forms apps actually produce — all must
        // resolve to the drive path, never a UNC host.
        assert_eq!(
            uri_path_to_pathbuf("/C:/Users/a/main.rs"),
            PathBuf::from(r"C:\Users\a\main.rs")
        );
        assert_eq!(
            uri_path_to_pathbuf("C:/Users/a/main.rs"),
            PathBuf::from(r"C:\Users\a\main.rs")
        );
        // Native separators + mixed — what `file://{CARGO_MANIFEST_DIR}/src/main.rs`
        // yields on Windows (the file-drop demo's outbound file drag).
        assert_eq!(
            uri_path_to_pathbuf(r"C:\Users\a\proj/src/main.rs"),
            PathBuf::from(r"C:\Users\a\proj\src\main.rs")
        );
        // A genuine UNC path still parses as UNC.
        assert_eq!(
            uri_path_to_pathbuf("server/share/f.txt"),
            PathBuf::from(r"\\server\share\f.txt")
        );
    }
}