diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index ac917329f36..b4250f0d9dd 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -87,9 +87,17 @@ impl TextAgent { }; let on_composition_update = { + let input = input.clone(); 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 start = input.selection_start().ok().flatten().unwrap_or(0) as usize; + let end = input.selection_end().ok().flatten().unwrap_or(0) as usize; + let event = egui::Event::Ime(egui::ImeEvent::Preedit { + text_mark: text, + start, + end, + }); runner.input.raw.events.push(event); runner.needs_repaint.repaint_asap(); } diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 54059cbd6ec..2ea573d7016 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -594,11 +594,15 @@ impl State { self.ime_event_enable(); } } - winit::event::Ime::Preedit(text, Some(_cursor)) => { + winit::event::Ime::Preedit(text, Some(cursor)) => { self.ime_event_enable(); self.egui_input .events - .push(egui::Event::Ime(egui::ImeEvent::Preedit(text.clone()))); + .push(egui::Event::Ime(egui::ImeEvent::Preedit { + text_mark: text.clone(), + start: cursor.0, + end: cursor.1, + })); } winit::event::Ime::Commit(text) => { self.egui_input @@ -618,8 +622,11 @@ impl State { // TextEdit in such situation. self.egui_input .events - .push(egui::Event::Ime(egui::ImeEvent::Preedit(String::new()))); - self.ime_event_disable(); + .push(egui::Event::Ime(egui::ImeEvent::Preedit { + text_mark: String::new(), + start: 0, + end: 0, + })); } } } diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 78786756921..f902af62b40 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -571,7 +571,11 @@ pub enum ImeEvent { Enabled, /// A new IME candidate is being suggested. - Preedit(String), + Preedit { + text_mark: String, + start: usize, + end: usize, + }, /// IME composition ended with this final result. Commit(String), diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index 37faf64c2c2..45e3b613346 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -53,10 +53,23 @@ impl SurrenderFocusOn { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum ImeLanguage { + #[default] + None, + Korean, + Japanese, + Chinese, +} + /// Options for input state handling. #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct InputOptions { + /// The language used for specialized IME (Input Method Editor) processing. + pub ime_language: ImeLanguage, + /// Multiplier for the scroll speed when reported in [`crate::MouseWheelUnit::Line`]s. pub line_scroll_speed: f32, @@ -110,6 +123,7 @@ impl Default for InputOptions { }; Self { + ime_language: ImeLanguage::default(), line_scroll_speed, scroll_zoom_speed: 1.0 / 200.0, max_click_dist: 6.0, @@ -127,6 +141,7 @@ impl InputOptions { /// Show the options in the ui. pub fn ui(&mut self, ui: &mut crate::Ui) { let Self { + ime_language, line_scroll_speed, scroll_zoom_speed, max_click_dist, @@ -141,6 +156,23 @@ impl InputOptions { .num_columns(2) .striped(true) .show(ui, |ui| { + ui.label("IME Language"); + let inner_response = crate::ComboBox::from_id_salt("ime_language_combo") + .selected_text(match ime_language { + ImeLanguage::None => "None", + ImeLanguage::Korean => "Korean", + ImeLanguage::Japanese => "Japanese", + ImeLanguage::Chinese => "Chinese", + }) + .show_ui(ui, |ui| { + ui.selectable_value(ime_language, ImeLanguage::None, "None"); + ui.selectable_value(ime_language, ImeLanguage::Korean, "Korean"); + ui.selectable_value(ime_language, ImeLanguage::Japanese, "Japanese"); + ui.selectable_value(ime_language, ImeLanguage::Chinese, "Chinese"); + }); + inner_response.response.on_hover_text("Select the language for specialized IME processing (CJK)"); + ui.end_row(); + ui.label("Line scroll speed"); ui.add(crate::DragValue::new(line_scroll_speed).range(0.0..=f32::INFINITY)) .on_hover_text( @@ -199,7 +231,6 @@ impl InputOptions { ui.label("surrender_focus_on"); surrender_focus_on.ui(ui); ui.end_row(); - }); } } @@ -330,7 +361,7 @@ pub struct InputState { /// Input state management configuration. /// /// This gets copied from `egui::Options` at the start of each frame for convenience. - options: InputOptions, + pub options: InputOptions, } impl Default for InputState { diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index fbf25babf96..8d248862df4 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -806,14 +806,21 @@ impl TextEdit<'_> { } } - // 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() { + // Ensures correct IME behavior when the text input area gains focus or moves. + if state.ime_enabled { + if response.gained_focus() { + state.ime_enabled = false; + ui.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Ime(_)))); + } + + if let Some(mut ccursor_range) = state.cursor.char_range() + && ccursor_range.secondary.index != state.ime_cursor_range.secondary.index + { + state.ime_enabled = false; 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(_)))); } - ui.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Ime(_)))); } state.clone().store(ui.ctx(), id); @@ -1066,70 +1073,18 @@ fn events( } => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key), Event::Ime(ime_event) => { - /// Empty prediction can be produced with [`ImeEvent::Preedit`] - /// or [`ImeEvent::Commit`] when user press backspace or escape - /// during IME, so this function should be called in both cases - /// to clear current text. - /// - /// Example platforms where only `ImeEvent::Preedit("")` of - /// those two events is emitted when the last character in the - /// prediction is deleted: - /// - macOS 15.7.3. - /// - Debian13 with gnome48 and wayland. - /// - /// An example platform where only `ImeEvent::Commit("")` of - /// those two events is emitted when the last character in the - /// prediction is deleted: - /// - Safari 26.2 (on macOS 15.7.3). - fn clear_prediction( - text: &mut dyn TextBuffer, - cursor_range: &CCursorRange, - ) -> CCursor { - text.delete_selected(cursor_range) - } - - match ime_event { - ImeEvent::Enabled => { - state.ime_enabled = true; - state.ime_cursor_range = cursor_range; - None - } - ImeEvent::Preedit(text_mark) => { - if text_mark == "\n" || text_mark == "\r" { - None - } else { - let mut ccursor = clear_prediction(text, &cursor_range); - - let start_cursor = ccursor; - if !text_mark.is_empty() { - text.insert_text_at(&mut ccursor, text_mark, char_limit); - } - state.ime_cursor_range = cursor_range; - Some(CCursorRange::two(start_cursor, ccursor)) - } + let ime_language = ui.input(|i| i.options.ime_language); + match ime_language { + crate::input_state::ImeLanguage::Korean => { + on_ime_korean(ime_event, state, text, &cursor_range, char_limit) } - ImeEvent::Commit(prediction) => { - if prediction == "\n" || prediction == "\r" { - None - } else { - state.ime_enabled = false; - - let mut ccursor = clear_prediction(text, &cursor_range); - - if !prediction.is_empty() - && cursor_range.secondary.index - == state.ime_cursor_range.secondary.index - { - text.insert_text_at(&mut ccursor, prediction, char_limit); - } - - Some(CCursorRange::one(ccursor)) - } + crate::input_state::ImeLanguage::Japanese => { + on_ime_japanese(ime_event, state, text, &cursor_range, char_limit) } - ImeEvent::Disabled => { - state.ime_enabled = false; - None + crate::input_state::ImeLanguage::Chinese => { + on_ime_chinese(ime_event, state, text, &cursor_range, char_limit) } + _ => on_ime_korean(ime_event, state, text, &cursor_range, char_limit), } } @@ -1159,6 +1114,227 @@ fn events( // ---------------------------------------------------------------------------- +/// Empty prediction can be produced with [`ImeEvent::Preedit`] +/// or [`ImeEvent::Commit`] when user press backspace or escape +/// during IME, so this function should be called in both cases +/// to clear current text. +/// +/// Example platforms where only `ImeEvent::Preedit("")` of +/// those two events is emitted when the last character in the +/// prediction is deleted: +/// - macOS 15.7.3. +/// - Debian13 with gnome48 and wayland. +/// +/// An example platform where only `ImeEvent::Commit("")` of +/// those two events is emitted when the last character in the +/// prediction is deleted: +/// - Safari 26.2 (on macOS 15.7.3). +fn clear_prediction(text: &mut dyn TextBuffer, cursor_range: &CCursorRange) -> CCursor { + text.delete_selected(cursor_range) +} + +// ---------------------------------------------------------------------------- + +// Handles IME input events for Korean character composition. +fn on_ime_korean( + ime_event: &ImeEvent, + state: &mut TextEditState, + text: &mut dyn TextBuffer, + cursor_range: &CCursorRange, + char_limit: usize, +) -> Option { + match ime_event { + ImeEvent::Enabled => { + state.ime_enabled = true; + state.ime_cursor_range = *cursor_range; + None + } + ImeEvent::Preedit { + text_mark, + start, + end, + } => { + if text_mark == "\n" || text_mark == "\r" { + None + } else if *start == 1 && *end == 1 { + // Special case for Korean Cheonjiin IME: re-compose the character immediately before the cursor. + let current_ccursor = clear_prediction(text, cursor_range); + + let prev_idx = current_ccursor.index.saturating_sub(1); + let replace_range = CCursorRange::two(CCursor::new(prev_idx), current_ccursor); + let mut insert_cursor = clear_prediction(text, &replace_range); + let start_cursor = insert_cursor; + + text.insert_text_at(&mut insert_cursor, text_mark, char_limit); + + let new_preedit_range = CCursorRange::two(start_cursor, insert_cursor); + state.ime_cursor_range = new_preedit_range; + + Some(new_preedit_range) + } else { + let mut ccursor = clear_prediction(text, cursor_range); + let start_cursor = ccursor; + + if text_mark.is_empty() { + #[cfg(target_arch = "wasm32")] + { + state.ime_enabled = false; + } + } else { + text.insert_text_at(&mut ccursor, text_mark, char_limit); + state.ime_cursor_range = *cursor_range; + } + Some(CCursorRange::two(start_cursor, ccursor)) + } + } + ImeEvent::Commit(prediction) => { + if prediction == "\n" || prediction == "\r" { + None + } else { + let mut ccursor = cursor_range.secondary; + + if state.ime_enabled + && !prediction.is_empty() + && cursor_range.secondary.index == state.ime_cursor_range.secondary.index + { + ccursor = clear_prediction(text, cursor_range); + text.insert_text_at(&mut ccursor, prediction, char_limit); + } + + state.ime_enabled = false; + + Some(CCursorRange::one(ccursor)) + } + } + ImeEvent::Disabled => { + state.ime_enabled = false; + None + } + } +} + +// ---------------------------------------------------------------------------- + +// Handles IME input events for Japanese character composition. +fn on_ime_japanese( + ime_event: &ImeEvent, + state: &mut TextEditState, + text: &mut dyn TextBuffer, + cursor_range: &CCursorRange, + char_limit: usize, +) -> Option { + match ime_event { + ImeEvent::Enabled => { + state.ime_enabled = true; + state.ime_cursor_range = *cursor_range; + None + } + ImeEvent::Preedit { + text_mark, + start: _, + end: _, + } => { + if text_mark == "\n" || text_mark == "\r" { + None + } else { + let mut ccursor = clear_prediction(text, cursor_range); + + let start_cursor = ccursor; + if text_mark.is_empty() { + state.ime_enabled = false; + } else { + text.insert_text_at(&mut ccursor, text_mark, char_limit); + } + state.ime_cursor_range = *cursor_range; + Some(CCursorRange::two(start_cursor, ccursor)) + } + } + ImeEvent::Commit(prediction) => { + if prediction == "\n" || prediction == "\r" { + None + } else { + state.ime_enabled = false; + + let mut ccursor = clear_prediction(text, cursor_range); + + if !prediction.is_empty() + && cursor_range.secondary.index == state.ime_cursor_range.secondary.index + { + text.insert_text_at(&mut ccursor, prediction, char_limit); + } + + Some(CCursorRange::one(ccursor)) + } + } + ImeEvent::Disabled => { + state.ime_enabled = false; + None + } + } +} + +// ---------------------------------------------------------------------------- + +// Handles IME input events for Chinese character composition. +fn on_ime_chinese( + ime_event: &ImeEvent, + state: &mut TextEditState, + text: &mut dyn TextBuffer, + cursor_range: &CCursorRange, + char_limit: usize, +) -> Option { + match ime_event { + ImeEvent::Enabled => { + state.ime_enabled = true; + state.ime_cursor_range = *cursor_range; + None + } + ImeEvent::Preedit { + text_mark, + start: _, + end: _, + } => { + if text_mark == "\n" || text_mark == "\r" { + None + } else { + let mut ccursor = clear_prediction(text, cursor_range); + + let start_cursor = ccursor; + if text_mark.is_empty() { + state.ime_enabled = false; + } else { + text.insert_text_at(&mut ccursor, text_mark, char_limit); + } + state.ime_cursor_range = *cursor_range; + Some(CCursorRange::two(start_cursor, ccursor)) + } + } + ImeEvent::Commit(prediction) => { + if prediction == "\n" || prediction == "\r" { + None + } else { + state.ime_enabled = false; + + let mut ccursor = clear_prediction(text, cursor_range); + + if !prediction.is_empty() + && cursor_range.secondary.index == state.ime_cursor_range.secondary.index + { + text.insert_text_at(&mut ccursor, prediction, char_limit); + } + + Some(CCursorRange::one(ccursor)) + } + } + ImeEvent::Disabled => { + state.ime_enabled = false; + None + } + } +} + +// ---------------------------------------------------------------------------- + fn remove_ime_incompatible_events(events: &mut Vec) { // Remove key events which cause problems while 'IME' is being used. // See https://github.com/emilk/egui/pull/4509