diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 872921591ac..b09ebfc0623 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -2,7 +2,7 @@ use egui::TexturesDelta; use crate::{epi, App}; -use super::{now_sec, web_painter::WebPainter, NeedRepaint}; +use super::{now_sec, text_agent::TextAgent, web_painter::WebPainter, NeedRepaint}; pub struct AppRunner { #[allow(dead_code)] @@ -14,7 +14,7 @@ pub struct AppRunner { app: Box, pub(crate) needs_repaint: std::sync::Arc, last_save_time: f64, - pub(crate) ime: Option, + pub(crate) text_agent: TextAgent, pub(crate) mutable_text_under_cursor: bool, // Output for the last run: @@ -35,6 +35,7 @@ impl AppRunner { canvas_id: &str, web_options: crate::WebOptions, app_creator: epi::AppCreator, + text_agent: TextAgent, ) -> Result { let painter = super::ActiveWebPainter::new(canvas_id, &web_options).await?; @@ -118,7 +119,7 @@ impl AppRunner { app, needs_repaint, last_save_time: now_sec(), - ime: None, + text_agent, mutable_text_under_cursor: false, textures_delta: Default::default(), clipped_primitives: None, @@ -269,9 +270,11 @@ impl AppRunner { self.mutable_text_under_cursor = mutable_text_under_cursor; - if self.ime != ime { - super::text_agent::move_text_cursor(ime, self.canvas()); - self.ime = ime; + if let Err(err) = self.text_agent.move_to(ime, self.canvas()) { + log::error!( + "failed to update text agent position: {}", + super::string_from_js_value(&err) + ); } } } diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index 33d36c356d7..b2f371bde3e 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -94,8 +94,8 @@ pub(crate) fn install_document_events(runner_ref: &WebRunner) -> Result<(), JsVa if !modifiers.ctrl && !modifiers.command && !should_ignore_key(&key) - // When text agent is shown, it sends text event instead. - && text_agent::text_agent().hidden() + // When text agent is focused, it is responsible for handling input events + && !runner.text_agent.has_focus() { runner.input.raw.events.push(egui::Event::Text(key)); } @@ -375,10 +375,12 @@ pub(crate) fn install_canvas_events(runner_ref: &WebRunner) -> Result<(), JsValu // event callback, which is why we run the app logic here and now: runner.logic(); + runner + .text_agent + .set_focus(runner.mutable_text_under_cursor); + // Make sure we paint the output of the above logic call asap: runner.needs_repaint.repaint_asap(); - - text_agent::update_text_agent(runner); } event.stop_propagation(); event.prevent_default(); @@ -467,13 +469,15 @@ pub(crate) fn install_canvas_events(runner_ref: &WebRunner) -> Result<(), JsValu runner.input.raw.events.push(egui::Event::PointerGone); push_touches(runner, egui::TouchPhase::End, &event); + + runner + .text_agent + .set_focus(runner.mutable_text_under_cursor); + runner.needs_repaint.repaint_asap(); event.stop_propagation(); event.prevent_default(); } - - // Finally, focus or blur text agent to toggle mobile keyboard: - text_agent::update_text_agent(runner); }, )?; diff --git a/crates/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs index f98bbc1ed01..1963795f126 100644 --- a/crates/eframe/src/web/mod.rs +++ b/crates/eframe/src/web/mod.rs @@ -53,6 +53,13 @@ pub(crate) fn string_from_js_value(value: &JsValue) -> String { value.as_string().unwrap_or_else(|| format!("{value:#?}")) } +pub(crate) fn focused_element() -> Option { + web_sys::window()? + .document()? + .active_element() + .and_then(|v| v.dyn_into().ok()) +} + /// Current time in seconds (since undefined point in time). /// /// Monotonically increasing. diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 5cfec81bf30..9ec78968328 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -1,237 +1,177 @@ -//! The text agent is an `` element used to trigger -//! mobile keyboard and IME input. -//! -use std::{cell::Cell, rc::Rc}; +//! The text agent is a hidden `` element used to capture +//! IME and mobile keyboard input events. + +use std::cell::Cell; use wasm_bindgen::prelude::*; use super::{AppRunner, WebRunner}; -static AGENT_ID: &str = "egui_text_agent"; - -pub fn text_agent() -> web_sys::HtmlInputElement { - web_sys::window() - .unwrap() - .document() - .unwrap() - .get_element_by_id(AGENT_ID) - .unwrap() - .dyn_into() - .unwrap() +pub struct TextAgent { + input: web_sys::HtmlInputElement, + prev_ime_output: Cell>, } -/// Text event handler, -pub fn install_text_agent(runner_ref: &WebRunner) -> 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(); - } - // 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); - - // When IME is off - runner_ref.add_event_listener(&input, "input", { - let input_clone = input.clone(); - let is_composing = is_composing.clone(); - - move |_event: web_sys::InputEvent, runner| { - let text = input_clone.value(); - if !text.is_empty() && !is_composing.get() { - input_clone.set_value(""); - runner.input.raw.events.push(egui::Event::Text(text)); - runner.needs_repaint.repaint_asap(); - } - } - })?; +impl TextAgent { + /// Attach the agent to the document. + pub fn attach(runner_ref: &WebRunner) -> Result { + let document = web_sys::window().unwrap().document().unwrap(); - { - // When IME is on, handle composition event - runner_ref.add_event_listener(&input, "compositionstart", { - let input_clone = input.clone(); - let is_composing = is_composing.clone(); + // create an `` element + let input = document + .create_element("input")? + .dyn_into::()?; + input.set_type("text"); - move |_event: web_sys::CompositionEvent, runner: &mut AppRunner| { - is_composing.set(true); - input_clone.set_value(""); + // append it to `` and hide it outside of the viewport + let style = input.style(); + style.set_property("opacity", "0")?; + style.set_property("width", "1px")?; + style.set_property("height", "1px")?; + style.set_property("position", "absolute")?; + style.set_property("top", "0")?; + style.set_property("left", "0")?; + document.body().unwrap().append_child(&input)?; + + // attach event listeners + + let on_input = { + let input = input.clone(); + move |event: web_sys::InputEvent, runner: &mut AppRunner| { + let text = input.value(); + // if `is_composing` is true, then user is using IME, for example: emoji, pinyin, kanji, hangul, etc. + // In that case, the browser emits both `input` and `compositionupdate` events, + // and we need to ignore the `input` event. + if !text.is_empty() && !event.is_composing() { + input.set_value(""); + let event = egui::Event::Text(text); + runner.input.raw.events.push(event); + runner.needs_repaint.repaint_asap(); + } + } + }; - let egui_event = egui::Event::Ime(egui::ImeEvent::Enabled); - runner.input.raw.events.push(egui_event); + let on_composition_start = { + let input = input.clone(); + move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { + input.set_value(""); + let event = egui::Event::Ime(egui::ImeEvent::Enabled); + runner.input.raw.events.push(event); + // Repaint moves the text agent into place, + // see `move_to` in `AppRunner::handle_platform_output`. runner.needs_repaint.repaint_asap(); } - })?; + }; - runner_ref.add_event_listener( - &input, - "compositionupdate", + let on_composition_update = { move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { - if let Some(text) = event.data() { - let egui_event = egui::Event::Ime(egui::ImeEvent::Preedit(text)); - runner.input.raw.events.push(egui_event); - runner.needs_repaint.repaint_asap(); - } - }, - )?; - - runner_ref.add_event_listener(&input, "compositionend", { - let input_clone = input.clone(); + let Some(text) = event.data() else { return }; + let event = egui::Event::Ime(egui::ImeEvent::Preedit(text)); + runner.input.raw.events.push(event); + runner.needs_repaint.repaint_asap(); + } + }; + let on_composition_end = { + let input = input.clone(); move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { - is_composing.set(false); - input_clone.set_value(""); - - if let Some(text) = event.data() { - let egui_event = egui::Event::Ime(egui::ImeEvent::Commit(text)); - runner.input.raw.events.push(egui_event); - runner.needs_repaint.repaint_asap(); - } + let Some(text) = event.data() else { return }; + input.set_value(""); + let event = egui::Event::Ime(egui::ImeEvent::Commit(text)); + runner.input.raw.events.push(event); + runner.needs_repaint.repaint_asap(); } - })?; - } + }; - // When input lost focus, focus on it again. - // It is useful when user click somewhere outside canvas. - let input_refocus = input.clone(); - runner_ref.add_event_listener(&input, "focusout", move |_event: web_sys::MouseEvent, _| { - // Delay 10 ms, and focus again. - let input_refocus = input_refocus.clone(); - call_after_delay(std::time::Duration::from_millis(10), move || { - input_refocus.focus().ok(); - }); - })?; + runner_ref.add_event_listener(&input, "input", on_input)?; + runner_ref.add_event_listener(&input, "compositionstart", on_composition_start)?; + runner_ref.add_event_listener(&input, "compositionupdate", on_composition_update)?; + runner_ref.add_event_listener(&input, "compositionend", on_composition_end)?; - body.append_child(&input)?; + Ok(TextAgent { + input, + prev_ime_output: Default::default(), + }) + } - Ok(()) -} + pub fn move_to( + &self, + ime: Option, + canvas: &web_sys::HtmlCanvasElement, + ) -> Result<(), JsValue> { + // don't move the text agent on mobile + if is_mobile() { + return Ok(()); + } -/// Focus or blur text agent to toggle mobile keyboard. -pub fn update_text_agent(runner: &AppRunner) -> Option<()> { - use web_sys::HtmlInputElement; - let window = web_sys::window()?; - let document = window.document()?; - let input: HtmlInputElement = document.get_element_by_id(AGENT_ID)?.dyn_into().unwrap(); - let canvas_style = runner.canvas().style(); + // don't move the text agent unless the position actually changed: + if self.prev_ime_output.get() == ime { + return Ok(()); + } + self.prev_ime_output.set(ime); - if runner.mutable_text_under_cursor { - let is_already_editing = input.hidden(); - if is_already_editing { - input.set_hidden(false); - input.focus().ok()?; + let Some(ime) = ime else { return Ok(()) }; - // Move up canvas so that text edit is shown at ~30% of screen height. - // Only on touch screens, when keyboard popups. - if let Some(latest_touch_pos) = runner.input.latest_touch_pos { - let window_height = window.inner_height().ok()?.as_f64()? as f32; - let current_rel = latest_touch_pos.y / window_height; + let ime_pos = ime.cursor_rect.left_top(); + let canvas_rect = canvas.get_bounding_client_rect(); + let new_pos = ime_pos + egui::vec2(canvas_rect.left() as f32, canvas_rect.top() as f32); - // estimated amount of screen covered by keyboard - let keyboard_fraction = 0.5; + let style = self.input.style(); + style.set_property("top", &format!("{}px", new_pos.y))?; + style.set_property("left", &format!("{}px", new_pos.x))?; - if current_rel > keyboard_fraction { - // below the keyboard + Ok(()) + } - let target_rel = 0.3; + pub fn set_focus(&self, on: bool) { + if on { + self.focus(); + } else { + self.blur(); + } + } - // Note: `delta` is negative, since we are moving the canvas UP - let delta = target_rel - current_rel; + pub fn has_focus(&self) -> bool { + let active_element = super::focused_element(); + self.input.clone().dyn_into().ok() == active_element + } - let delta = delta.max(-keyboard_fraction); // Don't move it crazy much + fn focus(&self) { + if self.has_focus() { + return; + } - let new_pos_percent = format!("{}%", (delta * 100.0).round()); + if let Err(err) = self.input.focus() { + log::error!("failed to set focus: {}", super::string_from_js_value(&err)); + }; + } - canvas_style.set_property("position", "absolute").ok()?; - canvas_style.set_property("top", &new_pos_percent).ok()?; - } - } + fn blur(&self) { + if !self.has_focus() { + return; } - } else { - // Holding the runner lock while calling input.blur() causes a panic. - // This is most probably caused by the browser running the event handler - // for the triggered blur event synchronously, meaning that the mutex - // lock does not get dropped by the time another event handler is called. - // - // Why this didn't exist before #1290 is a mystery to me, but it exists now - // and this apparently is the fix for it - // - // ¯\_(ツ)_/¯ - @DusterTheFirst - - // So since we are inside a runner lock here, we just postpone the blur/hide: - - call_after_delay(std::time::Duration::from_millis(0), move || { - input.blur().ok(); - input.set_hidden(true); - canvas_style.set_property("position", "absolute").ok(); - canvas_style.set_property("top", "0%").ok(); // move back to normal position - }); + + if let Err(err) = self.input.blur() { + log::error!("failed to set focus: {}", super::string_from_js_value(&err)); + }; } - Some(()) } -fn call_after_delay(delay: std::time::Duration, f: impl FnOnce() + 'static) { - use wasm_bindgen::prelude::*; - let window = web_sys::window().unwrap(); - let closure = Closure::once(f); - let delay_ms = delay.as_millis() as _; - window - .set_timeout_with_callback_and_timeout_and_arguments_0( - closure.as_ref().unchecked_ref(), - delay_ms, - ) - .unwrap(); - closure.forget(); // We must forget it, or else the callback is canceled on drop +impl Drop for TextAgent { + fn drop(&mut self) { + self.input.remove(); + } } -/// If context is running under mobile device? -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) -} +/// Returns `true` if the app is likely running on a mobile device. +fn is_mobile() -> bool { + fn inner() -> Option { + const MOBILE_DEVICE: [&str; 6] = + ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"]; -// Move text agent to text cursor's position, on desktop/laptop, -// candidate window moves following text element (agent), -// so it appears that the IME candidate window moves with text cursor. -// On mobile devices, there is no need to do that. -pub fn move_text_cursor( - ime: Option, - canvas: &web_sys::HtmlCanvasElement, -) -> Option<()> { - let style = text_agent().style(); - // Note: moving agent on mobile devices will lead to unpredictable scroll. - if is_mobile() == Some(false) { - ime.as_ref().and_then(|ime| { - let egui::Pos2 { x, y } = ime.cursor_rect.left_top(); - - let bounding_rect = text_agent().get_bounding_client_rect(); - let y = (y + (canvas.scroll_top() + canvas.offset_top()) as f32) - .min(canvas.client_height() as f32 - bounding_rect.height() as f32); - let x = x + (canvas.scroll_left() + canvas.offset_left()) as f32; - // Canvas is translated 50% horizontally in html. - let x = (x - canvas.offset_width() as f32 / 2.0) - .min(canvas.client_width() as f32 - bounding_rect.width() as f32); - style.set_property("position", "absolute").ok()?; - style.set_property("top", &format!("{y}px")).ok()?; - style.set_property("left", &format!("{x}px")).ok() - }) - } else { - style.set_property("position", "absolute").ok()?; - style.set_property("top", "0px").ok()?; - style.set_property("left", "0px").ok() + 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) } + inner().unwrap_or(false) } diff --git a/crates/eframe/src/web/web_runner.rs b/crates/eframe/src/web/web_runner.rs index 6a230a8e329..16f5101fef4 100644 --- a/crates/eframe/src/web/web_runner.rs +++ b/crates/eframe/src/web/web_runner.rs @@ -4,7 +4,7 @@ use wasm_bindgen::prelude::*; use crate::{epi, App}; -use super::{events, AppRunner, PanicHandler}; +use super::{events, text_agent::TextAgent, AppRunner, PanicHandler}; /// This is how `eframe` runs your wepp application /// @@ -65,14 +65,15 @@ impl WebRunner { let follow_system_theme = web_options.follow_system_theme; - let runner = AppRunner::new(canvas_id, web_options, app_creator).await?; + let text_agent = TextAgent::attach(self)?; + + let runner = AppRunner::new(canvas_id, web_options, app_creator, text_agent).await?; self.runner.replace(Some(runner)); { events::install_canvas_events(self)?; events::install_document_events(self)?; events::install_window_events(self)?; - super::text_agent::install_text_agent(self)?; if follow_system_theme { events::install_color_scheme_change_event(self)?;