/ egui_web / src / text_agent.rs
text_agent.rs
  1  //! The text agent is an `<input>` element used to trigger
  2  //! mobile keyboard and IME input.
  3  
  4  use crate::{canvas_element, AppRunner, AppRunnerContainer};
  5  use egui::mutex::MutexGuard;
  6  use std::cell::Cell;
  7  use std::rc::Rc;
  8  use wasm_bindgen::prelude::*;
  9  
 10  static AGENT_ID: &str = "egui_text_agent";
 11  
 12  pub fn text_agent() -> web_sys::HtmlInputElement {
 13      use wasm_bindgen::JsCast;
 14      web_sys::window()
 15          .unwrap()
 16          .document()
 17          .unwrap()
 18          .get_element_by_id(AGENT_ID)
 19          .unwrap()
 20          .dyn_into()
 21          .unwrap()
 22  }
 23  
 24  /// Text event handler,
 25  pub fn install_text_agent(runner_container: &AppRunnerContainer) -> Result<(), JsValue> {
 26      use wasm_bindgen::JsCast;
 27      let window = web_sys::window().unwrap();
 28      let document = window.document().unwrap();
 29      let body = document.body().expect("document should have a body");
 30      let input = document
 31          .create_element("input")?
 32          .dyn_into::<web_sys::HtmlInputElement>()?;
 33      let input = std::rc::Rc::new(input);
 34      input.set_id(AGENT_ID);
 35      let is_composing = Rc::new(Cell::new(false));
 36      {
 37          let style = input.style();
 38          // Transparent
 39          style.set_property("opacity", "0").unwrap();
 40          // Hide under canvas
 41          style.set_property("z-index", "-1").unwrap();
 42      }
 43      // Set size as small as possible, in case user may click on it.
 44      input.set_size(1);
 45      input.set_autofocus(true);
 46      input.set_hidden(true);
 47  
 48      // When IME is off
 49      runner_container.add_event_listener(&input, "input", {
 50          let input_clone = input.clone();
 51          let is_composing = is_composing.clone();
 52  
 53          move |_event: web_sys::InputEvent, mut runner_lock| {
 54              let text = input_clone.value();
 55              if !text.is_empty() && !is_composing.get() {
 56                  input_clone.set_value("");
 57                  runner_lock.input.raw.events.push(egui::Event::Text(text));
 58                  runner_lock.needs_repaint.set_true();
 59              }
 60          }
 61      })?;
 62  
 63      {
 64          // When IME is on, handle composition event
 65          runner_container.add_event_listener(&input, "compositionstart", {
 66              let input_clone = input.clone();
 67              let is_composing = is_composing.clone();
 68  
 69              move |_event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| {
 70                  is_composing.set(true);
 71                  input_clone.set_value("");
 72  
 73                  runner_lock
 74                      .input
 75                      .raw
 76                      .events
 77                      .push(egui::Event::CompositionStart);
 78                  runner_lock.needs_repaint.set_true();
 79              }
 80          })?;
 81  
 82          runner_container.add_event_listener(
 83              &input,
 84              "compositionupdate",
 85              move |event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| {
 86                  if let Some(event) = event.data().map(egui::Event::CompositionUpdate) {
 87                      runner_lock.input.raw.events.push(event);
 88                      runner_lock.needs_repaint.set_true();
 89                  }
 90              },
 91          )?;
 92  
 93          runner_container.add_event_listener(&input, "compositionend", {
 94              let input_clone = input.clone();
 95  
 96              move |event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| {
 97                  is_composing.set(false);
 98                  input_clone.set_value("");
 99  
100                  if let Some(event) = event.data().map(egui::Event::CompositionEnd) {
101                      runner_lock.input.raw.events.push(event);
102                      runner_lock.needs_repaint.set_true();
103                  }
104              }
105          })?;
106      }
107  
108      // When input lost focus, focus on it again.
109      // It is useful when user click somewhere outside canvas.
110      runner_container.add_event_listener(
111          &input,
112          "focusout",
113          move |_event: web_sys::MouseEvent, _| {
114              // Delay 10 ms, and focus again.
115              let func = js_sys::Function::new_no_args(&format!(
116                  "document.getElementById('{}').focus()",
117                  AGENT_ID
118              ));
119              window
120                  .set_timeout_with_callback_and_timeout_and_arguments_0(&func, 10)
121                  .unwrap();
122          },
123      )?;
124  
125      body.append_child(&input)?;
126  
127      Ok(())
128  }
129  
130  /// Focus or blur text agent to toggle mobile keyboard.
131  pub fn update_text_agent(runner: MutexGuard<'_, AppRunner>) -> Option<()> {
132      use wasm_bindgen::JsCast;
133      use web_sys::HtmlInputElement;
134      let window = web_sys::window()?;
135      let document = window.document()?;
136      let input: HtmlInputElement = document.get_element_by_id(AGENT_ID)?.dyn_into().unwrap();
137      let canvas_style = canvas_element(runner.canvas_id())?.style();
138  
139      if runner.mutable_text_under_cursor {
140          let is_already_editing = input.hidden();
141          if is_already_editing {
142              input.set_hidden(false);
143              input.focus().ok()?;
144  
145              // Move up canvas so that text edit is shown at ~30% of screen height.
146              // Only on touch screens, when keyboard popups.
147              if let Some(latest_touch_pos) = runner.input.latest_touch_pos {
148                  let window_height = window.inner_height().ok()?.as_f64()? as f32;
149                  let current_rel = latest_touch_pos.y / window_height;
150  
151                  // estimated amount of screen covered by keyboard
152                  let keyboard_fraction = 0.5;
153  
154                  if current_rel > keyboard_fraction {
155                      // below the keyboard
156  
157                      let target_rel = 0.3;
158  
159                      // Note: `delta` is negative, since we are moving the canvas UP
160                      let delta = target_rel - current_rel;
161  
162                      let delta = delta.max(-keyboard_fraction); // Don't move it crazy much
163  
164                      let new_pos_percent = format!("{}%", (delta * 100.0).round());
165  
166                      canvas_style.set_property("position", "absolute").ok()?;
167                      canvas_style.set_property("top", &new_pos_percent).ok()?;
168                  }
169              }
170          }
171      } else {
172          // Drop runner lock
173          drop(runner);
174  
175          // Holding the runner lock while calling input.blur() causes a panic.
176          // This is most probably caused by the browser running the event handler
177          // for the triggered blur event synchronously, meaning that the mutex
178          // lock does not get dropped by the time another event handler is called.
179          //
180          // Why this didn't exist before #1290 is a mystery to me, but it exists now
181          // and this apparently is the fix for it
182          //
183          // ¯\_(ツ)_/¯ - @DusterTheFirst
184          input.blur().ok()?;
185  
186          input.set_hidden(true);
187          canvas_style.set_property("position", "absolute").ok()?;
188          canvas_style.set_property("top", "0%").ok()?; // move back to normal position
189      }
190      Some(())
191  }
192  
193  /// If context is running under mobile device?
194  fn is_mobile() -> Option<bool> {
195      const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"];
196  
197      let user_agent = web_sys::window()?.navigator().user_agent().ok()?;
198      let is_mobile = MOBILE_DEVICE.iter().any(|&name| user_agent.contains(name));
199      Some(is_mobile)
200  }
201  
202  // Move text agent to text cursor's position, on desktop/laptop,
203  // candidate window moves following text element (agent),
204  // so it appears that the IME candidate window moves with text cursor.
205  // On mobile devices, there is no need to do that.
206  pub fn move_text_cursor(cursor: Option<egui::Pos2>, canvas_id: &str) -> Option<()> {
207      let style = text_agent().style();
208      // Note: movint agent on mobile devices will lead to unpredictable scroll.
209      if is_mobile() == Some(false) {
210          cursor.as_ref().and_then(|&egui::Pos2 { x, y }| {
211              let canvas = canvas_element(canvas_id)?;
212              let bounding_rect = text_agent().get_bounding_client_rect();
213              let y = (y + (canvas.scroll_top() + canvas.offset_top()) as f32)
214                  .min(canvas.client_height() as f32 - bounding_rect.height() as f32);
215              let x = x + (canvas.scroll_left() + canvas.offset_left()) as f32;
216              // Canvas is translated 50% horizontally in html.
217              let x = (x - canvas.offset_width() as f32 / 2.0)
218                  .min(canvas.client_width() as f32 - bounding_rect.width() as f32);
219              style.set_property("position", "absolute").ok()?;
220              style.set_property("top", &format!("{}px", y)).ok()?;
221              style.set_property("left", &format!("{}px", x)).ok()
222          })
223      } else {
224          style.set_property("position", "absolute").ok()?;
225          style.set_property("top", "0px").ok()?;
226          style.set_property("left", "0px").ok()
227      }
228  }