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