From 579b8d06f192d3a8056bcf1ea537564182fcd23b Mon Sep 17 00:00:00 2001 From: temportalflux Date: Thu, 25 Jun 2026 18:11:52 -0400 Subject: [PATCH] collapse UnicodeTextStorage api to only expose methods operating on the direction/boundary enums --- crates/gpui_elements/src/editable_text.rs | 2 + .../gpui_elements/src/editable_text/state.rs | 64 +++--- .../src/editable_text/storage.rs | 204 +++++++++--------- 3 files changed, 137 insertions(+), 133 deletions(-) diff --git a/crates/gpui_elements/src/editable_text.rs b/crates/gpui_elements/src/editable_text.rs index 3fb6d2e3b3..042b899260 100644 --- a/crates/gpui_elements/src/editable_text.rs +++ b/crates/gpui_elements/src/editable_text.rs @@ -17,6 +17,8 @@ //! - text sanitation & validation (see no-op implementation of [`EditableTextState::validate_incoming_text`]) //! - nav & select via PageUp/PageDown //! - masking text (e.g. for passwords) +//! - disabling `insert_tab` if favor of tab being used to change focus between elements (i.e. escaping the field) +//! pub mod actions; mod caret; diff --git a/crates/gpui_elements/src/editable_text/state.rs b/crates/gpui_elements/src/editable_text/state.rs index c82acfdbfe..68a597b169 100644 --- a/crates/gpui_elements/src/editable_text/state.rs +++ b/crates/gpui_elements/src/editable_text/state.rs @@ -904,10 +904,13 @@ impl<'app> EditableTextActionHandler> for EditableTextState cx.write_to_clipboard(ClipboardItem::new_string(slice.to_string())); self.replace_text(self.selected_range.clone(), ""); } else { + use NavigationDirection::*; + use TextBoundary::*; + // No selection: cut the entire current line (including newline) let caret = self.caret_pos(); - let line_start = self.storage.find_line_start(caret); - let line_end = self.storage.find_line_end(caret); + 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.storage.content_utf8().len(); // Include the newline character if there is one after the line @@ -976,7 +979,7 @@ impl<'app> EditableTextActionHandler> for EditableTextState window: &mut Window, cx: &mut Context<'app, Self>, ) { - let character_pos = self.index_for_pixel_point(text_position, window.line_height()); + let caret_pos = self.index_for_pixel_point(text_position, window.line_height()); window.focus(&self.focus_handle, cx); self.is_selecting = true; @@ -999,13 +1002,15 @@ impl<'app> EditableTextActionHandler> for EditableTextState match self.click_count { 2 => { - let (word_start, word_end) = self.storage.word_range_at(character_pos); - self.selected_range = word_start..word_end; + self.selected_range = self.storage.word_range_at(caret_pos); cx.notify(); } 3 => { - let line_start = self.storage.find_line_start(character_pos); - let line_end = self.storage.find_line_end(character_pos); + 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 { @@ -1016,9 +1021,9 @@ impl<'app> EditableTextActionHandler> for EditableTextState } _ => { if event.modifiers.shift { - self.select_to(character_pos, cx); + self.select_to(caret_pos, cx); } else { - self.move_to(character_pos, cx); + self.move_to(caret_pos, cx); } } } @@ -1643,14 +1648,18 @@ mod tests { let view = create_test_input(cx, "first\nsecond\nthird", 0..0); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, _cx| { - assert_eq!(input.storage().find_line_start(0), 0); - assert_eq!(input.storage().find_line_start(3), 0); - assert_eq!(input.storage().find_line_start(6), 6); - assert_eq!(input.storage().find_line_start(13), 13); + use NavigationDirection::*; + use TextBoundary::*; + let storage = input.storage(); - assert_eq!(input.storage().find_line_end(0), 5); - assert_eq!(input.storage().find_line_end(6), 12); - assert_eq!(input.storage().find_line_end(13), 18); + 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(); @@ -1720,7 +1729,9 @@ mod tests { let view = create_test_input(cx, "hello", 0..0); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, _cx| { - assert_eq!(input.storage().previous_boundary(0), 0); + use NavigationDirection::*; + use TextBoundary::*; + assert_eq!(input.storage().offset_from_caret(0, Back, Graphmeme), 0); }); }) .unwrap(); @@ -1731,8 +1742,11 @@ mod tests { let view = create_test_input(cx, "hello", 0..0); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, _cx| { - assert_eq!(input.storage().next_boundary(5), 5); - assert_eq!(input.storage().next_boundary(100), 5); + 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(); @@ -1743,13 +1757,13 @@ mod tests { let view = create_test_input(cx, "hello world", 0..0); view.update(cx, |view, _window, cx| { view.input.update(cx, |input, _cx| { - let (start, end) = input.storage().word_range_at(5); - assert_eq!(start, 0); - assert_eq!(end, 5); + let range = input.storage().word_range_at(5); + assert_eq!(range.start, 0); + assert_eq!(range.end, 5); - let (start, end) = input.storage().word_range_at(8); - assert_eq!(start, 6); - assert_eq!(end, 11); + let range = input.storage().word_range_at(8); + assert_eq!(range.start, 6); + assert_eq!(range.end, 11); }); }) .unwrap(); diff --git a/crates/gpui_elements/src/editable_text/storage.rs b/crates/gpui_elements/src/editable_text/storage.rs index 323090be92..1adb0cf434 100644 --- a/crates/gpui_elements/src/editable_text/storage.rs +++ b/crates/gpui_elements/src/editable_text/storage.rs @@ -4,13 +4,13 @@ use unicode_segmentation::UnicodeSegmentation; /// Describes a boundary within a chunk of text. pub enum TextBoundary { - /// The next utf-8 character in a direction from the caret + /// The utf-8 character Graphmeme, - /// The next word in a direction from the caret + /// The current word (using whitespace as delimiters) Word, - /// The start/end of the current line + /// The current line Line, - /// The rest of the text to the start/end of a document + /// The entire document Document, } @@ -63,8 +63,7 @@ pub trait UnicodeTextStorage { /// Replace contents within the provided range with the given str slice. fn replace_range(&mut self, range: Range, text: &str); - // TODO: Refine the api for these methods - + /// Returns the utf16 position equivalent of the provided utf8 character position. fn utf_offset_8to16(&self, pos_uft8: usize) -> usize { // Fast path: if offset is 0, return 0 if pos_uft8 == 0 { @@ -86,6 +85,7 @@ pub trait UnicodeTextStorage { count_utf16 } + /// Returns the utf8 position equivalent of the provided utf16 character position. fn utf_offset_16to8(&self, pos_utf16: usize) -> usize { // Fast path: if offset is 0, return 0 if pos_utf16 == 0 { @@ -102,104 +102,20 @@ pub trait UnicodeTextStorage { self.content_utf8().len() } + /// Converts a utf8 character range into a utf16 character range. fn utf_range_8to16(&self, range_utf8: &Range) -> Range { self.utf_offset_8to16(range_utf8.start)..self.utf_offset_8to16(range_utf8.end) } + /// Converts a utf16 character range into a utf8 character range. fn utf_range_16to8(&self, range_utf16: &Range) -> Range { self.utf_offset_16to8(range_utf16.start)..self.utf_offset_16to8(range_utf16.end) } - fn previous_boundary(&self, offset: usize) -> usize { - if offset == 0 { - return 0; - } - - let text_before = &self.content_utf8()[..offset.min(self.content_utf8().len())]; - text_before - .grapheme_indices(true) - .map(|(i, _)| i) - .next_back() - .unwrap_or(0) - } - - fn next_boundary(&self, offset: usize) -> usize { - let len_utf8 = self.content_utf8().len(); - if offset >= len_utf8 { - return len_utf8; - } - - let text_after = &self.content_utf8()[offset..]; - text_after - .grapheme_indices(true) - .nth(1) - .map(|(i, _)| offset + i) - .unwrap_or(len_utf8) - } - - fn previous_word_boundary(&self, offset: usize) -> usize { - if offset == 0 { - return 0; - } - - let text_before = &self.content_utf8()[..offset.min(self.content_utf8().len())]; - - let mut last_word_start = 0; - for (idx, _) in text_before.unicode_word_indices() { - if idx < offset { - last_word_start = idx; - } - } - - if last_word_start == 0 && offset > 0 { - let trimmed = text_before.trim_end(); - if trimmed.is_empty() { - return 0; - } - for (idx, _) in trimmed.unicode_word_indices() { - last_word_start = idx; - } - } - - last_word_start - } - - fn next_word_boundary(&self, offset: usize) -> usize { - let len_utf8 = self.content_utf8().len(); - if offset >= len_utf8 { - return len_utf8; - } - - let text_after = &self.content_utf8()[offset..]; - - for (idx, word) in text_after.unicode_word_indices() { - let word_end = offset + idx + word.len(); - if word_end > offset { - return word_end; - } - } - - len_utf8 - } - - /// Returns the utf-8 character position of first character after the first new-line preceeding the character at the provided utf-8 character position. - fn find_line_start(&self, position: usize) -> usize { - let content = self.content_utf8(); - content[..position.min(content.len())] - .rfind('\n') - .map(|pos| pos + 1) - .unwrap_or(0) - } - - /// Returns the utf-8 character position of the character immediately before the first new-line character after the character at the provided utf-8 character position. - fn find_line_end(&self, position: usize) -> usize { - let content = self.content_utf8(); - content[position.min(content.len())..] - .find('\n') - .map(|pos| position + pos) - .unwrap_or(content.len()) - } - + /// Builds a utf8 character range based on a caret position within the storage, + /// the direction to traverse, and the boundary to stop at. + /// The start of the range will be the earlier position (destination if Back, caret if Forward), + /// and the end will be the later position (caret if Back, destination if Forward). fn range_from_caret( &self, caret: usize, @@ -213,37 +129,109 @@ pub trait UnicodeTextStorage { } } + /// Finds the next location from the caret based on the direction to traverse and the boundary to stop at. fn offset_from_caret( &self, caret: usize, direction: NavigationDirection, - magnitude: TextBoundary, + boundary: TextBoundary, ) -> usize { use NavigationDirection::*; use TextBoundary::*; - match (direction, magnitude) { - (Back, Graphmeme) => self.previous_boundary(caret), - (Forward, Graphmeme) => self.next_boundary(caret), - (Back, Word) => self.previous_word_boundary(caret), - (Forward, Word) => self.next_word_boundary(caret), - (Back, Line) => self.find_line_start(caret), - (Forward, Line) => self.find_line_end(caret), + match (direction, boundary) { + (Back, Graphmeme) => { + if caret == 0 { + return 0; + } + + let str = self.content_utf8(); + let iter = str[..caret.min(str.len())].grapheme_indices(true); + iter.map(|(i, _)| i).next_back().unwrap_or(0) + } + (Forward, Graphmeme) => { + let str = self.content_utf8(); + let len_utf8 = str.len(); + if caret >= len_utf8 { + return len_utf8; + } + + let mut iter = str[caret..].grapheme_indices(true); + iter.nth(1).map(|(i, _)| caret + i).unwrap_or(len_utf8) + } + (Back, Word) => { + if caret == 0 { + return 0; + } + + let str = self.content_utf8(); + let str = &str[..caret.min(str.len())]; + + let mut last_word_start = 0; + for (idx, _) in str.unicode_word_indices() { + if idx < caret { + last_word_start = idx; + } + } + + if last_word_start == 0 && caret > 0 { + let trimmed = str.trim_end(); + if trimmed.is_empty() { + return 0; + } + for (idx, _) in trimmed.unicode_word_indices() { + last_word_start = idx; + } + } + + last_word_start + } + (Forward, Word) => { + let str = self.content_utf8(); + let len_utf8 = str.len(); + if caret >= len_utf8 { + return len_utf8; + } + + let str = &str[caret..]; + for (idx, word) in str.unicode_word_indices() { + let word_end = caret + idx + word.len(); + if word_end > caret { + return word_end; + } + } + len_utf8 + } + // Returns the utf-8 character position of first character after the first new-line + // preceding the character at the provided utf-8 character position. + (Back, Line) => { + let str = self.content_utf8(); + let iter = str[..caret.min(str.len())].rfind('\n'); + iter.map(|pos| pos + 1).unwrap_or(0) + } + // Returns the utf-8 character position of the character immediately before the first + // new-line character after the character at the provided utf-8 character position. + (Forward, Line) => { + let str = self.content_utf8(); + let iter = str[caret.min(str.len())..].find('\n'); + iter.map(|pos| caret + pos).unwrap_or(str.len()) + } (Back, Document) => 0, (Forward, Document) => self.content_utf8().len(), } } - fn word_range_at(&self, offset: usize) -> (usize, usize) { - let offset = offset.min(self.content_utf8().len()); + /// Returns the start and end of the word the position resides within. + fn word_range_at(&self, position: usize) -> Range { + let offset = position.min(self.content_utf8().len()); for (idx, word) in self.content_utf8().unicode_word_indices() { let word_end = idx + word.len(); if offset >= idx && offset <= word_end { - return (idx, word_end); + return idx..word_end; } } - (offset, offset) + offset..offset } }