pasteboard.rs

  1use core::slice;
  2use std::ffi::c_void;
  3
  4use cocoa::{
  5    appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF},
  6    base::{id, nil},
  7    foundation::NSData,
  8};
  9use objc::{msg_send, runtime::Object, sel, sel_impl};
 10use strum::IntoEnumIterator as _;
 11
 12use crate::{
 13    ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, asset_cache::hash,
 14    platform::mac::ns_string,
 15};
 16
 17pub struct Pasteboard {
 18    inner: id,
 19    text_hash_type: id,
 20    metadata_type: id,
 21}
 22
 23impl Pasteboard {
 24    pub fn general() -> Self {
 25        unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) }
 26    }
 27
 28    pub fn find() -> Self {
 29        unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) }
 30    }
 31
 32    #[cfg(test)]
 33    pub fn unique() -> Self {
 34        unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) }
 35    }
 36
 37    unsafe fn new(inner: id) -> Self {
 38        Self {
 39            inner,
 40            text_hash_type: unsafe { ns_string("zed-text-hash") },
 41            metadata_type: unsafe { ns_string("zed-metadata") },
 42        }
 43    }
 44
 45    pub fn read(&self) -> Option<ClipboardItem> {
 46        // First, see if it's a string.
 47        unsafe {
 48            let pasteboard_types: id = self.inner.types();
 49            let string_type: id = ns_string("public.utf8-plain-text");
 50
 51            if msg_send![pasteboard_types, containsObject: string_type] {
 52                let data = self.inner.dataForType(string_type);
 53                if data == nil {
 54                    return None;
 55                } else if data.bytes().is_null() {
 56                    // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
 57                    // "If the length of the NSData object is 0, this property returns nil."
 58                    return Some(self.read_string(&[]));
 59                } else {
 60                    let bytes =
 61                        slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
 62
 63                    return Some(self.read_string(bytes));
 64                }
 65            }
 66
 67            // If it wasn't a string, try the various supported image types.
 68            for format in ImageFormat::iter() {
 69                if let Some(item) = self.read_image(format) {
 70                    return Some(item);
 71                }
 72            }
 73        }
 74
 75        // If it wasn't a string or a supported image type, give up.
 76        None
 77    }
 78
 79    fn read_image(&self, format: ImageFormat) -> Option<ClipboardItem> {
 80        let mut ut_type: UTType = format.into();
 81
 82        unsafe {
 83            let types: id = self.inner.types();
 84            if msg_send![types, containsObject: ut_type.inner()] {
 85                self.data_for_type(ut_type.inner_mut()).map(|bytes| {
 86                    let bytes = bytes.to_vec();
 87                    let id = hash(&bytes);
 88
 89                    ClipboardItem {
 90                        entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
 91                    }
 92                })
 93            } else {
 94                None
 95            }
 96        }
 97    }
 98
 99    fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem {
100        unsafe {
101            let text = String::from_utf8_lossy(text_bytes).to_string();
102            let metadata = self
103                .data_for_type(self.text_hash_type)
104                .and_then(|hash_bytes| {
105                    let hash_bytes = hash_bytes.try_into().ok()?;
106                    let hash = u64::from_be_bytes(hash_bytes);
107                    let metadata = self.data_for_type(self.metadata_type)?;
108
109                    if hash == ClipboardString::text_hash(&text) {
110                        String::from_utf8(metadata.to_vec()).ok()
111                    } else {
112                        None
113                    }
114                });
115
116            ClipboardItem {
117                entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
118            }
119        }
120    }
121
122    unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> {
123        unsafe {
124            let data = self.inner.dataForType(kind);
125            if data == nil {
126                None
127            } else {
128                Some(slice::from_raw_parts(
129                    data.bytes() as *mut u8,
130                    data.length() as usize,
131                ))
132            }
133        }
134    }
135
136    pub fn write(&self, item: ClipboardItem) {
137        unsafe {
138            match item.entries.as_slice() {
139                [] => {
140                    // Writing an empty list of entries just clears the clipboard.
141                    self.inner.clearContents();
142                }
143                [ClipboardEntry::String(string)] => {
144                    self.write_plaintext(string);
145                }
146                [ClipboardEntry::Image(image)] => {
147                    self.write_image(image);
148                }
149                [ClipboardEntry::ExternalPaths(_)] => {}
150                _ => {
151                    // Agus NB: We're currently only writing string entries to the clipboard when we have more than one.
152                    //
153                    // This was the existing behavior before I refactored the outer clipboard code:
154                    // https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110
155                    //
156                    // Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor.
157
158                    let mut combined = ClipboardString {
159                        text: String::new(),
160                        metadata: None,
161                    };
162
163                    for entry in item.entries {
164                        match entry {
165                            ClipboardEntry::String(text) => {
166                                combined.text.push_str(&text.text());
167                                if combined.metadata.is_none() {
168                                    combined.metadata = text.metadata;
169                                }
170                            }
171                            _ => {}
172                        }
173                    }
174
175                    self.write_plaintext(&combined);
176                }
177            }
178        }
179    }
180
181    fn write_plaintext(&self, string: &ClipboardString) {
182        unsafe {
183            self.inner.clearContents();
184
185            let text_bytes = NSData::dataWithBytes_length_(
186                nil,
187                string.text.as_ptr() as *const c_void,
188                string.text.len() as u64,
189            );
190            self.inner
191                .setData_forType(text_bytes, NSPasteboardTypeString);
192
193            if let Some(metadata) = string.metadata.as_ref() {
194                let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
195                let hash_bytes = NSData::dataWithBytes_length_(
196                    nil,
197                    hash_bytes.as_ptr() as *const c_void,
198                    hash_bytes.len() as u64,
199                );
200                self.inner.setData_forType(hash_bytes, self.text_hash_type);
201
202                let metadata_bytes = NSData::dataWithBytes_length_(
203                    nil,
204                    metadata.as_ptr() as *const c_void,
205                    metadata.len() as u64,
206                );
207                self.inner
208                    .setData_forType(metadata_bytes, self.metadata_type);
209            }
210        }
211    }
212
213    unsafe fn write_image(&self, image: &Image) {
214        unsafe {
215            self.inner.clearContents();
216
217            let bytes = NSData::dataWithBytes_length_(
218                nil,
219                image.bytes.as_ptr() as *const c_void,
220                image.bytes.len() as u64,
221            );
222
223            self.inner
224                .setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
225        }
226    }
227}
228
229#[link(name = "AppKit", kind = "framework")]
230unsafe extern "C" {
231    /// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc)
232    pub static NSPasteboardNameFind: id;
233}
234
235impl From<ImageFormat> for UTType {
236    fn from(value: ImageFormat) -> Self {
237        match value {
238            ImageFormat::Png => Self::png(),
239            ImageFormat::Jpeg => Self::jpeg(),
240            ImageFormat::Tiff => Self::tiff(),
241            ImageFormat::Webp => Self::webp(),
242            ImageFormat::Gif => Self::gif(),
243            ImageFormat::Bmp => Self::bmp(),
244            ImageFormat::Svg => Self::svg(),
245            ImageFormat::Ico => Self::ico(),
246        }
247    }
248}
249
250// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
251pub struct UTType(id);
252
253impl UTType {
254    pub fn png() -> Self {
255        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
256        Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
257    }
258
259    pub fn jpeg() -> Self {
260        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
261        Self(unsafe { ns_string("public.jpeg") })
262    }
263
264    pub fn gif() -> Self {
265        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
266        Self(unsafe { ns_string("com.compuserve.gif") })
267    }
268
269    pub fn webp() -> Self {
270        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
271        Self(unsafe { ns_string("org.webmproject.webp") })
272    }
273
274    pub fn bmp() -> Self {
275        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
276        Self(unsafe { ns_string("com.microsoft.bmp") })
277    }
278
279    pub fn svg() -> Self {
280        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
281        Self(unsafe { ns_string("public.svg-image") })
282    }
283
284    pub fn ico() -> Self {
285        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
286        Self(unsafe { ns_string("com.microsoft.ico") })
287    }
288
289    pub fn tiff() -> Self {
290        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
291        Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
292    }
293
294    fn inner(&self) -> *const Object {
295        self.0
296    }
297
298    pub fn inner_mut(&self) -> *mut Object {
299        self.0 as *mut _
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData};
306
307    use crate::{ClipboardEntry, ClipboardItem, ClipboardString};
308
309    use super::*;
310
311    #[test]
312    fn test_string() {
313        let pasteboard = Pasteboard::unique();
314        assert_eq!(pasteboard.read(), None);
315
316        let item = ClipboardItem::new_string("1".to_string());
317        pasteboard.write(item.clone());
318        assert_eq!(pasteboard.read(), Some(item));
319
320        let item = ClipboardItem {
321            entries: vec![ClipboardEntry::String(
322                ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
323            )],
324        };
325        pasteboard.write(item.clone());
326        assert_eq!(pasteboard.read(), Some(item));
327
328        let text_from_other_app = "text from other app";
329        unsafe {
330            let bytes = NSData::dataWithBytes_length_(
331                nil,
332                text_from_other_app.as_ptr() as *const c_void,
333                text_from_other_app.len() as u64,
334            );
335            pasteboard
336                .inner
337                .setData_forType(bytes, NSPasteboardTypeString);
338        }
339        assert_eq!(
340            pasteboard.read(),
341            Some(ClipboardItem::new_string(text_from_other_app.to_string()))
342        );
343    }
344}