From e2df8dc3031757205d6c789c971ec8e6cd9923e7 Mon Sep 17 00:00:00 2001 From: umajho Date: Sun, 29 Mar 2026 22:31:45 +0800 Subject: [PATCH 01/17] Fix: attempt to make `TextAgent` more robust on some edge cases --- crates/eframe/src/web/events.rs | 3 +- crates/eframe/src/web/text_agent.rs | 119 +++++++++++++++---- crates/egui/src/data/input.rs | 9 ++ crates/egui/src/widgets/text_edit/builder.rs | 20 ++++ 4 files changed, 128 insertions(+), 23 deletions(-) diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index d77444563ede..f6dff83055af 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -170,8 +170,7 @@ pub(crate) fn on_keydown(event: web_sys::KeyboardEvent, runner: &mut AppRunner) return; } - if event.is_composing() || event.key_code() == 229 { - // 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.key_code() == 229 { return; } diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index ac917329f36f..38417506ee5b 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -1,7 +1,10 @@ //! The text agent is a hidden `` element used to capture //! IME and mobile keyboard input events. -use std::cell::Cell; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; use wasm_bindgen::prelude::*; use web_sys::{Document, Node}; @@ -50,10 +53,28 @@ impl TextAgent { root.append_child(&input)?; } + let last_text = Rc::new(RefCell::new(String::new())); + + fn clear(input: &web_sys::HtmlInputElement, last_text: &RefCell) { + input.set_value(""); + last_text.borrow_mut().clear(); + } + // attach event listeners + let on_before_input = { + let input = input.clone(); + let last_text = Rc::clone(&last_text); + move |event: web_sys::InputEvent, _runner: &mut AppRunner| { + if !event.is_composing() { + clear(&input, &last_text); + } + } + }; + let on_input = { let input = input.clone(); + let last_text = Rc::clone(&last_text); move |event: web_sys::InputEvent, runner: &mut AppRunner| { let text = input.value(); // Fix android virtual keyboard Gboard @@ -62,22 +83,41 @@ impl TextAgent { input.blur().ok(); input.focus().ok(); } - // 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(""); + if text.is_empty() { + return; + } + + if event.input_type() == "insertText" { + clear(&input, &last_text); let event = egui::Event::Text(text); runner.input.raw.events.push(event); runner.needs_repaint.repaint_asap(); + } else if event.is_composing() { + // 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. + + let last_text_ref = last_text.borrow(); + let prefix_len = longest_common_prefix_length(&text, &last_text_ref); + let last_text_len = last_text_ref.chars().count(); + if prefix_len < last_text_len { + let event = egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { + before_chars: last_text_len - prefix_len, + after_chars: 0, + }); + runner.input.raw.events.push(event); + } + let event = egui::Event::Ime(egui::ImeEvent::Preedit( + text.chars().skip(prefix_len).collect(), + )); + runner.input.raw.events.push(event); + runner.needs_repaint.repaint_asap(); } } }; let on_composition_start = { - let input = input.clone(); move |_: 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, @@ -86,34 +126,67 @@ impl TextAgent { } }; - let on_composition_update = { - move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { - let Some(text) = event.data() else { return }; - let event = egui::Event::Ime(egui::ImeEvent::Preedit(text)); + let on_composition_end = { + let input = input.clone(); + let last_text = Rc::clone(&last_text); + move |_event: web_sys::CompositionEvent, runner: &mut AppRunner| { + let text = input.value(); + + let mut last_text_ref = last_text.borrow_mut(); + let prefix_len = longest_common_prefix_length(&text, &last_text_ref); + let last_text_len = last_text_ref.chars().count(); + if prefix_len < last_text_len { + let event = egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { + before_chars: last_text_len - prefix_len, + after_chars: 0, + }); + runner.input.raw.events.push(event); + } + let event = egui::Event::Ime(egui::ImeEvent::Commit( + text.chars().skip(prefix_len).collect(), + )); runner.input.raw.events.push(event); + + *last_text_ref = text; + runner.needs_repaint.repaint_asap(); } }; - let on_composition_end = { + let on_blur = { let input = input.clone(); - move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { - 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(); + let last_text = Rc::clone(&last_text); + move |_: web_sys::FocusEvent, _runner: &mut AppRunner| { + clear(&input, &last_text); } }; + let on_keydown = { + let input = input.clone(); + let last_text = Rc::clone(&last_text); + move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { + if event.is_composing() { + // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ + return; + } + + clear(&input, &last_text); + + // 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, "beforeinput", on_before_input)?; 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)?; + runner_ref.add_event_listener(&input, "blur", on_blur)?; + runner_ref.add_event_listener(&input, "keydown", on_keydown)?; // The canvas doesn't get keydown/keyup events when the text agent is focused, // so we need to forward them to the runner: - runner_ref.add_event_listener(&input, "keydown", super::events::on_keydown)?; runner_ref.add_event_listener(&input, "keyup", super::events::on_keyup)?; Ok(Self { @@ -213,3 +286,7 @@ fn is_mobile_safari() -> bool { })() .unwrap_or(false) } + +fn longest_common_prefix_length(a: &str, b: &str) -> usize { + a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count() +} diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 5e1680334935..7ca0be4e494c 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -611,6 +611,15 @@ pub enum ImeEvent { /// IME composition ended with this final result. Commit(String), + /// Notifies when the text surrounding the cursor should be deleted. + /// + /// `before_chars` and `after_chars` are the number of characters (not + /// bytes) to delete before and after the cursor, respectively. + DeleteSurrounding { + before_chars: usize, + after_chars: usize, + }, + /// Notifies when the IME was disabled. Disabled, } diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index ef668a02ee07..c4e533f9ace5 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -1197,6 +1197,26 @@ fn events( Some(CCursorRange::one(ccursor)) } } + ImeEvent::DeleteSurrounding { + before_chars, + after_chars, + } => { + let mut ccurosr_end = cursor_range.secondary; + if *after_chars > 0 { + text.delete_selected_ccursor_range([ + ccurosr_end, + ccurosr_end + *after_chars, + ]); + } + if *before_chars > 0 { + text.delete_selected_ccursor_range([ + cursor_range.primary - *before_chars, + cursor_range.primary, + ]); + ccurosr_end -= *before_chars; + } + Some(CCursorRange::one(ccurosr_end)) + } ImeEvent::Disabled => { state.ime_enabled = false; None From b0b661457d5d8f39a8edfa07128e96bd82b3698b Mon Sep 17 00:00:00 2001 From: umajho Date: Mon, 30 Mar 2026 15:37:53 +0800 Subject: [PATCH 02/17] Fix(eframe/web): try also working around #8046 --- crates/eframe/src/web/text_agent.rs | 121 ++++++++++++++++++---------- 1 file changed, 77 insertions(+), 44 deletions(-) diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 38417506ee5b..dfb422820a09 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -19,7 +19,8 @@ pub struct TextAgent { impl TextAgent { /// Attach the agent to the document. pub fn attach(runner_ref: &WebRunner, root: Node) -> Result { - let document = web_sys::window().unwrap().document().unwrap(); + let window = web_sys::window().unwrap(); + let document = window.document().unwrap(); // create an `` element let input = document @@ -60,14 +61,37 @@ impl TextAgent { last_text.borrow_mut().clear(); } + // Workaround for GBoard on Android, which sometimes sends `KeyboardEvent` + // with `key` set to `"Process"` for backspace presses. + let has_received_processed_keydown_event = Rc::new(Cell::new(false)); + // attach event listeners let on_before_input = { let input = input.clone(); let last_text = Rc::clone(&last_text); - move |event: web_sys::InputEvent, _runner: &mut AppRunner| { - if !event.is_composing() { + let has_received_processed_keydown_event = + Rc::clone(&has_received_processed_keydown_event); + move |event: web_sys::InputEvent, runner: &mut AppRunner| { + if !event.is_composing() && event.input_type() != "insertText" { clear(&input, &last_text); + + let has_received_processed_keydown_event = + has_received_processed_keydown_event.take(); + + if event.input_type() == "deleteContentBackward" + && has_received_processed_keydown_event + { + for pressed in [true, false] { + runner.input.raw.events.push(egui::Event::Key { + key: egui::Key::Backspace, + physical_key: None, // TODO(fornwall) + pressed, + repeat: false, + modifiers: egui::Modifiers::default(), + }); + } + } } } }; @@ -76,43 +100,37 @@ impl TextAgent { let input = input.clone(); let last_text = Rc::clone(&last_text); move |event: web_sys::InputEvent, runner: &mut AppRunner| { - let text = input.value(); - // Fix android virtual keyboard Gboard - // This removes the virtual keyboard's suggestion. - if !event.is_composing() { - input.blur().ok(); - input.focus().ok(); - } - if text.is_empty() { + if !event.is_composing() && event.input_type() != "insertText" { return; } - if event.input_type() == "insertText" { - clear(&input, &last_text); - let event = egui::Event::Text(text); - runner.input.raw.events.push(event); - runner.needs_repaint.repaint_asap(); - } else if event.is_composing() { - // 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. - - let last_text_ref = last_text.borrow(); - let prefix_len = longest_common_prefix_length(&text, &last_text_ref); - let last_text_len = last_text_ref.chars().count(); - if prefix_len < last_text_len { - let event = egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { - before_chars: last_text_len - prefix_len, - after_chars: 0, - }); - runner.input.raw.events.push(event); - } - let event = egui::Event::Ime(egui::ImeEvent::Preedit( + let text = input.value(); + + let mut last_text_ref = last_text.borrow_mut(); + let prefix_len = longest_common_prefix_length(&text, &last_text_ref); + let last_text_len = last_text_ref.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 out_event = if event.is_composing() { + egui::Event::Ime(egui::ImeEvent::Preedit( text.chars().skip(prefix_len).collect(), - )); - runner.input.raw.events.push(event); - runner.needs_repaint.repaint_asap(); + )) + } else { + egui::Event::Text(text.chars().skip(prefix_len).collect()) + }; + runner.input.raw.events.push(out_event); + + if !event.is_composing() { + *last_text_ref = text; } + + runner.needs_repaint.repaint_asap(); } }; @@ -136,16 +154,16 @@ impl TextAgent { let prefix_len = longest_common_prefix_length(&text, &last_text_ref); let last_text_len = last_text_ref.chars().count(); if prefix_len < last_text_len { - let event = egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { + let out_event = egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: last_text_len - prefix_len, after_chars: 0, }); - runner.input.raw.events.push(event); + runner.input.raw.events.push(out_event); } - let event = egui::Event::Ime(egui::ImeEvent::Commit( + let out_event = egui::Event::Ime(egui::ImeEvent::Commit( text.chars().skip(prefix_len).collect(), )); - runner.input.raw.events.push(event); + runner.input.raw.events.push(out_event); *last_text_ref = text; @@ -162,21 +180,36 @@ impl TextAgent { }; let on_keydown = { - let input = input.clone(); - let last_text = Rc::clone(&last_text); + let has_received_processed_keydown_event = + Rc::clone(&has_received_processed_keydown_event); move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { + // 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() { - // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ return; } + if event.key() == "Process" { + has_received_processed_keydown_event.set(true); - clear(&input, &last_text); + return; + } // 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); } }; + let on_keyup = { + move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { + // 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() || event.key() == "Process" { + return; + } + + // 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); + } + }; runner_ref.add_event_listener(&input, "beforeinput", on_before_input)?; runner_ref.add_event_listener(&input, "input", on_input)?; @@ -187,7 +220,7 @@ impl TextAgent { runner_ref.add_event_listener(&input, "keydown", on_keydown)?; // The canvas doesn't get keydown/keyup events when the text agent is focused, // so we need to forward them to the runner: - runner_ref.add_event_listener(&input, "keyup", super::events::on_keyup)?; + runner_ref.add_event_listener(&input, "keyup", on_keyup)?; Ok(Self { input, From 5d2800a7fc52a911510b7640a43b2f4b7fc1670e Mon Sep 17 00:00:00 2001 From: umajho Date: Mon, 30 Mar 2026 16:25:04 +0800 Subject: [PATCH 03/17] Refactor(eframe/web): simplify --- crates/eframe/src/web/events.rs | 4 ---- crates/eframe/src/web/text_agent.rs | 19 +++---------------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index f6dff83055af..36e00044195e 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -170,10 +170,6 @@ pub(crate) fn on_keydown(event: web_sys::KeyboardEvent, runner: &mut AppRunner) return; } - if event.key_code() == 229 { - return; - } - let modifiers = modifiers_from_kb_event(&event); runner.input.raw.modifiers = modifiers; diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index dfb422820a09..c1fc0cc60db2 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -19,8 +19,7 @@ pub struct TextAgent { impl TextAgent { /// Attach the agent to the document. pub fn attach(runner_ref: &WebRunner, root: Node) -> Result { - let window = web_sys::window().unwrap(); - let document = window.document().unwrap(); + let document = web_sys::window().unwrap().document().unwrap(); // create an `` element let input = document @@ -67,7 +66,7 @@ impl TextAgent { // attach event listeners - let on_before_input = { + let on_input = { let input = input.clone(); let last_text = Rc::clone(&last_text); let has_received_processed_keydown_event = @@ -76,11 +75,8 @@ impl TextAgent { if !event.is_composing() && event.input_type() != "insertText" { clear(&input, &last_text); - let has_received_processed_keydown_event = - has_received_processed_keydown_event.take(); - if event.input_type() == "deleteContentBackward" - && has_received_processed_keydown_event + && has_received_processed_keydown_event.take() { for pressed in [true, false] { runner.input.raw.events.push(egui::Event::Key { @@ -92,15 +88,7 @@ impl TextAgent { }); } } - } - } - }; - let on_input = { - let input = input.clone(); - let last_text = Rc::clone(&last_text); - move |event: web_sys::InputEvent, runner: &mut AppRunner| { - if !event.is_composing() && event.input_type() != "insertText" { return; } @@ -211,7 +199,6 @@ impl TextAgent { } }; - runner_ref.add_event_listener(&input, "beforeinput", on_before_input)?; 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, "compositionend", on_composition_end)?; From da2731996c327d9397272c12707de3a2abfa1e28 Mon Sep 17 00:00:00 2001 From: umajho Date: Mon, 30 Mar 2026 19:50:46 +0800 Subject: [PATCH 04/17] Fix(eframe/web): Chromium not covered by GBoard workaround --- crates/eframe/src/web/text_agent.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index c1fc0cc60db2..ca0e80ff0d8c 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -60,24 +60,25 @@ impl TextAgent { last_text.borrow_mut().clear(); } - // Workaround for GBoard on Android, which sometimes sends `KeyboardEvent` - // with `key` set to `"Process"` for backspace presses. - let has_received_processed_keydown_event = Rc::new(Cell::new(false)); + // Workaround for GBoard on Android, which sometimes sends a + // `KeyboardEvent` with `key` set to `"Process"` (Firefox) or + // `"Unidentified"` (Chrome) for backspace presses. In both cases, + // the `keyCode` is `229`, hence the name `has_received_229`. + let has_received_229 = Rc::new(Cell::new(false)); // attach event listeners let on_input = { let input = input.clone(); let last_text = Rc::clone(&last_text); - let has_received_processed_keydown_event = - Rc::clone(&has_received_processed_keydown_event); + let has_received_229 = Rc::clone(&has_received_229); move |event: web_sys::InputEvent, runner: &mut AppRunner| { + let has_received_229 = has_received_229.take(); + if !event.is_composing() && event.input_type() != "insertText" { clear(&input, &last_text); - if event.input_type() == "deleteContentBackward" - && has_received_processed_keydown_event.take() - { + if event.input_type() == "deleteContentBackward" && has_received_229 { for pressed in [true, false] { runner.input.raw.events.push(egui::Event::Key { key: egui::Key::Backspace, @@ -168,15 +169,14 @@ impl TextAgent { }; let on_keydown = { - let has_received_processed_keydown_event = - Rc::clone(&has_received_processed_keydown_event); + let has_received_229 = Rc::clone(&has_received_229); move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { // 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() { return; } - if event.key() == "Process" { - has_received_processed_keydown_event.set(true); + if event.key_code() == 229 { + has_received_229.set(true); return; } @@ -189,7 +189,7 @@ impl TextAgent { let on_keyup = { move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { // 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() || event.key() == "Process" { + if event.is_composing() || event.key_code() == 229 { return; } From 69195bdc80871917f12167430412c42c78b636a3 Mon Sep 17 00:00:00 2001 From: umajho Date: Mon, 30 Mar 2026 22:13:17 +0800 Subject: [PATCH 05/17] Fix(eframe/web): try fixing GBoard Chinese Pinyin IME issue --- crates/eframe/src/web/text_agent.rs | 35 +++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index ca0e80ff0d8c..2a5c3344f822 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -19,7 +19,8 @@ pub struct TextAgent { impl TextAgent { /// Attach the agent to the document. pub fn attach(runner_ref: &WebRunner, root: Node) -> Result { - let document = web_sys::window().unwrap().document().unwrap(); + let window = web_sys::window().unwrap(); + let document = window.document().unwrap(); // create an `` element let input = document @@ -73,12 +74,10 @@ impl TextAgent { let last_text = Rc::clone(&last_text); let has_received_229 = Rc::clone(&has_received_229); move |event: web_sys::InputEvent, runner: &mut AppRunner| { - let has_received_229 = has_received_229.take(); - if !event.is_composing() && event.input_type() != "insertText" { clear(&input, &last_text); - if event.input_type() == "deleteContentBackward" && has_received_229 { + if event.input_type() == "deleteContentBackward" && has_received_229.take() { for pressed in [true, false] { runner.input.raw.events.push(egui::Event::Key { key: egui::Key::Backspace, @@ -170,15 +169,37 @@ impl TextAgent { let on_keydown = { let has_received_229 = Rc::clone(&has_received_229); + let input = input.clone(); + let last_text = Rc::clone(&last_text); move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { + if event.key_code() == 229 { + has_received_229.set(true); + + let reset_received_229 = { + let has_received_229 = Rc::clone(&has_received_229); + Closure::once_into_js(move || { + has_received_229.set(false); + }) + }; + + window + .set_timeout_with_callback(reset_received_229.unchecked_ref()) + .ok(); + + return; + } + // 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() { return; } - if event.key_code() == 229 { - has_received_229.set(true); - return; + if event.key().chars().count() > 1 + || event.ctrl_key() + || event.alt_key() + || event.meta_key() + { + clear(&input, &last_text); } // The canvas doesn't get keydown/keyup events when the text agent is focused, From ce148c5ff645ed4433445aa3933cb040c8658095 Mon Sep 17 00:00:00 2001 From: umajho Date: Wed, 1 Apr 2026 12:29:31 +0800 Subject: [PATCH 06/17] Fix: try fixing --- crates/eframe/src/web/text_agent.rs | 32 +++---- crates/egui/src/widgets/text_edit/builder.rs | 18 +--- .../egui/src/widgets/text_edit/text_buffer.rs | 88 +++++++++++++++++++ 3 files changed, 99 insertions(+), 39 deletions(-) diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 2a5c3344f822..822eb5bd09db 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -105,16 +105,17 @@ impl TextAgent { runner.input.raw.events.push(out_event); } + let preedit_text = text.chars().skip(prefix_len).collect(); let out_event = if event.is_composing() { - egui::Event::Ime(egui::ImeEvent::Preedit( - text.chars().skip(prefix_len).collect(), - )) + egui::Event::Ime(egui::ImeEvent::Preedit(preedit_text)) } else { - egui::Event::Text(text.chars().skip(prefix_len).collect()) + egui::Event::Text(preedit_text) }; runner.input.raw.events.push(out_event); - if !event.is_composing() { + if event.is_composing() { + *last_text_ref = text.chars().take(prefix_len).collect(); + } else { *last_text_ref = text; } @@ -135,25 +136,12 @@ impl TextAgent { let on_composition_end = { let input = input.clone(); let last_text = Rc::clone(&last_text); - move |_event: web_sys::CompositionEvent, runner: &mut AppRunner| { - let text = input.value(); - - let mut last_text_ref = last_text.borrow_mut(); - let prefix_len = longest_common_prefix_length(&text, &last_text_ref); - let last_text_len = last_text_ref.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 out_event = egui::Event::Ime(egui::ImeEvent::Commit( - text.chars().skip(prefix_len).collect(), - )); + move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { + let out_event = + egui::Event::Ime(egui::ImeEvent::Commit(event.data().unwrap_or_default())); runner.input.raw.events.push(out_event); - *last_text_ref = text; + *last_text.borrow_mut() = input.value(); runner.needs_repaint.repaint_asap(); } diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index c4e533f9ace5..e4b3b5232822 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -1200,23 +1200,7 @@ fn events( ImeEvent::DeleteSurrounding { before_chars, after_chars, - } => { - let mut ccurosr_end = cursor_range.secondary; - if *after_chars > 0 { - text.delete_selected_ccursor_range([ - ccurosr_end, - ccurosr_end + *after_chars, - ]); - } - if *before_chars > 0 { - text.delete_selected_ccursor_range([ - cursor_range.primary - *before_chars, - cursor_range.primary, - ]); - ccurosr_end -= *before_chars; - } - Some(CCursorRange::one(ccurosr_end)) - } + } => Some(text.delete_surrounding(cursor_range, *before_chars, *after_chars)), ImeEvent::Disabled => { state.ime_enabled = false; None diff --git a/crates/egui/src/widgets/text_edit/text_buffer.rs b/crates/egui/src/widgets/text_edit/text_buffer.rs index a67dc1b38510..2bec8b57353c 100644 --- a/crates/egui/src/widgets/text_edit/text_buffer.rs +++ b/crates/egui/src/widgets/text_edit/text_buffer.rs @@ -149,6 +149,24 @@ pub trait TextBuffer { self.delete_selected_ccursor_range([min_ccursor, max_ccursor]) } + fn delete_surrounding( + &mut self, + mut cursor_range: CCursorRange, + before_chars: usize, + after_chars: usize, + ) -> CCursorRange { + let [min, max] = cursor_range.sorted_cursors(); + if after_chars > 0 { + self.delete_selected_ccursor_range([max, max + after_chars]); + } + if before_chars > 0 { + self.delete_selected_ccursor_range([min - before_chars, min]); + cursor_range.primary -= before_chars; + cursor_range.secondary -= before_chars; + } + cursor_range + } + fn delete_paragraph_before_cursor( &mut self, galley: &Galley, @@ -315,3 +333,73 @@ impl TextBuffer for &str { std::any::TypeId::of::<&str>() } } + +#[cfg(test)] +mod tests { + use super::*; + + static CHAR_1_BYTE: &str = "a"; // 0x61 + static CHAR_2_BYTES: &str = "ĉ"; // 0xC4 0x89 + static CHAR_3_BYTES: &str = "字"; // 0xE5 0xAD 0x97 + + #[test] + fn test_delete_surrounding() { + fn test_case( + input_text: &str, + input_cursor_range: CCursorRange, + before_chars: usize, + after_chars: usize, + expected_text: &str, + expected_cursor_range: CCursorRange, + ) { + let mut text = input_text.to_owned(); + let new_cursor_range = + text.delete_surrounding(input_cursor_range, before_chars, after_chars); + assert_eq!(text, expected_text); + assert_eq!(new_cursor_range, expected_cursor_range); + } + + test_case( + &format!("<<{CHAR_1_BYTE}{CHAR_1_BYTE}>>"), + CCursorRange::one(CCursor::new(3)), + 1, + 1, + "<<>>", + CCursorRange::one(CCursor::new(2)), + ); + test_case( + &format!("<<{CHAR_1_BYTE}_{CHAR_1_BYTE}>>"), + CCursorRange::two(CCursor::new(3), CCursor::new(4)), + 1, + 1, + "<<_>>", + CCursorRange::two(CCursor::new(2), CCursor::new(3)), + ); + test_case( + &format!("<<{CHAR_2_BYTES}_{CHAR_2_BYTES}>>"), + CCursorRange::two(CCursor::new(3), CCursor::new(4)), + 1, + 1, + "<<_>>", + CCursorRange::two(CCursor::new(2), CCursor::new(3)), + ); + test_case( + &format!("<<{CHAR_3_BYTES}_{CHAR_3_BYTES}>>"), + CCursorRange::two(CCursor::new(3), CCursor::new(4)), + 1, + 1, + "<<_>>", + CCursorRange::two(CCursor::new(2), CCursor::new(3)), + ); + test_case( + &format!( + "<<{CHAR_1_BYTE}{CHAR_2_BYTES}{CHAR_3_BYTES}_{CHAR_1_BYTE}{CHAR_2_BYTES}{CHAR_3_BYTES}>>" + ), + CCursorRange::two(CCursor::new(5), CCursor::new(6)), + 3, + 3, + "<<_>>", + CCursorRange::two(CCursor::new(2), CCursor::new(3)), + ); + } +} From e223248de8160cda7a66a151c4326e3feed80fd0 Mon Sep 17 00:00:00 2001 From: umajho Date: Wed, 1 Apr 2026 14:39:43 +0800 Subject: [PATCH 07/17] Refactor(egui): improve naming, add docs, and add test cases --- crates/egui/src/widgets/text_edit/builder.rs | 6 +- .../egui/src/widgets/text_edit/text_buffer.rs | 140 +++++++++++------- 2 files changed, 94 insertions(+), 52 deletions(-) diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index e4b3b5232822..2ef02053b927 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -1200,7 +1200,11 @@ fn events( ImeEvent::DeleteSurrounding { before_chars, after_chars, - } => Some(text.delete_surrounding(cursor_range, *before_chars, *after_chars)), + } => Some(text.delete_surrounding_chars( + cursor_range, + *before_chars, + *after_chars, + )), ImeEvent::Disabled => { state.ime_enabled = false; None diff --git a/crates/egui/src/widgets/text_edit/text_buffer.rs b/crates/egui/src/widgets/text_edit/text_buffer.rs index 2bec8b57353c..385fa338d716 100644 --- a/crates/egui/src/widgets/text_edit/text_buffer.rs +++ b/crates/egui/src/widgets/text_edit/text_buffer.rs @@ -149,7 +149,13 @@ pub trait TextBuffer { self.delete_selected_ccursor_range([min_ccursor, max_ccursor]) } - fn delete_surrounding( + /// Deletes characters surrounding the current cursor range. + /// + /// Removes `before_chars` characters before the selection start and + /// `after_chars` characters after the selection end. + /// The returned [`CCursorRange`] is adjusted to account for the removed + /// characters before the selection. + fn delete_surrounding_chars( &mut self, mut cursor_range: CCursorRange, before_chars: usize, @@ -338,68 +344,100 @@ impl TextBuffer for &str { mod tests { use super::*; - static CHAR_1_BYTE: &str = "a"; // 0x61 - static CHAR_2_BYTES: &str = "ĉ"; // 0xC4 0x89 - static CHAR_3_BYTES: &str = "字"; // 0xE5 0xAD 0x97 - #[test] - fn test_delete_surrounding() { + fn test_delete_surrounding_chars() { fn test_case( - input_text: &str, - input_cursor_range: CCursorRange, + (mut input_text, input_cursor_range): (String, CCursorRange), before_chars: usize, after_chars: usize, - expected_text: &str, - expected_cursor_range: CCursorRange, + (expected_text, expected_cursor_range): (String, CCursorRange), ) { - let mut text = input_text.to_owned(); let new_cursor_range = - text.delete_surrounding(input_cursor_range, before_chars, after_chars); - assert_eq!(text, expected_text); + input_text.delete_surrounding_chars(input_cursor_range, before_chars, after_chars); + assert_eq!(input_text, expected_text); assert_eq!(new_cursor_range, expected_cursor_range); } - test_case( - &format!("<<{CHAR_1_BYTE}{CHAR_1_BYTE}>>"), - CCursorRange::one(CCursor::new(3)), - 1, - 1, - "<<>>", - CCursorRange::one(CCursor::new(2)), - ); - test_case( - &format!("<<{CHAR_1_BYTE}_{CHAR_1_BYTE}>>"), - CCursorRange::two(CCursor::new(3), CCursor::new(4)), - 1, - 1, - "<<_>>", - CCursorRange::two(CCursor::new(2), CCursor::new(3)), + fn txt_n_sel(input: &str) -> (String, CCursorRange) { + assert!( + input.matches('[').count() == 1 && input.matches(']').count() == 1, + "`input` must contain exactly one `[` and one `]` to indicate the selection (cursor range)" + ); + let mut primary_index = input.chars().position(|c| c == ']').unwrap(); + let mut secondary_index = input.chars().position(|c| c == '[').unwrap(); + let text = input.replace(['[', ']'], ""); + if primary_index > secondary_index { + primary_index -= 1; + } else { + secondary_index -= 1; + } + let cursor_range = CCursorRange { + primary: CCursor::new(primary_index), + secondary: CCursor::new(secondary_index), + h_pos: None, + }; + (text, cursor_range) + } + assert_eq!( + txt_n_sel("<>"), + ("<>".to_owned(), CCursorRange::one(CCursor::new(3))) ); - test_case( - &format!("<<{CHAR_2_BYTES}_{CHAR_2_BYTES}>>"), - CCursorRange::two(CCursor::new(3), CCursor::new(4)), - 1, - 1, - "<<_>>", - CCursorRange::two(CCursor::new(2), CCursor::new(3)), + assert_eq!( + txt_n_sel("<>"), + ( + "<>".to_owned(), + CCursorRange::two(CCursor::new(3), CCursor::new(4)) + ) ); - test_case( - &format!("<<{CHAR_3_BYTES}_{CHAR_3_BYTES}>>"), - CCursorRange::two(CCursor::new(3), CCursor::new(4)), - 1, - 1, - "<<_>>", - CCursorRange::two(CCursor::new(2), CCursor::new(3)), + assert_eq!( + txt_n_sel("<<左[_]右>>"), + ( + "<<左_右>>".to_owned(), + CCursorRange::two(CCursor::new(3), CCursor::new(4)) + ) ); - test_case( - &format!( - "<<{CHAR_1_BYTE}{CHAR_2_BYTES}{CHAR_3_BYTES}_{CHAR_1_BYTE}{CHAR_2_BYTES}{CHAR_3_BYTES}>>" - ), - CCursorRange::two(CCursor::new(5), CCursor::new(6)), - 3, - 3, - "<<_>>", - CCursorRange::two(CCursor::new(2), CCursor::new(3)), + assert_eq!( + txt_n_sel("<>"), + ( + "<>".to_owned(), + CCursorRange::two(CCursor::new(4), CCursor::new(3)) + ) ); + + test_case(txt_n_sel("<>"), 1, 1, txt_n_sel("<<[]>>")); + test_case(txt_n_sel("<>"), 1, 0, txt_n_sel("<<[_]R>>")); + test_case(txt_n_sel("<>"), 0, 1, txt_n_sel("<>")); + test_case(txt_n_sel("<>"), 1, 1, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<>"), 1, 1, txt_n_sel("<<[__]>>")); + test_case(txt_n_sel("<>"), 2, 2, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<>"), 1, 0, txt_n_sel("<<]_[R>>")); + test_case(txt_n_sel("<>"), 0, 1, txt_n_sel("<>")); + test_case(txt_n_sel("<>"), 1, 1, txt_n_sel("<<]_[>>")); + + // `˻`: 0xCB 0xBB + // `˼`: 0xCB 0xBC + test_case(txt_n_sel("<<˻[]˼>>"), 1, 1, txt_n_sel("<<[]>>")); + test_case(txt_n_sel("<<˻[_]˼>>"), 1, 0, txt_n_sel("<<[_]˼>>")); + test_case(txt_n_sel("<<˻[_]˼>>"), 0, 1, txt_n_sel("<<˻[_]>>")); + test_case(txt_n_sel("<<˻[_]˼>>"), 1, 1, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<<˻[__]˼>>"), 1, 1, txt_n_sel("<<[__]>>")); + test_case(txt_n_sel("<<˻˻[_]˼˼>>"), 2, 2, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<<˻]_[˼>>"), 1, 0, txt_n_sel("<<]_[˼>>")); + test_case(txt_n_sel("<<˻]_[˼>>"), 0, 1, txt_n_sel("<<˻]_[>>")); + test_case(txt_n_sel("<<˻]_[˼>>"), 1, 1, txt_n_sel("<<]_[>>")); + + // `左`: 0xE5 0xB7 0xA6 + // `右`: 0xE5 0x8F 0xB3 + test_case(txt_n_sel("<<左[]右>>"), 1, 1, txt_n_sel("<<[]>>")); + test_case(txt_n_sel("<<左[_]右>>"), 1, 0, txt_n_sel("<<[_]右>>")); + test_case(txt_n_sel("<<左[_]右>>"), 0, 1, txt_n_sel("<<左[_]>>")); + test_case(txt_n_sel("<<左[_]右>>"), 1, 1, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<<左[__]右>>"), 1, 1, txt_n_sel("<<[__]>>")); + test_case(txt_n_sel("<<左左[_]右右>>"), 2, 2, txt_n_sel("<<[_]>>")); + test_case(txt_n_sel("<<左]_[右>>"), 1, 0, txt_n_sel("<<]_[右>>")); + test_case(txt_n_sel("<<左]_[右>>"), 0, 1, txt_n_sel("<<左]_[>>")); + test_case(txt_n_sel("<<左]_[右>>"), 1, 1, txt_n_sel("<<]_[>>")); + + test_case(txt_n_sel("<>"), 3, 3, txt_n_sel("<<[_]>>")); } } From f139c604bfcde8ccffa103d33128af6de35a230c Mon Sep 17 00:00:00 2001 From: umajho Date: Mon, 6 Apr 2026 01:48:58 +0800 Subject: [PATCH 08/17] Fix(eframe/web): try fixing problem with Samsung Keyboard Korean auto-commiting --- crates/eframe/src/web/text_agent.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 822eb5bd09db..c67cae8f60d1 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -136,12 +136,18 @@ impl TextAgent { let on_composition_end = { let input = input.clone(); let last_text = Rc::clone(&last_text); - move |event: web_sys::CompositionEvent, runner: &mut AppRunner| { - let out_event = - egui::Event::Ime(egui::ImeEvent::Commit(event.data().unwrap_or_default())); + move |_event: web_sys::CompositionEvent, runner: &mut AppRunner| { + let mut last_text_ref = last_text.borrow_mut(); + let text = input.value(); + + let commit_text = { + let prefix_len = last_text_ref.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); - *last_text.borrow_mut() = input.value(); + *last_text_ref = text; runner.needs_repaint.repaint_asap(); } From 5ce55ad27c5f658e2d9ae51c8e4e1391c7ff7e7e Mon Sep 17 00:00:00 2001 From: umajho Date: Mon, 6 Apr 2026 18:20:43 +0800 Subject: [PATCH 09/17] Fix(eframe/web): text agent state not cleared on blur --- crates/eframe/src/web/text_agent.rs | 101 ++++++++++++++++------------ 1 file changed, 59 insertions(+), 42 deletions(-) diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 366854a13855..3afa78dbec79 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -13,9 +13,43 @@ use super::{AppRunner, WebRunner}; pub struct TextAgent { input: web_sys::HtmlInputElement, + input_state: Rc>, prev_ime_output: Cell>, } +struct InputState { + input: web_sys::HtmlInputElement, + last_text: String, + + /// Workaround for Gboard on Android, which sometimes sends a + /// `KeyboardEvent` with `key` set to `"Process"` (Firefox) or + /// `"Unidentified"` (Chrome) for backspace presses. In both cases, the + /// `keyCode` is `229`, hence the name `has_received_229`. + has_received_229: bool, +} + +impl InputState { + fn new(input: web_sys::HtmlInputElement) -> Self { + Self { + input, + last_text: String::new(), + has_received_229: false, + } + } + + fn clear(&mut self) { + self.input.set_value(""); + self.last_text.clear(); + self.has_received_229 = false; + } + + fn take_has_received_229(&mut self) -> bool { + let has_received_229 = self.has_received_229; + self.has_received_229 = false; + has_received_229 + } +} + impl TextAgent { /// Attach the agent to the document. pub fn attach(runner_ref: &WebRunner, root: Node) -> Result { @@ -30,6 +64,7 @@ impl TextAgent { let input = input.dyn_into::()?; input.set_type("text"); input.set_attribute("autocapitalize", "off")?; + let input_state = Rc::new(RefCell::new(InputState::new(input.clone()))); // append it to `` and hide it outside of the viewport let style = input.style(); @@ -54,30 +89,19 @@ impl TextAgent { root.append_child(&input)?; } - let last_text = Rc::new(RefCell::new(String::new())); - - fn clear(input: &web_sys::HtmlInputElement, last_text: &RefCell) { - input.set_value(""); - last_text.borrow_mut().clear(); - } - - // Workaround for GBoard on Android, which sometimes sends a - // `KeyboardEvent` with `key` set to `"Process"` (Firefox) or - // `"Unidentified"` (Chrome) for backspace presses. In both cases, - // the `keyCode` is `229`, hence the name `has_received_229`. - let has_received_229 = Rc::new(Cell::new(false)); - // attach event listeners let on_input = { let input = input.clone(); - let last_text = Rc::clone(&last_text); - let has_received_229 = Rc::clone(&has_received_229); + let input_state = Rc::clone(&input_state); move |event: web_sys::InputEvent, runner: &mut AppRunner| { + let mut input_state_ref = input_state.borrow_mut(); if !event.is_composing() && event.input_type() != "insertText" { - clear(&input, &last_text); + input_state_ref.clear(); - if event.input_type() == "deleteContentBackward" && has_received_229.take() { + if event.input_type() == "deleteContentBackward" + && input_state_ref.take_has_received_229() + { for pressed in [true, false] { runner.input.raw.events.push(egui::Event::Key { key: egui::Key::Backspace, @@ -94,9 +118,8 @@ impl TextAgent { let text = input.value(); - let mut last_text_ref = last_text.borrow_mut(); - let prefix_len = longest_common_prefix_length(&text, &last_text_ref); - let last_text_len = last_text_ref.chars().count(); + let prefix_len = longest_common_prefix_length(&text, &input_state_ref.last_text); + let last_text_len = input_state_ref.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, @@ -114,9 +137,9 @@ impl TextAgent { runner.input.raw.events.push(out_event); if event.is_composing() { - *last_text_ref = text.chars().take(prefix_len).collect(); + input_state_ref.last_text = text.chars().take(prefix_len).collect(); } else { - *last_text_ref = text; + input_state_ref.last_text = text; } runner.needs_repaint.repaint_asap(); @@ -133,44 +156,37 @@ impl TextAgent { let on_composition_end = { let input = input.clone(); - let last_text = Rc::clone(&last_text); + let input_state = Rc::clone(&input_state); move |_event: web_sys::CompositionEvent, runner: &mut AppRunner| { - let mut last_text_ref = last_text.borrow_mut(); + let mut input_state_ref = input_state.borrow_mut(); + let text = input.value(); let commit_text = { - let prefix_len = last_text_ref.chars().count(); + let prefix_len = input_state_ref.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); - *last_text_ref = text; + input_state_ref.last_text = text; runner.needs_repaint.repaint_asap(); } }; - let on_blur = { - let input = input.clone(); - let last_text = Rc::clone(&last_text); - move |_: web_sys::FocusEvent, _runner: &mut AppRunner| { - clear(&input, &last_text); - } - }; - let on_keydown = { - let has_received_229 = Rc::clone(&has_received_229); - let input = input.clone(); - let last_text = Rc::clone(&last_text); + let input_state = Rc::clone(&input_state); move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { + let mut input_state_ref = input_state.borrow_mut(); + if event.key_code() == 229 { - has_received_229.set(true); + input_state_ref.has_received_229 = true; let reset_received_229 = { - let has_received_229 = Rc::clone(&has_received_229); + let input_state = Rc::clone(&input_state); Closure::once_into_js(move || { - has_received_229.set(false); + input_state.borrow_mut().has_received_229 = false; }) }; @@ -191,7 +207,7 @@ impl TextAgent { || event.alt_key() || event.meta_key() { - clear(&input, &last_text); + input_state_ref.clear(); } // The canvas doesn't get keydown/keyup events when the text agent is focused, @@ -215,7 +231,6 @@ impl TextAgent { 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, "compositionend", on_composition_end)?; - runner_ref.add_event_listener(&input, "blur", on_blur)?; runner_ref.add_event_listener(&input, "keydown", on_keydown)?; // The canvas doesn't get keydown/keyup events when the text agent is focused, @@ -224,6 +239,7 @@ impl TextAgent { Ok(Self { input, + input_state, prev_ime_output: Default::default(), }) } @@ -298,6 +314,7 @@ impl TextAgent { 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(); } } From 7a2d9df5738ccb2e8727b8b330842de5f60209c9 Mon Sep 17 00:00:00 2001 From: umajho Date: Thu, 9 Apr 2026 00:19:22 +0800 Subject: [PATCH 10/17] Refactor: minor changes (cherry picked from commit 288224764bc979b3fb889135ce15823731184540) --- crates/eframe/src/web/text_agent.rs | 4 +- .../egui/src/widgets/text_edit/text_buffer.rs | 76 ++++++++++--------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index ad139eb4e4cd..628c78f9be66 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -232,10 +232,8 @@ impl TextAgent { runner_ref.add_event_listener(&input, "compositionstart", on_composition_start)?; runner_ref.add_event_listener(&input, "compositionend", on_composition_end)?; - runner_ref.add_event_listener(&input, "keydown", on_keydown)?; - // The canvas doesn't get keydown/keyup events when the text agent is focused, - // so we need to forward them to the runner: runner_ref.add_event_listener(&input, "keyup", on_keyup)?; + runner_ref.add_event_listener(&input, "keydown", on_keydown)?; Ok(Self { input, diff --git a/crates/egui/src/widgets/text_edit/text_buffer.rs b/crates/egui/src/widgets/text_edit/text_buffer.rs index 353a165ea879..100cdc6538a4 100644 --- a/crates/egui/src/widgets/text_edit/text_buffer.rs +++ b/crates/egui/src/widgets/text_edit/text_buffer.rs @@ -344,40 +344,29 @@ impl TextBuffer for &str { mod tests { use super::*; - #[test] - fn test_delete_surrounding_chars() { - fn test_case( - (mut input_text, input_cursor_range): (String, CCursorRange), - before_chars: usize, - after_chars: usize, - (expected_text, expected_cursor_range): (String, CCursorRange), - ) { - let new_cursor_range = - input_text.delete_surrounding_chars(input_cursor_range, before_chars, after_chars); - assert_eq!(input_text, expected_text); - assert_eq!(new_cursor_range, expected_cursor_range); + fn txt_n_sel(input: &str) -> (String, CCursorRange) { + assert!( + input.matches('[').count() == 1 && input.matches(']').count() == 1, + "`input` must contain exactly one `[` and one `]` to indicate the selection (cursor range)" + ); + let mut primary_index = input.chars().position(|c| c == ']').unwrap(); + let mut secondary_index = input.chars().position(|c| c == '[').unwrap(); + let text = input.replace(['[', ']'], ""); + if primary_index > secondary_index { + primary_index -= 1; + } else { + secondary_index -= 1; } + let cursor_range = CCursorRange { + primary: CCursor::new(primary_index), + secondary: CCursor::new(secondary_index), + h_pos: None, + }; + (text, cursor_range) + } - fn txt_n_sel(input: &str) -> (String, CCursorRange) { - assert!( - input.matches('[').count() == 1 && input.matches(']').count() == 1, - "`input` must contain exactly one `[` and one `]` to indicate the selection (cursor range)" - ); - let mut primary_index = input.chars().position(|c| c == ']').unwrap(); - let mut secondary_index = input.chars().position(|c| c == '[').unwrap(); - let text = input.replace(['[', ']'], ""); - if primary_index > secondary_index { - primary_index -= 1; - } else { - secondary_index -= 1; - } - let cursor_range = CCursorRange { - primary: CCursor::new(primary_index), - secondary: CCursor::new(secondary_index), - h_pos: None, - }; - (text, cursor_range) - } + #[test] + fn test_txt_n_sel() { assert_eq!( txt_n_sel("<>"), ("<>".to_owned(), CCursorRange::one(CCursor::new(3))) @@ -403,7 +392,23 @@ mod tests { CCursorRange::two(CCursor::new(4), CCursor::new(3)) ) ); + } + + #[test] + fn test_delete_surrounding_chars() { + fn test_case( + (mut input_text, input_cursor_range): (String, CCursorRange), + before_chars: usize, + after_chars: usize, + (expected_text, expected_cursor_range): (String, CCursorRange), + ) { + let new_cursor_range = + input_text.delete_surrounding_chars(input_cursor_range, before_chars, after_chars); + assert_eq!(input_text, expected_text); + assert_eq!(new_cursor_range, expected_cursor_range); + } + // 1 byte per char test_case(txt_n_sel("<>"), 1, 1, txt_n_sel("<<[]>>")); test_case(txt_n_sel("<>"), 1, 0, txt_n_sel("<<[_]R>>")); test_case(txt_n_sel("<>"), 0, 1, txt_n_sel("<>")); @@ -414,8 +419,7 @@ mod tests { test_case(txt_n_sel("<>"), 0, 1, txt_n_sel("<>")); test_case(txt_n_sel("<>"), 1, 1, txt_n_sel("<<]_[>>")); - // `˻`: 0xCB 0xBB - // `˼`: 0xCB 0xBC + // 2 bytes per char: `˻` = `0xCB 0xBB`, `˼` = `0xCB 0xBC` test_case(txt_n_sel("<<˻[]˼>>"), 1, 1, txt_n_sel("<<[]>>")); test_case(txt_n_sel("<<˻[_]˼>>"), 1, 0, txt_n_sel("<<[_]˼>>")); test_case(txt_n_sel("<<˻[_]˼>>"), 0, 1, txt_n_sel("<<˻[_]>>")); @@ -426,8 +430,7 @@ mod tests { test_case(txt_n_sel("<<˻]_[˼>>"), 0, 1, txt_n_sel("<<˻]_[>>")); test_case(txt_n_sel("<<˻]_[˼>>"), 1, 1, txt_n_sel("<<]_[>>")); - // `左`: 0xE5 0xB7 0xA6 - // `右`: 0xE5 0x8F 0xB3 + // 3 bytes per char: `左` = `0xE5 0xB7 0xA6`, `右` = `0xE5 0x8F 0xB3` test_case(txt_n_sel("<<左[]右>>"), 1, 1, txt_n_sel("<<[]>>")); test_case(txt_n_sel("<<左[_]右>>"), 1, 0, txt_n_sel("<<[_]右>>")); test_case(txt_n_sel("<<左[_]右>>"), 0, 1, txt_n_sel("<<左[_]>>")); @@ -438,6 +441,7 @@ mod tests { test_case(txt_n_sel("<<左]_[右>>"), 0, 1, txt_n_sel("<<左]_[>>")); test_case(txt_n_sel("<<左]_[右>>"), 1, 1, txt_n_sel("<<]_[>>")); + // mixed test_case(txt_n_sel("<>"), 3, 3, txt_n_sel("<<[_]>>")); } } From 3a90774dfa3fc5f5024b360071dc017d907b3667 Mon Sep 17 00:00:00 2001 From: umajho Date: Thu, 9 Apr 2026 00:40:42 +0800 Subject: [PATCH 11/17] Refactor(eframe/web): remove an obscure workaround that also not seem to work (cherry picked from commit 0273f25b51b88b20ebba1e8a51952656383d6c71) --- crates/eframe/src/web/text_agent.rs | 47 +---------------------------- 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 628c78f9be66..0277142c9d77 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -20,12 +20,6 @@ pub struct TextAgent { struct InputState { input: web_sys::HtmlInputElement, last_text: String, - - /// Workaround for Gboard on Android, which sometimes sends a - /// `KeyboardEvent` with `key` set to `"Process"` (Firefox) or - /// `"Unidentified"` (Chrome) for backspace presses. In both cases, the - /// `keyCode` is `229`, hence the name `has_received_229`. - has_received_229: bool, } impl InputState { @@ -33,20 +27,12 @@ impl InputState { Self { input, last_text: String::new(), - has_received_229: false, } } fn clear(&mut self) { self.input.set_value(""); self.last_text.clear(); - self.has_received_229 = false; - } - - fn take_has_received_229(&mut self) -> bool { - let has_received_229 = self.has_received_229; - self.has_received_229 = false; - has_received_229 } } @@ -99,20 +85,6 @@ impl TextAgent { if !event.is_composing() && event.input_type() != "insertText" { input_state_ref.clear(); - if event.input_type() == "deleteContentBackward" - && input_state_ref.take_has_received_229() - { - for pressed in [true, false] { - runner.input.raw.events.push(egui::Event::Key { - key: egui::Key::Backspace, - physical_key: None, // TODO(fornwall) - pressed, - repeat: false, - modifiers: egui::Modifiers::default(), - }); - } - } - return; } @@ -180,25 +152,8 @@ impl TextAgent { move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { let mut input_state_ref = input_state.borrow_mut(); - if event.key_code() == 229 { - input_state_ref.has_received_229 = true; - - let reset_received_229 = { - let input_state = Rc::clone(&input_state); - Closure::once_into_js(move || { - input_state.borrow_mut().has_received_229 = false; - }) - }; - - window - .set_timeout_with_callback(reset_received_229.unchecked_ref()) - .ok(); - - return; - } - // 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() { + if event.is_composing() || event.key_code() == 229 { return; } From 948531e38f95330465502e067f4f901782ace84d Mon Sep 17 00:00:00 2001 From: umajho Date: Wed, 15 Apr 2026 16:03:21 +0800 Subject: [PATCH 12/17] Refactor(eframe/web): move event handling within `impl InputState` --- crates/eframe/src/web/text_agent.rs | 204 +++++++++++++++------------- 1 file changed, 108 insertions(+), 96 deletions(-) diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 0277142c9d77..f314f6c931f7 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -34,6 +34,84 @@ impl InputState { self.input.set_value(""); self.last_text.clear(); } + + fn handle_input_event(&mut self, event: &web_sys::InputEvent, runner: &mut AppRunner) { + if !event.is_composing() && event.input_type() != "insertText" { + 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 = text.chars().skip(prefix_len).collect(); + let out_event = if event.is_composing() { + egui::Event::Ime(egui::ImeEvent::Preedit(preedit_text)) + } 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_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 { + // 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() || event.key_code() == 229 { + 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(event: &web_sys::KeyboardEvent) -> bool { + // 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 + } } impl TextAgent { @@ -77,118 +155,52 @@ impl TextAgent { // attach event listeners - let on_input = { - let input = input.clone(); - let input_state = Rc::clone(&input_state); - move |event: web_sys::InputEvent, runner: &mut AppRunner| { - let mut input_state_ref = input_state.borrow_mut(); - if !event.is_composing() && event.input_type() != "insertText" { - input_state_ref.clear(); - - return; - } - - let text = input.value(); - - let prefix_len = longest_common_prefix_length(&text, &input_state_ref.last_text); - let last_text_len = input_state_ref.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 = text.chars().skip(prefix_len).collect(); - let out_event = if event.is_composing() { - egui::Event::Ime(egui::ImeEvent::Preedit(preedit_text)) - } else { - egui::Event::Text(preedit_text) - }; - runner.input.raw.events.push(out_event); - - if event.is_composing() { - input_state_ref.last_text = text.chars().take(prefix_len).collect(); - } else { - input_state_ref.last_text = text; - } - - runner.needs_repaint.repaint_asap(); - } - }; - - let on_composition_start = { + runner_ref.add_event_listener( + &input, + "compositionstart", move |_: web_sys::CompositionEvent, runner: &mut AppRunner| { // Repaint moves the text agent into place, // see `move_to` in `AppRunner::handle_platform_output`. runner.needs_repaint.repaint_asap(); - } - }; + }, + )?; - let on_composition_end = { - let input = input.clone(); + 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| { - let mut input_state_ref = input_state.borrow_mut(); - - let text = input.value(); - - let commit_text = { - let prefix_len = input_state_ref.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); - - input_state_ref.last_text = text; - - runner.needs_repaint.repaint_asap(); + input_state + .borrow_mut() + .handle_composition_end_event(runner); } - }; + })?; - let on_keydown = { + runner_ref.add_event_listener(&input, "keydown", { let input_state = Rc::clone(&input_state); move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { - let mut input_state_ref = input_state.borrow_mut(); - - // 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() || event.key_code() == 229 { - return; + 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); } - - if event.key().chars().count() > 1 - || event.ctrl_key() - || event.alt_key() - || event.meta_key() - { - input_state_ref.clear(); - } - - // 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); } - }; - let on_keyup = { + })?; + runner_ref.add_event_listener(&input, "keyup", { move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { - // 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() || event.key_code() == 229 { - return; + let is_consumed = InputState::handle_keyup_event(&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); } - - // 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); } - }; - - 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, "compositionend", on_composition_end)?; - - runner_ref.add_event_listener(&input, "keyup", on_keyup)?; - runner_ref.add_event_listener(&input, "keydown", on_keydown)?; + })?; Ok(Self { input, From e16658cb13efc8b5703a6b716e18b787cb5b771a Mon Sep 17 00:00:00 2001 From: umajho Date: Wed, 15 Apr 2026 17:21:54 +0800 Subject: [PATCH 13/17] Feat: introduce custom debug informations in `debug_assertions` to help debug --- crates/eframe/src/web/app_runner.rs | 6 +++++ crates/eframe/src/web/events.rs | 5 ++++ crates/eframe/src/web/text_agent.rs | 24 ++++++++++++++++++ crates/egui/src/context.rs | 39 +++++++++++++++++++++++++++++ crates/egui/src/data/input.rs | 5 ++++ 5 files changed, 79 insertions(+) diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index c15e78d68692..bf5170b10d35 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -414,6 +414,12 @@ impl AppRunner { ); } } + + #[cfg(debug_assertions)] + pub(crate) fn update_custom_debug_informations(&mut self) { + self.text_agent + .update_custom_debug_informations(&mut self.input); + } } // ---------------------------------------------------------------------------- diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index 9f0c1fc237c5..d630a959f7ca 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -30,6 +30,11 @@ pub(crate) fn paint_and_schedule(runner_ref: &WebRunner) -> Result<(), JsValue> fn paint_if_needed(runner: &mut AppRunner) { if runner.needs_repaint.needs_repaint() { + #[cfg(debug_assertions)] + if !runner.input.raw.events.is_empty() { + runner.update_custom_debug_informations(); + } + if runner.has_outstanding_paint_data() { // We have already run the logic, e.g. in an on-click event, // so let's only present the results: diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index f314f6c931f7..6d809b4450d6 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -112,6 +112,23 @@ impl InputState { // 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 } + + #[cfg(debug_assertions)] + fn update_custom_debug_informations(&self, input: &mut crate::web::WebInput) { + input + .raw + .events + .push(egui::Event::CustomDebugInformationUpdated { + name: "eframe::web::text_agent::InputState".to_owned(), + value: format!( + " +last_text: {:?} +input.value: {:?}", + self.last_text, + self.input.value(), + ), + }); + } } impl TextAgent { @@ -287,6 +304,13 @@ impl TextAgent { } self.input_state.borrow_mut().clear(); } + + #[cfg(debug_assertions)] + pub(crate) fn update_custom_debug_informations(&self, input: &mut crate::web::WebInput) { + self.input_state + .borrow_mut() + .update_custom_debug_informations(input); + } } impl Drop for TextAgent { diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index d348517ef563..7b1229dae5fd 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -405,6 +405,9 @@ struct ContextImpl { is_accesskit_enabled: bool, loaders: Arc, + + #[cfg(debug_assertions)] + custom_debug_informations: CustomDebugInformations, } impl ContextImpl { @@ -453,6 +456,15 @@ impl ContextImpl { self.memory.begin_pass(&new_raw_input, &all_viewport_ids); + #[cfg(debug_assertions)] + for ev in &new_raw_input.events { + if let crate::Event::CustomDebugInformationUpdated { name, value } = ev { + self.custom_debug_informations + .0 + .insert(name.clone(), value.clone()); + } + } + viewport.input = std::mem::take(&mut viewport.input).begin_pass( new_raw_input, viewport.repaint.requested_immediate_repaint_prev_pass(), @@ -3287,6 +3299,14 @@ impl Context { let interact_widgets = self.write(|ctx| ctx.viewport().interact_widgets.clone()); interact_widgets.ui(ui); }); + + #[cfg(debug_assertions)] + CollapsingHeader::new("Custom debug informations") + .default_open(false) + .show(ui, |ui| { + self.read(|ctx| ctx.custom_debug_informations.clone()) + .ui(ui); + }); } /// Show stats about the allocated textures. @@ -4219,6 +4239,25 @@ fn warn_if_rect_changes_id( } } +#[cfg(debug_assertions)] +#[derive(Default, Clone)] +struct CustomDebugInformations(std::collections::HashMap); + +impl CustomDebugInformations { + fn ui(&self, ui: &mut Ui) { + let mut names = self.0.keys().cloned().collect::>(); + names.sort(); + for name in names { + let value = &self.0[&name]; + crate::CollapsingHeader::new(name) + .default_open(true) + .show(ui, |ui| { + ui.label(value); + }); + } + } +} + #[cfg(test)] mod test { use super::Context; diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 1e8710cac7c8..0ddb4102adf0 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -596,6 +596,11 @@ pub enum Event { image: std::sync::Arc, }, + + /// Custom debug information. They can be viewed in the inspection UI in + /// debug builds. + #[cfg(debug_assertions)] + CustomDebugInformationUpdated { name: String, value: String }, } /// IME event. From 33695e39a648c0af5c8a8b19f61d4d8fb17b6baf Mon Sep 17 00:00:00 2001 From: umajho Date: Wed, 15 Apr 2026 17:28:06 +0800 Subject: [PATCH 14/17] fixup --- crates/egui/src/context.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 7b1229dae5fd..41b4041070ca 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -4243,6 +4243,7 @@ fn warn_if_rect_changes_id( #[derive(Default, Clone)] struct CustomDebugInformations(std::collections::HashMap); +#[cfg(debug_assertions)] impl CustomDebugInformations { fn ui(&self, ui: &mut Ui) { let mut names = self.0.keys().cloned().collect::>(); From 886ad4c3152873e7484e16b683448da33a678e3b Mon Sep 17 00:00:00 2001 From: umajho Date: Wed, 15 Apr 2026 17:31:22 +0800 Subject: [PATCH 15/17] fix my English --- crates/eframe/src/web/app_runner.rs | 4 ++-- crates/eframe/src/web/events.rs | 2 +- crates/eframe/src/web/text_agent.rs | 6 +++--- crates/egui/src/context.rs | 13 ++++++------- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index bf5170b10d35..e1da3b3c28de 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -416,9 +416,9 @@ impl AppRunner { } #[cfg(debug_assertions)] - pub(crate) fn update_custom_debug_informations(&mut self) { + pub(crate) fn update_custom_debug_information(&mut self) { self.text_agent - .update_custom_debug_informations(&mut self.input); + .update_custom_debug_information(&mut self.input); } } diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index d630a959f7ca..1d0938c1647d 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -32,7 +32,7 @@ fn paint_if_needed(runner: &mut AppRunner) { if runner.needs_repaint.needs_repaint() { #[cfg(debug_assertions)] if !runner.input.raw.events.is_empty() { - runner.update_custom_debug_informations(); + runner.update_custom_debug_information(); } if runner.has_outstanding_paint_data() { diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 6d809b4450d6..6bd8e0abb991 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -114,7 +114,7 @@ impl InputState { } #[cfg(debug_assertions)] - fn update_custom_debug_informations(&self, input: &mut crate::web::WebInput) { + fn update_custom_debug_information(&self, input: &mut crate::web::WebInput) { input .raw .events @@ -306,10 +306,10 @@ impl TextAgent { } #[cfg(debug_assertions)] - pub(crate) fn update_custom_debug_informations(&self, input: &mut crate::web::WebInput) { + pub(crate) fn update_custom_debug_information(&self, input: &mut crate::web::WebInput) { self.input_state .borrow_mut() - .update_custom_debug_informations(input); + .update_custom_debug_information(input); } } diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 41b4041070ca..0d130ef621d5 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -407,7 +407,7 @@ struct ContextImpl { loaders: Arc, #[cfg(debug_assertions)] - custom_debug_informations: CustomDebugInformations, + custom_debug_information: CustomDebugInformation, } impl ContextImpl { @@ -459,7 +459,7 @@ impl ContextImpl { #[cfg(debug_assertions)] for ev in &new_raw_input.events { if let crate::Event::CustomDebugInformationUpdated { name, value } = ev { - self.custom_debug_informations + self.custom_debug_information .0 .insert(name.clone(), value.clone()); } @@ -3301,11 +3301,10 @@ impl Context { }); #[cfg(debug_assertions)] - CollapsingHeader::new("Custom debug informations") + CollapsingHeader::new("Custom debug information") .default_open(false) .show(ui, |ui| { - self.read(|ctx| ctx.custom_debug_informations.clone()) - .ui(ui); + self.read(|ctx| ctx.custom_debug_information.clone()).ui(ui); }); } @@ -4241,10 +4240,10 @@ fn warn_if_rect_changes_id( #[cfg(debug_assertions)] #[derive(Default, Clone)] -struct CustomDebugInformations(std::collections::HashMap); +struct CustomDebugInformation(std::collections::HashMap); #[cfg(debug_assertions)] -impl CustomDebugInformations { +impl CustomDebugInformation { fn ui(&self, ui: &mut Ui) { let mut names = self.0.keys().cloned().collect::>(); names.sort(); From 909050b64a0bd3dc6c0b27de724b61d4e7a246bb Mon Sep 17 00:00:00 2001 From: umajho Date: Fri, 26 Jun 2026 18:38:22 +0800 Subject: [PATCH 16/17] Style(eframe/web): make CI checks happy --- crates/eframe/src/web/text_agent.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 2e23ab6d0381..beafe2710cfb 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -380,5 +380,7 @@ fn is_mobile_safari() -> bool { } fn longest_common_prefix_length(a: &str, b: &str) -> usize { - a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count() + std::iter::zip(a.chars(), b.chars()) + .take_while(|(a, b)| a == b) + .count() } From 5c34b39f8295eaab8c083af83e4e524f10845296 Mon Sep 17 00:00:00 2001 From: umajho Date: Thu, 2 Jul 2026 00:54:03 +0800 Subject: [PATCH 17/17] Fix: fix bugs surfaced after #8083 merged found by rustbasic see: https://github.com/emilk/egui/pull/8045#issuecomment-4820226915 --- crates/eframe/src/web/app_runner.rs | 5 +- crates/eframe/src/web/text_agent.rs | 6 + crates/egui/src/widgets/text_edit/builder.rs | 137 ++++++++++++------- 3 files changed, 101 insertions(+), 47 deletions(-) diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 7493a63498c5..b5b3c7d723a8 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -395,7 +395,10 @@ impl AppRunner { if self.has_focus() { // The eframe app has focus. - if ime.is_some() { + if let Some(ime) = ime { + if ime.should_interrupt_composition { + self.text_agent.interrupt_ime_composition(); + } // We are editing text: give the focus to the text agent. self.text_agent.focus(); } else { diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index beafe2710cfb..7559cb591cb3 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -96,6 +96,8 @@ impl InputState { 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; } @@ -358,6 +360,10 @@ impl TextAgent { .borrow_mut() .update_custom_debug_information(input); } + + pub(crate) fn interrupt_ime_composition(&self) { + self.input_state.borrow_mut().clear(); + } } impl Drop for TextAgent { diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 0d522b49a518..bf2493aff3e6 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -527,6 +527,11 @@ impl TextEdit<'_> { let mut cursor_range = None; let mut prev_cursor_range = None; + let owns_ime_events = ui.memory(|mem| mem.owns_ime_events(id)); + if !owns_ime_events { + state.cursor_purpose = TextEditCursorPurpose::Selection; + } + let mut text_changed = false; let text_mutable = text.is_mutable(); @@ -547,14 +552,17 @@ impl TextEdit<'_> { text, galley, layouter, - id, - wrap_width, - multiline, - password, - default_cursor_range, - char_limit, - event_filter, - return_key, + &EventsOptions { + id, + wrap_width, + multiline, + password, + default_cursor_range, + owns_ime_events, + char_limit, + event_filter, + return_key, + }, ); if changed { @@ -772,6 +780,7 @@ impl TextEdit<'_> { if did_interact || response.clicked() { ui.memory_mut(|mem| mem.request_focus(response.id)); + state.cursor_purpose = TextEditCursorPurpose::Selection; state.last_interaction_time = ui.input(|i| i.time); } @@ -993,25 +1002,44 @@ fn mask_if_password(is_password: bool, text: &str) -> String { // ---------------------------------------------------------------------------- -/// Check for (keyboard) events to edit the cursor and/or text. -#[expect(clippy::too_many_arguments)] -fn events( - ui: &crate::Ui, - state: &mut TextEditState, - text: &mut dyn TextBuffer, - galley: &mut Arc, - layouter: &mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc, +/// Bundles parameters for [`events`] to avoid `clippy::too_many_arguments` and +/// `clippy::fn_params_excessive_bools`. +struct EventsOptions { id: Id, wrap_width: f32, multiline: bool, password: bool, default_cursor_range: CCursorRange, + owns_ime_events: bool, char_limit: usize, event_filter: EventFilter, return_key: Option, +} + +/// Check for (keyboard) events to edit the cursor and/or text. +fn events( + ui: &crate::Ui, + state: &mut TextEditState, + text: &mut dyn TextBuffer, + galley: &mut Arc, + layouter: &mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc, + opts: &EventsOptions, ) -> (bool, CCursorRange) { + let EventsOptions { + id, + wrap_width, + multiline, + password, + default_cursor_range, + owns_ime_events, + char_limit, + event_filter, + return_key, + } = *opts; + let os = ui.os(); + // let mut cursor_range = state.cursor.range(galley).unwrap_or(default_cursor_range); let mut cursor_range = state.cursor.range(galley).unwrap_or(default_cursor_range); // We feed state to the undoer both before and after handling input @@ -1031,9 +1059,13 @@ fn events( let events = ui.input(|i| i.filtered_events(&event_filter)); - let owns_ime_events = ui.memory(|mem| mem.owns_ime_events(id)); - if !owns_ime_events { - state.cursor_purpose = TextEditCursorPurpose::Selection; + enum CursorMutation { + Selection(CCursorRange), + ImeComposition { + cursor_range: CCursorRange, + active_range: Option>, + }, + ImeCompositionCursorRange(CCursorRange), } for event in &events { @@ -1052,7 +1084,9 @@ fn events( None } else { copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned()); - Some(CCursorRange::one(text.delete_selected(&cursor_range))) + Some(CursorMutation::Selection(CCursorRange::one( + text.delete_selected(&cursor_range), + ))) } } Event::Paste(text_to_insert) => { @@ -1067,7 +1101,7 @@ fn events( text.insert_text_at(&mut ccursor, &single_line, char_limit); } - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } } Event::Text(text_to_insert) => { @@ -1077,7 +1111,7 @@ fn events( text.insert_text_at(&mut ccursor, text_to_insert, char_limit); - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } else { None } @@ -1095,7 +1129,7 @@ fn events( } else { text.insert_text_at(&mut ccursor, "\t", char_limit); } - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } Event::Key { key, @@ -1110,7 +1144,7 @@ fn events( let mut ccursor = text.delete_selected(&cursor_range); text.insert_text_at(&mut ccursor, "\n", char_limit); // TODO(emilk): if code editor, auto-indent by same leading tabs, + one if the lines end on an opening bracket - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } else { ui.memory_mut(|mem| mem.surrender_focus(id)); // End input with enter break; @@ -1132,7 +1166,7 @@ fn events( .redo(&(cursor_range, text.as_str().to_owned())) { text.replace_with(redo_txt); - Some(*redo_ccursor_range) + Some(CursorMutation::Selection(*redo_ccursor_range)) } else { None } @@ -1150,7 +1184,7 @@ fn events( .undo(&(cursor_range, text.as_str().to_owned())) { text.replace_with(undo_txt); - Some(*undo_ccursor_range) + Some(CursorMutation::Selection(*undo_ccursor_range)) } else { None } @@ -1161,8 +1195,8 @@ fn events( key, pressed: true, .. - } => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key), - + } => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key) + .map(CursorMutation::Selection), Event::Ime(ime_event) if owns_ime_events => { /// Both `ImeEvent::Preedit("")` and `ImeEvent::Commit("")` /// might be emitted from different integrations to signify that @@ -1235,22 +1269,20 @@ fn events( text: preedit_text, active_range_chars, } => { - state.cursor_purpose = if preedit_text.is_empty() { - TextEditCursorPurpose::Selection + let mut ccursor = clear_preedit_text(text, &cursor_range); + + if preedit_text.is_empty() { + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } else { - TextEditCursorPurpose::ImeComposition { + let start_cursor = ccursor; + text.insert_text_at(&mut ccursor, preedit_text, char_limit); + Some(CursorMutation::ImeComposition { + cursor_range: CCursorRange::two(start_cursor, ccursor), active_range: active_range_chars.clone().map(|range| { CCursor::new(range.start)..CCursor::new(range.end) }), - } - }; - let mut ccursor = clear_preedit_text(text, &cursor_range); - - let start_cursor = ccursor; - if !preedit_text.is_empty() { - text.insert_text_at(&mut ccursor, preedit_text, char_limit); + }) } - Some(CCursorRange::two(start_cursor, ccursor)) } ImeEvent::Commit(commit_text) => { state.cursor_purpose = TextEditCursorPurpose::Selection; @@ -1260,15 +1292,13 @@ fn events( text.insert_text_at(&mut ccursor, commit_text, char_limit); } - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } ImeEvent::DeleteSurrounding { before_chars, after_chars, - } => Some(text.delete_surrounding_chars( - cursor_range, - *before_chars, - *after_chars, + } => Some(CursorMutation::ImeCompositionCursorRange( + text.delete_surrounding_chars(cursor_range, *before_chars, *after_chars), )), } } @@ -1276,14 +1306,29 @@ fn events( _ => None, }; - if let Some(new_ccursor_range) = did_mutate_text { + if let Some(cursor_mutation) = did_mutate_text { any_change = true; // Layout again to avoid frame delay, and to keep `text` and `galley` in sync. *galley = layouter(ui, text, wrap_width); // Set cursor_range using new galley: - cursor_range = new_ccursor_range; + match cursor_mutation { + CursorMutation::Selection(new_cursor_range) => { + cursor_range = new_cursor_range; + state.cursor_purpose = TextEditCursorPurpose::Selection; + } + CursorMutation::ImeComposition { + cursor_range: new_cursor_range, + active_range, + } => { + cursor_range = new_cursor_range; + state.cursor_purpose = TextEditCursorPurpose::ImeComposition { active_range }; + } + CursorMutation::ImeCompositionCursorRange(new_cursor_range) => { + cursor_range = new_cursor_range; + } + } } }