Add `Context::copy_image` (#5533)

* Closes https://github.com/emilk/egui/issues/5424

This adds support for copying images to the system clipboard on native
and on web using `Context::copy_image`.
This commit is contained in:
Emil Ernerfeldt 2024-12-29 18:03:32 +01:00 committed by GitHub
parent e2c7e9e733
commit bf6ed3adfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 285 additions and 41 deletions

View File

@ -33,4 +33,8 @@
"--all-features",
],
"rust-analyzer.showUnlinkedFileNotification": false,
// Uncomment the following options and restart rust-analyzer to get it to check code behind `cfg(target_arch=wasm32)`.
// Don't forget to put it in a comment again before committing.
// "rust-analyzer.cargo.target": "wasm32-unknown-unknown",
}

View File

@ -240,11 +240,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df099ccb16cd014ff054ac1bf392c67feeef57164b05c42f037cd40f5d4357f4"
dependencies = [
"clipboard-win",
"core-graphics",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"parking_lot",
"windows-sys 0.48.0",
"x11rb",
]
@ -1292,6 +1295,7 @@ dependencies = [
"accesskit_winit",
"ahash",
"arboard",
"bytemuck",
"document-features",
"egui",
"log",
@ -2205,6 +2209,7 @@ dependencies = [
"image-webp",
"num-traits",
"png",
"tiff",
"zune-core",
"zune-jpeg",
]
@ -2311,6 +2316,12 @@ dependencies = [
"libc",
]
[[package]]
name = "jpeg-decoder"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
[[package]]
name = "js-sys"
version = "0.3.72"
@ -3882,6 +3893,17 @@ dependencies = [
"syn",
]
[[package]]
name = "tiff"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
dependencies = [
"flate2",
"jpeg-decoder",
"weezl",
]
[[package]]
name = "time"
version = "0.3.36"

View File

@ -203,6 +203,7 @@ windows-sys = { workspace = true, features = [
# web:
[target.'cfg(target_arch = "wasm32")'.dependencies]
bytemuck.workspace = true
image = { workspace = true, features = ["png"] } # For copying images
js-sys = "0.3"
percent-encoding = "2.1"
wasm-bindgen.workspace = true
@ -210,8 +211,10 @@ wasm-bindgen-futures.workspace = true
web-sys = { workspace = true, features = [
"BinaryType",
"Blob",
"BlobPropertyBag",
"Clipboard",
"ClipboardEvent",
"ClipboardItem",
"CompositionEvent",
"console",
"CssStyleDeclaration",

View File

@ -318,6 +318,9 @@ impl AppRunner {
egui::OutputCommand::CopyText(text) => {
super::set_clipboard_text(&text);
}
egui::OutputCommand::CopyImage(image) => {
super::set_clipboard_image(&image);
}
egui::OutputCommand::OpenUrl(open_url) => {
super::open_url(&open_url.url, open_url.new_tab);
}

View File

@ -192,6 +192,95 @@ fn set_clipboard_text(s: &str) {
}
}
/// Set the clipboard image.
fn set_clipboard_image(image: &egui::ColorImage) {
if let Some(window) = web_sys::window() {
if !window.is_secure_context() {
log::error!(
"Clipboard is not available because we are not in a secure context. \
See https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts"
);
return;
}
let png_bytes = to_image(image).and_then(|image| to_png_bytes(&image));
let png_bytes = match png_bytes {
Ok(png_bytes) => png_bytes,
Err(err) => {
log::error!("Failed to encode image to png: {err}");
return;
}
};
let mime = "image/png";
let item = match create_clipboard_item(mime, &png_bytes) {
Ok(item) => item,
Err(err) => {
log::error!("Failed to copy image: {}", string_from_js_value(&err));
return;
}
};
let items = js_sys::Array::of1(&item);
let promise = window.navigator().clipboard().write(&items);
let future = wasm_bindgen_futures::JsFuture::from(promise);
let future = async move {
if let Err(err) = future.await {
log::error!(
"Copy/cut image action failed: {}",
string_from_js_value(&err)
);
}
};
wasm_bindgen_futures::spawn_local(future);
}
}
fn to_image(image: &egui::ColorImage) -> Result<image::RgbaImage, String> {
profiling::function_scope!();
image::RgbaImage::from_raw(
image.width() as _,
image.height() as _,
bytemuck::cast_slice(&image.pixels).to_vec(),
)
.ok_or_else(|| "Invalid IconData".to_owned())
}
fn to_png_bytes(image: &image::RgbaImage) -> Result<Vec<u8>, String> {
profiling::function_scope!();
let mut png_bytes: Vec<u8> = Vec::new();
image
.write_to(
&mut std::io::Cursor::new(&mut png_bytes),
image::ImageFormat::Png,
)
.map_err(|err| err.to_string())?;
Ok(png_bytes)
}
fn create_clipboard_item(mime: &str, bytes: &[u8]) -> Result<web_sys::ClipboardItem, JsValue> {
let array = js_sys::Uint8Array::from(bytes);
let blob_parts = js_sys::Array::new();
blob_parts.push(&array);
let options = web_sys::BlobPropertyBag::new();
options.set_type(mime);
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&blob_parts, &options)?;
let items = js_sys::Object::new();
// SAFETY: I hope so
#[allow(unsafe_code, unused_unsafe)] // Weird false positive
unsafe {
js_sys::Reflect::set(&items, &JsValue::from_str(mime), &blob)?
};
let clipboard_item = web_sys::ClipboardItem::new_with_record_from_str_to_blob_promise(&items)?;
Ok(clipboard_item)
}
fn cursor_web_name(cursor: egui::CursorIcon) -> &'static str {
match cursor {
egui::CursorIcon::Alias => "alias",

View File

@ -36,11 +36,11 @@ android-game-activity = ["winit/android-game-activity"]
android-native-activity = ["winit/android-native-activity"]
## [`bytemuck`](https://docs.rs/bytemuck) enables you to cast [`egui::epaint::Vertex`], [`egui::Vec2`] etc to `&[u8]`.
bytemuck = ["egui/bytemuck"]
bytemuck = ["egui/bytemuck", "dep:bytemuck"]
## Enable cut/copy/paste to OS clipboard.
## If disabled a clipboard will be simulated so you can still copy/paste within the egui app.
clipboard = ["arboard", "smithay-clipboard"]
clipboard = ["arboard", "bytemuck", "smithay-clipboard"]
## Enable opening links in a browser when an egui hyperlink is clicked.
links = ["webbrowser"]
@ -69,6 +69,8 @@ winit = { workspace = true, default-features = false }
# feature accesskit
accesskit_winit = { version = "0.23", optional = true }
bytemuck = { workspace = true, optional = true }
## Enable this when generating docs.
document-features = { workspace = true, optional = true }
@ -84,4 +86,6 @@ smithay-clipboard = { version = "0.7.2", optional = true }
wayland-cursor = { version = "0.31.1", default-features = false, optional = true }
[target.'cfg(not(target_os = "android"))'.dependencies]
arboard = { version = "3.3", optional = true, default-features = false }
arboard = { version = "3.3", optional = true, default-features = false, features = [
"image-data",
] }

View File

@ -82,7 +82,7 @@ impl Clipboard {
Some(self.clipboard.clone())
}
pub fn set(&mut self, text: String) {
pub fn set_text(&mut self, text: String) {
#[cfg(all(
any(
target_os = "linux",
@ -108,6 +108,24 @@ impl Clipboard {
self.clipboard = text;
}
pub fn set_image(&mut self, image: &egui::ColorImage) {
#[cfg(all(feature = "arboard", not(target_os = "android")))]
if let Some(clipboard) = &mut self.arboard {
if let Err(err) = clipboard.set_image(arboard::ImageData {
width: image.width(),
height: image.height(),
bytes: std::borrow::Cow::Borrowed(bytemuck::cast_slice(&image.pixels)),
}) {
log::error!("arboard copy/cut error: {err}");
}
log::debug!("Copied image to clipboard");
return;
}
log::error!("Copying images is not supported. Enable the 'clipboard' feature of `egui-winit` to enable it.");
_ = image;
}
}
#[cfg(all(feature = "arboard", not(target_os = "android")))]

View File

@ -190,7 +190,7 @@ impl State {
/// Places the text onto the clipboard.
pub fn set_clipboard_text(&mut self, text: String) {
self.clipboard.set(text);
self.clipboard.set_text(text);
}
/// Returns [`false`] or the last value that [`Window::set_ime_allowed()`] was called with, used for debouncing.
@ -840,7 +840,10 @@ impl State {
for command in commands {
match command {
egui::OutputCommand::CopyText(text) => {
self.clipboard.set(text);
self.clipboard.set_text(text);
}
egui::OutputCommand::CopyImage(image) => {
self.clipboard.set_image(&image);
}
egui::OutputCommand::OpenUrl(open_url) => {
open_url_in_browser(&open_url.url);
@ -855,7 +858,7 @@ impl State {
}
if !copied_text.is_empty() {
self.clipboard.set(copied_text);
self.clipboard.set_text(copied_text);
}
let allow_ime = ime.is_some();

View File

@ -1439,13 +1439,22 @@ impl Context {
/// Copy the given text to the system clipboard.
///
/// Note that in wasm applications, the clipboard is only accessible in secure contexts (e.g.,
/// Note that in web applications, the clipboard is only accessible in secure contexts (e.g.,
/// HTTPS or localhost). If this method is used outside of a secure context, it will log an
/// error and do nothing. See <https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts>.
pub fn copy_text(&self, text: String) {
self.send_cmd(crate::OutputCommand::CopyText(text));
}
/// Copy the given image to the system clipboard.
///
/// Note that in web applications, the clipboard is only accessible in secure contexts (e.g.,
/// HTTPS or localhost). If this method is used outside of a secure context, it will log an
/// error and do nothing. See <https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts>.
pub fn copy_image(&self, image: crate::ColorImage) {
self.send_cmd(crate::OutputCommand::CopyImage(image));
}
/// Format the given shortcut in a human-readable way (e.g. `Ctrl+Shift+X`).
///
/// Can be used to get the text for [`crate::Button::shortcut_text`].

View File

@ -85,11 +85,14 @@ pub struct IMEOutput {
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum OutputCommand {
/// Put this text in the system clipboard.
/// Put this text to the system clipboard.
///
/// This is often a response to [`crate::Event::Copy`] or [`crate::Event::Cut`].
CopyText(String),
/// Put this image to the system clipboard.
CopyImage(crate::ColorImage),
/// Open this url in a browser.
OpenUrl(OpenUrl),
}

View File

@ -92,6 +92,7 @@ impl Default for DemoGroups {
Box::<super::window_options::WindowOptions>::default(),
]),
tests: DemoGroup::new(vec![
Box::<super::tests::ClipboardTest>::default(),
Box::<super::tests::CursorTest>::default(),
Box::<super::tests::GridTest>::default(),
Box::<super::tests::IdTest>::default(),

View File

@ -0,0 +1,81 @@
pub struct ClipboardTest {
text: String,
}
impl Default for ClipboardTest {
fn default() -> Self {
Self {
text: "Example text you can copy-and-paste".to_owned(),
}
}
}
impl crate::Demo for ClipboardTest {
fn name(&self) -> &'static str {
"Clipboard Test"
}
fn show(&mut self, ctx: &egui::Context, open: &mut bool) {
egui::Window::new(self.name()).open(open).show(ctx, |ui| {
use crate::View as _;
self.ui(ui);
});
}
}
impl crate::View for ClipboardTest {
fn ui(&mut self, ui: &mut egui::Ui) {
ui.label("egui integrates with the system clipboard.");
ui.label("Try copy-cut-pasting text in the text edit below.");
let text_edit_response = ui
.horizontal(|ui| {
let text_edit_response = ui.text_edit_singleline(&mut self.text);
if ui.button("📋").clicked() {
ui.ctx().copy_text(self.text.clone());
}
text_edit_response
})
.inner;
if !cfg!(target_arch = "wasm32") {
// These commands are not yet implemented on web
ui.horizontal(|ui| {
for (name, cmd) in [
("Copy", egui::ViewportCommand::RequestCopy),
("Cut", egui::ViewportCommand::RequestCut),
("Paste", egui::ViewportCommand::RequestPaste),
] {
if ui.button(name).clicked() {
// Next frame we should get a copy/cut/paste-event…
ui.ctx().send_viewport_cmd(cmd);
// …that should en up here:
text_edit_response.request_focus();
}
}
});
}
ui.separator();
ui.label("You can also copy images:");
ui.horizontal(|ui| {
let image_source = egui::include_image!("../../../data/icon.png");
let uri = image_source.uri().unwrap().to_owned();
ui.image(image_source);
if let Ok(egui::load::ImagePoll::Ready { image }) =
ui.ctx().try_load_image(&uri, Default::default())
{
if ui.button("📋").clicked() {
ui.ctx().copy_image((*image).clone());
}
}
});
ui.vertical_centered_justified(|ui| {
ui.add(crate::egui_github_link_file!());
});
}
}

View File

@ -1,3 +1,4 @@
mod clipboard_test;
mod cursor_test;
mod grid_test;
mod id_test;
@ -7,6 +8,7 @@ mod layout_test;
mod manual_layout_test;
mod window_resize_test;
pub use clipboard_test::ClipboardTest;
pub use cursor_test::CursorTest;
pub use grid_test::GridTest;
pub use id_test::IdTest;

View File

@ -1,7 +1,7 @@
use std::collections::HashMap;
use egui::{
emath::GuiRounding, epaint, lerp, pos2, vec2, widgets::color_picker::show_color, Align2,
emath::GuiRounding as _, epaint, lerp, pos2, vec2, widgets::color_picker::show_color, Align2,
Color32, FontId, Image, Mesh, Pos2, Rect, Response, Rgba, RichText, Sense, Shape, Stroke,
TextureHandle, TextureOptions, Ui, Vec2,
};

View File

@ -143,37 +143,6 @@ impl ColorImage {
bytemuck::cast_slice_mut(&mut self.pixels)
}
/// Create a new Image from a patch of the current image. This method is especially convenient for screenshotting a part of the app
/// since `region` can be interpreted as screen coordinates of the entire screenshot if `pixels_per_point` is provided for the native application.
/// The floats of [`emath::Rect`] are cast to usize, rounding them down in order to interpret them as indices to the image data.
///
/// Panics if `region.min.x > region.max.x || region.min.y > region.max.y`, or if a region larger than the image is passed.
pub fn region(&self, region: &emath::Rect, pixels_per_point: Option<f32>) -> Self {
let pixels_per_point = pixels_per_point.unwrap_or(1.0);
let min_x = (region.min.x * pixels_per_point) as usize;
let max_x = (region.max.x * pixels_per_point) as usize;
let min_y = (region.min.y * pixels_per_point) as usize;
let max_y = (region.max.y * pixels_per_point) as usize;
assert!(
min_x <= max_x && min_y <= max_y,
"Screenshot region is invalid: {region:?}"
);
let width = max_x - min_x;
let height = max_y - min_y;
let mut output = Vec::with_capacity(width * height);
let row_stride = self.size[0];
for row in min_y..max_y {
output.extend_from_slice(
&self.pixels[row * row_stride + min_x..row * row_stride + max_x],
);
}
Self {
size: [width, height],
pixels: output,
}
}
/// Create a [`ColorImage`] from flat RGB data.
///
/// This is what you want to use after having loaded an image file (and if
@ -215,6 +184,39 @@ impl ColorImage {
pub fn height(&self) -> usize {
self.size[1]
}
/// Create a new image from a patch of the current image.
///
/// This method is especially convenient for screenshotting a part of the app
/// since `region` can be interpreted as screen coordinates of the entire screenshot if `pixels_per_point` is provided for the native application.
/// The floats of [`emath::Rect`] are cast to usize, rounding them down in order to interpret them as indices to the image data.
///
/// Panics if `region.min.x > region.max.x || region.min.y > region.max.y`, or if a region larger than the image is passed.
pub fn region(&self, region: &emath::Rect, pixels_per_point: Option<f32>) -> Self {
let pixels_per_point = pixels_per_point.unwrap_or(1.0);
let min_x = (region.min.x * pixels_per_point) as usize;
let max_x = (region.max.x * pixels_per_point) as usize;
let min_y = (region.min.y * pixels_per_point) as usize;
let max_y = (region.max.y * pixels_per_point) as usize;
assert!(
min_x <= max_x && min_y <= max_y,
"Screenshot region is invalid: {region:?}"
);
let width = max_x - min_x;
let height = max_y - min_y;
let mut output = Vec::with_capacity(width * height);
let row_stride = self.size[0];
for row in min_y..max_y {
output.extend_from_slice(
&self.pixels[row * row_stride + min_x..row * row_stride + max_x],
);
}
Self {
size: [width, height],
pixels: output,
}
}
}
impl std::ops::Index<(usize, usize)> for ColorImage {