From d7e41f74fb3e0279b24f190c19b4ccd3680d01b5 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 19 Dec 2025 13:31:27 -0300 Subject: [PATCH] search: Respect macOS' find pasteboard (#45311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #17467 Release Notes: - On macOS, buffer search now syncs with the system find pasteboard, allowing ⌘E and ⌘G to work seamlessly across Zed and other apps. --- crates/gpui/src/app.rs | 36 +- crates/gpui/src/platform.rs | 12 +- crates/gpui/src/platform/mac.rs | 3 +- .../src/platform/mac/attributed_string.rs | 129 ------ crates/gpui/src/platform/mac/pasteboard.rs | 344 +++++++++++++++ crates/gpui/src/platform/mac/platform.rs | 397 ++---------------- crates/gpui/src/platform/test/platform.rs | 24 +- crates/search/src/buffer_search.rs | 89 +++- 8 files changed, 499 insertions(+), 535 deletions(-) delete mode 100644 crates/gpui/src/platform/mac/attributed_string.rs create mode 100644 crates/gpui/src/platform/mac/pasteboard.rs diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 75600a9ee1..96f815ac0b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1077,11 +1077,9 @@ impl App { self.platform.window_appearance() } - /// Writes data to the primary selection buffer. - /// Only available on Linux. - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - pub fn write_to_primary(&self, item: ClipboardItem) { - self.platform.write_to_primary(item) + /// Reads data from the platform clipboard. + pub fn read_from_clipboard(&self) -> Option { + self.platform.read_from_clipboard() } /// Writes data to the platform clipboard. @@ -1096,9 +1094,31 @@ impl App { self.platform.read_from_primary() } - /// Reads data from the platform clipboard. - pub fn read_from_clipboard(&self) -> Option { - self.platform.read_from_clipboard() + /// Writes data to the primary selection buffer. + /// Only available on Linux. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + pub fn write_to_primary(&self, item: ClipboardItem) { + self.platform.write_to_primary(item) + } + + /// Reads data from macOS's "Find" pasteboard. + /// + /// Used to share the current search string between apps. + /// + /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find + #[cfg(target_os = "macos")] + pub fn read_from_find_pasteboard(&self) -> Option { + self.platform.read_from_find_pasteboard() + } + + /// Writes data to macOS's "Find" pasteboard. + /// + /// Used to share the current search string between apps. + /// + /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find + #[cfg(target_os = "macos")] + pub fn write_to_find_pasteboard(&self, item: ClipboardItem) { + self.platform.write_to_find_pasteboard(item) } /// Writes credentials to the platform keychain. diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 22f4c46921..112775890e 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -262,12 +262,18 @@ pub(crate) trait Platform: 'static { fn set_cursor_style(&self, style: CursorStyle); fn should_auto_hide_scrollbars(&self) -> bool; - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - fn write_to_primary(&self, item: ClipboardItem); + fn read_from_clipboard(&self) -> Option; fn write_to_clipboard(&self, item: ClipboardItem); + #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn read_from_primary(&self) -> Option; - fn read_from_clipboard(&self) -> Option; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn write_to_primary(&self, item: ClipboardItem); + + #[cfg(target_os = "macos")] + fn read_from_find_pasteboard(&self) -> Option; + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, item: ClipboardItem); fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task>; fn read_credentials(&self, url: &str) -> Task)>>>; diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index aa056846e6..a229ec7dce 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -5,6 +5,7 @@ mod display; mod display_link; mod events; mod keyboard; +mod pasteboard; #[cfg(feature = "screen-capture")] mod screen_capture; @@ -21,8 +22,6 @@ use metal_renderer as renderer; #[cfg(feature = "macos-blade")] use crate::platform::blade as renderer; -mod attributed_string; - #[cfg(feature = "font-kit")] mod open_type; diff --git a/crates/gpui/src/platform/mac/attributed_string.rs b/crates/gpui/src/platform/mac/attributed_string.rs deleted file mode 100644 index 42fe1e5bf7..0000000000 --- a/crates/gpui/src/platform/mac/attributed_string.rs +++ /dev/null @@ -1,129 +0,0 @@ -use cocoa::base::id; -use cocoa::foundation::NSRange; -use objc::{class, msg_send, sel, sel_impl}; - -/// The `cocoa` crate does not define NSAttributedString (and related Cocoa classes), -/// which are needed for copying rich text (that is, text intermingled with images) -/// to the clipboard. This adds access to those APIs. -#[allow(non_snake_case)] -pub trait NSAttributedString: Sized { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSAttributedString), alloc] - } - - unsafe fn init_attributed_string(self, string: id) -> id; - unsafe fn appendAttributedString_(self, attr_string: id); - unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id; - unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id; - unsafe fn string(self) -> id; -} - -impl NSAttributedString for id { - unsafe fn init_attributed_string(self, string: id) -> id { - msg_send![self, initWithString: string] - } - - unsafe fn appendAttributedString_(self, attr_string: id) { - let _: () = msg_send![self, appendAttributedString: attr_string]; - } - - unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id { - msg_send![self, RTFDFromRange: range documentAttributes: attrs] - } - - unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id { - msg_send![self, RTFFromRange: range documentAttributes: attrs] - } - - unsafe fn string(self) -> id { - msg_send![self, string] - } -} - -pub trait NSMutableAttributedString: NSAttributedString { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSMutableAttributedString), alloc] - } -} - -impl NSMutableAttributedString for id {} - -#[cfg(test)] -mod tests { - use crate::platform::mac::ns_string; - - use super::*; - use cocoa::appkit::NSImage; - use cocoa::base::nil; - use cocoa::foundation::NSAutoreleasePool; - #[test] - #[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348 - fn test_nsattributed_string() { - // TODO move these to parent module once it's actually ready to be used - #[allow(non_snake_case)] - pub trait NSTextAttachment: Sized { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSTextAttachment), alloc] - } - } - - impl NSTextAttachment for id {} - - unsafe { - let image: id = { - let img: id = msg_send![class!(NSImage), alloc]; - let img: id = msg_send![img, initWithContentsOfFile: ns_string("test.jpeg")]; - let img: id = msg_send![img, autorelease]; - img - }; - let _size = image.size(); - - let string = ns_string("Test String"); - let attr_string = NSMutableAttributedString::alloc(nil) - .init_attributed_string(string) - .autorelease(); - let hello_string = ns_string("Hello World"); - let hello_attr_string = NSAttributedString::alloc(nil) - .init_attributed_string(hello_string) - .autorelease(); - attr_string.appendAttributedString_(hello_attr_string); - - let attachment: id = msg_send![NSTextAttachment::alloc(nil), autorelease]; - let _: () = msg_send![attachment, setImage: image]; - let image_attr_string = - msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment]; - attr_string.appendAttributedString_(image_attr_string); - - let another_string = ns_string("Another String"); - let another_attr_string = NSAttributedString::alloc(nil) - .init_attributed_string(another_string) - .autorelease(); - attr_string.appendAttributedString_(another_attr_string); - - let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length]; - - /////////////////////////////////////////////////// - // pasteboard.clearContents(); - - let rtfd_data = attr_string.RTFDFromRange_documentAttributes_( - NSRange::new(0, msg_send![attr_string, length]), - nil, - ); - assert_ne!(rtfd_data, nil); - // if rtfd_data != nil { - // pasteboard.setData_forType(rtfd_data, NSPasteboardTypeRTFD); - // } - - // let rtf_data = attributed_string.RTFFromRange_documentAttributes_( - // NSRange::new(0, attributed_string.length()), - // nil, - // ); - // if rtf_data != nil { - // pasteboard.setData_forType(rtf_data, NSPasteboardTypeRTF); - // } - - // let plain_text = attributed_string.string(); - // pasteboard.setString_forType(plain_text, NSPasteboardTypeString); - } - } -} diff --git a/crates/gpui/src/platform/mac/pasteboard.rs b/crates/gpui/src/platform/mac/pasteboard.rs new file mode 100644 index 0000000000..38710951f1 --- /dev/null +++ b/crates/gpui/src/platform/mac/pasteboard.rs @@ -0,0 +1,344 @@ +use core::slice; +use std::ffi::c_void; + +use cocoa::{ + appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF}, + base::{id, nil}, + foundation::NSData, +}; +use objc::{msg_send, runtime::Object, sel, sel_impl}; +use strum::IntoEnumIterator as _; + +use crate::{ + ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, asset_cache::hash, + platform::mac::ns_string, +}; + +pub struct Pasteboard { + inner: id, + text_hash_type: id, + metadata_type: id, +} + +impl Pasteboard { + pub fn general() -> Self { + unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) } + } + + pub fn find() -> Self { + unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) } + } + + #[cfg(test)] + pub fn unique() -> Self { + unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) } + } + + unsafe fn new(inner: id) -> Self { + Self { + inner, + text_hash_type: unsafe { ns_string("zed-text-hash") }, + metadata_type: unsafe { ns_string("zed-metadata") }, + } + } + + pub fn read(&self) -> Option { + // First, see if it's a string. + unsafe { + let pasteboard_types: id = self.inner.types(); + let string_type: id = ns_string("public.utf8-plain-text"); + + if msg_send![pasteboard_types, containsObject: string_type] { + let data = self.inner.dataForType(string_type); + if data == nil { + return None; + } else if data.bytes().is_null() { + // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc + // "If the length of the NSData object is 0, this property returns nil." + return Some(self.read_string(&[])); + } else { + let bytes = + slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize); + + return Some(self.read_string(bytes)); + } + } + + // If it wasn't a string, try the various supported image types. + for format in ImageFormat::iter() { + if let Some(item) = self.read_image(format) { + return Some(item); + } + } + } + + // If it wasn't a string or a supported image type, give up. + None + } + + fn read_image(&self, format: ImageFormat) -> Option { + let mut ut_type: UTType = format.into(); + + unsafe { + let types: id = self.inner.types(); + if msg_send![types, containsObject: ut_type.inner()] { + self.data_for_type(ut_type.inner_mut()).map(|bytes| { + let bytes = bytes.to_vec(); + let id = hash(&bytes); + + ClipboardItem { + entries: vec![ClipboardEntry::Image(Image { format, bytes, id })], + } + }) + } else { + None + } + } + } + + fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem { + unsafe { + let text = String::from_utf8_lossy(text_bytes).to_string(); + let metadata = self + .data_for_type(self.text_hash_type) + .and_then(|hash_bytes| { + let hash_bytes = hash_bytes.try_into().ok()?; + let hash = u64::from_be_bytes(hash_bytes); + let metadata = self.data_for_type(self.metadata_type)?; + + if hash == ClipboardString::text_hash(&text) { + String::from_utf8(metadata.to_vec()).ok() + } else { + None + } + }); + + ClipboardItem { + entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })], + } + } + } + + unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> { + unsafe { + let data = self.inner.dataForType(kind); + if data == nil { + None + } else { + Some(slice::from_raw_parts( + data.bytes() as *mut u8, + data.length() as usize, + )) + } + } + } + + pub fn write(&self, item: ClipboardItem) { + unsafe { + match item.entries.as_slice() { + [] => { + // Writing an empty list of entries just clears the clipboard. + self.inner.clearContents(); + } + [ClipboardEntry::String(string)] => { + self.write_plaintext(string); + } + [ClipboardEntry::Image(image)] => { + self.write_image(image); + } + [ClipboardEntry::ExternalPaths(_)] => {} + _ => { + // Agus NB: We're currently only writing string entries to the clipboard when we have more than one. + // + // This was the existing behavior before I refactored the outer clipboard code: + // https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110 + // + // Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor. + + let mut combined = ClipboardString { + text: String::new(), + metadata: None, + }; + + for entry in item.entries { + match entry { + ClipboardEntry::String(text) => { + combined.text.push_str(&text.text()); + if combined.metadata.is_none() { + combined.metadata = text.metadata; + } + } + _ => {} + } + } + + self.write_plaintext(&combined); + } + } + } + } + + fn write_plaintext(&self, string: &ClipboardString) { + unsafe { + self.inner.clearContents(); + + let text_bytes = NSData::dataWithBytes_length_( + nil, + string.text.as_ptr() as *const c_void, + string.text.len() as u64, + ); + self.inner + .setData_forType(text_bytes, NSPasteboardTypeString); + + if let Some(metadata) = string.metadata.as_ref() { + let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes(); + let hash_bytes = NSData::dataWithBytes_length_( + nil, + hash_bytes.as_ptr() as *const c_void, + hash_bytes.len() as u64, + ); + self.inner.setData_forType(hash_bytes, self.text_hash_type); + + let metadata_bytes = NSData::dataWithBytes_length_( + nil, + metadata.as_ptr() as *const c_void, + metadata.len() as u64, + ); + self.inner + .setData_forType(metadata_bytes, self.metadata_type); + } + } + } + + unsafe fn write_image(&self, image: &Image) { + unsafe { + self.inner.clearContents(); + + let bytes = NSData::dataWithBytes_length_( + nil, + image.bytes.as_ptr() as *const c_void, + image.bytes.len() as u64, + ); + + self.inner + .setData_forType(bytes, Into::::into(image.format).inner_mut()); + } + } +} + +#[link(name = "AppKit", kind = "framework")] +unsafe extern "C" { + /// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc) + pub static NSPasteboardNameFind: id; +} + +impl From for UTType { + fn from(value: ImageFormat) -> Self { + match value { + ImageFormat::Png => Self::png(), + ImageFormat::Jpeg => Self::jpeg(), + ImageFormat::Tiff => Self::tiff(), + ImageFormat::Webp => Self::webp(), + ImageFormat::Gif => Self::gif(), + ImageFormat::Bmp => Self::bmp(), + ImageFormat::Svg => Self::svg(), + ImageFormat::Ico => Self::ico(), + } + } +} + +// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ +pub struct UTType(id); + +impl UTType { + pub fn png() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png + Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType + } + + pub fn jpeg() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg + Self(unsafe { ns_string("public.jpeg") }) + } + + pub fn gif() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif + Self(unsafe { ns_string("com.compuserve.gif") }) + } + + pub fn webp() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp + Self(unsafe { ns_string("org.webmproject.webp") }) + } + + pub fn bmp() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp + Self(unsafe { ns_string("com.microsoft.bmp") }) + } + + pub fn svg() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg + Self(unsafe { ns_string("public.svg-image") }) + } + + pub fn ico() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico + Self(unsafe { ns_string("com.microsoft.ico") }) + } + + pub fn tiff() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff + Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType + } + + fn inner(&self) -> *const Object { + self.0 + } + + pub fn inner_mut(&self) -> *mut Object { + self.0 as *mut _ + } +} + +#[cfg(test)] +mod tests { + use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData}; + + use crate::{ClipboardEntry, ClipboardItem, ClipboardString}; + + use super::*; + + #[test] + fn test_string() { + let pasteboard = Pasteboard::unique(); + assert_eq!(pasteboard.read(), None); + + let item = ClipboardItem::new_string("1".to_string()); + pasteboard.write(item.clone()); + assert_eq!(pasteboard.read(), Some(item)); + + let item = ClipboardItem { + entries: vec![ClipboardEntry::String( + ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]), + )], + }; + pasteboard.write(item.clone()); + assert_eq!(pasteboard.read(), Some(item)); + + let text_from_other_app = "text from other app"; + unsafe { + let bytes = NSData::dataWithBytes_length_( + nil, + text_from_other_app.as_ptr() as *const c_void, + text_from_other_app.len() as u64, + ); + pasteboard + .inner + .setData_forType(bytes, NSPasteboardTypeString); + } + assert_eq!( + pasteboard.read(), + Some(ClipboardItem::new_string(text_from_other_app.to_string())) + ); + } +} diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index ee67f465e3..9b32c6735b 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1,29 +1,24 @@ use super::{ - BoolExt, MacKeyboardLayout, MacKeyboardMapper, - attributed_string::{NSAttributedString, NSMutableAttributedString}, - events::key_to_native, - ns_string, renderer, + BoolExt, MacKeyboardLayout, MacKeyboardMapper, events::key_to_native, ns_string, renderer, }; use crate::{ - Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, - CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher, - MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, WindowParams, hash, + Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, + KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, + PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, + WindowParams, platform::mac::pasteboard::Pasteboard, }; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; use cocoa::{ appkit::{ NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, - NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard, - NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString, - NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow, + NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel, + NSVisualEffectState, NSVisualEffectView, NSWindow, }, base::{BOOL, NO, YES, id, nil, selector}, foundation::{ - NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSRange, NSString, - NSUInteger, NSURL, + NSArray, NSAutoreleasePool, NSBundle, NSInteger, NSProcessInfo, NSString, NSUInteger, NSURL, }, }; use core_foundation::{ @@ -49,7 +44,6 @@ use ptr::null_mut; use semver::Version; use std::{ cell::Cell, - convert::TryInto, ffi::{CStr, OsStr, c_void}, os::{raw::c_char, unix::ffi::OsStrExt}, path::{Path, PathBuf}, @@ -58,7 +52,6 @@ use std::{ slice, str, sync::{Arc, OnceLock}, }; -use strum::IntoEnumIterator; use util::{ ResultExt, command::{new_smol_command, new_std_command}, @@ -164,9 +157,8 @@ pub(crate) struct MacPlatformState { text_system: Arc, renderer_context: renderer::Context, headless: bool, - pasteboard: id, - text_hash_pasteboard_type: id, - metadata_pasteboard_type: id, + general_pasteboard: Pasteboard, + find_pasteboard: Pasteboard, reopen: Option>, on_keyboard_layout_change: Option>, quit: Option>, @@ -206,9 +198,8 @@ impl MacPlatform { background_executor: BackgroundExecutor::new(dispatcher.clone()), foreground_executor: ForegroundExecutor::new(dispatcher), renderer_context: renderer::Context::default(), - pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) }, - text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") }, - metadata_pasteboard_type: unsafe { ns_string("zed-metadata") }, + general_pasteboard: Pasteboard::general(), + find_pasteboard: Pasteboard::find(), reopen: None, quit: None, menu_command: None, @@ -224,20 +215,6 @@ impl MacPlatform { })) } - unsafe fn read_from_pasteboard(&self, pasteboard: *mut Object, kind: id) -> Option<&[u8]> { - unsafe { - let data = pasteboard.dataForType(kind); - if data == nil { - None - } else { - Some(slice::from_raw_parts( - data.bytes() as *mut u8, - data.length() as usize, - )) - } - } - } - unsafe fn create_menu_bar( &self, menus: &Vec, @@ -1034,119 +1011,24 @@ impl Platform for MacPlatform { } } - fn write_to_clipboard(&self, item: ClipboardItem) { - use crate::ClipboardEntry; - - unsafe { - // We only want to use NSAttributedString if there are multiple entries to write. - if item.entries.len() <= 1 { - match item.entries.first() { - Some(entry) => match entry { - ClipboardEntry::String(string) => { - self.write_plaintext_to_clipboard(string); - } - ClipboardEntry::Image(image) => { - self.write_image_to_clipboard(image); - } - ClipboardEntry::ExternalPaths(_) => {} - }, - None => { - // Writing an empty list of entries just clears the clipboard. - let state = self.0.lock(); - state.pasteboard.clearContents(); - } - } - } else { - let mut any_images = false; - let attributed_string = { - let mut buf = NSMutableAttributedString::alloc(nil) - // TODO can we skip this? Or at least part of it? - .init_attributed_string(ns_string("")) - .autorelease(); - - for entry in item.entries { - if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry - { - let to_append = NSAttributedString::alloc(nil) - .init_attributed_string(ns_string(&text)) - .autorelease(); - - buf.appendAttributedString_(to_append); - } - } - - buf - }; - - let state = self.0.lock(); - state.pasteboard.clearContents(); - - // Only set rich text clipboard types if we actually have 1+ images to include. - if any_images { - let rtfd_data = attributed_string.RTFDFromRange_documentAttributes_( - NSRange::new(0, msg_send![attributed_string, length]), - nil, - ); - if rtfd_data != nil { - state - .pasteboard - .setData_forType(rtfd_data, NSPasteboardTypeRTFD); - } - - let rtf_data = attributed_string.RTFFromRange_documentAttributes_( - NSRange::new(0, attributed_string.length()), - nil, - ); - if rtf_data != nil { - state - .pasteboard - .setData_forType(rtf_data, NSPasteboardTypeRTF); - } - } - - let plain_text = attributed_string.string(); - state - .pasteboard - .setString_forType(plain_text, NSPasteboardTypeString); - } - } - } - fn read_from_clipboard(&self) -> Option { let state = self.0.lock(); - let pasteboard = state.pasteboard; + state.general_pasteboard.read() + } - // First, see if it's a string. - unsafe { - let types: id = pasteboard.types(); - let string_type: id = ns_string("public.utf8-plain-text"); + fn write_to_clipboard(&self, item: ClipboardItem) { + let state = self.0.lock(); + state.general_pasteboard.write(item); + } - if msg_send![types, containsObject: string_type] { - let data = pasteboard.dataForType(string_type); - if data == nil { - return None; - } else if data.bytes().is_null() { - // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc - // "If the length of the NSData object is 0, this property returns nil." - return Some(self.read_string_from_clipboard(&state, &[])); - } else { - let bytes = - slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize); + fn read_from_find_pasteboard(&self) -> Option { + let state = self.0.lock(); + state.find_pasteboard.read() + } - return Some(self.read_string_from_clipboard(&state, bytes)); - } - } - - // If it wasn't a string, try the various supported image types. - for format in ImageFormat::iter() { - if let Some(item) = try_clipboard_image(pasteboard, format) { - return Some(item); - } - } - } - - // If it wasn't a string or a supported image type, give up. - None + fn write_to_find_pasteboard(&self, item: ClipboardItem) { + let state = self.0.lock(); + state.find_pasteboard.write(item); } fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { @@ -1255,116 +1137,6 @@ impl Platform for MacPlatform { } } -impl MacPlatform { - unsafe fn read_string_from_clipboard( - &self, - state: &MacPlatformState, - text_bytes: &[u8], - ) -> ClipboardItem { - unsafe { - let text = String::from_utf8_lossy(text_bytes).to_string(); - let metadata = self - .read_from_pasteboard(state.pasteboard, state.text_hash_pasteboard_type) - .and_then(|hash_bytes| { - let hash_bytes = hash_bytes.try_into().ok()?; - let hash = u64::from_be_bytes(hash_bytes); - let metadata = self - .read_from_pasteboard(state.pasteboard, state.metadata_pasteboard_type)?; - - if hash == ClipboardString::text_hash(&text) { - String::from_utf8(metadata.to_vec()).ok() - } else { - None - } - }); - - ClipboardItem { - entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })], - } - } - } - - unsafe fn write_plaintext_to_clipboard(&self, string: &ClipboardString) { - unsafe { - let state = self.0.lock(); - state.pasteboard.clearContents(); - - let text_bytes = NSData::dataWithBytes_length_( - nil, - string.text.as_ptr() as *const c_void, - string.text.len() as u64, - ); - state - .pasteboard - .setData_forType(text_bytes, NSPasteboardTypeString); - - if let Some(metadata) = string.metadata.as_ref() { - let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes(); - let hash_bytes = NSData::dataWithBytes_length_( - nil, - hash_bytes.as_ptr() as *const c_void, - hash_bytes.len() as u64, - ); - state - .pasteboard - .setData_forType(hash_bytes, state.text_hash_pasteboard_type); - - let metadata_bytes = NSData::dataWithBytes_length_( - nil, - metadata.as_ptr() as *const c_void, - metadata.len() as u64, - ); - state - .pasteboard - .setData_forType(metadata_bytes, state.metadata_pasteboard_type); - } - } - } - - unsafe fn write_image_to_clipboard(&self, image: &Image) { - unsafe { - let state = self.0.lock(); - state.pasteboard.clearContents(); - - let bytes = NSData::dataWithBytes_length_( - nil, - image.bytes.as_ptr() as *const c_void, - image.bytes.len() as u64, - ); - - state - .pasteboard - .setData_forType(bytes, Into::::into(image.format).inner_mut()); - } - } -} - -fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option { - let mut ut_type: UTType = format.into(); - - unsafe { - let types: id = pasteboard.types(); - if msg_send![types, containsObject: ut_type.inner()] { - let data = pasteboard.dataForType(ut_type.inner_mut()); - if data == nil { - None - } else { - let bytes = Vec::from(slice::from_raw_parts( - data.bytes() as *mut u8, - data.length() as usize, - )); - let id = hash(&bytes); - - Some(ClipboardItem { - entries: vec![ClipboardEntry::Image(Image { format, bytes, id })], - }) - } - } else { - None - } - } -} - unsafe fn path_from_objc(path: id) -> PathBuf { let len = msg_send![path, lengthOfBytesUsingEncoding: NSUTF8StringEncoding]; let bytes = unsafe { path.UTF8String() as *const u8 }; @@ -1605,120 +1377,3 @@ mod security { pub const errSecUserCanceled: OSStatus = -128; pub const errSecItemNotFound: OSStatus = -25300; } - -impl From for UTType { - fn from(value: ImageFormat) -> Self { - match value { - ImageFormat::Png => Self::png(), - ImageFormat::Jpeg => Self::jpeg(), - ImageFormat::Tiff => Self::tiff(), - ImageFormat::Webp => Self::webp(), - ImageFormat::Gif => Self::gif(), - ImageFormat::Bmp => Self::bmp(), - ImageFormat::Svg => Self::svg(), - ImageFormat::Ico => Self::ico(), - } - } -} - -// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ -struct UTType(id); - -impl UTType { - pub fn png() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png - Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType - } - - pub fn jpeg() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg - Self(unsafe { ns_string("public.jpeg") }) - } - - pub fn gif() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif - Self(unsafe { ns_string("com.compuserve.gif") }) - } - - pub fn webp() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp - Self(unsafe { ns_string("org.webmproject.webp") }) - } - - pub fn bmp() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp - Self(unsafe { ns_string("com.microsoft.bmp") }) - } - - pub fn svg() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg - Self(unsafe { ns_string("public.svg-image") }) - } - - pub fn ico() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico - Self(unsafe { ns_string("com.microsoft.ico") }) - } - - pub fn tiff() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff - Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType - } - - fn inner(&self) -> *const Object { - self.0 - } - - fn inner_mut(&self) -> *mut Object { - self.0 as *mut _ - } -} - -#[cfg(test)] -mod tests { - use crate::ClipboardItem; - - use super::*; - - #[test] - fn test_clipboard() { - let platform = build_platform(); - assert_eq!(platform.read_from_clipboard(), None); - - let item = ClipboardItem::new_string("1".to_string()); - platform.write_to_clipboard(item.clone()); - assert_eq!(platform.read_from_clipboard(), Some(item)); - - let item = ClipboardItem { - entries: vec![ClipboardEntry::String( - ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]), - )], - }; - platform.write_to_clipboard(item.clone()); - assert_eq!(platform.read_from_clipboard(), Some(item)); - - let text_from_other_app = "text from other app"; - unsafe { - let bytes = NSData::dataWithBytes_length_( - nil, - text_from_other_app.as_ptr() as *const c_void, - text_from_other_app.len() as u64, - ); - platform - .0 - .lock() - .pasteboard - .setData_forType(bytes, NSPasteboardTypeString); - } - assert_eq!( - platform.read_from_clipboard(), - Some(ClipboardItem::new_string(text_from_other_app.to_string())) - ); - } - - fn build_platform() -> MacPlatform { - let platform = MacPlatform::new(false); - platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) }; - platform - } -} diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index dfada36466..ca9d5e2c3b 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -32,6 +32,8 @@ pub(crate) struct TestPlatform { current_clipboard_item: Mutex>, #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex>, + #[cfg(target_os = "macos")] + current_find_pasteboard_item: Mutex>, pub(crate) prompts: RefCell, screen_capture_sources: RefCell>, pub opened_url: RefCell>, @@ -117,6 +119,8 @@ impl TestPlatform { current_clipboard_item: Mutex::new(None), #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex::new(None), + #[cfg(target_os = "macos")] + current_find_pasteboard_item: Mutex::new(None), weak: weak.clone(), opened_url: Default::default(), #[cfg(target_os = "windows")] @@ -398,9 +402,8 @@ impl Platform for TestPlatform { false } - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - fn write_to_primary(&self, item: ClipboardItem) { - *self.current_primary_item.lock() = Some(item); + fn read_from_clipboard(&self) -> Option { + self.current_clipboard_item.lock().clone() } fn write_to_clipboard(&self, item: ClipboardItem) { @@ -412,8 +415,19 @@ impl Platform for TestPlatform { self.current_primary_item.lock().clone() } - fn read_from_clipboard(&self) -> Option { - self.current_clipboard_item.lock().clone() + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn write_to_primary(&self, item: ClipboardItem) { + *self.current_primary_item.lock() = Some(item); + } + + #[cfg(target_os = "macos")] + fn read_from_find_pasteboard(&self) -> Option { + self.current_find_pasteboard_item.lock().clone() + } + + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, item: ClipboardItem) { + *self.current_find_pasteboard_item.lock() = Some(item); } fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 12b283ab22..be3331048b 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -106,7 +106,10 @@ pub struct BufferSearchBar { replacement_editor_focused: bool, active_searchable_item: Option>, active_match_index: Option, - active_searchable_item_subscription: Option, + #[cfg(target_os = "macos")] + active_searchable_item_subscriptions: Option<[Subscription; 2]>, + #[cfg(not(target_os = "macos"))] + active_searchable_item_subscriptions: Option, active_search: Option>, searchable_items_with_matches: HashMap, AnyVec>, pending_search: Option>, @@ -472,7 +475,7 @@ impl ToolbarItemView for BufferSearchBar { cx: &mut Context, ) -> ToolbarItemLocation { cx.notify(); - self.active_searchable_item_subscription.take(); + self.active_searchable_item_subscriptions.take(); self.active_searchable_item.take(); self.pending_search.take(); @@ -482,18 +485,58 @@ impl ToolbarItemView for BufferSearchBar { { let this = cx.entity().downgrade(); - self.active_searchable_item_subscription = - Some(searchable_item_handle.subscribe_to_search_events( - window, - cx, - Box::new(move |search_event, window, cx| { - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| { - this.on_active_searchable_item_event(search_event, window, cx) - }); + let search_event_subscription = searchable_item_handle.subscribe_to_search_events( + window, + cx, + Box::new(move |search_event, window, cx| { + if let Some(this) = this.upgrade() { + this.update(cx, |this, cx| { + this.on_active_searchable_item_event(search_event, window, cx) + }); + } + }), + ); + + #[cfg(target_os = "macos")] + { + let item_focus_handle = searchable_item_handle.item_focus_handle(cx); + + self.active_searchable_item_subscriptions = Some([ + search_event_subscription, + cx.on_focus(&item_focus_handle, window, |this, window, cx| { + if this.query_editor_focused || this.replacement_editor_focused { + // no need to read pasteboard since focus came from toolbar + return; } + + cx.defer_in(window, |this, window, cx| { + if let Some(item) = cx.read_from_find_pasteboard() + && let Some(text) = item.text() + { + if this.query(cx) != text { + let search_options = item + .metadata() + .and_then(|m| m.parse().ok()) + .and_then(SearchOptions::from_bits) + .unwrap_or(this.search_options); + + drop(this.search( + &text, + Some(search_options), + true, + window, + cx, + )); + } + } + }); }), - )); + ]); + } + #[cfg(not(target_os = "macos"))] + { + self.active_searchable_item_subscriptions = Some(search_event_subscription); + } let is_project_search = searchable_item_handle.supported_options(cx).find_in_results; self.active_searchable_item = Some(searchable_item_handle); @@ -663,7 +706,7 @@ impl BufferSearchBar { replacement_editor, replacement_editor_focused: false, active_searchable_item: None, - active_searchable_item_subscription: None, + active_searchable_item_subscriptions: None, active_match_index: None, searchable_items_with_matches: Default::default(), default_options: search_options, @@ -904,11 +947,21 @@ impl BufferSearchBar { }); self.set_search_options(options, cx); self.clear_matches(window, cx); + #[cfg(target_os = "macos")] + self.update_find_pasteboard(cx); cx.notify(); } self.update_matches(!updated, add_to_history, window, cx) } + #[cfg(target_os = "macos")] + pub fn update_find_pasteboard(&mut self, cx: &mut App) { + cx.write_to_find_pasteboard(gpui::ClipboardItem::new_string_with_metadata( + self.query(cx), + self.search_options.bits().to_string(), + )); + } + pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context) { if let Some(active_editor) = self.active_searchable_item.as_ref() { let handle = active_editor.item_focus_handle(cx); @@ -1098,11 +1151,12 @@ impl BufferSearchBar { cx.spawn_in(window, async move |this, cx| { if search.await.is_ok() { this.update_in(cx, |this, window, cx| { - this.activate_current_match(window, cx) - }) - } else { - Ok(()) + this.activate_current_match(window, cx); + #[cfg(target_os = "macos")] + this.update_find_pasteboard(cx); + })?; } + anyhow::Ok(()) }) .detach_and_log_err(cx); } @@ -1293,6 +1347,7 @@ impl BufferSearchBar { .insert(active_searchable_item.downgrade(), matches); this.update_match_index(window, cx); + if add_to_history { this.search_history .add(&mut this.search_history_cursor, query_text);