From 8f3b6c5ff30c7ed93d3c625ba3a46ca09263cc3b Mon Sep 17 00:00:00 2001 From: Ivy Date: Fri, 26 Apr 2024 16:47:13 -0700 Subject: [PATCH 01/16] mobile web keyboard port over from other fork --- Cargo.toml | 11 ++- README.md | 1 + src/lib.rs | 32 +++++- src/systems.rs | 25 +++++ src/text_agent.rs | 245 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 src/text_agent.rs diff --git a/Cargo.toml b/Cargo.toml index 24729c9ed..0f5daa680 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,10 +76,13 @@ web-sys = { version = "0.3.63", features = [ "Clipboard", "ClipboardEvent", "DataTransfer", - 'Document', - 'EventTarget', - "Window", + "Document", + "EventTarget", + "HtmlInputElement", + "InputEvent", "Navigator", + "TouchEvent", + "Window", ] } js-sys = "0.3.63" wasm-bindgen = "0.2.84" @@ -87,6 +90,8 @@ wasm-bindgen-futures = "0.4.36" console_log = "1.0.0" log = "0.4" crossbeam-channel = "0.5.8" +# https://doc.rust-lang.org/std/sync/struct.LazyLock.html is an experimental alt +once_cell = "1.19.0" [workspace] members = ["run-wasm"] diff --git a/README.md b/README.md index ecde33c56..6caaee2c8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ An example WASM project is live at [mvlabat.github.io/bevy_egui_web_showcase](ht - Opening URLs - Multiple windows support (see [./examples/two_windows.rs](https://github.com/mvlabat/bevy_egui/blob/v0.29.0/examples/two_windows.rs)) - Paint callback support (see [./examples/paint_callback.rs](https://github.com/mvlabat/bevy_egui/blob/v0.29.0/examples/paint_callback.rs)) +- Mobile web virtual keyboard (still rough support and only works without prevent_default_event_handling set to false on the WindowPlugin primary_window) `bevy_egui` can be compiled with using only `bevy`, `egui` and `bytemuck` as dependencies: `manage_clipboard` and `open_url` features, that require additional crates, can be disabled. diff --git a/src/lib.rs b/src/lib.rs index 60c7c9cdc..30187007c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,7 +68,10 @@ pub mod egui_render_to_texture_node; pub mod render_systems; /// Plugin systems. pub mod systems; -/// Clipboard management for web. +/// Mobile web keyboard hacky input support +#[cfg(target_arch = "wasm32")] +mod text_agent; +/// Clipboard management for web #[cfg(all( feature = "manage_clipboard", target_arch = "wasm32", @@ -714,6 +717,33 @@ impl Plugin for EguiPlugin { .after(InputSystem) .after(EguiSet::InitContexts), ); + #[cfg(target_arch = "wasm32")] + { + use bevy::prelude::Res; + app.init_resource::(); + + app.add_systems(PreStartup, |channel: Res| { + text_agent::install_text_agent(channel.sender.clone()).unwrap(); + }); + + app.add_systems( + PreStartup, + text_agent::virtual_keyboard_handler + .in_set(EguiSet::ProcessInput) + .after(process_input_system) + .after(InputSystem) + .after(EguiSet::InitContexts), + ); + + app.add_systems( + PreUpdate, + text_agent::propagate_text + .in_set(EguiSet::ProcessInput) + .after(process_input_system) + .after(InputSystem) + .after(EguiSet::InitContexts), + ); + } app.add_systems( PreUpdate, begin_frame_system diff --git a/src/systems.rs b/src/systems.rs index 07e1d85c0..9e7ecda33 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -26,6 +26,9 @@ use bevy::{ }; use std::{marker::PhantomData, time::Duration}; +#[cfg(target_arch = "wasm32")] +use crate::text_agent::VIRTUAL_KEYBOARD_GLOBAL; + #[allow(missing_docs)] #[derive(SystemParam)] // IMPORTANT: remember to add the logic to clear event readers to the `clear` method. @@ -240,6 +243,17 @@ pub fn process_input_system( }); } + #[cfg(target_arch = "wasm32")] + let mut editing_text = false; + #[cfg(target_arch = "wasm32")] + for context in context_params.contexts.iter() { + let platform_output = &context.egui_output.platform_output; + if platform_output.mutable_text_under_cursor || platform_output.ime.is_some() { + editing_text = true; + break; + } + } + for event in keyboard_input_events { let text_event_allowed = !command && !win || !*context_params.is_macos && ctrl && alt; let Some(mut window_context) = context_params.window_context(event.window) else { @@ -370,6 +384,7 @@ pub fn process_input_system( match event.phase { bevy::input::touch::TouchPhase::Started => { window_context.ctx.pointer_touch_id = Some(event.id); + // First move the pointer to the right location. window_context .egui_input @@ -390,6 +405,7 @@ pub fn process_input_system( }); } bevy::input::touch::TouchPhase::Moved => { + window_context .egui_input .events @@ -422,6 +438,15 @@ pub fn process_input_system( .push(egui::Event::PointerGone); } } + #[cfg(target_arch = "wasm32")] + match VIRTUAL_KEYBOARD_GLOBAL.lock() { + Ok(mut touch_info) => { + touch_info.editing_text = editing_text; + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; } } diff --git a/src/text_agent.rs b/src/text_agent.rs new file mode 100644 index 000000000..da3a82060 --- /dev/null +++ b/src/text_agent.rs @@ -0,0 +1,245 @@ +//! The text agent is an `` element used to trigger +//! mobile keyboard and IME input. + +use std::{cell::Cell, rc::Rc, sync::Mutex}; + +use bevy::{ + prelude::{EventWriter, Res, Resource}, + window::RequestRedraw, +}; +use crossbeam_channel::Sender; + +use once_cell::sync::Lazy; +use wasm_bindgen::prelude::*; + +use crate::systems::ContextSystemParams; + +static AGENT_ID: &str = "egui_text_agent"; + +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug, Default)] +pub struct VirtualTouchInfo { + pub editing_text: bool, +} + +pub static VIRTUAL_KEYBOARD_GLOBAL: Lazy> = + Lazy::new(|| Mutex::new(VirtualTouchInfo::default())); + +#[derive(Resource)] +pub struct TextAgentChannel { + pub sender: crossbeam_channel::Sender, + pub receiver: crossbeam_channel::Receiver, +} + +impl Default for TextAgentChannel { + fn default() -> Self { + let (sender, receiver) = crossbeam_channel::unbounded(); + Self { sender, receiver } + } +} + +pub fn propagate_text( + channel: Res, + mut context_params: ContextSystemParams, + mut redraw_event: EventWriter, +) { + for mut contexts in context_params.contexts.iter_mut() { + if contexts.egui_input.focused { + let mut redraw = false; + while let Ok(r) = channel.receiver.try_recv() { + redraw = true; + contexts.egui_input.events.push(r); + } + if redraw { + redraw_event.send(RequestRedraw); + } + break; + } + } +} + +fn is_mobile() -> Option { + const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"]; + + let user_agent = web_sys::window()?.navigator().user_agent().ok()?; + let is_mobile = MOBILE_DEVICE.iter().any(|&name| user_agent.contains(name)); + Some(is_mobile) +} + +/// Text event handler, +pub fn install_text_agent(sender: Sender) -> Result<(), JsValue> { + let window = web_sys::window().unwrap(); + let document = window.document().unwrap(); + let body = document.body().expect("document should have a body"); + let input = document + .create_element("input")? + .dyn_into::()?; + let input = std::rc::Rc::new(input); + input.set_id(AGENT_ID); + let is_composing = Rc::new(Cell::new(false)); + { + let style = input.style(); + // Transparent + style.set_property("opacity", "0").unwrap(); + // Hide under canvas + style.set_property("z-index", "-1").unwrap(); + + style.set_property("position", "absolute")?; + style.set_property("top", "0px")?; + style.set_property("left", "0px")?; + } + // Set size as small as possible, in case user may click on it. + input.set_size(1); + input.set_autofocus(true); + input.set_hidden(true); + + if let Some(true) = is_mobile() { + // keydown + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| { + if event.is_composing() || event.key_code() == 229 { + // https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ + return; + } + if "Backspace" == event.key() { + let _ = sender_clone.send(egui::Event::Key { + key: egui::Key::Backspace, + physical_key: None, + pressed: true, + modifiers: egui::Modifiers::NONE, + repeat: false, + }); + } + }) as Box); + document.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())?; + closure.forget(); + } + + if let Some(true) = is_mobile() { + // keyup + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| { + if "Backspace" == event.key() { + let _ = sender_clone.send(egui::Event::Key { + key: egui::Key::Backspace, + physical_key: None, + pressed: false, + modifiers: egui::Modifiers::NONE, + repeat: false, + }); + } + }) as Box); + document.add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref())?; + closure.forget(); + } + + { + // When IME is off + let input_clone = input.clone(); + let sender_clone = sender.clone(); + let is_composing = is_composing.clone(); + let on_input = Closure::wrap(Box::new(move |_event: web_sys::InputEvent| { + let text = input_clone.value(); + if !text.is_empty() && !is_composing.get() { + input_clone.set_value(""); + if text.len() == 1 { + let _ = sender_clone.send(egui::Event::Text(text.clone())); + } + } + }) as Box); + input.add_event_listener_with_callback("input", on_input.as_ref().unchecked_ref())?; + on_input.forget(); + } + + { + // When IME is off + let input_clone = input.clone(); + let sender_clone = sender.clone(); + let is_composing = is_composing.clone(); + let on_input = Closure::wrap(Box::new(move |_event: web_sys::InputEvent| { + let text = input_clone.value(); + if !text.is_empty() && !is_composing.get() { + input_clone.set_value(""); + if text.len() == 1 { + let _ = sender_clone.send(egui::Event::Text(text.clone())); + } + } + }) as Box); + input.add_event_listener_with_callback("input", on_input.as_ref().unchecked_ref())?; + on_input.forget(); + } + + body.append_child(&input)?; + + Ok(()) +} + +pub fn virtual_keyboard_handler() { + let document = web_sys::window().unwrap().document().unwrap(); + { + let closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| { + match VIRTUAL_KEYBOARD_GLOBAL.lock() { + Ok(touch_info) => { + update_text_agent(touch_info.editing_text); + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; + }) as Box); + document + .add_event_listener_with_callback("touchstart", closure.as_ref().unchecked_ref()) + .unwrap(); + closure.forget(); + } +} + +/// Focus or blur text agent to toggle mobile keyboard. +fn update_text_agent(editing_text: bool) { + use web_sys::HtmlInputElement; + + let window = match web_sys::window() { + Some(window) => window, + None => { + bevy::log::error!("No window found"); + return; + } + }; + let document = match window.document() { + Some(doc) => doc, + None => { + bevy::log::error!("No document found"); + return; + } + }; + let input: HtmlInputElement = match document.get_element_by_id(AGENT_ID) { + Some(ele) => ele, + None => { + bevy::log::error!("Agent element not found"); + return; + } + } + .dyn_into() + .unwrap(); + + let keyboard_closed = input.hidden(); + + if editing_text && keyboard_closed { + // open keyboard + input.set_hidden(false); + match input.focus().ok() { + Some(_) => {} + None => { + bevy::log::error!("Unable to set focus"); + } + } + } else { + // close keyboard + if input.blur().is_err() { + bevy::log::error!("Agent element not found"); + return; + } + + input.set_hidden(true); + } +} From 3a4f1b3ad026e96c072e15058d4e6ec1ef3f48c1 Mon Sep 17 00:00:00 2001 From: Ivy Date: Sat, 13 Jul 2024 15:56:27 -0700 Subject: [PATCH 02/16] cleanup and working but janky 0.14 --- Cargo.toml | 1 + src/systems.rs | 1 - src/text_agent.rs | 24 ++---------------------- 3 files changed, 3 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0f5daa680..d18bb3bfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ web-sys = { version = "0.3.63", features = [ "EventTarget", "HtmlInputElement", "InputEvent", + "KeyboardEvent", "Navigator", "TouchEvent", "Window", diff --git a/src/systems.rs b/src/systems.rs index 9e7ecda33..5a4fce1e4 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -405,7 +405,6 @@ pub fn process_input_system( }); } bevy::input::touch::TouchPhase::Moved => { - window_context .egui_input .events diff --git a/src/text_agent.rs b/src/text_agent.rs index da3a82060..882956bb9 100644 --- a/src/text_agent.rs +++ b/src/text_agent.rs @@ -1,7 +1,7 @@ //! The text agent is an `` element used to trigger //! mobile keyboard and IME input. -use std::{cell::Cell, rc::Rc, sync::Mutex}; +use std::sync::Mutex; use bevy::{ prelude::{EventWriter, Res, Resource}, @@ -76,7 +76,6 @@ pub fn install_text_agent(sender: Sender) -> Result<(), JsValue> { .dyn_into::()?; let input = std::rc::Rc::new(input); input.set_id(AGENT_ID); - let is_composing = Rc::new(Cell::new(false)); { let style = input.style(); // Transparent @@ -134,31 +133,12 @@ pub fn install_text_agent(sender: Sender) -> Result<(), JsValue> { } { - // When IME is off let input_clone = input.clone(); let sender_clone = sender.clone(); - let is_composing = is_composing.clone(); let on_input = Closure::wrap(Box::new(move |_event: web_sys::InputEvent| { let text = input_clone.value(); - if !text.is_empty() && !is_composing.get() { - input_clone.set_value(""); - if text.len() == 1 { - let _ = sender_clone.send(egui::Event::Text(text.clone())); - } - } - }) as Box); - input.add_event_listener_with_callback("input", on_input.as_ref().unchecked_ref())?; - on_input.forget(); - } - { - // When IME is off - let input_clone = input.clone(); - let sender_clone = sender.clone(); - let is_composing = is_composing.clone(); - let on_input = Closure::wrap(Box::new(move |_event: web_sys::InputEvent| { - let text = input_clone.value(); - if !text.is_empty() && !is_composing.get() { + if !text.is_empty() { input_clone.set_value(""); if text.len() == 1 { let _ = sender_clone.send(egui::Event::Text(text.clone())); From 52e6438131157094f842a15fcd6567ec409bbbb8 Mon Sep 17 00:00:00 2001 From: Ivy Date: Mon, 15 Jul 2024 14:39:28 -0700 Subject: [PATCH 03/16] janky SubscribedEvents --- src/lib.rs | 71 +++++++++++++++++++++++++++++++++++++++++--- src/text_agent.rs | 71 +++++++++++++++++++++++++++++++------------- src/web_clipboard.rs | 56 +++++++++++----------------------- 3 files changed, 134 insertions(+), 64 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 30187007c..51d8a3632 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,6 +101,7 @@ use bevy::{ app::Last, asset::{load_internal_asset, AssetEvent, Assets, Handle}, ecs::{event::EventReader, system::ResMut}, + prelude::NonSendMut, prelude::Shader, render::{ extract_component::{ExtractComponent, ExtractComponentPlugin}, @@ -132,6 +133,8 @@ use bevy::{ ))] use std::cell::{RefCell, RefMut}; +use wasm_bindgen::prelude::*; + /// Adds all Egui resources and render graph nodes. pub struct EguiPlugin; @@ -664,7 +667,12 @@ impl Plugin for EguiPlugin { target_arch = "wasm32", web_sys_unstable_apis ))] - world.init_non_send_resource::(); + world.init_non_send_resource::>(); + // virtual keyboard events for text_agent + #[cfg(target_arch = "wasm32")] + world.init_non_send_resource::>(); + #[cfg(target_arch = "wasm32")] + world.init_non_send_resource::>(); #[cfg(feature = "render")] world.init_resource::(); #[cfg(feature = "render")] @@ -722,9 +730,21 @@ impl Plugin for EguiPlugin { use bevy::prelude::Res; app.init_resource::(); - app.add_systems(PreStartup, |channel: Res| { - text_agent::install_text_agent(channel.sender.clone()).unwrap(); - }); + app.add_systems( + PreStartup, + |channel: Res, + mut subscribed_input_events: NonSendMut>, + mut subscribed_keyboard_events: NonSendMut< + SubscribedEvents, + >| { + text_agent::install_text_agent( + &mut subscribed_input_events, + &mut subscribed_keyboard_events, + channel.sender.clone(), + ) + .unwrap(); + }, + ); app.add_systems( PreStartup, @@ -976,6 +996,49 @@ fn free_egui_textures_system( } } +/// Stores the clipboard event listeners. + +pub struct SubscribedEvents { + event_closures: Vec>, +} + +impl Default for SubscribedEvents { + fn default() -> SubscribedEvents { + Self { + event_closures: vec![], + } + } +} + +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: {}", + crate::web_clipboard::string_from_js_value(&err) + ); + } + } + } + } +} + +struct EventClosure { + target: web_sys::EventTarget, + event_name: String, + // closure: Closure, + closure: wasm_bindgen::closure::Closure, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/text_agent.rs b/src/text_agent.rs index 882956bb9..d9417061d 100644 --- a/src/text_agent.rs +++ b/src/text_agent.rs @@ -12,7 +12,7 @@ use crossbeam_channel::Sender; use once_cell::sync::Lazy; use wasm_bindgen::prelude::*; -use crate::systems::ContextSystemParams; +use crate::{systems::ContextSystemParams, EventClosure, SubscribedEvents}; static AGENT_ID: &str = "egui_text_agent"; @@ -67,7 +67,11 @@ fn is_mobile() -> Option { } /// Text event handler, -pub fn install_text_agent(sender: Sender) -> Result<(), JsValue> { +pub fn install_text_agent( + subscribed_input_events: &mut SubscribedEvents, + subscribed_keyboard_events: &mut SubscribedEvents, + sender: Sender, +) -> Result<(), JsValue> { let window = web_sys::window().unwrap(); let document = window.document().unwrap(); let body = document.body().expect("document should have a body"); @@ -92,6 +96,30 @@ pub fn install_text_agent(sender: Sender) -> Result<(), JsValue> { input.set_autofocus(true); input.set_hidden(true); + { + let input_clone = input.clone(); + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |_event: web_sys::InputEvent| { + let text = input_clone.value(); + + if !text.is_empty() { + input_clone.set_value(""); + if text.len() == 1 { + let _ = sender_clone.send(egui::Event::Text(text.clone())); + } + } + }) as Box); + input.add_event_listener_with_callback("input", closure.as_ref().unchecked_ref())?; + subscribed_input_events.event_closures.push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_input".to_owned(), + closure, + }); + } + if let Some(true) = is_mobile() { // keydown let sender_clone = sender.clone(); @@ -111,7 +139,16 @@ pub fn install_text_agent(sender: Sender) -> Result<(), JsValue> { } }) as Box); document.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())?; - closure.forget(); + subscribed_keyboard_events + .event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_keydown".to_owned(), + closure, + }); } if let Some(true) = is_mobile() { @@ -129,24 +166,16 @@ pub fn install_text_agent(sender: Sender) -> Result<(), JsValue> { } }) as Box); document.add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref())?; - closure.forget(); - } - - { - let input_clone = input.clone(); - let sender_clone = sender.clone(); - let on_input = Closure::wrap(Box::new(move |_event: web_sys::InputEvent| { - let text = input_clone.value(); - - if !text.is_empty() { - input_clone.set_value(""); - if text.len() == 1 { - let _ = sender_clone.send(egui::Event::Text(text.clone())); - } - } - }) as Box); - input.add_event_listener_with_callback("input", on_input.as_ref().unchecked_ref())?; - on_input.forget(); + subscribed_keyboard_events + .event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_keyup".to_owned(), + closure, + }); } body.append_child(&input)?; diff --git a/src/web_clipboard.rs b/src/web_clipboard.rs index 34bef83ed..4ec9baf68 100644 --- a/src/web_clipboard.rs +++ b/src/web_clipboard.rs @@ -4,10 +4,12 @@ use crossbeam_channel::{Receiver, Sender}; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::spawn_local; +use crate::{EventClosure, SubscribedEvents}; + /// Startup system to initialize web clipboard events. pub fn startup_setup_web_events( mut egui_clipboard: ResMut, - mut subscribed_events: NonSendMut, + mut subscribed_events: NonSendMut>, ) { let (tx, rx) = crossbeam_channel::unbounded(); egui_clipboard.clipboard.event_receiver = Some(rx); @@ -71,41 +73,10 @@ impl WebClipboard { } } -/// Stores the clipboard event listeners. -#[derive(Default)] -pub struct SubscribedEvents { - event_closures: Vec, -} - -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) { +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; @@ -139,7 +110,10 @@ fn setup_clipboard_copy(subscribed_events: &mut SubscribedEvents, tx: Sender) { +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; @@ -173,7 +147,10 @@ fn setup_clipboard_cut(subscribed_events: &mut SubscribedEvents, tx: Sender) { +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; @@ -241,6 +218,7 @@ fn clipboard_copy(contents: String) { }); } -fn string_from_js_value(value: &JsValue) -> String { +/// Helper function for outputting a String from a JsValue +pub fn string_from_js_value(value: &JsValue) -> String { value.as_string().unwrap_or_else(|| format!("{value:#?}")) } From cd9b176d8c117a9949495de17289790e46cd518e Mon Sep 17 00:00:00 2001 From: Ivy Date: Mon, 15 Jul 2024 15:58:29 -0700 Subject: [PATCH 04/16] minor comment cleanup --- src/lib.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 51d8a3632..bf694053b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -996,8 +996,7 @@ fn free_egui_textures_system( } } -/// Stores the clipboard event listeners. - +/// Stores event listeners. pub struct SubscribedEvents { event_closures: Vec>, } @@ -1011,7 +1010,7 @@ impl Default for SubscribedEvents { } impl SubscribedEvents { - /// Use this method to unsubscribe from all the clipboard events, this can be useful + /// Use this method to unsubscribe from all stored 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); @@ -1035,7 +1034,6 @@ impl SubscribedEvents { struct EventClosure { target: web_sys::EventTarget, event_name: String, - // closure: Closure, closure: wasm_bindgen::closure::Closure, } From a597fcecd559acad043bf5c1b656acaf1676515d Mon Sep 17 00:00:00 2001 From: Ivy Date: Thu, 25 Jul 2024 11:25:18 -0700 Subject: [PATCH 05/16] fixing all target CI issues --- src/lib.rs | 16 ++++++++++++++-- src/web_clipboard.rs | 7 +------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index bf694053b..bc9531ba0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,12 +96,13 @@ use arboard::Clipboard; use bevy::ecs::query::Or; #[allow(unused_imports)] use bevy::log; +#[cfg(target_arch = "wasm32")] +use bevy::prelude::NonSendMut; #[cfg(feature = "render")] use bevy::{ app::Last, asset::{load_internal_asset, AssetEvent, Assets, Handle}, ecs::{event::EventReader, system::ResMut}, - prelude::NonSendMut, prelude::Shader, render::{ extract_component::{ExtractComponent, ExtractComponentPlugin}, @@ -133,6 +134,7 @@ use bevy::{ ))] use std::cell::{RefCell, RefMut}; +#[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; /// Adds all Egui resources and render graph nodes. @@ -996,11 +998,19 @@ fn free_egui_textures_system( } } +/// Helper function for outputting a String from a JsValue +#[cfg(target_arch = "wasm32")] +pub fn string_from_js_value(value: &JsValue) -> String { + value.as_string().unwrap_or_else(|| format!("{value:#?}")) +} + /// Stores event listeners. +#[cfg(target_arch = "wasm32")] pub struct SubscribedEvents { event_closures: Vec>, } +#[cfg(target_arch = "wasm32")] impl Default for SubscribedEvents { fn default() -> SubscribedEvents { Self { @@ -1009,6 +1019,7 @@ impl Default for SubscribedEvents { } } +#[cfg(target_arch = "wasm32")] impl SubscribedEvents { /// Use this method to unsubscribe from all stored events, this can be useful /// for gracefully destroying a Bevy instance in a page. @@ -1023,7 +1034,7 @@ impl SubscribedEvents { ) { log::error!( "Failed to unsubscribe from event: {}", - crate::web_clipboard::string_from_js_value(&err) + string_from_js_value(&err) ); } } @@ -1031,6 +1042,7 @@ impl SubscribedEvents { } } +#[cfg(target_arch = "wasm32")] struct EventClosure { target: web_sys::EventTarget, event_name: String, diff --git a/src/web_clipboard.rs b/src/web_clipboard.rs index 4ec9baf68..d9ffcf3a7 100644 --- a/src/web_clipboard.rs +++ b/src/web_clipboard.rs @@ -4,7 +4,7 @@ use crossbeam_channel::{Receiver, Sender}; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::spawn_local; -use crate::{EventClosure, SubscribedEvents}; +use crate::{string_from_js_value, EventClosure, SubscribedEvents}; /// Startup system to initialize web clipboard events. pub fn startup_setup_web_events( @@ -217,8 +217,3 @@ fn clipboard_copy(contents: String) { } }); } - -/// Helper function for outputting a String from a JsValue -pub fn string_from_js_value(value: &JsValue) -> String { - value.as_string().unwrap_or_else(|| format!("{value:#?}")) -} From ac030d8623dd1d044d05109f6b8e8dc3d2daffbb Mon Sep 17 00:00:00 2001 From: Ivy Date: Tue, 30 Jul 2024 15:42:45 -0700 Subject: [PATCH 06/16] steal 2 egui text_agent changes --- src/text_agent.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/text_agent.rs b/src/text_agent.rs index d9417061d..53d412cd9 100644 --- a/src/text_agent.rs +++ b/src/text_agent.rs @@ -79,17 +79,22 @@ pub fn install_text_agent( .create_element("input")? .dyn_into::()?; let input = std::rc::Rc::new(input); + input.set_type("text"); + input.set_autofocus(true); + input.set_attribute("autocapitalize", "off")?; input.set_id(AGENT_ID); { let style = input.style(); // Transparent - style.set_property("opacity", "0").unwrap(); - // Hide under canvas - style.set_property("z-index", "-1").unwrap(); - + style.set_property("background-color", "transparent")?; + style.set_property("border", "none")?; + style.set_property("outline", "none")?; + style.set_property("width", "1px")?; + style.set_property("height", "1px")?; + style.set_property("caret-color", "transparent")?; style.set_property("position", "absolute")?; - style.set_property("top", "0px")?; - style.set_property("left", "0px")?; + style.set_property("top", "0")?; + style.set_property("left", "0")?; } // Set size as small as possible, in case user may click on it. input.set_size(1); @@ -105,6 +110,8 @@ pub fn install_text_agent( if !text.is_empty() { input_clone.set_value(""); if text.len() == 1 { + input_clone.blur().ok(); + input_clone.focus().ok(); let _ = sender_clone.send(egui::Event::Text(text.clone())); } } @@ -153,8 +160,10 @@ pub fn install_text_agent( if let Some(true) = is_mobile() { // keyup + let input_clone = input.clone(); let sender_clone = sender.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| { + input_clone.focus().ok(); if "Backspace" == event.key() { let _ = sender_clone.send(egui::Event::Key { key: egui::Key::Backspace, From 579402d5123a946d9f118f39e9cd20911e11313c Mon Sep 17 00:00:00 2001 From: Ivy Date: Thu, 1 Aug 2024 11:40:09 -0700 Subject: [PATCH 07/16] prevent_default_event_handling and readme updates --- src/lib.rs | 80 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index bc9531ba0..e4912c25d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -730,41 +730,55 @@ impl Plugin for EguiPlugin { #[cfg(target_arch = "wasm32")] { use bevy::prelude::Res; - app.init_resource::(); - - app.add_systems( - PreStartup, - |channel: Res, - mut subscribed_input_events: NonSendMut>, - mut subscribed_keyboard_events: NonSendMut< - SubscribedEvents, - >| { - text_agent::install_text_agent( - &mut subscribed_input_events, - &mut subscribed_keyboard_events, - channel.sender.clone(), - ) - .unwrap(); - }, - ); - app.add_systems( - PreStartup, - text_agent::virtual_keyboard_handler - .in_set(EguiSet::ProcessInput) - .after(process_input_system) - .after(InputSystem) - .after(EguiSet::InitContexts), - ); + let maybe_window_plugin = app.get_added_plugins::(); + + if !maybe_window_plugin.is_empty() + && maybe_window_plugin[0].primary_window.is_some() + && maybe_window_plugin[0] + .primary_window + .as_ref() + .unwrap() + .prevent_default_event_handling + { + app.init_resource::(); + + app.add_systems( + PreStartup, + |channel: Res, + mut subscribed_input_events: NonSendMut< + SubscribedEvents, + >, + mut subscribed_keyboard_events: NonSendMut< + SubscribedEvents, + >| { + text_agent::install_text_agent( + &mut subscribed_input_events, + &mut subscribed_keyboard_events, + channel.sender.clone(), + ) + .unwrap(); + }, + ); - app.add_systems( - PreUpdate, - text_agent::propagate_text - .in_set(EguiSet::ProcessInput) - .after(process_input_system) - .after(InputSystem) - .after(EguiSet::InitContexts), - ); + app.add_systems( + PreStartup, + text_agent::virtual_keyboard_handler + .in_set(EguiSet::ProcessInput) + .after(process_input_system) + .after(InputSystem) + .after(EguiSet::InitContexts), + ); + + app.add_systems( + PreUpdate, + text_agent::propagate_text + .in_set(EguiSet::ProcessInput) + .after(process_input_system) + .after(InputSystem) + .after(EguiSet::InitContexts), + ); + } } app.add_systems( PreUpdate, From ee0792a0487f616dc715478d8f095f846096f74e Mon Sep 17 00:00:00 2001 From: Ivy Date: Sat, 3 Aug 2024 17:19:25 -0700 Subject: [PATCH 08/16] moving keyboard touch setting event to ended event --- src/systems.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/systems.rs b/src/systems.rs index 5a4fce1e4..35c9ddcff 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -248,7 +248,7 @@ pub fn process_input_system( #[cfg(target_arch = "wasm32")] for context in context_params.contexts.iter() { let platform_output = &context.egui_output.platform_output; - if platform_output.mutable_text_under_cursor || platform_output.ime.is_some() { + if platform_output.ime.is_some() || platform_output.mutable_text_under_cursor { editing_text = true; break; } @@ -428,6 +428,15 @@ pub fn process_input_system( .egui_input .events .push(egui::Event::PointerGone); + #[cfg(target_arch = "wasm32")] + match VIRTUAL_KEYBOARD_GLOBAL.lock() { + Ok(mut touch_info) => { + touch_info.editing_text = editing_text; + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; } bevy::input::touch::TouchPhase::Canceled => { window_context.ctx.pointer_touch_id = None; @@ -437,15 +446,6 @@ pub fn process_input_system( .push(egui::Event::PointerGone); } } - #[cfg(target_arch = "wasm32")] - match VIRTUAL_KEYBOARD_GLOBAL.lock() { - Ok(mut touch_info) => { - touch_info.editing_text = editing_text; - } - Err(poisoned) => { - let _unused = poisoned.into_inner(); - } - }; } } From 89a608aff3418b4fbf83f5c33b26d907918a8378 Mon Sep 17 00:00:00 2001 From: Ivy Date: Thu, 8 Aug 2024 17:10:29 -0700 Subject: [PATCH 09/16] more updates stolen from upstream and other edits --- src/lib.rs | 47 ++++++++++++++++---------------- src/systems.rs | 24 ++++++++++------- src/text_agent.rs | 69 ++++++++++++++++++++++++++++------------------- 3 files changed, 80 insertions(+), 60 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e4912c25d..558e0faea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -672,9 +672,11 @@ impl Plugin for EguiPlugin { world.init_non_send_resource::>(); // virtual keyboard events for text_agent #[cfg(target_arch = "wasm32")] + world.init_non_send_resource::>(); + #[cfg(target_arch = "wasm32")] world.init_non_send_resource::>(); #[cfg(target_arch = "wasm32")] - world.init_non_send_resource::>(); + world.init_non_send_resource::>(); #[cfg(feature = "render")] world.init_resource::(); #[cfg(feature = "render")] @@ -745,29 +747,28 @@ impl Plugin for EguiPlugin { app.add_systems( PreStartup, - |channel: Res, - mut subscribed_input_events: NonSendMut< + (|channel: Res, + mut subscribed_keyboard_events: NonSendMut< + SubscribedEvents, + >, + mut subscribed_input_events: NonSendMut< SubscribedEvents, >, - mut subscribed_keyboard_events: NonSendMut< - SubscribedEvents, + mut subscribed_touch_events: NonSendMut< + SubscribedEvents, >| { text_agent::install_text_agent( - &mut subscribed_input_events, &mut subscribed_keyboard_events, + &mut subscribed_input_events, + &mut subscribed_touch_events, channel.sender.clone(), ) .unwrap(); - }, - ); - - app.add_systems( - PreStartup, - text_agent::virtual_keyboard_handler - .in_set(EguiSet::ProcessInput) - .after(process_input_system) - .after(InputSystem) - .after(EguiSet::InitContexts), + }) + .in_set(EguiSet::ProcessInput) + .after(process_input_system) + .after(InputSystem) + .after(EguiSet::InitContexts), ); app.add_systems( @@ -1018,6 +1019,13 @@ pub fn string_from_js_value(value: &JsValue) -> String { value.as_string().unwrap_or_else(|| format!("{value:#?}")) } +#[cfg(target_arch = "wasm32")] +struct EventClosure { + target: web_sys::EventTarget, + event_name: String, + closure: wasm_bindgen::closure::Closure, +} + /// Stores event listeners. #[cfg(target_arch = "wasm32")] pub struct SubscribedEvents { @@ -1056,13 +1064,6 @@ impl SubscribedEvents { } } -#[cfg(target_arch = "wasm32")] -struct EventClosure { - target: web_sys::EventTarget, - event_name: String, - closure: wasm_bindgen::closure::Closure, -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/systems.rs b/src/systems.rs index 35c9ddcff..7c8333815 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -27,7 +27,7 @@ use bevy::{ use std::{marker::PhantomData, time::Duration}; #[cfg(target_arch = "wasm32")] -use crate::text_agent::VIRTUAL_KEYBOARD_GLOBAL; +use crate::text_agent::{is_mobile_safari, update_text_agent, VIRTUAL_KEYBOARD_GLOBAL}; #[allow(missing_docs)] #[derive(SystemParam)] @@ -428,15 +428,6 @@ pub fn process_input_system( .egui_input .events .push(egui::Event::PointerGone); - #[cfg(target_arch = "wasm32")] - match VIRTUAL_KEYBOARD_GLOBAL.lock() { - Ok(mut touch_info) => { - touch_info.editing_text = editing_text; - } - Err(poisoned) => { - let _unused = poisoned.into_inner(); - } - }; } bevy::input::touch::TouchPhase::Canceled => { window_context.ctx.pointer_touch_id = None; @@ -446,6 +437,19 @@ pub fn process_input_system( .push(egui::Event::PointerGone); } } + #[cfg(target_arch = "wasm32")] + if is_mobile_safari() { + match VIRTUAL_KEYBOARD_GLOBAL.lock() { + Ok(mut touch_info) => { + touch_info.editing_text = editing_text; + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; + } else { + update_text_agent(editing_text); + } } } diff --git a/src/text_agent.rs b/src/text_agent.rs index 53d412cd9..2db7d74d1 100644 --- a/src/text_agent.rs +++ b/src/text_agent.rs @@ -58,6 +58,19 @@ pub fn propagate_text( } } +// stolen from https://github.com/emilk/egui/pull/4855 +pub fn is_mobile_safari() -> bool { + (|| { + let user_agent = web_sys::window()?.navigator().user_agent().ok()?; + let is_ios = user_agent.contains("iPhone") + || user_agent.contains("iPad") + || user_agent.contains("iPod"); + let is_safari = user_agent.contains("Safari"); + Some(is_ios && is_safari) + })() + .unwrap_or(false) +} + fn is_mobile() -> Option { const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"]; @@ -68,8 +81,9 @@ fn is_mobile() -> Option { /// Text event handler, pub fn install_text_agent( - subscribed_input_events: &mut SubscribedEvents, subscribed_keyboard_events: &mut SubscribedEvents, + subscribed_input_events: &mut SubscribedEvents, + subscribed_touch_events: &mut SubscribedEvents, sender: Sender, ) -> Result<(), JsValue> { let window = web_sys::window().unwrap(); @@ -101,7 +115,7 @@ pub fn install_text_agent( input.set_autofocus(true); input.set_hidden(true); - { + if let Some(true) = is_mobile() { let input_clone = input.clone(); let sender_clone = sender.clone(); let closure = Closure::wrap(Box::new(move |_event: web_sys::InputEvent| { @@ -125,9 +139,32 @@ pub fn install_text_agent( event_name: "virtual_keyboard_input".to_owned(), closure, }); - } - if let Some(true) = is_mobile() { + // mobile safari doesn't let you set input focus outside of an event handler + if is_mobile_safari() { + let closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| { + match VIRTUAL_KEYBOARD_GLOBAL.lock() { + Ok(touch_info) => { + update_text_agent(touch_info.editing_text); + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; + }) as Box); + document + .add_event_listener_with_callback("touchend", closure.as_ref().unchecked_ref()) + .unwrap(); + subscribed_touch_events.event_closures.push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_touchend".to_owned(), + closure, + }); + } + // keydown let sender_clone = sender.clone(); let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| { @@ -156,9 +193,7 @@ pub fn install_text_agent( event_name: "virtual_keyboard_keydown".to_owned(), closure, }); - } - if let Some(true) = is_mobile() { // keyup let input_clone = input.clone(); let sender_clone = sender.clone(); @@ -192,28 +227,8 @@ pub fn install_text_agent( Ok(()) } -pub fn virtual_keyboard_handler() { - let document = web_sys::window().unwrap().document().unwrap(); - { - let closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| { - match VIRTUAL_KEYBOARD_GLOBAL.lock() { - Ok(touch_info) => { - update_text_agent(touch_info.editing_text); - } - Err(poisoned) => { - let _unused = poisoned.into_inner(); - } - }; - }) as Box); - document - .add_event_listener_with_callback("touchstart", closure.as_ref().unchecked_ref()) - .unwrap(); - closure.forget(); - } -} - /// Focus or blur text agent to toggle mobile keyboard. -fn update_text_agent(editing_text: bool) { +pub fn update_text_agent(editing_text: bool) { use web_sys::HtmlInputElement; let window = match web_sys::window() { From 25b965c904059ca091d745d85754c000e78d66e2 Mon Sep 17 00:00:00 2001 From: Ivy Date: Tue, 20 Aug 2024 11:52:02 -0700 Subject: [PATCH 10/16] removing global lock for LazyLock and better safari behavior with hack --- Cargo.toml | 3 +- src/lib.rs | 58 +++++++------- src/systems.rs | 21 ++--- src/text_agent.rs | 192 ++++++++++++++++++++++++++++++++-------------- 4 files changed, 171 insertions(+), 103 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d18bb3bfb..25a9fbdcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "bevy_egui" version = "0.29.0" +rust-version = "1.80.0" # needed for LazyLock https://doc.rust-lang.org/stable/std/sync/struct.LazyLock.html authors = ["mvlabat "] description = "A plugin for Egui integration into Bevy" license = "MIT" @@ -91,8 +92,6 @@ wasm-bindgen-futures = "0.4.36" console_log = "1.0.0" log = "0.4" crossbeam-channel = "0.5.8" -# https://doc.rust-lang.org/std/sync/struct.LazyLock.html is an experimental alt -once_cell = "1.19.0" [workspace] members = ["run-wasm"] diff --git a/src/lib.rs b/src/lib.rs index 558e0faea..0d8ead8e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,8 +96,6 @@ use arboard::Clipboard; use bevy::ecs::query::Or; #[allow(unused_imports)] use bevy::log; -#[cfg(target_arch = "wasm32")] -use bevy::prelude::NonSendMut; #[cfg(feature = "render")] use bevy::{ app::Last, @@ -137,6 +135,12 @@ use std::cell::{RefCell, RefMut}; #[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; +#[cfg(target_arch = "wasm32")] +use crate::text_agent::{ + install_text_agent, is_mobile_safari, process_safari_virtual_keyboard, propagate_text, + SafariVirtualKeyboardHack, TextAgentChannel, VirtualTouchInfo, +}; + /// Adds all Egui resources and render graph nodes. pub struct EguiPlugin; @@ -731,7 +735,7 @@ impl Plugin for EguiPlugin { ); #[cfg(target_arch = "wasm32")] { - use bevy::prelude::Res; + use std::sync::{LazyLock, Mutex}; let maybe_window_plugin = app.get_added_plugins::(); @@ -743,42 +747,42 @@ impl Plugin for EguiPlugin { .unwrap() .prevent_default_event_handling { - app.init_resource::(); + app.init_resource::(); app.add_systems( PreStartup, - (|channel: Res, - mut subscribed_keyboard_events: NonSendMut< - SubscribedEvents, - >, - mut subscribed_input_events: NonSendMut< - SubscribedEvents, - >, - mut subscribed_touch_events: NonSendMut< - SubscribedEvents, - >| { - text_agent::install_text_agent( - &mut subscribed_keyboard_events, - &mut subscribed_input_events, - &mut subscribed_touch_events, - channel.sender.clone(), - ) - .unwrap(); - }) - .in_set(EguiSet::ProcessInput) - .after(process_input_system) - .after(InputSystem) - .after(EguiSet::InitContexts), + install_text_agent + .in_set(EguiSet::ProcessInput) + .after(process_input_system) + .after(InputSystem) + .after(EguiSet::InitContexts), ); app.add_systems( PreUpdate, - text_agent::propagate_text + propagate_text .in_set(EguiSet::ProcessInput) .after(process_input_system) .after(InputSystem) .after(EguiSet::InitContexts), ); + + if is_mobile_safari() { + let (sender, receiver) = crossbeam_channel::unbounded(); + static TOUCH_INFO: LazyLock> = + LazyLock::new(|| Mutex::new(VirtualTouchInfo::default())); + + app.insert_resource(SafariVirtualKeyboardHack { + sender, + receiver, + touch_info: &TOUCH_INFO, + }); + + app.add_systems( + PostUpdate, + process_safari_virtual_keyboard.after(process_output_system), + ); + } } } app.add_systems( diff --git a/src/systems.rs b/src/systems.rs index 7c8333815..5b1198017 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -27,7 +27,7 @@ use bevy::{ use std::{marker::PhantomData, time::Duration}; #[cfg(target_arch = "wasm32")] -use crate::text_agent::{is_mobile_safari, update_text_agent, VIRTUAL_KEYBOARD_GLOBAL}; +use crate::text_agent::{is_mobile_safari, update_text_agent}; #[allow(missing_docs)] #[derive(SystemParam)] @@ -384,7 +384,6 @@ pub fn process_input_system( match event.phase { bevy::input::touch::TouchPhase::Started => { window_context.ctx.pointer_touch_id = Some(event.id); - // First move the pointer to the right location. window_context .egui_input @@ -428,6 +427,11 @@ pub fn process_input_system( .egui_input .events .push(egui::Event::PointerGone); + + #[cfg(target_arch = "wasm32")] + if !is_mobile_safari() { + update_text_agent(editing_text); + } } bevy::input::touch::TouchPhase::Canceled => { window_context.ctx.pointer_touch_id = None; @@ -437,19 +441,6 @@ pub fn process_input_system( .push(egui::Event::PointerGone); } } - #[cfg(target_arch = "wasm32")] - if is_mobile_safari() { - match VIRTUAL_KEYBOARD_GLOBAL.lock() { - Ok(mut touch_info) => { - touch_info.editing_text = editing_text; - } - Err(poisoned) => { - let _unused = poisoned.into_inner(); - } - }; - } else { - update_text_agent(editing_text); - } } } diff --git a/src/text_agent.rs b/src/text_agent.rs index 2db7d74d1..c6dd1c149 100644 --- a/src/text_agent.rs +++ b/src/text_agent.rs @@ -1,15 +1,14 @@ //! The text agent is an `` element used to trigger //! mobile keyboard and IME input. -use std::sync::Mutex; +use std::sync::{LazyLock, Mutex}; use bevy::{ - prelude::{EventWriter, Res, Resource}, + prelude::{EventWriter, NonSendMut, Res, Resource}, window::RequestRedraw, }; -use crossbeam_channel::Sender; +use crossbeam_channel::{unbounded, Receiver, Sender}; -use once_cell::sync::Lazy; use wasm_bindgen::prelude::*; use crate::{systems::ContextSystemParams, EventClosure, SubscribedEvents}; @@ -22,22 +21,49 @@ pub struct VirtualTouchInfo { pub editing_text: bool, } -pub static VIRTUAL_KEYBOARD_GLOBAL: Lazy> = - Lazy::new(|| Mutex::new(VirtualTouchInfo::default())); - #[derive(Resource)] pub struct TextAgentChannel { - pub sender: crossbeam_channel::Sender, - pub receiver: crossbeam_channel::Receiver, + pub sender: Sender, + pub receiver: Receiver, } impl Default for TextAgentChannel { fn default() -> Self { - let (sender, receiver) = crossbeam_channel::unbounded(); + let (sender, receiver) = unbounded(); Self { sender, receiver } } } +#[derive(Resource)] +pub struct SafariVirtualKeyboardHack { + pub sender: Sender, + pub receiver: Receiver, + pub touch_info: &'static LazyLock>, +} + +pub fn process_safari_virtual_keyboard( + context_params: ContextSystemParams, + safari_virtual_keyboard_hack: Res, +) { + for contexts in context_params.contexts.iter() { + while let Ok(true) = safari_virtual_keyboard_hack.receiver.try_recv() { + let platform_output = &contexts.egui_output.platform_output; + + if platform_output.ime.is_some() || platform_output.mutable_text_under_cursor { + match safari_virtual_keyboard_hack.touch_info.lock() { + Ok(mut touch_info) => { + touch_info.editing_text = true; + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; + break; + } + } + } +} + pub fn propagate_text( channel: Res, mut context_params: ContextSystemParams, @@ -58,63 +84,67 @@ pub fn propagate_text( } } -// stolen from https://github.com/emilk/egui/pull/4855 -pub fn is_mobile_safari() -> bool { - (|| { - let user_agent = web_sys::window()?.navigator().user_agent().ok()?; - let is_ios = user_agent.contains("iPhone") - || user_agent.contains("iPad") - || user_agent.contains("iPod"); - let is_safari = user_agent.contains("Safari"); - Some(is_ios && is_safari) - })() - .unwrap_or(false) -} - -fn is_mobile() -> Option { - const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"]; - - let user_agent = web_sys::window()?.navigator().user_agent().ok()?; - let is_mobile = MOBILE_DEVICE.iter().any(|&name| user_agent.contains(name)); - Some(is_mobile) -} - /// Text event handler, pub fn install_text_agent( - subscribed_keyboard_events: &mut SubscribedEvents, - subscribed_input_events: &mut SubscribedEvents, - subscribed_touch_events: &mut SubscribedEvents, - sender: Sender, -) -> Result<(), JsValue> { + mut subscribed_keyboard_events: NonSendMut>, + mut subscribed_input_events: NonSendMut>, + mut subscribed_touch_events: NonSendMut>, + text_agent_channel: Res, + safari_virtual_keyboard_hack: Res, +) { let window = web_sys::window().unwrap(); let document = window.document().unwrap(); let body = document.body().expect("document should have a body"); let input = document - .create_element("input")? - .dyn_into::()?; + .create_element("input") + .expect("failed to create input") + .dyn_into::() + .expect("failed input type coercion"); let input = std::rc::Rc::new(input); input.set_type("text"); input.set_autofocus(true); - input.set_attribute("autocapitalize", "off")?; + input + .set_attribute("autocapitalize", "off") + .expect("failed to turn off autocapitalize"); input.set_id(AGENT_ID); { let style = input.style(); // Transparent - style.set_property("background-color", "transparent")?; - style.set_property("border", "none")?; - style.set_property("outline", "none")?; - style.set_property("width", "1px")?; - style.set_property("height", "1px")?; - style.set_property("caret-color", "transparent")?; - style.set_property("position", "absolute")?; - style.set_property("top", "0")?; - style.set_property("left", "0")?; + style + .set_property("background-color", "transparent") + .expect("failed to set text_agent css properties"); + style + .set_property("border", "none") + .expect("failed to set text_agent css properties"); + style + .set_property("outline", "none") + .expect("failed to set text_agent css properties"); + style + .set_property("width", "1px") + .expect("failed to set text_agent css properties"); + style + .set_property("height", "1px") + .expect("failed to set text_agent css properties"); + style + .set_property("caret-color", "transparent") + .expect("failed to set text_agent css properties"); + style + .set_property("position", "absolute") + .expect("failed to set text_agent css properties"); + style + .set_property("top", "0") + .expect("failed to set text_agent css properties"); + style + .set_property("left", "0") + .expect("failed to set text_agent css properties"); } // Set size as small as possible, in case user may click on it. input.set_size(1); input.set_autofocus(true); input.set_hidden(true); + let sender = text_agent_channel.sender.clone(); + if let Some(true) = is_mobile() { let input_clone = input.clone(); let sender_clone = sender.clone(); @@ -130,7 +160,9 @@ pub fn install_text_agent( } } }) as Box); - input.add_event_listener_with_callback("input", closure.as_ref().unchecked_ref())?; + input + .add_event_listener_with_callback("input", closure.as_ref().unchecked_ref()) + .expect("failed to create input listener"); subscribed_input_events.event_closures.push(EventClosure { target: >::as_ref( &document, @@ -142,8 +174,25 @@ pub fn install_text_agent( // mobile safari doesn't let you set input focus outside of an event handler if is_mobile_safari() { + let safari_sender = safari_virtual_keyboard_hack.sender.clone(); + let closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| { + let _ = safari_sender.send(true); + }) as Box); + document + .add_event_listener_with_callback("touchstart", closure.as_ref().unchecked_ref()) + .expect("failed to create touchstart listener"); + subscribed_touch_events.event_closures.push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_touchstart".to_owned(), + closure, + }); + + let safari_touch_info_lock = safari_virtual_keyboard_hack.touch_info; let closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| { - match VIRTUAL_KEYBOARD_GLOBAL.lock() { + match safari_touch_info_lock.lock() { Ok(touch_info) => { update_text_agent(touch_info.editing_text); } @@ -154,7 +203,7 @@ pub fn install_text_agent( }) as Box); document .add_event_listener_with_callback("touchend", closure.as_ref().unchecked_ref()) - .unwrap(); + .expect("failed to create touchend listener"); subscribed_touch_events.event_closures.push(EventClosure { target: >::as_ref( &document, @@ -182,7 +231,9 @@ pub fn install_text_agent( }); } }) as Box); - document.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())?; + document + .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref()) + .expect("failed to create keydown listener"); subscribed_keyboard_events .event_closures .push(EventClosure { @@ -209,7 +260,9 @@ pub fn install_text_agent( }); } }) as Box); - document.add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref())?; + document + .add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref()) + .expect("failed to create keyup listener"); subscribed_keyboard_events .event_closures .push(EventClosure { @@ -222,9 +275,7 @@ pub fn install_text_agent( }); } - body.append_child(&input)?; - - Ok(()) + body.append_child(&input).expect("failed to append to body"); } /// Focus or blur text agent to toggle mobile keyboard. @@ -255,9 +306,10 @@ pub fn update_text_agent(editing_text: bool) { .dyn_into() .unwrap(); - let keyboard_closed = input.hidden(); + let keyboard_open = !input.hidden(); + bevy::log::error!("updating keyboard with {}", editing_text); - if editing_text && keyboard_closed { + if editing_text { // open keyboard input.set_hidden(false); match input.focus().ok() { @@ -266,7 +318,8 @@ pub fn update_text_agent(editing_text: bool) { bevy::log::error!("Unable to set focus"); } } - } else { + } else if !editing_text && keyboard_open { + bevy::log::error!("closingg keyboard"); // close keyboard if input.blur().is_err() { bevy::log::error!("Agent element not found"); @@ -276,3 +329,24 @@ pub fn update_text_agent(editing_text: bool) { input.set_hidden(true); } } + +// stolen from https://github.com/emilk/egui/pull/4855 +pub fn is_mobile_safari() -> bool { + (|| { + let user_agent = web_sys::window()?.navigator().user_agent().ok()?; + let is_ios = user_agent.contains("iPhone") + || user_agent.contains("iPad") + || user_agent.contains("iPod"); + let is_safari = user_agent.contains("Safari"); + Some(is_ios && is_safari) + })() + .unwrap_or(false) +} + +fn is_mobile() -> Option { + const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"]; + + let user_agent = web_sys::window()?.navigator().user_agent().ok()?; + let is_mobile = MOBILE_DEVICE.iter().any(|&name| user_agent.contains(name)); + Some(is_mobile) +} From 241ceac8179bf4df63e5088ae056ff59cbef3c50 Mon Sep 17 00:00:00 2001 From: Ivy Date: Tue, 20 Aug 2024 12:15:42 -0700 Subject: [PATCH 11/16] clipboard error change --- src/web_clipboard.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/web_clipboard.rs b/src/web_clipboard.rs index d9ffcf3a7..a355e0267 100644 --- a/src/web_clipboard.rs +++ b/src/web_clipboard.rs @@ -208,7 +208,9 @@ fn clipboard_copy(contents: String) { let clipboard = window.navigator().clipboard(); - let promise = clipboard.write_text(&contents); + let promise = clipboard + .expect("failed to get promise") + .write_text(&contents); if let Err(err) = wasm_bindgen_futures::JsFuture::from(promise).await { log::warn!( "Failed to write to clipboard: {}", From 3423a6ce0fd02dc054a5d921d923d1bb7941ed44 Mon Sep 17 00:00:00 2001 From: Ivy Date: Tue, 20 Aug 2024 12:27:19 -0700 Subject: [PATCH 12/16] adding lock back into processInput --- src/systems.rs | 14 ++++++++++++-- src/web_clipboard.rs | 4 +--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/systems.rs b/src/systems.rs index 5b1198017..36be2e78a 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -27,7 +27,7 @@ use bevy::{ use std::{marker::PhantomData, time::Duration}; #[cfg(target_arch = "wasm32")] -use crate::text_agent::{is_mobile_safari, update_text_agent}; +use crate::text_agent::{is_mobile_safari, update_text_agent, SafariVirtualKeyboardHack}; #[allow(missing_docs)] #[derive(SystemParam)] @@ -111,6 +111,7 @@ pub fn process_input_system( mut context_params: ContextSystemParams, egui_settings: Res, time: Res>, + safari_virtual_keyboard_hack: Res, ) { // Test whether it's macOS or OS X. use std::sync::Once; @@ -429,7 +430,16 @@ pub fn process_input_system( .push(egui::Event::PointerGone); #[cfg(target_arch = "wasm32")] - if !is_mobile_safari() { + if is_mobile_safari() { + match safari_virtual_keyboard_hack.touch_info.lock() { + Ok(touch_info) => { + update_text_agent(touch_info.editing_text); + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; + } else { update_text_agent(editing_text); } } diff --git a/src/web_clipboard.rs b/src/web_clipboard.rs index a355e0267..d9ffcf3a7 100644 --- a/src/web_clipboard.rs +++ b/src/web_clipboard.rs @@ -208,9 +208,7 @@ fn clipboard_copy(contents: String) { let clipboard = window.navigator().clipboard(); - let promise = clipboard - .expect("failed to get promise") - .write_text(&contents); + let promise = clipboard.write_text(&contents); if let Err(err) = wasm_bindgen_futures::JsFuture::from(promise).await { log::warn!( "Failed to write to clipboard: {}", From 087a8d33e7dad93b85931ecb12fab18a5bbb0826 Mon Sep 17 00:00:00 2001 From: Ivy Date: Tue, 20 Aug 2024 12:40:01 -0700 Subject: [PATCH 13/16] fixing resource missing bug --- src/lib.rs | 20 ++++++++++---------- src/systems.rs | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0d8ead8e0..f8ac71084 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -749,6 +749,16 @@ impl Plugin for EguiPlugin { { app.init_resource::(); + let (sender, receiver) = crossbeam_channel::unbounded(); + static TOUCH_INFO: LazyLock> = + LazyLock::new(|| Mutex::new(VirtualTouchInfo::default())); + + app.insert_resource(SafariVirtualKeyboardHack { + sender, + receiver, + touch_info: &TOUCH_INFO, + }); + app.add_systems( PreStartup, install_text_agent @@ -768,16 +778,6 @@ impl Plugin for EguiPlugin { ); if is_mobile_safari() { - let (sender, receiver) = crossbeam_channel::unbounded(); - static TOUCH_INFO: LazyLock> = - LazyLock::new(|| Mutex::new(VirtualTouchInfo::default())); - - app.insert_resource(SafariVirtualKeyboardHack { - sender, - receiver, - touch_info: &TOUCH_INFO, - }); - app.add_systems( PostUpdate, process_safari_virtual_keyboard.after(process_output_system), diff --git a/src/systems.rs b/src/systems.rs index 36be2e78a..e9f866852 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -111,7 +111,7 @@ pub fn process_input_system( mut context_params: ContextSystemParams, egui_settings: Res, time: Res>, - safari_virtual_keyboard_hack: Res, + #[cfg(target_arch = "wasm32")] safari_virtual_keyboard_hack: Res, ) { // Test whether it's macOS or OS X. use std::sync::Once; From 4f3f19d3e898998f3c88200ed62c2e73cd1ebe0a Mon Sep 17 00:00:00 2001 From: Ivy Date: Tue, 20 Aug 2024 13:29:11 -0700 Subject: [PATCH 14/16] more bug fixes and cleanup --- src/systems.rs | 14 ++------------ src/text_agent.rs | 23 +++++++++++------------ 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/systems.rs b/src/systems.rs index e9f866852..5b1198017 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -27,7 +27,7 @@ use bevy::{ use std::{marker::PhantomData, time::Duration}; #[cfg(target_arch = "wasm32")] -use crate::text_agent::{is_mobile_safari, update_text_agent, SafariVirtualKeyboardHack}; +use crate::text_agent::{is_mobile_safari, update_text_agent}; #[allow(missing_docs)] #[derive(SystemParam)] @@ -111,7 +111,6 @@ pub fn process_input_system( mut context_params: ContextSystemParams, egui_settings: Res, time: Res>, - #[cfg(target_arch = "wasm32")] safari_virtual_keyboard_hack: Res, ) { // Test whether it's macOS or OS X. use std::sync::Once; @@ -430,16 +429,7 @@ pub fn process_input_system( .push(egui::Event::PointerGone); #[cfg(target_arch = "wasm32")] - if is_mobile_safari() { - match safari_virtual_keyboard_hack.touch_info.lock() { - Ok(touch_info) => { - update_text_agent(touch_info.editing_text); - } - Err(poisoned) => { - let _unused = poisoned.into_inner(); - } - }; - } else { + if !is_mobile_safari() { update_text_agent(editing_text); } } diff --git a/src/text_agent.rs b/src/text_agent.rs index c6dd1c149..e6c3df861 100644 --- a/src/text_agent.rs +++ b/src/text_agent.rs @@ -48,18 +48,19 @@ pub fn process_safari_virtual_keyboard( for contexts in context_params.contexts.iter() { while let Ok(true) = safari_virtual_keyboard_hack.receiver.try_recv() { let platform_output = &contexts.egui_output.platform_output; + let mut editing_text = false; if platform_output.ime.is_some() || platform_output.mutable_text_under_cursor { - match safari_virtual_keyboard_hack.touch_info.lock() { - Ok(mut touch_info) => { - touch_info.editing_text = true; - } - Err(poisoned) => { - let _unused = poisoned.into_inner(); - } - }; - break; + editing_text = true; } + match safari_virtual_keyboard_hack.touch_info.lock() { + Ok(mut touch_info) => { + touch_info.editing_text = editing_text; + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; } } } @@ -307,7 +308,6 @@ pub fn update_text_agent(editing_text: bool) { .unwrap(); let keyboard_open = !input.hidden(); - bevy::log::error!("updating keyboard with {}", editing_text); if editing_text { // open keyboard @@ -318,8 +318,7 @@ pub fn update_text_agent(editing_text: bool) { bevy::log::error!("Unable to set focus"); } } - } else if !editing_text && keyboard_open { - bevy::log::error!("closingg keyboard"); + } else if keyboard_open { // close keyboard if input.blur().is_err() { bevy::log::error!("Agent element not found"); From e55668ae40307f67a7aa352db0b73d0733e18ba5 Mon Sep 17 00:00:00 2001 From: Ivy Date: Fri, 30 Aug 2024 12:06:16 -0700 Subject: [PATCH 15/16] fixing ime via CompositionEvent --- Cargo.toml | 1 + src/lib.rs | 2 ++ src/text_agent.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 25a9fbdcc..fc5a7a9b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ winit = "0.30" web-sys = { version = "0.3.63", features = [ "Clipboard", "ClipboardEvent", + "CompositionEvent", "DataTransfer", "Document", "EventTarget", diff --git a/src/lib.rs b/src/lib.rs index f8ac71084..41d67bbf2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -676,6 +676,8 @@ impl Plugin for EguiPlugin { world.init_non_send_resource::>(); // virtual keyboard events for text_agent #[cfg(target_arch = "wasm32")] + world.init_non_send_resource::>(); + #[cfg(target_arch = "wasm32")] world.init_non_send_resource::>(); #[cfg(target_arch = "wasm32")] world.init_non_send_resource::>(); diff --git a/src/text_agent.rs b/src/text_agent.rs index e6c3df861..9ae6a561e 100644 --- a/src/text_agent.rs +++ b/src/text_agent.rs @@ -87,6 +87,7 @@ pub fn propagate_text( /// Text event handler, pub fn install_text_agent( + mut subscribed_composition_events: NonSendMut>, mut subscribed_keyboard_events: NonSendMut>, mut subscribed_input_events: NonSendMut>, mut subscribed_touch_events: NonSendMut>, @@ -173,6 +174,68 @@ pub fn install_text_agent( closure, }); + let input_clone = input.clone(); + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |_event: web_sys::CompositionEvent| { + input_clone.set_value(""); + let _ = sender_clone.send(egui::Event::Ime(egui::ImeEvent::Enabled)); + }) as Box); + input + .add_event_listener_with_callback("compositionstart", closure.as_ref().unchecked_ref()) + .expect("failed to create compositionstart listener"); + subscribed_composition_events + .event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_compositionstart".to_owned(), + closure, + }); + + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |event: web_sys::CompositionEvent| { + let Some(text) = event.data() else { return }; + let event = egui::Event::Ime(egui::ImeEvent::Preedit(text)); + let _ = sender_clone.send(event); + }) as Box); + input + .add_event_listener_with_callback("compositionupdate", closure.as_ref().unchecked_ref()) + .expect("failed to create compositionupdate listener"); + subscribed_composition_events + .event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_compositionupdate".to_owned(), + closure, + }); + + let input_clone = input.clone(); + let sender_clone = sender.clone(); + let closure = Closure::wrap(Box::new(move |event: web_sys::CompositionEvent| { + let Some(text) = event.data() else { return }; + input_clone.set_value(""); + let event = egui::Event::Ime(egui::ImeEvent::Commit(text)); + let _ = sender_clone.send(event); + }) as Box); + input + .add_event_listener_with_callback("compositionend", closure.as_ref().unchecked_ref()) + .expect("failed to create compositionend listener"); + subscribed_composition_events + .event_closures + .push(EventClosure { + target: >::as_ref( + &document, + ) + .clone(), + event_name: "virtual_keyboard_compositionend".to_owned(), + closure, + }); + // mobile safari doesn't let you set input focus outside of an event handler if is_mobile_safari() { let safari_sender = safari_virtual_keyboard_hack.sender.clone(); From 1b0fc8a20e50ff0c47757f4485245b859c32c43d Mon Sep 17 00:00:00 2001 From: Ivy Date: Tue, 3 Sep 2024 14:36:00 -0700 Subject: [PATCH 16/16] fixing ime cursor not advancing with japanese input --- src/text_agent.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/text_agent.rs b/src/text_agent.rs index 9ae6a561e..8a76d981e 100644 --- a/src/text_agent.rs +++ b/src/text_agent.rs @@ -150,10 +150,10 @@ pub fn install_text_agent( if let Some(true) = is_mobile() { let input_clone = input.clone(); let sender_clone = sender.clone(); - let closure = Closure::wrap(Box::new(move |_event: web_sys::InputEvent| { + let closure = Closure::wrap(Box::new(move |event: web_sys::InputEvent| { let text = input_clone.value(); - if !text.is_empty() { + if !text.is_empty() && !event.is_composing() { input_clone.set_value(""); if text.len() == 1 { input_clone.blur().ok();