diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index ac917329f..e3d4f8860 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -75,11 +75,7 @@ impl TextAgent { }; 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, // see `move_to` in `AppRunner::handle_platform_output`. runner.needs_repaint.repaint_asap(); diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 90f0311d5..99a9894f3 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -101,9 +101,6 @@ pub struct State { /// Only one touch will be interpreted as pointer at any time. pointer_touch_id: Option, - /// track ime state - has_sent_ime_enabled: bool, - #[cfg(feature = "accesskit")] pub accesskit: Option, @@ -150,8 +147,6 @@ impl State { simulate_touch_screen: false, pointer_touch_id: None, - has_sent_ime_enabled: false, - #[cfg(feature = "accesskit")] accesskit: None, @@ -689,17 +684,11 @@ impl State { // } match ime { - winit::event::Ime::Enabled => { - if cfg!(target_os = "linux") { - // This event means different things in X11 and Wayland, but we can just - // ignore it and enable IME on the preedit event. - // See - } else { - self.ime_event_enable(); - } - } - winit::event::Ime::Preedit(text, Some(_cursor)) => { - self.ime_event_enable(); + // [`winit::event::Ime::Enabled`] means different things in X11 and + // Wayland, but it doesn't matter to us. + // See + winit::event::Ime::Enabled | winit::event::Ime::Disabled => {} + winit::event::Ime::Preedit(text, _) => { self.egui_input .events .push(egui::Event::Ime(egui::ImeEvent::Preedit(text.clone()))); @@ -708,53 +697,10 @@ impl State { self.egui_input .events .push(egui::Event::Ime(egui::ImeEvent::Commit(text.clone()))); - self.ime_event_disable(); - } - winit::event::Ime::Disabled => { - self.ime_event_disable(); - } - winit::event::Ime::Preedit(_, None) => { - if cfg!(target_os = "macos") { - // On macOS, when the user presses backspace to delete the - // last character in an IME composition, `winit` only emits - // `winit::event::Ime::Preedit("", None)` without a - // preceding `winit::event::Ime::Preedit("", Some(0, 0))`. - // - // The current implementation of `egui::TextEdit` relies on - // receiving an `egui::ImeEvent::Preedit("")` to remove the - // last character in the composition in this case, so we - // emit it here. - // - // This is guarded to macOS-only, as applying it on other - // platforms is unnecessary and can cause undesired - // behavior. - // See: https://github.com/emilk/egui/pull/7973 - self.egui_input - .events - .push(egui::Event::Ime(egui::ImeEvent::Preedit(String::new()))); - } - - self.ime_event_disable(); } } } - pub fn ime_event_enable(&mut self) { - if !self.has_sent_ime_enabled { - self.egui_input - .events - .push(egui::Event::Ime(egui::ImeEvent::Enabled)); - self.has_sent_ime_enabled = true; - } - } - - pub fn ime_event_disable(&mut self) { - self.egui_input - .events - .push(egui::Event::Ime(egui::ImeEvent::Disabled)); - self.has_sent_ime_enabled = false; - } - /// Returns `true` if the event was sent to egui. pub fn on_mouse_motion(&mut self, delta: (f64, f64)) -> bool { if !self.is_pointer_in_window() && !self.any_pointer_button_down { diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 00cf59cba..7a104a95e 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -605,15 +605,22 @@ pub enum Event { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub enum ImeEvent { /// Notifies when the IME was enabled. + #[deprecated = "No longer used by egui"] Enabled, /// A new IME candidate is being suggested. + /// + /// An empty preedit string indicates that the IME has been dismissed, while + /// a non-empty preedit string indicates that the IME is active. Preedit(String), /// IME composition ended with this final result. + /// + /// The IME is considered dismissed after this event. Commit(String), /// 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 2d2c74430..ea3ff4eec 100644 --- a/crates/egui/src/data/output.rs +++ b/crates/egui/src/data/output.rs @@ -123,6 +123,9 @@ pub struct PlatformOutput { /// This is set if, and only if, the user is currently editing text. /// /// Useful for IME. + /// + /// This field should only be set by the widget that currently owns IME + /// events (see [`crate::Memory::owns_ime_events`]). pub ime: Option, /// The difference in the widget tree since last frame. diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 08b08a462..34e0fe319 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -116,6 +116,22 @@ pub struct Memory { /// (e.g. relative to some other widget). #[cfg_attr(feature = "persistence", serde(skip))] popups: ViewportIdMap, + + /// When the last IME interruption was made. + #[cfg_attr(feature = "persistence", serde(skip))] + ime_interruption_time: ImeInterruptionTime, +} + +#[derive(Clone, Copy, Debug, Default)] +enum ImeInterruptionTime { + #[default] + None, + + /// The IME was interrupted in the current frame. + ThisFrame, + + /// The IME was interrupted in the previous frame. + LastFrame, } impl Default for Memory { @@ -133,6 +149,7 @@ impl Default for Memory { popups: Default::default(), everything_is_visible: Default::default(), add_fonts: Default::default(), + ime_interruption_time: Default::default(), }; slf.interactions.entry(slf.viewport_id).or_default(); slf.areas.entry(slf.viewport_id).or_default(); @@ -761,6 +778,16 @@ impl Memory { self.areas.entry(self.viewport_id).or_default(); + match self.ime_interruption_time { + ImeInterruptionTime::ThisFrame => { + self.ime_interruption_time = ImeInterruptionTime::LastFrame; + } + ImeInterruptionTime::LastFrame => { + self.ime_interruption_time = ImeInterruptionTime::None; + } + ImeInterruptionTime::None => {} + } + // self.interactions is handled elsewhere self.options.begin_pass(new_raw_input); @@ -875,9 +902,12 @@ impl Memory { /// Give keyboard focus to a specific widget. /// See also [`crate::Response::request_focus`]. + /// + /// Calling this will interrupt IME composition. #[inline(always)] pub fn request_focus(&mut self, id: Id) { self.focus_mut().focused_widget = Some(FocusWidget::new(id)); + self.interrupt_ime(); } /// Surrender keyboard focus for a specific widget. @@ -993,6 +1023,36 @@ impl Memory { pub(crate) fn focus_mut(&mut self) -> &mut Focus { self.focus.entry(self.viewport_id).or_default() } + + /// Check if the widget owns IME events. + /// + /// A widget should only consume IME events if this returns `true`. At most + /// one widget can own IME events for each frame. + pub fn owns_ime_events(&self, id: Id) -> bool { + let Some(focus) = self.focus() else { + return false; + }; + // We check across two frames because the widget that called + // `interrupt_ime` may run after other widgets that call this method + // within the same frame. + if matches!( + self.ime_interruption_time, + ImeInterruptionTime::ThisFrame | ImeInterruptionTime::LastFrame + ) { + return false; + } + focus.focused() == Some(id) + } + + /// Interrupt the current IME composition, if any. + /// + /// This causes [`Self::owns_ime_events`] to return `false` for all widgets + /// for the remainder of this frame and the next frame, giving time + /// for the IME to be dismissed (by making `platform_output.ime` be `None` + /// for at least one frame). + pub fn interrupt_ime(&mut self) { + self.ime_interruption_time = ImeInterruptionTime::ThisFrame; + } } /// State of an open popup. diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 6f4d9a044..9905a2a55 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -10,8 +10,11 @@ use crate::{ TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, epaint, os::OperatingSystem, output::OutputEvent, - response, text_selection, - text_selection::{CCursorRange, text_cursor_state::cursor_rect, visuals::paint_text_selection}, + response, + text_edit::state::TextEditCursorPurpose, + text_selection::{ + self, CCursorRange, text_cursor_state::cursor_rect, visuals::paint_text_selection, + }, vec2, }; @@ -858,33 +861,23 @@ impl TextEdit<'_> { now - state.last_interaction_time, ); } - - // Set IME output (in screen coords) when text is editable and visible - let to_global = ui - .ctx() - .layer_transform_to_global(ui.layer_id()) - .unwrap_or_default(); - - ui.output_mut(|o| { - o.ime = Some(crate::output::IMEOutput { - rect: to_global * inner_rect, - cursor_rect: to_global * primary_cursor_rect, + if ui.memory(|mem| mem.owns_ime_events(id)) { + // Set IME output (in screen coords) when text is editable and visible + let to_global = ui + .ctx() + .layer_transform_to_global(ui.layer_id()) + .unwrap_or_default(); + ui.output_mut(|o| { + o.ime = Some(crate::output::IMEOutput { + rect: to_global * inner_rect, + cursor_rect: to_global * primary_cursor_rect, + }); }); - }); + } } } } - // Ensures correct IME behavior when the text input area gains or loses focus. - if state.ime_enabled && (response.gained_focus() || response.lost_focus()) { - state.ime_enabled = false; - if let Some(mut ccursor_range) = state.cursor.char_range() { - ccursor_range.secondary.index = ccursor_range.primary.index; - state.cursor.set_char_range(Some(ccursor_range)); - } - ui.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Ime(_)))); - } - state.clone().store(ui.ctx(), id); if response.changed() { @@ -999,6 +992,11 @@ 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; + } + for event in &events { let did_mutate_text = match event { // First handle events that only changes the selection cursor, not the text: @@ -1126,7 +1124,7 @@ fn events( .. } => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key), - Event::Ime(ime_event) => { + Event::Ime(ime_event) if owns_ime_events => { /// Both `ImeEvent::Preedit("")` and `ImeEvent::Commit("")` /// might be emitted from different integrations to signify that /// the current IME composition should be cleared. @@ -1160,46 +1158,58 @@ fn events( } match ime_event { - ImeEvent::Enabled => { - state.ime_enabled = true; - state.ime_cursor_range = cursor_range; + #[expect(deprecated)] + ImeEvent::Enabled | ImeEvent::Disabled => None, + // Ignore `Preedit`/`Commit` events with empty text when + // there is no active IME composition. + // + // Some integrations may emit these events when there is no + // active IME composition (e.g. when `set_ime_allowed` or + // `set_ime_cursor_area` is called on `winit`'s `Window` on + // Wayland). Without this guard, they would clear any + // selected text. + // + // TODO(umajho): Ideally this would be handled by the + // integration, but since this guard is harmless for well- + // behaved integrations and also fixes the issue described + // above, it is good enough for now. + ImeEvent::Preedit(composition_text) | ImeEvent::Commit(composition_text) + if composition_text.is_empty() + && !matches!( + state.cursor_purpose, + TextEditCursorPurpose::ImeComposition + ) => + { + None + } + ImeEvent::Preedit(composition_text) | ImeEvent::Commit(composition_text) + if composition_text == "\n" || composition_text == "\r" => + { None } ImeEvent::Preedit(preedit_text) => { - if preedit_text == "\n" || preedit_text == "\r" { - None + state.cursor_purpose = if preedit_text.is_empty() { + TextEditCursorPurpose::Selection } else { - let mut ccursor = clear_preedit_text(text, &cursor_range); + TextEditCursorPurpose::ImeComposition + }; + 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); - } - state.ime_cursor_range = cursor_range; - Some(CCursorRange::two(start_cursor, ccursor)) + 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) => { - if commit_text == "\n" || commit_text == "\r" { - None - } else { - state.ime_enabled = false; + state.cursor_purpose = TextEditCursorPurpose::Selection; + let mut ccursor = clear_preedit_text(text, &cursor_range); - let mut ccursor = clear_preedit_text(text, &cursor_range); - - if !commit_text.is_empty() - && cursor_range.secondary.index - == state.ime_cursor_range.secondary.index - { - text.insert_text_at(&mut ccursor, commit_text, char_limit); - } - - Some(CCursorRange::one(ccursor)) + if !commit_text.is_empty() { + text.insert_text_at(&mut ccursor, commit_text, char_limit); } - } - ImeEvent::Disabled => { - state.ime_enabled = false; - None + + Some(CCursorRange::one(ccursor)) } } } diff --git a/crates/egui/src/widgets/text_edit/state.rs b/crates/egui/src/widgets/text_edit/state.rs index 5827aac4b..48935c4f7 100644 --- a/crates/egui/src/widgets/text_edit/state.rs +++ b/crates/egui/src/widgets/text_edit/state.rs @@ -37,18 +37,14 @@ pub struct TextEditState { /// Controls the text selection. pub cursor: TextCursorState, + /// The purpose of the cursor. + #[cfg_attr(feature = "serde", serde(skip))] + pub(crate) cursor_purpose: TextEditCursorPurpose, + /// Wrapped in Arc for cheaper clones. #[cfg_attr(feature = "serde", serde(skip))] pub(crate) undoer: Arc>, - // If IME candidate window is shown on this text edit. - #[cfg_attr(feature = "serde", serde(skip))] - pub(crate) ime_enabled: bool, - - // cursor range for IME candidate. - #[cfg_attr(feature = "serde", serde(skip))] - pub(crate) ime_cursor_range: CCursorRange, - // Text offset within the widget area. // Used for sensing and singleline text clipping. #[cfg_attr(feature = "serde", serde(skip))] @@ -82,3 +78,13 @@ impl TextEditState { self.set_undoer(TextEditUndoer::default()); } } + +#[derive(Clone, Default)] +pub(crate) enum TextEditCursorPurpose { + /// The cursor is used for text selection. + #[default] + Selection, + + /// The cursor is used for IME composition. + ImeComposition, +}