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:
1 parent
b24568132b
commit
0854657a2b
4 files changed
+198
-27
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user