From b53fb8633ef4cfa352bdb7970e59b8824a5a10a6 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 24 Jul 2023 23:59:37 -0600 Subject: [PATCH 01/12] Fix vim selection to include entire range Co-Authored-By: Max Brunsfeld --- assets/keymaps/vim.json | 18 +- crates/editor/src/display_map.rs | 52 ++-- crates/editor/src/editor.rs | 6 +- crates/editor/src/element.rs | 19 +- crates/vim/src/motion.rs | 15 +- crates/vim/src/normal.rs | 13 +- crates/vim/src/normal/change.rs | 11 +- crates/vim/src/normal/delete.rs | 14 +- crates/vim/src/normal/substitute.rs | 4 +- crates/vim/src/normal/yank.rs | 5 +- crates/vim/src/object.rs | 6 + crates/vim/src/state.rs | 21 +- crates/vim/src/test.rs | 2 +- .../src/test/neovim_backed_test_context.rs | 95 ++++-- crates/vim/src/test/neovim_connection.rs | 95 +++--- crates/vim/src/test/vim_test_context.rs | 5 +- crates/vim/src/vim.rs | 25 +- crates/vim/src/visual.rs | 274 +++++++++++------- .../vim/test_data/test_enter_visual_mode.json | 28 +- crates/vim/test_data/test_visual_change.json | 34 ++- .../test_data/test_visual_word_object.json | 82 +++--- 21 files changed, 489 insertions(+), 335 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 94a271f037..a1b6d939f4 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -266,22 +266,8 @@ "o": "vim::InsertLineBelow", "shift-o": "vim::InsertLineAbove", "~": "vim::ChangeCase", - "v": [ - "vim::SwitchMode", - { - "Visual": { - "line": false - } - } - ], - "shift-v": [ - "vim::SwitchMode", - { - "Visual": { - "line": true - } - } - ], + "v": "vim::ToggleVisual", + "shift-v": "vim::ToggleVisualLine", "p": "vim::Paste", "u": "editor::Undo", "ctrl-r": "editor::Redo", diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 6e04833f17..be0dd68adf 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -35,6 +35,12 @@ pub enum FoldStatus { Foldable, } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Clip { + None, + EndOfLine, +} + pub trait ToDisplayPoint { fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint; } @@ -50,7 +56,7 @@ pub struct DisplayMap { wrap_map: ModelHandle, block_map: BlockMap, text_highlights: TextHighlights, - pub clip_at_line_ends: bool, + pub default_clip: Clip, } impl Entity for DisplayMap { @@ -85,7 +91,7 @@ impl DisplayMap { wrap_map, block_map, text_highlights: Default::default(), - clip_at_line_ends: false, + default_clip: Clip::None, } } @@ -109,7 +115,7 @@ impl DisplayMap { wrap_snapshot, block_snapshot, text_highlights: self.text_highlights.clone(), - clip_at_line_ends: self.clip_at_line_ends, + default_clip: self.default_clip, } } @@ -296,7 +302,7 @@ pub struct DisplaySnapshot { wrap_snapshot: wrap_map::WrapSnapshot, block_snapshot: block_map::BlockSnapshot, text_highlights: TextHighlights, - clip_at_line_ends: bool, + default_clip: Clip, } impl DisplaySnapshot { @@ -577,21 +583,33 @@ impl DisplaySnapshot { column } - pub fn clip_point(&self, point: DisplayPoint, bias: Bias) -> DisplayPoint { - let mut clipped = self.block_snapshot.clip_point(point.0, bias); - if self.clip_at_line_ends { - clipped = self.clip_at_line_end(DisplayPoint(clipped)).0 - } - DisplayPoint(clipped) + pub fn move_left(&self, point: DisplayPoint, clip: Clip) -> DisplayPoint { + self.clip_point_with( + DisplayPoint::new(point.row(), point.column().saturating_sub(1)), + Bias::Left, + clip, + ) } - pub fn clip_at_line_end(&self, point: DisplayPoint) -> DisplayPoint { - let mut point = point.0; - if point.column == self.line_len(point.row) { - point.column = point.column.saturating_sub(1); - point = self.block_snapshot.clip_point(point, Bias::Left); + pub fn move_right(&self, point: DisplayPoint, clip: Clip) -> DisplayPoint { + self.clip_point_with( + DisplayPoint::new(point.row(), point.column() + 1), + Bias::Right, + clip, + ) + } + + pub fn clip_point_with(&self, point: DisplayPoint, bias: Bias, clip: Clip) -> DisplayPoint { + let new_point = DisplayPoint(self.block_snapshot.clip_point(point.0, bias)); + if clip == Clip::EndOfLine && new_point.column() == self.line_len(new_point.row()) { + self.move_left(new_point, Clip::None) + } else { + new_point } - DisplayPoint(point) + } + + pub fn clip_point(&self, point: DisplayPoint, bias: Bias) -> DisplayPoint { + self.clip_point_with(point, bias, self.default_clip) } pub fn folds_in_range(&self, range: Range) -> impl Iterator> @@ -1580,7 +1598,7 @@ pub mod tests { fn assert(text: &str, cx: &mut gpui::AppContext) { let (mut unmarked_snapshot, markers) = marked_display_snapshot(text, cx); - unmarked_snapshot.clip_at_line_ends = true; + unmarked_snapshot.default_clip = Clip::EndOfLine; assert_eq!( unmarked_snapshot.clip_point(markers[1], Bias::Left), markers[0] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b4145edb64..4b36691b60 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1544,10 +1544,10 @@ impl Editor { range.clone() } - pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut ViewContext) { - if self.display_map.read(cx).clip_at_line_ends != clip { + pub fn set_default_clip(&mut self, clip: Clip, cx: &mut ViewContext) { + if self.display_map.read(cx).default_clip != clip { self.display_map - .update(cx, |map, _| map.clip_at_line_ends = clip); + .update(cx, |map, _| map.default_clip = clip); } } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index b9bf74ee85..6440b56728 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -60,6 +60,7 @@ enum FoldMarkers {} struct SelectionLayout { head: DisplayPoint, + reversed: bool, cursor_shape: CursorShape, is_newest: bool, range: Range, @@ -78,6 +79,7 @@ impl SelectionLayout { let point_range = map.expand_to_line(selection.range()); Self { head: selection.head().to_display_point(map), + reversed: selection.reversed, cursor_shape, is_newest, range: point_range.start.to_display_point(map) @@ -87,6 +89,7 @@ impl SelectionLayout { let selection = selection.map(|p| p.to_display_point(map)); Self { head: selection.head(), + reversed: selection.reversed, cursor_shape, is_newest, range: selection.range(), @@ -844,6 +847,7 @@ impl EditorElement { if editor.show_local_cursors(cx) || replica_id != local_replica_id { let cursor_position = selection.head; + if layout .visible_display_row_range .contains(&cursor_position.row()) @@ -851,7 +855,15 @@ impl EditorElement { let cursor_row_layout = &layout.position_map.line_layouts [(cursor_position.row() - start_row) as usize] .line; - let cursor_column = cursor_position.column() as usize; + let mut cursor_column = cursor_position.column() as usize; + + if CursorShape::Block == selection.cursor_shape + && !selection.range.is_empty() + && !selection.reversed + && cursor_column > 0 + { + cursor_column -= 1; + } let cursor_character_x = cursor_row_layout.x_for_index(cursor_column); let mut block_width = @@ -863,7 +875,10 @@ impl EditorElement { layout .position_map .snapshot - .chars_at(cursor_position) + .chars_at(DisplayPoint::new( + cursor_position.row(), + cursor_column as u32, + )) .next() .and_then(|(character, _)| { let font_id = diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index b8bd256d8a..924fefc73f 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use editor::{ char_kind, - display_map::{DisplaySnapshot, ToDisplayPoint}, + display_map::{Clip, DisplaySnapshot, ToDisplayPoint}, movement, Bias, CharKind, DisplayPoint, ToOffset, }; use gpui::{actions, impl_actions, AppContext, WindowContext}; @@ -295,7 +295,11 @@ impl Motion { SelectionGoal::None, ), EndOfParagraph => ( - map.clip_at_line_end(movement::end_of_paragraph(map, point, times)), + map.clip_point_with( + movement::end_of_paragraph(map, point, times), + Bias::Left, + Clip::EndOfLine, + ), SelectionGoal::None, ), CurrentLine => (end_of_line(map, point), SelectionGoal::None), @@ -383,8 +387,7 @@ impl Motion { fn left(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { for _ in 0..times { - *point.column_mut() = point.column().saturating_sub(1); - point = map.clip_point(point, Bias::Left); + point = map.move_left(point, Clip::None); if point.column() == 0 { break; } @@ -425,9 +428,7 @@ fn up( pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { for _ in 0..times { - let mut new_point = point; - *new_point.column_mut() += 1; - let new_point = map.clip_point(new_point, Bias::Right); + let new_point = map.clip_point(map.move_right(point, Clip::None), Bias::Right); if point == new_point { break; } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 79c990ffeb..a25558fa70 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -16,8 +16,9 @@ use crate::{ }; use collections::{HashMap, HashSet}; use editor::{ - display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Anchor, Bias, ClipboardSelection, - DisplayPoint, + display_map::{Clip, ToDisplayPoint}, + scroll::autoscroll::Autoscroll, + Anchor, Bias, ClipboardSelection, DisplayPoint, }; use gpui::{actions, AppContext, ViewContext, WindowContext}; use language::{AutoindentMode, Point, SelectionGoal}; @@ -254,7 +255,7 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); + editor.set_default_clip(Clip::None, cx); if let Some(item) = cx.read_from_clipboard() { let mut clipboard_text = Cow::Borrowed(item.text()); if let Some(mut clipboard_selections) = @@ -382,7 +383,7 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { editor.insert(&clipboard_text, cx); } } - editor.set_clip_at_line_ends(true, cx); + editor.set_default_clip(Clip::EndOfLine, cx); }); }); }); @@ -392,7 +393,7 @@ pub(crate) fn normal_replace(text: Arc, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); + editor.set_default_clip(Clip::None, cx); let (map, display_selections) = editor.selections.all_display(cx); // Selections are biased right at the start. So we need to store // anchors that are biased left so that we can restore the selections @@ -425,7 +426,7 @@ pub(crate) fn normal_replace(text: Arc, cx: &mut WindowContext) { editor.buffer().update(cx, |buffer, cx| { buffer.edit(edits, None, cx); }); - editor.set_clip_at_line_ends(true, cx); + editor.set_default_clip(Clip::EndOfLine, cx); editor.change_selections(None, cx, |s| { s.select_anchor_ranges(stable_anchors); }); diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index d226c70410..6ce3a3ac6c 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -1,7 +1,10 @@ use crate::{motion::Motion, object::Object, state::Mode, utils::copy_selections_content, Vim}; use editor::{ - char_kind, display_map::DisplaySnapshot, movement, scroll::autoscroll::Autoscroll, CharKind, - DisplayPoint, + char_kind, + display_map::{Clip, DisplaySnapshot}, + movement, + scroll::autoscroll::Autoscroll, + CharKind, DisplayPoint, }; use gpui::WindowContext; use language::Selection; @@ -15,7 +18,7 @@ pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now - editor.set_clip_at_line_ends(false, cx); + editor.set_default_clip(Clip::None, cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { motion_succeeded |= if let Motion::NextWordStart { ignore_punctuation } = motion @@ -42,7 +45,7 @@ pub fn change_object(vim: &mut Vim, object: Object, around: bool, cx: &mut Windo let mut objects_found = false; vim.update_active_editor(cx, |editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now - editor.set_clip_at_line_ends(false, cx); + editor.set_default_clip(Clip::None, cx); editor.transact(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index 56fef78e1d..ed8fcabdc9 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -1,12 +1,16 @@ use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; use collections::{HashMap, HashSet}; -use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Bias}; +use editor::{ + display_map::{Clip, ToDisplayPoint}, + scroll::autoscroll::Autoscroll, + Bias, +}; use gpui::WindowContext; pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); + editor.set_default_clip(Clip::None, cx); let mut original_columns: HashMap<_, _> = Default::default(); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { @@ -19,7 +23,7 @@ pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m editor.insert("", cx); // Fixup cursor position after the deletion - editor.set_clip_at_line_ends(true, cx); + editor.set_default_clip(Clip::EndOfLine, cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { let mut cursor = selection.head(); @@ -39,7 +43,7 @@ pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); + editor.set_default_clip(Clip::None, cx); // Emulates behavior in vim where if we expanded backwards to include a newline // the cursor gets set back to the start of the line let mut should_move_to_start: HashSet<_> = Default::default(); @@ -77,7 +81,7 @@ pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut Windo editor.insert("", cx); // Fixup cursor position after the deletion - editor.set_clip_at_line_ends(true, cx); + editor.set_default_clip(Clip::EndOfLine, cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { let mut cursor = selection.head(); diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index ef72baae31..2f022dca98 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -4,8 +4,8 @@ use language::Point; use crate::{motion::Motion, Mode, Vim}; pub fn substitute(vim: &mut Vim, count: Option, cx: &mut WindowContext) { + vim.switch_mode(Mode::Insert, true, cx); vim.update_active_editor(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); editor.transact(cx, |editor, cx| { editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { @@ -21,9 +21,7 @@ pub fn substitute(vim: &mut Vim, count: Option, cx: &mut WindowContext) { }) } }); - editor.set_clip_at_line_ends(true, cx); }); - vim.switch_mode(Mode::Insert, true, cx) } #[cfg(test)] diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index 7212a865bd..beb468ecb0 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -1,11 +1,12 @@ use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; use collections::HashMap; +use editor::display_map::Clip; use gpui::WindowContext; pub fn yank_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); + editor.set_default_clip(Clip::None, cx); let mut original_positions: HashMap<_, _> = Default::default(); editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { @@ -28,7 +29,7 @@ pub fn yank_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut pub fn yank_object(vim: &mut Vim, object: Object, around: bool, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); + editor.set_default_clip(Clip::None, cx); let mut original_positions: HashMap<_, _> = Default::default(); editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 5388dac1a2..837b619fc5 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -481,6 +481,12 @@ mod test { async fn test_visual_word_object(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state("The quick ˇbrown\nfox").await; + cx.simulate_shared_keystrokes(["v"]).await; + cx.assert_shared_state("The quick «bˇ»rown\nfox").await; + cx.simulate_shared_keystrokes(["i", "w"]).await; + cx.assert_shared_state("The quick «brownˇ»\nfox").await; + cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS) .await; cx.assert_binding_matches_all_exempted( diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index eb52945ced..af78741333 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,3 +1,4 @@ +use editor::display_map::Clip; use gpui::keymap_matcher::KeymapContext; use language::CursorShape; use serde::{Deserialize, Serialize}; @@ -12,6 +13,15 @@ pub enum Mode { Visual { line: bool }, } +impl Mode { + pub fn is_visual(&self) -> bool { + match self { + Mode::Normal | Mode::Insert => false, + Mode::Visual { .. } => true, + } + } +} + impl Default for Mode { fn default() -> Self { Self::Normal @@ -78,12 +88,11 @@ impl VimState { ) } - pub fn clip_at_line_end(&self) -> bool { - !matches!(self.mode, Mode::Insert | Mode::Visual { .. }) - } - - pub fn empty_selections_only(&self) -> bool { - !matches!(self.mode, Mode::Visual { .. }) + pub fn default_clip(&self) -> Clip { + match self.mode { + Mode::Insert | Mode::Visual { .. } => Clip::None, + Mode::Normal => Clip::EndOfLine, + } } pub fn keymap_context_layer(&self) -> KeymapContext { diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 98d8cb8749..cd52e2afc8 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -141,7 +141,7 @@ async fn test_indent_outdent(cx: &mut gpui::TestAppContext) { // works in visuial mode cx.simulate_keystrokes(["shift-v", "down", ">"]); - cx.assert_editor_state("aa\n b«b\n cˇ»c"); + cx.assert_editor_state("aa\n b«b\n ccˇ»"); } #[gpui::test] diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 7f9a84b666..023ed880d2 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -61,6 +61,9 @@ pub struct NeovimBackedTestContext<'a> { // bindings are exempted. If None, all bindings are ignored for that insertion text. exemptions: HashMap>>, neovim: NeovimConnection, + + last_set_state: Option, + recent_keystrokes: Vec, } impl<'a> NeovimBackedTestContext<'a> { @@ -71,6 +74,9 @@ impl<'a> NeovimBackedTestContext<'a> { cx, exemptions: Default::default(), neovim: NeovimConnection::new(function_name).await, + + last_set_state: None, + recent_keystrokes: Default::default(), } } @@ -102,13 +108,21 @@ impl<'a> NeovimBackedTestContext<'a> { keystroke_texts: [&str; COUNT], ) -> ContextHandle { for keystroke_text in keystroke_texts.into_iter() { + self.recent_keystrokes.push(keystroke_text.to_string()); self.neovim.send_keystroke(keystroke_text).await; } self.simulate_keystrokes(keystroke_texts) } pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle { - let context_handle = self.set_state(marked_text, Mode::Normal); + let mode = if marked_text.contains("»") { + Mode::Visual { line: false } + } else { + Mode::Normal + }; + let context_handle = self.set_state(marked_text, mode); + self.last_set_state = Some(marked_text.to_string()); + self.recent_keystrokes = Vec::new(); self.neovim.set_state(marked_text).await; context_handle } @@ -116,15 +130,25 @@ impl<'a> NeovimBackedTestContext<'a> { pub async fn assert_shared_state(&mut self, marked_text: &str) { let neovim = self.neovim_state().await; if neovim != marked_text { + let initial_state = self + .last_set_state + .as_ref() + .unwrap_or(&"N/A".to_string()) + .clone(); panic!( indoc! {"Test is incorrect (currently expected != neovim state) - + # initial state: + {} + # keystrokes: + {} # currently expected: {} # neovim state: {} # zed state: {}"}, + initial_state, + self.recent_keystrokes.join(" "), marked_text, neovim, self.editor_state(), @@ -141,28 +165,40 @@ impl<'a> NeovimBackedTestContext<'a> { ) } + pub async fn neovim_mode(&mut self) -> Mode { + self.neovim.mode().await.unwrap() + } + async fn neovim_selection(&mut self) -> Range { - let mut neovim_selection = self.neovim.selection().await; - // Zed selections adjust themselves to make the end point visually make sense - if neovim_selection.start > neovim_selection.end { - neovim_selection.start.column += 1; - } + let neovim_selection = self.neovim.selection().await; neovim_selection.to_offset(&self.buffer_snapshot()) } pub async fn assert_state_matches(&mut self) { - assert_eq!( - self.neovim.text().await, - self.buffer_text(), - "{}", - self.assertion_context() - ); + let neovim = self.neovim_state().await; + let editor = self.editor_state(); + let initial_state = self + .last_set_state + .as_ref() + .unwrap_or(&"N/A".to_string()) + .clone(); - let selections = vec![self.neovim_selection().await]; - self.assert_editor_selections(selections); - - if let Some(neovim_mode) = self.neovim.mode().await { - assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),); + if neovim != editor { + panic!( + indoc! {"Test failed (zed does not match nvim behaviour) + # initial state: + {} + # keystrokes: + {} + # neovim state: + {} + # zed state: + {}"}, + initial_state, + self.recent_keystrokes.join(" "), + neovim, + editor, + ) } } @@ -207,6 +243,29 @@ impl<'a> NeovimBackedTestContext<'a> { } } + pub fn each_marked_position(&self, marked_positions: &str) -> Vec { + let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions); + let mut ret = Vec::with_capacity(cursor_offsets.len()); + + for cursor_offset in cursor_offsets.iter() { + let mut marked_text = unmarked_text.clone(); + marked_text.insert(*cursor_offset, 'ˇ'); + ret.push(marked_text) + } + + ret + } + + pub async fn assert_neovim_compatible( + &mut self, + marked_positions: &str, + keystrokes: [&str; COUNT], + ) { + self.set_shared_state(&marked_positions).await; + self.simulate_shared_keystrokes(keystrokes).await; + self.assert_state_matches().await; + } + pub async fn assert_binding_matches_all_exempted( &mut self, keystrokes: [&str; COUNT], diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs index 5bfae4e673..21d69d2152 100644 --- a/crates/vim/src/test/neovim_connection.rs +++ b/crates/vim/src/test/neovim_connection.rs @@ -213,6 +213,16 @@ impl NeovimConnection { ); } + #[cfg(feature = "neovim")] + async fn read_position(&mut self, cmd: &str) -> u32 { + self.nvim + .command_output(cmd) + .await + .unwrap() + .parse::() + .unwrap() + } + #[cfg(feature = "neovim")] pub async fn state(&mut self) -> (Option, String, Range) { let nvim_buffer = self @@ -226,22 +236,11 @@ impl NeovimConnection { .expect("Could not get buffer text") .join("\n"); - let cursor_row: u32 = self - .nvim - .command_output("echo line('.')") - .await - .unwrap() - .parse::() - .unwrap() - - 1; // Neovim rows start at 1 - let cursor_col: u32 = self - .nvim - .command_output("echo col('.')") - .await - .unwrap() - .parse::() - .unwrap() - - 1; // Neovim columns start at 1 + // nvim columns are 1-based, so -1. + let cursor_row = self.read_position("echo line('.')").await - 1; + let mut cursor_col = self.read_position("echo col('.')").await - 1; + let selection_row = self.read_position("echo line('v')").await - 1; + let mut selection_col = self.read_position("echo col('v')").await - 1; let nvim_mode_text = self .nvim @@ -266,46 +265,32 @@ impl NeovimConnection { _ => None, }; - let (start, end) = if let Some(Mode::Visual { .. }) = mode { - self.nvim - .input("") - .await - .expect("Could not exit visual mode"); - let nvim_buffer = self - .nvim - .get_current_buf() - .await - .expect("Could not get neovim buffer"); - let (start_row, start_col) = nvim_buffer - .get_mark("<") - .await - .expect("Could not get selection start"); - let (end_row, end_col) = nvim_buffer - .get_mark(">") - .await - .expect("Could not get selection end"); - self.nvim - .input("gv") - .await - .expect("Could not reselect visual selection"); - - if cursor_row == start_row as u32 - 1 && cursor_col == start_col as u32 { - ( - Point::new(end_row as u32 - 1, end_col as u32), - Point::new(start_row as u32 - 1, start_col as u32), - ) - } else { - ( - Point::new(start_row as u32 - 1, start_col as u32), - Point::new(end_row as u32 - 1, end_col as u32), - ) + // Vim uses the index of the first and last character in the selection + // Zed uses the index of the positions between the characters, so we need + // to add one to the end in visual mode. + match mode { + Some(Mode::Visual { .. }) => { + if selection_col > cursor_col { + let selection_line_length = + self.read_position("echo strlen(getline(line('v')))").await; + if selection_line_length > 0 { + selection_col += 1; + } + } else { + let cursor_line_length = + self.read_position("echo strlen(getline(line('.')))").await; + if cursor_line_length > 0 { + cursor_col += 1; + } + } } - } else { - ( - Point::new(cursor_row, cursor_col), - Point::new(cursor_row, cursor_col), - ) - }; + Some(Mode::Insert) | Some(Mode::Normal) | None => {} + } + + let (start, end) = ( + Point::new(selection_row, selection_col), + Point::new(cursor_row, cursor_col), + ); let state = NeovimData::Get { mode, diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index ea09e55091..ca1020cf75 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -86,12 +86,13 @@ impl<'a> VimTestContext<'a> { pub fn set_state(&mut self, text: &str, mode: Mode) -> ContextHandle { let window_id = self.window_id; + let context_handle = self.cx.set_state(text); self.update_window(window_id, |cx| { Vim::update(cx, |vim, cx| { - vim.switch_mode(mode, false, cx); + vim.switch_mode(mode, true, cx); }) }); - self.cx.set_state(text) + context_handle } #[track_caller] diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 22bd196c67..c9386eb0bc 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -13,7 +13,7 @@ mod visual; use anyhow::Result; use collections::CommandPaletteFilter; -use editor::{Bias, Editor, EditorMode, Event}; +use editor::{display_map::Clip, Editor, EditorMode, Event}; use gpui::{ actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext, Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext, @@ -181,6 +181,7 @@ impl Vim { } fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut WindowContext) { + let last_mode = self.state.mode; self.state.mode = mode; self.state.operator_stack.clear(); @@ -197,12 +198,16 @@ impl Vim { self.update_active_editor(cx, |editor, cx| { editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { - if self.state.empty_selections_only() { - let new_head = map.clip_point(selection.head(), Bias::Left); - selection.collapse_to(new_head, selection.goal) - } else { - selection - .set_head(map.clip_point(selection.head(), Bias::Left), selection.goal); + if last_mode.is_visual() && !mode.is_visual() { + let mut point = selection.head(); + if !selection.reversed { + point = map.move_left(selection.head(), Clip::None); + } + selection.collapse_to(point, selection.goal) + } else if !last_mode.is_visual() && mode.is_visual() { + if selection.is_empty() { + selection.end = map.move_right(selection.start, Clip::None); + } } }); }) @@ -265,7 +270,7 @@ impl Vim { } Some(Operator::Replace) => match Vim::read(cx).state.mode { Mode::Normal => normal_replace(text, cx), - Mode::Visual { line } => visual_replace(text, line, cx), + Mode::Visual { .. } => visual_replace(text, cx), _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)), }, _ => {} @@ -309,7 +314,7 @@ impl Vim { self.update_active_editor(cx, |editor, cx| { if self.enabled && editor.mode() == EditorMode::Full { editor.set_cursor_shape(cursor_shape, cx); - editor.set_clip_at_line_ends(state.clip_at_line_end(), cx); + editor.set_default_clip(state.default_clip(), cx); editor.set_collapse_matches(true); editor.set_input_enabled(!state.vim_controlled()); editor.selections.line_mode = matches!(state.mode, Mode::Visual { line: true }); @@ -326,7 +331,7 @@ impl Vim { fn unhook_vim_settings(&self, editor: &mut Editor, cx: &mut ViewContext) { editor.set_cursor_shape(CursorShape::Bar, cx); - editor.set_clip_at_line_ends(false, cx); + editor.set_default_clip(Clip::None, cx); editor.set_input_enabled(true); editor.selections.line_mode = false; diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index d87e4ff974..22d96e2d1c 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -2,7 +2,10 @@ use std::{borrow::Cow, sync::Arc}; use collections::HashMap; use editor::{ - display_map::ToDisplayPoint, movement, scroll::autoscroll::Autoscroll, Bias, ClipboardSelection, + display_map::{Clip, ToDisplayPoint}, + movement, + scroll::autoscroll::Autoscroll, + Bias, ClipboardSelection, }; use gpui::{actions, AppContext, ViewContext, WindowContext}; use language::{AutoindentMode, SelectionGoal}; @@ -16,9 +19,21 @@ use crate::{ Vim, }; -actions!(vim, [VisualDelete, VisualChange, VisualYank, VisualPaste]); +actions!( + vim, + [ + ToggleVisual, + ToggleVisualLine, + VisualDelete, + VisualChange, + VisualYank, + VisualPaste + ] +); pub fn init(cx: &mut AppContext) { + cx.add_action(toggle_visual); + cx.add_action(toggle_visual_line); cx.add_action(change); cx.add_action(delete); cx.add_action(yank); @@ -32,23 +47,32 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex s.move_with(|map, selection| { let was_reversed = selection.reversed; - if let Some((new_head, goal)) = - motion.move_point(map, selection.head(), selection.goal, times) - { - selection.set_head(new_head, goal); + let mut current_head = selection.head(); - if was_reversed && !selection.reversed { - // Head was at the start of the selection, and now is at the end. We need to move the start - // back by one if possible in order to compensate for this change. - *selection.start.column_mut() = - selection.start.column().saturating_sub(1); - selection.start = map.clip_point(selection.start, Bias::Left); - } else if !was_reversed && selection.reversed { - // Head was at the end of the selection, and now is at the start. We need to move the end - // forward by one if possible in order to compensate for this change. - *selection.end.column_mut() = selection.end.column() + 1; - selection.end = map.clip_point(selection.end, Bias::Right); - } + // our motions assume the current character is after the cursor, + // but in (forward) visual mode the current character is just + // before the end of the selection. + if !selection.reversed { + current_head = map.move_left(current_head, Clip::None); + } + + let Some((new_head, goal)) = + motion.move_point(map, current_head, selection.goal, times) else { return }; + + selection.set_head(new_head, goal); + + // ensure the current character is included in the selection. + if !selection.reversed { + selection.end = map.move_right(selection.end, Clip::None); + } + + // vim always ensures the anchor character stays selected. + // if our selection has reversed, we need to move the opposite end + // to ensure the anchor is still selected. + if was_reversed && !selection.reversed { + selection.start = map.move_left(selection.start, Clip::None); + } else if !was_reversed && selection.reversed { + selection.end = map.move_right(selection.end, Clip::None); } }); }); @@ -64,14 +88,30 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { - let head = selection.head(); - if let Some(mut range) = object.range(map, head, around) { - if !range.is_empty() { - if let Some((_, end)) = map.reverse_chars_at(range.end).next() { - range.end = end; - } + let mut head = selection.head(); - if selection.is_empty() { + // all our motions assume that the current character is + // after the cursor; however in the case of a visual selection + // the current character is before the cursor. + if !selection.reversed { + head = map.move_left(head, Clip::None); + } + + if let Some(range) = object.range(map, head, around) { + if !range.is_empty() { + let expand_both_ways = if selection.is_empty() { + true + // contains only one character + } else if let Some((_, start)) = + map.reverse_chars_at(selection.end).next() + { + selection.start == start + } else { + false + }; + dbg!(expand_both_ways); + + if expand_both_ways { selection.start = range.start; selection.end = range.end; } else if selection.reversed { @@ -88,10 +128,35 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) { }); } +pub fn toggle_visual(_: &mut Workspace, _: &ToggleVisual, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| match vim.state.mode { + Mode::Normal | Mode::Insert | Mode::Visual { line: true } => { + vim.switch_mode(Mode::Visual { line: false }, false, cx); + } + Mode::Visual { line: false } => { + vim.switch_mode(Mode::Normal, false, cx); + } + }) +} + +pub fn toggle_visual_line( + _: &mut Workspace, + _: &ToggleVisualLine, + cx: &mut ViewContext, +) { + Vim::update(cx, |vim, cx| match vim.state.mode { + Mode::Normal | Mode::Insert | Mode::Visual { line: false } => { + vim.switch_mode(Mode::Visual { line: true }, false, cx); + } + Mode::Visual { line: true } => { + vim.switch_mode(Mode::Normal, false, cx); + } + }) +} + pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); // Compute edits and resulting anchor selections. If in line mode, adjust // the anchor location and additional newline let mut edits = Vec::new(); @@ -99,13 +164,6 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); let mut original_columns: HashMap<_, _> = Default::default(); let line_mode = editor.selections.line_mode; editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { if line_mode { - original_columns - .insert(selection.id, selection.head().to_point(map).column); - } else if !selection.reversed { - // Head is at the end of the selection. Adjust the end position to - // to include the character under the cursor. - *selection.end.column_mut() = selection.end.column() + 1; - selection.end = map.clip_point(selection.end, Bias::Right); + let mut position = selection.head(); + if !selection.reversed { + position = map.move_left(position, Clip::None); + } + original_columns.insert(selection.id, position.to_point(map).column); } selection.goal = SelectionGoal::None; }); @@ -162,7 +217,6 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); let line_mode = editor.selections.line_mode; - if !line_mode { - editor.change_selections(None, cx, |s| { - s.move_with(|map, selection| { - if !selection.reversed { - // Head is at the end of the selection. Adjust the end position to - // to include the character under the cursor. - *selection.end.column_mut() = selection.end.column() + 1; - selection.end = map.clip_point(selection.end, Bias::Right); - } - }); - }); - } copy_selections_content(editor, line_mode, cx); editor.change_selections(None, cx, |s| { s.move_with(|_, selection| { @@ -203,7 +248,7 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext) }); }); }); - vim.switch_mode(Mode::Normal, false, cx); + vim.switch_mode(Mode::Normal, true, cx); }); } @@ -256,11 +301,7 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext let mut selection = selection.clone(); if !selection.reversed { - let mut adjusted = selection.end; - // Head is at the end of the selection. Adjust the end position to - // to include the character under the cursor. - *adjusted.column_mut() = adjusted.column() + 1; - adjusted = display_map.clip_point(adjusted, Bias::Right); + let adjusted = selection.end; // If the selection is empty, move both the start and end forward one // character if selection.is_empty() { @@ -311,11 +352,11 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext } }); }); - vim.switch_mode(Mode::Normal, false, cx); + vim.switch_mode(Mode::Normal, true, cx); }); } -pub(crate) fn visual_replace(text: Arc, line: bool, cx: &mut WindowContext) { +pub(crate) fn visual_replace(text: Arc, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { @@ -336,14 +377,7 @@ pub(crate) fn visual_replace(text: Arc, line: bool, cx: &mut WindowContext) let mut edits = Vec::new(); for selection in selections.iter() { - let mut selection = selection.clone(); - if !line && !selection.reversed { - // Head is at the end of the selection. Adjust the end position to - // to include the character under the cursor. - *selection.end.column_mut() = selection.end.column() + 1; - selection.end = display_map.clip_point(selection.end, Bias::Right); - } - + let selection = selection.clone(); for row_range in movement::split_display_range_by_lines(&display_map, selection.range()) { @@ -375,19 +409,42 @@ mod test { #[gpui::test] async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) { - let mut cx = NeovimBackedTestContext::new(cx) - .await - .binding(["v", "w", "j"]); - cx.assert_all(indoc! {" - The ˇquick brown - fox jumps ˇover - the ˇlazy dog"}) + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! { + "The ˇquick brown + fox jumps over + the lazy dog" + }) + .await; + + // entering visual mode should select the character + // under cursor + cx.simulate_shared_keystrokes(["v"]).await; + cx.assert_shared_state(indoc! { "The «qˇ»uick brown + fox jumps over + the lazy dog"}) .await; - let mut cx = cx.binding(["v", "b", "k"]); - cx.assert_all(indoc! {" - The ˇquick brown - fox jumps ˇover - the ˇlazy dog"}) + + // forwards motions should extend the selection + cx.simulate_shared_keystrokes(["w", "j"]).await; + cx.assert_shared_state(indoc! { "The «quick brown + fox jumps oˇ»ver + the lazy dog"}) + .await; + + cx.simulate_shared_keystrokes(["escape"]).await; + assert_eq!(Mode::Normal, cx.neovim_mode().await); + cx.assert_shared_state(indoc! { "The quick brown + fox jumps ˇover + the lazy dog"}) + .await; + + // motions work backwards + cx.simulate_shared_keystrokes(["v", "k", "b"]).await; + cx.assert_shared_state(indoc! { "The «ˇquick brown + fox jumps o»ver + the lazy dog"}) .await; } @@ -461,22 +518,33 @@ mod test { #[gpui::test] async fn test_visual_change(cx: &mut gpui::TestAppContext) { - let mut cx = NeovimBackedTestContext::new(cx) - .await - .binding(["v", "w", "c"]); - cx.assert("The quick ˇbrown").await; - let mut cx = cx.binding(["v", "w", "j", "c"]); - cx.assert_all(indoc! {" - The ˇquick brown - fox jumps ˇover - the ˇlazy dog"}) + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("The quick ˇbrown").await; + cx.simulate_shared_keystrokes(["v", "w", "c"]).await; + cx.assert_shared_state("The quick ˇ").await; + + cx.set_shared_state(indoc! {" + The ˇquick brown + fox jumps over + the lazy dog"}) .await; - let mut cx = cx.binding(["v", "b", "k", "c"]); - cx.assert_all(indoc! {" - The ˇquick brown - fox jumps ˇover - the ˇlazy dog"}) + cx.simulate_shared_keystrokes(["v", "w", "j", "c"]).await; + cx.assert_shared_state(indoc! {" + The ˇver + the lazy dog"}) .await; + + let cases = cx.each_marked_position(indoc! {" + The ˇquick brown + fox jumps ˇover + the ˇlazy dog"}); + for initial_state in cases { + cx.assert_neovim_compatible(&initial_state, ["v", "w", "j", "c"]) + .await; + cx.assert_neovim_compatible(&initial_state, ["v", "w", "k", "c"]) + .await; + } } #[gpui::test] @@ -605,7 +673,7 @@ mod test { cx.set_state( indoc! {" The quick brown - fox «jumpˇ»s over + fox «jumpsˇ» over the lazy dog"}, Mode::Visual { line: false }, ); @@ -629,7 +697,7 @@ mod test { cx.set_state( indoc! {" The quick brown - fox juˇmps over + fox ju«mˇ»ps over the lazy dog"}, Mode::Visual { line: true }, ); @@ -643,7 +711,7 @@ mod test { cx.set_state( indoc! {" The quick brown - the «lazˇ»y dog"}, + the «lazyˇ» dog"}, Mode::Visual { line: false }, ); cx.simulate_keystroke("p"); diff --git a/crates/vim/test_data/test_enter_visual_mode.json b/crates/vim/test_data/test_enter_visual_mode.json index bd4e91977f..6b5274b892 100644 --- a/crates/vim/test_data/test_enter_visual_mode.json +++ b/crates/vim/test_data/test_enter_visual_mode.json @@ -1,30 +1,12 @@ {"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} {"Key":"v"} +{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":{"Visual":{"line":false}}}} {"Key":"w"} {"Key":"j"} -{"Get":{"state":"The «quick brown\nfox jumps ˇ»over\nthe lazy dog","mode":{"Visual":{"line":false}}}} -{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}} +{"Get":{"state":"The «quick brown\nfox jumps oˇ»ver\nthe lazy dog","mode":{"Visual":{"line":false}}}} +{"Key":"escape"} +{"Get":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog","mode":"Normal"}} {"Key":"v"} -{"Key":"w"} -{"Key":"j"} -{"Get":{"state":"The quick brown\nfox jumps «over\nˇ»the lazy dog","mode":{"Visual":{"line":false}}}} -{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}} -{"Key":"v"} -{"Key":"w"} -{"Key":"j"} -{"Get":{"state":"The quick brown\nfox jumps over\nthe «lazy ˇ»dog","mode":{"Visual":{"line":false}}}} -{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} -{"Key":"v"} -{"Key":"b"} {"Key":"k"} -{"Get":{"state":"«ˇThe »quick brown\nfox jumps over\nthe lazy dog","mode":{"Visual":{"line":false}}}} -{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}} -{"Key":"v"} {"Key":"b"} -{"Key":"k"} -{"Get":{"state":"The «ˇquick brown\nfox jumps »over\nthe lazy dog","mode":{"Visual":{"line":false}}}} -{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}} -{"Key":"v"} -{"Key":"b"} -{"Key":"k"} -{"Get":{"state":"The quick brown\n«ˇfox jumps over\nthe »lazy dog","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The «ˇquick brown\nfox jumps o»ver\nthe lazy dog","mode":{"Visual":{"line":false}}}} diff --git a/crates/vim/test_data/test_visual_change.json b/crates/vim/test_data/test_visual_change.json index 8c252e49c5..7d1efe05ce 100644 --- a/crates/vim/test_data/test_visual_change.json +++ b/crates/vim/test_data/test_visual_change.json @@ -9,33 +9,39 @@ {"Key":"j"} {"Key":"c"} {"Get":{"state":"The ˇver\nthe lazy dog","mode":"Insert"}} +{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"v"} +{"Key":"w"} +{"Key":"j"} +{"Key":"c"} +{"Get":{"state":"The ˇver\nthe lazy dog","mode":"Insert"}} +{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"v"} +{"Key":"w"} +{"Key":"k"} +{"Key":"c"} +{"Get":{"state":"The ˇrown\nfox jumps over\nthe lazy dog","mode":"Insert"}} {"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}} {"Key":"v"} {"Key":"w"} {"Key":"j"} {"Key":"c"} {"Get":{"state":"The quick brown\nfox jumps ˇhe lazy dog","mode":"Insert"}} +{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}} +{"Key":"v"} +{"Key":"w"} +{"Key":"k"} +{"Key":"c"} +{"Get":{"state":"The quick brown\nˇver\nthe lazy dog","mode":"Insert"}} {"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}} {"Key":"v"} {"Key":"w"} {"Key":"j"} {"Key":"c"} {"Get":{"state":"The quick brown\nfox jumps over\nthe ˇog","mode":"Insert"}} -{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} -{"Key":"v"} -{"Key":"b"} -{"Key":"k"} -{"Key":"c"} -{"Get":{"state":"ˇuick brown\nfox jumps over\nthe lazy dog","mode":"Insert"}} -{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}} -{"Key":"v"} -{"Key":"b"} -{"Key":"k"} -{"Key":"c"} -{"Get":{"state":"The ˇver\nthe lazy dog","mode":"Insert"}} {"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}} {"Key":"v"} -{"Key":"b"} +{"Key":"w"} {"Key":"k"} {"Key":"c"} -{"Get":{"state":"The quick brown\nˇazy dog","mode":"Insert"}} +{"Get":{"state":"The quick brown\nfox jumpsˇazy dog","mode":"Insert"}} diff --git a/crates/vim/test_data/test_visual_word_object.json b/crates/vim/test_data/test_visual_word_object.json index 5514f7385a..b1765459e0 100644 --- a/crates/vim/test_data/test_visual_word_object.json +++ b/crates/vim/test_data/test_visual_word_object.json @@ -1,38 +1,44 @@ +{"Put":{"state":"The quick ˇbrown\nfox"}} +{"Key":"v"} +{"Get":{"state":"The quick «bˇ»rown\nfox","mode":{"Visual":{"line":false}}}} +{"Key":"i"} +{"Key":"w"} +{"Get":{"state":"The quick «brownˇ»\nfox","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick ˇbrown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick «browˇ»n \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick browˇn \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick «browˇ»n \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brownˇ \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown« ˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown« ˇ»\nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox ˇjumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox «jumpˇ»s over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox juˇmps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox «jumpˇ»s over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumpsˇ over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumpsˇ over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps« ˇ»over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dogˇ \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ» \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ»\n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \nˇ\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} @@ -52,62 +58,62 @@ {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«Thˇ»e-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«Theˇ»-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nTheˇ-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nTheˇ-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe«-ˇ»quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-ˇquick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quˇick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quickˇ brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quickˇ brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick« ˇ»brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick ˇbrown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «browˇ»n \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «brownˇ» \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brownˇ \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brownˇ \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown« ˇ»\n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \nˇ \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ» \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ»\n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \nˇ \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ» \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ»\n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \nˇ fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ» fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ»fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumpˇs over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-«jumpˇ»s over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-«jumpsˇ» over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dogˇ \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dogˇ \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog« ˇ»\n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \nˇ\n"}} {"Key":"v"} {"Key":"i"} @@ -117,37 +123,37 @@ {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick «browˇ»n \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick browˇn \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick «browˇ»n \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brownˇ \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown« ˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown« ˇ»\nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox ˇjumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox «jumpˇ»s over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox juˇmps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox «jumpˇ»s over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumpsˇ over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumpsˇ over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps« ˇ»over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dogˇ \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ» \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ»\n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \nˇ\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} @@ -167,62 +173,62 @@ {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nTheˇ-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-ˇquick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quˇick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quickˇ brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quickˇ brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick« ˇ»brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick ˇbrown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «browˇ»n \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «brownˇ» \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brownˇ \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brownˇ \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown« ˇ»\n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \nˇ \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ» \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ»\n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \nˇ \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ» \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ»\n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \nˇ fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ» fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ»fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumpˇs over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n «fox-jumpˇ»s over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n «fox-jumpsˇ» over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dogˇ \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dogˇ \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog« ˇ»\n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \nˇ\n"}} {"Key":"v"} {"Key":"i"} From 5edcb747609222f602138798d1a80e50f1160dae Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 27 Jul 2023 21:39:37 -0600 Subject: [PATCH 02/12] Add support for visual ranges ending with a newline These primarily happen when first entering visual mode, but can also be created with objects like `vi{`. Along the way fix the way ranges like `vi{` are selected to be more similar to nvim. --- assets/keymaps/vim.json | 4 +- crates/editor/src/display_map.rs | 59 ++++------ crates/editor/src/editor.rs | 6 +- crates/editor/src/element.rs | 34 +++--- crates/editor/src/movement.rs | 12 ++ crates/editor/src/selections_collection.rs | 1 + crates/vim/src/mode_indicator.rs | 2 +- crates/vim/src/motion.rs | 12 +- crates/vim/src/normal.rs | 13 +-- crates/vim/src/normal/change.rs | 11 +- crates/vim/src/normal/delete.rs | 14 +-- crates/vim/src/normal/yank.rs | 5 +- crates/vim/src/object.rs | 79 ++++++++++++- crates/vim/src/state.rs | 7 +- crates/vim/src/test/neovim_connection.rs | 15 ++- crates/vim/src/vim.rs | 10 +- crates/vim/src/visual.rs | 108 +++++++++++++++--- .../test_enter_visual_line_mode.json | 10 ++ .../vim/test_data/test_enter_visual_mode.json | 8 ++ ...ltiline_surrounding_character_objects.json | 10 ++ .../test_data/test_visual_word_object.json | 16 +-- 21 files changed, 301 insertions(+), 135 deletions(-) create mode 100644 crates/vim/test_data/test_enter_visual_line_mode.json create mode 100644 crates/vim/test_data/test_multiline_surrounding_character_objects.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index a1b6d939f4..ce5ce5aabd 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -101,6 +101,8 @@ "vim::SwitchMode", "Normal" ], + "v": "vim::ToggleVisual", + "shift-v": "vim::ToggleVisualLine", "*": "vim::MoveToNext", "#": "vim::MoveToPrev", "0": "vim::StartOfLine", // When no number operator present, use start of line motion @@ -266,8 +268,6 @@ "o": "vim::InsertLineBelow", "shift-o": "vim::InsertLineAbove", "~": "vim::ChangeCase", - "v": "vim::ToggleVisual", - "shift-v": "vim::ToggleVisualLine", "p": "vim::Paste", "u": "editor::Undo", "ctrl-r": "editor::Redo", diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index be0dd68adf..bf787eb0f7 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -35,12 +35,6 @@ pub enum FoldStatus { Foldable, } -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum Clip { - None, - EndOfLine, -} - pub trait ToDisplayPoint { fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint; } @@ -56,7 +50,7 @@ pub struct DisplayMap { wrap_map: ModelHandle, block_map: BlockMap, text_highlights: TextHighlights, - pub default_clip: Clip, + pub clip_at_line_ends: bool, } impl Entity for DisplayMap { @@ -91,7 +85,7 @@ impl DisplayMap { wrap_map, block_map, text_highlights: Default::default(), - default_clip: Clip::None, + clip_at_line_ends: false, } } @@ -115,7 +109,7 @@ impl DisplayMap { wrap_snapshot, block_snapshot, text_highlights: self.text_highlights.clone(), - default_clip: self.default_clip, + clip_at_line_ends: self.clip_at_line_ends, } } @@ -302,7 +296,7 @@ pub struct DisplaySnapshot { wrap_snapshot: wrap_map::WrapSnapshot, block_snapshot: block_map::BlockSnapshot, text_highlights: TextHighlights, - default_clip: Clip, + clip_at_line_ends: bool, } impl DisplaySnapshot { @@ -361,6 +355,9 @@ impl DisplaySnapshot { pub fn expand_to_line(&self, range: Range) -> Range { let mut new_start = self.prev_line_boundary(range.start).0; + if range.end.column == 0 { + return new_start..range.end; + } let mut new_end = self.next_line_boundary(range.end).0; if new_start.row == range.start.row && new_end.row == range.end.row { @@ -583,33 +580,21 @@ impl DisplaySnapshot { column } - pub fn move_left(&self, point: DisplayPoint, clip: Clip) -> DisplayPoint { - self.clip_point_with( - DisplayPoint::new(point.row(), point.column().saturating_sub(1)), - Bias::Left, - clip, - ) - } - - pub fn move_right(&self, point: DisplayPoint, clip: Clip) -> DisplayPoint { - self.clip_point_with( - DisplayPoint::new(point.row(), point.column() + 1), - Bias::Right, - clip, - ) - } - - pub fn clip_point_with(&self, point: DisplayPoint, bias: Bias, clip: Clip) -> DisplayPoint { - let new_point = DisplayPoint(self.block_snapshot.clip_point(point.0, bias)); - if clip == Clip::EndOfLine && new_point.column() == self.line_len(new_point.row()) { - self.move_left(new_point, Clip::None) - } else { - new_point - } - } - pub fn clip_point(&self, point: DisplayPoint, bias: Bias) -> DisplayPoint { - self.clip_point_with(point, bias, self.default_clip) + let mut clipped = self.block_snapshot.clip_point(point.0, bias); + if self.clip_at_line_ends { + clipped = self.clip_at_line_end(DisplayPoint(clipped)).0 + } + DisplayPoint(clipped) + } + + pub fn clip_at_line_end(&self, point: DisplayPoint) -> DisplayPoint { + let mut point = point.0; + if point.column == self.line_len(point.row) { + point.column = point.column.saturating_sub(1); + point = self.block_snapshot.clip_point(point, Bias::Left); + } + DisplayPoint(point) } pub fn folds_in_range(&self, range: Range) -> impl Iterator> @@ -1598,7 +1583,7 @@ pub mod tests { fn assert(text: &str, cx: &mut gpui::AppContext) { let (mut unmarked_snapshot, markers) = marked_display_snapshot(text, cx); - unmarked_snapshot.default_clip = Clip::EndOfLine; + unmarked_snapshot.clip_at_line_ends = true; assert_eq!( unmarked_snapshot.clip_point(markers[1], Bias::Left), markers[0] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4b36691b60..b4145edb64 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1544,10 +1544,10 @@ impl Editor { range.clone() } - pub fn set_default_clip(&mut self, clip: Clip, cx: &mut ViewContext) { - if self.display_map.read(cx).default_clip != clip { + pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut ViewContext) { + if self.display_map.read(cx).clip_at_line_ends != clip { self.display_map - .update(cx, |map, _| map.default_clip = clip); + .update(cx, |map, _| map.clip_at_line_ends = clip); } } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 6440b56728..cce302ed3f 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -847,23 +847,26 @@ impl EditorElement { if editor.show_local_cursors(cx) || replica_id != local_replica_id { let cursor_position = selection.head; + let mut cursor_column = cursor_position.column() as usize; + let mut cursor_row = cursor_position.row(); - if layout - .visible_display_row_range - .contains(&cursor_position.row()) + if CursorShape::Block == selection.cursor_shape + && !selection.range.is_empty() + && !selection.reversed { - let cursor_row_layout = &layout.position_map.line_layouts - [(cursor_position.row() - start_row) as usize] - .line; - let mut cursor_column = cursor_position.column() as usize; - - if CursorShape::Block == selection.cursor_shape - && !selection.range.is_empty() - && !selection.reversed - && cursor_column > 0 - { + if cursor_column > 0 { cursor_column -= 1; + } else if cursor_row > 0 { + cursor_row -= 1; + cursor_column = + layout.position_map.snapshot.line_len(cursor_row) as usize; } + } + + if layout.visible_display_row_range.contains(&cursor_row) { + let cursor_row_layout = &layout.position_map.line_layouts + [(cursor_row - start_row) as usize] + .line; let cursor_character_x = cursor_row_layout.x_for_index(cursor_column); let mut block_width = @@ -876,7 +879,7 @@ impl EditorElement { .position_map .snapshot .chars_at(DisplayPoint::new( - cursor_position.row(), + cursor_row as u32, cursor_column as u32, )) .next() @@ -903,8 +906,7 @@ impl EditorElement { }; let x = cursor_character_x - scroll_left; - let y = cursor_position.row() as f32 * layout.position_map.line_height - - scroll_top; + let y = cursor_row as f32 * layout.position_map.line_height - scroll_top; if selection.is_newest { editor.pixel_position_of_newest_cursor = Some(vec2f( bounds.origin_x() + x + block_width / 2., diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 1bd37da52f..f70436abeb 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -13,6 +13,13 @@ pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { map.clip_point(point, Bias::Left) } +pub fn saturating_left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { + if point.column() > 0 { + *point.column_mut() -= 1; + } + map.clip_point(point, Bias::Left) +} + pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { let max_column = map.line_len(point.row()); if point.column() < max_column { @@ -24,6 +31,11 @@ pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { map.clip_point(point, Bias::Right) } +pub fn saturating_right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { + *point.column_mut() += 1; + map.clip_point(point, Bias::Right) +} + pub fn up( map: &DisplaySnapshot, start: DisplayPoint, diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 1921bc0738..5f88eaecd8 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -498,6 +498,7 @@ impl<'a> MutableSelectionsCollection<'a> { T: ToOffset + ToPoint + Ord + std::marker::Copy + std::fmt::Debug, { let buffer = self.buffer.read(self.cx).snapshot(self.cx); + selections.sort_unstable_by_key(|s| s.start); // Merge overlapping selections. let mut i = 1; diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index 639a7594f1..4d68ec534e 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -87,7 +87,7 @@ impl View for ModeIndicator { Mode::Normal => "-- NORMAL --", Mode::Insert => "-- INSERT --", Mode::Visual { line: false } => "-- VISUAL --", - Mode::Visual { line: true } => "VISUAL LINE ", + Mode::Visual { line: true } => "VISUAL LINE", }; Label::new(text, theme.vim_mode_indicator.text.clone()) .contained() diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 924fefc73f..acf9d46ad3 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use editor::{ char_kind, - display_map::{Clip, DisplaySnapshot, ToDisplayPoint}, + display_map::{DisplaySnapshot, ToDisplayPoint}, movement, Bias, CharKind, DisplayPoint, ToOffset, }; use gpui::{actions, impl_actions, AppContext, WindowContext}; @@ -295,11 +295,7 @@ impl Motion { SelectionGoal::None, ), EndOfParagraph => ( - map.clip_point_with( - movement::end_of_paragraph(map, point, times), - Bias::Left, - Clip::EndOfLine, - ), + map.clip_at_line_end(movement::end_of_paragraph(map, point, times)), SelectionGoal::None, ), CurrentLine => (end_of_line(map, point), SelectionGoal::None), @@ -387,7 +383,7 @@ impl Motion { fn left(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { for _ in 0..times { - point = map.move_left(point, Clip::None); + point = movement::saturating_left(map, point); if point.column() == 0 { break; } @@ -428,7 +424,7 @@ fn up( pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { for _ in 0..times { - let new_point = map.clip_point(map.move_right(point, Clip::None), Bias::Right); + let new_point = movement::saturating_right(map, point); if point == new_point { break; } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index a25558fa70..79c990ffeb 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -16,9 +16,8 @@ use crate::{ }; use collections::{HashMap, HashSet}; use editor::{ - display_map::{Clip, ToDisplayPoint}, - scroll::autoscroll::Autoscroll, - Anchor, Bias, ClipboardSelection, DisplayPoint, + display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Anchor, Bias, ClipboardSelection, + DisplayPoint, }; use gpui::{actions, AppContext, ViewContext, WindowContext}; use language::{AutoindentMode, Point, SelectionGoal}; @@ -255,7 +254,7 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_default_clip(Clip::None, cx); + editor.set_clip_at_line_ends(false, cx); if let Some(item) = cx.read_from_clipboard() { let mut clipboard_text = Cow::Borrowed(item.text()); if let Some(mut clipboard_selections) = @@ -383,7 +382,7 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { editor.insert(&clipboard_text, cx); } } - editor.set_default_clip(Clip::EndOfLine, cx); + editor.set_clip_at_line_ends(true, cx); }); }); }); @@ -393,7 +392,7 @@ pub(crate) fn normal_replace(text: Arc, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_default_clip(Clip::None, cx); + editor.set_clip_at_line_ends(false, cx); let (map, display_selections) = editor.selections.all_display(cx); // Selections are biased right at the start. So we need to store // anchors that are biased left so that we can restore the selections @@ -426,7 +425,7 @@ pub(crate) fn normal_replace(text: Arc, cx: &mut WindowContext) { editor.buffer().update(cx, |buffer, cx| { buffer.edit(edits, None, cx); }); - editor.set_default_clip(Clip::EndOfLine, cx); + editor.set_clip_at_line_ends(true, cx); editor.change_selections(None, cx, |s| { s.select_anchor_ranges(stable_anchors); }); diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 6ce3a3ac6c..d226c70410 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -1,10 +1,7 @@ use crate::{motion::Motion, object::Object, state::Mode, utils::copy_selections_content, Vim}; use editor::{ - char_kind, - display_map::{Clip, DisplaySnapshot}, - movement, - scroll::autoscroll::Autoscroll, - CharKind, DisplayPoint, + char_kind, display_map::DisplaySnapshot, movement, scroll::autoscroll::Autoscroll, CharKind, + DisplayPoint, }; use gpui::WindowContext; use language::Selection; @@ -18,7 +15,7 @@ pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now - editor.set_default_clip(Clip::None, cx); + editor.set_clip_at_line_ends(false, cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { motion_succeeded |= if let Motion::NextWordStart { ignore_punctuation } = motion @@ -45,7 +42,7 @@ pub fn change_object(vim: &mut Vim, object: Object, around: bool, cx: &mut Windo let mut objects_found = false; vim.update_active_editor(cx, |editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now - editor.set_default_clip(Clip::None, cx); + editor.set_clip_at_line_ends(false, cx); editor.transact(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index ed8fcabdc9..56fef78e1d 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -1,16 +1,12 @@ use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; use collections::{HashMap, HashSet}; -use editor::{ - display_map::{Clip, ToDisplayPoint}, - scroll::autoscroll::Autoscroll, - Bias, -}; +use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Bias}; use gpui::WindowContext; pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_default_clip(Clip::None, cx); + editor.set_clip_at_line_ends(false, cx); let mut original_columns: HashMap<_, _> = Default::default(); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { @@ -23,7 +19,7 @@ pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m editor.insert("", cx); // Fixup cursor position after the deletion - editor.set_default_clip(Clip::EndOfLine, cx); + editor.set_clip_at_line_ends(true, cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { let mut cursor = selection.head(); @@ -43,7 +39,7 @@ pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_default_clip(Clip::None, cx); + editor.set_clip_at_line_ends(false, cx); // Emulates behavior in vim where if we expanded backwards to include a newline // the cursor gets set back to the start of the line let mut should_move_to_start: HashSet<_> = Default::default(); @@ -81,7 +77,7 @@ pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut Windo editor.insert("", cx); // Fixup cursor position after the deletion - editor.set_default_clip(Clip::EndOfLine, cx); + editor.set_clip_at_line_ends(true, cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { let mut cursor = selection.head(); diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index beb468ecb0..7212a865bd 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -1,12 +1,11 @@ use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; use collections::HashMap; -use editor::display_map::Clip; use gpui::WindowContext; pub fn yank_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_default_clip(Clip::None, cx); + editor.set_clip_at_line_ends(false, cx); let mut original_positions: HashMap<_, _> = Default::default(); editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { @@ -29,7 +28,7 @@ pub fn yank_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut pub fn yank_object(vim: &mut Vim, object: Object, around: bool, cx: &mut WindowContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { - editor.set_default_clip(Clip::None, cx); + editor.set_clip_at_line_ends(false, cx); let mut original_positions: HashMap<_, _> = Default::default(); editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 837b619fc5..85e6eab692 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -369,7 +369,7 @@ fn surrounding_markers( start = Some(point) } else { *point.column_mut() += char.len_utf8() as u32; - start = Some(point); + start = Some(point) } break; } @@ -420,11 +420,38 @@ fn surrounding_markers( } } - if let (Some(start), Some(end)) = (start, end) { - Some(start..end) - } else { - None + let (Some(mut start), Some(mut end)) = (start, end) else { + return None; + }; + + if !around { + // if a block starts with a newline, move the start to after the newline. + let mut was_newline = false; + for (char, point) in map.chars_at(start) { + if was_newline { + start = point; + } else if char == '\n' { + was_newline = true; + continue; + } + break; + } + // if a block ends with a newline, then whitespace, then the delimeter, + // move the end to after the newline. + let mut new_end = end; + for (char, point) in map.reverse_chars_at(end) { + if char == '\n' { + end = new_end; + break; + } + if !char.is_whitespace() { + break; + } + new_end = point + } } + + Some(start..end) } #[cfg(test)] @@ -681,6 +708,48 @@ mod test { } } + #[gpui::test] + async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! { + "func empty(a string) bool { + if a == \"\" { + return true + } + ˇreturn false + }" + }) + .await; + cx.simulate_shared_keystrokes(["v", "i", "{"]).await; + cx.assert_shared_state(indoc! {" + func empty(a string) bool { + « if a == \"\" { + return true + } + return false + ˇ»}"}) + .await; + cx.set_shared_state(indoc! { + "func empty(a string) bool { + if a == \"\" { + ˇreturn true + } + return false + }" + }) + .await; + cx.simulate_shared_keystrokes(["v", "i", "{"]).await; + cx.assert_shared_state(indoc! {" + func empty(a string) bool { + if a == \"\" { + « return true + ˇ» } + return false + }"}) + .await; + } + #[gpui::test] async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index af78741333..905bd5fd2a 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,4 +1,3 @@ -use editor::display_map::Clip; use gpui::keymap_matcher::KeymapContext; use language::CursorShape; use serde::{Deserialize, Serialize}; @@ -88,10 +87,10 @@ impl VimState { ) } - pub fn default_clip(&self) -> Clip { + pub fn clip_at_line_ends(&self) -> bool { match self.mode { - Mode::Insert | Mode::Visual { .. } => Clip::None, - Mode::Normal => Clip::EndOfLine, + Mode::Insert | Mode::Visual { .. } => false, + Mode::Normal => true, } } diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs index 21d69d2152..dd9be10723 100644 --- a/crates/vim/src/test/neovim_connection.rs +++ b/crates/vim/src/test/neovim_connection.rs @@ -237,10 +237,11 @@ impl NeovimConnection { .join("\n"); // nvim columns are 1-based, so -1. - let cursor_row = self.read_position("echo line('.')").await - 1; + let mut cursor_row = self.read_position("echo line('.')").await - 1; let mut cursor_col = self.read_position("echo col('.')").await - 1; - let selection_row = self.read_position("echo line('v')").await - 1; + let mut selection_row = self.read_position("echo line('v')").await - 1; let mut selection_col = self.read_position("echo col('v')").await - 1; + let total_rows = self.read_position("echo line('$')").await - 1; let nvim_mode_text = self .nvim @@ -273,14 +274,20 @@ impl NeovimConnection { if selection_col > cursor_col { let selection_line_length = self.read_position("echo strlen(getline(line('v')))").await; - if selection_line_length > 0 { + if selection_line_length > selection_col { selection_col += 1; + } else if selection_row < total_rows { + selection_col = 0; + selection_row += 1; } } else { let cursor_line_length = self.read_position("echo strlen(getline(line('.')))").await; - if cursor_line_length > 0 { + if cursor_line_length > cursor_col { cursor_col += 1; + } else if cursor_row < total_rows { + cursor_col = 0; + cursor_row += 1; } } } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index c9386eb0bc..e8d69d696c 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -13,7 +13,7 @@ mod visual; use anyhow::Result; use collections::CommandPaletteFilter; -use editor::{display_map::Clip, Editor, EditorMode, Event}; +use editor::{movement, Editor, EditorMode, Event}; use gpui::{ actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext, Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext, @@ -201,12 +201,12 @@ impl Vim { if last_mode.is_visual() && !mode.is_visual() { let mut point = selection.head(); if !selection.reversed { - point = map.move_left(selection.head(), Clip::None); + point = movement::left(map, selection.head()); } selection.collapse_to(point, selection.goal) } else if !last_mode.is_visual() && mode.is_visual() { if selection.is_empty() { - selection.end = map.move_right(selection.start, Clip::None); + selection.end = movement::right(map, selection.start); } } }); @@ -314,7 +314,7 @@ impl Vim { self.update_active_editor(cx, |editor, cx| { if self.enabled && editor.mode() == EditorMode::Full { editor.set_cursor_shape(cursor_shape, cx); - editor.set_default_clip(state.default_clip(), cx); + editor.set_clip_at_line_ends(state.clip_at_line_ends(), cx); editor.set_collapse_matches(true); editor.set_input_enabled(!state.vim_controlled()); editor.selections.line_mode = matches!(state.mode, Mode::Visual { line: true }); @@ -331,7 +331,7 @@ impl Vim { fn unhook_vim_settings(&self, editor: &mut Editor, cx: &mut ViewContext) { editor.set_cursor_shape(CursorShape::Bar, cx); - editor.set_default_clip(Clip::None, cx); + editor.set_clip_at_line_ends(false, cx); editor.set_input_enabled(true); editor.selections.line_mode = false; diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 22d96e2d1c..eae3c80f38 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -2,10 +2,7 @@ use std::{borrow::Cow, sync::Arc}; use collections::HashMap; use editor::{ - display_map::{Clip, ToDisplayPoint}, - movement, - scroll::autoscroll::Autoscroll, - Bias, ClipboardSelection, + display_map::ToDisplayPoint, movement, scroll::autoscroll::Autoscroll, Bias, ClipboardSelection, }; use gpui::{actions, AppContext, ViewContext, WindowContext}; use language::{AutoindentMode, SelectionGoal}; @@ -53,7 +50,7 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex // but in (forward) visual mode the current character is just // before the end of the selection. if !selection.reversed { - current_head = map.move_left(current_head, Clip::None); + current_head = movement::left(map, selection.end) } let Some((new_head, goal)) = @@ -63,16 +60,16 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex // ensure the current character is included in the selection. if !selection.reversed { - selection.end = map.move_right(selection.end, Clip::None); + selection.end = movement::right(map, selection.end) } // vim always ensures the anchor character stays selected. // if our selection has reversed, we need to move the opposite end // to ensure the anchor is still selected. if was_reversed && !selection.reversed { - selection.start = map.move_left(selection.start, Clip::None); + selection.start = movement::left(map, selection.start); } else if !was_reversed && selection.reversed { - selection.end = map.move_right(selection.end, Clip::None); + selection.end = movement::right(map, selection.end); } }); }); @@ -94,7 +91,7 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) { // after the cursor; however in the case of a visual selection // the current character is before the cursor. if !selection.reversed { - head = map.move_left(head, Clip::None); + head = movement::left(map, head); } if let Some(range) = object.range(map, head, around) { @@ -109,7 +106,6 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) { } else { false }; - dbg!(expand_both_ways); if expand_both_ways { selection.start = range.start; @@ -206,7 +202,7 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext, cx: &mut WindowContext) { #[cfg(test)] mod test { use indoc::indoc; + use workspace::item::Item; use crate::{ state::Mode, @@ -417,6 +410,7 @@ mod test { the lazy dog" }) .await; + let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor()); // entering visual mode should select the character // under cursor @@ -425,6 +419,7 @@ mod test { fox jumps over the lazy dog"}) .await; + cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor())); // forwards motions should extend the selection cx.simulate_shared_keystrokes(["w", "j"]).await; @@ -446,6 +441,87 @@ mod test { fox jumps o»ver the lazy dog"}) .await; + + // works on empty lines + cx.set_shared_state(indoc! {" + a + ˇ + b + "}) + .await; + let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor()); + cx.simulate_shared_keystrokes(["v"]).await; + cx.assert_shared_state(indoc! {" + a + « + ˇ»b + "}) + .await; + cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor())); + + // toggles off again + cx.simulate_shared_keystrokes(["v"]).await; + cx.assert_shared_state(indoc! {" + a + ˇ + b + "}) + .await; + + // works at the end of a document + cx.set_shared_state(indoc! {" + a + b + ˇ"}) + .await; + + cx.simulate_shared_keystrokes(["v"]).await; + cx.assert_shared_state(indoc! {" + a + b + ˇ"}) + .await; + assert_eq!(cx.mode(), cx.neovim_mode().await); + } + + #[gpui::test] + async fn test_enter_visual_line_mode(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! { + "The ˇquick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["shift-v"]).await; + cx.assert_shared_state(indoc! { "The «qˇ»uick brown + fox jumps over + the lazy dog"}) + .await; + assert_eq!(cx.mode(), cx.neovim_mode().await); + cx.simulate_shared_keystrokes(["x"]).await; + cx.assert_shared_state(indoc! { "fox ˇjumps over + the lazy dog"}) + .await; + + // it should work on empty lines + cx.set_shared_state(indoc! {" + a + ˇ + b"}) + .await; + cx.simulate_shared_keystrokes(["shift-v"]).await; + cx.assert_shared_state(indoc! { " + a + « + ˇ»b"}) + .await; + cx.simulate_shared_keystrokes(["x"]).await; + cx.assert_shared_state(indoc! { " + a + ˇb"}) + .await; } #[gpui::test] diff --git a/crates/vim/test_data/test_enter_visual_line_mode.json b/crates/vim/test_data/test_enter_visual_line_mode.json new file mode 100644 index 0000000000..09d7288d82 --- /dev/null +++ b/crates/vim/test_data/test_enter_visual_line_mode.json @@ -0,0 +1,10 @@ +{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"shift-v"} +{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":{"Visual":{"line":true}}}} +{"Key":"x"} +{"Get":{"state":"fox ˇjumps over\nthe lazy dog","mode":"Normal"}} +{"Put":{"state":"a\nˇ\nb"}} +{"Key":"shift-v"} +{"Get":{"state":"a\n«\nˇ»b","mode":{"Visual":{"line":true}}}} +{"Key":"x"} +{"Get":{"state":"a\nˇb","mode":"Normal"}} diff --git a/crates/vim/test_data/test_enter_visual_mode.json b/crates/vim/test_data/test_enter_visual_mode.json index 6b5274b892..4fdb4c7667 100644 --- a/crates/vim/test_data/test_enter_visual_mode.json +++ b/crates/vim/test_data/test_enter_visual_mode.json @@ -10,3 +10,11 @@ {"Key":"k"} {"Key":"b"} {"Get":{"state":"The «ˇquick brown\nfox jumps o»ver\nthe lazy dog","mode":{"Visual":{"line":false}}}} +{"Put":{"state":"a\nˇ\nb\n"}} +{"Key":"v"} +{"Get":{"state":"a\n«\nˇ»b\n","mode":{"Visual":{"line":false}}}} +{"Key":"v"} +{"Get":{"state":"a\nˇ\nb\n","mode":"Normal"}} +{"Put":{"state":"a\nb\nˇ"}} +{"Key":"v"} +{"Get":{"state":"a\nb\nˇ","mode":{"Visual":{"line":false}}}} diff --git a/crates/vim/test_data/test_multiline_surrounding_character_objects.json b/crates/vim/test_data/test_multiline_surrounding_character_objects.json new file mode 100644 index 0000000000..f683c0a314 --- /dev/null +++ b/crates/vim/test_data/test_multiline_surrounding_character_objects.json @@ -0,0 +1,10 @@ +{"Put":{"state":"func empty(a string) bool {\n if a == \"\" {\n return true\n }\n ˇreturn false\n}"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"{"} +{"Get":{"state":"func empty(a string) bool {\n« if a == \"\" {\n return true\n }\n return false\nˇ»}","mode":{"Visual":{"line":false}}}} +{"Put":{"state":"func empty(a string) bool {\n if a == \"\" {\n ˇreturn true\n }\n return false\n}"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"{"} +{"Get":{"state":"func empty(a string) bool {\n if a == \"\" {\n« return true\nˇ» }\n return false\n}","mode":{"Visual":{"line":false}}}} diff --git a/crates/vim/test_data/test_visual_word_object.json b/crates/vim/test_data/test_visual_word_object.json index b1765459e0..b1c43bf9a2 100644 --- a/crates/vim/test_data/test_visual_word_object.json +++ b/crates/vim/test_data/test_visual_word_object.json @@ -43,17 +43,17 @@ {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \nˇ\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n«\nˇ»\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\nˇ\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\nˇ\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n«\nˇ»\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\nˇ\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\nˇ\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n«\nˇ»The-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThˇe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} @@ -118,7 +118,7 @@ {"Key":"v"} {"Key":"i"} {"Key":"w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \nˇ\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n«\nˇ»","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick ˇbrown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} @@ -158,17 +158,17 @@ {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \nˇ\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n«\nˇ»\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\nˇ\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\nˇ\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n«\nˇ»\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\nˇ\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\nˇ\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n«\nˇ»The-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}} {"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThˇe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}} {"Key":"v"} {"Key":"i"} @@ -233,4 +233,4 @@ {"Key":"v"} {"Key":"i"} {"Key":"shift-w"} -{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \nˇ\n","mode":{"Visual":{"line":false}}}} +{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n«\nˇ»","mode":{"Visual":{"line":false}}}} From 3f2f3bb78dec914f0ab627111851d8b370918304 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 28 Jul 2023 13:46:25 -0600 Subject: [PATCH 03/12] Fix crash when deleting a long line in visual line mode --- crates/vim/src/visual.rs | 18 ++++++++++++++++-- .../vim/test_data/test_visual_line_delete.json | 5 +++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index eae3c80f38..cad82bc1ea 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -197,6 +197,7 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext = Default::default(); let line_mode = editor.selections.line_mode; + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { if line_mode { @@ -218,9 +219,13 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext Date: Fri, 28 Jul 2023 13:54:03 -0600 Subject: [PATCH 04/12] Add o/O for flipping selection --- assets/keymaps/vim.json | 2 ++ crates/vim/src/visual.rs | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index ce5ce5aabd..550d294ea0 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -360,6 +360,8 @@ "context": "Editor && vim_mode == visual && !VimWaiting", "bindings": { "u": "editor::Undo", + "o": "vim::OtherEnd", + "shift-o": "vim::OtherEnd", "c": "vim::VisualChange", "d": "vim::VisualDelete", "x": "vim::VisualDelete", diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index cad82bc1ea..ed41c70daa 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -24,13 +24,15 @@ actions!( VisualDelete, VisualChange, VisualYank, - VisualPaste + VisualPaste, + OtherEnd, ] ); pub fn init(cx: &mut AppContext) { cx.add_action(toggle_visual); cx.add_action(toggle_visual_line); + cx.add_action(other_end); cx.add_action(change); cx.add_action(delete); cx.add_action(yank); @@ -150,6 +152,18 @@ pub fn toggle_visual_line( }) } +pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.move_with(|_, selection| { + selection.reversed = !selection.reversed; + }) + }) + }) + }); +} + pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { From 236b755b1dc443f65914e7420cf259ec41caa90a Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 28 Jul 2023 14:14:15 -0600 Subject: [PATCH 05/12] Fix substitute in visual line mode --- crates/vim/src/normal/substitute.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index 2f022dca98..57388710e9 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -4,6 +4,7 @@ use language::Point; use crate::{motion::Motion, Mode, Vim}; pub fn substitute(vim: &mut Vim, count: Option, cx: &mut WindowContext) { + let line_mode = vim.state.mode == Mode::Visual { line: true }; vim.switch_mode(Mode::Insert, true, cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { @@ -12,6 +13,17 @@ pub fn substitute(vim: &mut Vim, count: Option, cx: &mut WindowContext) { if selection.start == selection.end { Motion::Right.expand_selection(map, selection, count, true); } + if line_mode { + Motion::CurrentLine.expand_selection(map, selection, None, false); + if let Some((point, _)) = Motion::FirstNonWhitespace.move_point( + map, + selection.start, + selection.goal, + None, + ) { + selection.start = point; + } + } }) }); let selections = editor.selections.all::(cx); @@ -67,5 +79,19 @@ mod test { // should transactionally undo selection changes cx.simulate_keystrokes(["escape", "u"]); cx.assert_editor_state("ˇcàfé\n"); + + // it handles visual line mode + cx.set_state( + indoc! {" + alpha + beˇta + gamma"}, + Mode::Normal, + ); + cx.simulate_keystrokes(["shift-v", "s"]); + cx.assert_editor_state(indoc! {" + alpha + ˇ + gamma"}); } } From 0c15ef730535dd96687aac7c24009a5b6b3fda02 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 28 Jul 2023 14:38:45 -0600 Subject: [PATCH 06/12] Merge VisualChange -> Substitute They both are supposed to work the same. --- assets/keymaps/vim.json | 2 +- crates/vim/src/normal.rs | 2 +- crates/vim/src/normal/substitute.rs | 84 +++++++++++-- crates/vim/src/visual.rs | 121 +------------------ crates/vim/test_data/test_visual_delete.json | 4 + 5 files changed, 87 insertions(+), 126 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 550d294ea0..880fe20188 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -362,12 +362,12 @@ "u": "editor::Undo", "o": "vim::OtherEnd", "shift-o": "vim::OtherEnd", - "c": "vim::VisualChange", "d": "vim::VisualDelete", "x": "vim::VisualDelete", "y": "vim::VisualYank", "p": "vim::VisualPaste", "s": "vim::Substitute", + "c": "vim::Substitute", "~": "vim::ChangeCase", "r": [ "vim::PushOperator", diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 79c990ffeb..5ac3e86165 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -3,7 +3,7 @@ mod change; mod delete; mod scroll; mod search; -mod substitute; +pub mod substitute; mod yank; use std::{borrow::Cow, sync::Arc}; diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index 57388710e9..d2429433fe 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -1,7 +1,7 @@ use gpui::WindowContext; use language::Point; -use crate::{motion::Motion, Mode, Vim}; +use crate::{motion::Motion, utils::copy_selections_content, Mode, Vim}; pub fn substitute(vim: &mut Vim, count: Option, cx: &mut WindowContext) { let line_mode = vim.state.mode == Mode::Visual { line: true }; @@ -26,19 +26,20 @@ pub fn substitute(vim: &mut Vim, count: Option, cx: &mut WindowContext) { } }) }); - let selections = editor.selections.all::(cx); - for selection in selections.into_iter().rev() { - editor.buffer().update(cx, |buffer, cx| { - buffer.edit([(selection.start..selection.end, "")], None, cx) - }) - } + copy_selections_content(editor, line_mode, cx); + let selections = editor.selections.all::(cx).into_iter(); + let edits = selections.map(|selection| (selection.start..selection.end, "")); + editor.edit(edits, cx); }); }); } #[cfg(test)] mod test { - use crate::{state::Mode, test::VimTestContext}; + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; use indoc::indoc; #[gpui::test] @@ -94,4 +95,71 @@ mod test { ˇ gamma"}); } + + #[gpui::test] + async fn test_visual_change(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("The quick ˇbrown").await; + cx.simulate_shared_keystrokes(["v", "w", "c"]).await; + cx.assert_shared_state("The quick ˇ").await; + + cx.set_shared_state(indoc! {" + The ˇquick brown + fox jumps over + the lazy dog"}) + .await; + cx.simulate_shared_keystrokes(["v", "w", "j", "c"]).await; + cx.assert_shared_state(indoc! {" + The ˇver + the lazy dog"}) + .await; + + let cases = cx.each_marked_position(indoc! {" + The ˇquick brown + fox jumps ˇover + the ˇlazy dog"}); + for initial_state in cases { + cx.assert_neovim_compatible(&initial_state, ["v", "w", "j", "c"]) + .await; + cx.assert_neovim_compatible(&initial_state, ["v", "w", "k", "c"]) + .await; + } + } + + #[gpui::test] + async fn test_visual_line_change(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["shift-v", "c"]); + cx.assert(indoc! {" + The quˇick brown + fox jumps over + the lazy dog"}) + .await; + // Test pasting code copied on change + cx.simulate_shared_keystrokes(["escape", "j", "p"]).await; + cx.assert_state_matches().await; + + cx.assert_all(indoc! {" + The quick brown + fox juˇmps over + the laˇzy dog"}) + .await; + let mut cx = cx.binding(["shift-v", "j", "c"]); + cx.assert(indoc! {" + The quˇick brown + fox jumps over + the lazy dog"}) + .await; + // Test pasting code copied on delete + cx.simulate_shared_keystrokes(["escape", "j", "p"]).await; + cx.assert_state_matches().await; + + cx.assert_all(indoc! {" + The quick brown + fox juˇmps over + the laˇzy dog"}) + .await; + } } diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index ed41c70daa..a062c5972e 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -22,7 +22,6 @@ actions!( ToggleVisual, ToggleVisualLine, VisualDelete, - VisualChange, VisualYank, VisualPaste, OtherEnd, @@ -33,7 +32,6 @@ pub fn init(cx: &mut AppContext) { cx.add_action(toggle_visual); cx.add_action(toggle_visual_line); cx.add_action(other_end); - cx.add_action(change); cx.add_action(delete); cx.add_action(yank); cx.add_action(paste); @@ -164,48 +162,6 @@ pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext) { - Vim::update(cx, |vim, cx| { - vim.update_active_editor(cx, |editor, cx| { - // Compute edits and resulting anchor selections. If in line mode, adjust - // the anchor location and additional newline - let mut edits = Vec::new(); - let mut new_selections = Vec::new(); - let line_mode = editor.selections.line_mode; - editor.change_selections(None, cx, |s| { - s.move_with(|map, selection| { - if line_mode { - let range = selection.map(|p| p.to_point(map)).range(); - let expanded_range = map.expand_to_line(range); - // If we are at the last line, the anchor needs to be after the newline so that - // it is on a line of its own. Otherwise, the anchor may be after the newline - let anchor = if expanded_range.end == map.buffer_snapshot.max_point() { - map.buffer_snapshot.anchor_after(expanded_range.end) - } else { - map.buffer_snapshot.anchor_before(expanded_range.start) - }; - - edits.push((expanded_range, "\n")); - new_selections.push(selection.map(|_| anchor)); - } else { - let range = selection.map(|p| p.to_point(map)).range(); - let anchor = map.buffer_snapshot.anchor_after(range.end); - edits.push((range, "")); - new_selections.push(selection.map(|_| anchor)); - } - selection.goal = SelectionGoal::None; - }); - }); - copy_selections_content(editor, editor.selections.line_mode, cx); - editor.edit_with_autoindent(edits, cx); - editor.change_selections(Some(Autoscroll::fit()), cx, |s| { - s.select_anchors(new_selections); - }); - }); - vim.switch_mode(Mode::Insert, true, cx); - }); -} - pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { @@ -228,16 +184,13 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext Date: Fri, 28 Jul 2023 15:36:14 -0600 Subject: [PATCH 07/12] Review --- crates/editor/src/selections_collection.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 5f88eaecd8..1921bc0738 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -498,7 +498,6 @@ impl<'a> MutableSelectionsCollection<'a> { T: ToOffset + ToPoint + Ord + std::marker::Copy + std::fmt::Debug, { let buffer = self.buffer.read(self.cx).snapshot(self.cx); - selections.sort_unstable_by_key(|s| s.start); // Merge overlapping selections. let mut i = 1; From 645c14934411bee4989662945c53d530856f5aaa Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 28 Jul 2023 22:23:14 -0600 Subject: [PATCH 08/12] Fix visual selection of trailing newline --- crates/editor/src/display_map.rs | 33 +++++++++-------- crates/editor/src/element.rs | 19 ++++++++-- crates/vim/src/visual.rs | 35 +++++++++++++++++-- .../test_enter_visual_line_mode.json | 5 +++ 4 files changed, 73 insertions(+), 19 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index bf787eb0f7..31183c484d 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -353,27 +353,30 @@ impl DisplaySnapshot { } } + // used by line_mode selections and tries to match vim behaviour pub fn expand_to_line(&self, range: Range) -> Range { - let mut new_start = self.prev_line_boundary(range.start).0; - if range.end.column == 0 { - return new_start..range.end; - } - let mut new_end = self.next_line_boundary(range.end).0; + let new_start = if range.start.row == 0 { + Point::new(0, 0) + } else if range.start.row == self.max_buffer_row() + || (range.end.column > 0 && range.end.row == self.max_buffer_row()) + { + Point::new(range.start.row - 1, self.line_len(range.start.row - 1)) + } else { + self.prev_line_boundary(range.start).0 + }; - if new_start.row == range.start.row && new_end.row == range.end.row { - if new_end.row < self.buffer_snapshot.max_point().row { - new_end.row += 1; - new_end.column = 0; - } else if new_start.row > 0 { - new_start.row -= 1; - new_start.column = self.buffer_snapshot.line_len(new_start.row); - } - } + let new_end = if range.end.column == 0 { + range.end + } else if range.end.row < self.max_buffer_row() { + Point::new(range.end.row + 1, 0) + } else { + self.buffer_snapshot.max_point() + }; new_start..new_end } - fn point_to_display_point(&self, point: Point, bias: Bias) -> DisplayPoint { + pub fn point_to_display_point(&self, point: Point, bias: Bias) -> DisplayPoint { let inlay_point = self.inlay_snapshot.to_inlay_point(point); let fold_point = self.fold_snapshot.to_fold_point(inlay_point, bias); let tab_point = self.tab_snapshot.to_tab_point(fold_point); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index cce302ed3f..98bf41cdae 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -850,13 +850,16 @@ impl EditorElement { let mut cursor_column = cursor_position.column() as usize; let mut cursor_row = cursor_position.row(); + // highlight the last character in a selection if CursorShape::Block == selection.cursor_shape && !selection.range.is_empty() && !selection.reversed { if cursor_column > 0 { cursor_column -= 1; - } else if cursor_row > 0 { + } else if cursor_row > 0 + && cursor_position != layout.position_map.snapshot.max_point() + { cursor_row -= 1; cursor_column = layout.position_map.snapshot.line_len(cursor_row) as usize; @@ -2186,7 +2189,19 @@ impl Element for EditorElement { for selection in &local_selections { let is_empty = selection.start == selection.end; let selection_start = snapshot.prev_line_boundary(selection.start).1; - let selection_end = snapshot.next_line_boundary(selection.end).1; + let mut selection_end = snapshot.next_line_boundary(selection.end).1; + + // in vim visual mode the newline is considered at the end of the previous line + // instead of at the start of the current line + if editor.cursor_shape == CursorShape::Block + && !is_empty + && !selection.reversed + && selection.end.column == 0 + && selection_end.row() > 0 + && selection_end.row() < snapshot.max_buffer_row() + { + selection_end = DisplayPoint::new(selection_end.row() - 1, 0); + } for row in cmp::max(selection_start.row(), start_row) ..=cmp::min(selection_end.row(), end_row) { diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index a062c5972e..8338d32de4 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -49,7 +49,14 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex // our motions assume the current character is after the cursor, // but in (forward) visual mode the current character is just // before the end of the selection. - if !selection.reversed { + + // If the file ends with a newline (which is common) we don't do this. + // so that if you go to the end of such a file you can use "up" to go + // to the previous line and have it work somewhat as expected. + if !selection.reversed + && !selection.is_empty() + && !(selection.end.column() == 0 && selection.end == map.max_point()) + { current_head = movement::left(map, selection.end) } @@ -60,7 +67,10 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex // ensure the current character is included in the selection. if !selection.reversed { - selection.end = movement::right(map, selection.end) + let next_point = movement::right(map, selection.end); + if !(next_point.column() == 0 && next_point == map.max_point()) { + selection.end = movement::right(map, selection.end) + } } // vim always ensures the anchor character stays selected. @@ -494,6 +504,27 @@ mod test { a ˇb"}) .await; + + // it should work at the end of the document + cx.set_shared_state(indoc! {" + a + b + ˇ"}) + .await; + let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor()); + cx.simulate_shared_keystrokes(["shift-v"]).await; + cx.assert_shared_state(indoc! {" + a + b + ˇ"}) + .await; + assert_eq!(cx.mode(), cx.neovim_mode().await); + cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor())); + cx.simulate_shared_keystrokes(["x"]).await; + cx.assert_shared_state(indoc! {" + a + ˇb"}) + .await; } #[gpui::test] diff --git a/crates/vim/test_data/test_enter_visual_line_mode.json b/crates/vim/test_data/test_enter_visual_line_mode.json index 09d7288d82..6769145412 100644 --- a/crates/vim/test_data/test_enter_visual_line_mode.json +++ b/crates/vim/test_data/test_enter_visual_line_mode.json @@ -8,3 +8,8 @@ {"Get":{"state":"a\n«\nˇ»b","mode":{"Visual":{"line":true}}}} {"Key":"x"} {"Get":{"state":"a\nˇb","mode":"Normal"}} +{"Put":{"state":"a\nb\nˇ"}} +{"Key":"shift-v"} +{"Get":{"state":"a\nb\nˇ","mode":{"Visual":{"line":true}}}} +{"Key":"x"} +{"Get":{"state":"a\nˇb","mode":"Normal"}} From 5f6535e92bccc248267dc2f227f6d87280b695ae Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 2 Aug 2023 15:06:46 +0100 Subject: [PATCH 09/12] TEMP --- crates/editor/src/element.rs | 1 + crates/vim/src/visual.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 98bf41cdae..bc6c2f0bb8 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -865,6 +865,7 @@ impl EditorElement { layout.position_map.snapshot.line_len(cursor_row) as usize; } } + dbg!(selection.head, cursor_row, cursor_column); if layout.visible_display_row_range.contains(&cursor_row) { let cursor_row_layout = &layout.position_map.line_layouts diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 8338d32de4..1716e2d1a5 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -67,7 +67,9 @@ pub fn visual_motion(motion: Motion, times: Option, cx: &mut WindowContex // ensure the current character is included in the selection. if !selection.reversed { + // TODO: maybe try clipping left for multi-buffers let next_point = movement::right(map, selection.end); + if !(next_point.column() == 0 && next_point == map.max_point()) { selection.end = movement::right(map, selection.end) } From 22927fa1d7c8041e71bcba78f12230814a8d006d Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 4 Aug 2023 14:39:05 +0100 Subject: [PATCH 10/12] Fix visual selection cursor in multibuffers --- crates/editor/src/element.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index bc6c2f0bb8..d2e7ab00d7 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -860,9 +860,15 @@ impl EditorElement { } else if cursor_row > 0 && cursor_position != layout.position_map.snapshot.max_point() { - cursor_row -= 1; - cursor_column = - layout.position_map.snapshot.line_len(cursor_row) as usize; + let new = layout.position_map.snapshot.clip_point( + DisplayPoint::new( + cursor_row - 1, + layout.position_map.snapshot.line_len(cursor_row), + ), + Bias::Left, + ); + cursor_row = new.row(); + cursor_column = new.column() as usize; } } dbg!(selection.head, cursor_row, cursor_column); From 19eb28035144f18111ae88657f14bc66aa957860 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Sun, 6 Aug 2023 23:42:30 +0100 Subject: [PATCH 11/12] Fix selection background too Refactor code to centralize the logic too --- assets/settings/default.json | 4 +- crates/editor/src/display_map.rs | 5 +- crates/editor/src/element.rs | 235 ++++++++++++++++++------------- 3 files changed, 141 insertions(+), 103 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 397dac0961..c6235e80a1 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -214,7 +214,9 @@ "copilot": { // The set of glob patterns for which copilot should be disabled // in any matching file. - "disabled_globs": [".env"] + "disabled_globs": [ + ".env" + ] }, // Settings specific to journaling "journal": { diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 31183c484d..aee41e6c53 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -368,7 +368,8 @@ impl DisplaySnapshot { let new_end = if range.end.column == 0 { range.end } else if range.end.row < self.max_buffer_row() { - Point::new(range.end.row + 1, 0) + self.buffer_snapshot + .clip_point(Point::new(range.end.row + 1, 0), Bias::Left) } else { self.buffer_snapshot.max_point() }; @@ -376,7 +377,7 @@ impl DisplaySnapshot { new_start..new_end } - pub fn point_to_display_point(&self, point: Point, bias: Bias) -> DisplayPoint { + fn point_to_display_point(&self, point: Point, bias: Bias) -> DisplayPoint { let inlay_point = self.inlay_snapshot.to_inlay_point(point); let fold_point = self.fold_snapshot.to_fold_point(inlay_point, bias); let tab_point = self.tab_snapshot.to_tab_point(fold_point); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index d2e7ab00d7..6d0161e086 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -60,10 +60,10 @@ enum FoldMarkers {} struct SelectionLayout { head: DisplayPoint, - reversed: bool, cursor_shape: CursorShape, is_newest: bool, range: Range, + active_rows: Range, } impl SelectionLayout { @@ -74,27 +74,42 @@ impl SelectionLayout { map: &DisplaySnapshot, is_newest: bool, ) -> Self { + let point_selection = selection.map(|p| p.to_point(&map.buffer_snapshot)); + let display_selection = point_selection.map(|p| p.to_display_point(map)); + let mut range = display_selection.range(); + let mut head = display_selection.head(); + let mut active_rows = map.prev_line_boundary(point_selection.start).1.row() + ..map.next_line_boundary(point_selection.end).1.row(); + if line_mode { - let selection = selection.map(|p| p.to_point(&map.buffer_snapshot)); - let point_range = map.expand_to_line(selection.range()); - Self { - head: selection.head().to_display_point(map), - reversed: selection.reversed, - cursor_shape, - is_newest, - range: point_range.start.to_display_point(map) - ..point_range.end.to_display_point(map), - } - } else { - let selection = selection.map(|p| p.to_display_point(map)); - Self { - head: selection.head(), - reversed: selection.reversed, - cursor_shape, - is_newest, - range: selection.range(), + let point_range = map.expand_to_line(point_selection.range()); + range = point_range.start.to_display_point(map)..point_range.end.to_display_point(map); + } + + if cursor_shape == CursorShape::Block && !range.is_empty() && !selection.reversed { + if head.column() > 0 { + head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left) + } else if head.row() > 0 && head != map.max_point() { + head = map.clip_point( + DisplayPoint::new(head.row() - 1, map.line_len(head.row() - 1)), + Bias::Left, + ); + + // updating range.end is a no-op unless you're on a multi-buffer divider + // in which case the clip_point may have moved the head up + // an additional row. + range.end = DisplayPoint::new(head.row() + 1, 0); + active_rows.end = head.row(); } } + + Self { + head, + cursor_shape, + is_newest, + range, + active_rows, + } } } @@ -847,36 +862,14 @@ impl EditorElement { if editor.show_local_cursors(cx) || replica_id != local_replica_id { let cursor_position = selection.head; - let mut cursor_column = cursor_position.column() as usize; - let mut cursor_row = cursor_position.row(); - - // highlight the last character in a selection - if CursorShape::Block == selection.cursor_shape - && !selection.range.is_empty() - && !selection.reversed + if layout + .visible_display_row_range + .contains(&cursor_position.row()) { - if cursor_column > 0 { - cursor_column -= 1; - } else if cursor_row > 0 - && cursor_position != layout.position_map.snapshot.max_point() - { - let new = layout.position_map.snapshot.clip_point( - DisplayPoint::new( - cursor_row - 1, - layout.position_map.snapshot.line_len(cursor_row), - ), - Bias::Left, - ); - cursor_row = new.row(); - cursor_column = new.column() as usize; - } - } - dbg!(selection.head, cursor_row, cursor_column); - - if layout.visible_display_row_range.contains(&cursor_row) { let cursor_row_layout = &layout.position_map.line_layouts - [(cursor_row - start_row) as usize] + [(cursor_position.row() - start_row) as usize] .line; + let cursor_column = cursor_position.column() as usize; let cursor_character_x = cursor_row_layout.x_for_index(cursor_column); let mut block_width = @@ -888,10 +881,7 @@ impl EditorElement { layout .position_map .snapshot - .chars_at(DisplayPoint::new( - cursor_row as u32, - cursor_column as u32, - )) + .chars_at(cursor_position) .next() .and_then(|(character, _)| { let font_id = @@ -916,7 +906,8 @@ impl EditorElement { }; let x = cursor_character_x - scroll_left; - let y = cursor_row as f32 * layout.position_map.line_height - scroll_top; + let y = cursor_position.row() as f32 * layout.position_map.line_height + - scroll_top; if selection.is_newest { editor.pixel_position_of_newest_cursor = Some(vec2f( bounds.origin_x() + x + block_width / 2., @@ -2187,34 +2178,37 @@ impl Element for EditorElement { } selections.extend(remote_selections); + let mut newest_selection_head = None; + if editor.show_local_selections { - let mut local_selections = editor + let mut local_selections: Vec> = editor .selections .disjoint_in_range(start_anchor..end_anchor, cx); local_selections.extend(editor.selections.pending(cx)); + let mut layouts = Vec::new(); let newest = editor.selections.newest(cx); - for selection in &local_selections { + for selection in local_selections.drain(..) { let is_empty = selection.start == selection.end; - let selection_start = snapshot.prev_line_boundary(selection.start).1; - let mut selection_end = snapshot.next_line_boundary(selection.end).1; + let is_newest = selection == newest; - // in vim visual mode the newline is considered at the end of the previous line - // instead of at the start of the current line - if editor.cursor_shape == CursorShape::Block - && !is_empty - && !selection.reversed - && selection.end.column == 0 - && selection_end.row() > 0 - && selection_end.row() < snapshot.max_buffer_row() - { - selection_end = DisplayPoint::new(selection_end.row() - 1, 0); + let layout = SelectionLayout::new( + selection, + editor.selections.line_mode, + editor.cursor_shape, + &snapshot.display_snapshot, + is_newest, + ); + if is_newest { + newest_selection_head = Some(layout.head); } - for row in cmp::max(selection_start.row(), start_row) - ..=cmp::min(selection_end.row(), end_row) + + for row in cmp::max(layout.active_rows.start, start_row) + ..=cmp::min(layout.active_rows.end, end_row) { let contains_non_empty_selection = active_rows.entry(row).or_insert(!is_empty); *contains_non_empty_selection |= !is_empty; } + layouts.push(layout); } // Render the local selections in the leader's color when following. @@ -2222,22 +2216,7 @@ impl Element for EditorElement { .leader_replica_id .unwrap_or_else(|| editor.replica_id(cx)); - selections.push(( - local_replica_id, - local_selections - .into_iter() - .map(|selection| { - let is_newest = selection == newest; - SelectionLayout::new( - selection, - editor.selections.line_mode, - editor.cursor_shape, - &snapshot.display_snapshot, - is_newest, - ) - }) - .collect(), - )); + selections.push((local_replica_id, layouts)); } let scrollbar_settings = &settings::get::(cx).scrollbar; @@ -2342,28 +2321,26 @@ impl Element for EditorElement { snapshot = editor.snapshot(cx); } - let newest_selection_head = editor - .selections - .newest::(cx) - .head() - .to_display_point(&snapshot); let style = editor.style(cx); let mut context_menu = None; let mut code_actions_indicator = None; - if (start_row..end_row).contains(&newest_selection_head.row()) { - if editor.context_menu_visible() { - context_menu = editor.render_context_menu(newest_selection_head, style.clone(), cx); + if let Some(newest_selection_head) = newest_selection_head { + if (start_row..end_row).contains(&newest_selection_head.row()) { + if editor.context_menu_visible() { + context_menu = + editor.render_context_menu(newest_selection_head, style.clone(), cx); + } + + let active = matches!( + editor.context_menu, + Some(crate::ContextMenu::CodeActions(_)) + ); + + code_actions_indicator = editor + .render_code_actions_indicator(&style, active, cx) + .map(|indicator| (newest_selection_head.row(), indicator)); } - - let active = matches!( - editor.context_menu, - Some(crate::ContextMenu::CodeActions(_)) - ); - - code_actions_indicator = editor - .render_code_actions_indicator(&style, active, cx) - .map(|indicator| (newest_selection_head.row(), indicator)); } let visible_rows = start_row..start_row + line_layouts.len() as u32; @@ -3040,6 +3017,64 @@ mod tests { assert_eq!(layouts.len(), 6); } + #[gpui::test] + fn test_vim_visual_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let (_, editor) = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple(&(sample_text(6, 6, 'a') + "\n"), cx); + Editor::new(EditorMode::Full, buffer, None, None, cx) + }); + let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx))); + let (_, state) = editor.update(cx, |editor, cx| { + editor.cursor_shape = CursorShape::Block; + editor.change_selections(None, cx, |s| { + s.select_ranges([ + Point::new(0, 0)..Point::new(1, 0), + Point::new(3, 2)..Point::new(3, 3), + Point::new(5, 6)..Point::new(6, 0), + ]); + }); + let mut new_parents = Default::default(); + let mut notify_views_if_parents_change = Default::default(); + let mut layout_cx = LayoutContext::new( + cx, + &mut new_parents, + &mut notify_views_if_parents_change, + false, + ); + element.layout( + SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)), + editor, + &mut layout_cx, + ) + }); + assert_eq!(state.selections.len(), 1); + let local_selections = &state.selections[0].1; + assert_eq!(local_selections.len(), 3); + // moves cursor back one line + assert_eq!( + local_selections[0].range, + DisplayPoint::new(0, 0)..DisplayPoint::new(0, 6) + ); + // moves cursor back one column + assert_eq!( + local_selections[1].range, + DisplayPoint::new(3, 2)..DisplayPoint::new(3, 2) + ); + // leaves cursor on the max point + assert_eq!( + local_selections[2].range, + DisplayPoint::new(5, 6)..DisplayPoint::new(6, 0) + ); + + // active lines does not include 1 + assert_eq!( + state.active_rows.keys().cloned().collect::>(), + vec![0, 3, 5, 6] + ); + } + #[gpui::test] fn test_layout_with_placeholder_text_and_blocks(cx: &mut TestAppContext) { init_test(cx, |_| {}); From 5b37cdcb042dc4487543c51416af75c78f6b8bec Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 14 Aug 2023 15:03:16 -0600 Subject: [PATCH 12/12] Better tests --- crates/editor/src/element.rs | 102 ++++++++++++++++++++++++++++-- crates/editor/src/multi_buffer.rs | 19 ++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 6d0161e086..e2cd15f7ee 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -81,11 +81,13 @@ impl SelectionLayout { let mut active_rows = map.prev_line_boundary(point_selection.start).1.row() ..map.next_line_boundary(point_selection.end).1.row(); + // vim visual line mode if line_mode { let point_range = map.expand_to_line(point_selection.range()); range = point_range.start.to_display_point(map)..point_range.end.to_display_point(map); } + // any vim visual mode (including line mode) if cursor_shape == CursorShape::Block && !range.is_empty() && !selection.reversed { if head.column() > 0 { head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left) @@ -94,8 +96,8 @@ impl SelectionLayout { DisplayPoint::new(head.row() - 1, map.line_len(head.row() - 1)), Bias::Left, ); - - // updating range.end is a no-op unless you're on a multi-buffer divider + // updating range.end is a no-op unless you're cursor is + // on the newline containing a multi-buffer divider // in which case the clip_point may have moved the head up // an additional row. range.end = DisplayPoint::new(head.row() + 1, 0); @@ -2996,7 +2998,7 @@ mod tests { use language::language_settings; use log::info; use std::{num::NonZeroU32, sync::Arc}; - use util::test::sample_text; + use util::test::{generate_marked_text, sample_text}; #[gpui::test] fn test_layout_line_numbers(cx: &mut TestAppContext) { @@ -3018,7 +3020,7 @@ mod tests { } #[gpui::test] - fn test_vim_visual_selections(cx: &mut TestAppContext) { + async fn test_vim_visual_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); let (_, editor) = cx.add_window(|cx| { @@ -3053,26 +3055,112 @@ mod tests { let local_selections = &state.selections[0].1; assert_eq!(local_selections.len(), 3); // moves cursor back one line + assert_eq!(local_selections[0].head, DisplayPoint::new(0, 6)); assert_eq!( local_selections[0].range, - DisplayPoint::new(0, 0)..DisplayPoint::new(0, 6) + DisplayPoint::new(0, 0)..DisplayPoint::new(1, 0) ); + // moves cursor back one column assert_eq!( local_selections[1].range, - DisplayPoint::new(3, 2)..DisplayPoint::new(3, 2) + DisplayPoint::new(3, 2)..DisplayPoint::new(3, 3) ); + assert_eq!(local_selections[1].head, DisplayPoint::new(3, 2)); + // leaves cursor on the max point assert_eq!( local_selections[2].range, DisplayPoint::new(5, 6)..DisplayPoint::new(6, 0) ); + assert_eq!(local_selections[2].head, DisplayPoint::new(6, 0)); - // active lines does not include 1 + // active lines does not include 1 (even though the range of the selection does) assert_eq!( state.active_rows.keys().cloned().collect::>(), vec![0, 3, 5, 6] ); + + // multi-buffer support + // in DisplayPoint co-ordinates, this is what we're dealing with: + // 0: [[file + // 1: header]] + // 2: aaaaaa + // 3: bbbbbb + // 4: cccccc + // 5: + // 6: ... + // 7: ffffff + // 8: gggggg + // 9: hhhhhh + // 10: + // 11: [[file + // 12: header]] + // 13: bbbbbb + // 14: cccccc + // 15: dddddd + let (_, editor) = cx.add_window(|cx| { + let buffer = MultiBuffer::build_multi( + [ + ( + &(sample_text(8, 6, 'a') + "\n"), + vec![ + Point::new(0, 0)..Point::new(3, 0), + Point::new(4, 0)..Point::new(7, 0), + ], + ), + ( + &(sample_text(8, 6, 'a') + "\n"), + vec![Point::new(1, 0)..Point::new(3, 0)], + ), + ], + cx, + ); + Editor::new(EditorMode::Full, buffer, None, None, cx) + }); + let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx))); + let (_, state) = editor.update(cx, |editor, cx| { + editor.cursor_shape = CursorShape::Block; + editor.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(4, 0)..DisplayPoint::new(7, 0), + DisplayPoint::new(10, 0)..DisplayPoint::new(13, 0), + ]); + }); + let mut new_parents = Default::default(); + let mut notify_views_if_parents_change = Default::default(); + let mut layout_cx = LayoutContext::new( + cx, + &mut new_parents, + &mut notify_views_if_parents_change, + false, + ); + element.layout( + SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)), + editor, + &mut layout_cx, + ) + }); + + assert_eq!(state.selections.len(), 1); + let local_selections = &state.selections[0].1; + assert_eq!(local_selections.len(), 2); + + // moves cursor on excerpt boundary back a line + // and doesn't allow selection to bleed through + assert_eq!( + local_selections[0].range, + DisplayPoint::new(4, 0)..DisplayPoint::new(6, 0) + ); + assert_eq!(local_selections[0].head, DisplayPoint::new(5, 0)); + + // moves cursor on buffer boundary back two lines + // and doesn't allow selection to bleed through + assert_eq!( + local_selections[1].range, + DisplayPoint::new(10, 0)..DisplayPoint::new(11, 0) + ); + assert_eq!(local_selections[1].head, DisplayPoint::new(10, 0)); } #[gpui::test] diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 31af03f768..8417c411f2 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -1565,6 +1565,25 @@ impl MultiBuffer { cx.add_model(|cx| Self::singleton(buffer, cx)) } + pub fn build_multi( + excerpts: [(&str, Vec>); COUNT], + cx: &mut gpui::AppContext, + ) -> ModelHandle { + let multi = cx.add_model(|_| Self::new(0)); + for (text, ranges) in excerpts { + let buffer = cx.add_model(|cx| Buffer::new(0, text, cx)); + let excerpt_ranges = ranges.into_iter().map(|range| ExcerptRange { + context: range, + primary: None, + }); + multi.update(cx, |multi, cx| { + multi.push_excerpts(buffer, excerpt_ranges, cx) + }); + } + + multi + } + pub fn build_from_buffer( buffer: ModelHandle, cx: &mut gpui::AppContext,