diff --git a/Cargo.lock b/Cargo.lock index 8b07f7afff..e63e611f87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3634,7 +3634,6 @@ dependencies = [ "gpui-shell", "gtk", "itertools 0.14.0", - "lsp-types 0.97.0", "rand 0.8.6", "regex", "rust-i18n", diff --git a/crates/base/src/input/base/blink_cursor.rs b/crates/base/src/input/base/blink_cursor.rs index d1b4cdd0f9..cece966321 100644 --- a/crates/base/src/input/base/blink_cursor.rs +++ b/crates/base/src/input/base/blink_cursor.rs @@ -73,13 +73,13 @@ impl BlinkCursor { self.paused || self.visible } - /// Pause the blinking, and delay 500ms to resume the blinking. + /// Show the cursor immediately and restart the idle delay before blinking resumes. pub(crate) fn pause(&mut self, cx: &mut Context) { self.paused = true; self.visible = true; cx.notify(); - // delay 500ms to start the blinking + // Every pause replaces the pending timer, keeping repeated input visible. let epoch = self.next_epoch(); self._task = cx.spawn(async move |this, cx| { cx.background_executor().timer(PAUSE_DELAY).await; @@ -93,3 +93,28 @@ impl BlinkCursor { }); } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext as _, TestAppContext}; + + #[gpui::test] + fn repeated_pauses_keep_cursor_visible_until_idle(cx: &mut TestAppContext) { + let cursor = cx.new(|_| BlinkCursor::new()); + assert!(!cursor.read_with(cx, |cursor, _| cursor.visible())); + for _ in 0..5 { + cursor.update(cx, |cursor, cx| cursor.pause(cx)); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + assert!(cursor.read_with(cx, |cursor, _| cursor.visible())); + } + cx.executor().advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + assert!(!cursor.read_with(cx, |cursor, _| cursor.visible())); + cx.executor().advance_clock(INTERVAL); + cx.run_until_parked(); + assert!(cursor.read_with(cx, |cursor, _| cursor.visible())); + } +} diff --git a/crates/base/src/input/base/change.rs b/crates/base/src/input/base/change.rs index 5d248db7d9..6de376829e 100644 --- a/crates/base/src/input/base/change.rs +++ b/crates/base/src/input/base/change.rs @@ -2,14 +2,14 @@ use std::fmt::Debug; use crate::input::Selection; +/// One text replacement, in the coordinates of the document as it stood +/// immediately before the replacement was applied. #[derive(Debug, PartialEq, Clone)] pub(super) struct Change { pub(crate) old_range: Selection, pub(crate) old_text: String, pub(crate) new_range: Selection, pub(crate) new_text: String, - pub(crate) selection_before: Selection, - pub(crate) selection_after: Selection, } impl Change { @@ -18,16 +18,24 @@ impl Change { old_text: &str, new_range: impl Into, new_text: &str, - selection_before: Selection, - selection_after: Selection, ) -> Self { Self { old_range: old_range.into(), old_text: old_text.to_string(), new_range: new_range.into(), new_text: new_text.to_string(), - selection_before, - selection_after, + } + } + + /// The same change as it would read after `delta` bytes were inserted + /// (positive) or removed (negative) ahead of it. + pub(super) fn shifted(&self, delta: isize) -> Self { + let shift = |offset: usize| (offset as isize + delta).max(0) as usize; + Self { + old_range: (shift(self.old_range.start)..shift(self.old_range.end)).into(), + old_text: self.old_text.clone(), + new_range: (shift(self.new_range.start)..shift(self.new_range.end)).into(), + new_text: self.new_text.clone(), } } } diff --git a/crates/base/src/input/base/cursor.rs b/crates/base/src/input/base/cursor.rs index 375600f61a..6246e510f0 100644 --- a/crates/base/src/input/base/cursor.rs +++ b/crates/base/src/input/base/cursor.rs @@ -52,9 +52,216 @@ impl RangeBounds for Selection { } } +use gpui::Pixels; + +use super::selection::CursorId; + +#[derive(Debug, Copy, Clone, PartialEq)] +pub(super) struct CursorSelection { + pub(super) id: CursorId, + pub(super) start: usize, + pub(super) end: usize, + pub(super) reversed: bool, + pub(super) column_anchor: Option<(Pixels, usize)>, +} + +impl CursorSelection { + pub(super) fn new(id: CursorId, start: usize, end: usize) -> Self { + Self { + id, + start, + end, + reversed: false, + column_anchor: None, + } + } + + pub(super) fn len(&self) -> usize { + self.end.saturating_sub(self.start) + } + + pub(super) fn is_empty(&self) -> bool { + self.start == self.end + } + + pub(super) fn clear(&mut self) { + self.start = 0; + self.end = 0; + } + + pub(super) fn contains(&self, offset: usize) -> bool { + offset >= self.start && offset < self.end + } + + pub(super) fn cursor_offset(&self) -> usize { + if self.reversed { self.start } else { self.end } + } + + pub(super) fn place_at(&mut self, offset: usize, column_anchor: Option<(Pixels, usize)>) { + self.start = offset; + self.end = offset; + self.reversed = false; + self.column_anchor = column_anchor; + } + + pub(super) fn is_collapsed(&self) -> bool { + self.is_empty() + } +} + +impl From> for CursorSelection { + fn from(value: Range) -> Self { + Self::new(CursorId::default(), value.start, value.end) + } +} + +impl From for Range { + fn from(value: CursorSelection) -> Self { + value.start..value.end + } +} + +impl RangeBounds for CursorSelection { + fn start_bound(&self) -> std::ops::Bound<&usize> { + std::ops::Bound::Included(&self.start) + } + + fn end_bound(&self) -> std::ops::Bound<&usize> { + std::ops::Bound::Excluded(&self.end) + } +} + +pub(super) struct Selections { + selections: Vec, + next_id: usize, +} + +impl Selections { + pub(super) fn new() -> Self { + Self { + selections: vec![CursorSelection::new(CursorId::new(0), 0, 0)], + next_id: 1, + } + } + + /// Returns the active selection. + pub(super) fn active(&self) -> &CursorSelection { + self.selections + .first() + .expect("Selections always has at least one selection") + } + + /// Returns a mutable reference to the active selection. + pub(super) fn active_mut(&mut self) -> &mut CursorSelection { + self.selections + .first_mut() + .expect("Selections always has at least one selection") + } + + pub(super) fn iter(&self) -> impl Iterator { + self.selections.iter() + } + + /// Returns the number of selections (always `>= 1`). + pub(super) fn len(&self) -> usize { + self.selections.len() + } + + /// Returns true when there is exactly one selection. + pub(super) fn is_single(&self) -> bool { + self.selections.len() == 1 + } + + /// Generates a new unique cursor id. + pub(super) fn generate_id(&mut self) -> CursorId { + let id = CursorId::new(self.next_id); + self.next_id += 1; + id + } + + /// Adds an additional selection. + pub(super) fn add(&mut self, selection: CursorSelection) { + self.selections.push(selection); + } + + /// Replaces all selections. Ignores an empty vec to keep the + /// "always at least one selection" invariant. + pub(super) fn replace_all(&mut self, selections: Vec) { + if !selections.is_empty() { + self.selections = selections; + } + } + + /// Removes every selection except the active one (index 0). + pub(super) fn remove_all_but_active(&mut self) { + self.selections.truncate(1); + } + + /// Merges overlapping selections. + /// + /// Selections are sorted by start and folded together when they overlap. + /// The active selection is preserved, propagated onto + /// the merged result if it was absorbed, and re-fronted afterwards. + pub(super) fn merge_overlapping(&mut self) { + if self.selections.len() <= 1 { + return; + } + + let active_id = self.active().id; + + self.selections.sort_by_key(|s| s.start); + + let mut merged: Vec = Vec::with_capacity(self.selections.len()); + for selection in &self.selections { + if let Some(last) = merged.last_mut() { + if selection.start <= last.end { + // Overlapping or adjacent, extend the last one. + let did_merge = selection.start != last.start || selection.end != last.end; + last.end = last.end.max(selection.end); + if selection.id == active_id { + last.id = active_id; + last.reversed = selection.reversed; + } + // Reset the column anchor on a real merge. + if did_merge { + last.column_anchor = None; + } + continue; + } + } + merged.push(*selection); + } + + // Re-front the active selection so it stays at index 0. + if let Some(pos) = merged.iter().position(|s| s.id == active_id) { + merged.swap(0, pos); + } + + self.selections = merged; + } +} + +impl Default for Selections { + fn default() -> Self { + Self::new() + } +} + #[cfg(test)] mod tests { + use super::*; use crate::input::Position; + use gpui::px; + + #[test] + fn selection_keeps_its_public_range_api() { + fn assert_eq() {} + + assert_eq::(); + let selection = Selection::new(2, 5); + assert_eq!(selection, Selection { start: 2, end: 5 }); + assert_eq!(Range::::from(selection), 2..5); + } #[test] fn test_line_column_from_to() { @@ -66,4 +273,88 @@ mod tests { } ); } + + #[test] + fn test_cursor_offset_reversed() { + let mut sel = CursorSelection::new(CursorId::new(0), 5, 10); + assert_eq!(sel.cursor_offset(), 10); + sel.reversed = true; + assert_eq!(sel.cursor_offset(), 5); + } + + #[test] + fn test_place_at() { + let mut sel = CursorSelection::new(CursorId::new(0), 5, 10); + sel.reversed = true; + sel.place_at(7, Some((px(12.), 3))); + assert_eq!(sel.start, 7); + assert_eq!(sel.end, 7); + assert!(sel.is_collapsed()); + assert!(!sel.reversed); + assert_eq!(sel.column_anchor, Some((px(12.), 3))); + } + + #[test] + fn test_selections_never_empty() { + let selections = Selections::new(); + assert_eq!(selections.len(), 1); + assert_eq!(selections.active().id, CursorId::new(0)); + + let default = Selections::default(); + assert_eq!(default.len(), 1); + } + + #[test] + fn test_selections_active_mut() { + let mut selections = Selections::new(); + selections.active_mut().place_at(4, None); + assert_eq!(selections.active().cursor_offset(), 4); + } + + #[test] + fn test_selections_merge_overlapping() { + let mut selections = Selections::new(); + + let id1 = selections.generate_id(); + let id2 = selections.generate_id(); + let id3 = selections.generate_id(); + + // id1 is the active selection (index 0). + selections.replace_all(vec![ + CursorSelection::new(id1, 0, 10), + CursorSelection::new(id2, 5, 15), // Overlaps with the first. + CursorSelection::new(id3, 20, 30), // Non-overlapping. + ]); + + selections.merge_overlapping(); + + // After merge: (0, 15) and (20, 30). + assert_eq!(selections.len(), 2); + // The active selection stays at index 0 and carries its id. + assert_eq!(selections.active().id, id1); + assert_eq!( + (selections.active().start, selections.active().end), + (0, 15) + ); + + let ranges: Vec<_> = selections.iter().map(|s| (s.start, s.end)).collect(); + assert!(ranges.contains(&(0, 15))); + assert!(ranges.contains(&(20, 30))); + } + + #[test] + fn merging_preserves_the_active_selection_direction() { + let mut selections = Selections::new(); + let active_id = selections.generate_id(); + let other_id = selections.generate_id(); + let mut active = CursorSelection::new(active_id, 5, 15); + active.reversed = true; + let other = CursorSelection::new(other_id, 0, 10); + selections.replace_all(vec![active, other]); + + selections.merge_overlapping(); + + assert_eq!(selections.active().id, active_id); + assert!(selections.active().reversed); + } } diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index 5165817fa6..8e9b428d24 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -430,26 +430,21 @@ impl TextElement { state.update(cx, |state, _| { state.auto_scroll.stop(); state.selecting = false; + state.column_select_start = None; }); } }); } - /// Returns the: - /// - /// - cursor bounds - /// - scroll offset - /// - current row index (No only the visible lines, but all lines) - /// - /// This method also will update for track scroll to cursor. - fn layout_cursor( + /// Lays out the carets and updates the scroll position for the active one. + fn layout_cursors( &self, last_layout: &LastLayout, bounds: &mut Bounds, scroll_size: Size, _: &mut Window, cx: &mut App, - ) -> (Option>, Point, Option) { + ) -> (Vec, Point, Option) { let state = self.state.read(cx); let line_height = last_layout.line_height; @@ -457,25 +452,10 @@ impl TextElement { let lines = &last_layout.lines; let line_number_width = last_layout.line_number_width; - let mut selected_range = state.selected_range; - - if let Some(ime_marked_range) = &state.ime_marked_range { - selected_range = (ime_marked_range.end..ime_marked_range.end).into(); - } - let is_selected_all = selected_range.len() == state.text.len(); - - let mut cursor = state.cursor(); - // Buffer rows from the raw (pre-mask) offsets, used to locate the cursor line. - let cursor_row = state.text.offset_to_point(cursor).row; - let sel_start_row = state.text.offset_to_point(selected_range.start).row; - let sel_end_row = state.text.offset_to_point(selected_range.end).row; - if state.masked { - selected_range.start = masked_display_offset(&state.text, selected_range.start); - selected_range.end = masked_display_offset(&state.text, selected_range.end); - cursor = masked_display_offset(&state.text, cursor); - } - + let active_id = state.active_selection().id; let mut scroll_offset = state.scroll_handle.offset(); + let mut current_row = None; + let mut cursor_infos: Vec = Vec::with_capacity(state.selections.len()); // Padding kept between the cursor and the viewport's top/bottom // edges, used by the auto-scroll-into-view computation below. @@ -504,80 +484,114 @@ impl TextElement { line_origin }; - let current_row = Some(cursor_row); - let cursor_pos = caret_for(cursor_row, cursor, state.cursor_line_end_affinity); - let cursor_start = caret_for(sel_start_row, selected_range.start, false); - let cursor_end = caret_for(sel_end_row, selected_range.end, false); - - let cursor_bounds = { - let selection_changed = state.last_selected_range != Some(selected_range); - let auto_scrolling = state.auto_scroll.is_active(); - if selection_changed && !is_selected_all { - // For Right alignment use 0 margin: cursor is clamped to bounds separately, - // so we never scroll the text for cursor-at-edge, avoiding a first-click jump. - let safety_margin = match last_layout.text_align { - TextAlign::Left => RIGHT_MARGIN, - TextAlign::Right => px(0.), - TextAlign::Center => CURSOR_WIDTH, - }; + let cursor_height = 0.85 * line_height; - scroll_offset.x = if scroll_offset.x + cursor_pos.x - > (bounds.size.width - line_number_width - safety_margin) - { - // cursor is out of right - bounds.size.width - line_number_width - safety_margin - cursor_pos.x - } else if scroll_offset.x + cursor_pos.x < px(0.) { - // cursor is out of left - scroll_offset.x - cursor_pos.x - } else { - scroll_offset.x - }; + for selection in state.selections.iter() { + let is_active = selection.id == active_id; + + let mut selected_range = *selection; + let mut cursor = selection.cursor_offset(); + if is_active { + if let Some(ime_marked_range) = &state.ime_marked_range { + selected_range = (ime_marked_range.end..ime_marked_range.end).into(); + cursor = ime_marked_range.end; + } + } + let is_selected_all = selected_range.len() == state.text.len(); + + // Buffer rows from the raw (pre-mask) offsets, used to locate the cursor line. + let cursor_row = state.text.offset_to_point(cursor).row; + + // Skip inactive cursors that are far outside the visible range. The + // active cursor is always processed so scroll tracking keeps working. + if !is_active + && (cursor_row + 2 < visible_range.start || cursor_row > visible_range.end + 2) + { + continue; + } + + let sel_start_row = state.text.offset_to_point(selected_range.start).row; + let sel_end_row = state.text.offset_to_point(selected_range.end).row; + if state.masked { + selected_range.start = masked_display_offset(&state.text, selected_range.start); + selected_range.end = masked_display_offset(&state.text, selected_range.end); + cursor = masked_display_offset(&state.text, cursor); + } + + let affinity = is_active && state.cursor_line_end_affinity; + let cursor_pos = caret_for(cursor_row, cursor, affinity); + let cursor_start = caret_for(sel_start_row, selected_range.start, false); + let cursor_end = caret_for(sel_end_row, selected_range.end, false); + + if is_active { + current_row = Some(cursor_row); + + let selection_changed = state.last_selected_range != Some(selected_range); + let auto_scrolling = state.auto_scroll.is_active(); + if selection_changed && !is_selected_all { + // For Right alignment use 0 margin: cursor is clamped to bounds separately, + // so we never scroll the text for cursor-at-edge, avoiding a first-click jump. + let safety_margin = match last_layout.text_align { + TextAlign::Left => RIGHT_MARGIN, + TextAlign::Right => px(0.), + TextAlign::Center => CURSOR_WIDTH, + }; - // Vertical cursor-follow is suppressed while auto-scroll manages the y axis, - // to prevent fighting the background scroll task. - if !auto_scrolling { - // If we change the scroll_offset.y, GPUI will render and trigger the next run loop. - // So, here we just adjust offset by `line_height` for move smooth. - scroll_offset.y = if scroll_offset.y + cursor_pos.y - > bounds.size.height - top_bottom_margin + scroll_offset.x = if scroll_offset.x + cursor_pos.x + > (bounds.size.width - line_number_width - safety_margin) { - // cursor is out of bottom - scroll_offset.y - line_height - } else if scroll_offset.y + cursor_pos.y < top_bottom_margin { - // cursor is out of top - (scroll_offset.y + line_height).min(px(0.)) + // cursor is out of right + bounds.size.width - line_number_width - safety_margin - cursor_pos.x + } else if scroll_offset.x + cursor_pos.x < px(0.) { + // cursor is out of left + scroll_offset.x - cursor_pos.x } else { - scroll_offset.y + scroll_offset.x }; - } - // For selection to move scroll - if state.selection_reversed { - if scroll_offset.x + cursor_start.x < px(0.) { - // selection start is out of left - scroll_offset.x = -cursor_start.x; - } - if !auto_scrolling && scroll_offset.y + cursor_start.y < px(0.) { - // selection start is out of top - scroll_offset.y = -cursor_start.y; - } - } else { - // TODO: Consider to remove this part, - // maybe is not necessary (But selection_reversed is needed). - if scroll_offset.x + cursor_end.x <= px(0.) { - // selection end is out of left - scroll_offset.x = -cursor_end.x; + // Vertical cursor-follow is suppressed while auto-scroll manages the y axis, + // to prevent fighting the background scroll task. + if !auto_scrolling { + // If we change the scroll_offset.y, GPUI will render and trigger the next run loop. + // So, here we just adjust offset by `line_height` for move smooth. + scroll_offset.y = if scroll_offset.y + cursor_pos.y + > bounds.size.height - top_bottom_margin + { + // cursor is out of bottom + scroll_offset.y - line_height + } else if scroll_offset.y + cursor_pos.y < top_bottom_margin { + // cursor is out of top + (scroll_offset.y + line_height).min(px(0.)) + } else { + scroll_offset.y + }; } - if !auto_scrolling && scroll_offset.y + cursor_end.y <= px(0.) { - // selection end is out of top - scroll_offset.y = -cursor_end.y; + + // For selection to move scroll + if selection.reversed { + if scroll_offset.x + cursor_start.x < px(0.) { + // selection start is out of left + scroll_offset.x = -cursor_start.x; + } + if !auto_scrolling && scroll_offset.y + cursor_start.y < px(0.) { + // selection start is out of top + scroll_offset.y = -cursor_start.y; + } + } else { + // TODO: Consider to remove this part, + // maybe is not necessary (But selection_reversed is needed). + if scroll_offset.x + cursor_end.x <= px(0.) { + // selection end is out of left + scroll_offset.x = -cursor_end.x; + } + if !auto_scrolling && scroll_offset.y + cursor_end.y <= px(0.) { + // selection end is out of top + scroll_offset.y = -cursor_end.y; + } } } } - // cursor bounds - let cursor_height = 0.85 * line_height; - // Match the caret to the deferred scroll target (applied below) that // the text paints at; otherwise the caret follows the cursor-scroll // while the text uses the deferred offset, flashing it mid-field. @@ -594,14 +608,17 @@ impl TextElement { } else { cursor_x }; - Some(Bounds::new( - point( - cursor_x, - bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), + cursor_infos.push(CursorRenderInfo { + bounds: Bounds::new( + point( + cursor_x, + bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), + ), + size(CURSOR_WIDTH, cursor_height), ), - size(CURSOR_WIDTH, cursor_height), - )) - }; + is_active, + }); + } if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { scroll_offset = deferred_scroll_offset; @@ -615,7 +632,7 @@ impl TextElement { bounds.origin = bounds.origin + scroll_offset; - (cursor_bounds, scroll_offset, current_row) + (cursor_infos, scroll_offset, current_row) } /// Layout the match range to a Path. @@ -827,37 +844,56 @@ impl TextElement { bounds: &mut Bounds, window: &mut Window, cx: &mut App, - ) -> Option> { + ) -> Vec> { let state = self.state.read(cx); if !state.focus_handle.is_focused(window) { - return None; + return vec![]; } - let mut selected_range = state.selected_range; - if let Some(ime_marked_range) = &state.ime_marked_range { - if !ime_marked_range.is_empty() { - selected_range = (ime_marked_range.end..ime_marked_range.end).into(); + let active_id = state.active_selection().id; + let mut paths = Vec::new(); + + for selection in state.selections.iter() { + let is_active = selection.id == active_id; + let mut selected_range = *selection; + + // IME composition replaces the active selection with a collapsed + // caret, so it never paints a selection highlight. + if is_active { + if let Some(ime_marked_range) = &state.ime_marked_range { + if !ime_marked_range.is_empty() { + selected_range = (ime_marked_range.end..ime_marked_range.end).into(); + } + } + } + if selected_range.is_empty() { + continue; } - } - if selected_range.is_empty() { - return None; - } - if state.masked { - selected_range.start = masked_display_offset(&state.text, selected_range.start); - selected_range.end = masked_display_offset(&state.text, selected_range.end); - } + if state.masked { + selected_range.start = masked_display_offset(&state.text, selected_range.start); + selected_range.end = masked_display_offset(&state.text, selected_range.end); + } - let (start_ix, end_ix) = if selected_range.start < selected_range.end { - (selected_range.start, selected_range.end) - } else { - (selected_range.end, selected_range.start) - }; + let (start_ix, end_ix) = if selected_range.start < selected_range.end { + (selected_range.start, selected_range.end) + } else { + (selected_range.end, selected_range.start) + }; - let range = start_ix.max(last_layout.visible_range_offset.start) - ..end_ix.min(last_layout.visible_range_offset.end); + let range = start_ix.max(last_layout.visible_range_offset.start) + ..end_ix.min(last_layout.visible_range_offset.end); - Self::layout_match_range(range, &last_layout, bounds) + if range.is_empty() { + continue; + } + + if let Some(path) = Self::layout_match_range(range, last_layout, bounds) { + paths.push(path); + } + } + + paths } /// Calculate the visible range of lines in the viewport. @@ -1563,6 +1599,13 @@ impl TextElement { } } +/// Layout data for a single caret, produced by [`TextElement::layout_cursors`]. +#[derive(Clone, Debug)] +struct CursorRenderInfo { + bounds: Bounds, + is_active: bool, +} + pub(super) struct PrepaintState { /// The lines of entire lines. last_layout: LastLayout, @@ -1572,11 +1615,12 @@ pub(super) struct PrepaintState { line_numbers: Option>>, /// Size of the scrollable area by entire lines. scroll_size: Size, - cursor_bounds: Option>, + /// Caret bounds for every selection, active flagged. + cursor_infos: Vec, cursor_scroll_offset: Point, /// row index (zero based), no wrap, same line as the cursor. current_row: Option, - selection_path: Option>, + selection_paths: Vec>, hover_highlight_path: Option>, search_match_paths: Vec<(Path, bool)>, document_color_paths: Vec<(Path, Hsla)>, @@ -1594,12 +1638,19 @@ pub(super) struct PrepaintState { } impl PrepaintState { - /// Returns cursor bounds adjusted for scroll offset, if available. - fn cursor_bounds_with_scroll(&self) -> Option> { - self.cursor_bounds.map(|mut bounds| { - bounds.origin.y += self.cursor_scroll_offset.y; - bounds - }) + /// Returns all cursor infos adjusted for scroll offset. + fn cursor_infos_with_scroll(&self) -> Vec { + self.cursor_infos + .iter() + .map(|info| { + let mut bounds = info.bounds; + bounds.origin.y += self.cursor_scroll_offset.y; + CursorRenderInfo { + bounds, + is_active: info.is_active, + } + }) + .collect() } } @@ -1981,18 +2032,22 @@ impl Element for TextElement { // Calculate the scroll offset to keep the cursor in view - // Save the unscrolled x before layout_cursor modifies bounds.origin with scroll_offset. + // Save the unscrolled x before layout_cursors modifies bounds.origin with scroll_offset. // Fold icons and their hitboxes must use this value so they stay fixed in the gutter // regardless of horizontal scroll position. let input_bounds = bounds; let original_x = bounds.origin.x; - let (cursor_bounds, cursor_scroll_offset, current_row) = - self.layout_cursor(&last_layout, &mut bounds, scroll_size, window, cx); - last_layout.cursor_bounds = cursor_bounds; + let (cursor_infos, cursor_scroll_offset, current_row) = + self.layout_cursors(&last_layout, &mut bounds, scroll_size, window, cx); + // Completion/code-action menus position at the active caret. + last_layout.cursor_bounds = cursor_infos + .iter() + .find(|info| info.is_active) + .map(|info| info.bounds); let search_match_paths = self.layout_search_matches(&last_layout, &mut bounds, cx); - let selection_path = self.layout_selections(&last_layout, &mut bounds, window, cx); + let selection_paths = self.layout_selections(&last_layout, &mut bounds, window, cx); let hover_highlight_path = self.layout_hover_highlight(&last_layout, &mut bounds, cx); let document_color_paths = self.layout_document_colors(&document_colors, &last_layout, &bounds, cx); @@ -2068,10 +2123,10 @@ impl Element for TextElement { last_layout, scroll_size, line_numbers, - cursor_bounds, + cursor_infos, cursor_scroll_offset, current_row, - selection_path, + selection_paths, search_match_paths, hover_highlight_path, hover_definition_hitbox, @@ -2100,7 +2155,7 @@ impl Element for TextElement { state.focus_handle.clone(), state.show_cursor(window, cx), state.disabled, - state.selected_range, + *state.active_selection(), state.editor_style.clone(), state.editor_paddings, ) @@ -2219,7 +2274,7 @@ impl Element for TextElement { } } - if let Some(path) = prepaint.selection_path.take() { + for path in prepaint.selection_paths.drain(..) { window.paint_path(path, editor_style.selection); } @@ -2302,10 +2357,10 @@ impl Element for TextElement { } } - // Paint blinking cursor + // Paint blinking cursors (shared blink state for all carets) if focused && show_cursor { - if let Some(cursor_bounds) = prepaint.cursor_bounds_with_scroll() { - window.paint_quad(fill(cursor_bounds, editor_style.caret)); + for cursor_info in prepaint.cursor_infos_with_scroll() { + window.paint_quad(fill(cursor_info.bounds, editor_style.caret)); } } @@ -2384,16 +2439,21 @@ impl Element for TextElement { cx.notify(); }); - if let Some(hitbox) = prepaint.hover_definition_hitbox.as_ref() { + if let Some(hitbox) = prepaint.hover_definition_hitbox.as_ref() + && !window.modifiers().alt + { window.set_cursor_style(gpui::CursorStyle::PointingHand, &hitbox); } // Paint inline completion first line suffix (after cursor on same line) if focused { if let Some(first_line) = &prepaint.ghost_first_line { - if let (Some(cursor_bounds), Some(cursor_row_y)) = - (prepaint.cursor_bounds_with_scroll(), cursor_row_y) - { + let active_cursor = prepaint + .cursor_infos_with_scroll() + .into_iter() + .find(|info| info.is_active); + if let (Some(cursor_info), Some(cursor_row_y)) = (active_cursor, cursor_row_y) { + let cursor_bounds = cursor_info.bounds; let first_line_x = cursor_bounds.origin.x + cursor_bounds.size.width; let p = point(first_line_x, cursor_row_y); diff --git a/crates/base/src/input/base/movement.rs b/crates/base/src/input/base/movement.rs index 0b3f60e968..b1b4b61779 100644 --- a/crates/base/src/input/base/movement.rs +++ b/crates/base/src/input/base/movement.rs @@ -1,9 +1,10 @@ use crate::input::InputModeKind; -use gpui::{Context, Point, Window}; +use gpui::{Context, Pixels, Point, Window}; use crate::input::{ InputBaseState, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, MoveToEnd, MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveUp, RopeExt as _, + cursor::CursorSelection, }; #[derive(Clone, Copy, PartialEq, Eq)] @@ -13,27 +14,43 @@ pub(crate) enum MoveDirection { } impl InputBaseState { - /// Called after moving the cursor. Updates preferred_column if we know where the cursor now is. - pub(super) fn update_preferred_column(&mut self) { - let Some(last_layout) = &self.last_layout else { - self.preferred_column = None; - return; - }; + /// Compute the column anchor for the given `offset`. Wrap/fold-aware. + pub(super) fn preferred_column_for(&self, offset: usize) -> Option<(Pixels, usize)> { + self.preferred_column_for_with_affinity(offset, false) + } - let point = self.text.offset_to_point(self.cursor()); - let Some(line) = last_layout.line(point.row) else { - self.preferred_column = None; - return; - }; + /// Like [`Self::preferred_column_for`], but resolves an offset on a soft wrap + /// boundary to the row the caret is drawn on. + fn preferred_column_for_with_affinity( + &self, + offset: usize, + line_end_affinity: bool, + ) -> Option<(Pixels, usize)> { + let last_layout = self.last_layout.as_ref()?; + let point = self.text.offset_to_point(offset); + let line = last_layout.line(point.row)?; + let pos = line.position_for_index(point.column, last_layout, line_end_affinity)?; + Some((pos.x, point.column)) + } - let Some(pos) = - line.position_for_index(point.column, last_layout, self.cursor_line_end_affinity) - else { - self.preferred_column = None; - return; - }; + /// The line-end affinity that applies to `sel`. Only the active cursor + /// carries one; every other cursor sits at the start of its row. + pub(super) fn line_end_affinity_for(&self, sel: &CursorSelection) -> bool { + sel.id == self.active_selection().id && self.cursor_line_end_affinity + } + + /// The line-end affinity for a cursor known only by its offset. Cursors never share an + /// offset, so this is the active cursor's affinity when `offset` is where it sits. + pub(super) fn line_end_affinity_at(&self, offset: usize) -> bool { + offset == self.cursor() && self.cursor_line_end_affinity + } - self.preferred_column = Some((pos.x, point.column)); + /// Called after moving the cursor. Updates the active selection's + /// `column_anchor` if we know where the cursor now is. + pub(super) fn update_preferred_column(&mut self) { + let anchor = + self.preferred_column_for_with_affinity(self.cursor(), self.cursor_line_end_affinity); + self.active_selection_mut().column_anchor = anchor; } /// Move the cursor to the given offset. @@ -65,9 +82,10 @@ impl InputBaseState { cx: &mut Context, ) { self.undo_manager.break_transaction_coalescing(); + self.selections.remove_all_but_active(); let offset = offset.clamp(0, self.text.len()); self.cursor_line_end_affinity = line_end_affinity; - self.selected_range = (offset..offset).into(); + self.set_cursor_to(offset); self.scroll_to(offset, direction, cx); self.pause_blink_cursor(cx); self.update_preferred_column(); @@ -76,30 +94,27 @@ impl InputBaseState { cx.notify() } - /// Move the cursor vertically by one line (up or down) while preserving the column if possible. + /// Compute the target offset when moving a cursor at `offset` vertically by + /// `move_lines`, honoring the remembered `column_anchor`. Wrap/fold-aware. /// - /// move_lines: Number of lines to move vertically (positive for down, negative for up). - pub(super) fn move_vertical( - &mut self, + /// Returns the new offset together with the line-end affinity the caret + /// should carry there. + pub(super) fn vertical_target( + &self, + offset: usize, + column_anchor: Option<(Pixels, usize)>, + line_end_affinity: bool, move_lines: isize, - _: &mut Window, - cx: &mut Context, - ) { - if self.is_single_line() { - return; - } + ) -> (usize, bool) { let Some(last_layout) = &self.last_layout else { - return; + return (offset, line_end_affinity); }; - let offset = self.cursor(); - let was_preferred_column = self.preferred_column; - // Start from the row the caret is drawn on, not the row the raw offset falls in: on a // soft wrap boundary those are two different rows. let mut display_point = self .display_map - .offset_to_wrap_display_point_with_affinity(offset, self.cursor_line_end_affinity); + .offset_to_wrap_display_point_with_affinity(offset, line_end_affinity); // Convert wrap row → display row (skips folded rows), move, then convert back let current_display_row = self @@ -123,7 +138,7 @@ impl InputBaseState { let mut new_offset = self.display_map.wrap_display_point_to_offset(display_point); let mut new_affinity = false; - if let Some((preferred_x, column)) = was_preferred_column { + if let Some((preferred_x, column)) = column_anchor { // Get display point again to update local_row. let mut next_display_point = self.display_map.offset_to_wrap_display_point(new_offset); next_display_point.column = 0; @@ -153,34 +168,145 @@ impl InputBaseState { } } + (new_offset, new_affinity) + } + + /// Move every cursor through `f`, which maps each selection to a + /// `(new_offset, column_anchor, line_end_affinity)`, collapsing each to a + /// cursor. Overlapping cursors are merged, then the standard post-move + /// sequence runs. Only the active cursor's affinity is kept, see + /// [`Self::move_to_with_affinity`]. + pub(super) fn move_all_cursors( + &mut self, + f: impl Fn(&Self, &CursorSelection) -> (usize, Option<(Pixels, usize)>, bool), + direction: Option, + _window: &mut Window, + cx: &mut Context, + ) { + self.undo_manager.break_transaction_coalescing(); + let len = self.text.len(); + let mut active_affinity = false; + let new_selections: Vec = self + .selections + .iter() + .map(|sel| { + let (offset, anchor, line_end_affinity) = f(self, sel); + if sel.id == self.active_selection().id { + active_affinity = line_end_affinity; + } + let mut new_sel = *sel; + new_sel.place_at(offset.clamp(0, len), anchor); + new_sel + }) + .collect(); + self.selections.replace_all(new_selections); + self.selections.merge_overlapping(); + + self.cursor_line_end_affinity = active_affinity; + self.scroll_to(self.cursor(), direction, cx); + self.pause_blink_cursor(cx); + M::hide_context_menu(self, cx); + M::clear_inline_completion(self, cx); + cx.notify(); + } + + /// Move every cursor vertically by `move_lines`. + /// + /// When `collapse` is set, a non-empty selection first collapses to just + /// outside its start (up) or end (down) before moving, otherwise the cursor + /// offset is used. + fn move_vertical( + &mut self, + move_lines: isize, + collapse: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.is_single_line() { + return; + } self.pause_blink_cursor(cx); + let direction = if move_lines < 0 { MoveDirection::Up } else { MoveDirection::Down }; - self.move_to_with_affinity(new_offset, Some(direction), new_affinity, cx); - // Set back the preferred_column - self.preferred_column = was_preferred_column; - cx.notify(); + + self.move_all_cursors( + move |s, sel| { + let (effective, anchor, affinity) = if sel.is_empty() || !collapse { + ( + sel.cursor_offset(), + sel.column_anchor, + s.line_end_affinity_for(sel), + ) + } else if move_lines < 0 { + let e = s.previous_boundary(sel.start.saturating_sub(1)); + (e, s.preferred_column_for(e), false) + } else { + let e = s.next_boundary(sel.end.saturating_sub(1)); + (e, s.preferred_column_for(e), false) + }; + let (offset, affinity) = s.vertical_target(effective, anchor, affinity, move_lines); + (offset, anchor, affinity) + }, + Some(direction), + window, + cx, + ); } - pub(super) fn left(&mut self, _: &MoveLeft, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - if self.selected_range.is_empty() { - self.move_to(self.previous_boundary(self.cursor()), None, cx); - } else { - self.move_to(self.selected_range.start, None, cx) + pub(super) fn left(&mut self, _: &MoveLeft, window: &mut Window, cx: &mut Context) { + // With a lone cursor at the very start there is nowhere to move. + // Propagate the keystroke so an ancestor (e.g. a navigable command + // palette) can act on it. This is harmless when nothing is bound there. + // With multiple cursors the others can still move, so only the + // single-cursor case propagates. + if self.selections.is_single() && self.active_selection().is_empty() && self.cursor() == 0 { + cx.propagate(); + return; } + + self.move_all_cursors( + |s, sel| { + let offset = if sel.is_empty() { + s.previous_boundary(sel.cursor_offset()) + } else { + sel.start + }; + (offset, s.preferred_column_for(offset), false) + }, + None, + window, + cx, + ); } - pub(super) fn right(&mut self, _: &MoveRight, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - if self.selected_range.is_empty() { - self.move_to(self.next_boundary(self.selected_range.end), None, cx); - } else { - self.move_to(self.selected_range.end, None, cx) + pub(super) fn right(&mut self, _: &MoveRight, window: &mut Window, cx: &mut Context) { + // Mirror `left`: a lone cursor at the end of the text has nowhere to + // move, so let the keystroke bubble to an ancestor. + if self.selections.is_single() + && self.active_selection().is_empty() + && self.cursor() == self.text.len() + { + cx.propagate(); + return; } + + self.move_all_cursors( + |s, sel| { + let offset = if sel.is_empty() { + s.next_boundary(sel.cursor_offset()) + } else { + sel.end + }; + (offset, s.preferred_column_for(offset), false) + }, + None, + window, + cx, + ); } pub(super) fn up(&mut self, action: &MoveUp, window: &mut Window, cx: &mut Context) { @@ -188,19 +314,7 @@ impl InputBaseState { return; } - if self.is_single_line() { - return; - } - - if !self.selected_range.is_empty() { - self.move_to( - self.previous_boundary(self.selected_range.start.saturating_sub(1)), - Some(MoveDirection::Up), - cx, - ); - } - self.pause_blink_cursor(cx); - self.move_vertical(-1, window, cx); + self.move_vertical(-1, true, window, cx); } pub(super) fn down(&mut self, action: &MoveDown, window: &mut Window, cx: &mut Context) { @@ -208,20 +322,7 @@ impl InputBaseState { return; } - if self.is_single_line() { - return; - } - - if !self.selected_range.is_empty() { - self.move_to( - self.next_boundary(self.selected_range.end.saturating_sub(1)), - Some(MoveDirection::Down), - cx, - ); - } - - self.pause_blink_cursor(cx); - self.move_vertical(1, window, cx); + self.move_vertical(1, true, window, cx); } pub(super) fn page_up(&mut self, _: &MovePageUp, window: &mut Window, cx: &mut Context) { @@ -234,7 +335,7 @@ impl InputBaseState { }; let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize; - self.move_vertical(-display_lines, window, cx); + self.move_vertical(-display_lines, false, window, cx); } pub(super) fn page_down( @@ -252,19 +353,32 @@ impl InputBaseState { }; let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize; - self.move_vertical(display_lines, window, cx); + self.move_vertical(display_lines, false, window, cx); } - pub(super) fn home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - let offset = self.start_of_line(); - self.move_to(offset, Some(MoveDirection::Up), cx); + pub(super) fn home(&mut self, _: &MoveHome, window: &mut Window, cx: &mut Context) { + self.move_all_cursors( + |s, sel| { + let offset = s.start_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)); + (offset, s.preferred_column_for(offset), false) + }, + Some(MoveDirection::Up), + window, + cx, + ); } - pub(super) fn end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - let offset = self.end_of_line(); - self.move_to_with_affinity(offset, Some(MoveDirection::Down), true, cx); + pub(super) fn end(&mut self, _: &MoveEnd, window: &mut Window, cx: &mut Context) { + self.move_all_cursors( + |s, sel| { + let offset = s.end_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)); + // The caret belongs at the end of the visual row it is on. + (offset, s.preferred_column_for(offset), true) + }, + Some(MoveDirection::Down), + window, + cx, + ); } pub(super) fn move_to_start( @@ -283,20 +397,34 @@ impl InputBaseState { pub(super) fn move_to_previous_word( &mut self, _: &MoveToPreviousWord, - _: &mut Window, + window: &mut Window, cx: &mut Context, ) { - let offset = self.previous_start_of_word(); - self.move_to(offset, None, cx); + self.move_all_cursors( + |s, sel| { + let offset = s.previous_start_of_word_at(sel.cursor_offset()); + (offset, s.preferred_column_for(offset), false) + }, + None, + window, + cx, + ); } pub(super) fn move_to_next_word( &mut self, _: &MoveToNextWord, - _: &mut Window, + window: &mut Window, cx: &mut Context, ) { - let offset = self.next_end_of_word(); - self.move_to(offset, None, cx); + self.move_all_cursors( + |s, sel| { + let offset = s.next_end_of_word_at(sel.cursor_offset()); + (offset, s.preferred_column_for(offset), false) + }, + None, + window, + cx, + ); } } diff --git a/crates/base/src/input/base/selection.rs b/crates/base/src/input/base/selection.rs index 8f7a92ef1c..0217b719f9 100644 --- a/crates/base/src/input/base/selection.rs +++ b/crates/base/src/input/base/selection.rs @@ -8,6 +8,16 @@ use sum_tree::Bias; use super::{InputBaseState, RopeExt as _}; use crate::text_boundary::word_range_from_chars; +/// Unique identifier for a cursor/selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)] +pub(super) struct CursorId(usize); + +impl CursorId { + pub(super) fn new(id: usize) -> Self { + Self(id) + } +} + impl InputBaseState { /// Select the word at the given offset on double-click. /// @@ -26,8 +36,9 @@ impl InputBaseState { }; self.undo_manager.break_transaction_coalescing(); - self.selected_range = (range.start..range.end).into(); - self.selected_word_range = Some(self.selected_range); + self.selections.remove_all_but_active(); + self.set_selection(range.start, range.end); + self.selected_word_range = Some(*self.active_selection()); cx.notify() } @@ -37,7 +48,8 @@ impl InputBaseState { pub(super) fn select_line(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { let range = TextSelector::line_range(&self.text, offset); self.undo_manager.break_transaction_coalescing(); - self.selected_range = (range.start..range.end).into(); + self.selections.remove_all_but_active(); + self.set_selection(range.start, range.end); self.selected_word_range = None; cx.notify() } diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 0d5030b0d3..bcbb7dcc66 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -6,8 +6,8 @@ use gpui::TextAlign; use gpui::{ Action, App, AppContext, Bounds, ClipboardItem, Context, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, - KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, - Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, + Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription, UTF16Selection, Window, actions, div, point, prelude::FluentBuilder as _, px, }; use ropey::{Rope, RopeSlice}; @@ -24,6 +24,7 @@ use super::{ InputHighlighterFactory, MASK_CHAR, MaskPattern, NativeMenu, NumberStep, WrappingIndent, blink_cursor::BlinkCursor, change::Change, + cursor::{CursorSelection, Selections}, element::{EditorScrollbar, EditorScrollbarSnapshot, TextElement}, kind::InputModeKind, mask_pattern::normalize_number_input, @@ -34,7 +35,7 @@ use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp}; use crate::input::blink_cursor::CURSOR_WIDTH; use crate::input::movement::MoveDirection; use crate::input::{ - InputExtras as _, Position, RopeExt as _, Selection, element::RIGHT_MARGIN, layout::LastLayout, + InputExtras as _, Position, RopeExt as _, element::RIGHT_MARGIN, layout::LastLayout, }; use crate::{AutoScroll, StepAction}; @@ -88,6 +89,8 @@ actions!( MoveEnd, MovePageUp, MovePageDown, + AddCursorAbove, + AddCursorBelow, SelectAll, SelectToStartOfLine, SelectToEndOfLine, @@ -190,6 +193,23 @@ pub(crate) fn init(cx: &mut App) { KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)), KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)), KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)), + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + KeyBinding::new("shift-alt-left", SelectLeft, Some(CONTEXT)), + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + KeyBinding::new("shift-alt-right", SelectRight, Some(CONTEXT)), + // Avoid Ctrl+Alt+arrows on Linux, where desktops may reserve them. + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-alt-up", AddCursorAbove, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-alt-down", AddCursorBelow, Some(CONTEXT)), + #[cfg(target_os = "windows")] + KeyBinding::new("ctrl-alt-up", AddCursorAbove, Some(CONTEXT)), + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + KeyBinding::new("shift-alt-up", AddCursorAbove, Some(CONTEXT)), + #[cfg(target_os = "windows")] + KeyBinding::new("ctrl-alt-down", AddCursorBelow, Some(CONTEXT)), + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + KeyBinding::new("shift-alt-down", AddCursorBelow, Some(CONTEXT)), KeyBinding::new("home", MoveHome, Some(CONTEXT)), KeyBinding::new("end", MoveEnd, Some(CONTEXT)), KeyBinding::new("shift-home", SelectToStartOfLine, Some(CONTEXT)), @@ -202,11 +222,11 @@ pub(crate) fn init(cx: &mut App) { KeyBinding::new("shift-cmd-left", SelectToStartOfLine, Some(CONTEXT)), #[cfg(target_os = "macos")] KeyBinding::new("shift-cmd-right", SelectToEndOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] KeyBinding::new("alt-shift-left", SelectToPreviousWordStart, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-shift-left", SelectToPreviousWordStart, Some(CONTEXT)), - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] KeyBinding::new("alt-shift-right", SelectToNextWordEnd, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-shift-right", SelectToNextWordEnd, Some(CONTEXT)), @@ -305,24 +325,24 @@ pub struct InputBaseState { pub(super) cursor_surrounding_lines: Option, pub(super) blink_cursor: Entity, pub(super) loading: bool, - /// Range in UTF-8 length for the selected text. + /// The cursors and selections. /// - /// - "Hello 世界💝" = 16 - /// - "💝" = 4 - pub(super) selected_range: Selection, + /// Always contains at least one selection where index 0 is the active cursor. + pub(super) selections: Selections, /// Range for save the selected word, use to keep word range when drag move. - pub(super) selected_word_range: Option, - pub(super) selection_reversed: bool, + pub(super) selected_word_range: Option, /// The marked range is the temporary insert text on IME typing. - pub(super) ime_marked_range: Option, + pub(super) ime_marked_range: Option, pub(super) last_layout: Option, pub(super) last_cursor: Option, /// The input container bounds pub(super) input_bounds: Bounds, /// The text bounds pub(super) last_bounds: Option>, - pub(super) last_selected_range: Option, + pub(super) last_selected_range: Option, pub(super) selecting: bool, + /// Anchor offset for an in-progress columnar (block) selection. + pub(super) column_select_start: Option, pub(crate) disabled: bool, pub(crate) readonly: bool, pub(crate) text_align: TextAlign, @@ -401,11 +421,6 @@ pub struct InputBaseState { /// A flag to indicate if we should emit InputEvents. pub(super) emit_events: bool, - /// To remember the horizontal column (x-coordinate) of the cursor position for keep column for move up/down. - /// - /// The first element is the x-coordinate (Pixels), preferred to use this. - /// The second element is the column (usize), fallback to use this. - pub(super) preferred_column: Option<(Pixels, usize)>, _subscriptions: Vec, pub(super) auto_scroll: AutoScroll, @@ -551,7 +566,7 @@ impl InputBaseState { /// /// A masked input keeps its value out of the clipboard. pub fn is_copyable(&self) -> bool { - !self.selected_range.is_empty() && !self.masked + self.selections.iter().any(|sel| !sel.is_empty()) && !self.masked } pub fn context_menu_capabilities(&self) -> InputContextMenuCapabilities { @@ -560,7 +575,7 @@ impl InputBaseState { .disabled(self.disabled) .readonly(self.readonly) .code_editor(self.is_code_editor()) - .selection(!self.selected_range.is_empty()) + .selection(!self.active_selection().is_empty()) .masked(self.masked) .go_to_definition(go_to_definition) .code_actions(code_actions) @@ -601,6 +616,17 @@ impl InputBaseState { let undo_manager = UndoManager::new(); let _subscriptions = vec![ + // Key bindings can consume events before on_key_down. Observe input + // before action dispatch so every keystroke resets the blink delay. + cx.intercept_keystrokes({ + let focus_handle = focus_handle.clone(); + let blink_cursor = blink_cursor.downgrade(); + move |_, window, cx| { + if focus_handle.is_focused(window) { + _ = blink_cursor.update(cx, |cursor, cx| cursor.pause(cx)); + } + } + }), // Observe the blink cursor to repaint the view when it changes. cx.observe(&blink_cursor, |_, _, cx| cx.notify()), // Blink the cursor when the window is active, pause when it's not. @@ -635,9 +661,8 @@ impl InputBaseState { cursor_surrounding_lines: None, blink_cursor, undo_manager, - selected_range: Selection::default(), + selections: Selections::default(), selected_word_range: None, - selection_reversed: false, ime_marked_range: None, input_bounds: Bounds::default(), selecting: false, @@ -658,13 +683,13 @@ impl InputBaseState { last_layout: None, last_bounds: None, last_selected_range: None, + column_select_start: None, last_cursor: None, scroll_handle: ScrollHandle::new(), scroll_size: gpui::size(px(0.), px(0.)), editor_scrollbar_snapshot: Cell::new(None), editor_paddings: Edges::default(), deferred_scroll_offset: None, - preferred_column: None, placeholder: SharedString::default(), mask_pattern: MaskPattern::default(), mask_pattern_set: false, @@ -902,10 +927,11 @@ impl InputBaseState { ) { let text: SharedString = text.into(); self.with_edits_allowed(|this| { - this.undo_manager.pending_intent = Some(EditIntent::Atomic); + this.undo_manager.set_pending_intent(EditIntent::Atomic); let range_utf16 = this.range_to_utf16(&(this.cursor()..this.cursor())); this.replace_text_in_range_silent(Some(range_utf16), &text, window, cx); - this.selected_range = (this.selected_range.end..this.selected_range.end).into(); + let end = this.active_selection().end; + this.set_cursor_to(end); }); } @@ -920,9 +946,10 @@ impl InputBaseState { ) { let text: SharedString = text.into(); self.with_edits_allowed(|this| { - this.undo_manager.pending_intent = Some(EditIntent::Atomic); + this.undo_manager.set_pending_intent(EditIntent::Atomic); this.replace_text_in_range_silent(None, &text, window, cx); - this.selected_range = (this.selected_range.end..this.selected_range.end).into(); + let end = this.active_selection().end; + this.set_cursor_to(end); }); } @@ -934,7 +961,7 @@ impl InputBaseState { ) { let text: SharedString = text.into(); self.with_edits_allowed(|this| { - this.undo_manager.pending_intent = Some(EditIntent::Atomic); + this.undo_manager.set_pending_intent(EditIntent::Atomic); let range = 0..this.text.chars().map(|c| c.len_utf16()).sum(); this.replace_text_in_range_silent(Some(range), &text, window, cx); this.reset_highlighter(cx); @@ -942,14 +969,16 @@ impl InputBaseState { } fn reset_selection(&mut self) { + self.selections.remove_all_but_active(); + // For single-line inputs the caret is placed at the end of the text // (matching HTML ``); multi-line inputs reset the selection to // `0..0`. if self.is_single_line() { let end = self.text.len(); - self.selected_range = (end..end).into(); + self.set_cursor_to(end); } else { - self.selected_range.clear(); + self.active_selection_mut().clear(); } } @@ -1218,12 +1247,11 @@ impl InputBaseState { pub(super) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { self.undo_manager.break_transaction_coalescing(); - self.select_to(self.previous_boundary(self.cursor()), cx); + self.select_all_cursors_to(|s, sel| s.previous_boundary(sel.cursor_offset()), cx); } pub(super) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { - self.undo_manager.break_transaction_coalescing(); - self.select_to(self.next_boundary(self.cursor()), cx); + self.select_all_cursors_to(|s, sel| s.next_boundary(sel.cursor_offset()), cx); } pub(super) fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { @@ -1231,8 +1259,15 @@ impl InputBaseState { return; } self.undo_manager.break_transaction_coalescing(); - let offset = self.start_of_line().saturating_sub(1); - self.select_to(self.previous_boundary(offset), cx); + self.select_all_cursors_to( + |s, sel| { + let offset = s + .start_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)) + .saturating_sub(1); + s.previous_boundary(offset) + }, + cx, + ); } pub(super) fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { @@ -1240,8 +1275,16 @@ impl InputBaseState { return; } self.undo_manager.break_transaction_coalescing(); - let offset = (self.end_of_line() + 1).min(self.text.len()); - self.select_to(self.next_boundary(offset), cx); + let len = self.text.len(); + self.select_all_cursors_to( + |s, sel| { + let offset = (s.end_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)) + + 1) + .min(len); + s.next_boundary(offset) + }, + cx, + ); } pub(super) fn on_action_select_all( @@ -1260,7 +1303,7 @@ impl InputBaseState { cx: &mut Context, ) { self.undo_manager.break_transaction_coalescing(); - self.select_to(0, cx); + self.select_all_cursors_to(|_, _| 0, cx); } pub(super) fn select_to_end( @@ -1271,7 +1314,7 @@ impl InputBaseState { ) { self.undo_manager.break_transaction_coalescing(); let end = self.text.len(); - self.select_to(end, cx); + self.select_all_cursors_to(move |_, _| end, cx); } pub(super) fn select_to_start_of_line( @@ -1281,8 +1324,10 @@ impl InputBaseState { cx: &mut Context, ) { self.undo_manager.break_transaction_coalescing(); - let offset = self.start_of_line(); - self.select_to(offset, cx); + self.select_all_cursors_to( + |s, sel| s.start_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)), + cx, + ); } pub(super) fn select_to_end_of_line( @@ -1292,9 +1337,12 @@ impl InputBaseState { cx: &mut Context, ) { self.undo_manager.break_transaction_coalescing(); - let offset = self.end_of_line(); + self.select_all_cursors_to( + |s, sel| s.end_of_line_at(sel.cursor_offset(), s.line_end_affinity_for(sel)), + cx, + ); // Mirrors MoveEnd: the caret belongs at the end of the visual row it is on. - self.select_to_with_affinity(offset, true, cx); + self.cursor_line_end_affinity = true; } pub(super) fn select_to_previous_word( @@ -1304,8 +1352,10 @@ impl InputBaseState { cx: &mut Context, ) { self.undo_manager.break_transaction_coalescing(); - let offset = self.previous_start_of_word(); - self.select_to(offset, cx); + self.select_all_cursors_to( + |s, sel| s.previous_start_of_word_at(sel.cursor_offset()), + cx, + ); } pub(super) fn select_to_next_word( @@ -1315,12 +1365,12 @@ impl InputBaseState { cx: &mut Context, ) { self.undo_manager.break_transaction_coalescing(); - let offset = self.next_end_of_word(); - self.select_to(offset, cx); + self.select_all_cursors_to(|s, sel| s.next_end_of_word_at(sel.cursor_offset()), cx); } /// Return the start offset of the previous word. - pub(super) fn previous_start_of_word(&mut self) -> usize { + /// Return the previous start offset of the word before `offset`. + pub(super) fn previous_start_of_word_at(&self, offset: usize) -> usize { if self.masked { // The mask replaces every character, so the displayed text has no // word boundaries to move or delete by. Collapse the word to the @@ -1328,7 +1378,6 @@ impl InputBaseState { return 0; } - let offset = self.selected_range.start; let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); // FIXME: Avoid to_string let left_part = self.text.slice(0..offset).to_string(); @@ -1339,14 +1388,13 @@ impl InputBaseState { .unwrap_or(0) } - /// Return the next end offset of the next word. - pub(super) fn next_end_of_word(&mut self) -> usize { + /// Return the next end offset of the word after `offset`. + pub(super) fn next_end_of_word_at(&self, offset: usize) -> usize { if self.masked { - // See `previous_start_of_word`. + // See `previous_start_of_word_at`. return self.text.len(); } - let offset = self.cursor(); let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); let right_part = self.text.slice(offset..self.text.len()).to_string(); @@ -1356,28 +1404,27 @@ impl InputBaseState { .unwrap_or(self.text.len()) } - /// Get start of line byte offset of cursor. + /// Get start of line byte offset for the given `offset`. /// /// When soft wrap is active, first press goes to visual line start, /// second press (already at visual start) goes to logical line start. - pub(super) fn start_of_line(&self) -> usize { + pub(super) fn start_of_line_at(&self, offset: usize, line_end_affinity: bool) -> usize { if self.is_single_line() { return 0; } - let row = self.text.offset_to_point(self.cursor()).row; + let row = self.text.offset_to_point(offset).row; let logical_start = self.text.line_start_offset(row); if self.soft_wrap && self.is_code_editor() { - let wrap_point = self.display_map.offset_to_wrap_display_point_with_affinity( - self.cursor(), - self.cursor_line_end_affinity, - ); + let wrap_point = self + .display_map + .offset_to_wrap_display_point_with_affinity(offset, line_end_affinity); if let Some(line) = self.display_map.line(row) && let Some(range) = line.wrapped_lines.get(wrap_point.local_row) { let visual_start = logical_start + range.start; - if self.cursor() != visual_start { + if offset != visual_start { return visual_start; } } @@ -1386,16 +1433,16 @@ impl InputBaseState { logical_start } - /// Get end of line byte offset of cursor. + /// Get end of line byte offset for the given `offset`. /// /// When soft wrap is active, first press goes to visual line end, /// second press (already at visual end) goes to logical line end. - pub(super) fn end_of_line(&self) -> usize { + pub(super) fn end_of_line_at(&self, offset: usize, line_end_affinity: bool) -> usize { if self.is_single_line() { return self.text.len(); } - let row = self.text.offset_to_point(self.cursor()).row; + let row = self.text.offset_to_point(offset).row; let logical_start = self.text.line_start_offset(row); let logical_end = self.text.line_end_offset(row); @@ -1403,15 +1450,14 @@ impl InputBaseState { // Use the row the caret is drawn on: at a wrap boundary the raw offset would name // the next row, and a second End press would keep walking down instead of falling // through to the logical line end. - let wrap_point = self.display_map.offset_to_wrap_display_point_with_affinity( - self.cursor(), - self.cursor_line_end_affinity, - ); + let wrap_point = self + .display_map + .offset_to_wrap_display_point_with_affinity(offset, line_end_affinity); if let Some(line) = self.display_map.line(row) && let Some(range) = line.wrapped_lines.get(wrap_point.local_row) { let visual_end = logical_start + range.end; - if self.cursor() != visual_end { + if offset != visual_end { return visual_end; } } @@ -1420,45 +1466,24 @@ impl InputBaseState { logical_end } - /// Get start line of selection start or end (The min value). - /// - /// This is means is always get the first line of selection. - pub(super) fn start_of_line_of_selection( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> usize { - if self.is_single_line() { - return 0; - } - - let mut offset = - self.previous_boundary(self.selected_range.start.min(self.selected_range.end)); - if self.text.char_at(offset) == Some('\r') { - offset += 1; - } - - let line = self - .text_for_range(self.range_to_utf16(&(0..offset + 1)), &mut None, window, cx) - .unwrap_or_default() - .rfind('\n') - .map(|i| i + 1) - .unwrap_or(0); - line - } - /// Get indent string of next line. /// /// To get current and next line indent, to return more depth one. pub(super) fn indent_of_next_line(&mut self) -> String { + self.indent_of_next_line_at(self.cursor()) + } + + /// Get indent string of the next line, relative to the given `offset`. + pub(super) fn indent_of_next_line_at(&mut self, offset: usize) -> String { if self.is_single_line() { return "".into(); } let mut current_indent = String::new(); let mut next_indent = String::new(); - let current_line_start_pos = self.start_of_line(); - let next_line_start_pos = self.end_of_line(); + let line_end_affinity = self.line_end_affinity_at(offset); + let current_line_start_pos = self.start_of_line_at(offset, line_end_affinity); + let next_line_start_pos = self.end_of_line_at(offset, line_end_affinity); for c in self.text.slice(current_line_start_pos..).chars() { if !c.is_whitespace() { break; @@ -1486,28 +1511,89 @@ impl InputBaseState { } } - pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { - let intent = if self.selected_range.is_empty() { - self.select_to(self.previous_boundary(self.cursor()), cx); - EditIntent::Backspace + /// Delete every selection as one batch. Collapsed cursors are first + /// expanded to a deletion range by `collapsed_target` and non-empty + /// selections delete their own range. + /// + /// `collapsed_intent` is the intent to record when every cursor is + /// collapsed, which is what makes a run of single-character deletes undo + /// as one gesture. Deleting a real selection is always atomic. + fn delete_selections( + &mut self, + silent: bool, + collapsed_intent: EditIntent, + mut collapsed_target: impl FnMut(&mut Self, usize) -> Range, + window: &mut Window, + cx: &mut Context, + ) { + if !self.is_editable() { + return; + } + let cursors: Vec = self.selections.iter().copied().collect(); + let intent = if cursors.iter().all(|sel| sel.is_empty()) { + collapsed_intent } else { EditIntent::Atomic }; - self.undo_manager.pending_intent = Some(intent); + let mut new_selections: Vec = Vec::with_capacity(cursors.len()); + for sel in &cursors { + let range = if sel.is_empty() { + collapsed_target(self, sel.cursor_offset()) + } else { + sel.start..sel.end + }; + let (start, end) = (range.start.min(range.end), range.start.max(range.end)); + let mut selection = *sel; + selection.start = start; + selection.end = end; + new_selections.push(selection); + } + // Capture the user's selections before expanding or merging deletion + // ranges. The edit ranges cannot reconstruct their original carets. + self.undo_manager.begin_transaction_with(intent); + self.undo_manager + .record_selections(cursors.clone(), cursors.clone()); + self.selections.replace_all(new_selections); + self.undo_manager.set_pending_intent(intent); + + let was_silent = self.silent_replace_text; + self.silent_replace_text = silent; self.replace_text_in_range(None, "", window, cx); + self.silent_replace_text = was_silent; + self.undo_manager + .record_selections(cursors, self.selections.iter().copied().collect()); + self.undo_manager.commit_transaction(); self.pause_blink_cursor(cx); } + pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + // Nothing to delete at the start of the text. Propagate so an ancestor + // (e.g. a command palette navigating back a level) can act on it. + // This is harmless when nothing upstream is bound to backspace. With multiple + // cursors the others can still delete, so only the lone-cursor case + // propagates. + if self.selections.is_single() && self.active_selection().is_empty() && self.cursor() == 0 { + cx.propagate(); + return; + } + + self.delete_selections( + false, + EditIntent::Backspace, + |s, offset| s.previous_boundary(offset)..offset, + window, + cx, + ); + } + pub(super) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { - let intent = if self.selected_range.is_empty() { - self.select_to(self.next_boundary(self.cursor()), cx); - EditIntent::DeleteForward - } else { - EditIntent::Atomic - }; - self.undo_manager.pending_intent = Some(intent); - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); + self.delete_selections( + false, + EditIntent::DeleteForward, + |s, offset| offset..s.next_boundary(offset), + window, + cx, + ); } pub(super) fn delete_to_beginning_of_line( @@ -1516,23 +1602,19 @@ impl InputBaseState { window: &mut Window, cx: &mut Context, ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let mut offset = self.start_of_line(); - if offset == self.cursor() { - offset = offset.saturating_sub(1); - } - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..self.cursor()))), - "", + self.delete_selections( + true, + EditIntent::Atomic, + |s, offset| { + let mut start = s.start_of_line_at(offset, s.line_end_affinity_at(offset)); + if start == offset { + start = start.saturating_sub(1); + } + start..offset + }, window, cx, ); - self.pause_blink_cursor(cx); } pub(super) fn delete_to_end_of_line( @@ -1541,23 +1623,19 @@ impl InputBaseState { window: &mut Window, cx: &mut Context, ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let mut offset = self.end_of_line(); - if offset == self.cursor() { - offset = (offset + 1).clamp(0, self.text.len()); - } - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(self.cursor()..offset))), - "", + self.delete_selections( + true, + EditIntent::Atomic, + |s, offset| { + let mut end = s.end_of_line_at(offset, s.line_end_affinity_at(offset)); + if end == offset { + end = (end + 1).clamp(0, s.text.len()); + } + offset..end + }, window, cx, ); - self.pause_blink_cursor(cx); } pub(super) fn delete_previous_word( @@ -1566,20 +1644,13 @@ impl InputBaseState { window: &mut Window, cx: &mut Context, ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let offset = self.previous_start_of_word(); - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..self.cursor()))), - "", + self.delete_selections( + true, + EditIntent::Atomic, + |s, offset| s.previous_start_of_word_at(offset)..offset, window, cx, ); - self.pause_blink_cursor(cx); } pub(super) fn delete_next_word( @@ -1588,20 +1659,13 @@ impl InputBaseState { window: &mut Window, cx: &mut Context, ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let offset = self.next_end_of_word(); - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(self.cursor()..offset))), - "", + self.delete_selections( + true, + EditIntent::Atomic, + |s, offset| offset..s.next_end_of_word_at(offset), window, cx, ); - self.pause_blink_cursor(cx); } pub(super) fn enter(&mut self, action: &Enter, window: &mut Window, cx: &mut Context) { @@ -1621,17 +1685,35 @@ impl InputBaseState { let insert_newline = self.is_multi_line() && (!self.submit_on_enter || action.shift); if insert_newline { - // Get current line indent - let indent = if self.is_code_editor() { - self.indent_of_next_line() + if !self.selections.is_single() { + // Insert a newline (with per-line indent) at every cursor. + self.selections.merge_overlapping(); + let selections: Vec = self.selections.iter().copied().collect(); + let mut edits: Vec<(Range, String)> = Vec::with_capacity(selections.len()); + for sel in &selections { + let indent = if self.is_code_editor() { + self.indent_of_next_line_at(sel.cursor_offset()) + } else { + String::new() + }; + edits.push((sel.start..sel.end, format!("\n{}", indent))); + } + edits.sort_by_key(|(range, _)| range.start); + self.replace_text_in_ranges(&edits, window, cx); + self.pause_blink_cursor(cx); } else { - "".to_string() - }; + // Get current line indent + let indent = if self.is_code_editor() { + self.indent_of_next_line() + } else { + "".to_string() + }; - // Add newline and indent - let new_line_text = format!("\n{}", indent); - self.replace_text_in_range_silent(None, &new_line_text, window, cx); - self.pause_blink_cursor(cx); + // Add newline and indent + let new_line_text = format!("\n{}", indent); + self.replace_text_in_range_silent(None, &new_line_text, window, cx); + self.pause_blink_cursor(cx); + } } else { // Single line input or submit-on-enter: just emit the event // (e.g.: in a dialog to confirm, or a chat textarea to send). @@ -1647,7 +1729,7 @@ impl InputBaseState { pub fn clean(&mut self, window: &mut Window, cx: &mut Context) { self.replace_text("", window, cx); - self.selected_range = (0..0).into(); + self.set_selection(0, 0); self.scroll_to(0, None, cx); } @@ -1656,6 +1738,14 @@ impl InputBaseState { return; } + // Collapse extra cursors back to the active one first. + if !self.selections.is_single() { + self.undo_manager.break_transaction_coalescing(); + self.selections.remove_all_but_active(); + cx.notify(); + return; + } + // Clear inline completion on escape if M::has_inline_completion(self) { M::clear_inline_completion(self, cx); @@ -1688,7 +1778,7 @@ impl InputBaseState { return; } - if !self.selected_range.contains(offset) { + if !self.active_selection().contains(offset) { self.move_to(offset, None, cx); } @@ -1704,6 +1794,161 @@ impl InputBaseState { } } + pub(super) fn add_cursor_above( + &mut self, + _: &AddCursorAbove, + _: &mut Window, + cx: &mut Context, + ) { + self.add_cursor_vertical(-1, cx); + } + + pub(super) fn add_cursor_below( + &mut self, + _: &AddCursorBelow, + _: &mut Window, + cx: &mut Context, + ) { + self.add_cursor_vertical(1, cx); + } + + /// Add a new cursor one display line above (`move_lines < 0`) or below each + /// existing cursor, preserving the column. Cursors that would not move (at + /// the first/last display row) or that would duplicate an existing cursor + /// are skipped. + fn add_cursor_vertical(&mut self, move_lines: isize, cx: &mut Context) { + if !self.is_multi_line() { + return; + } + + self.pause_blink_cursor(cx); + // Changing the cursor set ends the editing gesture that came before it. + self.undo_manager.break_transaction_coalescing(); + + let sources: Vec<(usize, Option<(Pixels, usize)>, bool)> = self + .selections + .iter() + .map(|sel| { + ( + sel.cursor_offset(), + sel.column_anchor, + self.line_end_affinity_for(sel), + ) + }) + .collect(); + let mut offsets: std::collections::HashSet = + sources.iter().map(|(offset, _, _)| *offset).collect(); + + let mut newest: Option = None; + for (offset, anchor, line_end_affinity) in sources { + let anchor = anchor.or_else(|| self.preferred_column_for(offset)); + let (target, _) = self.vertical_target(offset, anchor, line_end_affinity, move_lines); + if target == offset || offsets.contains(&target) { + continue; + } + offsets.insert(target); + let id = self.selections.generate_id(); + let mut cursor = CursorSelection::new(id, target, target); + cursor.column_anchor = anchor; + self.selections.add(cursor); + newest = Some(target); + } + + if let Some(newest) = newest { + self.scroll_to(newest, None, cx); + } + cx.notify(); + } + + /// Add an additional collapsed cursor at `offset`. + /// + /// Rejected when `offset` lands inside an existing selection or exactly on + /// an existing cursor. + pub(super) fn add_cursor_at(&mut self, offset: usize, cx: &mut Context) { + if !self.is_multi_line() { + return; + } + + for sel in self.selections.iter() { + if sel.contains(offset) { + return; + } + if sel.is_collapsed() && sel.cursor_offset() == offset { + return; + } + } + + self.undo_manager.break_transaction_coalescing(); + let id = self.selections.generate_id(); + self.selections + .add(CursorSelection::new(id, offset, offset)); + cx.notify(); + } + + /// Build a columnar (block) selection spanning the rows between the two + /// offsets, one selection per display row at the same column span. + pub(super) fn build_columnar_selection( + &mut self, + start_offset: usize, + end_offset: usize, + cx: &mut Context, + ) { + if !self.is_multi_line() { + return; + } + + self.undo_manager.break_transaction_coalescing(); + let (start, end) = if start_offset <= end_offset { + (start_offset, end_offset) + } else { + (end_offset, start_offset) + }; + + let start_point = self.display_map.offset_to_wrap_display_point(start); + let end_point = self.display_map.offset_to_wrap_display_point(end); + + let start_col = start_point.column; + let end_col = end_point.column; + let (start_col, end_col) = if start_col <= end_col { + (start_col, end_col) + } else { + (end_col, start_col) + }; + + let start_row = self + .display_map + .wrap_row_to_display_row(start_point.row) + .unwrap_or_else(|| { + self.display_map + .nearest_visible_display_row(start_point.row) + }); + let end_row = self + .display_map + .wrap_row_to_display_row(end_point.row) + .unwrap_or_else(|| self.display_map.nearest_visible_display_row(end_point.row)); + let (start_row, end_row) = (start_row.min(end_row), start_row.max(end_row)); + + let mut new_selections = Vec::with_capacity(end_row - start_row + 1); + for row in start_row..=end_row { + let sel_start = self + .display_map + .display_row_column_to_offset(row, start_col); + let sel_end = self.display_map.display_row_column_to_offset(row, end_col); + let id = self.selections.generate_id(); + let sel_start = self.text.clip_offset(sel_start, Bias::Left); + let sel_end = self.text.clip_offset(sel_end, Bias::Left); + new_selections.push(CursorSelection::new(id, sel_start, sel_end)); + } + + if new_selections.is_empty() { + let id = self.selections.generate_id(); + new_selections.push(CursorSelection::new(id, end, end)); + } + + self.selections.replace_all(new_selections); + cx.notify(); + } + pub(super) fn on_mouse_down( &mut self, event: &MouseDownEvent, @@ -1748,7 +1993,7 @@ impl InputBaseState { // Show Mouse context menu if event.button == MouseButton::Right { if self.enable_context_menu { - if !self.selected_range.contains(offset) { + if !self.active_selection().contains(offset) { self.move_to(offset, None, cx); } self.pending_context_menu = Some((event.position, offset)); @@ -1756,6 +2001,26 @@ impl InputBaseState { return; } + // Multi-cursor placement, multi-line only. + if self.is_multi_line() && event.button == MouseButton::Left { + if event.modifiers.alt + && (event.modifiers.shift || (cfg!(target_os = "linux") && event.modifiers.control)) + { + // Alt+Shift starts a block; Linux also accepts Ghostty's Ctrl+Alt. + // Mark selecting so the drag handler extends the block. + self.column_select_start = Some(offset); + self.selecting = true; + self.move_to_with_affinity(offset, None, line_end_affinity, cx); + return; + } else if event.modifiers.alt { + self.add_cursor_at(offset, cx); + // Keep click-to-add behavior, but use this press as the block + // anchor if the user continues dragging with the left button. + self.column_select_start = Some(offset); + return; + } + } + if event.modifiers.shift { self.select_to_with_affinity(offset, line_end_affinity, cx); } else { @@ -1774,11 +2039,12 @@ impl InputBaseState { self.handle_right_click_menu(position, offset, window, cx); } } - if self.selected_range.is_empty() { - self.selection_reversed = false; + if self.active_selection().is_empty() { + self.active_selection_mut().reversed = false; } self.selecting = false; self.selected_word_range = None; + self.column_select_start = None; self.auto_scroll.stop(); } @@ -1989,13 +2255,29 @@ impl InputBaseState { window.show_character_palette(); } + /// The text of every non-empty selection, in document order. + fn selected_texts(&self) -> Vec { + let mut selections: Vec = self + .selections + .iter() + .copied() + .filter(|sel| !sel.is_empty()) + .collect(); + selections.sort_by_key(|sel| sel.start); + selections + .iter() + .map(|sel| self.text.slice(*sel).to_string()) + .collect() + } + pub(super) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { if !self.is_copyable() { return; } - let selected_text = self.text.slice(self.selected_range).to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); + let texts = self.selected_texts(); + + cx.write_to_clipboard(ClipboardItem::new_string(texts.join("\n"))); } pub(super) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { @@ -2003,20 +2285,83 @@ impl InputBaseState { return; } - let selected_text = self.text.slice(self.selected_range).to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); + let texts = self.selected_texts(); + + cx.write_to_clipboard(ClipboardItem::new_string(texts.join("\n"))); - self.undo_manager.pending_intent = Some(EditIntent::Atomic); + self.undo_manager.set_pending_intent(EditIntent::Atomic); self.replace_text_in_range_silent(None, "", window, cx); } pub(super) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { - if let Some(clipboard) = cx.read_from_clipboard() { - let new_text = clipboard.text().unwrap_or_default(); - self.undo_manager.pending_intent = Some(EditIntent::Atomic); + if !self.is_editable() { + return; + } + let Some(clipboard) = cx.read_from_clipboard() else { + return; + }; + let mut new_text = clipboard.text().unwrap_or_default(); + // A paste is one atomic edit, never part of a typing run. + self.undo_manager.set_pending_intent(EditIntent::Atomic); + + if !self.is_multi_line() { + new_text = new_text.replace('\n', ""); self.replace_text_in_range_silent(None, &new_text, window, cx); self.scroll_to(self.cursor(), None, cx); + return; + } + + // Distribute one clipboard line per selection when the counts match. + // Otherwise insert the whole clipboard text at each cursor. + if !self.selections.is_single() { + self.selections.merge_overlapping(); + } + let lines: Vec = new_text.split('\n').map(|s| s.to_string()).collect(); + let count = self.selections.len(); + if count > 1 && lines.len() == count { + let mut selections: Vec = self.selections.iter().copied().collect(); + selections.sort_by_key(|sel| sel.start); + let edits: Vec<(Range, String)> = selections + .iter() + .zip(lines) + .map(|(sel, line)| (sel.start..sel.end, line)) + .collect(); + self.replace_text_in_ranges(&edits, window, cx); + } else { + self.replace_text_in_range_silent(None, &new_text, window, cx); + } + self.scroll_to(self.cursor(), None, cx); + } + + /// The intent of a batch the caller did not label: inserting text at + /// collapsed cursors is typing, anything else stands on its own. + fn typing_intent(&self, edits: &[(Range, String)], new_text: &str) -> EditIntent { + if !new_text.is_empty() + && !new_text.contains(['\n', '\r']) + && edits.iter().all(|(range, _)| range.is_empty()) + { + EditIntent::Typing + } else { + EditIntent::Atomic + } + } + + /// Where a cursor stood before an edit made with this intent. + /// + /// Backspace and forward delete expand a collapsed cursor over the text + /// they are about to remove, so the recorded cursor has to collapse back to + /// the side it came from for undo to restore it where the user left it. + fn collapse_for_intent( + intent: EditIntent, + mut selection: CursorSelection, + range: &Range, + ) -> CursorSelection { + match intent { + EditIntent::Backspace => selection.place_at(range.end, None), + EditIntent::DeleteForward => selection.place_at(range.start, None), + EditIntent::Typing | EditIntent::Atomic => {} } + selection } fn push_history( @@ -2025,11 +2370,11 @@ impl InputBaseState { range: &Range, new_text: &str, requested_intent: Option, - selection_before: Selection, - selection_after: Option, - ) { + selection_before: CursorSelection, + selection_after: Option, + ) -> bool { if self.undo_manager.is_ignoring() { - return; + return false; } let range = @@ -2049,53 +2394,70 @@ impl InputBaseState { } }); - let selection_before = match intent { - EditIntent::Backspace => Selection::new(range.end, range.end), - EditIntent::DeleteForward => Selection::new(range.start, range.start), - EditIntent::Typing | EditIntent::Atomic => selection_before, - }; + let selection_before = Self::collapse_for_intent(intent, selection_before, &range); let selection_after = - selection_after.unwrap_or_else(|| Selection::new(new_range.end, new_range.end)); + selection_after.unwrap_or_else(|| (new_range.end..new_range.end).into()); - self.undo_manager.record_transaction( - Change::new( - range, - &old_text, - new_range, - new_text, - selection_before, - selection_after, - ), - intent, - ); + let open_transaction = self.undo_manager.has_open_transaction(); + let recorded = self + .undo_manager + .record_transaction(Change::new(range, &old_text, new_range, new_text), intent); + // A batch records its own cursor sets. This covers a change that is a + // transaction on its own. + if recorded && !open_transaction { + self.undo_manager + .record_selections(vec![selection_before], vec![selection_after]); + } + recorded } pub(super) fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { self.undo_manager.set_ignoring(true); - if let Some(changes) = self.undo_manager.undo() { - let selection = changes.last().unwrap().selection_before; - for change in &changes { + // The manager hands the changes back in reverse application order. + if let Some(replay) = self.undo_manager.undo() { + for change in &replay.changes { let range_utf16 = self.range_to_utf16(&change.new_range.into()); self.replace_text_in_range_silent(Some(range_utf16), &change.old_text, window, cx); } - self.selected_range = selection; + self.restore_selections(replay.selections); } self.undo_manager.set_ignoring(false); } pub(super) fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context) { self.undo_manager.set_ignoring(true); - if let Some(changes) = self.undo_manager.redo() { - let selection = changes.last().unwrap().selection_after; - for change in &changes { + // Redo replays in forward application order. + if let Some(replay) = self.undo_manager.redo() { + for change in &replay.changes { let range_utf16 = self.range_to_utf16(&change.old_range.into()); self.replace_text_in_range_silent(Some(range_utf16), &change.new_text, window, cx); } - self.selected_range = selection; + self.restore_selections(replay.selections); } self.undo_manager.set_ignoring(false); } + /// Restore a set of selections captured in a transaction, clamping offsets + /// to the current text length. `None` leaves the current selections as the + /// replay left them. + fn restore_selections(&mut self, selections: Option>) { + let Some(selections) = selections else { + return; + }; + + let len = self.text.len(); + let restored: Vec = selections + .into_iter() + .map(|mut sel| { + sel.start = sel.start.min(len); + sel.end = sel.end.min(len); + sel + }) + .collect(); + self.selections.replace_all(restored); + self.selections.merge_overlapping(); + } + /// Get byte offset of the cursor. /// /// The offset is the UTF-8 offset. @@ -2104,11 +2466,34 @@ impl InputBaseState { return ime_marked_range.end; } - if self.selection_reversed { - self.selected_range.start - } else { - self.selected_range.end - } + self.selections.active().cursor_offset() + } + + /// Returns the active selection. + pub(super) fn active_selection(&self) -> &CursorSelection { + self.selections.active() + } + + /// Returns a mutable reference to the active selection. + pub(super) fn active_selection_mut(&mut self) -> &mut CursorSelection { + self.selections.active_mut() + } + + /// Sets the active selection to the given range, keeping its `reversed` + /// and `column_anchor` state untouched. + pub(super) fn set_selection(&mut self, start: usize, end: usize) { + let active = self.active_selection_mut(); + active.start = start; + active.end = end; + } + + /// Collapses the active selection to a cursor at the given offset, + /// clearing `reversed`. + pub(super) fn set_cursor_to(&mut self, offset: usize) { + let active = self.active_selection_mut(); + active.start = offset; + active.end = offset; + active.reversed = false; } /// Visible row range in the last laid-out viewport, `None` before first layout. @@ -2134,22 +2519,25 @@ impl InputBaseState { self.last_layout.as_ref().map(|l| l.line_height) } - /// Returns the current selection as a byte range into the text. + /// Returns the active selection as a byte range into the text. + /// + /// With multiple cursors, this reads only the active selection. /// /// The range is empty (`start == end`) when no text is selected; in /// that case the offset equals `cursor()`. Byte offsets are measured /// in the underlying rope's byte units. pub fn selected_range(&self) -> std::ops::Range { - self.selected_range.into() + (*self.selections.active()).into() } pub fn select_all(&mut self, _: &mut Window, cx: &mut Context) { self.undo_manager.break_transaction_coalescing(); - self.selected_range = (0..self.text.len()).into(); + self.selections.remove_all_but_active(); + self.set_selection(0, self.text.len()); cx.notify(); } - /// Set the selected range using UTF-8 byte offsets. + /// Set the selected range using UTF-8 byte offsets, removing additional cursors. /// /// Non-empty ranges expand to character boundaries. Empty ranges remain empty and are /// clipped to the preceding character boundary. @@ -2163,7 +2551,7 @@ impl InputBaseState { let end = self.text.clip_offset(range.end, end_bias); self.move_to(start, None, cx); - self.selection_reversed = false; + self.active_selection_mut().reversed = false; self.selected_word_range = None; self.select_to(end, cx); } @@ -2260,6 +2648,36 @@ impl InputBaseState { /// The offset is the UTF-8 offset. /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. + /// Extend a single selection so its moving end lands at `offset`, flipping + /// `reversed` when the ends cross. When a sticky `word_range` is given the + /// selection is kept covering it. + fn extend_selection( + sel: &mut CursorSelection, + offset: usize, + word_range: Option, + ) { + if sel.reversed { + sel.start = offset; + } else { + sel.end = offset; + } + + if sel.end < sel.start { + sel.reversed = !sel.reversed; + std::mem::swap(&mut sel.start, &mut sel.end); + } + + if let Some(word_range) = word_range { + if sel.start > word_range.start { + sel.start = word_range.start; + } + if sel.end < word_range.end { + sel.end = word_range.end; + } + } + } + + /// Extend only the active selection to `offset`. Used by mouse drag. pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context) { self.select_to_with_affinity(offset, false, cx); } @@ -2280,37 +2698,54 @@ impl InputBaseState { self.cursor_line_end_affinity = line_end_affinity; let offset = offset.clamp(0, self.text.len()); - if self.selection_reversed { - self.selected_range.start = offset - } else { - self.selected_range.end = offset - }; + let word_range = self.selected_word_range; + Self::extend_selection(self.active_selection_mut(), offset, word_range); - if self.selected_range.end < self.selected_range.start { - self.selection_reversed = !self.selection_reversed; - self.selected_range = (self.selected_range.end..self.selected_range.start).into(); - } - - // Ensure keep word selected range - if let Some(word_range) = self.selected_word_range.as_ref() { - if self.selected_range.start > word_range.start { - self.selected_range.start = word_range.start; - } - if self.selected_range.end < word_range.end { - self.selected_range.end = word_range.end; - } - } - if self.selected_range.is_empty() { + if self.active_selection().is_empty() { self.update_preferred_column(); } cx.notify() } - /// Unselects the currently selected text. - pub fn unselect(&mut self, _: &mut Window, cx: &mut Context) { - self.undo_manager.break_transaction_coalescing(); + /// Extend every selection to the offset produced by `f`, then merge any + /// selections that now overlap. Used by keyboard selection commands. + fn select_all_cursors_to( + &mut self, + f: impl Fn(&Self, &CursorSelection) -> usize, + cx: &mut Context, + ) { + self.pause_blink_cursor(cx); + self.undo_manager.break_transaction_coalescing(); + M::clear_inline_completion(self, cx); + + let len = self.text.len(); + let new_selections: Vec = self + .selections + .iter() + .map(|sel| { + let offset = f(self, sel).clamp(0, len); + let mut new_sel = *sel; + Self::extend_selection(&mut new_sel, offset, None); + new_sel + }) + .collect(); + // Resolve targets using the old caret affinity before clearing it. + self.cursor_line_end_affinity = false; + self.selections.replace_all(new_selections); + self.selections.merge_overlapping(); + + if self.active_selection().is_empty() { + self.update_preferred_column(); + } + self.scroll_to(self.cursor(), None, cx); + cx.notify() + } + + /// Unselects the currently selected text. + pub fn unselect(&mut self, _: &mut Window, cx: &mut Context) { + self.undo_manager.break_transaction_coalescing(); let offset = self.cursor(); - self.selected_range = (offset..offset).into(); + self.set_cursor_to(offset); cx.notify() } @@ -2462,10 +2897,6 @@ impl InputBaseState { }); } - pub(super) fn on_key_down(&mut self, _: &KeyDownEvent, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - } - pub(super) fn on_drag_move( &mut self, event: &MouseMoveEvent, @@ -2490,7 +2921,11 @@ impl InputBaseState { self.auto_scroll.last_drag_position = Some(event.position); let (offset, line_end_affinity) = self.index_for_mouse_position(event.position); - self.select_to_with_affinity(offset, line_end_affinity, cx); + if let Some(start) = self.column_select_start { + self.build_columnar_selection(start, offset, cx); + } else { + self.select_to_with_affinity(offset, line_end_affinity, cx); + } if !self.is_single_line() { let delta = AutoScroll::compute_delta(event.position.y, self.input_bounds); @@ -2501,7 +2936,11 @@ impl InputBaseState { state.update_scroll_offset(Some(point(current.x, current.y + delta)), cx); if let Some(pos) = state.auto_scroll.last_drag_position { let (offset, line_end_affinity) = state.index_for_mouse_position(pos); - state.select_to_with_affinity(offset, line_end_affinity, cx); + if let Some(start) = state.column_select_start { + state.build_columnar_selection(start, offset, cx); + } else { + state.select_to_with_affinity(offset, line_end_affinity, cx); + } } }); } @@ -2614,12 +3053,12 @@ impl InputBaseState { } } - /// Return the selected portion of the text, borrowed out of the [`Rope`] + /// Return the active selection's text, borrowed out of the [`Rope`] /// the state owns. /// /// See [`Self::selected_value`] when an owned string is wanted. pub fn selected_text(&self) -> RopeSlice<'_> { - let range_utf16 = self.range_to_utf16(&self.selected_range.into()); + let range_utf16 = self.range_to_utf16(&self.selected_range()); let range = self.range_from_utf16(&range_utf16); self.text.slice(range) } @@ -2667,6 +3106,167 @@ impl InputBaseState { self.silent_replace_text = false; } + /// Apply a batch of edits as one atomic history transaction. + /// + /// `edits` are `(byte range in the current pre-edit document, replacement)` + /// pairs. + pub(crate) fn replace_text_in_ranges( + &mut self, + edits: &[(Range, String)], + window: &mut Window, + cx: &mut Context, + ) { + if !self.is_editable() || edits.is_empty() { + return; + } + + // Sort descending by start so applying front-of-vec first edits the + // highest offsets first, leaving lower offsets unchanged. + let mut sorted: Vec<(Range, &str)> = edits + .iter() + .map(|(range, text)| (range.clone(), text.as_str())) + .collect(); + sorted.sort_by_key(|edit| std::cmp::Reverse(edit.0.start)); + + #[cfg(debug_assertions)] + for pair in sorted.windows(2) { + debug_assert!( + pair[1].0.end <= pair[0].0.start, + "replace_text_in_ranges requires disjoint ranges" + ); + } + + // Wrap multiple edits in one explicit transaction so they undo as a + // unit. A single edit records directly, which keeps it eligible for + // the undo manager's typing coalescing. + let requested_intent = self.undo_manager.take_pending_intent(); + let selection_before = *self.active_selection(); + let original_selections: Vec = self.selections.iter().copied().collect(); + // Snapshot the cursors before applying, so undo can restore them. A + // delete has already expanded them over the text it removes, so they + // collapse back to where the user left them. + let selections_before: Vec = self + .selections + .iter() + .map(|selection| { + Self::collapse_for_intent( + requested_intent.unwrap_or(EditIntent::Atomic), + *selection, + &(selection.start..selection.end), + ) + }) + .collect(); + let group = sorted.len() > 1; + if group { + self.undo_manager.begin_transaction(); + } + + let mut recorded = false; + for (range, new_text) in &sorted { + let old_text = self.text.clone(); + self.text.replace(range.clone(), new_text); + + M::adjust_annotations(self, range, new_text.len()); + recorded |= self.push_history( + &old_text, + range, + new_text, + requested_intent, + selection_before, + None, + ); + + // Incremental, single-range updates must run per edit. + self.display_map + .adjust_folds_for_edit(&old_text, range, new_text); + self.display_map + .on_text_changed(&self.text, range, &Rope::from(*new_text), cx); + + self.mode.update_highlighter( + super::mode::HighlighterUpdate { + selected_range: range, + old_text: &old_text, + new_text: &self.text, + change_text: new_text, + force: true, + }, + window, + cx, + ); + + self.update_fold_candidates_incremental(range, new_text); + } + + if group { + self.undo_manager.commit_transaction(); + } + + // One observable update per batch instead of one per edit. + if let Some(diagnostics) = self.mode.diagnostics_mut() { + diagnostics.reset(&self.text) + } + M::refresh_language_features(self, window, cx); + self.update_search(cx); + + // Compute the resulting cursors. + // One collapsed cursor per edit, at the end of its inserted text. + let mut ascending: Vec<(Range, &str)> = sorted.clone(); + ascending.sort_by_key(|edit| edit.0.start); + let text_len = self.text.len(); + let mut delta: isize = 0; + let mut edit_results = Vec::with_capacity(ascending.len()); + for (range, new_text) in &ascending { + let offset = ((range.start as isize + delta) as usize + new_text.len()).min(text_len); + edit_results.push((range.clone(), offset)); + delta += new_text.len() as isize - (range.end as isize - range.start as isize); + } + + let mut used = vec![false; edit_results.len()]; + let mut new_selections: Vec = Vec::with_capacity(ascending.len()); + for selection in original_selections { + if let Ok(index) = edit_results + .binary_search_by_key(&(selection.start, selection.end), |(range, _)| { + (range.start, range.end) + }) + && !used[index] + { + let offset = edit_results[index].1; + used[index] = true; + let mut selection = selection; + selection.place_at(offset, None); + new_selections.push(selection); + } + } + for (index, (_, offset)) in edit_results.into_iter().enumerate() { + if !used[index] { + new_selections.push(CursorSelection::new( + self.selections.generate_id(), + offset, + offset, + )); + } + } + self.selections.replace_all(new_selections); + self.selections.merge_overlapping(); + + // Record the cursor snapshots for undo/redo restore. + let selections_after: Vec = self.selections.iter().copied().collect(); + if recorded { + self.undo_manager + .record_selections(selections_before, selections_after); + } + + self.ime_marked_range.take(); + self.update_preferred_column(); + if self.is_multi_line() { + self.mode.update_auto_grow(&self.display_map); + } + if self.emit_events { + cx.emit(InputEvent::Change); + } + cx.notify(); + } + /// Update fold candidates from tree-sitter syntax tree (full extraction). /// Used only on initial load or language changes. fn update_fold_candidates(&mut self) { @@ -2733,7 +3333,7 @@ impl EntityInputHandler for InputBaseState { _cx: &mut Context, ) -> Option { Some(UTF16Selection { - range: self.range_to_utf16(&self.selected_range.into()), + range: self.range_to_utf16(&self.selected_range()), reversed: false, }) } @@ -2763,15 +3363,16 @@ impl EntityInputHandler for InputBaseState { window: &mut Window, cx: &mut Context, ) { - let requested_intent = self.undo_manager.pending_intent.take(); + let requested_intent = self.undo_manager.take_pending_intent(); if !self.is_editable() { return; } - let selection_before = self.selected_range; + let selection_before = *self.active_selection(); + // Committing a composition ends the transaction it opened, whether or + // not the platform follows up with `unmark_text`. + let ends_composition = self.ime_marked_range.is_some(); - if self.blink_cursor.read(cx).visible() { - self.pause_blink_cursor(cx); - } + self.pause_blink_cursor(cx); // NOTE: The normalization keeps the UTF-16 length, but may change the // UTF-8 byte length, so all the byte-offset calculations below must @@ -2786,8 +3387,49 @@ impl EntityInputHandler for InputBaseState { let range = self.range_to_utf16(&(range.start..range.end)); self.range_from_utf16(&range) })) - .unwrap_or(self.selected_range.into()); + .unwrap_or(self.selected_range()); + + if self.is_multi_line() { + let multi_cursor = range_utf16.is_none() + && self.ime_marked_range.is_none() + && !self.selections.is_single(); + if multi_cursor { + self.selections.merge_overlapping(); + let mut edits: Vec<(Range, String)> = self + .selections + .iter() + .map(|sel| (sel.start..sel.end, new_text.to_string())) + .collect(); + edits.sort_by_key(|(range, _)| range.start); + // One keystroke across several cursors is one batch, committed + // with the intent of the keystroke so a run of them coalesces + // into one undo just like single-cursor typing. + let intent = + requested_intent.unwrap_or_else(|| self.typing_intent(&edits, new_text)); + self.undo_manager.begin_transaction_with(intent); + self.undo_manager.set_pending_intent(intent); + self.replace_text_in_ranges(&edits, window, cx); + self.undo_manager.commit_transaction(); + } else { + if range_utf16.is_some() { + self.selections.remove_all_but_active(); + } + if let Some(intent) = requested_intent { + self.undo_manager.set_pending_intent(intent); + } + self.replace_text_in_ranges(&[(range.clone(), new_text.to_string())], window, cx); + } + if ends_composition { + self.undo_manager.commit_transaction(); + } + if !self.silent_replace_text { + M::on_text_typed(self, &range, new_text, window, cx); + } + return; + } + + // Single-line path let old_text = self.text.clone(); self.text.replace(range.clone(), new_text); @@ -2838,7 +3480,7 @@ impl EntityInputHandler for InputBaseState { &self.text.to_string(), Some(EditIntent::Atomic), selection_before, - Some(Selection::new(new_offset, new_offset)), + Some((new_offset..new_offset).into()), ); } else { self.push_history( @@ -2850,12 +3492,6 @@ impl EntityInputHandler for InputBaseState { None, ); } - // A commit ends the IME composition: macOS delivers `insertText:` for - // the confirmed candidate without a following `unmarkText`, so close - // the transaction here. Leaving it open would keep merging every later - // edit into the same change, which then carries the text and selection - // of the first composition. - self.undo_manager.commit_transaction(); if let Some(diagnostics) = self.mode.diagnostics_mut() { diagnostics.reset(&self.text) } @@ -2879,8 +3515,18 @@ impl EntityInputHandler for InputBaseState { self.update_fold_candidates_incremental(&range, new_text); M::refresh_language_features(self, window, cx); - self.selected_range = (new_offset..new_offset).into(); + self.set_cursor_to(new_offset); self.ime_marked_range.take(); + // A commit ends the IME composition: macOS delivers `insertText:` for + // the confirmed candidate without a following `unmarkText`, so close + // the transaction here. Leaving it open would keep merging every later + // edit into the same change, which then carries the text and selection + // of the first composition. + if ends_composition { + self.undo_manager + .record_selections(vec![selection_before], vec![*self.active_selection()]); + self.undo_manager.commit_transaction(); + } self.update_preferred_column(); self.update_search(cx); if self.is_multi_line() { @@ -2904,17 +3550,20 @@ impl EntityInputHandler for InputBaseState { window: &mut Window, cx: &mut Context, ) { - let requested_intent = self.undo_manager.pending_intent.take(); + let requested_intent = self.undo_manager.take_pending_intent(); if !self.is_editable() { return; } - let selection_before = self.selected_range; + let selection_before = *self.active_selection(); let starts_composition = self.ime_marked_range.is_none(); if starts_composition { self.undo_manager.begin_transaction(); } + // Collapse any extra cursors so we never leave stale secondary cursors behind. + self.selections.remove_all_but_active(); + M::reset_language_features(self); // See the same NOTE in `replace_text_in_range`. @@ -2928,7 +3577,7 @@ impl EntityInputHandler for InputBaseState { let range = self.range_to_utf16(&(range.start..range.end)); self.range_from_utf16(&range) })) - .unwrap_or(self.selected_range.into()); + .unwrap_or(self.selected_range()); let old_text = self.text.clone(); self.text.replace(range.clone(), new_text); @@ -2973,31 +3622,34 @@ impl EntityInputHandler for InputBaseState { M::refresh_language_features(self, window, cx); if new_text.is_empty() { // Cancel selection, when cancel IME input. - self.selected_range = (range.start..range.start).into(); + self.set_cursor_to(range.start); self.ime_marked_range = None; } else { self.ime_marked_range = Some((range.start..range.start + new_text.len()).into()); - self.selected_range = new_selected_range_utf16 + let new_range = new_selected_range_utf16 .as_ref() .map(|range_utf16| { let new_text = Rope::from(new_text); range.start + new_text.offset_utf16_to_offset(range_utf16.start) ..range.start + new_text.offset_utf16_to_offset(range_utf16.end) }) - .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()) - .into(); + .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); + self.set_selection(new_range.start, new_range.end); } if self.is_multi_line() { self.mode.update_auto_grow(&self.display_map); } - self.push_history( + if self.push_history( &old_text, &range, new_text, requested_intent, selection_before, - Some(self.selected_range), - ); + Some(*self.active_selection()), + ) { + self.undo_manager + .record_selections(vec![selection_before], vec![*self.active_selection()]); + } if new_text.is_empty() { self.undo_manager.commit_transaction(); } @@ -3153,6 +3805,8 @@ impl Render for InputBaseState { .on_action(window.listener_for(&entity, InputBaseState::select_down)) .on_action(window.listener_for(&entity, InputBaseState::page_up)) .on_action(window.listener_for(&entity, InputBaseState::page_down)) + .on_action(window.listener_for(&entity, InputBaseState::add_cursor_above)) + .on_action(window.listener_for(&entity, InputBaseState::add_cursor_below)) }) .on_action(window.listener_for(&entity, InputBaseState::on_action_select_all)) .on_action(window.listener_for(&entity, InputBaseState::select_to_start_of_line)) @@ -3171,7 +3825,6 @@ impl Render for InputBaseState { .on_action(window.listener_for(&entity, InputBaseState::copy)) .on_action(window.listener_for(&entity, InputBaseState::on_action_search)) .on_action(window.listener_for(&entity, InputBaseState::on_action_replace)) - .on_key_down(window.listener_for(&entity, InputBaseState::on_key_down)) .on_mouse_down( MouseButton::Left, window.listener_for(&entity, InputBaseState::on_mouse_down), @@ -3190,7 +3843,16 @@ impl Render for InputBaseState { ) .on_mouse_move(window.listener_for(&entity, InputBaseState::on_mouse_move)) .on_scroll_wheel(window.listener_for(&entity, InputBaseState::on_scroll_wheel)) - .when(!self.disabled, |this| this.cursor_text()) + .when(self.is_multi_line() && !self.disabled, |this| { + this.on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify())) + }) + .when(!self.disabled, |this| { + if self.is_multi_line() && window.modifiers().alt { + this.cursor_crosshair() + } else { + this.cursor_text() + } + }) .flex_1() .when(self.is_multi_line(), |this| this.h_full()) .flex_grow_1() @@ -3614,7 +4276,7 @@ mod tests { cx.update(|_, cx| { input.read_with(cx, |state, _| { assert_eq!(state.value(), "12.5"); - let cursor: Range = state.selected_range.into(); + let cursor: Range = state.selected_range(); assert_eq!(cursor, 4..4); }); }); @@ -3644,7 +4306,7 @@ mod tests { cx.update(|_, cx| { input.read_with(cx, |state, _| { assert_eq!(state.value(), "."); - let cursor: Range = state.selected_range.into(); + let cursor: Range = state.selected_range(); assert_eq!(cursor, 1..1); }); }); @@ -4359,7 +5021,7 @@ mod tests { let range = state.range_to_utf16(&(0..1)); state.replace_text_in_range(Some(range), "", window, cx); assert_eq!(state.value(), ".2"); - let cursor: Range = state.selected_range.into(); + let cursor: Range = state.selected_range(); assert_eq!(cursor, 0..0); // The user can type a new integer part. @@ -4423,8 +5085,8 @@ mod tests { state.set_value(value.clone(), window, cx); assert_eq!( - state.selected_range, - Selection::new(len, len), + state.selected_range(), + len..len, "single-line caret should be at the end after set_value" ); assert_eq!( @@ -4475,8 +5137,8 @@ mod tests { state.replace_all(value.clone(), window, cx); assert_eq!(state.value(), value); assert_eq!( - state.selected_range, - Selection::new(len, len), + state.selected_range(), + len..len, "single-line caret should be at the end after replace_all" ); assert_eq!( @@ -4554,8 +5216,8 @@ mod tests { state.replace_all("baz\nqux", window, cx); assert_eq!(state.value(), "baz\nqux"); assert_eq!( - state.selected_range, - Selection::new(0, 0), + state.selected_range(), + 0..0, "multi-line selection should be cleared after replace_all" ); assert_eq!( @@ -4653,6 +5315,142 @@ mod tests { }); } + /// A single-edit batch round-trips through undo/redo. + #[gpui::test] + fn test_replace_text_in_ranges_single_edit(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state.default_value("hello world")); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |s, cx| { + s.replace_text_in_ranges(&[(0..5, "HELLO".to_string())], window, cx); + assert_eq!(s.value(), "HELLO world"); + + s.undo(&Undo, window, cx); + assert_eq!(s.value(), "hello world"); + + s.redo(&Redo, window, cx); + assert_eq!(s.value(), "HELLO world"); + }); + }); + } + + /// A single undo restores the exact original text and a single redo + /// re-applies all edits, verifying the back-to-front application ordering. + #[gpui::test] + fn test_replace_text_in_ranges_multi_edit_transaction(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state.default_value("aaa bbb ccc")); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |s, cx| { + // Two edits at different positions, given in pre-edit + // coordinates and in arbitrary (non-sorted) order. + s.replace_text_in_ranges( + &[(0..3, "X".to_string()), (8..11, "Y".to_string())], + window, + cx, + ); + assert_eq!(s.value(), "X bbb Y"); + + // One collapsed cursor per edit, at the end of each inserted text. + let cursors: Vec = + s.selections.iter().map(|sel| sel.cursor_offset()).collect(); + assert_eq!(cursors, vec![1, 7]); + + // The whole batch is a single undo transaction. + assert_eq!(s.undo_manager.undo_count(), 1); + + // One undo restores the exact original text. + s.undo(&Undo, window, cx); + assert_eq!(s.value(), "aaa bbb ccc"); + + // One redo re-applies all edits. + s.redo(&Redo, window, cx); + assert_eq!(s.value(), "X bbb Y"); + }); + }); + } + + /// An IME composition (marking then commit) undoes as a single unit. + #[gpui::test] + fn test_ime_composition_undoes_as_one_unit(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state.default_value("")); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |s, cx| { + // Simulate an IME composition: mark, refine, then commit. + s.replace_and_mark_text_in_range(None, "n", Some(1..1), window, cx); + s.replace_and_mark_text_in_range(None, "ni", Some(2..2), window, cx); + s.replace_text_in_range(None, "你", window, cx); + assert_eq!(s.value(), "你"); + + // The entire composition is one undo transaction. + assert_eq!(s.undo_manager.undo_count(), 1); + + s.undo(&Undo, window, cx); + assert_eq!(s.value(), ""); + + s.redo(&Redo, window, cx); + assert_eq!(s.value(), "你"); + }); + }); + } + + /// A keystroke right after a committed composition must be its own undo + /// entry, not merged into the (finalized) composition transaction. + #[gpui::test] + fn test_edit_after_composition_is_separate_undo(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state.default_value("")); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |s, cx| { + s.replace_and_mark_text_in_range(None, "n", Some(1..1), window, cx); + s.replace_text_in_range(None, "你", window, cx); + assert_eq!(s.value(), "你"); + assert_eq!(s.undo_manager.undo_count(), 1); + + // Typing after the commit is a distinct transaction. + s.replace_text_in_range(None, "x", window, cx); + assert_eq!(s.value(), "你x"); + assert_eq!(s.undo_manager.undo_count(), 2); + + s.undo(&Undo, window, cx); + assert_eq!(s.value(), "你"); + s.undo(&Undo, window, cx); + assert_eq!(s.value(), ""); + }); + }); + } + + /// Canceling a composition via `unmark_text` closes its transaction so it + /// does not leak and swallow a later edit. + #[gpui::test] + fn test_composition_cancel_via_unmark_does_not_leak(cx: &mut TestAppContext) { + let input_view = InputView::build_textarea(cx, |state| state.default_value("")); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |s, cx| { + // Start a composition, then cancel it via unmark. + s.replace_and_mark_text_in_range(None, "n", Some(1..1), window, cx); + s.unmark_text(window, cx); + let after_cancel = s.undo_manager.undo_count(); + + // A later edit is recorded independently. + s.replace_text_in_range(None, "x", window, cx); + assert_eq!(s.undo_manager.undo_count(), after_cancel + 1); + }); + }); + } + #[gpui::test] fn test_set_selected_range_clips_to_utf8_boundaries(cx: &mut TestAppContext) { let input_view = InputView::build(cx, |state| state.default_value("éx")); @@ -4760,9 +5558,9 @@ mod tests { input.update(cx, |state, cx| { state.replace_and_mark_text_in_range(None, "n", None, window, cx); state.replace_text_in_range(None, "你", window, cx); - state.undo_manager.pending_intent = Some(EditIntent::Typing); + state.undo_manager.set_pending_intent(EditIntent::Typing); state.replace_text_in_range(None, "a", window, cx); - state.undo_manager.pending_intent = Some(EditIntent::Typing); + state.undo_manager.set_pending_intent(EditIntent::Typing); state.replace_text_in_range(None, "b", window, cx); assert_eq!(state.value(), "你ab"); @@ -4866,10 +5664,34 @@ mod tests { cx.update(|window, cx| { input.update(cx, |state, cx| { state.replace_text_in_range(None, "a", window, cx); + state.move_to(0, None, cx); + state.replace_text_in_range(None, "", window, cx); state.undo(&Undo, window, cx); - state.backspace(&Backspace, window, cx); + assert_eq!(state.value(), ""); state.redo(&Redo, window, cx); assert_eq!(state.value(), "a"); + assert_eq!(state.cursor(), 1); + }); + }); + } + + #[gpui::test] + fn test_cursor_round_trip_stops_typing_coalescing(cx: &mut TestAppContext) { + let input_view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(input_view.window_handle.into(), cx); + let input = input_view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + state.left(&MoveLeft, window, cx); + state.right(&MoveRight, window, cx); + state.replace_text_in_range(None, "b", window, cx); + + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "a"); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), ""); }); }); } @@ -5079,6 +5901,1143 @@ mod tests { .input .read_with(&mut editor_cx, |state, _| assert!(state.soft_wrap)); } + + /// Parse a cursor spec into `(text, cursor_offsets)`. Non-empty lines are + /// joined with `\n` plus a trailing `\n`. `|` marks a cursor. Leading + /// whitespace is kept, so a spec can express indentation. + fn parse_cursor_spec(input: &str) -> (String, Vec) { + let mut full_text = String::new(); + let mut cursor_offsets = Vec::new(); + let non_empty_lines: Vec<&str> = input.lines().filter(|l| !l.is_empty()).collect(); + + for (line_idx, line) in non_empty_lines.iter().enumerate() { + let mut positions = Vec::new(); + let mut text = String::new(); + for ch in line.chars() { + if ch == '|' { + positions.push(text.len()); + } else { + text.push(ch); + } + } + + if line_idx > 0 { + full_text.push('\n'); + } + let line_start = full_text.len(); + for pos in positions { + cursor_offsets.push(line_start + pos); + } + full_text.push_str(&text); + } + full_text.push('\n'); + + (full_text, cursor_offsets) + } + + /// Build a multi-line input for multi-cursor tests. + fn multi_line(cx: &mut TestAppContext) -> InputView { + InputView::build_textarea(cx, |state| state) + } + + /// Set the text and cursor positions from a spec (see [`parse_cursor_spec`]). + fn setup_cursors( + cx: &mut VisualTestContext, + input: &Entity>, + spec: &str, + ) { + let (full_text, offsets) = parse_cursor_spec(spec); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value(&full_text, window, cx); + let selections = offsets + .into_iter() + .map(|offset| { + CursorSelection::new(state.selections.generate_id(), offset, offset) + }) + .collect(); + state.selections.replace_all(selections); + cx.notify(); + }); + }); + } + + /// Assert the text and cursor positions match a spec. + #[track_caller] + fn assert_cursors( + cx: &mut VisualTestContext, + input: &Entity>, + spec: &str, + ) { + let (expected_text, mut expected_cursors) = parse_cursor_spec(spec); + expected_cursors.sort(); + + let (actual_text, mut actual_cursors) = input.read_with(cx, |state, _| { + ( + state.text.to_string(), + state + .selections + .iter() + .map(|s| s.cursor_offset()) + .collect::>(), + ) + }); + actual_cursors.sort(); + + assert_eq!( + actual_text, expected_text, + "Text mismatch:\nExpected: {expected_text:?}\nActual: {actual_text:?}" + ); + assert_eq!( + actual_cursors, expected_cursors, + "Cursor mismatch:\nExpected: {expected_cursors:?}\nActual: {actual_cursors:?}" + ); + } + + #[gpui::test] + fn test_alt_drag_selects_a_block_and_replaces_each_row(cx: &mut TestAppContext) { + cx.update(crate::init); + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + for modifiers in [ + gpui::Modifiers { + alt: true, + ..Default::default() + }, + gpui::Modifiers { + alt: true, + shift: true, + ..Default::default() + }, + #[cfg(target_os = "linux")] + gpui::Modifiers { + alt: true, + control: true, + ..Default::default() + }, + ] { + setup_cursors(&mut cx, &view.input, "|abcd\nabcd\nabcd"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| state.focus(window, cx)); + }); + let (start, end) = view.input.read_with(&cx, |state, _| { + let layout = state.last_layout.as_ref().unwrap(); + let origin = state.last_bounds.unwrap().origin; + let position = |row: usize, col| { + let local = layout.lines[row] + .position_for_index(col, layout, false) + .unwrap(); + origin + + point( + layout.line_number_width + local.x, + layout.line_height * (row as f32 + 0.5), + ) + }; + (position(0, 1), position(2, 3)) + }); + // A cached Ctrl-hover definition must not steal a column gesture. + cx.update(|_, cx| { + view.input.update(cx, |state, _| { + state.extras.hover_definition.update( + 0..4, + vec![lsp_types::LocationLink { + origin_selection_range: None, + target_uri: "file:///tmp/column-selection.rs".parse().unwrap(), + target_range: Default::default(), + target_selection_range: Default::default(), + }], + ); + }); + }); + cx.simulate_mouse_down(start, MouseButton::Left, modifiers); + cx.simulate_mouse_move(end, MouseButton::Left, modifiers); + cx.simulate_mouse_up(end, MouseButton::Left, modifiers); + view.input.read_with(&cx, |state, _| { + let ranges: Vec<_> = state + .selections + .iter() + .map(|sel| sel.start..sel.end) + .collect(); + assert_eq!(ranges, vec![1..3, 6..8, 11..13]); + }); + // Moving after release must leave the block intact. + cx.simulate_mouse_move(start, None, modifiers); + cx.simulate_keystrokes("x"); + assert_cursors(&mut cx, &view.input, "ax|d\nax|d\nax|d"); + } + } + + #[gpui::test] + fn test_alt_drag_extends_upward_from_an_existing_cursor(cx: &mut TestAppContext) { + cx.update(crate::init); + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "abcd\nabcd\na|bcd"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| state.focus(window, cx)); + }); + let (start, end) = view.input.read_with(&cx, |state, _| { + let layout = state.last_layout.as_ref().unwrap(); + let origin = state.last_bounds.unwrap().origin; + let local = layout.lines[0] + .position_for_index(1, layout, false) + .unwrap(); + let x = layout.line_number_width + local.x; + ( + origin + point(x, layout.line_height * 2.5), + origin + point(x, layout.line_height * 0.5), + ) + }); + let modifiers = gpui::Modifiers { + alt: true, + ..Default::default() + }; + cx.simulate_mouse_down(start, MouseButton::Left, modifiers); + cx.simulate_mouse_move(end, MouseButton::Left, modifiers); + cx.simulate_mouse_up(end, MouseButton::Left, modifiers); + assert_cursors(&mut cx, &view.input, "a|bcd\na|bcd\na|bcd"); + } + + #[gpui::test] + fn test_alt_mouse_release_outside_editor_ends_column_selection(cx: &mut TestAppContext) { + cx.update(crate::init); + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "|abcd\nabcd"); + let position = view.input.read_with(&cx, |state, _| { + let layout = state.last_layout.as_ref().unwrap(); + state.last_bounds.unwrap().origin + + point(layout.line_number_width + px(2.), layout.line_height * 0.5) + }); + let modifiers = gpui::Modifiers { + alt: true, + ..Default::default() + }; + cx.simulate_mouse_down(position, MouseButton::Left, modifiers); + cx.simulate_mouse_up(point(px(-100.), px(-100.)), MouseButton::Left, modifiers); + view.input.read_with(&cx, |state, _| { + assert!(!state.selecting); + assert!(state.column_select_start.is_none()); + }); + } + + #[gpui::test] + fn test_consumed_keystrokes_keep_cursor_visible(cx: &mut TestAppContext) { + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "a|b"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { + state.focus(window, cx); + state.pause_blink_cursor(cx); + }); + }); + cx.run_until_parked(); + cx.executor() + .advance_clock(std::time::Duration::from_millis(300)); + cx.run_until_parked(); + view.input.read_with(&cx, |state, cx| { + assert!(!state.blink_cursor.read(cx).visible()); + }); + // Copy consumes its shortcut without editing text or moving selections. + for _ in 0..5 { + #[cfg(target_os = "macos")] + cx.simulate_keystrokes("cmd-c"); + #[cfg(not(target_os = "macos"))] + cx.simulate_keystrokes("ctrl-c"); + cx.run_until_parked(); + cx.executor() + .advance_clock(std::time::Duration::from_millis(200)); + cx.run_until_parked(); + view.input.read_with(&cx, |state, cx| { + assert!(state.blink_cursor.read(cx).visible()); + }); + } + } + + #[gpui::test] + fn test_multi_cursor_actions_reveal_hidden_carets(cx: &mut TestAppContext) { + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "ab\na|b\nab"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { + // Start each action in the hidden phase without depending on a + // key-down listener: actions and text input also arrive directly. + for action in 0..6 { + state.blink_cursor = cx.new(|_| BlinkCursor::new()); + assert!(!state.blink_cursor.read(cx).visible()); + match action { + 0 => state.add_cursor_above(&AddCursorAbove, window, cx), + 1 => state.add_cursor_below(&AddCursorBelow, window, cx), + 2 => state.select_up(&SelectUp, window, cx), + 3 => state.select_down(&SelectDown, window, cx), + 4 => state.replace_text_in_range(None, "x", window, cx), + _ => state.backspace(&Backspace, window, cx), + } + assert!(state.blink_cursor.read(cx).visible(), "action {action}"); + } + }); + }); + } + + #[gpui::test] + fn test_multi_cursor_keyboard_dispatch(cx: &mut TestAppContext) { + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "ab\na|b\nab"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| state.focus(window, cx)); + }); + #[cfg(target_os = "macos")] + cx.simulate_keystrokes("cmd-alt-up"); + #[cfg(target_os = "windows")] + cx.simulate_keystrokes("ctrl-alt-up"); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + cx.simulate_keystrokes("alt-shift-up"); + view.input + .read_with(&cx, |state, _| assert_eq!(state.selections.len(), 2)); + #[cfg(target_os = "macos")] + cx.simulate_keystrokes("cmd-alt-down"); + #[cfg(target_os = "windows")] + cx.simulate_keystrokes("ctrl-alt-down"); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + cx.simulate_keystrokes("alt-shift-down"); + view.input + .read_with(&cx, |state, _| assert_eq!(state.selections.len(), 3)); + cx.simulate_keystrokes("x"); + assert_cursors(&mut cx, &view.input, "ax|b\nax|b\nax|b"); + } + + #[gpui::test] + fn test_multi_cursor_platform_word_selection_dispatch(cx: &mut TestAppContext) { + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "one |two\none |two"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| state.focus(window, cx)); + }); + #[cfg(any(target_os = "macos", target_os = "linux"))] + cx.simulate_keystrokes("alt-shift-right"); + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + cx.simulate_keystrokes("ctrl-shift-right"); + cx.simulate_keystrokes("x"); + assert_cursors(&mut cx, &view.input, "one x|\none x|"); + setup_cursors(&mut cx, &view.input, "one two|\none two|"); + #[cfg(any(target_os = "macos", target_os = "linux"))] + cx.simulate_keystrokes("alt-shift-left"); + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + cx.simulate_keystrokes("ctrl-shift-left"); + cx.simulate_keystrokes("x"); + assert_cursors(&mut cx, &view.input, "one x|\none x|"); + } + + #[cfg(not(target_os = "macos"))] + #[gpui::test] + fn test_multi_cursor_horizontal_selection_dispatch(cx: &mut TestAppContext) { + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "ab\na|b"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| state.focus(window, cx)); + }); + #[cfg(target_os = "windows")] + cx.simulate_keystrokes("ctrl-alt-up"); + #[cfg(not(target_os = "windows"))] + cx.simulate_keystrokes("alt-shift-up"); + #[cfg(target_os = "linux")] + cx.simulate_keystrokes("shift-right"); + #[cfg(not(target_os = "linux"))] + cx.simulate_keystrokes("alt-shift-right"); + view.input.read_with(&cx, |state, _| { + assert_eq!( + state + .selections + .iter() + .map(|s| s.start..s.end) + .collect::>(), + vec![4..5, 1..2] + ); + }); + #[cfg(target_os = "linux")] + cx.simulate_keystrokes("shift-left shift-left"); + #[cfg(not(target_os = "linux"))] + cx.simulate_keystrokes("alt-shift-left alt-shift-left"); + view.input.read_with(&cx, |state, _| { + assert_eq!( + state + .selections + .iter() + .map(|s| s.start..s.end) + .collect::>(), + vec![3..4, 0..1] + ); + }); + cx.simulate_keystrokes("x"); + assert_cursors(&mut cx, &view.input, "x|b\nx|b"); + } + + #[gpui::test] + fn test_multi_cursor_alt_click_dispatch(cx: &mut TestAppContext) { + cx.update(crate::init); + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "a|b\nab\nab"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| state.focus(window, cx)); + }); + let position = view.input.read_with(&cx, |state, _| { + let bounds = state.last_bounds.unwrap(); + let layout = state.last_layout.as_ref().unwrap(); + bounds.origin + point(layout.line_number_width + px(2.), layout.line_height * 1.5) + }); + cx.simulate_click( + position, + gpui::Modifiers { + alt: true, + ..Default::default() + }, + ); + view.input + .read_with(&cx, |state, _| assert_eq!(state.selections.len(), 2)); + } + + #[gpui::test] + fn test_word_delete_undo_restores_caret(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "hello|"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { + state.delete_previous_word(&DeleteToPreviousWordStart, window, cx); + state.undo(&Undo, window, cx); + assert_eq!(state.selected_range(), 5..5); + }); + }); + } + + #[gpui::test] + fn test_merged_delete_undo_restores_all_carets(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "a|b|c"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { + state.backspace(&Backspace, window, cx); + state.undo(&Undo, window, cx); + }); + }); + assert_cursors(&mut cx, &view.input, "a|b|c"); + } + + #[gpui::test] + fn test_shift_end_respects_soft_wrap_end(cx: &mut TestAppContext) { + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { + state.set_value("abcdef ".repeat(30), window, cx); + state.display_map.on_layout_changed(Some(px(60.)), cx); + let line = state.display_map.line(0).unwrap(); + assert!(line.wrapped_lines.len() > 1); + let boundary = line.wrapped_lines[0].end; + state.move_to_with_affinity(boundary, None, true, cx); + state.select_to_end_of_line(&SelectToEndOfLine, window, cx); + assert_eq!(state.selected_range(), boundary..state.text.len()); + }); + }); + } + + #[gpui::test] + fn test_outdent_unindented_unicode_is_unchanged(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "|你好\n|世界"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { + state.outdent(false, window, cx); + state.outdent(true, window, cx); + }); + }); + assert_cursors(&mut cx, &view.input, "|你好\n|世界"); + } + + #[gpui::test] + fn test_column_selection_stays_on_unicode_boundaries(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "ab\n你好\ncd"); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { + state.build_columnar_selection(1, 11, cx); + let text = state.value(); + for sel in state.selections.iter() { + assert!(text.is_char_boundary(sel.start)); + assert!(text.is_char_boundary(sel.end)); + } + state.replace_text_in_range(None, "X", window, cx); + }); + }); + } + + #[gpui::test] + fn test_editor_decorations_follow_typing(cx: &mut TestAppContext) { + let view = InputView::::new(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { + state.set_value("abc def", window, cx); + state.create_decorations_collection( + vec![crate::input::TextDecoration::new( + 4..7, + gpui::HighlightStyle::default(), + )], + cx, + ); + state.set_selected_range(0..0, cx); + state.replace_text_in_range(None, "X", window, cx); + let layers = state.extras.decoration_layers(); + assert_eq!(layers.into_iter().flatten().next().unwrap().range, 5..8); + }); + }); + } + + #[gpui::test] + fn test_block_indent_tracks_all_preceding_edits(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "|ab\n|cd\n|ef"); + cx.update(|window, cx| { + view.input + .update(cx, |state, cx| state.indent(true, window, cx)); + }); + assert_cursors(&mut cx, &view.input, " |ab\n |cd\n |ef"); + cx.update(|window, cx| { + view.input + .update(cx, |state, cx| state.outdent(true, window, cx)); + }); + assert_cursors(&mut cx, &view.input, "|ab\n|cd\n|ef"); + } + + #[gpui::test] + fn test_block_outdent_clamps_cursor_inside_indent(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + setup_cursors(&mut cx, &view.input, "ab\n | cd"); + cx.update(|window, cx| { + view.input + .update(cx, |state, cx| state.outdent(true, window, cx)); + }); + assert_cursors(&mut cx, &view.input, "ab\n|cd"); + } + + #[gpui::test] + fn test_ime_restores_original_selection(cx: &mut TestAppContext) { + let view = InputView::build(cx, |state| state); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { + state.set_value("abc", window, cx); + state.set_selected_range(1..2, cx); + state.replace_and_mark_text_in_range(None, "ni", None, window, cx); + state.replace_text_in_range(None, "你", window, cx); + state.undo(&Undo, window, cx); + assert_eq!(state.value(), "abc"); + assert_eq!(state.selected_range(), 1..2); + state.redo(&Redo, window, cx); + assert_eq!(state.selected_range(), 4..4); + }); + }); + } + + #[gpui::test] + fn test_noop_does_not_change_redo_selection(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + cx.update(|window, cx| { + view.input.update(cx, |state, cx| { + state.set_value("abc", window, cx); + state.set_selected_range(1..1, cx); + state.replace_text_in_range(None, "X", window, cx); + state.set_selected_range(0..0, cx); + state.replace_text_in_range(None, "", window, cx); + state.undo(&Undo, window, cx); + state.redo(&Redo, window, cx); + assert_eq!(state.value(), "aXbc"); + assert_eq!(state.selected_range(), 2..2); + }); + }); + } + + #[gpui::test] + fn test_multi_cursor_insert_text(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "|hello |world|"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, ">>>", window, cx); + }); + }); + assert_cursors(&mut cx, &input, ">>>|hello >>>|world>>>|"); + } + + #[gpui::test] + fn test_multi_cursor_delete_backward(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "|islands| cars|"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.backspace(&Backspace, window, cx); + }); + }); + // The first cursor has nothing to delete. The others delete an `s`. + assert_cursors(&mut cx, &input, "|island| car|"); + } + + #[gpui::test] + fn test_multi_cursor_delete_forward_merges(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "hello| |world"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.delete(&Delete, window, cx); + }); + }); + // Adjacent deletions merge into a single cursor. + assert_cursors(&mut cx, &input, "hello|orld"); + } + + #[gpui::test] + fn test_multi_cursor_multiline_insert_and_delete(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "|1\n|2\n|3"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + }); + }); + assert_cursors(&mut cx, &input, "a|1\na|2\na|3"); + + // The whole multi-edit insert is a single undo transaction. + input.read_with(&cx, |state, _| { + assert_eq!(state.undo_manager.undo_count(), 1); + }); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.backspace(&Backspace, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "|1\n|2\n|3"); + } + + #[gpui::test] + fn test_add_cursor_below_preserves_column(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "ab|cd\nabcd"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.add_cursor_below(&AddCursorBelow, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "ab|cd\nab|cd"); + } + + #[gpui::test] + fn test_add_cursor_at_rejects_duplicates(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "he|llo"); + cx.update(|_, cx| { + input.update(cx, |state, cx| { + // Duplicate of the existing cursor is rejected. + state.add_cursor_at(2, cx); + assert_eq!(state.selections.len(), 1); + // A distinct offset adds a cursor. + state.add_cursor_at(4, cx); + assert_eq!(state.selections.len(), 2); + }); + }); + } + + #[gpui::test] + fn test_multi_cursor_undo_redo_restores_selections(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "|1\n|2\n|3"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + }); + }); + assert_cursors(&mut cx, &input, "a|1\na|2\na|3"); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.undo(&Undo, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "|1\n|2\n|3"); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.redo(&Redo, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "a|1\na|2\na|3"); + } + + #[gpui::test] + fn test_multi_cursor_undo_redo_different_line_lengths(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "abc123|\nabc12345|\nabc1234567|"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + }); + }); + assert_cursors(&mut cx, &input, "abc123a|\nabc12345a|\nabc1234567a|"); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.undo(&Undo, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "abc123|\nabc12345|\nabc1234567|"); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.redo(&Redo, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "abc123a|\nabc12345a|\nabc1234567a|"); + } + + #[gpui::test] + fn test_multi_cursor_undo_multiple_inserts(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "|1\n|2\n|3"); + for ch in ['a', 'b', 'c'] { + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, &ch.to_string(), window, cx); + }); + }); + } + assert_cursors(&mut cx, &input, "abc|1\nabc|2\nabc|3"); + + // The repeated keystrokes form one typing gesture. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + assert_eq!(state.undo_manager.undo_count(), 1); + state.undo(&Undo, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "|1\n|2\n|3"); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.redo(&Redo, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "abc|1\nabc|2\nabc|3"); + } + + #[gpui::test] + fn test_multi_cursor_backspace_run_is_one_undo(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "abc|1\nabc|2\nabc|3"); + for _ in 0..3 { + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.backspace(&Backspace, window, cx); + }); + }); + } + assert_cursors(&mut cx, &input, "|1\n|2\n|3"); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + assert_eq!(state.undo_manager.undo_count(), 1); + state.undo(&Undo, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "abc|1\nabc|2\nabc|3"); + } + + #[gpui::test] + fn test_adding_a_cursor_splits_the_typing_gesture(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "|1\n2\n3"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "a", window, cx); + state.add_cursor_below(&AddCursorBelow, window, cx); + state.replace_text_in_range(None, "b", window, cx); + }); + }); + assert_cursors(&mut cx, &input, "ab|1\n2b|\n3"); + + // The keystroke after the cursor was added is its own undo entry. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.undo(&Undo, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "a|1\n2|\n3"); + } + + #[gpui::test] + fn test_multi_cursor_indent_is_one_undo(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "|1\n|2\n|3"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.indent_inline(&IndentInline, window, cx); + assert_eq!(state.undo_manager.undo_count(), 1); + state.undo(&Undo, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "|1\n|2\n|3"); + } + + #[gpui::test] + fn test_multi_cursor_indent_then_outdent_roundtrips(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + // Cursors at line starts. + setup_cursors(&mut cx, &input, "|1\n|2\n|3"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.indent(false, window, cx); + }); + }); + assert_cursors(&mut cx, &input, " |1\n |2\n |3"); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.outdent(false, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "|1\n|2\n|3"); + } + + #[gpui::test] + fn test_inline_outdent_only_removes_line_indentation(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + // A mid-line indent lands at the cursor, and the outdent does not + // take it back: it only ever removes leading line indentation. + setup_cursors(&mut cx, &input, "1|2\n1|2"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.indent(false, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "1 |2\n1 |2"); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.outdent(false, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "1 |2\n1 |2"); + + // A line with leading indentation loses that, wherever the cursor is. + setup_cursors(&mut cx, &input, " 1|2\n 1|2"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.outdent(false, window, cx); + }); + }); + assert_cursors(&mut cx, &input, "1|2\n1|2"); + } + + #[gpui::test] + fn test_readonly_multi_cursor_commands_leave_state_unchanged(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "a|b\nc|d"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + let before: Vec<_> = state.selections.iter().copied().collect(); + state.set_readonly(true, cx); + state.backspace(&Backspace, window, cx); + state.indent_inline(&IndentInline, window, cx); + + assert_eq!(state.value(), "ab\ncd\n"); + assert_eq!(state.selections.iter().copied().collect::>(), before); + }); + }); + } + + #[gpui::test] + fn test_multi_cursor_edit_preserves_the_active_cursor(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "x|\n|y"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + let mut selections: Vec<_> = state.selections.iter().copied().collect(); + selections.swap(0, 1); + state.selections.replace_all(selections); + let active_id = state.active_selection().id; + state.replace_text_in_range(None, "!", window, cx); + assert_eq!(state.active_selection().id, active_id); + }); + }); + } + + #[gpui::test] + fn test_block_indent_outdent_with_selection(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.set_value("line1\nline2\nline3", window, cx); + let id = state.selections.generate_id(); + state + .selections + .replace_all(vec![CursorSelection::new(id, 0, 17)]); + cx.notify(); + }); + }); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.indent(true, window, cx); + }); + }); + input.read_with(&cx, |state, _| { + assert_eq!(state.text.to_string(), " line1\n line2\n line3"); + }); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.outdent(true, window, cx); + }); + }); + input.read_with(&cx, |state, _| { + assert_eq!(state.text.to_string(), "line1\nline2\nline3"); + }); + } + + #[gpui::test] + fn test_multi_cursor_word_movement(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors( + &mut cx, + &input, + "on|e two three\none t|wo three\non|e two three", + ); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.move_to_next_word(&MoveToNextWord, window, cx); + }); + }); + assert_cursors( + &mut cx, + &input, + "one| two three\none two| three\none| two three", + ); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.move_to_previous_word(&MoveToPreviousWord, window, cx); + }); + }); + assert_cursors( + &mut cx, + &input, + "|one two three\none |two three\n|one two three", + ); + + // Move to end/start of document collapses to a single cursor. + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.move_to_end(&MoveToEnd, window, cx); + }); + }); + input.read_with(&cx, |state, _| { + let cursors: Vec = state.selections.iter().map(|s| s.cursor_offset()).collect(); + assert_eq!(cursors, vec![state.text.len()]); + }); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.move_to_start(&MoveToStart, window, cx); + }); + }); + input.read_with(&cx, |state, _| { + let cursors: Vec = state.selections.iter().map(|s| s.cursor_offset()).collect(); + assert_eq!(cursors, vec![0]); + }); + } + + #[gpui::test] + fn test_multi_cursor_selection_commands(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors( + &mut cx, + &input, + "on|e two three\none t|wo three\non|e two three", + ); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.select_to_start_of_line(&SelectToStartOfLine, window, cx); + }); + }); + assert_cursors( + &mut cx, + &input, + "|one two three\n|one two three\n|one two three", + ); + + // Select to document start collapses to the active cursor only. + setup_cursors( + &mut cx, + &input, + "on|e two three\none t|wo three\non|e two three", + ); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.select_to_start(&SelectToStart, window, cx); + }); + }); + input.read_with(&cx, |state, _| { + let cursors: Vec = state.selections.iter().map(|s| s.cursor_offset()).collect(); + assert_eq!(cursors, vec![0]); + }); + } + + #[gpui::test] + fn test_multi_cursor_replace_selection(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "|a\n|b\n|c"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.select_right(&SelectRight, window, cx); + }); + }); + input.read_with(&cx, |state, _| { + let ranges: Vec<_> = state.selections.iter().map(|s| (s.start, s.end)).collect(); + assert_eq!(ranges, vec![(0, 1), (2, 3), (4, 5)]); + }); + + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.replace_text_in_range(None, "x", window, cx); + }); + }); + assert_cursors(&mut cx, &input, "x|\nx|\nx|"); + } + + #[gpui::test] + fn test_multi_cursor_escape_collapses(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "|a\n|b\n|c"); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.escape(&Escape, window, cx); + }); + }); + input.read_with(&cx, |state, _| { + assert_eq!(state.selections.len(), 1); + }); + } + + #[gpui::test] + fn test_build_columnar_selection(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "abcd\nabcd\nabcd"); + cx.update(|_, cx| { + input.update(cx, |state, cx| { + // From row 0 col 1 to row 2 col 3. + state.build_columnar_selection(1, 13, cx); + let ranges: Vec<_> = state.selections.iter().map(|s| (s.start, s.end)).collect(); + assert_eq!(ranges, vec![(1, 3), (6, 8), (11, 13)]); + }); + }); + } + + #[gpui::test] + fn test_multi_cursor_paste_distributes_lines(cx: &mut TestAppContext) { + let view = multi_line(cx); + let mut cx = VisualTestContext::from_window(view.window_handle.into(), cx); + let input = view.input; + + setup_cursors(&mut cx, &input, "|1\n|2\n|3"); + cx.update(|_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string("x\ny\nz".to_string())); + }); + cx.update(|window, cx| { + input.update(cx, |state, cx| { + state.paste(&Paste, window, cx); + }); + }); + // One clipboard line per cursor. + assert_cursors(&mut cx, &input, "x|1\ny|2\nz|3"); + } } /// Methods that only a single-line input offers. diff --git a/crates/base/src/input/base/undo_manager.rs b/crates/base/src/input/base/undo_manager.rs index 15d89ed896..1ed55a6365 100644 --- a/crates/base/src/input/base/undo_manager.rs +++ b/crates/base/src/input/base/undo_manager.rs @@ -1,5 +1,7 @@ use crate::input::change::Change; +use super::cursor::CursorSelection; + const MAX_UNDO_TRANSACTIONS: usize = 1000; const MAX_CHANGES_PER_TRANSACTION: usize = 1000; @@ -15,23 +17,49 @@ pub(crate) enum EditIntent { struct UndoTransaction { intent: EditIntent, changes: Vec, + /// How many changes the most recently appended batch contributed. A batch + /// is one logical edit with one change per cursor. Only a following batch + /// of the same length can coalesce into this transaction. + last_batch_len: usize, + /// The cursors as they stood before this transaction, restored on undo. + selections_before: Option>, + /// The cursors as they stood after it, restored on redo. + selections_after: Option>, +} + +/// A batch of changes being collected between `begin_transaction` and the +/// matching `commit_transaction`. +#[derive(Debug)] +struct PendingTransaction { + intent: EditIntent, + changes: Vec, + selections_before: Option>, + selections_after: Option>, +} + +/// One transaction handed back to be replayed, with the cursors to restore +/// once the changes have been applied. +pub(crate) struct Replay { + pub(super) changes: Vec, + pub(super) selections: Option>, } /// Coordinates undo and redo as explicit editing transactions. /// /// Each edit first creates a transaction. Compatible adjacent transactions /// may then coalesce until an explicit boundary is encountered. Callers that -/// perform one logical edit through several callbacks (currently IME -/// composition) bracket those changes with `begin_transaction` and -/// `commit_transaction`. +/// perform one logical edit through several changes (IME composition, and any +/// multi-cursor edit) bracket those changes with `begin_transaction` and +/// `commit_transaction`. The bracket nests, so an outer caller can group +/// several already-bracketed edits into one undo entry. #[derive(Debug)] pub(crate) struct UndoManager { undo_transactions: Vec, redo_transactions: Vec, ignoring: bool, - transaction_open: bool, - pending_change: Option, - pub(crate) pending_intent: Option, + transaction_depth: usize, + pending: Option, + pending_intent: Option, coalescing_boundary: bool, } @@ -41,74 +69,114 @@ impl UndoManager { undo_transactions: Vec::new(), redo_transactions: Vec::new(), ignoring: false, - transaction_open: false, - pending_change: None, + transaction_depth: 0, + pending: None, pending_intent: None, coalescing_boundary: false, } } - pub(super) fn record_transaction(&mut self, change: Change, intent: EditIntent) { + /// The intent requested for the next recorded change, taken by the edit + /// that records it. + pub(super) fn take_pending_intent(&mut self) -> Option { + self.pending_intent.take() + } + + /// Request the intent to record the next change with. + pub(super) fn set_pending_intent(&mut self, intent: EditIntent) { + self.pending_intent = Some(intent); + } + + pub(super) fn record_transaction(&mut self, change: Change, intent: EditIntent) -> bool { if self.ignoring { - return; + return false; } if change.old_range == change.new_range && change.old_text == change.new_text { self.break_transaction_coalescing(); - return; + return false; } - if self.transaction_open { - if let Some(pending) = self.pending_change.as_mut() { - pending.new_range = change.new_range; - pending.new_text = change.new_text; - pending.selection_after = change.selection_after; - } else { - self.pending_change = Some(change); - } - } else { - self.push_transaction(change, intent); + match self.pending.as_mut() { + Some(pending) => pending.changes.push(change), + None => self.push_batch(vec![change], intent), } + true } + /// Open a transaction whose changes commit as one atomic undo entry. pub(super) fn begin_transaction(&mut self) { - if self.transaction_open { - return; + self.begin_transaction_with(EditIntent::Atomic); + } + + /// Open a transaction that commits with `intent`, so a following batch of + /// the same intent and shape can coalesce into it. Multi-cursor typing uses + /// this to group a burst of keystrokes the way single-cursor typing does. + /// + /// Only the outermost bracket decides the intent. A nested + /// `begin_transaction` merely keeps the batch open. + pub(super) fn begin_transaction_with(&mut self, intent: EditIntent) { + self.transaction_depth += 1; + if self.transaction_depth == 1 { + self.pending = Some(PendingTransaction { + intent, + changes: Vec::new(), + selections_before: None, + selections_after: None, + }); } - self.transaction_open = true; - self.pending_change = None; } pub(super) fn commit_transaction(&mut self) { - if !self.transaction_open { + if self.transaction_depth == 0 { return; } - self.transaction_open = false; - if let Some(change) = self.pending_change.take() - && (change.old_range != change.new_range || change.old_text != change.new_text) - { - self.push_transaction(change, EditIntent::Atomic); + + self.transaction_depth -= 1; + if self.transaction_depth > 0 { + return; + } + + let Some(pending) = self.pending.take() else { + return; + }; + // A composition that ends where it started (typed then canceled) leaves + // the document untouched and must not become an undo entry. + if pending.changes.is_empty() || is_noop_batch(&pending.changes) { + return; + } + self.push_batch(pending.changes, pending.intent); + if let Some(before) = pending.selections_before { + self.record_selections_before(before); + } + if let Some(after) = pending.selections_after { + self.record_selections_after(after); } } - fn push_transaction(&mut self, change: Change, intent: EditIntent) { + /// Push one logical edit, which is one or more changes in application + /// order, onto the undo stack. + fn push_batch(&mut self, changes: Vec, intent: EditIntent) { + if changes.is_empty() { + return; + } + self.redo_transactions.clear(); let can_coalesce = !self.coalescing_boundary && intent != EditIntent::Atomic && self.undo_transactions.last().is_some_and(|previous| { previous.intent == intent - && previous.changes.len() < MAX_CHANGES_PER_TRANSACTION - && previous - .changes - .last() - .is_some_and(|last| is_adjacent(intent, last, &change)) + && previous.last_batch_len == changes.len() + && previous.changes.len() + changes.len() <= MAX_CHANGES_PER_TRANSACTION + && is_adjacent_batch(intent, previous.trailing_batch(), &changes) }); if can_coalesce { - self.undo_transactions + let previous = self + .undo_transactions .last_mut() - .expect("coalescing requires a previous transaction") - .changes - .push(change); + .expect("coalescing requires a previous transaction"); + previous.last_batch_len = changes.len(); + previous.changes.extend(changes); return; } @@ -117,16 +185,70 @@ impl UndoManager { } self.undo_transactions.push(UndoTransaction { intent, - changes: vec![change], + last_batch_len: changes.len(), + changes, + selections_before: None, + selections_after: None, }); self.coalescing_boundary = intent == EditIntent::Atomic; } + /// Record the cursors around the transaction being built, or around the + /// most recent one when no bracket is open. + /// + /// A transaction keeps the cursors it was entered with, so a coalescing + /// burst of keystrokes still undoes to where the burst began. + pub(super) fn record_selections( + &mut self, + before: Vec, + after: Vec, + ) { + if self.ignoring { + return; + } + self.record_selections_before(before); + self.record_selections_after(after); + } + + fn record_selections_before(&mut self, before: Vec) { + if let Some(pending) = self.pending.as_mut() { + pending.selections_before.get_or_insert(before); + } else if let Some(transaction) = self.undo_transactions.last_mut() { + transaction.selections_before.get_or_insert(before); + } + } + + fn record_selections_after(&mut self, after: Vec) { + if let Some(pending) = self.pending.as_mut() { + pending.selections_after = Some(after); + } else if let Some(transaction) = self.undo_transactions.last_mut() { + transaction.selections_after = Some(after); + } + } + + /// True while a `begin_transaction` bracket is collecting changes. + pub(super) fn has_open_transaction(&self) -> bool { + self.transaction_depth > 0 + } + pub(super) fn break_transaction_coalescing(&mut self) { - self.commit_transaction(); + // While a batch is open the boundary applies to that batch, so never + // close a bracket the caller still owns. + if self.transaction_depth == 0 { + self.commit_all_transactions(); + } self.coalescing_boundary = true; } + /// Close every open bracket, committing whatever they collected. + fn commit_all_transactions(&mut self) { + if self.transaction_depth == 0 { + return; + } + self.transaction_depth = 1; + self.commit_transaction(); + } + pub(super) fn is_ignoring(&self) -> bool { self.ignoring } @@ -134,41 +256,110 @@ impl UndoManager { pub(super) fn set_ignoring(&mut self, ignoring: bool) { self.ignoring = ignoring; if ignoring { - self.commit_transaction(); + self.commit_all_transactions(); } } pub(super) fn clear(&mut self) { self.undo_transactions.clear(); self.redo_transactions.clear(); - self.transaction_open = false; - self.pending_change = None; + self.transaction_depth = 0; + self.pending = None; self.pending_intent = None; self.coalescing_boundary = false; } - pub(super) fn undo(&mut self) -> Option> { - self.commit_transaction(); + pub(super) fn undo(&mut self) -> Option { + self.commit_all_transactions(); let transaction = self.undo_transactions.pop()?; - let changes = transaction.changes.iter().rev().cloned().collect(); + let replay = Replay { + changes: transaction.changes.iter().rev().cloned().collect(), + selections: transaction.selections_before.clone(), + }; self.redo_transactions.push(transaction); self.coalescing_boundary = true; - Some(changes) + Some(replay) } - pub(super) fn redo(&mut self) -> Option> { - self.commit_transaction(); + pub(super) fn redo(&mut self) -> Option { + self.commit_all_transactions(); let transaction = self.redo_transactions.pop()?; - let changes = transaction.changes.clone(); + let replay = Replay { + changes: transaction.changes.clone(), + selections: transaction.selections_after.clone(), + }; self.undo_transactions.push(transaction); self.coalescing_boundary = true; - Some(changes) + Some(replay) } #[cfg(test)] pub(super) fn has_undos(&self) -> bool { !self.undo_transactions.is_empty() } + + #[cfg(test)] + pub(super) fn undo_count(&self) -> usize { + self.undo_transactions.len() + } +} + +impl UndoTransaction { + /// The changes contributed by the most recent batch, which are the ones a + /// following batch has to be adjacent to. + fn trailing_batch(&self) -> &[Change] { + &self.changes[self.changes.len() - self.last_batch_len..] + } +} + +/// True when a chain of changes that each rewrite the region the previous one +/// produced leaves the document exactly as it was found. +/// +/// Changes that do not form such a chain (multi-cursor batches, for one) always +/// report `false`, so this only ever collapses the single-region case. +fn is_noop_batch(changes: &[Change]) -> bool { + let Some(first) = changes.first() else { + return true; + }; + + let mut last = first; + for change in &changes[1..] { + if last.new_range.start != change.old_range.start + || last.new_range.end != change.old_range.end + { + return false; + } + last = change; + } + + first.old_range.start == last.new_range.start + && first.old_range.end == last.new_range.end + && first.old_text == last.new_text +} + +/// True when every change in `current` continues the change at the same +/// position in `previous`, so the two batches are one editing gesture. +/// +/// A batch applies from the highest offset down, so each of its changes is +/// still to be shifted by the ones that follow it before the next batch, made +/// against the settled document, can line up with it. +fn is_adjacent_batch(intent: EditIntent, previous: &[Change], current: &[Change]) -> bool { + if previous.len() != current.len() { + return false; + } + + let mut shifts = vec![0isize; previous.len()]; + let mut shift = 0isize; + for (index, change) in previous.iter().enumerate().rev() { + shifts[index] = shift; + shift += change.new_text.len() as isize - change.old_text.len() as isize; + } + + previous + .iter() + .zip(current) + .zip(shifts) + .all(|((previous, current), shift)| is_adjacent(intent, &previous.shifted(shift), current)) } fn is_adjacent(intent: EditIntent, previous: &Change, current: &Change) -> bool { @@ -197,18 +388,10 @@ fn is_adjacent(intent: EditIntent, previous: &Change, current: &Change) -> bool #[cfg(test)] mod tests { use super::*; - use crate::input::Selection; fn typing_change(offset: usize, text: &str) -> Change { let end = offset + text.len(); - Change::new( - offset..offset, - "", - offset..end, - text, - Selection::new(offset, offset), - Selection::new(end, end), - ) + Change::new(offset..offset, "", offset..end, text) } #[test] @@ -217,7 +400,7 @@ mod tests { manager.record_transaction(typing_change(0, "a"), EditIntent::Typing); manager.record_transaction(typing_change(1, "b"), EditIntent::Typing); - assert_eq!(manager.undo().unwrap().len(), 2); + assert_eq!(manager.undo().unwrap().changes.len(), 2); assert!(manager.undo().is_none()); } @@ -226,12 +409,81 @@ mod tests { let mut manager = UndoManager::new(); manager.begin_transaction(); manager.record_transaction(typing_change(0, "a"), EditIntent::Typing); - manager.record_transaction(typing_change(0, "ab"), EditIntent::Typing); + manager.record_transaction(typing_change(1, "b"), EditIntent::Typing); + manager.commit_transaction(); + + // One undo entry that replays its changes in reverse application + // order. + let transaction = manager.undo().unwrap().changes; + assert_eq!(transaction.len(), 2); + assert_eq!(transaction[0].new_text, "b"); + assert_eq!(transaction[1].new_text, "a"); + assert!(manager.undo().is_none()); + } + + #[test] + fn nested_transactions_commit_as_one_entry() { + let mut manager = UndoManager::new(); + manager.begin_transaction(); + manager.record_transaction(typing_change(0, "a"), EditIntent::Typing); + manager.begin_transaction(); + manager.record_transaction(typing_change(1, "b"), EditIntent::Typing); + manager.commit_transaction(); + // The inner bracket does not close the transaction. + assert!(!manager.has_undos()); + manager.record_transaction(typing_change(2, "c"), EditIntent::Typing); + manager.commit_transaction(); + + assert_eq!(manager.undo().unwrap().changes.len(), 3); + assert!(manager.undo().is_none()); + } + + #[test] + fn batches_of_the_same_shape_and_intent_coalesce() { + let mut manager = UndoManager::new(); + + // Two cursors typing "a" then "b", as a multi-cursor keystroke burst. + // A batch applies from the highest offset down, and the second burst + // sees the offsets the first one left behind. + manager.begin_transaction_with(EditIntent::Typing); + manager.record_transaction(typing_change(5, "a"), EditIntent::Typing); + manager.record_transaction(typing_change(0, "a"), EditIntent::Typing); + manager.commit_transaction(); + manager.begin_transaction_with(EditIntent::Typing); + manager.record_transaction(typing_change(7, "b"), EditIntent::Typing); + manager.record_transaction(typing_change(1, "b"), EditIntent::Typing); + manager.commit_transaction(); + + assert_eq!(manager.undo().unwrap().changes.len(), 4); + assert!(manager.undo().is_none()); + } + + #[test] + fn a_batch_does_not_coalesce_into_a_differently_shaped_one() { + let mut manager = UndoManager::new(); + + manager.begin_transaction_with(EditIntent::Typing); + manager.record_transaction(typing_change(5, "a"), EditIntent::Typing); + manager.record_transaction(typing_change(0, "a"), EditIntent::Typing); + manager.commit_transaction(); + // One cursor left: a single change cannot continue a two-cursor batch. + manager.record_transaction(typing_change(1, "b"), EditIntent::Typing); + + assert_eq!(manager.undo().unwrap().changes.len(), 1); + assert_eq!(manager.undo().unwrap().changes.len(), 2); + } + + #[test] + fn an_atomic_batch_never_coalesces() { + let mut manager = UndoManager::new(); + + manager.begin_transaction(); + manager.record_transaction(typing_change(0, "a"), EditIntent::Typing); manager.commit_transaction(); + manager.record_transaction(typing_change(1, "b"), EditIntent::Typing); - let transaction = manager.undo().unwrap(); - assert_eq!(transaction.len(), 1); - assert_eq!(transaction[0].new_text, "ab"); + assert_eq!(manager.undo().unwrap().changes.len(), 1); + assert_eq!(manager.undo().unwrap().changes.len(), 1); } #[test] @@ -256,8 +508,11 @@ mod tests { manager.record_transaction(typing_change(offset, "a"), EditIntent::Typing); } - assert_eq!(manager.undo().unwrap().len(), 100); - assert_eq!(manager.undo().unwrap().len(), MAX_CHANGES_PER_TRANSACTION); + assert_eq!(manager.undo().unwrap().changes.len(), 100); + assert_eq!( + manager.undo().unwrap().changes.len(), + MAX_CHANGES_PER_TRANSACTION + ); assert!(manager.undo().is_none()); } } diff --git a/crates/base/src/input/editor/display_map/display_map.rs b/crates/base/src/input/editor/display_map/display_map.rs index c2a6b8c555..15f7f6223d 100644 --- a/crates/base/src/input/editor/display_map/display_map.rs +++ b/crates/base/src/input/editor/display_map/display_map.rs @@ -324,6 +324,16 @@ impl DisplayMap { self.fold_map.display_row_to_wrap_row(display_row) } + pub(crate) fn display_row_column_to_offset(&self, display_row: usize, column: usize) -> usize { + let wrap_row = self + .fold_map + .display_row_to_wrap_row(display_row) + .unwrap_or(0); + self.wrap_map + .wrapper() + .display_point_to_offset(WrapDisplayPoint::new(wrap_row, 0, column)) + } + /// Get the longest row index (by byte length). #[inline] pub(crate) fn longest_row(&self) -> usize { diff --git a/crates/base/src/input/editor/indent.rs b/crates/base/src/input/editor/indent.rs index 33b008efce..d50bff1c10 100644 --- a/crates/base/src/input/editor/indent.rs +++ b/crates/base/src/input/editor/indent.rs @@ -1,11 +1,11 @@ use crate::input::InputModeKind; use crate::input::{ - Indent, IndentInline, InputBaseState, Outdent, OutdentInline, RopeExt, element::TextElement, - layout::LastLayout, mode::LayoutMode, + Indent, IndentInline, InputBaseState, Outdent, OutdentInline, RopeExt, cursor::CursorSelection, + element::TextElement, layout::LastLayout, mode::LayoutMode, }; use gpui::{ - Bounds, Context, EntityInputHandler as _, Hsla, Path, PathBuilder, Pixels, SharedString, - TextRun, TextStyle, Window, point, px, + Bounds, Context, Hsla, Path, PathBuilder, Pixels, SharedString, TextRun, TextStyle, Window, + point, px, }; use ropey::RopeSlice; @@ -231,139 +231,216 @@ impl InputBaseState { } pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { - if !self.is_multi_line() || !self.mode.is_indentable() { + self.apply_indent(IndentDirection::Indent, block, window, cx); + } + + pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { + self.apply_indent(IndentDirection::Outdent, block, window, cx); + } + + /// Apply an indent or outdent across all selections as one batch edit. + /// + /// A batch keeps the whole operation a single undo transaction (instead of + /// one push per line) and restores the correct multi-selection extents on + /// undo/redo. + fn apply_indent( + &mut self, + direction: IndentDirection, + block: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !self.is_editable() || !self.is_multi_line() || !self.mode.is_indentable() { cx.propagate(); return; - }; + } let tab_indent = self.mode.tab_size().to_string(); - let selected_range = self.selected_range; - let mut added_len = 0; - let is_selected = !self.selected_range.is_empty(); - - if is_selected || block { - let start_offset = self.start_of_line_of_selection(window, cx); - let mut offset = start_offset; - - let selected_text = self - .text_for_range( - self.range_to_utf16(&(offset..selected_range.end)), - &mut None, - window, - cx, - ) - .unwrap_or("".into()); - - for line in selected_text.split('\n') { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset))), - &tab_indent, - window, - cx, - ); - added_len += tab_indent.len(); - // +1 for "\n", the `\r` is included in the `line`. - offset += line.len() + tab_indent.len() + 1; - } + let tab_len = tab_indent.len(); - if is_selected { - self.selected_range = (start_offset..selected_range.end + added_len).into(); - } else { - self.selected_range = - (selected_range.start + added_len..selected_range.end + added_len).into(); - } + // Non-collapsed selections and explicit block operations indent whole lines. + let has_non_collapsed = self.selections.iter().any(|sel| !sel.is_collapsed()); + let use_block = has_non_collapsed || block; + + let before: Vec = self.selections.iter().copied().collect(); + + let (edits, new_selections) = if use_block { + self.compute_block_indent(direction, &tab_indent, tab_len) } else { - // Selected none - let offset = self.selected_range.start; - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset))), - &tab_indent, - window, - cx, - ); - added_len = tab_indent.len(); - - self.selected_range = - (selected_range.start + added_len..selected_range.end + added_len).into(); + self.compute_inline_indent(direction, tab_len) + }; + + if edits.is_empty() { + return; } + + self.undo_manager.begin_transaction(); + self.replace_text_in_ranges(&edits, window, cx); + self.selections.replace_all(new_selections); + let after: Vec = self.selections.iter().copied().collect(); + self.undo_manager.record_selections(before, after); + self.undo_manager.commit_transaction(); + + self.scroll_to(self.cursor(), None, cx); + cx.notify(); } - pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { - if !self.is_multi_line() || !self.mode.is_indentable() { - cx.propagate(); - return; + /// Build the per-line edits and resulting selections for a block + /// indent/outdent across every selection. + fn compute_block_indent( + &self, + direction: IndentDirection, + tab_indent: &str, + tab_len: usize, + ) -> (Vec<(std::ops::Range, String)>, Vec) { + let mut rows: std::collections::HashSet = std::collections::HashSet::new(); + for sel in self.selections.iter() { + let start_row = self.text.offset_to_point(sel.start).row; + let end_row = self.text.offset_to_point(sel.end).row; + for row in start_row..=end_row { + rows.insert(row); + } + } + + let mut rows: Vec = rows.into_iter().collect(); + rows.sort_unstable(); + + let mut edits: Vec<(std::ops::Range, String)> = Vec::new(); + for row in rows { + let line_start = self.text.line_start_offset(row); + match direction { + IndentDirection::Indent => { + edits.push((line_start..line_start, tab_indent.to_string())); + } + IndentDirection::Outdent => { + if self + .text + .slice(line_start..) + .chars() + .take(tab_indent.chars().count()) + .eq(tab_indent.chars()) + { + edits.push((line_start..line_start + tab_len, String::new())); + } + } + } + } + + // Map both endpoints through every earlier edit, including edits belonging + // to other cursors. A point inside removed indentation stays on its line. + let map_offset = |offset: usize| match direction { + IndentDirection::Indent => { + offset + edits.partition_point(|(range, _)| range.start <= offset) * tab_len + } + IndentDirection::Outdent => { + let preceding = edits.partition_point(|(range, _)| range.end <= offset); + let partial = edits + .get(preceding) + .map_or(0, |(range, _)| offset.saturating_sub(range.start)); + offset - preceding * tab_len - partial + } }; + let new_selections = self + .selections + .iter() + .map(|sel| { + let mut selection = *sel; + selection.start = map_offset(sel.start); + selection.end = map_offset(sel.end); + selection.column_anchor = None; + selection + }) + .collect(); + + (edits, new_selections) + } + /// Build the per-cursor edits and resulting cursors for a collapsed inline + /// indent/outdent. + fn compute_inline_indent( + &self, + direction: IndentDirection, + tab_len: usize, + ) -> (Vec<(std::ops::Range, String)>, Vec) { let tab_indent = self.mode.tab_size().to_string(); - let selected_range = self.selected_range; - let mut removed_len = 0; - let is_selected = !self.selected_range.is_empty(); - - if is_selected || block { - let start_offset = self.start_of_line_of_selection(window, cx); - let mut offset = start_offset; - - let selected_text = self - .text_for_range( - self.range_to_utf16(&(offset..selected_range.end)), - &mut None, - window, - cx, - ) - .unwrap_or("".into()); - - for line in selected_text.split('\n') { - if line.starts_with(tab_indent.as_ref()) { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), - "", - window, - cx, - ); - removed_len += tab_indent.len(); - - // +1 for "\n" - offset += line.len().saturating_sub(tab_indent.len()) + 1; - } else { - offset += line.len() + 1; + + // The edit range for each cursor: an insertion point for indent, the + // removed range for a removable outdent. + let mut ranges: Vec> = Vec::with_capacity(self.selections.len()); + for sel in self.selections.iter() { + let cursor = sel.cursor_offset(); + match direction { + IndentDirection::Indent => ranges.push(cursor..cursor), + IndentDirection::Outdent => { + let row = self.text.offset_to_point(cursor).row; + let start = self.text.line_start_offset(row); + if self + .text + .slice(start..) + .chars() + .take(tab_indent.chars().count()) + .eq(tab_indent.chars()) + { + ranges.push(start..start + tab_len); + } } } + } - if is_selected { - self.selected_range = - (start_offset..selected_range.end.saturating_sub(removed_len)).into(); - } else { - self.selected_range = (selected_range.start.saturating_sub(removed_len) - ..selected_range.end.saturating_sub(removed_len)) - .into(); - } - } else { - // Selected none - let start_offset = self.selected_range.start; - let offset = self.start_of_line_of_selection(window, cx); - let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); - - if self - .text - .slice(offset..self.text.len()) - .chars() - .take(tab_indent.chars().count()) - .eq(tab_indent.chars()) - { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), - "", - window, - cx, - ); - removed_len = tab_indent.len(); - let new_offset = start_offset.saturating_sub(removed_len); - self.selected_range = (new_offset..new_offset).into(); + // Build disjoint edits, dropping any that would overlap a previous one. + ranges.sort_by_key(|range| range.start); + let mut edits: Vec<(std::ops::Range, String)> = Vec::new(); + let mut last_end: Option = None; + for range in ranges { + if let Some(last_end) = last_end { + if range.start < last_end { + continue; + } } + last_end = Some(range.end); + let text = match direction { + IndentDirection::Indent => tab_indent.to_string(), + IndentDirection::Outdent => String::new(), + }; + edits.push((range, text)); } + + // Shift every cursor by the surviving edits before (or at) it. Cursors + // whose own edit was dropped or not applicable keep their position. + let mut new_selections: Vec = Vec::with_capacity(self.selections.len()); + for sel in self.selections.iter() { + let cursor = sel.cursor_offset(); + let new_offset = match direction { + IndentDirection::Indent => { + let inserted_before = edits + .iter() + .filter(|(range, _)| range.start <= cursor) + .count(); + cursor + inserted_before * tab_len + } + IndentDirection::Outdent => { + let removed_before: usize = edits + .iter() + .map(|(range, _)| range.end.min(cursor) - range.start.min(cursor)) + .sum(); + cursor - removed_before + } + }; + let mut selection = CursorSelection::new(sel.id, new_offset, new_offset); + selection.column_anchor = None; + new_selections.push(selection); + } + + (edits, new_selections) } } +#[derive(Debug, Copy, Clone, PartialEq)] +enum IndentDirection { + Indent, + Outdent, +} + #[cfg(test)] mod tests { use ropey::RopeSlice; diff --git a/crates/base/src/input/editor/lsp/code_actions.rs b/crates/base/src/input/editor/lsp/code_actions.rs index 562088a972..b93971cecb 100644 --- a/crates/base/src/input/editor/lsp/code_actions.rs +++ b/crates/base/src/input/editor/lsp/code_actions.rs @@ -56,7 +56,7 @@ impl InputBaseState { cx: &mut Context, ) { let providers = self.extras.lsp.code_action_providers.clone(); - let range = self.selected_range.start..self.selected_range.end; + let range = self.selected_range(); let state = cx.entity(); self.extras.context_menu_task = cx.spawn_in(window, async move |editor, cx| { diff --git a/crates/base/src/input/editor/lsp/definitions.rs b/crates/base/src/input/editor/lsp/definitions.rs index a5d5c33197..3f1a7d0307 100644 --- a/crates/base/src/input/editor/lsp/definitions.rs +++ b/crates/base/src/input/editor/lsp/definitions.rs @@ -133,7 +133,7 @@ impl InputBaseState { window: &mut Window, cx: &mut Context>, ) -> bool { - if !event.modifiers.secondary() { + if !event.modifiers.secondary() || event.modifiers.alt { return false; } diff --git a/crates/base/src/input/editor/lsp/hover.rs b/crates/base/src/input/editor/lsp/hover.rs index 4782e922c7..39bf4bc483 100644 --- a/crates/base/src/input/editor/lsp/hover.rs +++ b/crates/base/src/input/editor/lsp/hover.rs @@ -88,6 +88,10 @@ impl InputBaseState { window: &mut Window, cx: &mut Context, ) { + if event.modifiers.alt { + self.clear_hover_state(cx); + return; + } if event.modifiers.secondary() { self.handle_hover_definition(offset, window, cx); } else { diff --git a/crates/story/Cargo.toml b/crates/story/Cargo.toml index f366e1821b..9f1902625c 100644 --- a/crates/story/Cargo.toml +++ b/crates/story/Cargo.toml @@ -21,7 +21,6 @@ chrono = "0.4" csv = "1.4" fake = { version = "2.10.0", features = ["dummy"] } itertools = "0.14.0" -lsp-types.workspace = true rand = "0.8" regex = "1" serde = "1" diff --git a/website/base/primitives/editor.md b/website/base/primitives/editor.md index af2f1a52c9..d39c5aac7b 100644 --- a/website/base/primitives/editor.md +++ b/website/base/primitives/editor.md @@ -13,6 +13,12 @@ decorations, highlighting, search infrastructure, diagnostics, and LSP hooks. Use [Input](./input.md) for single-line values and [Textarea](./textarea.md) for ordinary multi-line text. +## Keyboard shortcuts + +The base and styled editors share keyboard and mouse behavior. See +[Keyboard shortcuts and column selection](../../docs/components/editor.md#keyboard-shortcuts-and-column-selection) +for the macOS, Linux, and Windows bindings, multi-cursor editing, and column-selection details. + ## Import ```rust diff --git a/website/docs/components/editor.md b/website/docs/components/editor.md index d7f7273c18..5c5bd7af89 100644 --- a/website/docs/components/editor.md +++ b/website/docs/components/editor.md @@ -49,6 +49,43 @@ let editor = cx.new(|cx| { }); ``` +## Keyboard shortcuts and column selection + +These defaults apply while the editor is focused. On macOS, Option is the Alt +modifier. Linux uses no Super/Win bindings for these operations. + +| Operation | macOS | Linux | Windows | +| --- | --- | --- | --- | +| Add a cursor above / below | Cmd+Option+Up / Down | Alt+Shift+Up / Down | Ctrl+Alt+Up / Down | +| Extend every selection by one character | Shift+Left / Right | Shift+Left / Right | Shift+Left / Right | +| Extend every selection by one word | Option+Shift+Left / Right | Ctrl+Shift+Left / Right | Ctrl+Shift+Left / Right | +| Add a cursor with the mouse | Option+left click | Alt+left click | Alt+left click | +| Select a rectangular block | Option+Shift+left drag | Alt+Shift+left drag | Alt+Shift+left drag | +| Keep only the active cursor | Escape | Escape | Escape | + +Linux also accepts Ctrl+Alt+left drag for rectangular selection, matching +Ghostty, and Alt+Shift+Left / Right for word selection. Windows additionally +accepts Alt+Shift+Left / Right for character selection. Alt/Option+left drag +works as a column-selection shortcut on all three platforms: a click adds a +cursor, while dragging builds a new block from the mouse-down position. + +Holding Alt/Option over the editor shows a `+` crosshair. Selection gestures +that include Alt take priority over Ctrl/Cmd-click go-to-definition. A block +creates one selection per display row, clipped to the available text on short +rows. Typing or deleting edits all selections. Releasing the mouse ends the +drag; Escape keeps the active cursor (an open context menu handles Escape +first). + +Adding cursors with Up / Down is additive: reversing direction does not shrink +the block's height. This is multi-cursor editing with mouse column selection, +not a persistent Vim Visual Block mode. During keyboard input, carets remain +visible; blinking resumes after 300 ms without input. + +Linux desktop shortcuts can intercept key combinations before the editor sees +them. In particular, Ctrl+Alt+Up / Down is not bound by default on Linux because +some desktops use it to switch workspaces. The shortcuts above refer to logical +modifiers after any keyboard remapping. + ## Decorations ```rust diff --git a/website/zh-CN/base/primitives/editor.md b/website/zh-CN/base/primitives/editor.md index 8c7c443588..4bfb8bb8e4 100644 --- a/website/zh-CN/base/primitives/editor.md +++ b/website/zh-CN/base/primitives/editor.md @@ -8,6 +8,11 @@ order: 16 `Editor` 是源代码编辑控件。它建立在共享文本引擎之上,增加语言、行号槽、折叠、空白字符显示、文本装饰、高亮、搜索基础、诊断与 LSP 扩展。单行值使用 [Input](./input.md),普通多行文本使用 [Textarea](./textarea.md)。 +## 快捷键 + +Base 与样式组件共享键盘和鼠标行为。各平台快捷键、多光标编辑和矩形列选的细节请参阅 +[快捷键与矩形列选](../../docs/components/editor.md#快捷键与矩形列选)。 + ## 导入 ```rust diff --git a/website/zh-CN/docs/components/editor.md b/website/zh-CN/docs/components/editor.md index 9cab1cfb55..6988b3d329 100644 --- a/website/zh-CN/docs/components/editor.md +++ b/website/zh-CN/docs/components/editor.md @@ -46,6 +46,27 @@ let editor = cx.new(|cx| { }); ``` +## 快捷键与矩形列选 + +以下默认快捷键在编辑器聚焦时生效。macOS 的 Option 对应 Alt 修饰键;Linux 的这些操作不使用 Super/Win。 + +| 操作 | macOS | Linux | Windows | +| --- | --- | --- | --- | +| 在上方/下方添加光标 | Cmd+Option+↑ / ↓ | Alt+Shift+↑ / ↓ | Ctrl+Alt+↑ / ↓ | +| 逐字符扩展所有选区 | Shift+← / → | Shift+← / → | Shift+← / → | +| 按词扩展所有选区 | Option+Shift+← / → | Ctrl+Shift+← / → | Ctrl+Shift+← / → | +| 鼠标添加光标 | Option+左键点击 | Alt+左键点击 | Alt+左键点击 | +| 矩形列选 | Option+Shift+左键拖动 | Alt+Shift+左键拖动 | Alt+Shift+左键拖动 | +| 只保留活动光标 | Escape | Escape | Escape | + +Linux 额外支持与 Ghostty 一致的 Ctrl+Alt+左键拖动列选,以及 Alt+Shift+← / → 按词选择。Windows 额外支持 Alt+Shift+← / → 逐字符选择。三个平台都兼容 Alt/Option+左键拖动列选:单击添加光标,继续拖动则以鼠标按下位置为起点建立新的矩形选区。 + +在编辑区按住 Alt/Option 时,鼠标指针显示为 `+`。带 Alt 的选择手势优先于 Ctrl/Cmd+点击跳转定义。矩形选区按显示行生成,每行一个选区,短行会截断到已有文本边界。输入和删除同时作用于所有选区。松开鼠标结束拖动,Escape 只保留活动光标(若上下文菜单已打开,则先处理菜单的 Escape)。 + +使用 ↑ / ↓ 添加光标是累加操作,反向按键不会收缩矩形高度。因此这是多光标编辑与鼠标列选,并非持续的 Vim Visual Block 模式。键盘输入期间光标保持可见,空闲 300ms 后恢复闪烁。 + +Linux 桌面可能在编辑器收到事件之前拦截快捷键。部分桌面使用 Ctrl+Alt+↑ / ↓ 切换工作区,因此 Linux 默认不绑定这一组合。以上快捷键指键盘重映射后的逻辑修饰键。 + ## 文本装饰 ```rust