diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 05f12d55..31f1c26d 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -182,6 +182,8 @@ pub struct TextInput { scrollbar_state: Rc>, /// The size of the scrollable content. pub(crate) scroll_size: gpui::Size, + /// To remember the horizontal column (x-coordinate) of the cursor position. + preferred_x_offset: Option, } impl EventEmitter for TextInput {} @@ -223,6 +225,7 @@ impl TextInput { scroll_handle: ScrollHandle::new(), scrollbar_state: Rc::new(Cell::new(ScrollbarState::default())), scroll_size: gpui::size(px(0.), px(0.)), + preferred_x_offset: None, }; // Observe the blink cursor to repaint the view when it changes. @@ -253,6 +256,125 @@ impl TextInput { self } + /// Called after moving the cursor. Updates preferred_x_offset if we know where the cursor now is. + fn update_preferred_x_offset(&mut self, _cx: &mut ViewContext) { + if let (Some(lines), Some(bounds)) = (&self.last_layout, &self.last_bounds) { + let offset = self.cursor_offset(); + let line_height = self.last_line_height; + + // Find which line and sub-line the cursor is on and its position + let (_line_index, _sub_line_index, cursor_pos) = + self.line_and_position_for_offset(offset, lines, line_height); + + if let Some(pos) = cursor_pos { + // Adjust by scroll offset + let scroll_offset = bounds.origin; + self.preferred_x_offset = Some(pos.x + scroll_offset.x); + } + } + } + + /// Find which line and sub-line the given offset belongs to, along with the position within that sub-line. + fn line_and_position_for_offset( + &self, + offset: usize, + lines: &[WrappedLine], + line_height: Pixels, + ) -> (usize, usize, Option>) { + let mut prev_lines_offset = 0; + let mut y_offset = px(0.); + for (line_index, line) in lines.iter().enumerate() { + let local_offset = offset.saturating_sub(prev_lines_offset); + if let Some(pos) = line.position_for_index(local_offset, line_height) { + let sub_line_index = (pos.y.0 / line_height.0) as usize; + let adjusted_pos = point(pos.x, pos.y + y_offset); + return (line_index, sub_line_index, Some(adjusted_pos)); + } + + y_offset += line.size(line_height).height; + prev_lines_offset += line.len() + 1; + } + (0, 0, None) + } + + /// Move the cursor vertically by one line (up or down) while preserving the column if possible. + /// direction: -1 for up, +1 for down + fn move_vertical(&mut self, direction: i32, cx: &mut ViewContext) { + if self.is_single_line() { + return; + } + + let (Some(lines), Some(bounds)) = (&self.last_layout, &self.last_bounds) else { + return; + }; + + let offset = self.cursor_offset(); + let line_height = self.last_line_height; + let (current_line_index, current_sub_line, current_pos) = + self.line_and_position_for_offset(offset, lines, line_height); + + let Some(current_pos) = current_pos else { + return; + }; + + let current_x = self + .preferred_x_offset + .unwrap_or_else(|| current_pos.x + bounds.origin.x); + + let mut new_line_index = current_line_index; + let mut new_sub_line = current_sub_line as i32; + + new_sub_line += direction; + + if new_sub_line < 0 { + if new_line_index > 0 { + new_line_index -= 1; + new_sub_line = lines[new_line_index].wrap_boundaries.len() as i32; + } else { + new_sub_line = 0; + } + } else { + let max_sub_line = lines[new_line_index].wrap_boundaries.len() as i32; + if new_sub_line > max_sub_line { + if new_line_index < lines.len() - 1 { + new_line_index += 1; + new_sub_line = 0; + } else { + new_sub_line = max_sub_line; + } + } + } + + if new_line_index == current_line_index && new_sub_line == current_sub_line as i32 { + return; + } + + let target_line = &lines[new_line_index]; + let line_x = current_x - bounds.origin.x; + let target_sub_line = new_sub_line as usize; + + let approx_pos = point(line_x, px(target_sub_line as f32 * line_height.0)); + let index_res = target_line.index_for_position(approx_pos, line_height); + + let new_local_index = match index_res { + Ok(i) => i, + Err(i) => i, + }; + + let mut prev_lines_offset = 0; + for (i, l) in lines.iter().enumerate() { + if i == new_line_index { + break; + } + prev_lines_offset += l.len() + 1; + } + + let new_offset = (prev_lines_offset + new_local_index).min(self.text.len()); + self.selected_range = new_offset..new_offset; + self.pause_blink_cursor(cx); + cx.notify(); + } + #[inline] pub(super) fn is_multi_line(&self) -> bool { self.multi_line @@ -433,9 +555,7 @@ impl TextInput { return; } self.pause_blink_cursor(cx); - - let offset = self.start_of_line(cx).saturating_sub(1); - self.move_to(offset, cx); + self.move_vertical(-1, cx); } fn down(&mut self, _: &Down, cx: &mut ViewContext) { @@ -443,9 +563,7 @@ impl TextInput { return; } self.pause_blink_cursor(cx); - - let offset = (self.end_of_line(cx) + 1).min(self.text.len()); - self.move_to(offset, cx); + self.move_vertical(1, cx); } fn select_left(&mut self, _: &SelectLeft, cx: &mut ViewContext) { @@ -713,6 +831,7 @@ impl TextInput { fn move_to(&mut self, offset: usize, cx: &mut ViewContext) { self.selected_range = offset..offset; self.pause_blink_cursor(cx); + self.update_preferred_x_offset(cx); cx.notify() } @@ -837,7 +956,9 @@ impl TextInput { self.selected_range.end = word_range.end; } } - + if self.selected_range.is_empty() { + self.update_preferred_x_offset(cx); + } cx.notify() } @@ -1073,6 +1194,7 @@ impl ViewInputHandler for TextInput { self.text = pending_text; self.selected_range = range.start + new_text.len()..range.start + new_text.len(); self.marked_range.take(); + self.update_preferred_x_offset(cx); cx.emit(InputEvent::Change(self.text.clone())); cx.notify(); }