diff --git a/crates/gpui_elements/src/editable_text.rs b/crates/gpui_elements/src/editable_text.rs index a261ac292e..a8556d2fe3 100644 --- a/crates/gpui_elements/src/editable_text.rs +++ b/crates/gpui_elements/src/editable_text.rs @@ -11,10 +11,10 @@ pub use storage::*; /* TODO list - remove gpuikit based input -- auto-scroll when cursor moves - cursor blinking - color styling configs - text sanitation +- add page up/down actions to nav by an entire page or expand selection by an entire page - test IME (char palette only available on macos) */ diff --git a/crates/gpui_elements/src/editable_text/element.rs b/crates/gpui_elements/src/editable_text/element.rs index 2f4a28f3a9..13b0660d24 100644 --- a/crates/gpui_elements/src/editable_text/element.rs +++ b/crates/gpui_elements/src/editable_text/element.rs @@ -16,7 +16,7 @@ use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; #[track_caller] pub fn editable_text(id: impl Into) -> EditableTextElement { let mut this = EditableTextElement { - interactivity: Interactivity::new(), + interactivity: Interactivity::default(), state_entity: Rc::new(RefCell::new(WeakEntity::new_invalid())), supports_multiline: true, init_storage: InitStorage::default(), @@ -192,23 +192,28 @@ impl Element for EditableTextElement { focus_handle = state.focus_handle(cx); show_placeholder = state.storage().content_utf8().is_empty(); storage_version = state.storage().version(); + + if let Some(scroll_offset) = state.layout_data.next_scroll_offset { + self.interactivity + .set_scroll_offset(global_id, window, -scroll_offset); + } } // TODO: This required a gpui api change in order to sync the focus handle between Interactivity and TextInputStateBase - self.interactivity().track_focus(focus_handle); + self.interactivity.track_focus(focus_handle); let placeholder = self.placeholder.clone(); let placeholder_color = Hsla::white().opacity(0.5); // TODO: as an element param let supports_multiline = self.supports_multiline; let accepts_input = self.accepts_input; - let layout_id = self.interactivity().request_layout( + let layout_id = self.interactivity.request_layout( global_id, inspector_id, window, cx, |style, window, cx| { let state = state.clone(); - window.with_text_style(style.text_style().cloned(), |window| { + window.with_text_style(style.text_style().cloned(), move |window| { // NOTE: Loosely mirrors TextLayout::layout let text_layout_id = window.request_measured_layout(Default::default(), { let text_style = window.text_style(); @@ -325,10 +330,13 @@ impl Element for EditableTextElement { let layout_data = TextInputLayoutData { supports_multiline, accepts_input, + scroll_bounds: Bounds::default(), wrap_width, size: Some(size), last_seen_storage_version, lines, + line_height, + next_scroll_offset: None, }; // Update the state for use in prepaint, paint, and action handlers. @@ -370,7 +378,7 @@ impl Element for EditableTextElement { struct InteractivityPrepaint { hitbox: Option, scroll_offset: Point, - padding: gpui::Edges, + inner_bounds: Bounds, } let prepaint = self.interactivity().prepaint( global_id, @@ -379,31 +387,38 @@ impl Element for EditableTextElement { content_size, window, cx, - |style, scroll_offset, hitbox, window, _cx| { + |style, scroll_offset, hitbox, window, cx| { let hitbox = hitbox.or_else(|| Some(window.insert_hitbox(bounds, HitboxBehavior::Normal))); - let padding = style - .padding - .to_pixels(bounds.size.into(), window.rem_size()); + let inner_bounds = { + let padding = style + .padding + .to_pixels(bounds.size.into(), window.rem_size()); + + let mut bounds = bounds; + bounds.origin += point(padding.left, padding.top); + bounds.size.width -= padding.left + padding.right; + bounds.size.height -= padding.top + padding.bottom; + bounds + }; + request_layout.state.update(cx, |state, _cx| { + // while gpui tracks scroll_offset with negative values, + // this is converted into positive for usage with bounds + state.layout_data.scroll_bounds = + Bounds::new(-scroll_offset, inner_bounds.size); + }); InteractivityPrepaint { hitbox, scroll_offset, - padding, + inner_bounds, } }, ); let InteractivityPrepaint { hitbox, scroll_offset, - padding, + inner_bounds, } = prepaint; - let inner_bounds = { - let mut bounds = bounds; - bounds.origin += point(padding.left, padding.top); - bounds.size.width -= padding.left + padding.right; - bounds.size.height -= padding.top + padding.bottom; - bounds - }; let selection_color = Hsla::blue().opacity(0.5); // TODO: as an element param let caret_color = Hsla::white(); // TODO: as an element param @@ -503,11 +518,7 @@ impl Element for EditableTextElement { } } - let is_cursor_in_line = if segment_is_empty { - caret_pos == segment.text_range.start - } else { - segment.text_range.contains(&caret_pos) || caret_pos == segment.text_range.end - }; + let is_cursor_in_line = segment.contains_position(caret_pos); if is_cursor_in_line && let Some(wrapped) = &segment.wrapped_line { // TODO: when the cursor is functionally at a character that is on the next line // (a line that spans multiple rows), the cursor displays at the end of the previous diff --git a/crates/gpui_elements/src/editable_text/state.rs b/crates/gpui_elements/src/editable_text/state.rs index 4a9160c605..3bfe7097ae 100644 --- a/crates/gpui_elements/src/editable_text/state.rs +++ b/crates/gpui_elements/src/editable_text/state.rs @@ -8,7 +8,7 @@ use gpui::{ App, Bounds, ClipboardItem, Context, EntityInputHandler, EventEmitter, FocusHandle, Focusable, NavigationDirection, Pixels, Point, Size, UTF16Selection, Window, WrappedLine, point, }; -use std::{ops::Range, sync::Arc}; +use std::{borrow::Cow, ops::Range, sync::Arc}; pub struct EditableTextState { storage: Box, @@ -39,8 +39,12 @@ pub struct EditableTextState { #[derive(Default)] pub(super) struct TextInputLayoutData { + /// Whether the element supports multiple lines of text pub supports_multiline: bool, + /// Whether the element is currently accepting inputs pub accepts_input: bool, + /// The last seen scroll position and size of the element + pub scroll_bounds: Bounds, /// The last known width at which the lines were wrapped. pub wrap_width: Option, /// The last known size of the text, as generated during layout. @@ -50,6 +54,10 @@ pub(super) struct TextInputLayoutData { /// The `ShapedLine` produced by the painter's `prepaint`. /// Cached so IME `bounds_for_range` / `character_index_for_point` can evaluate without re-shaping. pub lines: Vec, + pub line_height: Pixels, + /// The next position the scroll view should move to. + /// Set by the state in response to user actions. + pub next_scroll_offset: Option>, } /// A segment of text that is a single logical/document line but can take up multiple rows due to wrapping. pub(super) struct TextLineSegment { @@ -72,6 +80,14 @@ impl TextLineSegment { .map(|line| line.wrap_boundaries().len()); count.unwrap_or_default() + 1 } + + pub fn contains_position(&self, pos: usize) -> bool { + if self.text_range.is_empty() { + pos == self.text_range.start + } else { + pos >= self.text_range.start && pos < self.text_range.end + } + } } impl EventEmitter for EditableTextState {} @@ -248,11 +264,58 @@ impl EditableTextState { } impl EditableTextState { + fn scroll_to_caret(&mut self) { + if self.layout_data.scroll_bounds.is_empty() { + return; + } + let Some(content_size) = self.layout_data.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()); + + println!("{point:?} ?= {:?}", self.layout_data.scroll_bounds); + + // 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); + } + } + pub fn move_to(&mut self, caret_pos: usize, cx: &mut Context) { //cx.emit(CursorTrigger::PauseBlinkingForUserAction); let caret_pos = caret_pos.min(self.storage.content_utf8().len()); self.selected_range = caret_pos..caret_pos; - //self.scroll_to_cursor(); + self.scroll_to_caret(); cx.notify(); } @@ -260,7 +323,7 @@ impl EditableTextState { //cx.emit(CursorTrigger::PauseBlinkingForUserAction); let caret_pos = caret_pos.min(self.storage().content_utf8().len()); self.selected_range.start = caret_pos; - //self.scroll_to_cursor(); + self.scroll_to_caret(); cx.notify(); } @@ -309,7 +372,24 @@ impl EditableTextState { self.move_to(caret_pos, cx); } - pub fn line_index_and_point_at_caret(&self, line_height: Pixels) -> (usize, Point) { + pub fn select_document(&mut self, cx: &mut Context) { + self.selected_range = 0..self.storage.content_utf8().len(); + cx.notify(); + } + + 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); + } + + fn line_index_and_point_at_caret(&self, line_height: Pixels) -> (usize, Point) { if self.layout_data.lines.is_empty() { return (0, Point::default()); } @@ -342,13 +422,12 @@ impl EditableTextState { (segment_index.saturating_sub(1), Point::default()) } - pub fn find_position_in_vertical_direction( + fn find_position_in_vertical_direction( &self, direction: i32, line_height: Pixels, ) -> Option { let (line_index, point) = self.line_index_and_point_at_caret(line_height); - println!("{line_index:?} {point:?}"); let line_index = line_index.saturating_add_signed(direction as isize); let mut current_visual_line = 0; @@ -372,7 +451,6 @@ impl EditableTextState { let closest_idx = closest_result.unwrap_or_else(|closest| closest); let clamped = closest_idx.min(wrapped.text.len()); let result = segment.text_range.start + clamped; - println!("{result:?}"); return Some(result); } @@ -385,21 +463,26 @@ impl EditableTextState { (direction > 0).then(|| self.storage.content_utf8().len()) } - pub fn select_document(&mut self, cx: &mut Context) { - self.selected_range = 0..self.storage.content_utf8().len(); - cx.notify(); - } + fn find_point_for_character_position(&self, character_pos: usize) -> Point { + let line_height = self.layout_data.line_height; + let mut row_count = 0; + for segment in &self.layout_data.lines { + if !segment.contains_position(character_pos) { + row_count += segment.row_count(); + continue; + } - 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); + let line_origin = point(Pixels::ZERO, row_count * line_height); + return match &segment.wrapped_line { + None => line_origin, + Some(wrapped) => { + let local_offset = character_pos.saturating_sub(segment.text_range.start); + let position = wrapped.position_for_index(local_offset, line_height); + position.unwrap_or_default() + line_origin + } + }; + } + Point::default() } } @@ -602,7 +685,6 @@ impl<'app> EditableTextActionHandler> for EditableTextState if !self.layout_data.accepts_input { return; } - // TODO: Why is the cursor appearing at the start of the entire field instead of on the new line? self.replace_text_in_range(None, "\n", window, cx); } @@ -738,8 +820,6 @@ impl<'app> EditableTextActionHandler> for EditableTextState if let Some(caret_pos) = self.find_position_in_vertical_direction(-1, window.line_height()) { self.select_to(caret_pos, cx); - //self.scroll_to_cursor(); - cx.notify(); } } @@ -752,8 +832,6 @@ impl<'app> EditableTextActionHandler> for EditableTextState if let Some(caret_pos) = self.find_position_in_vertical_direction(1, window.line_height()) { self.select_to(caret_pos, cx); - //self.scroll_to_cursor(); - cx.notify(); } }