diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index b3b5dd0914..cbf6e405cf 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -112,6 +112,15 @@ pub trait EntityInputHandler: 'static + Sized { ) -> TextInputConfiguration { TextInputConfiguration::default() } + + /// See [`InputHandler::text_input_editable_range`] for details + fn text_input_editable_range( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } } /// The canonical implementation of [`crate::PlatformInputHandler`]. Call [`Window::handle_input`] @@ -263,6 +272,15 @@ impl InputHandler for ElementInputHandler { self.view .update(cx, |view, cx| view.text_input_configuration(window, cx)) } + + fn text_input_editable_range( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> Option> { + self.view + .update(cx, |view, cx| view.text_input_editable_range(window, cx)) + } } #[cfg(test)] diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 1f079485fb..eeba999dac 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1668,6 +1668,14 @@ impl PlatformInputHandler { ) -> TextInputConfiguration { self.handler.text_input_configuration(window, cx) } + + /// See [`InputHandler::text_input_editable_range`]. + pub fn text_input_editable_range(&mut self) -> Option> { + self.cx + .update(|window, cx| self.handler.text_input_editable_range(window, cx)) + .ok() + .flatten() + } } /// A struct representing a selection in a text buffer, in UTF16 characters. @@ -1826,6 +1834,24 @@ pub trait InputHandler: 'static { true } + /// The contiguous range of text, in UTF-16 code units, that platform text + /// input may read and edit around the current selection. + /// + /// Platforms that mirror document text into an IME-editable buffer clamp + /// the mirrored window to this range, so multi-step IME edit gestures + /// (word deletion, autocorrect rewrites, suggestion picks) cannot reach + /// content outside it. The range should contain the current selection; + /// when it cannot (a selection spanning a region boundary), platforms + /// degrade the mirrored IME context rather than widening the range. + /// `None` places no bound. + fn text_input_editable_range( + &mut self, + _window: &mut Window, + _cx: &mut App, + ) -> Option> { + None + } + /// Returns whether printable keys should be routed to the IME before keybinding /// matching when a non-ASCII input source (e.g. Japanese, Korean, Chinese IME) /// is active. This prevents multi-stroke keybindings like `jj` from intercepting diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index d36a4b3e11..0c3cc8d4b9 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -142,6 +142,7 @@ impl WebWindowInner { self.register_blur(), self.register_pointer_enter(), ]; + handles.extend(self.register_selection_change()); handles.extend(self.register_visibility_change()); handles.extend(self.register_appearance_change()); handles.extend(self.register_fullscreen_change()); @@ -758,6 +759,77 @@ impl WebWindowInner { }) } + /// Imports IME-driven selection moves on the mirror element into the app. + /// + /// Some IME gestures preview their effect by moving the field's + /// selection before committing an edit — Android's slide-on-backspace + /// grows a selection over the text it will delete. A native field + /// renders that selection itself; this import gives the app the same + /// chance. Like edit imports, the move is expressed relative to the + /// element's stored selection and applied to the app selection queried + /// in the same synchronous callback, never through document coordinates, + /// which go stale in a collaborative document. + /// + /// Self-inflicted events are filtered by state, not by suppression + /// flags: `selectionchange` dispatches asynchronously, after the sync or + /// import that caused it has already adopted the element's selection, so + /// a stored-state match means there is nothing to import. + /// + /// Registered on the document: Chrome dispatches text-control selection + /// changes there, not on the element. + fn register_selection_change(self: &Rc) -> Option { + let document = self.browser_window.document()?; + let this = Rc::clone(self); + Some(EventListenerHandle::add( + document.as_ref(), + "selectionchange", + move |_event: JsValue| { + if this.is_composing.get() || !this.ime_mirror.is_focused() { + return; + } + // An in-flight edit owns the selection; its import adopts it. + if this.ime_mirror.value() != this.ime_mirror.stored_text() { + return; + } + let Some(element_start) = this.ime_mirror.selection_start() else { + return; + }; + let element_end = this + .ime_mirror + .element_selection_end() + .unwrap_or(element_start); + let (stored_start, stored_end) = this.ime_mirror.stored_selection(); + if (element_start, element_end) == (stored_start, stored_end) { + return; + } + let applied = this.with_input_handler(|handler| { + let Some(selection) = handler.selected_text_range(false) else { + return false; + }; + // The app range corresponding to the stored element + // selection is exactly `selection`; a single consistent + // alignment between the two maps the moved endpoints. + // Disagreeing alignments mean the app selection changed + // underneath and the pending resync owns the element. + let alignment = selection.range.start.checked_sub(stored_start as usize); + if alignment.is_none() + || alignment != selection.range.end.checked_sub(stored_end as usize) + { + return false; + } + let alignment = alignment.unwrap(); + handler.set_selected_text_range( + alignment + element_start as usize..alignment + element_end as usize, + ); + true + }); + if applied == Some(true) { + this.ime_mirror.adopt_element_state(); + } + }, + )) + } + /// Software keyboards (IMEs) express editing through `beforeinput` /// rather than key events: Android IMEs emit only a placeholder key /// event (`key: "Unidentified"`, `keyCode` 229). This handler only diff --git a/crates/gpui_web/src/ime_mirror.rs b/crates/gpui_web/src/ime_mirror.rs index fa074cebaf..e519cfe2bb 100644 --- a/crates/gpui_web/src/ime_mirror.rs +++ b/crates/gpui_web/src/ime_mirror.rs @@ -201,6 +201,10 @@ impl ImeMirror { self.element.selection_start().ok().flatten() } + pub(crate) fn element_selection_end(&self) -> Option { + self.element.selection_end().ok().flatten() + } + pub(crate) fn stored_text(&self) -> String { self.text.borrow().clone() } @@ -297,8 +301,21 @@ impl ImeMirror { mirror.selection.set((0, 0)); return; }; + // The mirrored window never crosses the handler's editable + // range: everything in the element is reachable by multi-step + // IME edit gestures (word deletion, autocorrect rewrites), so + // text outside the range must not be mirrored at all. The IME + // sees the range's edges as the field's edges. + let editable_range = window + .with_input_handler(|handler| handler.text_input_editable_range()) + .flatten(); - if is_consistent(window, &selection.range, MIN_EDGE_CHARS) { + if is_consistent( + window, + &selection.range, + editable_range.as_ref(), + MIN_EDGE_CHARS, + ) { return; } @@ -307,12 +324,24 @@ impl ImeMirror { // tap in a plain textarea. Rewriting the value restarts the IME // connection, which desynchronizes the keyboard's word model // right when it is about to act on the tapped word. - if move_selection_within_window(window, &selection.range, MIN_EDGE_CHARS) { + if move_selection_within_window( + window, + &selection.range, + editable_range.as_ref(), + MIN_EDGE_CHARS, + ) { return; } - let window_range = selection.range.start.saturating_sub(CONTEXT_CHARS) + let mut window_range = selection.range.start.saturating_sub(CONTEXT_CHARS) ..selection.range.end + CONTEXT_CHARS; + if let Some(editable_range) = &editable_range { + window_range.start = window_range.start.max(editable_range.start); + window_range.end = window_range + .end + .min(editable_range.end) + .max(window_range.start); + } let mut adjusted = None; let text = window .with_input_handler(|handler| { @@ -362,6 +391,7 @@ impl ImeMirror { fn move_selection_within_window( window: &WebWindowInner, app_selection: &std::ops::Range, + editable_range: Option<&std::ops::Range>, min_edge: usize, ) -> bool { let mirror = &window.ime_mirror; @@ -373,10 +403,19 @@ fn move_selection_within_window( let window_start = mirror.window_hint.get(); // The new selection must sit inside the window with enough context - // on both sides — except where the window is pinned to a document - // boundary, where less context is all the context there is. This is - // the common case: a chat thread's caret usually sits at the end of - // the document, where the window has no right margin at all. + // on both sides — except where the window is pinned to a boundary (of + // the document or of the editable range), where less context is all + // the context there is. This is the common case: a chat thread's + // caret usually sits at the end of the document, where the window has + // no right margin at all. + // A window that leaks outside the editable range mirrors text the IME + // must not reach, however consistent it is. + if let Some(range) = editable_range + && (window_start < range.start || window_start + stored_length > range.end) + { + return false; + } + let left_boundary = editable_range.map_or(0, |range| range.start); let Some(selection_start) = app_selection.start.checked_sub(window_start) else { return false; }; @@ -384,14 +423,15 @@ fn move_selection_within_window( if selection_end > stored_length { return false; } - if selection_start < min_edge && window_start != 0 { + if selection_start < min_edge && window_start > left_boundary { return false; } // Verify the hint: the stored window text must still equal the // document at this alignment. Asking for one unit extra also // determines whether the window reaches the document's end, which - // excuses a missing right margin. + // excuses a missing right margin, as does reaching the editable + // range's end. let mut adjusted = None; let document_text = window .with_input_handler(|handler| { @@ -403,8 +443,9 @@ fn move_selection_within_window( .flatten() .unwrap_or_default(); let document_text_length = document_text.encode_utf16().count(); - let window_at_document_end = document_text_length == stored_length; - if selection_end + min_edge > stored_length && !window_at_document_end { + let window_at_right_boundary = document_text_length == stored_length + || editable_range.is_some_and(|range| window_start + stored_length >= range.end); + if selection_end + min_edge > stored_length && !window_at_right_boundary { return false; } if !document_text.starts_with(stored_text.as_str()) || document_text_length > stored_length + 1 @@ -434,6 +475,7 @@ fn move_selection_within_window( fn is_consistent( window: &WebWindowInner, app_selection: &std::ops::Range, + editable_range: Option<&std::ops::Range>, min_edge: usize, ) -> bool { let mirror = &window.ime_mirror; @@ -453,28 +495,41 @@ fn is_consistent( return false; } // Enough context on both sides of the selection, unless the window - // is pinned to a document boundary (start of window at document - // offset 0, or window end at document end — approximated by the - // stored window being shorter than requested on that side). + // is pinned to a boundary of the document or of the editable range + // (start of window at the left boundary, or window end at the right + // boundary — the document's approximated by the stored window being + // shorter than requested on that side). let app_window_start = match app_selection.start.checked_sub(element_selection_start) { Some(start) => start, None => return false, }; - let has_left_context = element_selection_start >= min_edge || app_window_start == 0; + // A window that leaks outside the editable range mirrors text the IME + // must not reach, however consistent it is. + if let Some(range) = editable_range + && (app_window_start < range.start || app_window_start + stored_length > range.end) + { + return false; + } + let left_boundary = editable_range.map_or(0, |range| range.start); + let has_left_context = element_selection_start >= min_edge || app_window_start <= left_boundary; let right_context = stored_length.saturating_sub(element_selection_end); if !has_left_context || right_context < min_edge { - // A short right side is fine when the window genuinely reaches - // the end of the document; verify by asking for one unit past - // the stored window. - let mut adjusted = None; - let past_end = app_window_start + stored_length; - let more = window - .with_input_handler(|handler| { - handler.text_for_range(past_end..past_end + 1, &mut adjusted) - }) - .flatten() - .unwrap_or_default(); - if !has_left_context || !more.is_empty() { + let window_end = app_window_start + stored_length; + let at_right_boundary = if let Some(range) = editable_range { + window_end >= range.end + } else { + // Verify the window genuinely reaches the end of the document + // by asking for one unit past the stored window. + let mut adjusted = None; + window + .with_input_handler(|handler| { + handler.text_for_range(window_end..window_end + 1, &mut adjusted) + }) + .flatten() + .unwrap_or_default() + .is_empty() + }; + if !has_left_context || !at_right_boundary { return false; } }