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("<<[_]>>")); + } +}