Skip to main content

winit_appkit/
dnd.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::io;
4use std::ops::{BitOr, ControlFlow};
5use std::sync::{Arc, OnceLock};
6
7use dispatch2::MainThreadBound;
8use objc2::rc::{Retained, Weak};
9use objc2::runtime::AnyObject;
10use objc2::{AnyThread, DefinedClass as _, MainThreadMarker, Message, define_class, msg_send};
11use objc2_app_kit::{
12    NSDragOperation, NSPasteboard, NSPasteboardType, NSPasteboardTypeFileURL, NSPasteboardTypeHTML,
13    NSPasteboardTypePNG, NSPasteboardTypeSound, NSPasteboardTypeString, NSPasteboardTypeTIFF,
14    NSPasteboardWriting, NSPasteboardWritingOptions,
15};
16use objc2_foundation::{NSArray, NSData, NSObject, NSObjectProtocol, NSString};
17use winit_core::data_transfer::{
18    DataTransfer, DataTransferId, DataTransferSend, SendData, TransferType, TypeHint, TypedData,
19};
20use winit_core::event_loop::DndAction;
21use winit_core::window::WindowId;
22
23/// A thin wrapper around [`NSPasteboardType`], implementing [`TransferType`].
24#[derive(PartialEq, Eq, Debug, Clone)]
25pub struct PasteboardType {
26    hint: Option<TypeHint>,
27    // We need to convert `NSString` to `str` since `NSString` isn't `Send`/`Sync`
28    inner: Arc<str>,
29}
30
31impl PasteboardType {
32    fn from_hint(hint: TypeHint) -> Option<Self> {
33        let hint_to_pasteboard_type = unsafe {
34            [
35                (TypeHint::UriList, NSPasteboardTypeFileURL),
36                (TypeHint::Plaintext, NSPasteboardTypeString),
37                (TypeHint::Html, NSPasteboardTypeHTML),
38                (TypeHint::Image { extension_hint: Some("png") }, NSPasteboardTypePNG),
39                (TypeHint::Image { extension_hint: Some("tiff") }, NSPasteboardTypeTIFF),
40                (TypeHint::Audio { extension_hint: None }, NSPasteboardTypeSound),
41            ]
42        };
43
44        hint_to_pasteboard_type.into_iter().find_map(|(haystack, inner)| {
45            (haystack.matches(&hint))
46                .then(|| Self { hint: Some(hint), inner: inner.to_string().into() })
47        })
48    }
49}
50
51impl From<Retained<NSPasteboardType>> for PasteboardType {
52    fn from(value: Retained<NSPasteboardType>) -> Self {
53        let pasteboard_type_to_hint = unsafe {
54            [
55                // Just in case the source application uses the deprecated method, we handle it
56                // here
57                #[expect(deprecated)]
58                (objc2_app_kit::NSFilenamesPboardType, TypeHint::UriList),
59                (NSPasteboardTypeFileURL, TypeHint::UriList),
60                (NSPasteboardTypeString, TypeHint::Plaintext),
61                (NSPasteboardTypeHTML, TypeHint::Html),
62                (NSPasteboardTypePNG, TypeHint::Image { extension_hint: Some("png") }),
63                (NSPasteboardTypeTIFF, TypeHint::Image { extension_hint: Some("tiff") }),
64                (NSPasteboardTypeSound, TypeHint::Audio { extension_hint: None }),
65            ]
66        };
67
68        let hint = pasteboard_type_to_hint
69            .iter()
70            .find_map(|(pb_type, hint)| (**pb_type == *value).then_some(hint));
71
72        Self { hint: hint.copied(), inner: value.to_string().into() }
73    }
74}
75
76impl TransferType for PasteboardType {
77    fn hint(&self) -> Option<winit_core::data_transfer::TypeHint> {
78        self.hint
79    }
80
81    fn matches(&self, other: &dyn TransferType) -> bool {
82        if let Some(other_pb_type) = other.cast_ref::<Self>() {
83            *self == *other_pb_type
84        } else {
85            // If either hint is `None`, return false
86            self.hint().is_some_and(|hint| other.hint() == Some(hint))
87        }
88    }
89}
90
91/// A thin wrapper around [`NSPasteboard`], implementing [`DataTransfer`].
92#[derive(Debug)]
93pub struct Pasteboard {
94    transfer_id: DataTransferId,
95    ns_pasteboard: MainThreadBound<Retained<NSPasteboard>>,
96    types: OnceLock<Arc<[PasteboardType]>>,
97}
98
99impl Clone for Pasteboard {
100    fn clone(&self) -> Self {
101        let inner = self.ns_pasteboard.get_on_main(|inner| {
102            MainThreadBound::new(inner.clone(), MainThreadMarker::new().unwrap())
103        });
104
105        Self { transfer_id: self.transfer_id, ns_pasteboard: inner, types: self.types.clone() }
106    }
107}
108
109impl Pasteboard {
110    fn new(
111        transfer_id: DataTransferId,
112        ns_pasteboard: MainThreadBound<Retained<NSPasteboard>>,
113    ) -> Self {
114        Self { transfer_id, ns_pasteboard, types: Default::default() }
115    }
116
117    /// Get the array of [`PasteboardType`]s advertized by this [`Pasteboard`].
118    pub fn types(&self) -> &[PasteboardType] {
119        self.types.get_or_init(|| {
120            self.ns_pasteboard.get_on_main(|pb| {
121                pb.types()
122                    .map(|types| types.into_iter().map(PasteboardType::from).collect::<Vec<_>>())
123                    .unwrap_or_default()
124                    .into()
125            })
126        })
127    }
128
129    /// Get the `DataTransferId` of this pasteboard.
130    pub fn id(&self) -> DataTransferId {
131        self.transfer_id
132    }
133
134    /// Get a typed reader for this pasteboard. This is only necessary in the cross-platform case,
135    /// as a user downcasting to the platform-specific type can just access the `NSPasteboard`
136    /// directly.
137    pub(crate) fn with_type(&self, type_: PasteboardTypeSpec) -> PasteboardValue {
138        PasteboardValue { type_, pasteboard: self.clone() }
139    }
140}
141
142impl DataTransfer for Pasteboard {
143    fn for_each_available_type<'this>(
144        &'this self,
145        func: &'_ mut dyn FnMut(&'this dyn TransferType) -> std::ops::ControlFlow<()>,
146    ) {
147        let _ = self.types().iter().map(|mime| mime as &dyn TransferType).try_for_each(func);
148    }
149}
150
151#[derive(Debug, Clone)]
152pub(crate) enum PasteboardTypeSpec {
153    PasteboardType(PasteboardType),
154    TypeHint(TypeHint),
155}
156
157impl PasteboardTypeSpec {
158    pub(crate) fn from_dyn(type_: &dyn TransferType) -> Option<Self> {
159        match type_.cast_ref::<PasteboardType>() {
160            Some(pb_type) => Some(Self::PasteboardType(pb_type.clone())),
161            None => type_.hint().map(Into::into),
162        }
163    }
164}
165
166impl From<TypeHint> for PasteboardTypeSpec {
167    fn from(value: TypeHint) -> Self {
168        match PasteboardType::from_hint(value) {
169            Some(pb_type) => Self::PasteboardType(pb_type),
170            None => Self::TypeHint(value),
171        }
172    }
173}
174
175impl PasteboardTypeSpec {
176    fn pasteboard_type(&self) -> Option<&PasteboardType> {
177        match self {
178            PasteboardTypeSpec::PasteboardType(pasteboard_type) => Some(pasteboard_type),
179            PasteboardTypeSpec::TypeHint(_) => None,
180        }
181    }
182}
183
184pub fn dnd_action_to_ns_drag_operation(value: DndAction) -> NSDragOperation {
185    match value {
186        DndAction::Copy => NSDragOperation::Copy,
187        DndAction::Move => NSDragOperation::Move,
188        DndAction::Link => NSDragOperation::Link,
189        DndAction::Private => NSDragOperation::Private,
190        _ => NSDragOperation::empty(),
191    }
192}
193
194pub fn ns_drag_operation_to_dnd_action(value: NSDragOperation) -> Option<DndAction> {
195    [
196        (NSDragOperation::Copy, DndAction::Copy),
197        (NSDragOperation::Move, DndAction::Move),
198        (NSDragOperation::Link, DndAction::Link),
199        (NSDragOperation::Private, DndAction::Private),
200        // Sometimes the OS returns `Generic`, in which case we just fall back to `Copy`.
201        (NSDragOperation::Generic, DndAction::Copy),
202    ]
203    .into_iter()
204    .find_map(|(appkit, winit)| value.contains(appkit).then_some(winit))
205}
206
207pub fn dnd_actions_to_ns_drag_operation(value: &[DndAction]) -> NSDragOperation {
208    value
209        .iter()
210        .copied()
211        .map(dnd_action_to_ns_drag_operation)
212        .fold(NSDragOperation::empty(), BitOr::bitor)
213}
214
215pub fn preferred_drag_operation(
216    value: NSDragOperation,
217    preference: &[DndAction],
218) -> Option<DndAction> {
219    preference
220        .iter()
221        .find(|action| value.intersects(dnd_action_to_ns_drag_operation(**action)))
222        .copied()
223}
224
225/// A thin wrapper around [`NSPasteboard`], implementing [`TypedData`].
226#[derive(Debug)]
227pub struct PasteboardValue {
228    // The concept of "top-level" types for a pasteboard doesn't always make sense on macOS due to
229    // the use of `pasteboardItems`, so we allow using `TypeHint` instead to preserve the user's
230    // intention.
231    type_: PasteboardTypeSpec,
232    pasteboard: Pasteboard,
233}
234
235impl TypedData for PasteboardValue {
236    fn type_(&self) -> &dyn TransferType {
237        match &self.type_ {
238            PasteboardTypeSpec::PasteboardType(pasteboard_type) => {
239                pasteboard_type as &dyn TransferType
240            },
241            PasteboardTypeSpec::TypeHint(type_hint) => type_hint,
242        }
243    }
244
245    fn try_read(&self) -> Option<Box<dyn io::BufRead>> {
246        self.try_as_bytes()
247            .ok()
248            .map(|bytes| Box::new(io::Cursor::new(bytes)) as Box<dyn io::BufRead>)
249    }
250
251    fn try_as_bytes(&self) -> io::Result<Vec<u8>> {
252        let type_ = self.type_.clone();
253        self.pasteboard
254            .ns_pasteboard
255            .get_on_main(|pasteboard| {
256                let bytes =
257                    pasteboard.dataForType(&NSString::from_str(&type_.pasteboard_type()?.inner))?;
258                Some(bytes.to_vec())
259            })
260            .ok_or_else(|| {
261                io::Error::other(format!(
262                    "NSPasteboard doesn't advertise a binary representation for type {:?}",
263                    self.type_
264                ))
265            })
266    }
267
268    fn try_as_uris(&self) -> io::Result<Vec<String>> {
269        // TODO: We should probably use `readObjects`, need to check how that works.
270        if self.type_().hint() != Some(TypeHint::UriList) {
271            return Err(io::ErrorKind::InvalidData.into());
272        }
273
274        self.pasteboard.ns_pasteboard.get_on_main(|pasteboard| {
275            let Some(items) = pasteboard.pasteboardItems() else {
276                // The pasteboard didn't expose any items, so we try with the deprecated method.
277                #[expect(deprecated)]
278                let property_list = match pasteboard
279                    .propertyListForType(unsafe { objc2_app_kit::NSFilenamesPboardType })
280                {
281                    Some(property_list) => property_list,
282                    None => {
283                        return pasteboard
284                            .stringForType(unsafe { NSPasteboardTypeFileURL })
285                            .map(|ns_str| vec![ns_str.to_string()])
286                            .ok_or_else(|| io::ErrorKind::InvalidData.into());
287                    },
288                };
289
290                let paths = property_list
291                    .downcast::<NSArray>()
292                    .unwrap()
293                    .into_iter()
294                    .map(|file| file.downcast::<NSString>().unwrap().to_string())
295                    .collect();
296
297                return Ok(paths);
298            };
299
300            Ok(items
301                .into_iter()
302                .filter_map(|item| item.stringForType(unsafe { NSPasteboardTypeFileURL }))
303                .map(|ns_str| ns_str.to_string())
304                .collect())
305        })
306    }
307
308    fn try_as_string(&self) -> io::Result<String> {
309        let type_ = self.type_.clone();
310
311        self.pasteboard.ns_pasteboard.get_on_main(|pasteboard| {
312            pasteboard
313                .stringForType(&NSString::from_str(
314                    &type_.pasteboard_type().ok_or(io::ErrorKind::InvalidData)?.inner,
315                ))
316                .map(|ns_str| ns_str.to_string())
317                .ok_or_else(|| io::ErrorKind::InvalidData.into())
318        })
319    }
320}
321
322#[derive(Debug)]
323struct ActivePasteboard {
324    window_id: WindowId,
325    pb: MainThreadBound<Weak<NSPasteboard>>,
326}
327
328#[derive(Debug, Default)]
329pub struct Pasteboards {
330    inner: RefCell<HashMap<DataTransferId, ActivePasteboard>>,
331}
332
333impl Pasteboards {
334    pub fn remove_deloaded_pasteboards(&self) {
335        self.inner.borrow_mut().retain(|_, ActivePasteboard { pb, .. }| {
336            pb.get_on_main(|state| state.load().is_some())
337        });
338    }
339
340    /// If the data transfer exists, update the pasteboard it points to.
341    pub fn set_pasteboard(
342        &self,
343        id: DataTransferId,
344        new_pb: &MainThreadBound<Retained<NSPasteboard>>,
345    ) {
346        let mut inner = self.inner.borrow_mut();
347        if let Some(ActivePasteboard { pb, .. }) = inner.get_mut(&id) {
348            *pb = new_pb.get_on_main(|pb| {
349                MainThreadBound::new(Weak::from_retained(pb), MainThreadMarker::new().unwrap())
350            });
351        }
352    }
353
354    pub fn insert(
355        &self,
356        transfer_id: DataTransferId,
357        pb: &MainThreadBound<Retained<NSPasteboard>>,
358        window_id: WindowId,
359    ) {
360        let mut inner = self.inner.borrow_mut();
361        let transfer = inner.entry(transfer_id).or_insert_with(move || {
362            pb.get_on_main(move |pb| ActivePasteboard {
363                window_id,
364                pb: MainThreadBound::new(Weak::from_retained(pb), MainThreadMarker::new().unwrap()),
365            })
366        });
367
368        transfer.window_id = window_id;
369    }
370
371    pub fn get(&self, id: DataTransferId) -> Option<Pasteboard> {
372        self.inner.borrow().get(&id).and_then(|ActivePasteboard { pb, .. }| {
373            pb.get_on_main(|state| {
374                let pb = state.load()?;
375                let pb = MainThreadBound::new(pb, MainThreadMarker::new().unwrap());
376                Some(Pasteboard::new(id, pb))
377            })
378        })
379    }
380
381    /// Get the window ID that most-recently saw the provided data transfer.
382    pub fn window_id(&self, id: DataTransferId) -> Option<WindowId> {
383        self.inner.borrow().get(&id).map(|active_pasteboard| active_pasteboard.window_id)
384    }
385}
386
387pub(crate) struct PasteboardWriterState {
388    data: Box<dyn DataTransferSend>,
389    // The macOS drag-and-drop API has some confusing aspects when handling multi-drag. The best
390    // we can really do is have the first element contain all the cross-platform items, and
391    // any further items are file paths only.
392    uri: Option<Retained<NSString>>,
393    writable_types: Retained<NSArray<NSPasteboardType>>,
394}
395
396impl PasteboardWriter {
397    pub(crate) fn new(
398        value: Box<dyn DataTransferSend>,
399        uri: Option<Retained<NSString>>,
400    ) -> Retained<Self> {
401        let mut writable_types = Vec::<Retained<NSPasteboardType>>::new();
402        value.for_each_available_type(&mut |type_| {
403            let Some(spec) = PasteboardTypeSpec::from_dyn(type_) else {
404                return ControlFlow::Continue(());
405            };
406
407            let Some(pb_type) = spec.pasteboard_type() else {
408                return ControlFlow::Continue(());
409            };
410
411            writable_types.push(NSString::from_str(&pb_type.inner));
412
413            ControlFlow::Continue(())
414        });
415
416        let pb_writer = Self::alloc().set_ivars(PasteboardWriterState {
417            data: value,
418            uri,
419            writable_types: NSArray::from_retained_slice(&writable_types),
420        });
421
422        // Unsure if there's an easier way to do this, but this is how `WindowDelegate` does it.
423        unsafe { msg_send![super(pb_writer), init] }
424    }
425}
426
427impl PasteboardWriterState {
428    fn data_for_pasteboard_type(
429        &self,
430        pasteboard_type: &NSPasteboardType,
431    ) -> Option<Retained<AnyObject>> {
432        if pasteboard_type == unsafe { NSPasteboardTypeFileURL } {
433            if let Some(out) = self.uri.clone().map(Into::into) {
434                return Some(out);
435            }
436        }
437        let pb_type = PasteboardType::from(pasteboard_type.retain());
438
439        let mut out = None;
440
441        self.data.for_each_available_type(&mut |haystack| {
442            if haystack.matches(&pb_type) {
443                out = self.data.data_for_type(haystack);
444                ControlFlow::Break(())
445            } else {
446                ControlFlow::Continue(())
447            }
448        });
449
450        match out? {
451            // This should be handled separately
452            // TODO: Is there a better way to do this?
453            SendData::Uris(_) => None,
454            SendData::String(string) => Some(NSString::from_str(&string).into()),
455            SendData::Bytes(binary) => Some(NSData::from_vec(binary).into()),
456            _ => None,
457        }
458    }
459}
460
461define_class!(
462    #[unsafe(super(NSObject))]
463    #[thread_kind = AnyThread]
464    #[name = "WinitPasteboardWriter"]
465    #[ivars = PasteboardWriterState]
466    pub(crate) struct PasteboardWriter;
467
468    unsafe impl NSObjectProtocol for PasteboardWriter {}
469
470    unsafe impl NSPasteboardWriting for PasteboardWriter {
471        #[unsafe(method_id(writableTypesForPasteboard:))]
472        fn writable_types_for_pasteboard(
473            &self,
474            _: &NSPasteboard,
475        ) -> Retained<NSArray<NSPasteboardType>> {
476            let vars = self.ivars();
477            vars.writable_types.clone()
478        }
479
480        #[unsafe(method(writingOptionsForType:pasteboard:))]
481        fn writing_options_for_type(
482            &self,
483            type_: &NSPasteboardType,
484            pasteboard: &NSPasteboard,
485        ) -> NSPasteboardWritingOptions {
486            let _ = type_;
487            let _ = pasteboard;
488            NSPasteboardWritingOptions::empty()
489        }
490
491        #[unsafe(method_id(pasteboardPropertyListForType:))]
492        fn pasteboard_property_list_for_type(
493            &self,
494            type_: &NSPasteboardType,
495        ) -> Option<Retained<AnyObject>> {
496            let vars = self.ivars();
497            vars.data_for_pasteboard_type(type_)
498        }
499    }
500);