1use std::cell::{Cell, RefCell};
2use std::collections::HashMap;
3use std::io;
4use std::ops::{BitOr, ControlFlow};
5use std::sync::atomic::{AtomicI64, Ordering};
6use std::sync::{Arc, OnceLock};
7
8use dispatch2::MainThreadBound;
9use objc2::rc::{Retained, Weak};
10use objc2::runtime::AnyObject;
11use objc2::{
12 AnyThread, ClassType, DefinedClass as _, MainThreadMarker, Message, define_class, msg_send,
13};
14use objc2_app_kit::{
15 NSDragOperation, NSPasteboard, NSPasteboardType, NSPasteboardTypeFileURL, NSPasteboardTypeHTML,
16 NSPasteboardTypePNG, NSPasteboardTypeSound, NSPasteboardTypeString, NSPasteboardTypeTIFF,
17 NSPasteboardWriting, NSPasteboardWritingOptions,
18};
19use objc2_foundation::{NSArray, NSData, NSObject, NSObjectProtocol, NSString, NSURL};
20use wingy_core::data_transfer::{
21 DataTransfer, DataTransferId, DataTransferSend, SendData, TransferType, TypeHint, TypedData,
22};
23use wingy_core::event_loop::DndAction;
24use wingy_core::window::WindowId;
25
26#[derive(PartialEq, Eq, Debug, Clone)]
28pub struct PasteboardType {
29 hint: Option<TypeHint>,
30 inner: Arc<str>,
32}
33
34impl PasteboardType {
35 fn from_hint(hint: TypeHint) -> Option<Self> {
36 let hint_to_pasteboard_type = unsafe {
37 [
38 (TypeHint::UriList, NSPasteboardTypeFileURL),
39 (TypeHint::Plaintext, NSPasteboardTypeString),
40 (TypeHint::Html, NSPasteboardTypeHTML),
41 (TypeHint::Image { extension_hint: Some("png") }, NSPasteboardTypePNG),
42 (TypeHint::Image { extension_hint: Some("tiff") }, NSPasteboardTypeTIFF),
43 (TypeHint::Audio { extension_hint: None }, NSPasteboardTypeSound),
44 ]
45 };
46
47 hint_to_pasteboard_type.into_iter().find_map(|(haystack, inner)| {
48 (haystack.matches(&hint))
49 .then(|| Self { hint: Some(hint), inner: inner.to_string().into() })
50 })
51 }
52}
53
54impl From<Retained<NSPasteboardType>> for PasteboardType {
55 fn from(value: Retained<NSPasteboardType>) -> Self {
56 let pasteboard_type_to_hint = unsafe {
57 [
58 #[expect(deprecated)]
61 (objc2_app_kit::NSFilenamesPboardType, TypeHint::UriList),
62 (NSPasteboardTypeFileURL, TypeHint::UriList),
63 (NSPasteboardTypeString, TypeHint::Plaintext),
64 (NSPasteboardTypeHTML, TypeHint::Html),
65 (NSPasteboardTypePNG, TypeHint::Image { extension_hint: Some("png") }),
66 (NSPasteboardTypeTIFF, TypeHint::Image { extension_hint: Some("tiff") }),
67 (NSPasteboardTypeSound, TypeHint::Audio { extension_hint: None }),
68 ]
69 };
70
71 let hint = pasteboard_type_to_hint
72 .iter()
73 .find_map(|(pb_type, hint)| (**pb_type == *value).then_some(hint));
74
75 Self { hint: hint.copied(), inner: value.to_string().into() }
76 }
77}
78
79impl TransferType for PasteboardType {
80 fn hint(&self) -> Option<wingy_core::data_transfer::TypeHint> {
81 self.hint
82 }
83
84 fn matches(&self, other: &dyn TransferType) -> bool {
85 if let Some(other_pb_type) = other.cast_ref::<Self>() {
86 *self == *other_pb_type
87 } else {
88 self.hint().is_some_and(|hint| other.hint() == Some(hint))
90 }
91 }
92}
93
94#[derive(Debug)]
96pub struct Pasteboard {
97 transfer_id: DataTransferId,
98 ns_pasteboard: MainThreadBound<Retained<NSPasteboard>>,
99 change_count: isize,
100 types: OnceLock<Arc<[PasteboardType]>>,
101}
102
103impl Clone for Pasteboard {
104 fn clone(&self) -> Self {
105 let inner = self.ns_pasteboard.get_on_main(|inner| {
106 MainThreadBound::new(inner.clone(), MainThreadMarker::new().unwrap())
107 });
108
109 Self {
110 transfer_id: self.transfer_id,
111 ns_pasteboard: inner,
112 change_count: self.change_count,
113 types: self.types.clone(),
114 }
115 }
116}
117
118impl Pasteboard {
119 pub(crate) fn new(
120 transfer_id: DataTransferId,
121 ns_pasteboard: Retained<NSPasteboard>,
122 mtm: MainThreadMarker,
123 ) -> Self {
124 let change_count = ns_pasteboard.changeCount();
125 Self {
126 transfer_id,
127 ns_pasteboard: MainThreadBound::new(ns_pasteboard, mtm),
128 change_count,
129 types: Default::default(),
130 }
131 }
132
133 pub(crate) fn change_count(&self) -> isize {
134 self.change_count
135 }
136
137 fn try_access<R: Send>(&self, f: impl FnOnce(&NSPasteboard) -> R + Send) -> io::Result<R> {
138 self.ns_pasteboard.get_on_main(|pasteboard| {
139 if pasteboard.changeCount() != self.change_count {
140 return Err(io::Error::other("pasteboard contents changed"));
141 }
142 let result = f(pasteboard);
143 if pasteboard.changeCount() != self.change_count {
144 return Err(io::Error::other("pasteboard contents changed"));
145 }
146 Ok(result)
147 })
148 }
149
150 pub fn types(&self) -> &[PasteboardType] {
152 self.types.get_or_init(|| {
153 self.try_access(|pb| {
154 pb.types()
155 .map(|types| types.into_iter().map(PasteboardType::from).collect::<Vec<_>>())
156 .unwrap_or_default()
157 })
158 .unwrap_or_default()
159 .into()
160 })
161 }
162
163 pub fn id(&self) -> DataTransferId {
165 self.transfer_id
166 }
167
168 pub(crate) fn with_type(&self, type_: PasteboardTypeSpec) -> PasteboardValue {
172 PasteboardValue { type_, pasteboard: self.clone() }
173 }
174}
175
176impl DataTransfer for Pasteboard {
177 fn for_each_available_type<'this>(
178 &'this self,
179 func: &'_ mut dyn FnMut(&'this dyn TransferType) -> std::ops::ControlFlow<()>,
180 ) {
181 let _ = self.types().iter().map(|mime| mime as &dyn TransferType).try_for_each(func);
182 }
183}
184
185#[derive(Debug, Clone)]
186pub(crate) enum PasteboardTypeSpec {
187 PasteboardType(PasteboardType),
188 TypeHint(TypeHint),
189}
190
191impl PasteboardTypeSpec {
192 pub(crate) fn from_dyn(type_: &dyn TransferType) -> Option<Self> {
193 match type_.cast_ref::<PasteboardType>() {
194 Some(pb_type) => Some(Self::PasteboardType(pb_type.clone())),
195 None => type_.hint().map(Into::into),
196 }
197 }
198}
199
200impl From<TypeHint> for PasteboardTypeSpec {
201 fn from(value: TypeHint) -> Self {
202 match PasteboardType::from_hint(value) {
203 Some(pb_type) => Self::PasteboardType(pb_type),
204 None => Self::TypeHint(value),
205 }
206 }
207}
208
209impl PasteboardTypeSpec {
210 fn pasteboard_type(&self) -> Option<&PasteboardType> {
211 match self {
212 PasteboardTypeSpec::PasteboardType(pasteboard_type) => Some(pasteboard_type),
213 PasteboardTypeSpec::TypeHint(_) => None,
214 }
215 }
216}
217
218pub fn dnd_action_to_ns_drag_operation(value: DndAction) -> NSDragOperation {
219 match value {
220 DndAction::Copy => NSDragOperation::Copy,
221 DndAction::Move => NSDragOperation::Move,
222 DndAction::Link => NSDragOperation::Link,
223 DndAction::Private => NSDragOperation::Private,
224 _ => NSDragOperation::empty(),
225 }
226}
227
228pub fn ns_drag_operation_to_dnd_action(value: NSDragOperation) -> Option<DndAction> {
229 [
230 (NSDragOperation::Copy, DndAction::Copy),
231 (NSDragOperation::Move, DndAction::Move),
232 (NSDragOperation::Link, DndAction::Link),
233 (NSDragOperation::Private, DndAction::Private),
234 (NSDragOperation::Generic, DndAction::Copy),
236 ]
237 .into_iter()
238 .find_map(|(appkit, wingy)| value.contains(appkit).then_some(wingy))
239}
240
241pub fn dnd_actions_to_ns_drag_operation(value: &[DndAction]) -> NSDragOperation {
242 value
243 .iter()
244 .copied()
245 .map(dnd_action_to_ns_drag_operation)
246 .fold(NSDragOperation::empty(), BitOr::bitor)
247}
248
249pub fn preferred_drag_operation(
250 value: NSDragOperation,
251 preference: &[DndAction],
252) -> Option<DndAction> {
253 preference
254 .iter()
255 .find(|action| value.intersects(dnd_action_to_ns_drag_operation(**action)))
256 .copied()
257}
258
259#[derive(Debug)]
261pub struct PasteboardValue {
262 type_: PasteboardTypeSpec,
266 pasteboard: Pasteboard,
267}
268
269impl TypedData for PasteboardValue {
270 fn type_(&self) -> &dyn TransferType {
271 match &self.type_ {
272 PasteboardTypeSpec::PasteboardType(pasteboard_type) => {
273 pasteboard_type as &dyn TransferType
274 },
275 PasteboardTypeSpec::TypeHint(type_hint) => type_hint,
276 }
277 }
278
279 fn try_read(&self) -> Option<Box<dyn io::BufRead>> {
280 self.try_as_bytes()
281 .ok()
282 .map(|bytes| Box::new(io::Cursor::new(bytes)) as Box<dyn io::BufRead>)
283 }
284
285 fn try_as_bytes(&self) -> io::Result<Vec<u8>> {
286 let type_ = self.type_.clone();
287 self.pasteboard
288 .try_access(|pasteboard| {
289 let bytes =
290 pasteboard.dataForType(&NSString::from_str(&type_.pasteboard_type()?.inner))?;
291 Some(bytes.to_vec())
292 })?
293 .ok_or_else(|| {
294 io::Error::other(format!(
295 "NSPasteboard doesn't advertise a binary representation for type {:?}",
296 self.type_
297 ))
298 })
299 }
300
301 fn try_as_uris(&self) -> io::Result<Vec<String>> {
302 if self.type_().hint() != Some(TypeHint::UriList) {
303 return Err(io::ErrorKind::InvalidData.into());
304 }
305
306 self.pasteboard.try_access(|pasteboard| {
307 let classes = NSArray::from_slice(&[NSURL::class()]);
308 let uris = unsafe { pasteboard.readObjectsForClasses_options(&classes, None) }
309 .into_iter()
310 .flatten()
311 .filter_map(|url| url.downcast::<NSURL>().ok())
312 .filter_map(|url| {
313 url.filePathURL()
314 .as_deref()
315 .unwrap_or(&url)
316 .absoluteString()
317 .map(|uri| uri.to_string())
318 })
319 .collect::<Vec<_>>();
320
321 if !uris.is_empty() {
322 return Ok(uris);
323 }
324
325 #[expect(deprecated)]
327 let property_list = pasteboard
328 .propertyListForType(unsafe { objc2_app_kit::NSFilenamesPboardType })
329 .ok_or(io::ErrorKind::InvalidData)?;
330
331 Ok(property_list
332 .downcast::<NSArray>()
333 .unwrap()
334 .into_iter()
335 .filter_map(|file| {
336 NSURL::fileURLWithPath(&file.downcast::<NSString>().unwrap()).absoluteString()
337 })
338 .map(|uri| uri.to_string())
339 .collect())
340 })?
341 }
342
343 fn try_as_string(&self) -> io::Result<String> {
344 let type_ = self.type_.clone();
345
346 self.pasteboard.try_access(|pasteboard| {
347 pasteboard
348 .stringForType(&NSString::from_str(
349 &type_.pasteboard_type().ok_or(io::ErrorKind::InvalidData)?.inner,
350 ))
351 .map(|ns_str| ns_str.to_string())
352 .ok_or_else(|| io::ErrorKind::InvalidData.into())
353 })?
354 }
355}
356
357#[derive(Debug)]
358struct ActivePasteboard {
359 window_id: WindowId,
360 pb: MainThreadBound<Weak<NSPasteboard>>,
361}
362
363#[derive(Debug, Default)]
364pub struct Pasteboards {
365 inner: RefCell<HashMap<DataTransferId, ActivePasteboard>>,
366}
367
368impl Pasteboards {
369 pub fn remove_deloaded_pasteboards(&self) {
370 self.inner.borrow_mut().retain(|_, ActivePasteboard { pb, .. }| {
371 pb.get_on_main(|state| state.load().is_some())
372 });
373 }
374
375 pub fn set_pasteboard(
377 &self,
378 id: DataTransferId,
379 new_pb: &MainThreadBound<Retained<NSPasteboard>>,
380 ) {
381 let mut inner = self.inner.borrow_mut();
382 if let Some(ActivePasteboard { pb, .. }) = inner.get_mut(&id) {
383 *pb = new_pb.get_on_main(|pb| {
384 MainThreadBound::new(Weak::from_retained(pb), MainThreadMarker::new().unwrap())
385 });
386 }
387 }
388
389 pub fn insert(
390 &self,
391 transfer_id: DataTransferId,
392 pb: &MainThreadBound<Retained<NSPasteboard>>,
393 window_id: WindowId,
394 ) {
395 let mut inner = self.inner.borrow_mut();
396 let transfer = inner.entry(transfer_id).or_insert_with(move || {
397 pb.get_on_main(move |pb| ActivePasteboard {
398 window_id,
399 pb: MainThreadBound::new(Weak::from_retained(pb), MainThreadMarker::new().unwrap()),
400 })
401 });
402
403 transfer.window_id = window_id;
404 }
405
406 pub fn get(&self, id: DataTransferId) -> Option<Pasteboard> {
407 self.inner.borrow().get(&id).and_then(|ActivePasteboard { pb, .. }| {
408 pb.get_on_main(|state| {
409 Some(Pasteboard::new(id, state.load()?, MainThreadMarker::new().unwrap()))
410 })
411 })
412 }
413
414 pub fn window_id(&self, id: DataTransferId) -> Option<WindowId> {
416 self.inner.borrow().get(&id).map(|active_pasteboard| active_pasteboard.window_id)
417 }
418}
419
420#[derive(Debug)]
421struct DragState {
422 id: DataTransferId,
423 valid_actions: Vec<DndAction>,
424}
425
426#[derive(Debug, Default)]
427pub(crate) struct DataTransferState {
428 pasteboards: Pasteboards,
429 clipboard: Cell<Option<(isize, DataTransferId)>>,
430 drag: RefCell<Option<DragState>>,
431}
432
433impl DataTransferState {
434 pub(crate) fn pasteboards(&self) -> &Pasteboards {
435 &self.pasteboards
436 }
437
438 pub(crate) fn register_drag(
439 &self,
440 id: DataTransferId,
441 pb: &MainThreadBound<Retained<NSPasteboard>>,
442 window_id: WindowId,
443 ) {
444 self.pasteboards.insert(id, pb, window_id);
445 self.drag.replace(Some(DragState { id, valid_actions: vec![] }));
446 }
447
448 pub(crate) fn remove_drag(&self, id: DataTransferId) {
449 let mut state = self.drag.borrow_mut();
450 if state.as_ref().is_some_and(|s| s.id == id) {
451 *state = None;
452 }
453 }
454
455 pub(crate) fn current_drag_id(&self) -> Option<DataTransferId> {
456 self.drag.borrow().as_ref().map(|state| state.id)
457 }
458
459 pub(crate) fn set_valid_dnd_actions(&self, id: DataTransferId, actions: &[DndAction]) -> bool {
460 match &mut *self.drag.borrow_mut() {
461 Some(state) if state.id == id => {
462 state.valid_actions.clear();
463 state.valid_actions.extend_from_slice(actions);
464 true
465 },
466 _ => false,
467 }
468 }
469
470 pub(crate) fn proposed_drag_action(
471 &self,
472 source_operations: NSDragOperation,
473 ) -> Option<DndAction> {
474 self.drag
475 .borrow()
476 .as_ref()
477 .and_then(|state| preferred_drag_operation(source_operations, &state.valid_actions))
478 }
479
480 pub(crate) fn proposed_drag_operation(
481 &self,
482 source_operations: NSDragOperation,
483 ) -> NSDragOperation {
484 self.proposed_drag_action(source_operations)
485 .map(dnd_action_to_ns_drag_operation)
486 .unwrap_or(NSDragOperation::empty())
487 }
488
489 pub(crate) fn clipboard_transfer_id(&self, change_count: isize) -> DataTransferId {
490 static NEXT_ID: AtomicI64 = AtomicI64::new(-1);
492
493 match self.clipboard.get() {
494 Some((cached, id)) if cached == change_count => id,
495 _ => {
496 let id = DataTransferId::from_raw(NEXT_ID.fetch_sub(1, Ordering::Relaxed));
497 self.clipboard.set(Some((change_count, id)));
498 id
499 },
500 }
501 }
502}
503
504pub(crate) struct PasteboardWriterState {
505 data: Box<dyn DataTransferSend>,
506 uri: Option<Retained<NSString>>,
510 writable_types: Retained<NSArray<NSPasteboardType>>,
511}
512
513impl PasteboardWriter {
514 pub(crate) fn new(
515 value: Box<dyn DataTransferSend>,
516 uri: Option<Retained<NSString>>,
517 ) -> Retained<Self> {
518 let mut writable_types = Vec::<Retained<NSPasteboardType>>::new();
519 value.for_each_available_type(&mut |type_| {
520 let Some(spec) = PasteboardTypeSpec::from_dyn(type_) else {
521 return ControlFlow::Continue(());
522 };
523
524 let Some(pb_type) = spec.pasteboard_type() else {
525 return ControlFlow::Continue(());
526 };
527
528 writable_types.push(NSString::from_str(&pb_type.inner));
529
530 ControlFlow::Continue(())
531 });
532
533 let pb_writer = Self::alloc().set_ivars(PasteboardWriterState {
534 data: value,
535 uri,
536 writable_types: NSArray::from_retained_slice(&writable_types),
537 });
538
539 unsafe { msg_send![super(pb_writer), init] }
541 }
542}
543
544impl PasteboardWriterState {
545 fn data_for_pasteboard_type(
546 &self,
547 pasteboard_type: &NSPasteboardType,
548 ) -> Option<Retained<AnyObject>> {
549 if pasteboard_type == unsafe { NSPasteboardTypeFileURL } {
550 if let Some(out) = self.uri.clone().map(Into::into) {
551 return Some(out);
552 }
553 }
554 let pb_type = PasteboardType::from(pasteboard_type.retain());
555
556 let mut out = None;
557
558 self.data.for_each_available_type(&mut |haystack| {
559 if haystack.matches(&pb_type) {
560 out = self.data.data_for_type(haystack);
561 ControlFlow::Break(())
562 } else {
563 ControlFlow::Continue(())
564 }
565 });
566
567 match out? {
568 SendData::Uris(_) => None,
571 SendData::String(string) => Some(NSString::from_str(&string).into()),
572 SendData::Bytes(binary) => Some(NSData::from_vec(binary).into()),
573 _ => None,
574 }
575 }
576}
577
578define_class!(
579 #[unsafe(super(NSObject))]
580 #[thread_kind = AnyThread]
581 #[name = "WingyPasteboardWriter"]
582 #[ivars = PasteboardWriterState]
583 pub(crate) struct PasteboardWriter;
584
585 unsafe impl NSObjectProtocol for PasteboardWriter {}
586
587 unsafe impl NSPasteboardWriting for PasteboardWriter {
588 #[unsafe(method_id(writableTypesForPasteboard:))]
589 fn writable_types_for_pasteboard(
590 &self,
591 _: &NSPasteboard,
592 ) -> Retained<NSArray<NSPasteboardType>> {
593 let vars = self.ivars();
594 vars.writable_types.clone()
595 }
596
597 #[unsafe(method(writingOptionsForType:pasteboard:))]
598 fn writing_options_for_type(
599 &self,
600 type_: &NSPasteboardType,
601 pasteboard: &NSPasteboard,
602 ) -> NSPasteboardWritingOptions {
603 let _ = type_;
604 let _ = pasteboard;
605 NSPasteboardWritingOptions::empty()
606 }
607
608 #[unsafe(method_id(pasteboardPropertyListForType:))]
609 fn pasteboard_property_list_for_type(
610 &self,
611 type_: &NSPasteboardType,
612 ) -> Option<Retained<AnyObject>> {
613 let vars = self.ivars();
614 vars.data_for_pasteboard_type(type_)
615 }
616 }
617);