diff --git a/examples/render_to_image_widget.rs b/examples/render_to_image_widget.rs index e8ba75e07..3dba0ae87 100644 --- a/examples/render_to_image_widget.rs +++ b/examples/render_to_image_widget.rs @@ -1,15 +1,14 @@ use bevy::{ prelude::*, render::{ - camera::{ClearColorConfig, RenderTarget}, + camera::RenderTarget, render_resource::{ Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, }, view::RenderLayers, }, }; -use bevy_egui::{egui, EguiContexts, EguiPlugin, EguiUserTextures}; -use egui::Widget; +use bevy_egui::{egui::Widget, EguiContexts, EguiPlugin, EguiUserTextures}; fn main() { App::new() diff --git a/examples/side_panel.rs b/examples/side_panel.rs index ff739a82c..5536bbe65 100644 --- a/examples/side_panel.rs +++ b/examples/side_panel.rs @@ -1,5 +1,5 @@ -use bevy::{prelude::*, render::camera::Projection, window::PrimaryWindow}; -use bevy_egui::{egui, EguiContexts, EguiPlugin}; +use bevy::{prelude::*, window::PrimaryWindow}; +use bevy_egui::{EguiContexts, EguiPlugin}; #[derive(Default, Resource)] struct OccupiedScreenSpace { diff --git a/examples/simple.rs b/examples/simple.rs index 63d592f6e..47d532f58 100644 --- a/examples/simple.rs +++ b/examples/simple.rs @@ -1,5 +1,5 @@ use bevy::prelude::*; -use bevy_egui::{egui, EguiContexts, EguiPlugin}; +use bevy_egui::{EguiContexts, EguiPlugin}; fn main() { App::new() diff --git a/examples/ui.rs b/examples/ui.rs index 6592fb4ae..06043ef68 100644 --- a/examples/ui.rs +++ b/examples/ui.rs @@ -1,5 +1,5 @@ use bevy::{prelude::*, window::PrimaryWindow}; -use bevy_egui::{egui, EguiContexts, EguiPlugin, EguiSettings}; +use bevy_egui::{EguiContexts, EguiPlugin, EguiSettings}; struct Images { bevy_icon: Handle, @@ -47,6 +47,7 @@ struct UiState { inverted: bool, egui_texture_handle: Option, is_window_open: bool, + text: String, } fn configure_visuals_system(mut contexts: EguiContexts) { @@ -192,12 +193,14 @@ fn ui_example_system( egui::Window::new("Window") .vscroll(true) - .open(&mut ui_state.is_window_open) + // .open(&mut ui_state.is_window_open) .show(ctx, |ui| { ui.label("Windows can be moved by dragging them."); ui.label("They are automatically sized based on contents."); ui.label("You can turn on resizing and scrolling if you like."); ui.label("You would normally chose either panels OR windows."); + + ui.text_edit_multiline(&mut ui_state.text) }); if invert { diff --git a/src/lib.rs b/src/lib.rs index a25a195e4..7cd739ac0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,23 +50,19 @@ //! //! - [`bevy-inspector-egui`](https://github.com/jakobhellermann/bevy-inspector-egui) +/// Egui render node. +#[cfg(feature = "render")] +pub mod egui_node; /// Plugin systems for the render app. #[cfg(feature = "render")] pub mod render_systems; /// Plugin systems. pub mod systems; - -/// Egui render node. -#[cfg(feature = "render")] -pub mod egui_node; - /// Clipboard management for web #[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))] pub mod web_clipboard; pub use egui; -#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))] -use web_clipboard::{WebEventCopy, WebEventCut, WebEventPaste}; use crate::systems::*; #[cfg(feature = "render")] @@ -117,11 +113,6 @@ use std::borrow::Cow; not(any(target_arch = "wasm32", target_os = "android")) ))] use std::cell::{RefCell, RefMut}; -#[cfg(all( - feature = "manage_clipboard", - not(any(target_arch = "wasm32", target_os = "android")) -))] -use thread_local::ThreadLocal; /// Adds all Egui resources and render graph nodes. pub struct EguiPlugin; @@ -184,16 +175,9 @@ pub struct EguiInput(pub egui::RawInput); #[derive(Default, Resource)] pub struct EguiClipboard { #[cfg(not(target_arch = "wasm32"))] - clipboard: ThreadLocal>>, - /// for copy events. - #[cfg(target_arch = "wasm32")] - pub web_copy: web_clipboard::WebChannel, - /// for copy events. + clipboard: thread_local::ThreadLocal>>, #[cfg(target_arch = "wasm32")] - pub web_cut: web_clipboard::WebChannel, - /// for paste events, only supporting strings. - #[cfg(target_arch = "wasm32")] - pub web_paste: web_clipboard::WebChannel, + clipboard: web_clipboard::WebClipboard, } #[cfg(all(feature = "manage_clipboard", not(target_os = "android")))] @@ -203,6 +187,13 @@ impl EguiClipboard { self.set_contents_impl(contents); } + /// Sets the internal buffer of clipboard contents. + /// This buffer is used to remember the contents of the last "Paste" event. + #[cfg(target_arch = "wasm32")] + pub fn set_contents_internal(&mut self, contents: &str) { + self.clipboard.set_contents_internal(contents); + } + /// Gets clipboard contents. Returns [`None`] if clipboard provider is unavailable or returns an error. #[must_use] #[cfg(not(target_arch = "wasm32"))] @@ -217,6 +208,12 @@ impl EguiClipboard { self.get_contents_impl() } + /// Receives a clipboard event sent by the `copy`/`cut`/`paste` listeners. + #[cfg(target_arch = "wasm32")] + pub fn try_receive_clipboard_event(&self) -> Option { + self.clipboard.try_receive_clipboard_event() + } + #[cfg(not(target_arch = "wasm32"))] fn set_contents_impl(&mut self, contents: &str) { if let Some(mut clipboard) = self.get() { @@ -228,7 +225,7 @@ impl EguiClipboard { #[cfg(target_arch = "wasm32")] fn set_contents_impl(&mut self, contents: &str) { - web_clipboard::clipboard_copy(contents.to_owned()); + self.clipboard.set_contents(contents); } #[cfg(not(target_arch = "wasm32"))] @@ -245,7 +242,7 @@ impl EguiClipboard { #[cfg(target_arch = "wasm32")] #[allow(clippy::unnecessary_wraps)] fn get_contents_impl(&mut self) -> Option { - self.web_paste.try_read_clipboard_event().map(|e| e.0) + self.clipboard.get_contents() } #[cfg(not(target_arch = "wasm32"))] @@ -601,6 +598,8 @@ impl Plugin for EguiPlugin { world.init_resource::(); #[cfg(all(feature = "manage_clipboard", not(target_os = "android")))] world.init_resource::(); + #[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))] + world.init_non_send_resource::(); #[cfg(feature = "render")] world.init_resource::(); world.init_resource::(); diff --git a/src/render_systems.rs b/src/render_systems.rs index 939699351..c04357f1d 100644 --- a/src/render_systems.rs +++ b/src/render_systems.rs @@ -14,7 +14,6 @@ use bevy::{ DynamicUniformBuffer, PipelineCache, ShaderType, SpecializedRenderPipelines, }, renderer::{RenderDevice, RenderQueue}, - texture::Image, view::ExtractedWindows, Extract, }, diff --git a/src/systems.rs b/src/systems.rs index 11ee98e51..1eeba2213 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -304,53 +304,48 @@ pub fn process_input_system( // We also check that it's an `ButtonState::Pressed` event, as we don't want to // copy, cut or paste on the key release. - #[cfg(all(feature = "manage_clipboard", not(target_os = "android")))] - { - #[cfg(not(target_arch = "wasm32"))] - if command && ev.state.is_pressed() { - match key { - egui::Key::C => { - focused_input.events.push(egui::Event::Copy); - } - egui::Key::X => { - focused_input.events.push(egui::Event::Cut); - } - egui::Key::V => { - if let Some(contents) = - input_resources.egui_clipboard.get_contents() - { - focused_input.events.push(egui::Event::Text(contents)) - } - } - _ => {} - } - } - #[cfg(target_arch = "wasm32")] - { - if input_resources - .egui_clipboard - .web_copy - .try_read_clipboard_event() - .is_some() - { + #[cfg(all( + feature = "manage_clipboard", + not(target_os = "android"), + not(target_arch = "wasm32") + ))] + if command && ev.state.is_pressed() { + match key { + egui::Key::C => { focused_input.events.push(egui::Event::Copy); } - if input_resources - .egui_clipboard - .web_cut - .try_read_clipboard_event() - .is_some() - { + egui::Key::X => { focused_input.events.push(egui::Event::Cut); } - if let Some(contents) = input_resources.egui_clipboard.get_contents() { - focused_input.events.push(egui::Event::Text(contents)); + egui::Key::V => { + if let Some(contents) = input_resources.egui_clipboard.get_contents() { + focused_input.events.push(egui::Event::Text(contents)) + } } + _ => {} } } } } + #[cfg(target_arch = "wasm32")] + while let Some(event) = input_resources.egui_clipboard.try_receive_clipboard_event() { + match event { + crate::web_clipboard::WebClipboardEvent::Copy => { + focused_input.events.push(egui::Event::Copy); + } + crate::web_clipboard::WebClipboardEvent::Cut => { + focused_input.events.push(egui::Event::Cut); + } + crate::web_clipboard::WebClipboardEvent::Paste(contents) => { + input_resources + .egui_clipboard + .set_contents_internal(&contents); + focused_input.events.push(egui::Event::Text(contents)) + } + } + } + for touch in input_events.ev_touch.read() { let scale_factor = egui_settings.scale_factor; let touch_position: (f32, f32) = (touch.position / scale_factor).into(); diff --git a/src/web_clipboard.rs b/src/web_clipboard.rs index 10ce1750d..c335e874e 100644 --- a/src/web_clipboard.rs +++ b/src/web_clipboard.rs @@ -1,132 +1,250 @@ +use crate::EguiClipboard; +use bevy::{log, prelude::*}; use crossbeam_channel::{Receiver, Sender}; - -use bevy::prelude::*; +use wasm_bindgen::prelude::*; use wasm_bindgen_futures::spawn_local; -use crate::EguiClipboard; -use wasm_bindgen::{closure::Closure, prelude::*}; - -/// startup system for bevy to initialize web events. -pub fn startup_setup_web_events(mut clipboard_channel: ResMut) { - setup_clipboard_copy(&mut clipboard_channel.web_copy); - setup_clipboard_cut(&mut clipboard_channel.web_cut); - setup_clipboard_paste(&mut clipboard_channel.web_paste); +/// Startup system to initialize web clipboard events. +pub fn startup_setup_web_events( + mut egui_clipboard: ResMut, + mut subscribed_events: NonSendMut, +) { + let (tx, rx) = crossbeam_channel::unbounded(); + egui_clipboard.clipboard.event_receiver = Some(rx); + setup_clipboard_copy(&mut subscribed_events, tx.clone()); + setup_clipboard_cut(&mut subscribed_events, tx.clone()); + setup_clipboard_paste(&mut subscribed_events, tx); } -/// To get data from web events +/// Internal implementation of `[crate::EguiClipboard]` for web. #[derive(Default)] -pub struct WebChannel { - rx: Option>, +pub struct WebClipboard { + event_receiver: Option>, + contents: Option, } -impl WebChannel { - /// Only returns Some if user explicitly triggered an event. Should be called each frame to react as soon as the event is fired. - pub fn try_read_clipboard_event(&mut self) -> Option { - match &mut self.rx { - Some(rx) => { - if let Ok(data) = rx.try_recv() { - return Some(data); - } +/// Events sent by the `cut`/`copy`/`paste` listeners. +#[derive(Debug)] +pub enum WebClipboardEvent { + /// Is sent whenever the `cut` event listener is called. + Cut, + /// Is sent whenever the `copy` event listener is called. + Copy, + /// Is sent whenever the `paste` event listener is called, includes the plain text content. + Paste(String), +} + +impl WebClipboard { + /// Sets clipboard contents. + pub fn set_contents(&mut self, contents: &str) { + self.set_contents_internal(contents); + clipboard_copy(contents.to_owned()); + } + + /// Sets the internal buffer of clipboard contents. + /// This buffer is used to remember the contents of the last `paste` event. + pub fn set_contents_internal(&mut self, contents: &str) { + self.contents = Some(contents.to_owned()); + } + + /// Gets clipboard contents. Returns [`None`] if the `copy`/`cut` operation have never been invoked yet, + /// or the `paste` event has never been received yet. + pub fn get_contents(&mut self) -> Option { + self.contents.clone() + } + + /// Receives a clipboard event sent by the `copy`/`cut`/`paste` listeners. + pub fn try_receive_clipboard_event(&self) -> Option { + let Some(rx) = &self.event_receiver else { + log::error!("Web clipboard event receiver isn't initialized"); + return None; + }; + + match rx.try_recv() { + Ok(event) => Some(event), + Err(crossbeam_channel::TryRecvError::Empty) => None, + Err(err @ crossbeam_channel::TryRecvError::Disconnected) => { + log::error!("Failed to read a web clipboard event: {err:?}"); None } - None => None, } } } -/// User provided a string to paste -#[derive(Debug, Default)] -pub struct WebEventPaste(pub String); -/// User asked to cut -#[derive(Default)] -pub struct WebEventCut; -/// Used asked to copy +/// Stores the clipboard event listeners. #[derive(Default)] -pub struct WebEventCopy; +pub struct SubscribedEvents { + event_closures: Vec, +} -fn setup_clipboard_copy(clipboard_channel: &mut WebChannel) { - let (tx, rx): (Sender, Receiver) = crossbeam_channel::bounded(1); +impl SubscribedEvents { + /// Use this method to unsubscribe from all the clipboard events, this can be useful + /// for gracefully destroying a Bevy instance in a page. + pub fn unsubscribe_from_events(&mut self) { + let events_to_unsubscribe = std::mem::take(&mut self.event_closures); + + if !events_to_unsubscribe.is_empty() { + for event in events_to_unsubscribe { + if let Err(err) = event.target.remove_event_listener_with_callback( + event.event_name.as_str(), + event.closure.as_ref().unchecked_ref(), + ) { + log::error!( + "Failed to unsubscribe from event: {}", + string_from_js_value(&err) + ); + } + } + } + } +} + +struct EventClosure { + target: web_sys::EventTarget, + event_name: String, + closure: Closure, +} + +fn setup_clipboard_copy(subscribed_events: &mut SubscribedEvents, tx: Sender) { + let Some(window) = web_sys::window() else { + log::error!("Failed to add the \"copy\" listener: no window object"); + return; + }; + let Some(document) = window.document() else { + log::error!("Failed to add the \"copy\" listener: no document object"); + return; + }; let closure = Closure::::new(move |_event: web_sys::ClipboardEvent| { - let _ = tx.try_send(WebEventCopy); + if tx.send(WebClipboardEvent::Copy).is_err() { + log::error!("Failed to send a \"copy\" event: channel is disconnected"); + } }); let listener = closure.as_ref().unchecked_ref(); - web_sys::window() - .expect("Could not retrieve web_sys::window()") - .document() - .expect("Could not retrieve web_sys window's document") - .add_event_listener_with_callback("copy", listener) - .expect("Could not add copy event listener."); - closure.forget(); - *clipboard_channel = WebChannel:: { rx: Some(rx) }; + + if let Err(err) = document.add_event_listener_with_callback("copy", listener) { + log::error!( + "Failed to add the \"copy\" event listener: {}", + string_from_js_value(&err) + ); + drop(closure); + return; + }; + subscribed_events.event_closures.push(EventClosure { + target: >::as_ref(&document) + .clone(), + event_name: "copy".to_owned(), + closure, + }); } -fn setup_clipboard_cut(clipboard_channel: &mut WebChannel) { - let (tx, rx): (Sender, Receiver) = crossbeam_channel::bounded(1); +fn setup_clipboard_cut(subscribed_events: &mut SubscribedEvents, tx: Sender) { + let Some(window) = web_sys::window() else { + log::error!("Failed to add the \"cut\" listener: no window object"); + return; + }; + let Some(document) = window.document() else { + log::error!("Failed to add the \"cut\" listener: no document object"); + return; + }; let closure = Closure::::new(move |_event: web_sys::ClipboardEvent| { - let _ = tx.try_send(WebEventCut); + if tx.send(WebClipboardEvent::Cut).is_err() { + log::error!("Failed to send a \"cut\" event: channel is disconnected"); + } }); let listener = closure.as_ref().unchecked_ref(); - web_sys::window() - .expect("Could not retrieve web_sys::window()") - .document() - .expect("Could not retrieve web_sys window's document") - .add_event_listener_with_callback("cut", listener) - .expect("Could not add cut event listener."); - closure.forget(); - *clipboard_channel = WebChannel:: { rx: Some(rx) }; + + if let Err(err) = document.add_event_listener_with_callback("cut", listener) { + log::error!( + "Failed to add the \"cut\" event listener: {}", + string_from_js_value(&err) + ); + drop(closure); + return; + }; + subscribed_events.event_closures.push(EventClosure { + target: >::as_ref(&document) + .clone(), + event_name: "cut".to_owned(), + closure, + }); } -fn setup_clipboard_paste(clipboard_channel: &mut WebChannel) { - let (tx, rx): (Sender, Receiver) = crossbeam_channel::bounded(1); +fn setup_clipboard_paste(subscribed_events: &mut SubscribedEvents, tx: Sender) { + let Some(window) = web_sys::window() else { + log::error!("Failed to add the \"paste\" listener: no window object"); + return; + }; + let Some(document) = window.document() else { + log::error!("Failed to add the \"paste\" listener: no document object"); + return; + }; let closure = Closure::::new(move |event: web_sys::ClipboardEvent| { - match event - .clipboard_data() - .expect("could not get clipboard data.") - .get_data("text/plain") - { + let Some(clipboard_data) = event.clipboard_data() else { + log::error!("Failed to access clipboard data"); + return; + }; + match clipboard_data.get_data("text/plain") { Ok(data) => { - let _ = tx.try_send(WebEventPaste(data)); + if tx.send(WebClipboardEvent::Paste(data)).is_err() { + log::error!("Failed to send the \"paste\" event: channel is disconnected"); + } } - _ => { - error!("Not implemented."); + Err(err) => { + log::error!( + "Failed to read clipboard data: {}", + string_from_js_value(&err) + ); } } }); let listener = closure.as_ref().unchecked_ref(); - web_sys::window() - .expect("Could not retrieve web_sys::window()") - .document() - .expect("Could not retrieve web_sys window's document") - .add_event_listener_with_callback("paste", listener) - .expect("Could not add paste event listener."); - closure.forget(); - *clipboard_channel = WebChannel:: { rx: Some(rx) }; + + if let Err(err) = document.add_event_listener_with_callback("paste", listener) { + log::error!( + "Failed to add the \"paste\" event listener: {}", + string_from_js_value(&err) + ); + drop(closure); + return; + }; + subscribed_events.event_closures.push(EventClosure { + target: >::as_ref(&document) + .clone(), + event_name: "paste".to_owned(), + closure, + }); } -/// Puts argument string to the web clipboard -pub fn clipboard_copy(text: String) { +/// Sets contents of the clipboard via the Web API. +fn clipboard_copy(contents: String) { spawn_local(async move { - let window = web_sys::window().expect("window"); + let Some(window) = web_sys::window() else { + log::warn!("Failed to access the window object"); + return; + }; let nav = window.navigator(); - - let clipboard = nav.clipboard(); - match clipboard { - Some(a) => { - let p = a.write_text(&text); - let _result = wasm_bindgen_futures::JsFuture::from(p) - .await - .expect("clipboard populated"); - } - None => { - warn!("failed to write clipboard data"); - } + let Some(clipboard) = nav.clipboard() else { + log::warn!("Failed to access clipboard"); + return; }; + + let promise = clipboard.write_text(&contents); + if let Err(err) = wasm_bindgen_futures::JsFuture::from(promise).await { + log::warn!( + "Failed to write to clipboard: {}", + string_from_js_value(&err) + ); + } }); } + +fn string_from_js_value(value: &JsValue) -> String { + value.as_string().unwrap_or_else(|| format!("{value:#?}")) +}