mirror of
https://github.com/emilk/egui.git
synced 2026-09-03 07:10:04 -04:00
Add clipboard image paste support (Event::PasteImage) (#8472)
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 <emil.ernerfeldt@gmail.com>
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,13 @@ impl Clipboard {
|
||||
return match clipboard.get_text() {
|
||||
Ok(text) => Some(text),
|
||||
Err(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<egui::ColorImage> {
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<egui::ColorImage> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<ColorImage>),
|
||||
|
||||
/// Text input, e.g. via keyboard.
|
||||
///
|
||||
/// When the user presses enter/return, do not send a [`Text`](Event::Text) (just [`Key::Enter`]).
|
||||
|
||||
Reference in New Issue
Block a user