teksilo-core 0.24.2

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
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
// 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,
}

/// When an OS-exportable in-app drag is handed to the platform as a native
/// drag. Set on the payload with [`DragPayload::with_os_handoff`], or on a data
/// view with its `export_os_handoff` builder.
///
/// Has no effect on a payload that carries nothing an OS drag could export
/// ([`DragPayload::is_os_exportable`] is `false`): such a drag stays in the
/// application whatever this says.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OsHandoff {
    /// Hand the drag to the platform when the pointer leaves the source
    /// window. Until then it is an in-app drag, with its preview, and a drop
    /// inside the window is an in-app drop. The default.
    #[default]
    AtWindowEdge,
    /// Hand the drag to the platform on the first pointer move after the drag
    /// starts, even while the pointer is still over the source window, so the
    /// platform decides which window is under it.
    ///
    /// This is what a file export needs when the destination window can lie
    /// wholly inside the source window's rectangle (a file manager in front
    /// of a virtual machine's screen): the source window keeps receiving the
    /// pointer while the drag is in-app, so it can never see the pointer leave,
    /// and only the platform knows that another window is in front.
    ///
    /// What it costs:
    ///
    /// - No in-app drag preview once the platform has the drag: the platform
    ///   draws its own drag image.
    /// - A drop on the source window, or on another window of the
    ///   application, goes through the platform and back, so in-app hover
    ///   feedback during the drag depends on the platform delivering the
    ///   application's own drag back to it.
    /// - The handoff is asked for on the first real pointer move, and commits
    ///   only once the platform has started the native drag. A release before
    ///   that ends the drag as [`DropOutcome::Cancelled`], with no drop.
    ///
    /// A drop back on the source window reports [`DropOutcome::InApp`] to the
    /// source, so a data view exporting with
    /// `DragTransferMode::Move` still removes the rows it moved to another view
    /// of the same window. A drop on another window of the application reports
    /// the platform's outcome ([`DropOutcome::OsCopy`]).
    ///
    /// Verification status: headless tests cover the widget tree's side, with
    /// a memory backend; the native paths have not been verified on any
    /// desktop yet. The desktop passes are listed in `docs/drag-and-drop.md`
    /// §11.5.
    Eager,
}

/// Identifies one native drag a platform backend starts on its own loop, so
/// that the events it posts about it later (its start, its end) are not
/// credited to another drag: one released before its start, whose late
/// events arrive while the next drag is waiting for its own.
///
/// A backend takes a fresh one with [`OsDragId::next`] when it answers
/// [`OsDragStart::Pending`], and tags the drag's events with it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OsDragId(u64);

impl OsDragId {
    /// A fresh id, unique in the process.
    pub fn next() -> Self {
        static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
        Self(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
    }

    /// The number behind the id, for logs.
    pub fn raw(self) -> u64 {
        self.0
    }
}

/// What the platform did with a request to start a native OS drag
/// ([`WindowOps::begin_os_drag`](crate::window::WindowOps::begin_os_drag)).
///
/// Some platforms start a native drag synchronously, inside the request
/// (macOS); others start it a little later on their own loop (Wayland, X11, and
/// Windows, whose drag loop is modal and cannot run inside the dispatch that
/// asked for it). The in-app drag session must stay alive until the platform
/// really has the pointer, or a release in between finds no session and the
/// drop is lost; this is how a backend says which case it is in.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OsDragStart {
    /// Nothing started. The in-app drag continues, and a later pointer move may
    /// ask again.
    Declined,
    /// The native drag is running: the platform has the pointer now. The
    /// framework hands the in-app session over at once.
    Started,
    /// The platform accepted the request and will start the native drag on its
    /// own loop. The in-app session stays alive until the platform confirms the
    /// start ([`WidgetTree::confirm_os_drag`](crate::WidgetTree::confirm_os_drag)),
    /// or reports that the drag ended without starting
    /// ([`WidgetTree::handle_os_drag_ended_for`](crate::WidgetTree::handle_os_drag_ended_for)),
    /// each naming the drag by this id.
    Pending(OsDragId),
}

/// 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>,
    /// When an exportable drag is handed to the platform.
    os_handoff: OsHandoff,
}

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,
            os_handoff: OsHandoff::AtWindowEdge,
        }
    }

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

    /// 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),
            os_handoff: OsHandoff::AtWindowEdge,
        }
    }

    /// 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 non-file drops, and for internal
    /// drags unless [`Self::enrich_external_from_mime`] has filled the
    /// external view).
    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 file-only drops, and for internal
    /// drags unless [`Self::enrich_external_from_mime`] has filled the
    /// external view).
    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.
    ///
    /// A payload with one is OS-exportable ([`Self::is_os_exportable`]): an
    /// in-app drag carrying it is handed to the platform as a native drag when
    /// [`Self::with_os_handoff`] says so.
    pub fn with_mime(mut self, mime_type: &str, data: Vec<u8>) -> Self {
        self.mime_data.insert(mime_type.to_string(), data);
        self
    }

    /// Choose when this drag is handed to the platform as a native drag, if it
    /// is OS-exportable. The default is [`OsHandoff::AtWindowEdge`]. No effect
    /// on a payload [`Self::is_os_exportable`] rejects.
    pub fn with_os_handoff(mut self, handoff: OsHandoff) -> Self {
        self.os_handoff = handoff;
        self
    }

    /// When this drag is handed to the platform. See [`OsHandoff`].
    pub fn os_handoff(&self) -> OsHandoff {
        self.os_handoff
    }

    /// 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.
    /// When it is handed to the platform is [`Self::os_handoff`].
    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 is
    /// handed to the platform as an OS drag (at the window boundary, or
    /// earlier with [`OsHandoff::Eager`]).
    ///
    /// 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())
            .field("os_handoff", &self.os_handoff)
            .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::*;

    #[test]
    fn every_constructor_hands_off_at_the_window_edge() {
        assert_eq!(
            DragPayload::typed(1_u8).os_handoff(),
            OsHandoff::AtWindowEdge
        );
        assert_eq!(DragPayload::empty().os_handoff(), OsHandoff::AtWindowEdge);
        assert_eq!(
            DragPayload::external(ExternalDropData::default()).os_handoff(),
            OsHandoff::AtWindowEdge
        );
        let eager = DragPayload::typed(1_u8)
            .with_mime("text/plain", b"x".to_vec())
            .with_os_handoff(OsHandoff::Eager);
        assert_eq!(eager.os_handoff(), OsHandoff::Eager);
    }

    #[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")
        );
    }
}