Skip to content

Commit

Permalink
mobile web keyboard port over from other fork
Browse files Browse the repository at this point in the history
  • Loading branch information
Ivy committed May 4, 2024
1 parent 6021f22 commit dd5904d
Show file tree
Hide file tree
Showing 4 changed files with 307 additions and 3 deletions.
11 changes: 8 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,22 @@ 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"
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"]
30 changes: 30 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -687,6 +690,33 @@ impl Plugin for EguiPlugin {
.after(InputSystem)
.after(EguiSet::InitContexts),
);
#[cfg(target_arch = "wasm32")]
{
use bevy::prelude::Res;
app.init_resource::<text_agent::TextAgentChannel>();

app.add_systems(PreStartup, |channel: Res<text_agent::TextAgentChannel>| {
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
Expand Down
24 changes: 24 additions & 0 deletions src/systems.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -391,6 +405,7 @@ pub fn process_input_system(
});
}
bevy::input::touch::TouchPhase::Moved => {

window_context
.egui_input
.events
Expand Down Expand Up @@ -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();
}
};
}
}

Expand Down
245 changes: 245 additions & 0 deletions src/text_agent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
//! The text agent is an `<input>` 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<Mutex<VirtualTouchInfo>> =
Lazy::new(|| Mutex::new(VirtualTouchInfo::default()));

#[derive(Resource)]
pub struct TextAgentChannel {
pub sender: crossbeam_channel::Sender<egui::Event>,
pub receiver: crossbeam_channel::Receiver<egui::Event>,
}

impl Default for TextAgentChannel {
fn default() -> Self {
let (sender, receiver) = crossbeam_channel::unbounded();
Self { sender, receiver }
}
}

pub fn propagate_text(
channel: Res<TextAgentChannel>,
mut context_params: ContextSystemParams,
mut redraw_event: EventWriter<RequestRedraw>,
) {
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<bool> {
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<egui::Event>) -> 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::<web_sys::HtmlInputElement>()?;
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<dyn FnMut(_)>);
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<dyn FnMut(_)>);
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<dyn FnMut(_)>);
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<dyn FnMut(_)>);
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<dyn FnMut(_)>);
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);
}
}

0 comments on commit dd5904d

Please sign in to comment.