//! The text agent is a hidden `` element used to capture
//! IME and mobile keyboard input events.
use std::{cell::RefCell, rc::Rc};
use wasm_bindgen::prelude::*;
use super::{AppRunner, WebRunner};
pub struct TextAgent {
input: web_sys::HtmlInputElement,
input_state: Rc>,
}
impl TextAgent {
/// Attach the agent to the document.
pub fn attach(
runner_ref: &WebRunner,
canvas: &web_sys::HtmlCanvasElement,
) -> Result {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
// create an `` element
let input = document
.create_element("input")?
.dyn_into::()?;
input.set_type("text");
input.set_attribute("autocapitalize", "off")?;
let input_state = Rc::new(RefCell::new(InputState::new(input.clone())));
// Hide the element, and park it over the top-left corner of the canvas
// so that focusing it can never scroll some other part
// of the page into view.
let style = input.style();
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", &format!("{}px", canvas.offset_top()))?;
style.set_property("left", &format!("{}px", canvas.offset_left()))?;
// Prevent auto-zoom on mobile browsers (requires at least 16px).
style.set_property("font-size", "16px")?;
// Insert the input as a sibling of the canvas, so that its
// `position: absolute` resolves against the same containing block
// as the canvas' `offset_top`/`offset_left`.
// This anchors the input to the canvas regardless of how the page
// is scrolled or how the canvas is embedded, and also works when
// the canvas is inside a shadow DOM.
if let Some(parent) = canvas.parent_node() {
parent.insert_before(&input, canvas.next_sibling().as_ref())?;
} else if let Some(body) = document.body() {
log::warn!("Canvas has no parent element - appending text agent to document body");
body.append_child(&input)?;
}
// Focus the app on startup, without scrolling the page.
// We do this instead of setting the `autofocus` attribute,
// since the browser scrolls the focused element into view when
// honoring `autofocus`, and there is no way to prevent that.
// See https://github.com/emilk/egui/issues/8295
super::focus_without_scroll(&input).ok();
// attach event listeners
runner_ref.add_event_listener(
&input,
"compositionstart",
move |_: web_sys::CompositionEvent, runner: &mut AppRunner| {
// Repaint moves the text agent into place,
// see `AppRunner::handle_platform_output`, which calls
// `TextAgent::update`.
runner.needs_repaint.repaint_asap();
},
)?;
runner_ref.add_event_listener(&input, "input", {
let input_state = Rc::clone(&input_state);
move |event: web_sys::InputEvent, runner: &mut AppRunner| {
input_state.borrow_mut().handle_input_event(&event, runner);
}
})?;
runner_ref.add_event_listener(&input, "compositionend", {
let input_state = Rc::clone(&input_state);
move |_event: web_sys::CompositionEvent, runner: &mut AppRunner| {
input_state
.borrow_mut()
.handle_composition_end_event(runner);
}
})?;
runner_ref.add_event_listener(&input, "keydown", {
let input_state = Rc::clone(&input_state);
move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| {
let is_consumed = InputState::handle_keydown_event(&input_state, &event);
if !is_consumed {
// The canvas doesn't get keydown/keyup events when the text agent is focused,
// so we need to forward them to the runner:
super::events::on_keydown(event, runner);
}
}
})?;
runner_ref.add_event_listener(&input, "keyup", {
let input_state = Rc::clone(&input_state);
move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| {
let is_consumed = InputState::handle_keyup_event(&input_state, &event);
if !is_consumed {
// The canvas doesn't get keydown/keyup events when the text agent is focused,
// so we need to forward them to the runner:
super::events::on_keyup(event, runner);
}
}
})?;
Ok(Self { input, input_state })
}
pub fn update(
&self,
ime: Option,
canvas: &web_sys::HtmlCanvasElement,
zoom_factor: f32,
) -> Result<(), JsValue> {
self.input_state
.borrow_mut()
.update(ime, canvas, zoom_factor)
}
pub fn set_focus(&self, on: bool) {
if on {
self.focus();
} else {
self.blur();
}
}
pub fn has_focus(&self) -> bool {
super::has_focus(&self.input)
}
pub fn focus(&self) {
if self.has_focus() {
return;
}
log::trace!("Focusing text agent");
if let Err(err) = super::focus_without_scroll(&self.input) {
log::error!("failed to set focus: {}", super::string_from_js_value(&err));
}
}
pub fn blur(&self) {
if !self.has_focus() {
return;
}
log::trace!("Blurring text agent");
if let Err(err) = self.input.blur() {
log::error!("failed to set focus: {}", super::string_from_js_value(&err));
}
self.input_state.borrow_mut().clear();
}
pub(crate) fn interrupt_ime_composition(&self) {
self.input_state.borrow_mut().clear();
}
}
impl Drop for TextAgent {
fn drop(&mut self) {
self.input.remove();
}
}
struct InputState {
input: web_sys::HtmlInputElement,
last_text: String,
ime_output: Option,
keydown_special_case: KeydownSpecialCase,
}
#[derive(Clone, Copy)]
enum KeydownSpecialCase {
None,
/// On Android Gboard 14.7.09, when suggestions remain visible while typing
/// letters without IME composition (e.g., Latin or Cyrillic), pressing
/// Backspace produces key code 229 instead of the expected Backspace key
/// code.
/// Without the workaround, users have to press Backspace twice before text
/// starts being deleted.
///
/// This workaround is also required for Android Gboard corrections and
/// completions (e.g., `tex|` -> `Texas`) to work correctly. In these
/// cases, a `deleteContentBackward` input event fires first (e.g., to
/// delete `tex`), followed by an `insertText` input event (e.g., to insert
/// `Texas`).
///
/// Since it is difficult to distinguish between a Backspace press and a
/// correction or completion (e.g., when the state is `t|`, it is unclear
/// whether the user wants to delete `t` or replace it with `Texas`), we
/// send a `DeleteSurrounding` IME event in all cases instead of
/// synthetically generating Backspace press and release events.
AndroidKeycode229,
/// iOS (18.6)'s built-in Korean keyboard uses `deleteContentBackward` to
/// compose Hangul characters. In these cases, the key code is 0.
IosKeycode0,
}
impl InputState {
fn new(input: web_sys::HtmlInputElement) -> Self {
Self {
input,
last_text: String::new(),
ime_output: None,
keydown_special_case: KeydownSpecialCase::None,
}
}
fn update(
&mut self,
ime: Option,
canvas: &web_sys::HtmlCanvasElement,
zoom_factor: f32,
) -> Result<(), JsValue> {
// Don't move the text agent unless the position actually changed:
if self.ime_output == ime {
return Ok(());
}
self.ime_output = ime;
let Some(ime) = ime else { return Ok(()) };
// NOTE: we don't set the input's `type` to `password` based on
// `ime.purpose`, because that would confuse some password managers.
// For example, Chrome's password manager will always think the last
// letter typed in the password field is the password.
let style = self.input.style();
let native_ppp = super::native_pixels_per_point();
// The input is a sibling of the canvas (see `attach`), so we position
// it relative to the same containing block using the canvas offset.
// Unlike `get_bounding_client_rect`, the offset is unaffected by page
// scrolling, and doesn't flap when the virtual keyboard is shown on
// mobile Safari.
// Clamp the input position within the canvas width to prevent unwanted horizontal scrolling.
let logical_canvas_width = canvas.width() as f32 / native_ppp;
let visible_x = ime.cursor_rect.center().x * zoom_factor;
let clamped_x = visible_x.clamp(0.0, logical_canvas_width);
// Clamp the input position within the canvas height to prevent unwanted vertical scrolling.
let logical_canvas_height = canvas.height() as f32 / native_ppp;
let visible_y = ime.cursor_rect.center().y * zoom_factor;
let clamped_y = visible_y.clamp(0.0, logical_canvas_height);
// This is where the IME input will point to:
style.set_property(
"left",
&format!("{}px", canvas.offset_left() as f32 + clamped_x),
)?;
style.set_property(
"top",
&format!("{}px", canvas.offset_top() as f32 + clamped_y),
)?;
Ok(())
}
fn clear(&mut self) {
self.input.set_value("");
self.last_text.clear();
}
fn handle_input_event(&mut self, event: &web_sys::InputEvent, runner: &mut AppRunner) {
if self
.ime_output
.as_ref()
.is_some_and(|ime| ime.purpose == egui::IMEPurpose::Password)
{
self.handle_input_event_password(event, runner);
return;
}
let input_type = event.input_type();
if !event.is_composing()
&& input_type != "insertText"
// iOS uses this for corrections and completions (e.g., `tex|` ->
// `Texas`).
&& input_type != "insertReplacementText"
&& (matches!(self.keydown_special_case, KeydownSpecialCase::None)
|| input_type != "deleteContentBackward")
{
self.clear();
return;
}
let text = self.input.value();
let prefix_len = longest_common_prefix_length(&text, &self.last_text);
let last_text_len = self.last_text.chars().count();
if prefix_len < last_text_len {
let out_event = egui::Event::Ime(egui::ImeEvent::DeleteSurrounding {
before_chars: last_text_len - prefix_len,
after_chars: 0,
});
runner.input.raw.events.push(out_event);
}
let preedit_text: String = text.chars().skip(prefix_len).collect();
let out_event = if event.is_composing() {
// We handle the composition update here instead of in a
// `compositionupdate` event because the selection range
// has not yet been updated when `compositionupdate` fires.
let active_range_chars = self.active_range_chars(&text, prefix_len);
egui::Event::Ime(egui::ImeEvent::Preedit {
text: preedit_text,
active_range_chars,
})
} else {
egui::Event::Text(preedit_text)
};
runner.input.raw.events.push(out_event);
if event.is_composing() {
self.last_text = text.chars().take(prefix_len).collect();
} else {
self.last_text = text;
}
runner.needs_repaint.repaint_asap();
}
fn handle_input_event_password(&mut self, event: &web_sys::InputEvent, runner: &mut AppRunner) {
let input_type = event.input_type();
if input_type != "insertText" {
return;
}
let text = self.input.value();
runner.input.raw.events.push(egui::Event::Text(text));
self.clear();
}
/// Compute the active range (cursor or conversion segment) within the
/// preedit text, based on the selection in the input element.
///
/// `text` is the full `input.value()`, and `prefix_len_chars` is the
/// number of chars at the start of `text` that are committed (not part
/// of the preedit). `selectionStart`/`selectionEnd` are UTF-16 offsets
/// within the full `input.value()`, so they are adjusted to be relative
/// to the preedit text.
fn active_range_chars(
&self,
text: &str,
prefix_len_chars: usize,
) -> Option> {
let selection_start = self.input.selection_start().unwrap_or(None)? as usize;
let selection_end = self.input.selection_end().unwrap_or(None)? as usize;
let text_utf16 = text.encode_utf16().collect::>();
if selection_start > text_utf16.len() || selection_end > text_utf16.len() {
// This can occur on Android Chrome. see discussion in:
// .
return None;
}
let text_before_selection = String::from_utf16_lossy(&text_utf16[..selection_start]);
let text_in_selection =
String::from_utf16_lossy(&text_utf16[selection_start..selection_end]);
let count_before_selection = text_before_selection.chars().count();
let count_in_selection = text_in_selection.chars().count();
// Adjust for the committed prefix to get the range within the preedit text.
let start = count_before_selection.saturating_sub(prefix_len_chars);
let end = start + count_in_selection;
Some(start..end)
}
fn handle_composition_end_event(&mut self, runner: &mut AppRunner) {
let text = self.input.value();
let commit_text = {
let prefix_len = self.last_text.chars().count();
text.chars().skip(prefix_len).collect::()
};
let out_event = egui::Event::Ime(egui::ImeEvent::Commit(commit_text));
runner.input.raw.events.push(out_event);
self.last_text = text;
runner.needs_repaint.repaint_asap();
}
/// ## Returns
/// Whether the event is consumed. If `true`, the caller should not do
/// further processing for this event.
fn handle_keydown_event(input_state: &RefCell, event: &web_sys::KeyboardEvent) -> bool {
// Platform-sniffing methods are unreliable, so they are not used as
// guards here.
let special_case = match event.key_code() {
229 => KeydownSpecialCase::AndroidKeycode229,
0 => KeydownSpecialCase::IosKeycode0,
_ => KeydownSpecialCase::None,
};
input_state.borrow_mut().keydown_special_case = special_case;
// https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/
if event.is_composing() || !matches!(special_case, KeydownSpecialCase::None) {
true
} else {
if event.key().chars().count() > 1
|| event.ctrl_key()
|| event.alt_key()
|| event.meta_key()
{
input_state.borrow_mut().clear();
}
false
}
}
/// ## Returns
/// Whether the event is consumed. If `true`, the caller should not do
/// further processing for this event.
fn handle_keyup_event(input_state: &RefCell, event: &web_sys::KeyboardEvent) -> bool {
input_state.borrow_mut().keydown_special_case = KeydownSpecialCase::None;
// https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/
event.is_composing() || event.key_code() == 229
}
}
fn longest_common_prefix_length(a: &str, b: &str) -> usize {
std::iter::zip(a.chars(), b.chars())
.take_while(|(a, b)| a == b)
.count()
}