diff --git a/.vscode/settings.json b/.vscode/settings.json index 681dc12fe15..d4794e0334c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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", } diff --git a/Cargo.lock b/Cargo.lock index f6b05ed1439..7e11391f1c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index eb4766dfaab..f2384b87800 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -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", diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 789e8e11d50..6d11069f891 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -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); } diff --git a/crates/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs index 827feb9244f..911c453f224 100644 --- a/crates/eframe/src/web/mod.rs +++ b/crates/eframe/src/web/mod.rs @@ -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 { + 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, String> { + profiling::function_scope!(); + let mut png_bytes: Vec = 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 { + 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", diff --git a/crates/egui-winit/Cargo.toml b/crates/egui-winit/Cargo.toml index c584db85e70..e4a83782302 100644 --- a/crates/egui-winit/Cargo.toml +++ b/crates/egui-winit/Cargo.toml @@ -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", +] } diff --git a/crates/egui-winit/src/clipboard.rs b/crates/egui-winit/src/clipboard.rs index c4192f78d55..c8adcad2112 100644 --- a/crates/egui-winit/src/clipboard.rs +++ b/crates/egui-winit/src/clipboard.rs @@ -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")))] diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index d6166a57bed..ac396a01bb2 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -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(); diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 828f867e370..3ecf42ca46b 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -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 . 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 . + 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`]. diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs index 61e87bf618c..32773fc5522 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -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), } diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index cee322c0f54..7e4891e1f78 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -92,6 +92,7 @@ impl Default for DemoGroups { Box::::default(), ]), tests: DemoGroup::new(vec![ + Box::::default(), Box::::default(), Box::::default(), Box::::default(), diff --git a/crates/egui_demo_lib/src/demo/tests/clipboard_test.rs b/crates/egui_demo_lib/src/demo/tests/clipboard_test.rs new file mode 100644 index 00000000000..e602d046c30 --- /dev/null +++ b/crates/egui_demo_lib/src/demo/tests/clipboard_test.rs @@ -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!()); + }); + } +} diff --git a/crates/egui_demo_lib/src/demo/tests/mod.rs b/crates/egui_demo_lib/src/demo/tests/mod.rs index 78ad0a01185..13332fea7b7 100644 --- a/crates/egui_demo_lib/src/demo/tests/mod.rs +++ b/crates/egui_demo_lib/src/demo/tests/mod.rs @@ -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; diff --git a/crates/egui_demo_lib/src/rendering_test.rs b/crates/egui_demo_lib/src/rendering_test.rs index bd454267e7a..011ca473623 100644 --- a/crates/egui_demo_lib/src/rendering_test.rs +++ b/crates/egui_demo_lib/src/rendering_test.rs @@ -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, }; diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index 9a204a12199..f653be2e701 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -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) -> 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) -> 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 {