diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index 82a150aad..c262b5a90 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -831,6 +831,11 @@ impl GlowWinitRunning<'_> { .events .push(egui::Event::Paste(contents)); } + } else if let Some(image) = egui_winit.clipboard_image() { + egui_winit + .egui_input_mut() + .events + .push(egui::Event::PasteImage(std::sync::Arc::new(image))); } } } diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index 4a94a5d49..2189367be 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -838,6 +838,11 @@ impl WgpuWinitRunning<'_> { .events .push(egui::Event::Paste(contents)); } + } else if let Some(image) = egui_winit.clipboard_image() { + egui_winit + .egui_input_mut() + .events + .push(egui::Event::PasteImage(std::sync::Arc::new(image))); } } } diff --git a/crates/egui-winit/src/clipboard.rs b/crates/egui-winit/src/clipboard.rs index 2410c3ee6..3d0847ce7 100644 --- a/crates/egui-winit/src/clipboard.rs +++ b/crates/egui-winit/src/clipboard.rs @@ -81,7 +81,13 @@ impl Clipboard { return match clipboard.get_text() { Ok(text) => Some(text), Err(err) => { - log::error!("arboard paste error: {err}"); + // Expected whenever the clipboard holds something other than text (e.g. + // an image copied with a screenshot tool) — the caller falls back to + // `Self::get_image` in that case, so this is not an error worth + // alarming the user/log about. + if !is_expected_content_absence(&err) { + log::error!("arboard paste error: {err}"); + } None } }; @@ -120,6 +126,34 @@ impl Clipboard { self.clipboard = text; } + /// Get an image from the clipboard, if there is one and the platform backend supports it. + /// + /// This mirrors [`Self::set_image`] for the opposite direction, so that a Ctrl+V/Cmd+V + /// paste can carry an image (e.g. a screenshot or a copied image) instead of text — see + /// [`egui::Event::PasteImage`]. + pub fn get_image(&mut self) -> Option { + #[cfg(all( + not(any(target_os = "android", target_os = "ios")), + feature = "arboard", + ))] + if let Some(clipboard) = &mut self.arboard { + return match clipboard.get_image() { + Ok(image) => Some(color_image_from_arboard(&image)), + Err(err) => { + // Expected whenever the clipboard holds neither text nor an image (e.g. + // it's simply empty) — `Self::get` was already tried first and came up + // empty too, so this is the mundane "nothing to paste" case, not an error. + if !is_expected_content_absence(&err) { + log::error!("arboard paste-image error: {err}"); + } + None + } + }; + } + + None + } + pub fn set_image(&mut self, image: &egui::ColorImage) { #[cfg(all( not(any(target_os = "android", target_os = "ios")), @@ -144,6 +178,29 @@ impl Clipboard { } } +/// Whether an `arboard::Error` from reading the clipboard is the expected, mundane outcome +/// of the clipboard simply not holding the requested content type (e.g. text was asked for +/// but the clipboard holds an image, or vice versa, or it's just empty) — as opposed to a +/// genuine failure (permissions, a locked clipboard, a conversion error) worth an `error!` log. +/// +/// Pulled out as its own pure function (rather than inlined in the two `match`es above) so it +/// can be unit-tested without touching the real OS clipboard, which CI can't rely on. +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + feature = "arboard", +))] +fn is_expected_content_absence(err: &arboard::Error) -> bool { + matches!(err, arboard::Error::ContentNotAvailable) +} + +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + feature = "arboard", +))] +fn color_image_from_arboard(image: &arboard::ImageData<'_>) -> egui::ColorImage { + egui::ColorImage::from_rgba_unmultiplied([image.width, image.height], &image.bytes) +} + #[cfg(all( not(any(target_os = "android", target_os = "ios")), feature = "arboard", @@ -192,3 +249,58 @@ fn init_smithay_clipboard( None } } + +#[cfg(all( + not(any(target_os = "android", target_os = "ios")), + feature = "arboard", +))] +#[cfg(test)] +mod tests { + use super::{color_image_from_arboard, is_expected_content_absence}; + + /// Regression test for the spurious `error!`-level log a maintainer caught by manually + /// testing an image paste (nothing had exercised this distinction before): only + /// `ContentNotAvailable` — clipboard simply doesn't hold the requested content type — is + /// expected and should stay silent; every other `arboard::Error` variant is a real failure + /// and must still be logged. + #[test] + fn only_content_not_available_is_treated_as_expected() { + assert!(is_expected_content_absence( + &arboard::Error::ContentNotAvailable + )); + + assert!(!is_expected_content_absence( + &arboard::Error::ClipboardNotSupported + )); + assert!(!is_expected_content_absence( + &arboard::Error::ClipboardOccupied + )); + assert!(!is_expected_content_absence( + &arboard::Error::ConversionFailure + )); + assert!(!is_expected_content_absence(&arboard::Error::Unknown { + description: "anything".to_owned(), + })); + } + + #[test] + fn color_image_from_arboard_converts_straight_to_premultiplied_alpha() { + // 2x1 image: opaque red, then half-transparent white — straight (unmultiplied) alpha, + // as arboard/the OS clipboard would hand it to us. + let image = arboard::ImageData { + width: 2, + height: 1, + bytes: std::borrow::Cow::Borrowed(&[255, 0, 0, 255, 255, 255, 255, 128]), + }; + let color_image = color_image_from_arboard(&image); + assert_eq!(color_image.size, [2, 1]); + assert_eq!( + color_image.pixels[0], + egui::Color32::from_rgba_unmultiplied(255, 0, 0, 255) + ); + assert_eq!( + color_image.pixels[1], + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 128) + ); + } +} diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 5ca800108..747a62c53 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -220,6 +220,12 @@ impl State { self.clipboard.get() } + /// Fetches an image from the clipboard and returns it, if there is one and the platform + /// backend supports it. Mirrors [`Self::clipboard_text`] for images. + pub fn clipboard_image(&mut self) -> Option { + self.clipboard.get_image() + } + /// Places the text onto the clipboard. pub fn set_clipboard_text(&mut self, text: String) { self.clipboard.set_text(text); @@ -1030,6 +1036,14 @@ impl State { if !contents.is_empty() { self.egui_input.events.push(egui::Event::Paste(contents)); } + } else if let Some(image) = self.clipboard.get_image() { + // No usable text on the clipboard (e.g. an image was copied with + // mspaint/Snipping Tool, which never puts a text representation + // alongside it) — fall back to an image paste rather than doing + // nothing, mirroring `Event::Copy`/`OutputCommand::CopyImage`. + self.egui_input + .events + .push(egui::Event::PasteImage(std::sync::Arc::new(image))); } return; } diff --git a/crates/egui/src/data/input/event.rs b/crates/egui/src/data/input/event.rs index 9641d081d..b366cec16 100644 --- a/crates/egui/src/data/input/event.rs +++ b/crates/egui/src/data/input/event.rs @@ -24,6 +24,15 @@ pub enum Event { /// The integration detected a "paste" event (e.g. Cmd+V). Paste(String), + /// The integration detected a "paste" event (e.g. Cmd+V) where the clipboard held an + /// image instead of text (e.g. a screenshot, or an image copied from another app). + /// + /// Mirrors [`crate::OutputCommand::CopyImage`] for the opposite direction: an integration + /// that supports copying an image out (via `arboard`, say) should support pasting one back + /// in the same way. Only emitted when the clipboard has no usable text representation — + /// [`Self::Paste`] still takes priority when both are available. + PasteImage(std::sync::Arc), + /// Text input, e.g. via keyboard. /// /// When the user presses enter/return, do not send a [`Text`](Event::Text) (just [`Key::Enter`]).