linux/wayland: Add support for pasting images (#17671)

maan2003 created

Release Notes:

- You can now paste images into the Assistant Panel to include them as
context on Linux wayland

Change summary

crates/gpui/src/platform/linux/platform.rs          |  14 -
crates/gpui/src/platform/linux/wayland/client.rs    |   9 
crates/gpui/src/platform/linux/wayland/clipboard.rs | 148 +++++++++-----
3 files changed, 106 insertions(+), 65 deletions(-)

Detailed changes

crates/gpui/src/platform/linux/platform.rs 🔗

@@ -603,17 +603,11 @@ pub(super) fn get_xkb_compose_state(cx: &xkb::Context) -> Option<xkb::compose::S
     state
 }
 
-pub(super) unsafe fn read_fd(mut fd: FileDescriptor) -> Result<String> {
+pub(super) unsafe fn read_fd(mut fd: FileDescriptor) -> Result<Vec<u8>> {
     let mut file = File::from_raw_fd(fd.as_raw_fd());
-
-    let mut buffer = String::new();
-    file.read_to_string(&mut buffer)?;
-
-    // Normalize the text to unix line endings, otherwise
-    // copying from eg: firefox inserts a lot of blank
-    // lines, and that is super annoying.
-    let result = buffer.replace("\r\n", "\n");
-    Ok(result)
+    let mut buffer = Vec::new();
+    file.read_to_end(&mut buffer)?;
+    Ok(buffer)
 }
 
 impl CursorStyle {

crates/gpui/src/platform/linux/wayland/client.rs 🔗

@@ -1799,10 +1799,11 @@ impl Dispatch<wl_data_device::WlDataDevice, ()> for WaylandClientStatePtr {
                     let fd = pipe.read;
                     drop(pipe.write);
 
-                    let read_task = state
-                        .common
-                        .background_executor
-                        .spawn(async { unsafe { read_fd(fd) } });
+                    let read_task = state.common.background_executor.spawn(async {
+                        let buffer = unsafe { read_fd(fd)? };
+                        let text = String::from_utf8(buffer)?;
+                        anyhow::Ok(text)
+                    });
 
                     let this = this.clone();
                     state

crates/gpui/src/platform/linux/wayland/clipboard.rs 🔗

@@ -6,10 +6,14 @@ use std::{
 
 use calloop::{LoopHandle, PostAction};
 use filedescriptor::Pipe;
+use strum::IntoEnumIterator;
 use wayland_client::{protocol::wl_data_offer::WlDataOffer, Connection};
 use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1;
 
-use crate::{platform::linux::platform::read_fd, ClipboardItem, WaylandClientStatePtr};
+use crate::{
+    hash, platform::linux::platform::read_fd, ClipboardEntry, ClipboardItem, Image, ImageFormat,
+    WaylandClientStatePtr,
+};
 
 pub(crate) const TEXT_MIME_TYPE: &str = "text/plain;charset=utf-8";
 pub(crate) const FILE_LIST_MIME_TYPE: &str = "text/uri-list";
@@ -33,14 +37,30 @@ pub(crate) struct Clipboard {
     current_primary_offer: Option<DataOffer<ZwpPrimarySelectionOfferV1>>,
 }
 
+pub(crate) trait ReceiveData {
+    fn receive_data(&self, mime_type: String, fd: BorrowedFd<'_>);
+}
+
+impl ReceiveData for WlDataOffer {
+    fn receive_data(&self, mime_type: String, fd: BorrowedFd<'_>) {
+        self.receive(mime_type, fd);
+    }
+}
+
+impl ReceiveData for ZwpPrimarySelectionOfferV1 {
+    fn receive_data(&self, mime_type: String, fd: BorrowedFd<'_>) {
+        self.receive(mime_type, fd);
+    }
+}
+
 #[derive(Clone, Debug)]
 /// Wrapper for `WlDataOffer` and `ZwpPrimarySelectionOfferV1`, used to help track mime types.
-pub(crate) struct DataOffer<T> {
+pub(crate) struct DataOffer<T: ReceiveData> {
     pub inner: T,
     mime_types: Vec<String>,
 }
 
-impl<T> DataOffer<T> {
+impl<T: ReceiveData> DataOffer<T> {
     pub fn new(offer: T) -> Self {
         Self {
             inner: offer,
@@ -52,17 +72,71 @@ impl<T> DataOffer<T> {
         self.mime_types.push(mime_type)
     }
 
-    pub fn has_mime_type(&self, mime_type: &str) -> bool {
+    fn has_mime_type(&self, mime_type: &str) -> bool {
         self.mime_types.iter().any(|t| t == mime_type)
     }
 
-    pub fn find_text_mime_type(&self) -> Option<String> {
-        for offered_mime_type in &self.mime_types {
-            if let Some(offer_text_mime_type) = ALLOWED_TEXT_MIME_TYPES
-                .into_iter()
-                .find(|text_mime_type| text_mime_type == offered_mime_type)
-            {
-                return Some(offer_text_mime_type.to_owned());
+    fn read_bytes(&self, connection: &Connection, mime_type: &str) -> Option<Vec<u8>> {
+        let pipe = Pipe::new().unwrap();
+        self.inner.receive_data(mime_type.to_string(), unsafe {
+            BorrowedFd::borrow_raw(pipe.write.as_raw_fd())
+        });
+        let fd = pipe.read;
+        drop(pipe.write);
+
+        connection.flush().unwrap();
+
+        match unsafe { read_fd(fd) } {
+            Ok(bytes) => Some(bytes),
+            Err(err) => {
+                log::error!("error reading clipboard pipe: {err:?}");
+                None
+            }
+        }
+    }
+
+    fn read_text(&self, connection: &Connection) -> Option<ClipboardItem> {
+        let mime_type = self.mime_types.iter().find(|&mime_type| {
+            ALLOWED_TEXT_MIME_TYPES
+                .iter()
+                .any(|&allowed| allowed == mime_type)
+        })?;
+        let bytes = self.read_bytes(connection, mime_type)?;
+        let text_content = match String::from_utf8(bytes) {
+            Ok(content) => content,
+            Err(e) => {
+                log::error!("Failed to convert clipboard content to UTF-8: {}", e);
+                return None;
+            }
+        };
+
+        // Normalize the text to unix line endings, otherwise
+        // copying from eg: firefox inserts a lot of blank
+        // lines, and that is super annoying.
+        let result = text_content.replace("\r\n", "\n");
+        Some(ClipboardItem::new_string(result))
+    }
+
+    fn read_image(&self, connection: &Connection) -> Option<ClipboardItem> {
+        for format in ImageFormat::iter() {
+            let mime_type = match format {
+                ImageFormat::Png => "image/png",
+                ImageFormat::Jpeg => "image/jpeg",
+                ImageFormat::Webp => "image/webp",
+                ImageFormat::Gif => "image/gif",
+                ImageFormat::Svg => "image/svg+xml",
+                ImageFormat::Bmp => "image/bmp",
+                ImageFormat::Tiff => "image/tiff",
+            };
+            if !self.has_mime_type(mime_type) {
+                continue;
+            }
+
+            if let Some(bytes) = self.read_bytes(connection, mime_type) {
+                let id = hash(&bytes);
+                return Some(ClipboardItem {
+                    entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
+                });
             }
         }
         None
@@ -128,7 +202,7 @@ impl Clipboard {
     }
 
     pub fn read(&mut self) -> Option<ClipboardItem> {
-        let offer = self.current_offer.clone()?;
+        let offer = self.current_offer.as_ref()?;
         if let Some(cached) = self.cached_read.clone() {
             return Some(cached);
         }
@@ -137,30 +211,16 @@ impl Clipboard {
             return self.contents.clone();
         }
 
-        let mime_type = offer.find_text_mime_type()?;
-        let pipe = Pipe::new().unwrap();
-        offer.inner.receive(mime_type, unsafe {
-            BorrowedFd::borrow_raw(pipe.write.as_raw_fd())
-        });
-        let fd = pipe.read;
-        drop(pipe.write);
-
-        self.connection.flush().unwrap();
+        let item = offer
+            .read_text(&self.connection)
+            .or_else(|| offer.read_image(&self.connection))?;
 
-        match unsafe { read_fd(fd) } {
-            Ok(v) => {
-                self.cached_read = Some(ClipboardItem::new_string(v));
-                self.cached_read.clone()
-            }
-            Err(err) => {
-                log::error!("error reading clipboard pipe: {err:?}");
-                None
-            }
-        }
+        self.cached_read = Some(item.clone());
+        Some(item)
     }
 
     pub fn read_primary(&mut self) -> Option<ClipboardItem> {
-        let offer = self.current_primary_offer.clone()?;
+        let offer = self.current_primary_offer.as_ref()?;
         if let Some(cached) = self.cached_primary_read.clone() {
             return Some(cached);
         }
@@ -169,26 +229,12 @@ impl Clipboard {
             return self.primary_contents.clone();
         }
 
-        let mime_type = offer.find_text_mime_type()?;
-        let pipe = Pipe::new().unwrap();
-        offer.inner.receive(mime_type, unsafe {
-            BorrowedFd::borrow_raw(pipe.write.as_raw_fd())
-        });
-        let fd = pipe.read;
-        drop(pipe.write);
-
-        self.connection.flush().unwrap();
+        let item = offer
+            .read_text(&self.connection)
+            .or_else(|| offer.read_image(&self.connection))?;
 
-        match unsafe { read_fd(fd) } {
-            Ok(v) => {
-                self.cached_primary_read = Some(ClipboardItem::new_string(v.clone()));
-                self.cached_primary_read.clone()
-            }
-            Err(err) => {
-                log::error!("error reading clipboard pipe: {err:?}");
-                None
-            }
-        }
+        self.cached_primary_read = Some(item.clone());
+        Some(item)
     }
 
     fn send_internal(&self, fd: OwnedFd, bytes: Vec<u8>) {