diff --git a/crates/gpui_elements/src/editable_text.rs b/crates/gpui_elements/src/editable_text.rs index 40e85948ef..dc434bbeca 100644 --- a/crates/gpui_elements/src/editable_text.rs +++ b/crates/gpui_elements/src/editable_text.rs @@ -1,7 +1,16 @@ mod actions; -mod state_field; +mod input_element; +mod input_state; +pub mod notify; +mod shared_state; mod storage; +mod text_area_element; +mod text_area_state; pub use actions::*; -pub use state_field::*; +pub use input_element::*; +pub use input_state::*; +pub use shared_state::*; pub use storage::*; +pub use text_area_element::*; +pub use text_area_state::*; diff --git a/crates/gpui_elements/src/editable_text/input_element.rs b/crates/gpui_elements/src/editable_text/input_element.rs new file mode 100644 index 0000000000..4800e2c35b --- /dev/null +++ b/crates/gpui_elements/src/editable_text/input_element.rs @@ -0,0 +1,3 @@ +use gpui::ElementId; + +pub fn input(id: impl Into) {} diff --git a/crates/gpui_elements/src/editable_text/input_state.rs b/crates/gpui_elements/src/editable_text/input_state.rs new file mode 100644 index 0000000000..854ee04569 --- /dev/null +++ b/crates/gpui_elements/src/editable_text/input_state.rs @@ -0,0 +1,103 @@ +use crate::editable_text::{TextInputStateBase, notify::TextChanged}; +use gpui::{ + Bounds, Context, EntityInputHandler, EventEmitter, Pixels, Point, UTF16Selection, Window, +}; +use std::ops::Range; + +pub struct TextInputState { + internal: TextInputStateBase, +} + +impl EventEmitter for TextInputState {} + +impl EntityInputHandler for TextInputState { + fn text_for_range( + &mut self, + range_utf16: Range, + adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + self.internal + .ime_text_for_range(range_utf16, adjusted_range) + } + + fn selected_text_range( + &mut self, + ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + self.internal.ime_selected_text_range(ignore_disabled_input) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + self.internal.ime_marked_text_range() + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { + self.internal.ime_unmark_text(); + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + text_to_insert: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let range_utf8 = self.internal.ime_resolve_range(range_utf16); + self.internal + .replace_text_in_range_bytes(range_utf8, text_to_insert); + //self.mark_layout_dirty(); + //cx.emit(CursorTrigger::PauseBlinkingForUserAction); + cx.emit(TextChanged); + 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.internal.ime_resolve_range(range_utf16); + self.internal + .replace_text_in_range_bytes(range.clone(), text_to_insert); + self.internal + .ime_mark_text_in_range(&range, text_to_insert.len()); + self.internal.ime_mark_selected_range( + &range, + &new_selected_range_utf16, + text_to_insert.len(), + ); + //self.mark_layout_dirty(); + cx.emit(TextChanged); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + unimplemented!() + } + + fn character_index_for_point( + &mut self, + point: Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + unimplemented!() + } +} diff --git a/crates/gpui_elements/src/editable_text/notify.rs b/crates/gpui_elements/src/editable_text/notify.rs new file mode 100644 index 0000000000..4e5e01ec65 --- /dev/null +++ b/crates/gpui_elements/src/editable_text/notify.rs @@ -0,0 +1,49 @@ +use std::{ops::Range, time::Instant}; + +use crate::editable_text::UnicodeTextStorage; + +pub struct TextChanged; + +pub struct TextHistoryPushed { + pub timestamp: Instant, + pub modified_range: Range, + pub text_payload: String, + pub new_length: usize, + pub selected_range: Range, +} +impl TextHistoryPushed { + pub fn new( + range: Range, + new_length: usize, + storage: impl UnicodeTextStorage, + selected_range: Range, + ) -> Self { + let timestamp = Instant::now(); + let modified_range = range.start..range.start + new_length; + // NOTE: not performant to allocate a new text payload if the event doesnt + // need to be logged (based on timestamp). Should consider a more robust way to access + // the storage only if it absolutely needs to be cloned from. + let text_payload = storage.content_utf8()[range].to_string(); + Self { + timestamp, + modified_range, + text_payload, + new_length, + selected_range, + } + } + + pub fn convert_to_redo(self, content: &str) -> Self { + let undo_start = self.modified_range.start; + let undo_end = (self.modified_range.start + self.new_length).min(content.len()); + let text_payload = content[undo_start..undo_end].to_string(); + let new_length = self.text_payload.len(); + Self { + timestamp: self.timestamp, + modified_range: undo_start..undo_start + self.text_payload.len(), + text_payload, + new_length, + selected_range: self.selected_range, + } + } +} diff --git a/crates/gpui_elements/src/editable_text/shared_state.rs b/crates/gpui_elements/src/editable_text/shared_state.rs new file mode 100644 index 0000000000..e4a975fb2a --- /dev/null +++ b/crates/gpui_elements/src/editable_text/shared_state.rs @@ -0,0 +1,180 @@ +use crate::editable_text::UnicodeTextStorage; +use gpui::{App, FocusHandle, Focusable, NavigationDirection, Pixels, Point, UTF16Selection}; +use std::ops::Range; + +pub struct TextInputStateBase { + 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). + /// + /// 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: Range, + + /// 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, +} + +impl Focusable for TextInputStateBase { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl TextInputStateBase { + /// Creates a new `Input` with the specified multiline setting. + /// Cursor blinking is enabled by default. + pub fn new(storage: impl Into>, cx: &mut App) -> Self { + Self { + storage: storage.into(), + + selected_range: 0..0, + marked_range: None, + + is_selecting: false, + last_click_position: None, + click_count: 0, + + focus_handle: cx.focus_handle(), + } + } + + pub fn storage(&self) -> &Box { + &self.storage + } + + /// 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 fn selected_range(&self) -> Range { + self.selected_range.start.min(self.selected_range.end) + ..self.selected_range.start.max(self.selected_range.end) + } + + pub 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), + } + } + + pub fn caret_pos(&self) -> usize { + self.selected_range.start + } +} + +impl TextInputStateBase { + pub fn ime_text_for_range( + &self, + range_utf16: Range, + adjusted_range: &mut Option>, + ) -> 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()) + } + + pub fn ime_selected_text_range(&self, _ignore_disabled_input: bool) -> 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), + }) + } + + pub fn ime_marked_text_range(&self) -> Option> { + self.marked_range + .as_ref() + .map(|range| self.storage.utf_range_8to16(range)) + } + + pub fn ime_unmark_text(&mut self) { + self.marked_range = None; + } + + pub 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.storage().content_utf8().len(); + range.start.min(storage_len_utf8)..range.end.min(storage_len_utf8) + } + + pub fn replace_text(&mut self, start: usize, end: usize, new_text: &str) { + let storage_len_utf8 = self.storage.content_utf8().len(); + let start = start.min(storage_len_utf8); + let end = end.max(start).min(storage_len_utf8); + self.storage.replace_range(start..end, new_text); + + let new_caret = start + new_text.len(); + self.selected_range = new_caret..new_caret; + } + + pub fn replace_text_in_range_bytes(&mut self, range: Range, mut text_to_insert: &str) { + // TODO: Apply text sanitization + // single-line fields should prune \n and \r + // fields should be able to provide a max_length or other validations on text-input + + let max_length = None::; + + // 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. + if let Some(cap) = max_length { + let existing_len = self.storage().content_utf8().len() - (range.end - range.start); + let room = cap.saturating_sub(existing_len); + text_to_insert = &text_to_insert[..text_to_insert.len().min(room)]; + } + + // TODO: Push history diff + // self.push_undo_patch(range.clone(), text_to_insert.len()); + + self.storage.replace_range(range, text_to_insert); + self.marked_range = None; + + // TODO: caller emits events + } + + pub 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), + }; + } + + pub 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 + }); + new_range.unwrap_or_else(|| { + range_overwritten.start + text_len..range_overwritten.start + text_len + }) + }; + } +} diff --git a/crates/gpui_elements/src/editable_text/storage.rs b/crates/gpui_elements/src/editable_text/storage.rs index a2ab788c9a..1907d70107 100644 --- a/crates/gpui_elements/src/editable_text/storage.rs +++ b/crates/gpui_elements/src/editable_text/storage.rs @@ -1,4 +1,3 @@ -use gpui::SharedString; use std::ops::Range; use unicode_segmentation::UnicodeSegmentation; @@ -9,6 +8,8 @@ pub trait UnicodeTextStorage { /// Returns the UTF-16 length of the content. fn len_utf16(&self) -> usize; + fn replace_range(&mut self, range: Range, text: &str); + fn utf_offset_8to16(&self, pos_uft8: usize) -> usize { // Fast path: if offset is 0, return 0 if pos_uft8 == 0 { @@ -148,14 +149,8 @@ impl UnicodeTextStorage for String { fn len_utf16(&self) -> usize { self.len() } -} -impl UnicodeTextStorage for SharedString { - fn content_utf8(&self) -> &str { - self.as_str() - } - - fn len_utf16(&self) -> usize { - self.len() + fn replace_range(&mut self, range: Range, text: &str) { + self.replace_range(range, &text); } } diff --git a/crates/gpui_elements/src/editable_text/text_area_element.rs b/crates/gpui_elements/src/editable_text/text_area_element.rs new file mode 100644 index 0000000000..cad2948606 --- /dev/null +++ b/crates/gpui_elements/src/editable_text/text_area_element.rs @@ -0,0 +1,3 @@ +use gpui::ElementId; + +pub fn text_area(id: impl Into) {} diff --git a/crates/gpui_elements/src/editable_text/state_field.rs b/crates/gpui_elements/src/editable_text/text_area_state.rs similarity index 100% rename from crates/gpui_elements/src/editable_text/state_field.rs rename to crates/gpui_elements/src/editable_text/text_area_state.rs