use crate::native_events;
use gpui::{
App, Bounds, Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity,
EntityInputHandler, FocusHandle, Focusable, GlobalElementId, IntoElement, KeyBinding, LayoutId,
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, Render,
SharedString, Style, TextRun, UTF16Selection, Window, actions, div, fill, hsla, point,
prelude::*, px, relative, rgb, rgba,
};
use std::ops::Range;
use unicode_segmentation::UnicodeSegmentation;
actions!(
guppy_text_input,
[
Backspace,
Delete,
Left,
Right,
SelectLeft,
SelectRight,
SelectAll,
Home,
End,
Newline,
ShowCharacterPalette,
Paste,
Cut,
Copy,
]
);
pub fn bind_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("backspace", Backspace, None),
KeyBinding::new("delete", Delete, None),
KeyBinding::new("left", Left, None),
KeyBinding::new("right", Right, None),
KeyBinding::new("shift-left", SelectLeft, None),
KeyBinding::new("shift-right", SelectRight, None),
KeyBinding::new("cmd-a", SelectAll, None),
KeyBinding::new("enter", Newline, None),
KeyBinding::new("cmd-v", Paste, None),
KeyBinding::new("cmd-c", Copy, None),
KeyBinding::new("cmd-x", Cut, None),
KeyBinding::new("home", Home, None),
KeyBinding::new("end", End, None),
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, None),
]);
}
pub struct BridgeTextInputOptions {
pub view_id: u64,
pub node_id: String,
pub value: String,
pub placeholder: String,
pub change: Option<String>,
pub disabled: bool,
pub tab_index: Option<isize>,
pub multiline: bool,
}
pub struct BridgeTextInput {
pub view_id: u64,
pub node_id: String,
pub value: SharedString,
pub placeholder: SharedString,
pub change: Option<String>,
pub disabled: bool,
pub tab_index: Option<isize>,
pub multiline: bool,
focus_handle: FocusHandle,
selected_range: Range<usize>,
selection_reversed: bool,
marked_range: Option<Range<usize>>,
last_lines: Vec<LineLayout>,
last_bounds: Option<Bounds<Pixels>>,
is_selecting: bool,
}
impl BridgeTextInput {
pub fn new(
cx: &mut Context<crate::bridge_view::BridgeView>,
options: BridgeTextInputOptions,
) -> Entity<Self> {
let BridgeTextInputOptions {
view_id,
node_id,
value,
placeholder,
change,
disabled,
tab_index,
multiline,
} = options;
cx.new(|cx| Self {
view_id,
node_id,
value: value.into(),
placeholder: placeholder.into(),
change,
disabled,
tab_index,
multiline,
focus_handle: focus_handle_for(cx, tab_index, disabled),
selected_range: 0..0,
selection_reversed: false,
marked_range: None,
last_lines: Vec::new(),
last_bounds: None,
is_selecting: false,
})
}
pub fn sync_from_ir(
&mut self,
value: &str,
placeholder: &str,
change: Option<&str>,
disabled: bool,
tab_index: Option<isize>,
multiline: bool,
) {
if self.value.as_ref() != value {
self.value = value.to_owned().into();
let cursor = self.value.len();
self.selected_range = cursor..cursor;
self.selection_reversed = false;
self.marked_range = None;
}
self.placeholder = placeholder.to_owned().into();
self.change = change.map(str::to_owned);
self.disabled = disabled;
self.tab_index = tab_index;
self.multiline = multiline;
self.focus_handle = configure_focus_handle(self.focus_handle.clone(), tab_index, disabled);
}
pub fn focus_handle(&self) -> FocusHandle {
self.focus_handle.clone()
}
fn send_change_event(&self) {
let Some(callback_id) = self.change.as_ref() else {
return;
};
let _ = native_events::send_change_event(
self.view_id,
&self.node_id,
callback_id,
self.value.as_ref(),
);
}
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if self.selected_range.is_empty() {
self.move_to(self.previous_boundary(self.cursor_offset()), cx);
} else {
self.move_to(self.selected_range.start, cx)
}
}
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if self.selected_range.is_empty() {
self.move_to(self.next_boundary(self.selected_range.end), cx);
} else {
self.move_to(self.selected_range.end, cx)
}
}
fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
self.select_to(self.previous_boundary(self.cursor_offset()), cx);
}
fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
self.select_to(self.next_boundary(self.cursor_offset()), cx);
}
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
self.move_to(0, cx);
self.select_to(self.value.len(), cx)
}
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
self.move_to(0, cx);
}
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
self.move_to(self.value.len(), cx);
}
fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if self.selected_range.is_empty() {
self.select_to(self.previous_boundary(self.cursor_offset()), cx)
}
self.replace_text_in_range(None, "", window, cx)
}
fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if self.selected_range.is_empty() {
self.select_to(self.next_boundary(self.cursor_offset()), cx)
}
self.replace_text_in_range(None, "", window, cx)
}
fn on_mouse_down(
&mut self,
event: &MouseDownEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if self.disabled {
return;
}
self.is_selecting = true;
if event.modifiers.shift {
self.select_to(self.index_for_mouse_position(event.position), cx);
} else {
self.move_to(self.index_for_mouse_position(event.position), cx)
}
}
fn on_mouse_up(&mut self, _: &MouseUpEvent, _window: &mut Window, _: &mut Context<Self>) {
self.is_selecting = false;
}
fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if self.is_selecting {
self.select_to(self.index_for_mouse_position(event.position), cx);
}
}
fn show_character_palette(
&mut self,
_: &ShowCharacterPalette,
window: &mut Window,
_: &mut Context<Self>,
) {
if self.disabled {
return;
}
window.show_character_palette();
}
fn newline(&mut self, _: &Newline, window: &mut Window, cx: &mut Context<Self>) {
if self.disabled || !self.multiline {
return;
}
self.replace_text_in_range(None, "\n", window, cx);
}
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
let text = if self.multiline {
text
} else {
text.replace("\n", " ")
};
self.replace_text_in_range(None, &text, window, cx);
}
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if !self.selected_range.is_empty() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(
self.value[self.selected_range.clone()].to_string(),
));
}
}
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if !self.selected_range.is_empty() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(
self.value[self.selected_range.clone()].to_string(),
));
self.replace_text_in_range(None, "", window, cx)
}
}
fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
self.selected_range = offset..offset;
cx.notify()
}
fn cursor_offset(&self) -> usize {
if self.selection_reversed {
self.selected_range.start
} else {
self.selected_range.end
}
}
fn index_for_mouse_position(&self, position: Point<Pixels>) -> usize {
if self.value.is_empty() {
return 0;
}
let Some(bounds) = self.last_bounds.as_ref() else {
return 0;
};
if position.y < bounds.top() {
return 0;
}
if position.y > bounds.bottom() {
return self.value.len();
}
let local_y = position.y - bounds.top();
let line = self
.last_lines
.iter()
.find(|line| local_y >= line.top && local_y <= line.bottom)
.or_else(|| self.last_lines.last());
line.map(|line| line.start + line.layout.closest_index_for_x(position.x - bounds.left()))
.unwrap_or(0)
}
fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
if self.selection_reversed {
self.selected_range.start = offset
} else {
self.selected_range.end = offset
};
if self.selected_range.end < self.selected_range.start {
self.selection_reversed = !self.selection_reversed;
self.selected_range = self.selected_range.end..self.selected_range.start;
}
cx.notify()
}
fn offset_to_utf16(&self, offset: usize) -> usize {
offset_to_utf16_in_text(self.value.as_ref(), offset)
}
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
range_to_utf16_in_text(self.value.as_ref(), range)
}
fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> {
range_from_utf16_in_text(self.value.as_ref(), range_utf16)
}
fn previous_boundary(&self, offset: usize) -> usize {
self.value
.grapheme_indices(true)
.rev()
.find_map(|(idx, _)| (idx < offset).then_some(idx))
.unwrap_or(0)
}
fn next_boundary(&self, offset: usize) -> usize {
self.value
.grapheme_indices(true)
.find_map(|(idx, _)| (idx > offset).then_some(idx))
.unwrap_or(self.value.len())
}
fn line_for_offset(&self, offset: usize) -> Option<&LineLayout> {
self.last_lines
.iter()
.find(|line| offset >= line.start && offset <= line.end)
.or_else(|| self.last_lines.last())
}
}
impl EntityInputHandler for BridgeTextInput {
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
actual_range: &mut Option<Range<usize>>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<String> {
let range = self.range_from_utf16(&range_utf16);
actual_range.replace(self.range_to_utf16(&range));
Some(self.value[range].to_string())
}
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.range_to_utf16(&self.selected_range),
reversed: self.selection_reversed,
})
}
fn marked_text_range(
&self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Range<usize>> {
self.marked_range
.as_ref()
.map(|range| self.range_to_utf16(range))
}
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
self.marked_range = None;
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.disabled {
return;
}
let range = range_utf16
.as_ref()
.map(|range_utf16| self.range_from_utf16(range_utf16))
.or(self.marked_range.clone())
.unwrap_or(self.selected_range.clone());
self.value =
(self.value[0..range.start].to_owned() + new_text + &self.value[range.end..]).into();
self.selected_range = range.start + new_text.len()..range.start + new_text.len();
self.marked_range.take();
self.send_change_event();
cx.notify();
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
new_selected_range_utf16: Option<Range<usize>>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if self.disabled {
return;
}
let range = range_utf16
.as_ref()
.map(|range_utf16| self.range_from_utf16(range_utf16))
.or(self.marked_range.clone())
.unwrap_or(self.selected_range.clone());
self.value =
(self.value[0..range.start].to_owned() + new_text + &self.value[range.end..]).into();
if !new_text.is_empty() {
self.marked_range = Some(range.start..range.start + new_text.len());
} else {
self.marked_range = None;
}
self.selected_range = new_selected_range_utf16
.as_ref()
.map(|range_utf16| marked_selection_from_utf16(range.start, new_text, range_utf16))
.unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len());
self.send_change_event();
cx.notify();
}
fn bounds_for_range(
&mut self,
range_utf16: Range<usize>,
bounds: Bounds<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let range = self.range_from_utf16(&range_utf16);
let line = self.line_for_offset(range.start)?;
Some(Bounds::from_corners(
point(
bounds.left()
+ line
.layout
.x_for_index(range.start.saturating_sub(line.start)),
bounds.top() + line.top,
),
point(
bounds.left()
+ line
.layout
.x_for_index(range.end.min(line.end).saturating_sub(line.start)),
bounds.top() + line.bottom,
),
))
}
fn character_index_for_point(
&mut self,
point: Point<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
let line_point = self.last_bounds?.localize(&point)?;
let line = self
.last_lines
.iter()
.find(|line| line_point.y >= line.top && line_point.y <= line.bottom)
.or_else(|| self.last_lines.last())?;
let utf8_index = line.start + line.layout.index_for_x(line_point.x)?;
Some(self.offset_to_utf16(utf8_index))
}
}
struct TextElement {
input: Entity<BridgeTextInput>,
}
struct LineLayout {
start: usize,
end: usize,
top: Pixels,
bottom: Pixels,
layout: gpui::ShapedLine,
}
struct PrepaintState {
lines: Vec<LineLayout>,
cursor: Option<PaintQuad>,
selections: Vec<PaintQuad>,
}
impl IntoElement for TextElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for TextElement {
type RequestLayoutState = ();
type PrepaintState = PrepaintState;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let input = self.input.read(cx);
let mut style = Style::default();
style.size.width = relative(1.).into();
style.size.height = if input.multiline {
(window.line_height() * 5.).into()
} else {
window.line_height().into()
};
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let input = self.input.read(cx);
let content = input.value.clone();
let selected_range = input.selected_range.clone();
let cursor = input.cursor_offset();
let style = window.text_style();
let (display_text, text_color) = if content.is_empty() {
(input.placeholder.clone(), hsla(0., 0., 0., 0.35))
} else {
(content, style.color)
};
let font_size = style.font_size.to_pixels(window.rem_size());
let line_height = window.line_height();
let mut lines = Vec::new();
let mut selections = Vec::new();
let mut cursor_quad = None;
for (line_index, (start, end)) in line_ranges(display_text.as_ref()).into_iter().enumerate()
{
let line_text = display_text[start..end].to_string();
let run = TextRun {
len: line_text.len(),
font: style.font(),
color: text_color,
background_color: None,
underline: None,
strikethrough: None,
};
let layout = window
.text_system()
.shape_line(line_text.into(), font_size, &[run], None);
let top = line_height * line_index as f32;
let bottom = top + line_height;
if selected_range.is_empty() && cursor >= start && cursor <= end {
let cursor_pos = layout.x_for_index(cursor.saturating_sub(start));
cursor_quad = Some(fill(
Bounds::new(
point(bounds.left() + cursor_pos, bounds.top() + top),
gpui::size(px(2.), line_height),
),
gpui::blue(),
));
} else if !selected_range.is_empty() {
let selection_start = selected_range.start.max(start);
let selection_end = selected_range.end.min(end);
if selection_start < selection_end {
selections.push(fill(
Bounds::from_corners(
point(
bounds.left()
+ layout.x_for_index(selection_start.saturating_sub(start)),
bounds.top() + top,
),
point(
bounds.left()
+ layout.x_for_index(selection_end.saturating_sub(start)),
bounds.top() + bottom,
),
),
rgba(0x3311ff30),
));
}
}
lines.push(LineLayout {
start,
end,
top,
bottom,
layout,
});
}
PrepaintState {
lines,
cursor: cursor_quad,
selections,
}
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let focus_handle = self.input.read(cx).focus_handle.clone();
let disabled = self.input.read(cx).disabled;
if !disabled {
window.handle_input(
&focus_handle,
ElementInputHandler::new(bounds, self.input.clone()),
cx,
);
}
for selection in prepaint.selections.drain(..) {
window.paint_quad(selection)
}
for line in &prepaint.lines {
let _ = line.layout.paint(
point(bounds.left(), bounds.top() + line.top),
window.line_height(),
window,
cx,
);
}
if focus_handle.is_focused(window)
&& let Some(cursor) = prepaint.cursor.take()
&& !disabled
{
window.paint_quad(cursor);
}
let lines = std::mem::take(&mut prepaint.lines);
self.input.update(cx, |input, _cx| {
input.last_lines = lines;
input.last_bounds = Some(bounds);
});
}
}
fn offset_from_utf16_in_text(text: &str, offset: usize) -> usize {
let mut utf8_offset = 0;
let mut utf16_count = 0;
for ch in text.chars() {
if utf16_count >= offset {
break;
}
utf16_count += ch.len_utf16();
utf8_offset += ch.len_utf8();
}
utf8_offset
}
fn offset_to_utf16_in_text(text: &str, offset: usize) -> usize {
let mut utf16_offset = 0;
let mut utf8_count = 0;
for ch in text.chars() {
if utf8_count >= offset {
break;
}
utf8_count += ch.len_utf8();
utf16_offset += ch.len_utf16();
}
utf16_offset
}
fn range_to_utf16_in_text(text: &str, range: &Range<usize>) -> Range<usize> {
offset_to_utf16_in_text(text, range.start)..offset_to_utf16_in_text(text, range.end)
}
fn range_from_utf16_in_text(text: &str, range_utf16: &Range<usize>) -> Range<usize> {
offset_from_utf16_in_text(text, range_utf16.start)
..offset_from_utf16_in_text(text, range_utf16.end)
}
fn marked_selection_from_utf16(
range_start: usize,
new_text: &str,
range_utf16: &Range<usize>,
) -> Range<usize> {
let relative = range_from_utf16_in_text(new_text, range_utf16);
range_start + relative.start..range_start + relative.end
}
fn line_ranges(text: &str) -> Vec<(usize, usize)> {
if text.is_empty() {
return vec![(0, 0)];
}
let mut ranges = Vec::new();
let mut start = 0;
for (index, ch) in text.char_indices() {
if ch == '\n' {
ranges.push((start, index));
start = index + ch.len_utf8();
}
}
ranges.push((start, text.len()));
ranges
}
impl Render for BridgeTextInput {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let background = if self.disabled {
rgb(0x2f2f2f)
} else {
rgb(0xffffff)
};
let text_color = if self.disabled {
rgb(0x9f9f9f)
} else {
rgb(0x111111)
};
let border_color = if self.disabled {
rgb(0x666666)
} else {
rgb(0xd0d0d0)
};
let handle = self.focus_handle.clone();
div()
.w_full()
.key_context("BridgeTextInput")
.track_focus(&handle)
.cursor(if self.disabled {
CursorStyle::Arrow
} else {
CursorStyle::IBeam
})
.on_action(cx.listener(Self::backspace))
.on_action(cx.listener(Self::delete))
.on_action(cx.listener(Self::left))
.on_action(cx.listener(Self::right))
.on_action(cx.listener(Self::select_left))
.on_action(cx.listener(Self::select_right))
.on_action(cx.listener(Self::select_all))
.on_action(cx.listener(Self::home))
.on_action(cx.listener(Self::end))
.on_action(cx.listener(Self::newline))
.on_action(cx.listener(Self::show_character_palette))
.on_action(cx.listener(Self::paste))
.on_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::copy))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_move(cx.listener(Self::on_mouse_move))
.child(
div()
.w_full()
.h(if self.multiline { px(120.0) } else { px(36.0) })
.p(px(6.0))
.rounded_md()
.border_1()
.border_color(border_color)
.bg(background)
.text_color(text_color)
.child(TextElement { input: cx.entity() }),
)
}
}
impl Focusable for BridgeTextInput {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
fn focus_handle_for(
cx: &mut Context<BridgeTextInput>,
tab_index: Option<isize>,
disabled: bool,
) -> FocusHandle {
configure_focus_handle(cx.focus_handle(), tab_index, disabled)
}
fn configure_focus_handle(
focus_handle: FocusHandle,
tab_index: Option<isize>,
disabled: bool,
) -> FocusHandle {
let focus_handle = focus_handle.tab_stop(!disabled);
match tab_index {
Some(index) => focus_handle.tab_index(index),
None => focus_handle,
}
}
#[cfg(test)]
mod tests {
use super::{BridgeTextInput, focus_handle_for, line_ranges, marked_selection_from_utf16};
use gpui::EntityInputHandler;
#[test]
fn line_ranges_preserve_empty_multiline_segments() {
assert_eq!(line_ranges(""), vec![(0, 0)]);
assert_eq!(line_ranges("one\ntwo"), vec![(0, 3), (4, 7)]);
assert_eq!(line_ranges("one\n"), vec![(0, 3), (4, 4)]);
assert_eq!(line_ranges("one\n\nthree"), vec![(0, 3), (4, 4), (5, 10)]);
}
#[test]
fn marked_selection_utf16_range_is_relative_to_replacement_text() {
let prefix_len = "é".len();
let replacement = "あ🙂b";
assert_eq!(
marked_selection_from_utf16(prefix_len, replacement, &(1..3)),
prefix_len + "あ".len()..prefix_len + "あ🙂".len()
);
}
#[gpui::test]
fn replace_and_mark_selection_is_relative_to_new_marked_text(cx: &mut gpui::TestAppContext) {
let (input, cx) = cx.add_window_view(|_, cx| BridgeTextInput {
view_id: 1,
node_id: "ime_input".to_string(),
value: "é".into(),
placeholder: "".into(),
change: None,
disabled: false,
tab_index: None,
multiline: false,
focus_handle: focus_handle_for(cx, None, false),
selected_range: "é".len().."é".len(),
selection_reversed: false,
marked_range: None,
last_lines: Vec::new(),
last_bounds: None,
is_selecting: false,
});
input.update_in(cx, |input, window, cx| {
let prefix_len = "é".len();
let replacement = "あ🙂b";
input.replace_and_mark_text_in_range(None, replacement, Some(1..3), window, cx);
assert_eq!(input.value.as_ref(), "éあ🙂b");
assert_eq!(
input.marked_range,
Some(prefix_len..prefix_len + replacement.len())
);
assert_eq!(
input.selected_range,
prefix_len + "あ".len()..prefix_len + "あ🙂".len()
);
});
}
}