From c69834e65a0681d4fa40c30545b006ce39527034 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Uma=C4=B5o?= <107099960+umajho@users.noreply.github.com>
Date: Thu, 30 Jul 2026 00:55:23 +0800
Subject: [PATCH] Improve robustness of text input handling for `eframe/web`
(#8045)
* Fix [the Samsung Keyboard Korean Cheonjiin layout
bug](https://github.com/emilk/egui/pull/7967#issuecomment-4098503570)
* Partially fix (does not close) #8046
* Supersedes #7914
* Supersedes #8047
* Related: #8068
* Related: #7983
* Related: #8078
* [x] I have followed the instructions in the PR template
This PR reworks the text input handling logic in eframe's web
integration, primarily in `text_agent.rs`.
It also adds a new `ImeEvent::DeleteSurrounding` variant, along with the
corresponding handling logic in `egui` to support the changes.
## Fix: Samsung Keyboard Cheonjiin issue
This PR fixes a bug reported by @rustbasic when using Samsung Keyboard's
Cheonjiin Korean layout.
Since Samsung Keyboard is only available on Samsung devices, I wasn't
able to verify it myself. The fix is based on @rustbasic's testing and
confirmation.
The root cause is that the layout relies on the preceding text to
correctly handle batchim composition. Previously, the text agent eagerly
cleared the text input after every IME composition, removing the context
too early. This PR makes that cleanup more conservative, preserving the
text when it may still be needed.
My understanding is that this is a quirk specific to Samsung Keyboard:
The IME reports that composition has finished even though it is
effectively still active and the composed text may continue to change.
## Fix: Keystrokes resetting keyboard layout (numpad/symbols/etc.)
Partially fixes #8046.
Previously, keystrokes would cause the on-screen keyboard to switch back
to its primary layout.
One remaining issue is that tapping within the active `TextEdit` to
reposition the cursor still resets the keyboard to its primary layout.
## About text suggestions
I originally planned to include text suggestion support in this PR
because #8068 implemented it.
However, adding text suggestion support would broaden the scope of this
PR, so I think it is better addressed in a separate PR.
For reference, [the reverted
implementation](https://github.com/emilk/egui/pull/8045/commits/8a4f70859c8ba1e00186db1431e8f6906d2d1426)
works fine on Android (Gboard), but not on iOS (iPadOS 17 + SwiftKey).
---
crates/eframe/src/web/app_runner.rs | 7 +-
crates/eframe/src/web/events.rs | 5 -
crates/eframe/src/web/text_agent.rs | 468 ++++++++++++------
crates/egui-winit/src/lib.rs | 21 +-
crates/egui/src/data/input/ime_event.rs | 9 +
crates/egui/src/data/output.rs | 3 +
crates/egui/src/widgets/text_edit/builder.rs | 158 ++++--
.../egui/src/widgets/text_edit/text_buffer.rs | 130 +++++
8 files changed, 603 insertions(+), 198 deletions(-)
diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs
index f913e9b6d..3364d83ce 100644
--- a/crates/eframe/src/web/app_runner.rs
+++ b/crates/eframe/src/web/app_runner.rs
@@ -394,7 +394,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 {
@@ -406,7 +409,7 @@ impl AppRunner {
if let Err(err) = self
.text_agent
- .move_to(ime, self.canvas(), self.egui_ctx.zoom_factor())
+ .update(ime, self.canvas(), self.egui_ctx.zoom_factor())
{
log::error!(
"failed to update text agent position: {}",
diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs
index 326ba1556..f9d992c2f 100644
--- a/crates/eframe/src/web/events.rs
+++ b/crates/eframe/src/web/events.rs
@@ -190,11 +190,6 @@ 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/
- return;
- }
-
let modifiers = modifiers_from_kb_event(&event);
runner.input.set_modifiers(modifiers);
diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs
index d8d6b672e..b80882769 100644
--- a/crates/eframe/src/web/text_agent.rs
+++ b/crates/eframe/src/web/text_agent.rs
@@ -1,7 +1,7 @@
//! The text agent is a hidden ` ` element used to capture
//! IME and mobile keyboard input events.
-use std::cell::Cell;
+use std::{cell::RefCell, rc::Rc};
use wasm_bindgen::prelude::*;
@@ -9,7 +9,7 @@ use super::{AppRunner, WebRunner};
pub struct TextAgent {
input: web_sys::HtmlInputElement,
- prev_ime_output: Cell>,
+ input_state: Rc>,
}
impl TextAgent {
@@ -18,7 +18,8 @@ impl TextAgent {
runner_ref: &WebRunner,
canvas: &web_sys::HtmlCanvasElement,
) -> 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
@@ -26,6 +27,7 @@ impl TextAgent {
.dyn_into::()?;
input.set_type("text");
input.set_attribute("autocapitalize", "off")?;
+ let input_state = Rc::new(RefCell::new(InputState::new(input.clone())));
// Hide the element, and park it over the top-left corner of the canvas
// so that focusing it can never scroll some other part
@@ -65,157 +67,67 @@ impl TextAgent {
// attach event listeners
- let on_input = {
- let input = input.clone();
- move |event: web_sys::InputEvent, runner: &mut AppRunner| {
- let text = input.value();
- // Workaround for an Android Gboard issue: after typing a word,
- // the user has to delete invisible characters (whose count
- // matches the length of the current suggestion) before actual
- // characters are deleted, unless the focus has been reset.
- //
- // this issue appears to have been fixed in Gboard sometime
- // between versions 14.7.09 and 17.0.12.
- if !event.is_composing() {
- input.blur().ok();
- super::focus_without_scroll(&input).ok();
- }
-
- 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.
- // We handle the composition update here instead of in the
- // `compositionupdate` event because the selection range
- // has not yet been updated when `compositionupdate` fires.
-
- let Some(text) = event.data() else { return };
- let selection_start = input
- .selection_start()
- .unwrap_or(None)
- .map(|pos| pos as usize);
- let selection_end = input
- .selection_end()
- .unwrap_or(None)
- .map(|pos| pos as usize);
- let active_range_chars = if let Some(selection_start) = selection_start
- && let Some(selection_end) = selection_end
- {
- let text_utf16 = text.encode_utf16().collect::>();
- 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();
- Some(count_before_selection..count_before_selection + count_in_selection)
- } else {
- None
- };
- let event = egui::Event::Ime(egui::ImeEvent::Preedit {
- text,
- active_range_chars,
- });
- runner.input.raw.events.push(event);
- } else {
- if text.is_empty() {
- return;
- }
-
- input.set_value("");
- let event = egui::Event::Text(text);
- runner.input.raw.events.push(event);
- }
-
- 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`.
+ // 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);
}
- };
-
- let on_composition_end = {
- 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();
+ })?;
+ 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, "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, "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);
+ }
+ }
+ })?;
- // 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 {
- input,
- prev_ime_output: Default::default(),
- })
+ Ok(Self { input, input_state })
}
- pub fn move_to(
+ pub fn update(
&self,
ime: Option,
canvas: &web_sys::HtmlCanvasElement,
zoom_factor: f32,
) -> Result<(), JsValue> {
- // Don't move the text agent unless the position actually changed:
- if self.prev_ime_output.get() == ime {
- return Ok(());
- }
- self.prev_ime_output.set(ime);
-
- let Some(ime) = ime else { return Ok(()) };
-
- if ime.should_interrupt_composition {
- // no-op for now: currently, the text agent is sizeless, so any
- // click shifts focus to the canvas, which naturally interrupts the
- // composition.
- }
-
- 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(())
+ self.input_state
+ .borrow_mut()
+ .update(ime, canvas, zoom_factor)
}
pub fn set_focus(&self, on: bool) {
@@ -252,6 +164,11 @@ 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();
+ }
+
+ pub(crate) fn interrupt_ime_composition(&self) {
+ self.input_state.borrow_mut().clear();
}
}
@@ -260,3 +177,274 @@ impl Drop for TextAgent {
self.input.remove();
}
}
+
+struct InputState {
+ input: web_sys::HtmlInputElement,
+ last_text: String,
+ ime_output: Option,
+ keydown_special_case: KeydownSpecialCase,
+}
+
+#[derive(Clone, Copy)]
+enum KeydownSpecialCase {
+ None,
+
+ /// On Android Gboard 14.7.09, when suggestions remain visible while typing
+ /// letters without IME composition (e.g., Latin or Cyrillic), pressing
+ /// Backspace produces key code 229 instead of the expected Backspace key
+ /// code.
+ /// Without the workaround, users have to press Backspace twice before text
+ /// starts being deleted.
+ ///
+ /// This workaround is also required for Android Gboard corrections and
+ /// completions (e.g., `tex|` -> `Texas`) to work correctly. In these
+ /// cases, a `deleteContentBackward` input event fires first (e.g., to
+ /// delete `tex`), followed by an `insertText` input event (e.g., to insert
+ /// `Texas`).
+ ///
+ /// Since it is difficult to distinguish between a Backspace press and a
+ /// correction or completion (e.g., when the state is `t|`, it is unclear
+ /// whether the user wants to delete `t` or replace it with `Texas`), we
+ /// send a `DeleteSurrounding` IME event in all cases instead of
+ /// synthetically generating Backspace press and release events.
+ AndroidKeycode229,
+
+ /// iOS (18.6)'s built-in Korean keyboard uses `deleteContentBackward` to
+ /// compose Hangul characters. In these cases, the key code is 0.
+ IosKeycode0,
+}
+
+impl InputState {
+ fn new(input: web_sys::HtmlInputElement) -> Self {
+ Self {
+ input,
+ last_text: String::new(),
+ ime_output: None,
+ keydown_special_case: KeydownSpecialCase::None,
+ }
+ }
+
+ fn update(
+ &mut self,
+ ime: Option,
+ canvas: &web_sys::HtmlCanvasElement,
+ zoom_factor: f32,
+ ) -> Result<(), JsValue> {
+ // Don't move the text agent unless the position actually changed:
+ if self.ime_output == ime {
+ return Ok(());
+ }
+ self.ime_output = ime;
+
+ let Some(ime) = ime else { return Ok(()) };
+
+ // NOTE: we don't set the input's `type` to `password` based on
+ // `ime.purpose`, because that would confuse some password managers.
+ // For example, Chrome's password manager will always think the last
+ // letter typed in the password field is the password.
+
+ let style = self.input.style();
+ let native_ppp = super::native_pixels_per_point();
+
+ // The input is a sibling of the canvas (see `attach`), so we position
+ // it relative to the same containing block using the canvas offset.
+ // Unlike `get_bounding_client_rect`, the offset is unaffected by page
+ // scrolling, and doesn't flap when the virtual keyboard is shown on
+ // mobile Safari.
+
+ // Clamp the input position within the canvas width to prevent unwanted horizontal scrolling.
+ let logical_canvas_width = canvas.width() as f32 / native_ppp;
+ let visible_x = ime.cursor_rect.center().x * zoom_factor;
+ let clamped_x = visible_x.clamp(0.0, logical_canvas_width);
+
+ // Clamp the input position within the canvas height to prevent unwanted vertical scrolling.
+ let logical_canvas_height = canvas.height() as f32 / native_ppp;
+ let visible_y = ime.cursor_rect.center().y * zoom_factor;
+ let clamped_y = visible_y.clamp(0.0, logical_canvas_height);
+
+ // This is where the IME input will point to:
+ style.set_property(
+ "left",
+ &format!("{}px", canvas.offset_left() as f32 + clamped_x),
+ )?;
+ style.set_property(
+ "top",
+ &format!("{}px", canvas.offset_top() as f32 + clamped_y),
+ )?;
+
+ Ok(())
+ }
+
+ fn clear(&mut self) {
+ self.input.set_value("");
+ self.last_text.clear();
+ }
+
+ fn handle_input_event(&mut self, event: &web_sys::InputEvent, runner: &mut AppRunner) {
+ if self
+ .ime_output
+ .as_ref()
+ .is_some_and(|ime| ime.purpose == egui::IMEPurpose::Password)
+ {
+ self.handle_input_event_password(event, runner);
+ return;
+ }
+
+ let input_type = event.input_type();
+
+ if !event.is_composing()
+ && input_type != "insertText"
+ // iOS uses this for corrections and completions (e.g., `tex|` ->
+ // `Texas`).
+ && input_type != "insertReplacementText"
+ && (matches!(self.keydown_special_case, KeydownSpecialCase::None)
+ || input_type != "deleteContentBackward")
+ {
+ self.clear();
+
+ return;
+ }
+
+ let text = self.input.value();
+
+ let prefix_len = longest_common_prefix_length(&text, &self.last_text);
+ let last_text_len = self.last_text.chars().count();
+ if prefix_len < last_text_len {
+ let out_event = egui::Event::Ime(egui::ImeEvent::DeleteSurrounding {
+ before_chars: last_text_len - prefix_len,
+ after_chars: 0,
+ });
+ runner.input.raw.events.push(out_event);
+ }
+
+ let preedit_text: String = text.chars().skip(prefix_len).collect();
+ let out_event = if event.is_composing() {
+ // We handle the composition update here instead of in a
+ // `compositionupdate` event because the selection range
+ // has not yet been updated when `compositionupdate` fires.
+ let active_range_chars = self.active_range_chars(&text, prefix_len);
+ egui::Event::Ime(egui::ImeEvent::Preedit {
+ text: preedit_text,
+ active_range_chars,
+ })
+ } else {
+ egui::Event::Text(preedit_text)
+ };
+ runner.input.raw.events.push(out_event);
+
+ if event.is_composing() {
+ self.last_text = text.chars().take(prefix_len).collect();
+ } else {
+ self.last_text = text;
+ }
+
+ runner.needs_repaint.repaint_asap();
+ }
+
+ fn handle_input_event_password(&mut self, event: &web_sys::InputEvent, runner: &mut AppRunner) {
+ let input_type = event.input_type();
+
+ if input_type != "insertText" {
+ return;
+ }
+
+ let text = self.input.value();
+
+ runner.input.raw.events.push(egui::Event::Text(text));
+ self.clear();
+ }
+
+ /// Compute the active range (cursor or conversion segment) within the
+ /// preedit text, based on the selection in the input element.
+ ///
+ /// `text` is the full `input.value()`, and `prefix_len_chars` is the
+ /// number of chars at the start of `text` that are committed (not part
+ /// of the preedit). `selectionStart`/`selectionEnd` are UTF-16 offsets
+ /// within the full `input.value()`, so they are adjusted to be relative
+ /// to the preedit text.
+ fn active_range_chars(
+ &self,
+ text: &str,
+ prefix_len_chars: usize,
+ ) -> Option> {
+ let selection_start = self.input.selection_start().unwrap_or(None)? as usize;
+ let selection_end = self.input.selection_end().unwrap_or(None)? as usize;
+
+ let text_utf16 = text.encode_utf16().collect::>();
+ if selection_start > text_utf16.len() || selection_end > text_utf16.len() {
+ // This can occur on Android Chrome. see discussion in:
+ // .
+ return None;
+ }
+
+ let text_before_selection = String::from_utf16_lossy(&text_utf16[..selection_start]);
+ let text_in_selection =
+ String::from_utf16_lossy(&text_utf16[selection_start..selection_end]);
+ let count_before_selection = text_before_selection.chars().count();
+ let count_in_selection = text_in_selection.chars().count();
+
+ // Adjust for the committed prefix to get the range within the preedit text.
+ let start = count_before_selection.saturating_sub(prefix_len_chars);
+ let end = start + count_in_selection;
+ Some(start..end)
+ }
+
+ fn handle_composition_end_event(&mut self, runner: &mut AppRunner) {
+ let text = self.input.value();
+
+ let commit_text = {
+ let prefix_len = self.last_text.chars().count();
+ text.chars().skip(prefix_len).collect::()
+ };
+ let out_event = egui::Event::Ime(egui::ImeEvent::Commit(commit_text));
+ runner.input.raw.events.push(out_event);
+
+ self.last_text = text;
+
+ runner.needs_repaint.repaint_asap();
+ }
+
+ /// ## Returns
+ /// Whether the event is consumed. If `true`, the caller should not do
+ /// further processing for this event.
+ fn handle_keydown_event(input_state: &RefCell, event: &web_sys::KeyboardEvent) -> bool {
+ // Platform-sniffing methods are unreliable, so they are not used as
+ // guards here.
+ let special_case = match event.key_code() {
+ 229 => KeydownSpecialCase::AndroidKeycode229,
+ 0 => KeydownSpecialCase::IosKeycode0,
+ _ => KeydownSpecialCase::None,
+ };
+ input_state.borrow_mut().keydown_special_case = special_case;
+
+ // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/
+ if event.is_composing() || !matches!(special_case, KeydownSpecialCase::None) {
+ true
+ } else {
+ if event.key().chars().count() > 1
+ || event.ctrl_key()
+ || event.alt_key()
+ || event.meta_key()
+ {
+ input_state.borrow_mut().clear();
+ }
+ false
+ }
+ }
+
+ /// ## Returns
+ /// Whether the event is consumed. If `true`, the caller should not do
+ /// further processing for this event.
+ fn handle_keyup_event(input_state: &RefCell, event: &web_sys::KeyboardEvent) -> bool {
+ input_state.borrow_mut().keydown_special_case = KeydownSpecialCase::None;
+
+ // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/
+ event.is_composing() || event.key_code() == 229
+ }
+}
+
+fn longest_common_prefix_length(a: &str, b: &str) -> usize {
+ std::iter::zip(a.chars(), b.chars())
+ .take_while(|(a, b)| a == b)
+ .count()
+}
diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs
index d17428adb..85b22a997 100644
--- a/crates/egui-winit/src/lib.rs
+++ b/crates/egui-winit/src/lib.rs
@@ -121,6 +121,7 @@ pub struct State {
allow_ime: bool,
ime_rect_px: Option,
+ old_ime_purpose: egui::IMEPurpose,
/// Used by [`State::try_on_ime_processed_keyboard_input`] to track key
/// release events that should be filtered out. See comments in that method
@@ -171,6 +172,7 @@ impl State {
allow_ime: false,
ime_rect_px: None,
+ old_ime_purpose: egui::IMEPurpose::Normal,
#[cfg(target_os = "windows")]
pressed_processed_physical_keys: HashSet::new(),
};
@@ -1158,6 +1160,11 @@ impl State {
window.set_ime_allowed(true);
}
+ if ime.purpose != self.old_ime_purpose {
+ self.old_ime_purpose = ime.purpose;
+ window.set_ime_purpose(to_winit_ime_purpose(ime.purpose));
+ }
+
let pixels_per_point = pixels_per_point(&self.egui_ctx, window);
let ime_rect_px = pixels_per_point * ime.rect;
if self.ime_rect_px != Some(ime_rect_px)
@@ -1880,11 +1887,7 @@ fn process_viewport_command(
);
}
ViewportCommand::IMEAllowed(v) => window.set_ime_allowed(v),
- ViewportCommand::IMEPurpose(p) => window.set_ime_purpose(match p {
- egui::viewport::IMEPurpose::Password => winit::window::ImePurpose::Password,
- egui::viewport::IMEPurpose::Terminal => winit::window::ImePurpose::Terminal,
- egui::viewport::IMEPurpose::Normal => winit::window::ImePurpose::Normal,
- }),
+ ViewportCommand::IMEPurpose(p) => window.set_ime_purpose(to_winit_ime_purpose(p)),
ViewportCommand::Focus => {
if !window.has_focus() {
window.focus_window();
@@ -1945,6 +1948,14 @@ fn process_viewport_command(
}
}
+fn to_winit_ime_purpose(purpose: egui::IMEPurpose) -> winit::window::ImePurpose {
+ match purpose {
+ egui::IMEPurpose::Password => winit::window::ImePurpose::Password,
+ egui::IMEPurpose::Terminal => winit::window::ImePurpose::Terminal,
+ egui::IMEPurpose::Normal => winit::window::ImePurpose::Normal,
+ }
+}
+
/// Build and intitlaize a window.
///
/// Wrapper around `create_winit_window_builder` and `apply_viewport_builder_to_window`.
diff --git a/crates/egui/src/data/input/ime_event.rs b/crates/egui/src/data/input/ime_event.rs
index de12a920f..b814b51cd 100644
--- a/crates/egui/src/data/input/ime_event.rs
+++ b/crates/egui/src/data/input/ime_event.rs
@@ -22,6 +22,15 @@ pub enum ImeEvent {
/// The IME is considered dismissed after this event.
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.
#[deprecated = "No longer used by egui"]
Disabled,
diff --git a/crates/egui/src/data/output.rs b/crates/egui/src/data/output.rs
index dc55d4712..bbd271b71 100644
--- a/crates/egui/src/data/output.rs
+++ b/crates/egui/src/data/output.rs
@@ -82,6 +82,9 @@ impl FullOutput {
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct IMEOutput {
+ /// IME's purpose.
+ pub purpose: crate::IMEPurpose,
+
/// Where the [`crate::TextEdit`] is located on screen.
pub rect: crate::Rect,
diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs
index ccc3a56c8..0a52d636c 100644
--- a/crates/egui/src/widgets/text_edit/builder.rs
+++ b/crates/egui/src/widgets/text_edit/builder.rs
@@ -5,9 +5,10 @@ use epaint::text::{Galley, LayoutJob, TextWrapMode, cursor::CCursor};
use crate::{
Align, Align2, AsIdSalt, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context,
- CursorIcon, Event, EventFilter, FontSelection, Frame, Id, IdSalt, ImeEvent, IntoAtoms,
- IntoSizedResult, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense,
- SizedAtomKind, TextBuffer, TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, epaint,
+ CursorIcon, Event, EventFilter, FontSelection, Frame, IMEPurpose, Id, IdSalt, ImeEvent,
+ IntoAtoms, IntoSizedResult, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response,
+ Sense, SizedAtomKind, TextBuffer, TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState,
+ epaint,
os::OperatingSystem,
output::OutputEvent,
response,
@@ -527,6 +528,19 @@ 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;
+ if !state.cursor.is_empty() {
+ state.cursor.set_char_range(
+ state
+ .cursor
+ .char_range()
+ .map(|r| CCursorRange::one(r.primary)),
+ );
+ }
+ }
+
let mut text_changed = false;
let text_mutable = text.is_mutable();
@@ -547,14 +561,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 +789,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);
}
@@ -907,6 +925,11 @@ impl TextEdit<'_> {
.unwrap_or_default();
ui.output_mut(|o| {
o.ime = Some(crate::output::IMEOutput {
+ purpose: if password {
+ IMEPurpose::Password
+ } else {
+ IMEPurpose::Normal
+ },
rect: to_global * inner_rect,
cursor_rect: to_global * primary_cursor_rect,
should_interrupt_composition: false,
@@ -993,23 +1016,41 @@ fn mask_if_password(is_password: bool, text: &str) -> String {
// ----------------------------------------------------------------------------
+/// 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.
-#[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,
- id: Id,
- wrap_width: f32,
- multiline: bool,
- password: bool,
- default_cursor_range: CCursorRange,
- char_limit: usize,
- event_filter: EventFilter,
- return_key: Option,
+ 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);
@@ -1031,9 +1072,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 +1097,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 +1114,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 +1124,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 +1142,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 +1157,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 +1179,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 +1197,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 +1208,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 +1282,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,22 +1305,43 @@ 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(CursorMutation::ImeCompositionCursorRange(
+ text.delete_surrounding_chars(cursor_range, *before_chars, *after_chars),
+ )),
}
}
_ => 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;
+ }
+ }
}
}
diff --git a/crates/egui/src/widgets/text_edit/text_buffer.rs b/crates/egui/src/widgets/text_edit/text_buffer.rs
index 848b993d0..1fada9626 100644
--- a/crates/egui/src/widgets/text_edit/text_buffer.rs
+++ b/crates/egui/src/widgets/text_edit/text_buffer.rs
@@ -154,6 +154,30 @@ pub trait TextBuffer {
self.delete_selected_ccursor_range([min_ccursor, max_ccursor])
}
+ /// 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,
+ 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,
@@ -320,3 +344,109 @@ impl TextBuffer for &str {
std::any::TypeId::of::<&str>()
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ 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)))
+ );
+ assert_eq!(
+ txt_n_sel("<>"),
+ (
+ "<>".to_owned(),
+ CCursorRange::two(CCursor::new(3), CCursor::new(4))
+ )
+ );
+ assert_eq!(
+ txt_n_sel("<<左[_]右>>"),
+ (
+ "<<左_右>>".to_owned(),
+ CCursorRange::two(CCursor::new(3), CCursor::new(4))
+ )
+ );
+ assert_eq!(
+ txt_n_sel("<>"),
+ (
+ "<>".to_owned(),
+ 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("<>"));
+ 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("<<]_[>>"));
+
+ // 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("<<˻[_]>>"));
+ 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("<<]_[>>"));
+
+ // 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("<<左[_]>>"));
+ 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("<<]_[>>"));
+
+ // mixed
+ test_case(txt_n_sel("<>"), 3, 3, txt_n_sel("<<[_]>>"));
+ }
+}