From dd5904de157e0fc81994e26194a76dc95772f1e5 Mon Sep 17 00:00:00 2001 From: Ivy Date: Fri, 26 Apr 2024 16:47:13 -0700 Subject: [PATCH] mobile web keyboard port over from other fork --- Cargo.toml | 11 ++- src/lib.rs | 30 ++++++ src/systems.rs | 24 +++++ src/text_agent.rs | 245 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 src/text_agent.rs diff --git a/Cargo.toml b/Cargo.toml index 452bcf38a..c31a94935 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,10 +64,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" @@ -75,6 +78,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/src/lib.rs b/src/lib.rs index f57e1b5b4..453dd73db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,9 @@ pub mod egui_node; pub mod render_systems; /// Plugin systems. pub mod systems; +/// Mobile web keyboard hacky input support +#[cfg(target_arch = "wasm32")] +mod text_agent; /// Clipboard management for web #[cfg(all( feature = "manage_clipboard", @@ -687,6 +690,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 491e2d73e..057890458 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -20,6 +20,9 @@ use bevy::{ }; use std::marker::PhantomData; +#[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. @@ -254,6 +257,16 @@ pub fn process_input_system( } } + 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 Some(mut window_context) = context_params.window_context(event.window) else { continue; @@ -371,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 @@ -391,6 +405,7 @@ pub fn process_input_system( }); } bevy::input::touch::TouchPhase::Moved => { + window_context .egui_input .events @@ -423,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); + } +}