gpui: Add text_edit_editable_range API (#63294)

When syncing text to the browser IME mirror, we sometimes need to mark a
particular range as "editable". Native clients have a pull-based model,
so don't need this mechanism, but on the web we need to proactively push
content to the IME mirror `textarea`.

---

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
Cameron Mcloughlinandzed-zippy[bot] authored and GitHub committed 2026-08-27 10:13:49 +00:00
1 parent b24568132b
commit 0854657a2b
4 files changed
+198 -27

No files matched your search

+18
View File
@@ -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<Self>,
) -> Option<Range<usize>> {
None
}
}
/// The canonical implementation of [`crate::PlatformInputHandler`]. Call [`Window::handle_input`]
@@ -263,6 +272,15 @@ impl<V: EntityInputHandler> InputHandler for ElementInputHandler<V> {
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<Range<usize>> {
self.view
.update(cx, |view, cx| view.text_input_editable_range(window, cx))
}
}
#[cfg(test)]
+26
View File
@@ -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<Range<usize>> {
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<Range<usize>> {
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
+72
View File
@@ -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<Self>) -> Option<EventListenerHandle> {
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
+82 -27
View File
@@ -201,6 +201,10 @@ impl ImeMirror {
self.element.selection_start().ok().flatten()
}
pub(crate) fn element_selection_end(&self) -> Option<u32> {
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<usize>,
editable_range: Option<&std::ops::Range<usize>>,
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<usize>,
editable_range: Option<&std::ops::Range<usize>>,
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;
}
}