1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
//! The text agent is a hidden `<input>` 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<RefCell<InputState>>,
}
impl TextAgent {
/// Attach the agent to the document.
pub fn attach(
runner_ref: &WebRunner,
canvas: &web_sys::HtmlCanvasElement,
) -> Result<Self, JsValue> {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
// create an `<input>` element
let input = document
.create_element("input")?
.dyn_into::<web_sys::HtmlInputElement>()?;
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<egui::output::IMEOutput>,
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<egui::output::IMEOutput>,
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<egui::output::IMEOutput>,
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<std::ops::Range<usize>> {
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::<Vec<u16>>();
if selection_start > text_utf16.len() || selection_end > text_utf16.len() {
// This can occur on Android Chrome. see discussion in:
// <https://github.com/emilk/egui/pull/8045>.
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::<String>()
};
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<Self>, 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<Self>, 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()
}