From dadf573dde7a685d03a6dc8c32d6b01311878b9c Mon Sep 17 00:00:00 2001 From: BernardDGS <143314488+BernardDGS@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:00:43 +0000 Subject: [PATCH] Add clipboard image paste support (`Event::PasteImage`) (#8472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `egui::Event::PasteImage`, emitted on Ctrl+V/Cmd+V when the clipboard holds an image with no usable text representation (mirrors `OutputCommand::CopyImage` for the opposite direction). `egui-winit::Clipboard` gains `get_image()`, backed by `arboard::Clipboard::get_image()` — the symmetric counterpart to the `set_image()` already used for copy. Wired in the winit keyboard-shortcut path and in both the glow and wgpu eframe backends' AccessKit-driven paste action. Straight (unmultiplied) alpha from the OS clipboard is converted via the existing `ColorImage::from_rgba_unmultiplied` helper rather than reinterpreting bytes directly, since the two aren't bit-compatible for translucent pixels. Not covered here: the web (wasm32) target — browser clipboard image reads are inherently async (`Blob::array_buffer` returns a Promise), while `Event::PasteImage` as designed here carries an already-decoded `ColorImage`. Bridging that needs either an async event payload or a deferred/queued event on `AppRunner` — more of a design decision than a small mirrored addition, flagging it rather than guessing. * Related: #2108 (not fixed: web) --------- Co-authored-by: Emil Ernerfeldt --- crates/eframe/src/native/glow_integration.rs | 5 + crates/eframe/src/native/wgpu_integration.rs | 5 + crates/egui-winit/src/clipboard.rs | 114 ++++++++++++++++++- crates/egui-winit/src/lib.rs | 14 +++ crates/egui/src/data/input/event.rs | 9 ++ 5 files changed, 146 insertions(+), 1 deletion(-) 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`]).