use std::any::Any;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DragOrigin {
Internal,
External,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DropOutcome {
InApp {
accepted: bool,
},
OsCopy,
OsMove,
Cancelled,
}
#[derive(Debug, Clone, Default)]
pub struct OutboundDragData {
pub mime: HashMap<String, Vec<u8>>,
pub files: Vec<PathBuf>,
pub text: Option<String>,
pub uris: Vec<String>,
}
impl OutboundDragData {
pub fn is_empty(&self) -> bool {
self.mime.is_empty() && self.files.is_empty() && self.text.is_none() && self.uris.is_empty()
}
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 {
list.push_str(uri);
list.push_str("\r\n");
}
list
}
}
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
}
#[derive(Debug, Clone)]
pub struct DragImageData {
pub rgba: Vec<u8>,
pub width: u32,
pub height: u32,
pub hot_x: f32,
pub hot_y: f32,
}
#[derive(Debug, Clone, Default)]
pub struct ExternalDropData {
pub files: Vec<PathBuf>,
pub text: Option<String>,
pub uris: Vec<String>,
pub mime: HashMap<String, Vec<u8>>,
pub formats: Vec<String>,
}
impl ExternalDropData {
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()],
}
}
pub fn is_empty(&self) -> bool {
self.files.is_empty() && self.text.is_none() && self.uris.is_empty()
}
}
fn uri_path_to_pathbuf(after_scheme: &str) -> PathBuf {
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)]
{
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"\"));
}
if !authority.is_empty() {
let mut s = String::from(r"\\");
s.push_str(authority);
s.push_str(&decoded.replace('/', r"\"));
return PathBuf::from(s);
}
let trimmed = decoded.strip_prefix('/').unwrap_or(&decoded);
PathBuf::from(trimmed.replace('/', r"\"))
}
#[cfg(not(windows))]
{
let _ = authority; PathBuf::from(decoded)
}
}
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()
}
pub struct DragPayload {
typed: Option<Box<dyn Any>>,
mime_data: HashMap<String, Vec<u8>>,
origin: DragOrigin,
external: Option<ExternalDropData>,
}
impl DragPayload {
pub fn typed<T: 'static>(data: T) -> Self {
Self {
typed: Some(Box::new(data)),
mime_data: HashMap::new(),
origin: DragOrigin::Internal,
external: None,
}
}
pub fn empty() -> Self {
Self {
typed: None,
mime_data: HashMap::new(),
origin: DragOrigin::Internal,
external: None,
}
}
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),
}
}
pub fn origin(&self) -> DragOrigin {
self.origin
}
pub fn is_external(&self) -> bool {
self.origin == DragOrigin::External
}
pub fn files(&self) -> &[PathBuf] {
self.external.as_ref().map_or(&[], |e| &e.files)
}
pub fn text(&self) -> Option<&str> {
self.external.as_ref().and_then(|e| e.text.as_deref())
}
pub fn uris(&self) -> &[String] {
self.external.as_ref().map_or(&[], |e| &e.uris)
}
pub fn formats(&self) -> &[String] {
self.external.as_ref().map_or(&[], |e| &e.formats)
}
pub fn with_mime(mut self, mime_type: &str, data: Vec<u8>) -> Self {
self.mime_data.insert(mime_type.to_string(), data);
self
}
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);
}
}
pub fn get_typed<T: 'static>(&self) -> Option<&T> {
self.typed.as_ref().and_then(|v| v.downcast_ref::<T>())
}
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) => {
self.typed = Some(boxed);
None
}
}
}
pub fn has_typed<T: 'static>(&self) -> bool {
self.typed
.as_ref()
.is_some_and(|v| v.downcast_ref::<T>().is_some())
}
pub fn has_mime(&self, mime_type: &str) -> bool {
self.mime_data.contains_key(mime_type)
}
pub fn get_mime(&self, mime_type: &str) -> Option<&[u8]> {
self.mime_data.get(mime_type).map(|v| v.as_slice())
}
pub fn mime_types(&self) -> Vec<&str> {
self.mime_data.keys().map(|s| s.as_str()).collect()
}
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())
}
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()
}
}
pub trait DragData: Any + std::fmt::Debug + 'static {
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>()); }
#[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()]);
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);
#[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() {
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");
}
#[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");
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() {
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() {
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() {
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")
);
assert_eq!(
uri_path_to_pathbuf(r"C:\Users\a\proj/src/main.rs"),
PathBuf::from(r"C:\Users\a\proj\src\main.rs")
);
assert_eq!(
uri_path_to_pathbuf("server/share/f.txt"),
PathBuf::from(r"\\server\share\f.txt")
);
}
}