From 2aa366606328dd9ac52f0eea34b4c0bab9000101 Mon Sep 17 00:00:00 2001 From: John Tur Date: Wed, 18 Mar 2026 22:09:26 -0400 Subject: [PATCH] Improve clipboard support on Windows (#51807) - Simplify and improve Windows clipboard handler. - Fixed components that weren't handling multiple formats on the clipboard properly. Closes https://github.com/zed-industries/zed/issues/51278 Release Notes: - windows: Fixed an issue where text copied from Office applications couldn't be pasted into Zed. --- crates/agent_ui/src/mention_set.rs | 20 +- crates/agent_ui/src/message_editor.rs | 17 +- crates/agent_ui/src/text_thread_editor.rs | 121 ++--- crates/editor/src/editor.rs | 36 +- crates/gpui_windows/src/clipboard.rs | 589 ++++++++++------------ crates/terminal_view/src/terminal_view.rs | 21 +- crates/vim/src/replace.rs | 12 +- crates/vim/src/state.rs | 11 +- 8 files changed, 382 insertions(+), 445 deletions(-) diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 6c70510500fa84c1ec1af351d14bd6dd1d575df1..d1af49320695424e53cce223e63a57cf8bdbeec5 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -843,6 +843,17 @@ pub(crate) fn paste_images_as_context( cx: &mut App, ) -> Option> { let clipboard = cx.read_from_clipboard()?; + + // Only handle paste if the first clipboard entry is an image or file path. + // If text comes first, return None so the caller falls through to text paste. + // This respects the priority order set by the source application. + if matches!( + clipboard.entries().first(), + Some(ClipboardEntry::String(_)) | None + ) { + return None; + } + Some(window.spawn(cx, async move |mut cx| { use itertools::Itertools; let (mut images, paths) = clipboard @@ -885,12 +896,9 @@ pub(crate) fn paste_images_as_context( ); } - cx.update(|_window, cx| { - cx.stop_propagation(); - }) - .ok(); - - insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await; + if !images.is_empty() { + insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await; + } })) } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index b073fda1662c12b428222abde34090e008b9ac95..105de5f665cf3e7ffcd37fb58960e922b38b3593 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -640,15 +640,14 @@ impl MessageEditor { let Some(workspace) = self.workspace.upgrade() else { return; }; - let editor_clipboard_selections = cx - .read_from_clipboard() - .and_then(|item| item.entries().first().cloned()) - .and_then(|entry| match entry { + let editor_clipboard_selections = cx.read_from_clipboard().and_then(|item| { + item.entries().iter().find_map(|entry| match entry { ClipboardEntry::String(text) => { text.metadata_json::>() } _ => None, - }); + }) + }); // Insert creases for pasted clipboard selections that: // 1. Contain exactly one selection @@ -774,14 +773,12 @@ impl MessageEditor { // Handle text paste with potential markdown mention links. // This must be checked BEFORE paste_images_as_context because that function // returns a task even when there are no images in the clipboard. - if let Some(clipboard_text) = cx - .read_from_clipboard() - .and_then(|item| item.entries().first().cloned()) - .and_then(|entry| match entry { + if let Some(clipboard_text) = cx.read_from_clipboard().and_then(|item| { + item.entries().iter().find_map(|entry| match entry { ClipboardEntry::String(text) => Some(text.text().to_string()), _ => None, }) - { + }) { if clipboard_text.contains("[@") { cx.stop_propagation(); let selections_before = self.editor.update(cx, |editor, cx| { diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 118de80af215d5ede10b125af1fe154461c3f80d..5cac22e0a069f94ed9d1138cc16cf14b3f10ffff 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1761,15 +1761,14 @@ impl TextThreadEditor { let Some(workspace) = self.workspace.upgrade() else { return; }; - let editor_clipboard_selections = cx - .read_from_clipboard() - .and_then(|item| item.entries().first().cloned()) - .and_then(|entry| match entry { + let editor_clipboard_selections = cx.read_from_clipboard().and_then(|item| { + item.entries().iter().find_map(|entry| match entry { ClipboardEntry::String(text) => { text.metadata_json::>() } _ => None, - }); + }) + }); // Insert creases for pasted clipboard selections that: // 1. Contain exactly one selection @@ -1801,7 +1800,14 @@ impl TextThreadEditor { .unwrap_or(false); if should_insert_creases && let Some(clipboard_item) = cx.read_from_clipboard() { - if let Some(ClipboardEntry::String(clipboard_text)) = clipboard_item.entries().first() { + let clipboard_text = clipboard_item + .entries() + .iter() + .find_map(|entry| match entry { + ClipboardEntry::String(s) => Some(s), + _ => None, + }); + if let Some(clipboard_text) = clipboard_text { if let Some(selections) = editor_clipboard_selections { cx.stop_propagation(); @@ -1872,65 +1878,60 @@ impl TextThreadEditor { cx.stop_propagation(); - let mut images = if let Some(item) = cx.read_from_clipboard() { - item.into_entries() - .filter_map(|entry| { - if let ClipboardEntry::Image(image) = entry { - Some(image) - } else { - None - } - }) - .collect() - } else { - Vec::new() - }; + let clipboard_item = cx.read_from_clipboard(); - if let Some(paths) = cx.read_from_clipboard() { - for path in paths - .into_entries() - .filter_map(|entry| { - if let ClipboardEntry::ExternalPaths(paths) = entry { - Some(paths.paths().to_owned()) - } else { - None + let mut images: Vec = Vec::new(); + let mut paths: Vec = Vec::new(); + let mut metadata: Option = None; + + if let Some(item) = &clipboard_item { + for entry in item.entries() { + match entry { + ClipboardEntry::Image(image) => images.push(image.clone()), + ClipboardEntry::ExternalPaths(external) => { + paths.extend(external.paths().iter().cloned()); } - }) - .flatten() - { - let Ok(content) = std::fs::read(path) else { - continue; - }; - let Ok(format) = image::guess_format(&content) else { - continue; - }; - images.push(gpui::Image::from_bytes( - match format { - image::ImageFormat::Png => gpui::ImageFormat::Png, - image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, - image::ImageFormat::WebP => gpui::ImageFormat::Webp, - image::ImageFormat::Gif => gpui::ImageFormat::Gif, - image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, - image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, - image::ImageFormat::Ico => gpui::ImageFormat::Ico, - _ => continue, - }, - content, - )); + ClipboardEntry::String(text) => { + if metadata.is_none() { + metadata = text.metadata_json::(); + } + } + } } } - let metadata = if let Some(item) = cx.read_from_clipboard() { - item.entries().first().and_then(|entry| { - if let ClipboardEntry::String(text) = entry { - text.metadata_json::() - } else { - None - } - }) - } else { - None - }; + for path in paths { + let Ok(content) = std::fs::read(path) else { + continue; + }; + let Ok(format) = image::guess_format(&content) else { + continue; + }; + images.push(gpui::Image::from_bytes( + match format { + image::ImageFormat::Png => gpui::ImageFormat::Png, + image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, + image::ImageFormat::WebP => gpui::ImageFormat::Webp, + image::ImageFormat::Gif => gpui::ImageFormat::Gif, + image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, + image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, + image::ImageFormat::Ico => gpui::ImageFormat::Ico, + _ => continue, + }, + content, + )); + } + + // Respect entry priority order — if the first entry is text, the source + // application considers text the primary content. Discard collected images + // so the text-paste branch runs instead. + if clipboard_item + .as_ref() + .and_then(|item| item.entries().first()) + .is_some_and(|entry| matches!(entry, ClipboardEntry::String(_))) + { + images.clear(); + } if images.is_empty() { self.editor.update(cx, |editor, cx| { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b4f88ea50136cc169761c0899444e7544804bf4d..61051ec4b65bc1cc694232d7a91075d8764bf0b9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -14142,13 +14142,12 @@ impl Editor { return; }; - let clipboard_text = match cx.read_from_clipboard() { - Some(item) => match item.entries().first() { - Some(ClipboardEntry::String(text)) => Some(text.text().to_string()), + let clipboard_text = cx.read_from_clipboard().and_then(|item| { + item.entries().iter().find_map(|entry| match entry { + ClipboardEntry::String(text) => Some(text.text().to_string()), _ => None, - }, - None => None, - }; + }) + }); let Some(clipboard_text) = clipboard_text else { log::warn!("Clipboard doesn't contain text."); @@ -14167,19 +14166,18 @@ impl Editor { pub fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); if let Some(item) = cx.read_from_clipboard() { - let entries = item.entries(); - - match entries.first() { - // For now, we only support applying metadata if there's one string. In the future, we can incorporate all the selections - // of all the pasted entries. - Some(ClipboardEntry::String(clipboard_string)) if entries.len() == 1 => self - .do_paste( - clipboard_string.text(), - clipboard_string.metadata_json::>(), - true, - window, - cx, - ), + let clipboard_string = item.entries().iter().find_map(|entry| match entry { + ClipboardEntry::String(s) => Some(s), + _ => None, + }); + match clipboard_string { + Some(clipboard_string) => self.do_paste( + clipboard_string.text(), + clipboard_string.metadata_json::>(), + true, + window, + cx, + ), _ => self.do_paste(&item.text().unwrap_or_default(), None, true, window, cx), } } diff --git a/crates/gpui_windows/src/clipboard.rs b/crates/gpui_windows/src/clipboard.rs index c2b8c0ff30c4d9e4c99c9d8c69ffcbf3da19211b..cd0694ab3159984ee7e66f0d71f8c3a6c6ea16b6 100644 --- a/crates/gpui_windows/src/clipboard.rs +++ b/crates/gpui_windows/src/clipboard.rs @@ -8,24 +8,22 @@ use windows::Win32::{ System::{ DataExchange::{ CloseClipboard, CountClipboardFormats, EmptyClipboard, EnumClipboardFormats, - GetClipboardData, GetClipboardFormatNameW, IsClipboardFormatAvailable, OpenClipboard, - RegisterClipboardFormatW, SetClipboardData, + GetClipboardData, GetClipboardFormatNameW, OpenClipboard, RegisterClipboardFormatW, + SetClipboardData, }, Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock}, Ole::{CF_DIB, CF_HDROP, CF_UNICODETEXT}, }, UI::Shell::{DragQueryFileW, HDROP}, }; -use windows_core::PCWSTR; +use windows::core::{Owned, PCWSTR}; use gpui::{ ClipboardEntry, ClipboardItem, ClipboardString, ExternalPaths, Image, ImageFormat, hash, }; -// https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-dragqueryfilew const DRAGDROP_GET_FILES_COUNT: u32 = 0xFFFFFFFF; -// Clipboard formats static CLIPBOARD_HASH_FORMAT: LazyLock = LazyLock::new(|| register_clipboard_format(windows::core::w!("GPUI internal text hash"))); static CLIPBOARD_METADATA_FORMAT: LazyLock = @@ -39,47 +37,94 @@ static CLIPBOARD_PNG_FORMAT: LazyLock = static CLIPBOARD_JPG_FORMAT: LazyLock = LazyLock::new(|| register_clipboard_format(windows::core::w!("JFIF"))); -// Helper maps and sets -static FORMATS_MAP: LazyLock> = LazyLock::new(|| { - let mut formats_map = FxHashMap::default(); - formats_map.insert(CF_UNICODETEXT.0 as u32, ClipboardFormatType::Text); - formats_map.insert(*CLIPBOARD_PNG_FORMAT, ClipboardFormatType::Image); - formats_map.insert(*CLIPBOARD_GIF_FORMAT, ClipboardFormatType::Image); - formats_map.insert(*CLIPBOARD_JPG_FORMAT, ClipboardFormatType::Image); - formats_map.insert(*CLIPBOARD_SVG_FORMAT, ClipboardFormatType::Image); - formats_map.insert(CF_DIB.0 as u32, ClipboardFormatType::Image); - formats_map.insert(CF_HDROP.0 as u32, ClipboardFormatType::Files); - formats_map -}); static IMAGE_FORMATS_MAP: LazyLock> = LazyLock::new(|| { - let mut formats_map = FxHashMap::default(); - formats_map.insert(*CLIPBOARD_PNG_FORMAT, ImageFormat::Png); - formats_map.insert(*CLIPBOARD_GIF_FORMAT, ImageFormat::Gif); - formats_map.insert(*CLIPBOARD_JPG_FORMAT, ImageFormat::Jpeg); - formats_map.insert(*CLIPBOARD_SVG_FORMAT, ImageFormat::Svg); - formats_map + let mut map = FxHashMap::default(); + map.insert(*CLIPBOARD_PNG_FORMAT, ImageFormat::Png); + map.insert(*CLIPBOARD_GIF_FORMAT, ImageFormat::Gif); + map.insert(*CLIPBOARD_JPG_FORMAT, ImageFormat::Jpeg); + map.insert(*CLIPBOARD_SVG_FORMAT, ImageFormat::Svg); + map }); -#[derive(Debug, Clone, Copy)] -enum ClipboardFormatType { - Text, - Image, - Files, +fn register_clipboard_format(format: PCWSTR) -> u32 { + let ret = unsafe { RegisterClipboardFormatW(format) }; + if ret == 0 { + panic!( + "Error when registering clipboard format: {}", + std::io::Error::last_os_error() + ); + } + log::debug!( + "Registered clipboard format {} as {}", + unsafe { format.display() }, + ret + ); + ret +} + +fn get_clipboard_data(format: u32) -> Option { + let global = HGLOBAL(unsafe { GetClipboardData(format).ok() }?.0); + LockedGlobal::lock(global) } pub(crate) fn write_to_clipboard(item: ClipboardItem) { - with_clipboard(|| write_to_clipboard_inner(item)); + let Some(_clip) = ClipboardGuard::open() else { + return; + }; + + let result: Result<()> = (|| { + unsafe { EmptyClipboard()? }; + for entry in item.entries() { + match entry { + ClipboardEntry::String(string) => write_string(string)?, + ClipboardEntry::Image(image) => write_image(image)?, + ClipboardEntry::ExternalPaths(_) => {} + } + } + Ok(()) + })(); + + if let Err(e) = result { + log::error!("Failed to write to clipboard: {e}"); + } } pub(crate) fn read_from_clipboard() -> Option { - with_clipboard(|| { - with_best_match_format(|item_format| match format_to_type(item_format) { - ClipboardFormatType::Text => read_string_from_clipboard(), - ClipboardFormatType::Image => read_image_from_clipboard(item_format), - ClipboardFormatType::Files => read_files_from_clipboard(), - }) - }) - .flatten() + let _clip = ClipboardGuard::open()?; + + let mut entries = Vec::new(); + let mut have_text = false; + let mut have_image = false; + let mut have_files = false; + + let count = unsafe { CountClipboardFormats() }; + let mut format = 0; + for _ in 0..count { + format = unsafe { EnumClipboardFormats(format) }; + + if !have_text && format == CF_UNICODETEXT.0 as u32 { + if let Some(entry) = read_string() { + entries.push(entry); + have_text = true; + } + } else if !have_image && is_image_format(format) { + if let Some(entry) = read_image(format) { + entries.push(entry); + have_image = true; + } + } else if !have_files && format == CF_HDROP.0 as u32 { + if let Some(entry) = read_files() { + entries.push(entry); + have_files = true; + } + } + } + + if entries.is_empty() { + log_unsupported_clipboard_formats(); + return None; + } + Some(ClipboardItem { entries }) } pub(crate) fn with_file_names(hdrop: HDROP, mut f: F) @@ -97,359 +142,247 @@ where } match String::from_utf16(&buffer[0..filename_length]) { Ok(file_name) => f(file_name), - Err(e) => { - log::error!("dragged file name is not UTF-16: {}", e) - } + Err(e) => log::error!("dragged file name is not UTF-16: {}", e), } } } -fn with_clipboard(f: F) -> Option -where - F: FnOnce() -> T, -{ - match unsafe { OpenClipboard(None) } { - Ok(()) => { - let result = f(); - if let Err(e) = unsafe { CloseClipboard() } { - log::error!("Failed to close clipboard: {e}",); - } - Some(result) - } - Err(e) => { - log::error!("Failed to open clipboard: {e}",); - None - } +fn set_clipboard_bytes(data: &[T], format: u32) -> Result<()> { + unsafe { + let global = Owned::new(GlobalAlloc(GMEM_MOVEABLE, std::mem::size_of_val(data))?); + let ptr = GlobalLock(*global); + anyhow::ensure!(!ptr.is_null(), "GlobalLock returned null"); + std::ptr::copy_nonoverlapping(data.as_ptr(), ptr as _, data.len()); + GlobalUnlock(*global).ok(); + SetClipboardData(format, Some(HANDLE(global.0)))?; + // SetClipboardData succeeded — the system now owns the memory. + std::mem::forget(global); } + Ok(()) } -fn register_clipboard_format(format: PCWSTR) -> u32 { - let ret = unsafe { RegisterClipboardFormatW(format) }; - if ret == 0 { - panic!( - "Error when registering clipboard format: {}", - std::io::Error::last_os_error() - ); +fn get_clipboard_string(format: u32) -> Option { + let locked = get_clipboard_data(format)?; + let bytes = locked.as_bytes(); + let words_len = bytes.len() / std::mem::size_of::(); + if words_len == 0 { + return Some(String::new()); } - log::debug!( - "Registered clipboard format {} as {}", - unsafe { format.display() }, - ret - ); - ret + let slice = unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const u16, words_len) }; + let actual_len = slice.iter().position(|&c| c == 0).unwrap_or(words_len); + Some(String::from_utf16_lossy(&slice[..actual_len])) } -#[inline] -fn format_to_type(item_format: u32) -> &'static ClipboardFormatType { - FORMATS_MAP.get(&item_format).unwrap() -} - -// Currently, we only write the first item. -fn write_to_clipboard_inner(item: ClipboardItem) -> Result<()> { - unsafe { - EmptyClipboard()?; - } - match item.entries().first() { - Some(entry) => match entry { - ClipboardEntry::String(string) => { - write_string_to_clipboard(string)?; - } - ClipboardEntry::Image(image) => { - write_image_to_clipboard(image)?; - } - ClipboardEntry::ExternalPaths(_) => {} - }, - None => { - // Writing an empty list of entries just clears the clipboard. - } - } - Ok(()) +fn is_image_format(format: u32) -> bool { + IMAGE_FORMATS_MAP.contains_key(&format) || format == CF_DIB.0 as u32 } -fn write_string_to_clipboard(item: &ClipboardString) -> Result<()> { - let encode_wide = item.text.encode_utf16().chain(Some(0)).collect_vec(); - set_data_to_clipboard(&encode_wide, CF_UNICODETEXT.0 as u32)?; +fn write_string(item: &ClipboardString) -> Result<()> { + let wide: Vec = item.text.encode_utf16().chain(Some(0)).collect_vec(); + set_clipboard_bytes(&wide, CF_UNICODETEXT.0 as u32)?; if let Some(metadata) = item.metadata.as_ref() { - let hash_result = { - let hash = ClipboardString::text_hash(&item.text); - hash.to_ne_bytes() - }; - let encode_wide = - unsafe { std::slice::from_raw_parts(hash_result.as_ptr().cast::(), 4) }; - set_data_to_clipboard(encode_wide, *CLIPBOARD_HASH_FORMAT)?; - - let metadata_wide = metadata.encode_utf16().chain(Some(0)).collect_vec(); - set_data_to_clipboard(&metadata_wide, *CLIPBOARD_METADATA_FORMAT)?; + let hash_bytes = ClipboardString::text_hash(&item.text).to_ne_bytes(); + set_clipboard_bytes(&hash_bytes, *CLIPBOARD_HASH_FORMAT)?; + + let wide: Vec = metadata.encode_utf16().chain(Some(0)).collect_vec(); + set_clipboard_bytes(&wide, *CLIPBOARD_METADATA_FORMAT)?; } Ok(()) } -fn set_data_to_clipboard(data: &[T], format: u32) -> Result<()> { - unsafe { - let global = GlobalAlloc(GMEM_MOVEABLE, std::mem::size_of_val(data))?; - let handle = GlobalLock(global); - std::ptr::copy_nonoverlapping(data.as_ptr(), handle as _, data.len()); - let _ = GlobalUnlock(global); - SetClipboardData(format, Some(HANDLE(global.0)))?; +fn write_image(item: &Image) -> Result<()> { + let native_format = match item.format { + ImageFormat::Svg => Some(*CLIPBOARD_SVG_FORMAT), + ImageFormat::Gif => Some(*CLIPBOARD_GIF_FORMAT), + ImageFormat::Png => Some(*CLIPBOARD_PNG_FORMAT), + ImageFormat::Jpeg => Some(*CLIPBOARD_JPG_FORMAT), + _ => None, + }; + if let Some(format) = native_format { + set_clipboard_bytes(item.bytes(), format)?; } - Ok(()) -} -// Here writing PNG to the clipboard to better support other apps. For more info, please ref to -// the PR. -fn write_image_to_clipboard(item: &Image) -> Result<()> { - match item.format { - ImageFormat::Svg => set_data_to_clipboard(item.bytes(), *CLIPBOARD_SVG_FORMAT)?, - ImageFormat::Gif => { - set_data_to_clipboard(item.bytes(), *CLIPBOARD_GIF_FORMAT)?; - let png_bytes = convert_image_to_png_format(item.bytes(), ImageFormat::Gif)?; - set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?; - } - ImageFormat::Png => { - set_data_to_clipboard(item.bytes(), *CLIPBOARD_PNG_FORMAT)?; - let png_bytes = convert_image_to_png_format(item.bytes(), ImageFormat::Png)?; - set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?; - } - ImageFormat::Jpeg => { - set_data_to_clipboard(item.bytes(), *CLIPBOARD_JPG_FORMAT)?; - let png_bytes = convert_image_to_png_format(item.bytes(), ImageFormat::Jpeg)?; - set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?; - } - other => { - log::warn!( - "Clipboard unsupported image format: {:?}, convert to PNG instead.", - item.format - ); - let png_bytes = convert_image_to_png_format(item.bytes(), other)?; - set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?; + // Also provide a PNG copy for broad compatibility. + // SVG can't be rasterized by the image crate, so skip it. + if item.format != ImageFormat::Svg && native_format != Some(*CLIPBOARD_PNG_FORMAT) { + if let Some(png_bytes) = convert_to_png(item.bytes(), item.format) { + set_clipboard_bytes(&png_bytes, *CLIPBOARD_PNG_FORMAT)?; } } Ok(()) } -fn convert_image_to_png_format(bytes: &[u8], image_format: ImageFormat) -> Result> { - let image = - image::load_from_memory_with_format(bytes, gpui_image_format_to_image(image_format))?; - let mut output_buf = Vec::new(); - image.write_to( - &mut std::io::Cursor::new(&mut output_buf), - image::ImageFormat::Png, - )?; - Ok(output_buf) -} - -// Here, we enumerate all formats on the clipboard and find the first one that we can process. -// The reason we don't use `GetPriorityClipboardFormat` is that it sometimes returns the -// wrong format. -// For instance, when copying a JPEG image from Microsoft Word, there may be several formats -// on the clipboard: Jpeg, Png, Svg. -// If we use `GetPriorityClipboardFormat`, it will return Svg, which is not what we want. -fn with_best_match_format(f: F) -> Option -where - F: Fn(u32) -> Option, -{ - let mut text = None; - let mut image = None; - let mut files = None; - let count = unsafe { CountClipboardFormats() }; - let mut clipboard_format = 0; - for _ in 0..count { - clipboard_format = unsafe { EnumClipboardFormats(clipboard_format) }; - let Some(item_format) = FORMATS_MAP.get(&clipboard_format) else { - continue; - }; - let bucket = match item_format { - ClipboardFormatType::Text if text.is_none() => &mut text, - ClipboardFormatType::Image if image.is_none() => &mut image, - ClipboardFormatType::Files if files.is_none() => &mut files, - _ => continue, - }; - if let Some(entry) = f(clipboard_format) { - *bucket = Some(entry); - } - } - - if let Some(entry) = [image, files, text].into_iter().flatten().next() { - return Some(ClipboardItem { - entries: vec![entry], - }); - } - - // log the formats that we don't support yet. - { - clipboard_format = 0; - for _ in 0..count { - clipboard_format = unsafe { EnumClipboardFormats(clipboard_format) }; - let mut buffer = [0u16; 64]; - unsafe { GetClipboardFormatNameW(clipboard_format, &mut buffer) }; - let format_name = String::from_utf16_lossy(&buffer); - log::warn!( - "Try to paste with unsupported clipboard format: {}, {}.", - clipboard_format, - format_name - ); - } - } - None +fn convert_to_png(bytes: &[u8], format: ImageFormat) -> Option> { + let img_format = gpui_to_image_format(format)?; + let image = image::load_from_memory_with_format(bytes, img_format) + .map_err(|e| log::warn!("Failed to decode image for PNG conversion: {e}")) + .ok()?; + let mut buf = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) + .map_err(|e| log::warn!("Failed to encode PNG: {e}")) + .ok()?; + Some(buf) } -fn read_string_from_clipboard() -> Option { - let text = with_clipboard_data(CF_UNICODETEXT.0 as u32, |data_ptr, _| { - let pcwstr = PCWSTR(data_ptr as *const u16); - String::from_utf16_lossy(unsafe { pcwstr.as_wide() }) - })?; - let Some(hash) = read_hash_from_clipboard() else { - return Some(ClipboardEntry::String(ClipboardString::new(text))); - }; - let Some(metadata) = read_metadata_from_clipboard() else { - return Some(ClipboardEntry::String(ClipboardString::new(text))); - }; - if hash == ClipboardString::text_hash(&text) { - Some(ClipboardEntry::String(ClipboardString { - text, - metadata: Some(metadata), - })) - } else { - Some(ClipboardEntry::String(ClipboardString::new(text))) - } +fn read_string() -> Option { + let text = get_clipboard_string(CF_UNICODETEXT.0 as u32)?; + let metadata = read_clipboard_metadata(&text); + Some(ClipboardEntry::String(ClipboardString { text, metadata })) } -fn read_hash_from_clipboard() -> Option { - if unsafe { IsClipboardFormatAvailable(*CLIPBOARD_HASH_FORMAT).is_err() } { +fn read_clipboard_metadata(text: &str) -> Option { + let locked = get_clipboard_data(*CLIPBOARD_HASH_FORMAT)?; + let hash_bytes: [u8; 8] = locked.as_bytes().get(..8)?.try_into().ok()?; + let hash = u64::from_ne_bytes(hash_bytes); + if hash != ClipboardString::text_hash(text) { return None; } - with_clipboard_data(*CLIPBOARD_HASH_FORMAT, |data_ptr, size| { - if size < 8 { - return None; - } - let hash_bytes: [u8; 8] = unsafe { - std::slice::from_raw_parts(data_ptr.cast::(), 8) - .try_into() - .ok() - }?; - Some(u64::from_ne_bytes(hash_bytes)) - })? + get_clipboard_string(*CLIPBOARD_METADATA_FORMAT) } -fn read_metadata_from_clipboard() -> Option { - unsafe { IsClipboardFormatAvailable(*CLIPBOARD_METADATA_FORMAT).ok()? }; - with_clipboard_data(*CLIPBOARD_METADATA_FORMAT, |data_ptr, _size| { - let pcwstr = PCWSTR(data_ptr as *const u16); - String::from_utf16_lossy(unsafe { pcwstr.as_wide() }) - }) +fn read_image(format: u32) -> Option { + let locked = get_clipboard_data(format)?; + let (bytes, image_format) = if format == CF_DIB.0 as u32 { + (convert_dib_to_bmp(locked.as_bytes())?, ImageFormat::Bmp) + } else { + let image_format = *IMAGE_FORMATS_MAP.get(&format)?; + (locked.as_bytes().to_vec(), image_format) + }; + let id = hash(&bytes); + Some(ClipboardEntry::Image(Image { + format: image_format, + bytes, + id, + })) } -fn read_image_from_clipboard(format: u32) -> Option { - // Handle CF_DIB format specially - it's raw bitmap data that needs conversion - if format == CF_DIB.0 as u32 { - return read_image_for_type(format, ImageFormat::Bmp, Some(convert_dib_to_bmp)); - } - let image_format = format_number_to_image_format(format)?; - read_image_for_type:: Option>>(format, *image_format, None) +fn read_files() -> Option { + let locked = get_clipboard_data(CF_HDROP.0 as u32)?; + let hdrop = HDROP(locked.ptr as *mut _); + let mut filenames = Vec::new(); + with_file_names(hdrop, |name| filenames.push(std::path::PathBuf::from(name))); + Some(ClipboardEntry::ExternalPaths(ExternalPaths( + filenames.into(), + ))) } -/// Convert DIB data to BMP file format. -/// DIB is essentially BMP without a file header, so we just need to add the 14-byte BITMAPFILEHEADER. -fn convert_dib_to_bmp(dib_data: &[u8]) -> Option> { - if dib_data.len() < 40 { +/// DIB is BMP without the 14-byte BITMAPFILEHEADER. Prepend one. +fn convert_dib_to_bmp(dib: &[u8]) -> Option> { + if dib.len() < 40 { return None; } - let file_size = 14 + dib_data.len() as u32; - // Calculate pixel data offset - let header_size = u32::from_le_bytes(dib_data[0..4].try_into().ok()?); - let bit_count = u16::from_le_bytes(dib_data[14..16].try_into().ok()?); - let compression = u32::from_le_bytes(dib_data[16..20].try_into().ok()?); + let header_size = u32::from_le_bytes(dib[0..4].try_into().ok()?); + let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?); + let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?); - // Calculate color table size let color_table_size = if bit_count <= 8 { - let colors_used = u32::from_le_bytes(dib_data[32..36].try_into().ok()?); - let num_colors = if colors_used == 0 { + let colors_used = u32::from_le_bytes(dib[32..36].try_into().ok()?); + (if colors_used == 0 { 1u32 << bit_count } else { colors_used - }; - num_colors * 4 + }) * 4 } else if compression == 3 { 12 // BI_BITFIELDS } else { 0 }; - let pixel_data_offset = 14 + header_size + color_table_size; + let pixel_offset = 14 + header_size + color_table_size; + let file_size = 14 + dib.len() as u32; - // Build BITMAPFILEHEADER (14 bytes) - let mut bmp_data = Vec::with_capacity(file_size as usize); - bmp_data.extend_from_slice(b"BM"); // Signature - bmp_data.extend_from_slice(&file_size.to_le_bytes()); // File size - bmp_data.extend_from_slice(&[0u8; 4]); // Reserved - bmp_data.extend_from_slice(&pixel_data_offset.to_le_bytes()); // Pixel data offset - bmp_data.extend_from_slice(dib_data); // DIB data + let mut bmp = Vec::with_capacity(file_size as usize); + bmp.extend_from_slice(b"BM"); + bmp.extend_from_slice(&file_size.to_le_bytes()); + bmp.extend_from_slice(&[0u8; 4]); // reserved + bmp.extend_from_slice(&pixel_offset.to_le_bytes()); + bmp.extend_from_slice(dib); + Some(bmp) +} - Some(bmp_data) +fn log_unsupported_clipboard_formats() { + let count = unsafe { CountClipboardFormats() }; + let mut format = 0; + for _ in 0..count { + format = unsafe { EnumClipboardFormats(format) }; + let mut buffer = [0u16; 64]; + unsafe { GetClipboardFormatNameW(format, &mut buffer) }; + let format_name = String::from_utf16_lossy(&buffer); + log::warn!( + "Try to paste with unsupported clipboard format: {}, {}.", + format, + format_name + ); + } } -#[inline] -fn format_number_to_image_format(format_number: u32) -> Option<&'static ImageFormat> { - IMAGE_FORMATS_MAP.get(&format_number) +fn gpui_to_image_format(value: ImageFormat) -> Option { + match value { + ImageFormat::Png => Some(image::ImageFormat::Png), + ImageFormat::Jpeg => Some(image::ImageFormat::Jpeg), + ImageFormat::Webp => Some(image::ImageFormat::WebP), + ImageFormat::Gif => Some(image::ImageFormat::Gif), + ImageFormat::Bmp => Some(image::ImageFormat::Bmp), + ImageFormat::Tiff => Some(image::ImageFormat::Tiff), + other => { + log::warn!("No image crate equivalent for format: {other:?}"); + None + } + } } -fn read_image_for_type( - format_number: u32, - format: ImageFormat, - convert: Option, -) -> Option -where - F: FnOnce(&[u8]) -> Option>, -{ - let (bytes, id) = with_clipboard_data(format_number, |data_ptr, size| { - let raw_bytes = unsafe { std::slice::from_raw_parts(data_ptr as *const u8, size) }; - let bytes = match convert { - Some(converter) => converter(raw_bytes)?, - None => raw_bytes.to_vec(), - }; - let id = hash(&bytes); - Some((bytes, id)) - })??; - Some(ClipboardEntry::Image(Image { format, bytes, id })) +struct ClipboardGuard; + +impl ClipboardGuard { + fn open() -> Option { + match unsafe { OpenClipboard(None) } { + Ok(()) => Some(Self), + Err(e) => { + log::error!("Failed to open clipboard: {e}"); + None + } + } + } } -fn read_files_from_clipboard() -> Option { - let filenames = with_clipboard_data(CF_HDROP.0 as u32, |data_ptr, _size| { - let hdrop = HDROP(data_ptr); - let mut filenames = Vec::new(); - with_file_names(hdrop, |file_name| { - filenames.push(std::path::PathBuf::from(file_name)); - }); - filenames - })?; - Some(ClipboardEntry::ExternalPaths(ExternalPaths( - filenames.into(), - ))) +impl Drop for ClipboardGuard { + fn drop(&mut self) { + if let Err(e) = unsafe { CloseClipboard() } { + log::error!("Failed to close clipboard: {e}"); + } + } } -fn with_clipboard_data(format: u32, f: F) -> Option -where - F: FnOnce(*mut std::ffi::c_void, usize) -> R, -{ - let global = HGLOBAL(unsafe { GetClipboardData(format).ok() }?.0); - let size = unsafe { GlobalSize(global) }; - let data_ptr = unsafe { GlobalLock(global) }; - let result = f(data_ptr, size); - unsafe { GlobalUnlock(global).ok() }; - Some(result) +struct LockedGlobal { + global: HGLOBAL, + ptr: *const u8, + size: usize, } -fn gpui_image_format_to_image(value: ImageFormat) -> image::ImageFormat { - match value { - ImageFormat::Png => image::ImageFormat::Png, - ImageFormat::Jpeg => image::ImageFormat::Jpeg, - ImageFormat::Webp => image::ImageFormat::WebP, - ImageFormat::Gif => image::ImageFormat::Gif, - // TODO: ImageFormat::Svg - ImageFormat::Bmp => image::ImageFormat::Bmp, - ImageFormat::Tiff => image::ImageFormat::Tiff, - _ => unreachable!(), +impl LockedGlobal { + fn lock(global: HGLOBAL) -> Option { + let size = unsafe { GlobalSize(global) }; + let ptr = unsafe { GlobalLock(global) }; + if ptr.is_null() { + return None; + } + Some(Self { + global, + ptr: ptr as *const u8, + size, + }) + } + + fn as_bytes(&self) -> &[u8] { + unsafe { std::slice::from_raw_parts(self.ptr, self.size) } + } +} + +impl Drop for LockedGlobal { + fn drop(&mut self) { + unsafe { GlobalUnlock(self.global).ok() }; } } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index fc1b0b12f9238e776164b23776eb5cea0208270f..0b2bfa44870282de79d63a74e507115fb198ed66 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -813,17 +813,16 @@ impl TerminalView { return; }; - if clipboard.entries().iter().any(|entry| match entry { - ClipboardEntry::Image(image) => !image.bytes.is_empty(), - _ => false, - }) { - self.forward_ctrl_v(cx); - return; - } - - if let Some(text) = clipboard.text() { - self.terminal - .update(cx, |terminal, _cx| terminal.paste(&text)); + match clipboard.entries().first() { + Some(ClipboardEntry::Image(image)) if !image.bytes.is_empty() => { + self.forward_ctrl_v(cx); + } + _ => { + if let Some(text) = clipboard.text() { + self.terminal + .update(cx, |terminal, _cx| terminal.paste(&text)); + } + } } } diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs index 49cc4a27ff59eebde2f9ce50e99e08a27e830853..338cf9bfe985654172d690518240879c84e9e2ae 100644 --- a/crates/vim/src/replace.rs +++ b/crates/vim/src/replace.rs @@ -282,12 +282,12 @@ impl Vim { /// Pastes the clipboard contents, replacing the same number of characters /// as the clipboard's contents. pub fn paste_replace(&mut self, window: &mut Window, cx: &mut Context) { - let clipboard_text = - cx.read_from_clipboard() - .and_then(|item| match item.entries().first() { - Some(ClipboardEntry::String(text)) => Some(text.text().to_string()), - _ => None, - }); + let clipboard_text = cx.read_from_clipboard().and_then(|item| { + item.entries().iter().find_map(|entry| match entry { + ClipboardEntry::String(text) => Some(text.text().to_string()), + _ => None, + }) + }); if let Some(text) = clipboard_text { self.push_operator(Operator::Replace, window, cx); diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index b0d21badc730dae973ab43786d46c7682ecc5263..85bc6991d3ece282b6dd549925c13c94b7919eef 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -191,14 +191,15 @@ impl From for ClipboardItem { impl From for Register { fn from(item: ClipboardItem) -> Self { - // For now, we don't store metadata for multiple entries. - match item.entries().first() { - Some(ClipboardEntry::String(value)) if item.entries().len() == 1 => Register { + match item.entries().iter().find_map(|entry| match entry { + ClipboardEntry::String(value) => Some(value), + _ => None, + }) { + Some(value) => Register { text: value.text().to_owned().into(), clipboard_selections: value.metadata_json::>(), }, - // For now, registers can't store images. This could change in the future. - _ => Register::default(), + None => Register::default(), } } }