diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index 8d3f6c9d7be9..b5b3c7d723a8 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -395,7 +395,10 @@ impl AppRunner { if self.has_focus() { // The eframe app has focus. - if ime.is_some() { + if let Some(ime) = ime { + if ime.should_interrupt_composition { + self.text_agent.interrupt_ime_composition(); + } // We are editing text: give the focus to the text agent. self.text_agent.focus(); } else { @@ -415,6 +418,12 @@ impl AppRunner { ); } } + + #[cfg(debug_assertions)] + pub(crate) fn update_custom_debug_information(&mut self) { + self.text_agent + .update_custom_debug_information(&mut self.input); + } } // ---------------------------------------------------------------------------- diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index ec6bf2a075b1..95cdb8dd01c4 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -30,6 +30,11 @@ pub(crate) fn paint_and_schedule(runner_ref: &WebRunner) -> Result<(), JsValue> fn paint_if_needed(runner: &mut AppRunner) { if runner.needs_repaint.needs_repaint() { + #[cfg(debug_assertions)] + if !runner.input.raw.events.is_empty() { + runner.update_custom_debug_information(); + } + if runner.has_outstanding_paint_data() { // We have already run the logic, e.g. in an on-click event, // so let's only present the results: @@ -190,11 +195,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.raw.modifiers = modifiers; diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 60d617d8160a..7559cb591cb3 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -1,7 +1,10 @@ //! The text agent is a hidden `` element used to capture //! IME and mobile keyboard input events. -use std::cell::Cell; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, +}; use wasm_bindgen::prelude::*; use web_sys::{Document, Node}; @@ -10,13 +13,171 @@ use super::{AppRunner, WebRunner}; pub struct TextAgent { input: web_sys::HtmlInputElement, + input_state: Rc>, prev_ime_output: Cell>, } +struct InputState { + input: web_sys::HtmlInputElement, + last_text: String, +} + +impl InputState { + fn new(input: web_sys::HtmlInputElement) -> Self { + Self { + input, + last_text: String::new(), + } + } + + 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 !event.is_composing() && event.input_type() != "insertText" { + self.clear(); + + return; + } + + let text = self.input.value(); + + let prefix_len = longest_common_prefix_length(&text, &self.last_text); + let last_text_len = self.last_text.chars().count(); + if prefix_len < last_text_len { + let out_event = egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { + before_chars: last_text_len - prefix_len, + after_chars: 0, + }); + runner.input.raw.events.push(out_event); + } + + let preedit_text: 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(); + } + + /// 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 { + // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ + if event.is_composing() || event.key_code() == 229 { + true + } else { + if event.key().chars().count() > 1 + || event.ctrl_key() + || event.alt_key() + || event.meta_key() + { + input_state.borrow_mut().clear(); + } + false + } + } + + /// ## Returns + /// Whether the event is consumed. If `true`, the caller should not do + /// further processing for this event. + fn handle_keyup_event(event: &web_sys::KeyboardEvent) -> bool { + // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ + event.is_composing() || event.key_code() == 229 + } + + #[cfg(debug_assertions)] + fn update_custom_debug_information(&self, input: &mut crate::web::WebInput) { + input + .raw + .events + .push(egui::Event::CustomDebugInformationUpdated { + name: "eframe::web::text_agent::InputState".to_owned(), + value: format!( + " +last_text: {:?} +input.value: {:?}", + self.last_text, + self.input.value(), + ), + }); + } +} + impl TextAgent { /// Attach the agent to the document. pub fn attach(runner_ref: &WebRunner, root: Node) -> Result { - let document = web_sys::window().unwrap().document().unwrap(); + let window = web_sys::window().unwrap(); + let document = window.document().unwrap(); // create an `` element let input = document @@ -26,6 +187,7 @@ impl TextAgent { let input = input.dyn_into::()?; input.set_type("text"); input.set_attribute("autocapitalize", "off")?; + let input_state = Rc::new(RefCell::new(InputState::new(input.clone()))); // append it to `` and hide it outside of the viewport let style = input.style(); @@ -54,103 +216,56 @@ 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(); - input.focus().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`. runner.needs_repaint.repaint_asap(); - } - }; + }, + )?; - 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, "input", { + let input_state = Rc::clone(&input_state); + move |event: web_sys::InputEvent, runner: &mut AppRunner| { + input_state.borrow_mut().handle_input_event(&event, runner); } - }; - - runner_ref.add_event_listener(&input, "input", on_input)?; - runner_ref.add_event_listener(&input, "compositionstart", on_composition_start)?; - runner_ref.add_event_listener(&input, "compositionend", on_composition_end)?; - - // 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)?; + })?; + 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, "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", { + move |event: web_sys::KeyboardEvent, runner: &mut AppRunner| { + let is_consumed = InputState::handle_keyup_event(&event); + if !is_consumed { + // The canvas doesn't get keydown/keyup events when the text agent is focused, + // so we need to forward them to the runner: + super::events::on_keyup(event, runner); + } + } + })?; Ok(Self { input, + input_state, prev_ime_output: Default::default(), }) } @@ -236,6 +351,18 @@ 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(); + } + + #[cfg(debug_assertions)] + pub(crate) fn update_custom_debug_information(&self, input: &mut crate::web::WebInput) { + self.input_state + .borrow_mut() + .update_custom_debug_information(input); + } + + pub(crate) fn interrupt_ime_composition(&self) { + self.input_state.borrow_mut().clear(); } } @@ -257,3 +384,9 @@ fn is_mobile_safari() -> bool { })() .unwrap_or(false) } + +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/src/context.rs b/crates/egui/src/context.rs index 1ad4a8fb1228..ca08419c1ea1 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -405,6 +405,9 @@ struct ContextImpl { is_accesskit_enabled: bool, loaders: Arc, + + #[cfg(debug_assertions)] + custom_debug_information: CustomDebugInformation, } impl ContextImpl { @@ -453,6 +456,15 @@ impl ContextImpl { self.memory.begin_pass(&new_raw_input, &all_viewport_ids); + #[cfg(debug_assertions)] + for ev in &new_raw_input.events { + if let crate::Event::CustomDebugInformationUpdated { name, value } = ev { + self.custom_debug_information + .0 + .insert(name.clone(), value.clone()); + } + } + viewport.input = std::mem::take(&mut viewport.input).begin_pass( new_raw_input, viewport.repaint.requested_immediate_repaint_prev_pass(), @@ -3343,6 +3355,13 @@ impl Context { let interact_widgets = self.write(|ctx| ctx.viewport().interact_widgets.clone()); interact_widgets.ui(ui); }); + + #[cfg(debug_assertions)] + CollapsingHeader::new("Custom debug information") + .default_open(false) + .show(ui, |ui| { + self.read(|ctx| ctx.custom_debug_information.clone()).ui(ui); + }); } /// Show stats about the allocated textures. @@ -4275,6 +4294,26 @@ fn warn_if_rect_changes_id( } } +#[cfg(debug_assertions)] +#[derive(Default, Clone)] +struct CustomDebugInformation(std::collections::HashMap); + +#[cfg(debug_assertions)] +impl CustomDebugInformation { + fn ui(&self, ui: &mut Ui) { + let mut names = self.0.keys().cloned().collect::>(); + names.sort(); + for name in names { + let value = &self.0[&name]; + crate::CollapsingHeader::new(name) + .default_open(true) + .show(ui, |ui| { + ui.label(value); + }); + } + } +} + #[cfg(test)] mod test { use super::Context; diff --git a/crates/egui/src/data/input/event.rs b/crates/egui/src/data/input/event.rs index 117a200b691d..2ccee4348128 100644 --- a/crates/egui/src/data/input/event.rs +++ b/crates/egui/src/data/input/event.rs @@ -183,4 +183,9 @@ pub enum Event { image: std::sync::Arc, }, + + /// Custom debug information. They can be viewed in the inspection UI in + /// debug builds. + #[cfg(debug_assertions)] + CustomDebugInformationUpdated { name: String, value: String }, } diff --git a/crates/egui/src/data/input/ime_event.rs b/crates/egui/src/data/input/ime_event.rs index de12a920f5da..b814b51cdf9d 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/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index ccc3a56c845a..bf2493aff3e6 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -527,6 +527,11 @@ impl TextEdit<'_> { let mut cursor_range = None; let mut prev_cursor_range = None; + let owns_ime_events = ui.memory(|mem| mem.owns_ime_events(id)); + if !owns_ime_events { + state.cursor_purpose = TextEditCursorPurpose::Selection; + } + let mut text_changed = false; let text_mutable = text.is_mutable(); @@ -547,14 +552,17 @@ impl TextEdit<'_> { text, galley, layouter, - id, - wrap_width, - multiline, - password, - default_cursor_range, - char_limit, - event_filter, - return_key, + &EventsOptions { + id, + wrap_width, + multiline, + password, + default_cursor_range, + owns_ime_events, + char_limit, + event_filter, + return_key, + }, ); if changed { @@ -772,6 +780,7 @@ impl TextEdit<'_> { if did_interact || response.clicked() { ui.memory_mut(|mem| mem.request_focus(response.id)); + state.cursor_purpose = TextEditCursorPurpose::Selection; state.last_interaction_time = ui.input(|i| i.time); } @@ -993,25 +1002,44 @@ fn mask_if_password(is_password: bool, text: &str) -> String { // ---------------------------------------------------------------------------- -/// Check for (keyboard) events to edit the cursor and/or text. -#[expect(clippy::too_many_arguments)] -fn events( - ui: &crate::Ui, - state: &mut TextEditState, - text: &mut dyn TextBuffer, - galley: &mut Arc, - layouter: &mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc, +/// Bundles parameters for [`events`] to avoid `clippy::too_many_arguments` and +/// `clippy::fn_params_excessive_bools`. +struct EventsOptions { id: Id, wrap_width: f32, multiline: bool, password: bool, default_cursor_range: CCursorRange, + owns_ime_events: bool, char_limit: usize, event_filter: EventFilter, return_key: Option, +} + +/// Check for (keyboard) events to edit the cursor and/or text. +fn events( + ui: &crate::Ui, + state: &mut TextEditState, + text: &mut dyn TextBuffer, + galley: &mut Arc, + layouter: &mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc, + opts: &EventsOptions, ) -> (bool, CCursorRange) { + let EventsOptions { + id, + wrap_width, + multiline, + password, + default_cursor_range, + owns_ime_events, + char_limit, + event_filter, + return_key, + } = *opts; + let os = ui.os(); + // let mut cursor_range = state.cursor.range(galley).unwrap_or(default_cursor_range); let mut cursor_range = state.cursor.range(galley).unwrap_or(default_cursor_range); // We feed state to the undoer both before and after handling input @@ -1031,9 +1059,13 @@ fn events( let events = ui.input(|i| i.filtered_events(&event_filter)); - let owns_ime_events = ui.memory(|mem| mem.owns_ime_events(id)); - if !owns_ime_events { - state.cursor_purpose = TextEditCursorPurpose::Selection; + enum CursorMutation { + Selection(CCursorRange), + ImeComposition { + cursor_range: CCursorRange, + active_range: Option>, + }, + ImeCompositionCursorRange(CCursorRange), } for event in &events { @@ -1052,7 +1084,9 @@ fn events( None } else { copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned()); - Some(CCursorRange::one(text.delete_selected(&cursor_range))) + Some(CursorMutation::Selection(CCursorRange::one( + text.delete_selected(&cursor_range), + ))) } } Event::Paste(text_to_insert) => { @@ -1067,7 +1101,7 @@ fn events( text.insert_text_at(&mut ccursor, &single_line, char_limit); } - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } } Event::Text(text_to_insert) => { @@ -1077,7 +1111,7 @@ fn events( text.insert_text_at(&mut ccursor, text_to_insert, char_limit); - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } else { None } @@ -1095,7 +1129,7 @@ fn events( } else { text.insert_text_at(&mut ccursor, "\t", char_limit); } - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } Event::Key { key, @@ -1110,7 +1144,7 @@ fn events( let mut ccursor = text.delete_selected(&cursor_range); text.insert_text_at(&mut ccursor, "\n", char_limit); // TODO(emilk): if code editor, auto-indent by same leading tabs, + one if the lines end on an opening bracket - Some(CCursorRange::one(ccursor)) + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } else { ui.memory_mut(|mem| mem.surrender_focus(id)); // End input with enter break; @@ -1132,7 +1166,7 @@ fn events( .redo(&(cursor_range, text.as_str().to_owned())) { text.replace_with(redo_txt); - Some(*redo_ccursor_range) + Some(CursorMutation::Selection(*redo_ccursor_range)) } else { None } @@ -1150,7 +1184,7 @@ fn events( .undo(&(cursor_range, text.as_str().to_owned())) { text.replace_with(undo_txt); - Some(*undo_ccursor_range) + Some(CursorMutation::Selection(*undo_ccursor_range)) } else { None } @@ -1161,8 +1195,8 @@ fn events( key, pressed: true, .. - } => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key), - + } => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key) + .map(CursorMutation::Selection), Event::Ime(ime_event) if owns_ime_events => { /// Both `ImeEvent::Preedit("")` and `ImeEvent::Commit("")` /// might be emitted from different integrations to signify that @@ -1235,22 +1269,20 @@ fn events( text: preedit_text, active_range_chars, } => { - state.cursor_purpose = if preedit_text.is_empty() { - TextEditCursorPurpose::Selection + let mut ccursor = clear_preedit_text(text, &cursor_range); + + if preedit_text.is_empty() { + Some(CursorMutation::Selection(CCursorRange::one(ccursor))) } else { - TextEditCursorPurpose::ImeComposition { + let start_cursor = ccursor; + text.insert_text_at(&mut ccursor, preedit_text, char_limit); + Some(CursorMutation::ImeComposition { + cursor_range: CCursorRange::two(start_cursor, ccursor), active_range: active_range_chars.clone().map(|range| { CCursor::new(range.start)..CCursor::new(range.end) }), - } - }; - let mut ccursor = clear_preedit_text(text, &cursor_range); - - let start_cursor = ccursor; - if !preedit_text.is_empty() { - text.insert_text_at(&mut ccursor, preedit_text, char_limit); + }) } - Some(CCursorRange::two(start_cursor, ccursor)) } ImeEvent::Commit(commit_text) => { state.cursor_purpose = TextEditCursorPurpose::Selection; @@ -1260,22 +1292,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 848b993d06e6..1fada96262b5 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("<<[_]>>")); + } +}