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:
parent
e2c7e9e733
commit
bf6ed3adfc
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
22
Cargo.lock
22
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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
] }
|
||||
|
|
|
|||
|
|
@ -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")))]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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!());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue