use crate::editable_text::{ StringStorage, TextBoundary, UnicodeTextStorage, actions::EditableTextActionHandler, caret::CaretNotify, history::EditableTextHistory, layout::{EditableTextLayoutResult, TextLineSegment}, }; use gpui::{ App, Bounds, ClipboardItem, Context, ElementId, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, NavigationDirection, Pixels, Point, UTF16Selection, Window, point, }; use std::{borrow::Cow, ops::Range}; const CARET_PIXELS_EPSILON: Pixels = gpui::px(4.); /// The utf-8 character range that is currently selected by the user. /// Valid both when start < end and start > end (which dictates the direction of the selection). /// Empty when start==end. The start of this range is always the current position of the caret (input cursor). /// Diverges from the semantics/expectations of the Range type (since `Range` is incoherent if start > end). #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct CaretSelection { start: usize, end: usize, } impl From for CaretSelection { fn from(value: usize) -> Self { Self { start: value, end: value, } } } impl From<(usize, usize)> for CaretSelection { fn from((start, end): (usize, usize)) -> Self { Self { start, end } } } impl From> for CaretSelection { fn from(value: Range) -> Self { Self { start: value.start, end: value.end, } } } impl Into<(usize, usize)> for CaretSelection { fn into(self) -> (usize, usize) { (self.start, self.end) } } impl CaretSelection { fn is_empty(&self) -> bool { self.start == self.end } fn range(&self) -> Range { self.start.min(self.end)..self.start.max(self.end) } } /// Internal state for EditableText elements. pub struct EditableTextState { /// The storage medium backing this element-state. Hypothetically supports both /// std String and other crates (e.g. long document text). storage: Box, /// The utf-8 character range that is currently selected by the user. /// Valid both when start < end and start > end (which dictates the direction of the selection). /// Empty when start==end. The start of this range is always the current position of the caret (input cursor). /// This means it breaks the semantics/expectations of the Range type. /// /// NOTE: because each input has its own selection state, its trivial for users to have /// multiple selections active across multiple inputs at the same time. /// This could be considered undesirable behavior, and could prompt the question of /// whether there should be a mechanism to clear selection when focus is lost. selected_range: CaretSelection, /// The utf-8 character range of `storage` which is being composed by IME marked_range: Option>, /// True while the user is in the act of highlighting a section of the text (e.g. during mouse pressed & dragging). is_selecting: bool, /// The last ui location relative to the element that the user clicked. Used to filter when a user clicks multiple times in the same area. last_click_position: Option>, /// The number of times the user has clicked `last_click_position`. Used to determine which click behavior to trigger, depending on single, double, or triple clicks. click_count: usize, focus_handle: FocusHandle, history: Option, pub(super) layout_data: EditableTextLayoutResult, } impl EventEmitter for EditableTextState {} /// Event emitted when an `EditableTextState` is changed. /// /// This is not suitable for input sanitation (which should occur before the mutation). pub struct TextChanged; impl EventEmitter for EditableTextState {} impl Focusable for EditableTextState { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl AsRef for EditableTextState { fn as_ref(&self) -> &str { self.as_str() } } impl EditableTextState { /// Uses a pre-existing state attached to the element at `key`, as long as the element has existed over consecutive frames. /// If the state does not yet exist, a new one is created using the default [`UnicodeTextStorage`] medium. pub fn use_keyed(key: impl Into, window: &mut Window, cx: &mut App) -> Entity { Self::use_keyed_init(key, window, cx, |_, _| StringStorage::default()) } /// Uses a pre-existing state attached to the element at `key`, as long as the element has existed over consecutive frames. /// If the state does not yet exist, a new one is created calling `init` to create a [`UnicodeTextStorage`] medium. /// /// ``` /// # use gpui::{RenderOnce, Window, App, IntoElement, ElementId}; /// # use gpui_elements::editable_text::{EditableTextState, StringStorage, editable_text}; /// pub struct Form; /// impl RenderOnce for Form { /// fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { /// let field_a_id = ElementId::from("field_a"); /// let field_a = EditableTextState::use_keyed_init(field_a_id.clone(), window, cx, /// |_window, _cx| StringStorage::from("this is some default editable text content")); /// editable_text(field_a_id).state(field_a.downgrade()) /// } /// } /// ``` pub fn use_keyed_init( key: impl Into, window: &mut Window, cx: &mut App, init: F, ) -> Entity where F: 'static + Fn(&mut Window, &mut Context<'_, EditableTextState>) -> StorageType, StorageType: 'static + UnicodeTextStorage, { window.use_keyed_state(key, cx, |window, cx| Self::new(init(window, cx), cx)) } /// Creates a new EditableText state with a given storage medium. /// /// Does not intrinsicly handle the state being attached to an element /// over multiple frames (e.g. via [`RenderOnce`]). Use [`use_keyed`] or [`use_keyed_init`] for that. /// /// Expected to be called via [`AppContext::new`] such as: /// ``` /// # use gpui::{AppContext, Window, App, Entity}; /// # use gpui_elements::editable_text::{StringStorage, EditableTextState}; /// # fn new(_window: &mut Window, cx: &mut App) -> Entity { /// cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)) /// # } /// ``` pub fn new(storage: impl UnicodeTextStorage + 'static, cx: &mut Context) -> Self { Self { storage: Box::new(storage), selected_range: 0.into(), marked_range: None, is_selecting: false, last_click_position: None, click_count: 0, focus_handle: cx.focus_handle(), // TODO: what is the best way to give users access to configure this via element history: Some(EditableTextHistory::default()), layout_data: EditableTextLayoutResult::default(), } } /// Returns the current contents of [`storage`] as a string slice. pub fn as_str(&self) -> &str { self.storage.content_utf8() } pub fn version(&self) -> u16 { self.storage.version() } /// Replaces the contents of the stored text with the provided string slice. pub fn emplace(&mut self, content: &str, cx: &mut Context) { let len = self.storage.content_utf8().len(); self.replace_text(0..len, content); self.emit_text_changed(cx); cx.notify(); } /// Returns the utf-8 character range that is currently selected within the current state of the text. /// Internally converts the stored direction-aware range into a canonical range. pub(super) fn selected_range(&self) -> Range { self.selected_range.range() } pub(super) fn selection_direction(&self) -> Option { match self.selected_range.start.cmp(&self.selected_range.end) { std::cmp::Ordering::Less => Some(NavigationDirection::Forward), std::cmp::Ordering::Equal => None, std::cmp::Ordering::Greater => Some(NavigationDirection::Back), } } /// Returns the position of the caret in utf8 character space. pub(super) fn caret_pos(&self) -> usize { self.selected_range.start } /// Returns the IME marked range for character operations. pub(super) fn marked_range(&self) -> Option> { self.marked_range.clone() } } impl EditableTextState { /// Validates/sanitizes incoming text according to the rules of the field. fn validate_incoming_text<'text>( &self, _range: &Range, text_to_insert: &'text str, ) -> Cow<'text, str> { // TODO: Apply text sanitization, ideally using externally-sourced implementations. // example optional/opt-in sanitations include: // - single-line fields should prune /n & /r // - maximum utf8 length // - numbers only // should also consider validation support, for features such as: // - total syntax evaluation (e.g. passwords) // - conforms to regex or math (e.g. ssn, phone number, email, etc) let mut text_to_insert = Cow::Borrowed(text_to_insert); if !self.layout_data.supports_multiline { text_to_insert = Cow::Owned(text_to_insert.replace("\n", "").replace("\r", "")); } /* A sample implementation of max-length sampled from gpuikit // Decide the effective new text up front (honouring `max_length`). // This avoids the "apply, then truncate" path which would leave the caret past the end. let max_length = None::; if let Some(cap) = max_length { let existing_len = self.as_str().len() - (range.end - range.start); let room = cap.saturating_sub(existing_len); text_to_insert = &text_to_insert[..text_to_insert.len().min(room)]; } */ // for now, this function is no-op text_to_insert } /// Internal method to record historical changes and perform text replacement in storage. /// Selection is moved to the end of the inserted text and ime marked range is cleared. fn replace_text(&mut self, range: Range, text_to_insert: &str) { let end_pos = range.start + text_to_insert.len(); self.record_history(range.clone(), text_to_insert.len()); self.storage.replace_range(range, text_to_insert); self.selected_range = end_pos.into(); self.marked_range = None; } fn emit_text_changed(&self, cx: &mut Context) { cx.emit(TextChanged); } } /// Parameters used to find a desired TextLineSegment from layout data. enum TextSegmentQuery { /// Find the line containing a character at a position (relative to the document, not a given line). CharacterPosition(usize), /// Find the line that most closely contains the provided screen position. ScreenPosition { point: Point, line_height: Pixels, }, /// Find the line containing a visual row of text, which may be wrapped /// (relative to the document, not a given line). Row(usize), } // Screen space (text layout engine output) & String space transformers impl EditableTextState { /// Attempts to find a text segment based on the provided query parameters. /// If found, the returned tuple is the segment & the number of preceding visual rows. /// If not found, the resulting "err" is the total number of visual rows of all line segments. fn find_segment(&self, query: TextSegmentQuery) -> Result<(&TextLineSegment, usize), usize> { let mut row_count = 0; for segment in &self.layout_data.lines { let found = match &query { TextSegmentQuery::CharacterPosition(pos) => segment.contains_position(*pos, false), TextSegmentQuery::ScreenPosition { point, line_height } => { let segment_start_pos_y = segment.pos_y * *line_height; let segment_height = *line_height * segment.row_count() as f32; point.y >= segment_start_pos_y && point.y < segment_start_pos_y + segment_height } TextSegmentQuery::Row(row_index) => *row_index < row_count + segment.row_count(), }; if found { return Ok((segment, row_count)); } row_count += segment.row_count(); } Err(row_count) } /// Returns the utf-8 character position of the start of the line that contains the provided pixel-point. fn index_for_pixel_point(&self, point: Point, line_height: Pixels) -> usize { let storage_len_utf8 = self.as_str().len(); if storage_len_utf8 == 0 { return 0; } let segment = self.find_segment(TextSegmentQuery::ScreenPosition { point, line_height }); let Ok((segment, _preceding_row_count)) = segment else { return storage_len_utf8; }; // the screen position of the caret relative to the text segment let relative_point = point - gpui::point(Pixels::ZERO, segment.pos_y * line_height); return segment.character_index_at_point(relative_point, line_height); } fn find_position_in_vertical_direction( &self, direction: i32, line_height: Pixels, ) -> Option { let (caret_line_index, caret_point) = self.line_index_and_point_at_caret(line_height); let target_line_index = caret_line_index.saturating_add_signed(direction as isize); let segment = self.find_segment(TextSegmentQuery::Row(target_line_index)); let Ok((segment, preceding_row_count)) = segment else { return (direction > 0).then(|| self.as_str().len()); }; // calculate the screen space position of the row we are navigating to, // relative to the y-position of the segment. let row_index = (target_line_index - preceding_row_count) as f32; let relative_point = gpui::point(caret_point.x, row_index * line_height); Some(segment.character_index_at_point(relative_point, line_height)) } fn line_index_and_point_at_caret(&self, line_height: Pixels) -> (usize, Point) { if self.layout_data.lines.is_empty() { return (0, Point::default()); } let caret_pos = self.caret_pos(); let segment = self.find_segment(TextSegmentQuery::CharacterPosition(caret_pos)); let (segment, preceding_row_count) = match segment { Ok(segment) => segment, Err(total_row_count) => return (total_row_count.saturating_sub(1), Point::default()), }; // Find the screen position relative to this segment where the caret is at. let relative_point = segment.position_for_index(caret_pos, line_height); let point = relative_point.unwrap_or_default(); // the visual row offset from the start of the segment let row_offset = (point.y / line_height).floor() as usize; (preceding_row_count + row_offset, point) } fn find_point_for_character_position(&self, character_pos: usize) -> Point { let segment = self.find_segment(TextSegmentQuery::CharacterPosition(character_pos)); let Ok((segment, preceding_row_count)) = segment else { return Point::default(); }; let line_height = self.layout_data.line_height; // Find the screen position relative to this segment where the caret is at. let relative_point = segment.position_for_index(character_pos, line_height); let line_origin = point(Pixels::ZERO, preceding_row_count as f32 * line_height); return line_origin + relative_point.unwrap_or_default(); } } // Internal user action / logical processors impl EditableTextState { fn scroll_to_caret(&mut self) { if self.layout_data.scroll_bounds.is_empty() { return; } let Some(content_size) = self.layout_data.state.size else { return; }; // point will be relative to content_size, and may or may not be within the current scroll_bounds let point = self.find_point_for_character_position(self.caret_pos()); // this scroll_offset diverges from the rest of gpui, as it is stored in the // positive real number space (interactivity stores it in the negatives) let mut scroll_offset = Cow::Borrowed(&self.layout_data.scroll_bounds.origin); if self.layout_data.scroll_bounds.contains(&point) { return; } // No existing "shift bounds origin so is contained", but that is effectively what this does if point.x < self.layout_data.scroll_bounds.left() { scroll_offset.to_mut().x = point.x; } if point.y < self.layout_data.scroll_bounds.top() { scroll_offset.to_mut().y = point.y; } let right = self.layout_data.scroll_bounds.right(); if point.x > right { scroll_offset.to_mut().x += point.x - right; } let bottom = self.layout_data.scroll_bounds.bottom(); let point_bottom = point.y + self.layout_data.line_height; if point_bottom > bottom { let delta = point_bottom - bottom; scroll_offset.to_mut().y += delta; } if let Cow::Owned(mut offset) = scroll_offset { offset.x = offset.x.clamp(Pixels::ZERO, content_size.width); offset.y = offset.y.clamp(Pixels::ZERO, content_size.height); println!("shift to {offset:?}"); self.layout_data.next_scroll_offset = Some(offset); } } /// Moves the caret to the provided position. /// /// Will cause the current scroll position/offset to update on the next frame, /// if the line the carent is on is out of view. pub fn move_to(&mut self, caret_pos: usize, cx: &mut Context) { cx.emit(CaretNotify::PauseBlinking); let caret_pos = caret_pos.min(self.storage.content_utf8().len()); self.selected_range = caret_pos.into(); self.scroll_to_caret(); cx.notify(); } /// Changes the current selection to extend to the provided position. /// /// Will cause the current scroll position/offset to update on the next frame, /// if the line the carent is on is out of view. pub fn select_to(&mut self, caret_pos: usize, cx: &mut Context) { cx.emit(CaretNotify::PauseBlinking); let caret_pos = caret_pos.min(self.as_str().len()); self.selected_range.start = caret_pos; self.scroll_to_caret(); cx.notify(); } /// Removes a chunk of text at the cursor/selection. /// No-op if the element is currently not accepting input. /// /// If there is a selection of multiple characters, the slice of text represented /// by range is replaced with an empty string. /// If there is no selection, `direction` and `boundary` are used to determine the slice of text to remove. /// /// [`NavigationDirection::Back`] represents scanning earlier in the text string from the caret. /// /// [`NavigationDirection::Forward`] represents scanning later in the text string from the caret. /// /// [`TextBoundary`] describes how far to jump from the caret. pub fn delete_linear( &mut self, direction: NavigationDirection, boundary: TextBoundary, cx: &mut Context, ) { if !self.layout_data.accepts_input { return; } let range = self.selected_range(); let range = match range.is_empty() { false => range, true => self .storage .range_from_caret(self.caret_pos(), direction, boundary), }; let storage_len_utf8 = self.storage.content_utf8().len(); let start = range.start.min(storage_len_utf8); let end = range.end.max(start).min(storage_len_utf8); self.replace_text(start..end, ""); self.emit_text_changed(cx); cx.notify(); } /// Moves the caret somewhere relative to its current location, according to `direction` and `boundary`. /// /// If there is currently a selection, the cursor will jump to the start/end of that selection based on `direction`. /// /// [`NavigationDirection::Back`] represents scanning earlier in the text string from the current caret. /// /// [`NavigationDirection::Forward`] represents scanning later in the text string from the current caret. /// /// [`TextBoundary`] describes how far to jump from the current caret pub fn nav_linear( &mut self, direction: NavigationDirection, boundary: TextBoundary, cx: &mut Context, ) { let caret_pos = match self.selected_range.is_empty() { false => match direction { NavigationDirection::Back => self.selected_range.start, NavigationDirection::Forward => self.selected_range.end, }, true => self .storage .offset_from_caret(self.caret_pos(), direction, boundary), }; self.move_to(caret_pos, cx); } /// Sets the current selection to be the entire text in the storage medium pub fn select_document(&mut self, cx: &mut Context) { self.selected_range = (0, self.storage.content_utf8().len()).into(); cx.notify(); } /// Extends the current selection to include some amount of textrelative the current /// location of the caret, according to `direction` and `boundary`. /// /// [`NavigationDirection::Back`] represents scanning earlier in the text string from the current caret. /// /// [`NavigationDirection::Forward`] represents scanning later in the text string from the current caret. /// /// [`TextBoundary`] describes how far to jump from the current caret pub fn select_linear( &mut self, direction: NavigationDirection, boundary: TextBoundary, cx: &mut Context, ) { let caret_pos = self .storage .offset_from_caret(self.caret_pos(), direction, boundary); self.select_to(caret_pos, cx); } /// Updates the mouse-click tracker so we can detect when a mouse click results in different actions. fn apply_click(&mut self, click_count: usize, text_position: Point) { let should_continue_click = click_count > 1 && self.is_position_nearly_at_previous_click(text_position); self.click_count = if should_continue_click { click_count } else { 1 }; self.last_click_position = Some(text_position); } fn is_position_nearly_at_previous_click(&self, point: Point) -> bool { match self.last_click_position { None => false, Some(previous_pos) => point.is_nearly_eq(&previous_pos, CARET_PIXELS_EPSILON), } } fn select_word_at(&mut self, caret_pos: usize, cx: &mut Context) { self.selected_range = self.storage.word_range_at(caret_pos).into(); cx.notify(); } fn select_line_at(&mut self, caret_pos: usize, cx: &mut Context) { use NavigationDirection::*; use TextBoundary::*; let line_start = self.storage.offset_from_caret(caret_pos, Back, Line); let line_end = self.storage.offset_from_caret(caret_pos, Forward, Line); let line_end_with_newline = if line_end < self.storage.content_utf8().len() { line_end + 1 } else { line_end }; self.selected_range = (line_start, line_end_with_newline).into(); cx.notify(); } } // History management impl EditableTextState { /// Returns the history log of the element, which is the data that supports undo/redo operations. pub fn history(&self) -> Option<&EditableTextHistory> { self.history.as_ref() } fn record_history(&mut self, range: Range, new_text_len: usize) { // Don't record during IME composition if self.marked_range.is_some() { return; } let Some(history) = &mut self.history else { return; }; // Capture the text that will be replaced let old_text = &self.storage.content_utf8()[range.clone()]; history.record(range, old_text, new_text_len, self.selected_range.into()); } fn apply_from_history(&mut self, src: HistoryKind, dst: HistoryKind, cx: &mut Context) { let Some(history) = &mut self.history else { return; }; let Some(entry) = history.take(src) else { return; }; let range = entry.char_range(self.storage.content_utf8().len()); // Snapshot the sub-slice that is being replaced let removed_text = self.storage.content_utf8()[range.clone()].to_string(); // Replace the slice with the history value self.storage.replace_range(range, &entry.old_text); self.selected_range = entry.selected_range.into(); // Push the entry onto the redo stack so the undo can be undone history.push(dst, entry.as_inverted(removed_text)); self.scroll_to_caret(); cx.notify(); } } impl EditableTextState { fn ime_resolve_range(&self, range_utf16: Option>) -> Range { // Use a series of fallbacks to pick the range to operate on. // Fallback order: IME provided range, active IME marked range, selection let range = range_utf16.map(|range_utf16| self.storage.utf_range_16to8(&range_utf16)); let range = range.or_else(|| self.marked_range.clone()); let range = range.unwrap_or_else(|| self.selected_range()); let storage_len_utf8 = self.as_str().len(); range.start.min(storage_len_utf8)..range.end.min(storage_len_utf8) } fn ime_mark_text_in_range(&mut self, range: &Range, text_len: usize) { self.marked_range = match text_len { 0 => None, _ => Some(range.start..range.start + text_len), }; } fn ime_mark_selected_range( &mut self, range_overwritten: &Range, new_selected_range_utf16: &Option>, text_len: usize, ) { // NOTE: Differs from yororen-ui // https://github.com/MeowLynxSea/yororen-ui/blob/346502ac654b77fdaff3be2d7444fca8783acfc9/crates/yororen-ui-core/src/headless/text_input_core.rs#L359-L371 self.selected_range = { let new_range = new_selected_range_utf16.as_ref(); let new_range = new_range.map(|range_utf16| self.storage.utf_range_16to8(range_utf16)); let new_range = new_range.map(|new_range| { new_range.start + range_overwritten.start..new_range.end + range_overwritten.start }); let new_range = new_range.unwrap_or_else(|| { range_overwritten.start + text_len..range_overwritten.start + text_len }); new_range.into() }; } } // IME handler impl EntityInputHandler for EditableTextState { fn text_for_range( &mut self, range_utf16: Range, adjusted_range: &mut Option>, _window: &mut Window, _cx: &mut Context, ) -> Option { let range = self.storage.utf_range_16to8(&range_utf16); let storage_len_utf8 = self.storage.content_utf8().len(); let clamped_range = range.start.min(storage_len_utf8)..range.end.min(storage_len_utf8); adjusted_range.replace(self.storage.utf_range_8to16(&clamped_range)); Some(self.storage.content_utf8()[clamped_range].to_string()) } fn selected_text_range( &mut self, _ignore_disabled_input: bool, _window: &mut Window, _cx: &mut Context, ) -> Option { let selection_range = self.selected_range(); let direction = self.selection_direction(); Some(UTF16Selection { range: self.storage.utf_range_8to16(&selection_range), reversed: direction == Some(NavigationDirection::Back), }) } fn marked_text_range( &self, _window: &mut Window, _cx: &mut Context, ) -> Option> { self.marked_range .as_ref() .map(|range| self.storage.utf_range_8to16(range)) } fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { self.marked_range = None; } fn replace_text_in_range( &mut self, range_utf16: Option>, text_to_insert: &str, _window: &mut Window, cx: &mut Context, ) { let range_utf8 = self.ime_resolve_range(range_utf16); let text_to_insert = self.validate_incoming_text(&range_utf8, text_to_insert); self.replace_text(range_utf8, text_to_insert.as_ref()); cx.emit(CaretNotify::PauseBlinking); self.emit_text_changed(cx); cx.notify(); } fn replace_and_mark_text_in_range( &mut self, range_utf16: Option>, text_to_insert: &str, new_selected_range_utf16: Option>, _window: &mut Window, cx: &mut Context, ) { let range = self.ime_resolve_range(range_utf16); let text_to_insert = self.validate_incoming_text(&range, text_to_insert); self.replace_text(range.clone(), text_to_insert.as_ref()); self.ime_mark_text_in_range(&range, text_to_insert.len()); self.ime_mark_selected_range(&range, &new_selected_range_utf16, text_to_insert.len()); self.emit_text_changed(cx); cx.notify(); } fn bounds_for_range( &mut self, range_utf16: Range, bounds: Bounds, window: &mut Window, _cx: &mut Context, ) -> Option> { let range = self.storage.utf_range_16to8(&range_utf16); let line_height = window.line_height(); for line in &self.layout_data.lines { // The vertical offset of the text-segment from the start of the virtual box (which could be scrolled). // Scrolling is not relevant here, so we are just operating on the internal virtualized space. let y_offset = line.pos_y * line_height; // The start of the line in screen space as if there was no scrolling. let line_origin = bounds.origin + point(Pixels::ZERO, y_offset); if line.text_range.is_empty() { if range.start == line.text_range.start { return Some(Bounds::from_corners( line_origin, line_origin + point(CARET_PIXELS_EPSILON, line_height), )); } } else if line.text_range.contains(&range.start) && let Some(wrapped) = &line.wrapped_line { let local_start = range.start - line.text_range.start; let local_end = (range.end - line.text_range.start).min(wrapped.text.len()); // The start of the line in screen-space pixels let line_start_screen_pos = wrapped .position_for_index(local_start, line_height) .unwrap_or_default(); // The end of the line in screen-space pixels let line_end_screen_pos = wrapped .position_for_index(local_end, line_height) .unwrap_or_else(|| { // the y-height/position of the last line let last_line_y = line_height * (line.row_count() - 1) as f32; point(wrapped.width(), last_line_y) }); // The number of rows this text-segment spans let line_height_range = (line_start_screen_pos.y / line_height).floor() as usize ..(line_end_screen_pos.y / line_height).floor() as usize; // The width of the line-segment that may span multiple rows let width = match line_height_range.is_empty() { true => line_end_screen_pos.x, false => wrapped.width(), }; return Some(Bounds::from_corners( line_origin + line_start_screen_pos, line_origin + point(width, line_start_screen_pos.y + line_height), )); } } None } fn character_index_for_point( &mut self, point: Point, window: &mut Window, _cx: &mut Context, ) -> Option { let index = self.index_for_pixel_point(point, window.line_height()); Some(self.storage.utf_offset_8to16(index)) } } // Input Action handler use super::{actions::*, history::HistoryKind}; impl<'app> EditableTextActionHandler> for EditableTextState { fn escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context<'app, Self>) { self.selected_range = 0.into(); cx.notify(); window.blur(); } fn insert_enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context<'app, Self>) { if !self.layout_data.supports_multiline { return; } if !self.layout_data.accepts_input { return; } self.replace_text_in_range(None, "\n", window, cx); } fn insert_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<'app, Self>) { if !self.layout_data.accepts_input { return; } self.replace_text_in_range(None, "\t", window, cx); } fn delete_left(&mut self, _: &DeleteLeft, _: &mut Window, cx: &mut Context<'app, Self>) { self.delete_linear(NavigationDirection::Back, TextBoundary::Graphmeme, cx); } fn delete_right(&mut self, _: &DeleteRight, _w: &mut Window, cx: &mut Context<'app, Self>) { self.delete_linear(NavigationDirection::Forward, TextBoundary::Graphmeme, cx); } fn delete_word_left( &mut self, _: &DeleteWordLeft, _w: &mut Window, cx: &mut Context<'app, Self>, ) { self.delete_linear(NavigationDirection::Back, TextBoundary::Word, cx); } fn delete_word_right( &mut self, _: &DeleteWordRight, _w: &mut Window, cx: &mut Context<'app, Self>, ) { self.delete_linear(NavigationDirection::Forward, TextBoundary::Word, cx); } fn delete_to_line_start( &mut self, _: &DeleteToLineStart, _w: &mut Window, cx: &mut Context<'app, Self>, ) { self.delete_linear(NavigationDirection::Back, TextBoundary::Line, cx); } fn delete_to_line_end( &mut self, _: &DeleteToLineEnd, _w: &mut Window, cx: &mut Context<'app, Self>, ) { self.delete_linear(NavigationDirection::Forward, TextBoundary::Line, cx); } fn nav_left(&mut self, _: &NavLeft, _w: &mut Window, cx: &mut Context<'app, Self>) { self.nav_linear(NavigationDirection::Back, TextBoundary::Graphmeme, cx); } fn nav_right(&mut self, _: &NavRight, _w: &mut Window, cx: &mut Context<'app, Self>) { self.nav_linear(NavigationDirection::Forward, TextBoundary::Graphmeme, cx); } fn nav_up(&mut self, _: &NavUp, window: &mut Window, cx: &mut Context<'app, Self>) { if !self.layout_data.supports_multiline { // semantically equivalent to line self.nav_linear(NavigationDirection::Back, TextBoundary::Line, cx); return; } if let Some(caret_pos) = self.find_position_in_vertical_direction(-1, window.line_height()) { self.move_to(caret_pos, cx); } } fn nav_down(&mut self, _: &NavDown, window: &mut Window, cx: &mut Context<'app, Self>) { if !self.layout_data.supports_multiline { // semantically equivalent to line self.nav_linear(NavigationDirection::Forward, TextBoundary::Line, cx); return; } if let Some(caret_pos) = self.find_position_in_vertical_direction(1, window.line_height()) { self.move_to(caret_pos, cx); } } fn nav_line_start(&mut self, _: &NavLineStart, _w: &mut Window, cx: &mut Context<'app, Self>) { // [when not multiline] semantically equivalent to document self.nav_linear(NavigationDirection::Back, TextBoundary::Line, cx); } fn nav_line_end(&mut self, _: &NavLineEnd, _w: &mut Window, cx: &mut Context<'app, Self>) { // [when not multiline] semantically equivalent to document self.nav_linear(NavigationDirection::Forward, TextBoundary::Line, cx); } fn nav_start(&mut self, _: &NavDocumentStart, _w: &mut Window, cx: &mut Context<'app, Self>) { self.nav_linear(NavigationDirection::Back, TextBoundary::Document, cx); } fn nav_end(&mut self, _: &NavDocumentEnd, _w: &mut Window, cx: &mut Context<'app, Self>) { self.nav_linear(NavigationDirection::Forward, TextBoundary::Document, cx); } fn nav_left_word(&mut self, _: &NavWordLeft, _w: &mut Window, cx: &mut Context<'app, Self>) { self.nav_linear(NavigationDirection::Back, TextBoundary::Word, cx); } fn nav_right_word(&mut self, _: &NavWordRight, _w: &mut Window, cx: &mut Context<'app, Self>) { self.nav_linear(NavigationDirection::Forward, TextBoundary::Word, cx); } fn select_all(&mut self, _: &SelectAll, _w: &mut Window, cx: &mut Context<'app, Self>) { self.select_document(cx); } fn select_left(&mut self, _: &SelectLeft, _w: &mut Window, cx: &mut Context<'app, Self>) { self.select_linear(NavigationDirection::Back, TextBoundary::Graphmeme, cx); } fn select_right(&mut self, _: &SelectRight, _w: &mut Window, cx: &mut Context<'app, Self>) { self.select_linear(NavigationDirection::Forward, TextBoundary::Graphmeme, cx); } fn select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<'app, Self>) { if !self.layout_data.supports_multiline { // semantically equivalent to select document self.select_linear(NavigationDirection::Back, TextBoundary::Document, cx); return; } if let Some(caret_pos) = self.find_position_in_vertical_direction(-1, window.line_height()) { self.select_to(caret_pos, cx); } } fn select_down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context<'app, Self>) { if !self.layout_data.supports_multiline { // semantically equivalent to select document self.select_linear(NavigationDirection::Forward, TextBoundary::Document, cx); return; } if let Some(caret_pos) = self.find_position_in_vertical_direction(1, window.line_height()) { self.select_to(caret_pos, cx); } } fn select_start( &mut self, _: &SelectDocumentStart, _w: &mut Window, cx: &mut Context<'app, Self>, ) { self.select_linear(NavigationDirection::Back, TextBoundary::Document, cx); } fn select_end(&mut self, _: &SelectDocumentEnd, _w: &mut Window, cx: &mut Context<'app, Self>) { self.select_linear(NavigationDirection::Forward, TextBoundary::Document, cx); } fn select_left_word( &mut self, _: &SelectWordLeft, _w: &mut Window, cx: &mut Context<'app, Self>, ) { self.select_linear(NavigationDirection::Back, TextBoundary::Word, cx); } fn select_right_word( &mut self, _: &SelectWordRight, _w: &mut Window, cx: &mut Context<'app, Self>, ) { self.select_linear(NavigationDirection::Forward, TextBoundary::Word, cx); } fn cut(&mut self, _: &Cut, _w: &mut Window, cx: &mut Context<'app, Self>) { if !self.layout_data.accepts_input { return; } let range_to_cut = match self.selected_range.is_empty() { // selection is more than a caret, use that range of text false => self.selected_range.range(), // No selection: cut the entire current line (including newline) true => { use NavigationDirection::*; use TextBoundary::*; let caret = self.caret_pos(); let line_start = self.storage.offset_from_caret(caret, Back, Line); let line_end = self.storage.offset_from_caret(caret, Forward, Line); let storage_len_utf8 = self.as_str().len(); // Include the newline character if there is one after the line let cut_end = if line_end < storage_len_utf8 { line_end + 1 // Include the newline } else if line_start > 0 { // Last line with no trailing newline - include preceding newline instead line_end } else { line_end }; // For last line, also remove the preceding newline if it exists let cut_start = if line_end >= storage_len_utf8 && line_start > 0 { line_start - 1 // Include preceding newline for last line } else { line_start }; cut_start..cut_end } }; // Cut selected text let slice = &self.storage.content_utf8()[range_to_cut.clone()]; cx.write_to_clipboard(ClipboardItem::new_string(slice.to_string())); self.replace_text(range_to_cut, ""); self.emit_text_changed(cx); cx.notify(); } fn copy(&mut self, _: &Copy, _w: &mut Window, cx: &mut Context<'app, Self>) { if !self.selected_range.is_empty() { let slice = &self.storage.content_utf8()[self.selected_range.range()]; cx.write_to_clipboard(ClipboardItem::new_string(slice.to_string())); } } fn paste(&mut self, _: &Paste, _w: &mut Window, cx: &mut Context<'app, Self>) { if !self.layout_data.accepts_input { return; } let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { return; }; let range = self.ime_resolve_range(None); let text_to_insert = self.validate_incoming_text(&range, &text); self.replace_text(range, text_to_insert.as_ref()); self.emit_text_changed(cx); cx.notify(); } fn undo(&mut self, _: &Undo, _w: &mut Window, cx: &mut Context<'app, Self>) { self.apply_from_history(HistoryKind::Undo, HistoryKind::Redo, cx); } fn redo(&mut self, _: &Redo, _w: &mut Window, cx: &mut Context<'app, Self>) { self.apply_from_history(HistoryKind::Redo, HistoryKind::Undo, cx); } fn on_mouse_down( &mut self, event: &gpui::MouseDownEvent, text_position: Point, window: &mut Window, cx: &mut Context<'app, Self>, ) { const DOUBLE_CLICK: usize = 2; const TRIPLE_CLICK: usize = 3; let caret_pos = self.index_for_pixel_point(text_position, window.line_height()); self.is_selecting = true; self.apply_click(event.click_count, text_position); match self.click_count { DOUBLE_CLICK => self.select_word_at(caret_pos, cx), TRIPLE_CLICK => self.select_line_at(caret_pos, cx), _ if event.modifiers.shift => self.select_to(caret_pos, cx), _ => self.move_to(caret_pos, cx), } } fn on_mouse_up( &mut self, _event: &gpui::MouseUpEvent, _w: &mut Window, _cx: &mut Context<'app, Self>, ) { self.is_selecting = false; } fn on_mouse_move( &mut self, _event: &gpui::MouseMoveEvent, text_position: Point, window: &mut Window, cx: &mut Context<'app, Self>, ) { if self.is_selecting && self.click_count == 1 { let character_pos = self.index_for_pixel_point(text_position, window.line_height()); self.select_to(character_pos, cx); } } } /// Backlog: /// - tests for text layout (generating TextLineSegment and wrap-boundaries); /// permutations of: single and multiline fields, wrap vs no-wrap, overflow scroll vs no scroll #[cfg(test)] mod tests { use std::time::Duration; use super::*; use crate::editable_text::StringStorage; use gpui::{AppContext, Entity, IntoElement, Render, TestAppContext, WindowHandle, div}; struct TestView { input: Entity, } impl Render for TestView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() } } fn default_state(content: &str, cx: &mut Context) -> EditableTextState { EditableTextState::new(StringStorage::from(content), cx) } fn create_test_input( cx: &mut TestAppContext, content: &str, range: impl Into, ) -> WindowHandle { cx.add_window(|_window, cx| { let input = cx.new(|cx| { let mut input = default_state(content, cx); input.selected_range = range.into(); input.layout_data.accepts_input = true; input }); TestView { input } }) } // Disable grouping for predictable test behavior fn without_history_grouping(state: &mut EditableTextState) { state .history .get_or_insert_default() .set_grouping_interval(Duration::from_secs(0)); } fn is_history_kind_available(state: &EditableTextState, kind: HistoryKind) -> bool { state .history() .map(|history| history.has_next(kind)) .unwrap_or_default() } // ============================================================ // BASIC MOVEMENT // ============================================================ #[gpui::test] fn test_left_at_start_of_content(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_left_moves_by_grapheme(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 3); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range, 2.into()); }); }) .unwrap(); } #[gpui::test] fn test_left_collapses_selection_to_start(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", (1, 4)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range, 1.into()); }); }) .unwrap(); } #[gpui::test] fn test_left_stops_at_end_of_line(cx: &mut TestAppContext) { // "ab\ncd" - cursor at position 3 (start of "cd", after newline) // Pressing left should move to position 2 (end of "ab", before newline) let view = create_test_input(cx, "ab\ncd", 3); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range, 2.into()); // cursor at end of line 1 }); }) .unwrap(); } #[gpui::test] fn test_right_at_end_of_content(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 5.into()); }); }) .unwrap(); } #[gpui::test] fn test_right_moves_by_grapheme(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 2); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 3.into()); }); }) .unwrap(); } #[gpui::test] fn test_right_collapses_selection_to_end(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", (1, 4)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 4.into()); }); }) .unwrap(); } #[gpui::test] fn test_right_stops_at_end_of_line(cx: &mut TestAppContext) { // "ab\ncd" - cursor at position 1 (after 'a') // Pressing right should move to position 2 (end of "ab", before newline) let view = create_test_input(cx, "ab\ncd", 1); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 2.into()); // cursor at end of line 1 }); }) .unwrap(); } #[gpui::test] fn test_right_crosses_newline(cx: &mut TestAppContext) { // "ab\ncd" - cursor at position 2 (end of "ab", before newline) // Pressing right should move to position 3 (after newline, start of "cd") let view = create_test_input(cx, "ab\ncd", 2); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 3.into()); // cursor at start of line 2 }); }) .unwrap(); } #[gpui::test] fn test_left_crosses_newline(cx: &mut TestAppContext) { // "ab\ncd" - cursor at position 2 (end of "ab", before newline) // Pressing left should move to position 1 (after 'a') let view = create_test_input(cx, "ab\ncd", 2); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range, 1.into()); }); }) .unwrap(); } #[gpui::test] fn test_home_moves_to_line_start(cx: &mut TestAppContext) { let view = create_test_input(cx, "first\nsecond", 9); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_line_start(&NavLineStart, window, cx); assert_eq!(input.selected_range, 6.into()); }); }) .unwrap(); } #[gpui::test] fn test_end_moves_to_line_end(cx: &mut TestAppContext) { let view = create_test_input(cx, "first\nsecond", 8); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_line_end(&NavLineEnd, window, cx); assert_eq!(input.selected_range, 12.into()); }); }) .unwrap(); } #[gpui::test] fn test_move_to_beginning(cx: &mut TestAppContext) { let view = create_test_input(cx, "first\nsecond\nthird", 9); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_start(&NavDocumentStart, window, cx); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_move_to_end(cx: &mut TestAppContext) { let view = create_test_input(cx, "first\nsecond\nthird", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_end(&NavDocumentEnd, window, cx); assert_eq!(input.selected_range, 18.into()); }); }) .unwrap(); } // ============================================================ // WORD MOVEMENT // ============================================================ #[gpui::test] fn test_word_left_at_start(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_left_word(&NavWordLeft, window, cx); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_word_left_stops_at_boundary(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world test", 11); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_left_word(&NavWordLeft, window, cx); assert_eq!(input.selected_range, 6.into()); }); }) .unwrap(); } #[gpui::test] fn test_word_right_at_end(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 11); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right_word(&NavWordRight, window, cx); assert_eq!(input.selected_range, 11.into()); }); }) .unwrap(); } #[gpui::test] fn test_word_right_stops_at_boundary(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world test", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right_word(&NavWordRight, window, cx); assert_eq!(input.selected_range, 5.into()); }); }) .unwrap(); } // ============================================================ // SELECTION // ============================================================ #[gpui::test] fn test_select_left_extends_selection(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 3); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.select_left(&SelectLeft, window, cx); assert_eq!(input.selected_range, (2, 3).into()); }); }) .unwrap(); } #[gpui::test] fn test_select_right_extends_selection(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 2..2); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.select_right(&SelectRight, window, cx); assert_eq!(input.selected_range, (3, 2).into()); }); }) .unwrap(); } #[gpui::test] fn test_select_all(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello\nworld", 3); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.select_all(&SelectAll, window, cx); assert_eq!(input.selected_range, (0, 11).into()); }); }) .unwrap(); } #[gpui::test] fn test_select_to_beginning(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 6); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.select_start(&SelectDocumentStart, window, cx); assert_eq!(input.selected_range, (0, 6).into()); }); }) .unwrap(); } #[gpui::test] fn test_select_to_end(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 6); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.select_end(&SelectDocumentEnd, window, cx); assert_eq!(input.selected_range, (11, 6).into()); }); }) .unwrap(); } // ============================================================ // EDITING - BACKSPACE // ============================================================ #[gpui::test] fn test_backspace_deletes_selection(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", (6, 11)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_left(&DeleteLeft, window, cx); assert_eq!(input.as_str(), "hello "); assert_eq!(input.selected_range, 6.into()); }); }) .unwrap(); } #[gpui::test] fn test_backspace_deletes_previous_grapheme(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_left(&DeleteLeft, window, cx); assert_eq!(input.as_str(), "hell"); assert_eq!(input.selected_range, 4.into()); }); }) .unwrap(); } #[gpui::test] fn test_backspace_at_start_does_nothing(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_left(&DeleteLeft, window, cx); assert_eq!(input.as_str(), "hello"); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_backspace_deletes_entire_emoji(cx: &mut TestAppContext) { let view = create_test_input(cx, "Hi πŸ‘‹", 7); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_left(&DeleteLeft, window, cx); assert_eq!(input.as_str(), "Hi "); assert_eq!(input.selected_range, 3.into()); }); }) .unwrap(); } // ============================================================ // EDITING - DELETE // ============================================================ #[gpui::test] fn test_delete_deletes_selection(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", (0, 5)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_right(&DeleteRight, window, cx); assert_eq!(input.as_str(), " world"); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_delete_deletes_next_grapheme(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_right(&DeleteRight, window, cx); assert_eq!(input.as_str(), "ello"); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_delete_at_end_does_nothing(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_right(&DeleteRight, window, cx); assert_eq!(input.as_str(), "hello"); assert_eq!(input.selected_range, 5.into()); }); }) .unwrap(); } // ============================================================ // EDITING - ENTER // ============================================================ #[gpui::test] fn test_enter_inserts_newline(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.layout_data.supports_multiline = true; input.insert_enter(&Enter, window, cx); assert_eq!(input.as_str(), "hello\n world"); assert_eq!(input.selected_range, 6.into()); }); }) .unwrap(); } #[gpui::test] fn test_enter_replaces_selection(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", (5, 6)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.layout_data.supports_multiline = true; input.insert_enter(&Enter, window, cx); assert_eq!(input.as_str(), "hello\nworld"); assert_eq!(input.selected_range, 6.into()); }); }) .unwrap(); } // ============================================================ // CLIPBOARD // ============================================================ #[gpui::test] fn test_copy_with_selection(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", (6, 11)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.copy(&Copy, window, cx); }); }) .unwrap(); let clipboard = cx.read_from_clipboard(); assert!(clipboard.is_some()); assert_eq!(clipboard.unwrap().text().as_deref(), Some("world")); } #[gpui::test] fn test_cut_with_selection(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", (0, 5)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); assert_eq!(input.as_str(), " world"); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); let clipboard = cx.read_from_clipboard(); assert_eq!(clipboard.unwrap().text().as_deref(), Some("hello")); } #[gpui::test] fn test_paste_inserts_text(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 5); cx.write_to_clipboard(ClipboardItem::new_string(" there".to_string())); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.paste(&Paste, window, cx); assert_eq!(input.as_str(), "hello there world"); assert_eq!(input.selected_range, 11.into()); }); }) .unwrap(); } // ============================================================ // UNICODE / GRAPHEME HANDLING // ============================================================ #[gpui::test] fn test_movement_with_multibyte_utf8(cx: &mut TestAppContext) { let view = create_test_input(cx, "cafΓ©", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 1.into()); input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 2.into()); input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 3.into()); input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 5.into()); }); }) .unwrap(); } #[gpui::test] fn test_movement_with_emoji(cx: &mut TestAppContext) { let view = create_test_input(cx, "aπŸ‘‹b", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 1.into()); input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 5.into()); input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 6.into()); }); }) .unwrap(); } #[gpui::test] fn test_selection_with_multibyte_characters(cx: &mut TestAppContext) { let view = create_test_input(cx, "ζ—₯本θͺž", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.select_right(&SelectRight, window, cx); assert_eq!(input.selected_range, (3, 0).into()); input.select_right(&SelectRight, window, cx); assert_eq!(input.selected_range, (6, 0).into()); input.select_right(&SelectRight, window, cx); assert_eq!(input.selected_range, (9, 0).into()); }); }) .unwrap(); } // ============================================================ // NEWLINE HANDLING // ============================================================ #[gpui::test] fn test_find_line_start_and_end(cx: &mut TestAppContext) { let view = create_test_input(cx, "first\nsecond\nthird", 0); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, _cx| { use NavigationDirection::*; use TextBoundary::*; let storage = &input.storage; assert_eq!(storage.offset_from_caret(0, Back, Line), 0); assert_eq!(storage.offset_from_caret(3, Back, Line), 0); assert_eq!(storage.offset_from_caret(6, Back, Line), 6); assert_eq!(storage.offset_from_caret(13, Back, Line), 13); assert_eq!(storage.offset_from_caret(0, Forward, Line), 5); assert_eq!(storage.offset_from_caret(6, Forward, Line), 12); assert_eq!(storage.offset_from_caret(13, Forward, Line), 18); }); }) .unwrap(); } // ============================================================ // EDGE CASES // ============================================================ #[gpui::test] fn test_operations_on_empty_content(cx: &mut TestAppContext) { let view = create_test_input(cx, "", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range, 0.into()); input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range, 0.into()); input.delete_left(&DeleteLeft, window, cx); assert_eq!(input.as_str(), ""); input.delete_right(&DeleteRight, window, cx); assert_eq!(input.as_str(), ""); input.select_all(&SelectAll, window, cx); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_set_content_resets_selection(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", (3, 8)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.marked_range = Some(5..7); input.replace_text_in_range(Some(0..11), "new content", window, cx); assert_eq!(input.as_str(), "new content"); assert_eq!(input.selected_range, 11.into()); assert_eq!(input.marked_range, None); }); }) .unwrap(); } #[gpui::test] fn test_cursor_clamped_to_content_length(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 100); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, cx| { input.move_to(1000, cx); assert_eq!(input.selected_range, 5.into()); input.selected_range = 0.into(); input.select_to(1000, cx); assert_eq!(input.selected_range, (5, 0).into()); }); }) .unwrap(); } #[gpui::test] fn test_previous_boundary_at_start(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 0); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, _cx| { use NavigationDirection::*; use TextBoundary::*; assert_eq!(input.storage.offset_from_caret(0, Back, Graphmeme), 0); }); }) .unwrap(); } #[gpui::test] fn test_next_boundary_at_end(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 0); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, _cx| { use NavigationDirection::*; use TextBoundary::*; let storage = &input.storage; assert_eq!(storage.offset_from_caret(5, Forward, Graphmeme), 5); assert_eq!(storage.offset_from_caret(100, Forward, Graphmeme), 5); }); }) .unwrap(); } #[gpui::test] fn test_word_range_at_boundary(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 0); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, _cx| { let range = input.storage.word_range_at(5); assert_eq!(range.start, 0); assert_eq!(range.end, 5); let range = input.storage.word_range_at(8); assert_eq!(range.start, 6); assert_eq!(range.end, 11); }); }) .unwrap(); } // ============================================================ // EMOJI & GRAPHEME CLUSTERS // ============================================================ #[gpui::test] fn test_simple_emoji_navigation(cx: &mut TestAppContext) { // πŸ˜€ is 4 bytes in UTF-8 let view = create_test_input(cx, "aπŸ˜€b", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { // Move right through: a -> πŸ˜€ -> b input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range.start, 1); // after 'a' input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range.start, 5); // after πŸ˜€ (1 + 4 bytes) input.nav_right(&NavRight, window, cx); assert_eq!(input.selected_range.start, 6); // after 'b' // Move left back input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range.start, 5); // before 'b' input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range.start, 1); // before πŸ˜€ }); }) .unwrap(); } #[gpui::test] fn test_emoji_with_skin_tone_modifier(cx: &mut TestAppContext) { // πŸ‘‹πŸ½ = πŸ‘‹ (U+1F44B, 4 bytes) + 🏽 (U+1F3FD, 4 bytes) = 8 bytes total let emoji = "πŸ‘‹πŸ½"; assert_eq!(emoji.len(), 8); let view = create_test_input(cx, &format!("a{}b", emoji), 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); // past 'a' assert_eq!(input.selected_range.start, 1); input.nav_right(&NavRight, window, cx); // past entire emoji with modifier assert_eq!(input.selected_range.start, 9); // 1 + 8 input.nav_left(&NavLeft, window, cx); // back before emoji assert_eq!(input.selected_range.start, 1); }); }) .unwrap(); } #[gpui::test] fn test_zwj_family_emoji(cx: &mut TestAppContext) { // πŸ‘¨β€πŸ‘©β€πŸ‘§ = man + ZWJ + woman + ZWJ + girl // Each person emoji is 4 bytes, ZWJ is 3 bytes // Total: 4 + 3 + 4 + 3 + 4 = 18 bytes let family = "πŸ‘¨β€πŸ‘©β€πŸ‘§"; assert_eq!(family.len(), 18); let view = create_test_input(cx, &format!("x{}y", family), 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); // past 'x' assert_eq!(input.selected_range.start, 1); input.nav_right(&NavRight, window, cx); // past entire ZWJ sequence assert_eq!(input.selected_range.start, 19); // 1 + 18 input.nav_right(&NavRight, window, cx); // past 'y' assert_eq!(input.selected_range.start, 20); }); }) .unwrap(); } #[gpui::test] fn test_backspace_deletes_emoji_between_ascii(cx: &mut TestAppContext) { let view = create_test_input(cx, "aπŸ˜€b", 5); // cursor after emoji view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_left(&DeleteLeft, window, cx); assert_eq!(input.as_str(), "ab"); assert_eq!(input.selected_range.start, 1); }); }) .unwrap(); } #[gpui::test] fn test_backspace_deletes_zwj_sequence(cx: &mut TestAppContext) { let family = "πŸ‘¨β€πŸ‘©β€πŸ‘§"; let content = format!("a{}b", family); let cursor_pos = 1 + family.len(); // after the family emoji let view = create_test_input(cx, &content, cursor_pos..cursor_pos); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_left(&DeleteLeft, window, cx); assert_eq!(input.as_str(), "ab"); assert_eq!(input.selected_range.start, 1); }); }) .unwrap(); } #[gpui::test] fn test_delete_removes_entire_emoji(cx: &mut TestAppContext) { let view = create_test_input(cx, "aπŸ˜€b", 1); // cursor before emoji view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_right(&DeleteRight, window, cx); assert_eq!(input.as_str(), "ab"); assert_eq!(input.selected_range.start, 1); }); }) .unwrap(); } #[gpui::test] fn test_flag_emoji_navigation(cx: &mut TestAppContext) { // πŸ‡―πŸ‡΅ = Regional Indicator J (4 bytes) + Regional Indicator P (4 bytes) let flag = "πŸ‡―πŸ‡΅"; assert_eq!(flag.len(), 8); let view = create_test_input(cx, &format!("x{}y", flag), 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); // past 'x' input.nav_right(&NavRight, window, cx); // past flag (should be single grapheme) assert_eq!(input.selected_range.start, 9); // 1 + 8 }); }) .unwrap(); } #[gpui::test] fn test_combining_diacritical_marks(cx: &mut TestAppContext) { // Γ© as e + combining acute accent (U+0301) let combining = "e\u{0301}"; // 1 + 2 = 3 bytes assert_eq!(combining.len(), 3); let view = create_test_input(cx, &format!("a{}b", combining), 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); // past 'a' assert_eq!(input.selected_range.start, 1); input.nav_right(&NavRight, window, cx); // past e + combining mark (single grapheme) assert_eq!(input.selected_range.start, 4); // 1 + 3 input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range.start, 1); }); }) .unwrap(); } #[gpui::test] fn test_multiple_combining_marks(cx: &mut TestAppContext) { // ë́ = e + combining diaeresis (U+0308) + combining acute (U+0301) let multi_combining = "e\u{0308}\u{0301}"; // 1 + 2 + 2 = 5 bytes assert_eq!(multi_combining.len(), 5); let view = create_test_input(cx, &format!("x{}y", multi_combining), 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); // past 'x' input.nav_right(&NavRight, window, cx); // past entire combined character assert_eq!(input.selected_range.start, 6); // 1 + 5 }); }) .unwrap(); } #[gpui::test] fn test_select_emoji_with_shift(cx: &mut TestAppContext) { let view = create_test_input(cx, "aπŸ˜€b", 1); // cursor before emoji view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.select_right(&SelectRight, window, cx); assert_eq!(input.selected_range, (5, 1).into()); // selected the entire emoji }); }) .unwrap(); } #[gpui::test] fn test_cjk_characters(cx: &mut TestAppContext) { // δ½ ε₯½ - each character is 3 bytes in UTF-8 let view = create_test_input(cx, "aδ½ ε₯½b", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); // past 'a' assert_eq!(input.selected_range.start, 1); input.nav_right(&NavRight, window, cx); // past δ½  assert_eq!(input.selected_range.start, 4); // 1 + 3 input.nav_right(&NavRight, window, cx); // past ε₯½ assert_eq!(input.selected_range.start, 7); // 4 + 3 input.nav_right(&NavRight, window, cx); // past 'b' assert_eq!(input.selected_range.start, 8); }); }) .unwrap(); } #[gpui::test] fn test_mixed_script_text(cx: &mut TestAppContext) { // Mix of ASCII, CJK, and emoji let view = create_test_input(cx, "Hiδ½ πŸ˜€", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); // past 'H' assert_eq!(input.selected_range.start, 1); input.nav_right(&NavRight, window, cx); // past 'i' assert_eq!(input.selected_range.start, 2); input.nav_right(&NavRight, window, cx); // past δ½  (3 bytes) assert_eq!(input.selected_range.start, 5); input.nav_right(&NavRight, window, cx); // past πŸ˜€ (4 bytes) assert_eq!(input.selected_range.start, 9); // Now go back input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range.start, 5); input.nav_left(&NavLeft, window, cx); assert_eq!(input.selected_range.start, 2); }); }) .unwrap(); } #[gpui::test] fn test_variation_selector_emoji(cx: &mut TestAppContext) { // ☺️ = ☺ (U+263A, 3 bytes) + variation selector-16 (U+FE0F, 3 bytes) let emoji_presentation = "☺\u{FE0F}"; assert_eq!(emoji_presentation.len(), 6); let view = create_test_input(cx, &format!("a{}b", emoji_presentation), 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); // past 'a' input.nav_right(&NavRight, window, cx); // past emoji with variation selector assert_eq!(input.selected_range.start, 7); // 1 + 6 }); }) .unwrap(); } #[gpui::test] fn test_keycap_emoji(cx: &mut TestAppContext) { // 1️⃣ = 1 + variation selector + combining enclosing keycap let keycap = "1\u{FE0F}\u{20E3}"; let view = create_test_input(cx, &format!("x{}y", keycap), 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_right(&NavRight, window, cx); // past 'x' input.nav_right(&NavRight, window, cx); // past keycap sequence let expected_pos = 1 + keycap.len(); assert_eq!(input.selected_range.start, expected_pos); }); }) .unwrap(); } // Single-line input tests fn create_single_line_input( cx: &mut TestAppContext, content: &str, selected_range: impl Into, ) -> WindowHandle { cx.add_window(|_window, cx| { let input = cx.new(|cx| { let mut input = default_state(content, cx); input.selected_range = selected_range.into(); input }); TestView { input } }) } #[gpui::test] fn test_single_line_enter_does_nothing(cx: &mut TestAppContext) { let view = create_single_line_input(cx, "hello", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.insert_enter(&Enter, window, cx); assert_eq!(input.as_str(), "hello"); assert_eq!(input.selected_range, 5.into()); }); }) .unwrap(); } #[gpui::test] fn test_single_line_up_moves_to_start(cx: &mut TestAppContext) { let view = create_single_line_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_up(&NavUp, window, cx); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_single_line_down_moves_to_end(cx: &mut TestAppContext) { let view = create_single_line_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.nav_down(&NavDown, window, cx); assert_eq!(input.selected_range, 11.into()); // "hello world".len() == 11 }); }) .unwrap(); } #[gpui::test] fn test_single_line_select_up_selects_to_start(cx: &mut TestAppContext) { let view = create_single_line_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.select_up(&SelectUp, window, cx); assert_eq!(input.selected_range, (0, 5).into()); }); }) .unwrap(); } #[gpui::test] fn test_single_line_select_down_selects_to_end(cx: &mut TestAppContext) { let view = create_single_line_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.select_down(&SelectDown, window, cx); assert_eq!(input.selected_range, (11, 5).into()); // "hello world".len() == 11 }); }) .unwrap(); } // ============================================================ // UNDO / REDO // ============================================================ #[gpui::test] fn test_undo_restores_content(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); // Make an edit input.replace_text_in_range(None, " world", window, cx); assert_eq!(input.as_str(), "hello world"); // Undo should restore original content input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello"); }); }) .unwrap(); } #[gpui::test] fn test_redo_restores_undone_content(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.replace_text_in_range(None, " world", window, cx); assert_eq!(input.as_str(), "hello world"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello"); input.redo(&Redo, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } #[gpui::test] fn test_undo_with_no_history_does_nothing(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { assert!(!is_history_kind_available(input, HistoryKind::Undo)); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello"); }); }) .unwrap(); } #[gpui::test] fn test_redo_with_no_history_does_nothing(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { assert!(!is_history_kind_available(input, HistoryKind::Redo)); input.redo(&Redo, window, cx); assert_eq!(input.as_str(), "hello"); }); }) .unwrap(); } #[gpui::test] fn test_undo_restores_selection(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", (0, 5)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); // Delete selection input.replace_text_in_range(None, "", window, cx); assert_eq!(input.as_str(), " world"); assert_eq!(input.selected_range, 0.into()); // Undo should restore content and selection input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello world"); assert_eq!(input.selected_range, (0, 5).into()); }); }) .unwrap(); } #[gpui::test] fn test_multiple_undo_redo(cx: &mut TestAppContext) { let view = create_test_input(cx, "", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.replace_text_in_range(None, "a", window, cx); input.replace_text_in_range(None, "b", window, cx); input.replace_text_in_range(None, "c", window, cx); assert_eq!(input.as_str(), "abc"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "ab"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "a"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), ""); input.redo(&Redo, window, cx); assert_eq!(input.as_str(), "a"); input.redo(&Redo, window, cx); assert_eq!(input.as_str(), "ab"); input.redo(&Redo, window, cx); assert_eq!(input.as_str(), "abc"); }); }) .unwrap(); } #[gpui::test] fn test_new_edit_clears_redo_stack(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.replace_text_in_range(None, " world", window, cx); assert_eq!(input.as_str(), "hello world"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello"); assert!(is_history_kind_available(input, HistoryKind::Redo)); // New edit should clear redo stack input.replace_text_in_range(None, "!", window, cx); assert_eq!(input.as_str(), "hello!"); assert!(!is_history_kind_available(input, HistoryKind::Redo)); }); }) .unwrap(); } #[gpui::test] fn test_can_undo_can_redo(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); assert!(!is_history_kind_available(input, HistoryKind::Undo)); assert!(!is_history_kind_available(input, HistoryKind::Redo)); input.replace_text_in_range(None, "!", window, cx); assert!(is_history_kind_available(input, HistoryKind::Undo)); assert!(!is_history_kind_available(input, HistoryKind::Redo)); input.undo(&Undo, window, cx); assert!(!is_history_kind_available(input, HistoryKind::Undo)); assert!(is_history_kind_available(input, HistoryKind::Redo)); input.redo(&Redo, window, cx); assert!(is_history_kind_available(input, HistoryKind::Undo)); assert!(!is_history_kind_available(input, HistoryKind::Redo)); }); }) .unwrap(); } #[gpui::test] fn test_backspace_is_undoable(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.delete_left(&DeleteLeft, window, cx); assert_eq!(input.as_str(), "hell"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello"); }); }) .unwrap(); } #[gpui::test] fn test_delete_is_undoable(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.delete_right(&DeleteRight, window, cx); assert_eq!(input.as_str(), "ello"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello"); }); }) .unwrap(); } #[gpui::test] fn test_cut_is_undoable(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", (0, 5)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.cut(&Cut, window, cx); assert_eq!(input.as_str(), " world"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } #[gpui::test] fn test_cut_line_with_no_selection(cx: &mut TestAppContext) { // Cursor in middle line, no selection - should cut entire line including newline let view = create_test_input(cx, "line1\nline2\nline3", 8); // cursor in "line2" view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); assert_eq!(input.as_str(), "line1\nline3"); }); }) .unwrap(); let clipboard = cx.read_from_clipboard(); assert_eq!(clipboard.unwrap().text().as_deref(), Some("line2\n")); } #[gpui::test] fn test_cut_first_line_with_no_selection(cx: &mut TestAppContext) { // Cursor on first line, no selection let view = create_test_input(cx, "line1\nline2\nline3", 2); // cursor in "line1" view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); assert_eq!(input.as_str(), "line2\nline3"); }); }) .unwrap(); let clipboard = cx.read_from_clipboard(); assert_eq!(clipboard.unwrap().text().as_deref(), Some("line1\n")); } #[gpui::test] fn test_cut_last_line_with_no_selection(cx: &mut TestAppContext) { // Cursor on last line, no selection - should include preceding newline let view = create_test_input(cx, "line1\nline2\nline3", 14); // cursor in "line3" view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); assert_eq!(input.as_str(), "line1\nline2"); }); }) .unwrap(); let clipboard = cx.read_from_clipboard(); assert_eq!(clipboard.unwrap().text().as_deref(), Some("\nline3")); } #[gpui::test] fn test_cut_empty_line(cx: &mut TestAppContext) { // Cursor on empty line - should remove that line let view = create_test_input(cx, "line1\n\nline3", 6); // cursor on empty line view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); assert_eq!(input.as_str(), "line1\nline3"); }); }) .unwrap(); let clipboard = cx.read_from_clipboard(); assert_eq!(clipboard.unwrap().text().as_deref(), Some("\n")); } #[gpui::test] fn test_cut_only_line_with_no_selection(cx: &mut TestAppContext) { // Single line content, no selection - should cut entire content let view = create_test_input(cx, "hello", 2); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.cut(&Cut, window, cx); assert_eq!(input.as_str(), ""); }); }) .unwrap(); let clipboard = cx.read_from_clipboard(); assert_eq!(clipboard.unwrap().text().as_deref(), Some("hello")); } #[gpui::test] fn test_cut_line_is_undoable(cx: &mut TestAppContext) { let view = create_test_input(cx, "line1\nline2\nline3", 8); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.cut(&Cut, window, cx); assert_eq!(input.as_str(), "line1\nline3"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "line1\nline2\nline3"); }); }) .unwrap(); } #[gpui::test] fn test_paste_is_undoable(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello", 5); cx.write_to_clipboard(ClipboardItem::new_string(" world".to_string())); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.paste(&Paste, window, cx); assert_eq!(input.as_str(), "hello world"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello"); }); }) .unwrap(); } #[gpui::test] fn test_enter_is_undoable(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.layout_data.supports_multiline = true; input.insert_enter(&Enter, window, cx); assert_eq!(input.as_str(), "hello\n world"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } #[gpui::test] fn test_delete_word_left(cx: &mut TestAppContext) { // Cursor at end of "hello" in "hello world" let view = create_test_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_left(&DeleteWordLeft, window, cx); assert_eq!(input.as_str(), " world"); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_delete_word_left_with_selection(cx: &mut TestAppContext) { // Selection from 0 to 5 ("hello") let view = create_test_input(cx, "hello world", (0, 5)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_left(&DeleteWordLeft, window, cx); assert_eq!(input.as_str(), " world"); }); }) .unwrap(); } #[gpui::test] fn test_delete_word_left_at_start(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_left(&DeleteWordLeft, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } #[gpui::test] fn test_delete_word_right(cx: &mut TestAppContext) { // Cursor at start let view = create_test_input(cx, "hello world", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_right(&DeleteWordRight, window, cx); assert_eq!(input.as_str(), " world"); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_delete_word_right_with_selection(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", (0, 5)); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_right(&DeleteWordRight, window, cx); assert_eq!(input.as_str(), " world"); }); }) .unwrap(); } #[gpui::test] fn test_delete_word_right_at_end(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 11); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_word_right(&DeleteWordRight, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } #[gpui::test] fn test_delete_to_beginning_of_line(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_line_start(&DeleteToLineStart, window, cx); assert_eq!(input.as_str(), " world"); assert_eq!(input.selected_range, 0.into()); }); }) .unwrap(); } #[gpui::test] fn test_delete_to_beginning_of_line_multiline(cx: &mut TestAppContext) { // Cursor at position 8 (middle of "line2") let view = create_test_input(cx, "line1\nline2\nline3", 8); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_line_start(&DeleteToLineStart, window, cx); assert_eq!(input.as_str(), "line1\nne2\nline3"); }); }) .unwrap(); } #[gpui::test] fn test_delete_to_beginning_of_line_at_start(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 0); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_line_start(&DeleteToLineStart, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } #[gpui::test] fn test_delete_to_end_of_line(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_line_end(&DeleteToLineEnd, window, cx); assert_eq!(input.as_str(), "hello"); assert_eq!(input.selected_range, 5.into()); }); }) .unwrap(); } #[gpui::test] fn test_delete_to_end_of_line_multiline(cx: &mut TestAppContext) { // Cursor at position 8 (middle of "line2") let view = create_test_input(cx, "line1\nline2\nline3", 8); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_line_end(&DeleteToLineEnd, window, cx); assert_eq!(input.as_str(), "line1\nli\nline3"); }); }) .unwrap(); } #[gpui::test] fn test_delete_to_end_of_line_at_end(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 11); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { input.delete_to_line_end(&DeleteToLineEnd, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } #[gpui::test] fn test_delete_word_left_is_undoable(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.delete_word_left(&DeleteWordLeft, window, cx); assert_eq!(input.as_str(), " world"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } #[gpui::test] fn test_delete_word_right_is_undoable(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 6); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.delete_word_right(&DeleteWordRight, window, cx); assert_eq!(input.as_str(), "hello "); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } #[gpui::test] fn test_delete_to_beginning_of_line_is_undoable(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.delete_to_line_start(&DeleteToLineStart, window, cx); assert_eq!(input.as_str(), " world"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } #[gpui::test] fn test_delete_to_end_of_line_is_undoable(cx: &mut TestAppContext) { let view = create_test_input(cx, "hello world", 5); view.update(cx, |view, window, cx| { view.input.update(cx, |input, cx| { without_history_grouping(input); input.delete_to_line_end(&DeleteToLineEnd, window, cx); assert_eq!(input.as_str(), "hello"); input.undo(&Undo, window, cx); assert_eq!(input.as_str(), "hello world"); }); }) .unwrap(); } }