diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index e08ce47caf..639daef614 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -38,6 +38,8 @@ "^": "vim::FirstNonWhitespace", "shift-g": "vim::EndOfDocument", "w": "vim::NextWordStart", + "{": "vim::StartOfParagraph", + "}": "vim::EndOfParagraph", "shift-w": [ "vim::NextWordStart", { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e979bd9c1e..8d7b8ffad6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5123,7 +5123,7 @@ impl Editor { self.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { selection.collapse_to( - movement::start_of_paragraph(map, selection.head()), + movement::start_of_paragraph(map, selection.head(), 1), SelectionGoal::None, ) }); @@ -5143,7 +5143,7 @@ impl Editor { self.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { selection.collapse_to( - movement::end_of_paragraph(map, selection.head()), + movement::end_of_paragraph(map, selection.head(), 1), SelectionGoal::None, ) }); @@ -5162,7 +5162,10 @@ impl Editor { self.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_heads_with(|map, head, _| { - (movement::start_of_paragraph(map, head), SelectionGoal::None) + ( + movement::start_of_paragraph(map, head, 1), + SelectionGoal::None, + ) }); }) } @@ -5179,7 +5182,10 @@ impl Editor { self.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_heads_with(|map, head, _| { - (movement::end_of_paragraph(map, head), SelectionGoal::None) + ( + movement::end_of_paragraph(map, head, 1), + SelectionGoal::None, + ) }); }) } diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index e50d7d8306..1bd37da52f 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -193,7 +193,11 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo }) } -pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint { +pub fn start_of_paragraph( + map: &DisplaySnapshot, + display_point: DisplayPoint, + mut count: usize, +) -> DisplayPoint { let point = display_point.to_point(map); if point.row == 0 { return map.max_point(); @@ -203,7 +207,11 @@ pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> for row in (0..point.row + 1).rev() { let blank = map.buffer_snapshot.is_line_blank(row); if found_non_blank_line && blank { - return Point::new(row, 0).to_display_point(map); + if count <= 1 { + return Point::new(row, 0).to_display_point(map); + } + count -= 1; + found_non_blank_line = false; } found_non_blank_line |= !blank; @@ -212,7 +220,11 @@ pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint::zero() } -pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint { +pub fn end_of_paragraph( + map: &DisplaySnapshot, + display_point: DisplayPoint, + mut count: usize, +) -> DisplayPoint { let point = display_point.to_point(map); if point.row == map.max_buffer_row() { return DisplayPoint::zero(); @@ -222,7 +234,11 @@ pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> D for row in point.row..map.max_buffer_row() + 1 { let blank = map.buffer_snapshot.is_line_blank(row); if found_non_blank_line && blank { - return Point::new(row, 0).to_display_point(map); + if count <= 1 { + return Point::new(row, 0).to_display_point(map); + } + count -= 1; + found_non_blank_line = false; } found_non_blank_line |= !blank; diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index 95da7ff297..bac70f139a 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -210,6 +210,10 @@ impl<'a> EditorTestContext<'a> { self.assert_selections(expected_selections, marked_text.to_string()) } + pub fn editor_state(&mut self) -> String { + generate_marked_text(self.buffer_text().as_str(), &self.editor_selections(), true) + } + #[track_caller] pub fn assert_editor_background_highlights(&mut self, marked_text: &str) { let expected_ranges = self.ranges(marked_text); @@ -248,14 +252,8 @@ impl<'a> EditorTestContext<'a> { self.assert_selections(expected_selections, expected_marked_text) } - #[track_caller] - fn assert_selections( - &mut self, - expected_selections: Vec>, - expected_marked_text: String, - ) { - let actual_selections = self - .editor + fn editor_selections(&self) -> Vec> { + self.editor .read_with(self.cx, |editor, cx| editor.selections.all::(cx)) .into_iter() .map(|s| { @@ -265,12 +263,22 @@ impl<'a> EditorTestContext<'a> { s.start..s.end } }) - .collect::>(); + .collect::>() + } + + #[track_caller] + fn assert_selections( + &mut self, + expected_selections: Vec>, + expected_marked_text: String, + ) { + let actual_selections = self.editor_selections(); let actual_marked_text = generate_marked_text(&self.buffer_text(), &actual_selections, true); if expected_selections != actual_selections { panic!( indoc! {" + {}Editor has unexpected selections. Expected selections: diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index faf69d9473..07b095dd5e 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -31,6 +31,8 @@ pub enum Motion { CurrentLine, StartOfLine, EndOfLine, + StartOfParagraph, + EndOfParagraph, StartOfDocument, EndOfDocument, Matching, @@ -72,6 +74,8 @@ actions!( StartOfLine, EndOfLine, CurrentLine, + StartOfParagraph, + EndOfParagraph, StartOfDocument, EndOfDocument, Matching, @@ -92,6 +96,12 @@ pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &StartOfLine, cx: _| motion(Motion::StartOfLine, cx)); cx.add_action(|_: &mut Workspace, _: &EndOfLine, cx: _| motion(Motion::EndOfLine, cx)); cx.add_action(|_: &mut Workspace, _: &CurrentLine, cx: _| motion(Motion::CurrentLine, cx)); + cx.add_action(|_: &mut Workspace, _: &StartOfParagraph, cx: _| { + motion(Motion::StartOfParagraph, cx) + }); + cx.add_action(|_: &mut Workspace, _: &EndOfParagraph, cx: _| { + motion(Motion::EndOfParagraph, cx) + }); cx.add_action(|_: &mut Workspace, _: &StartOfDocument, cx: _| { motion(Motion::StartOfDocument, cx) }); @@ -142,7 +152,8 @@ impl Motion { pub fn linewise(&self) -> bool { use Motion::*; match self { - Down | Up | StartOfDocument | EndOfDocument | CurrentLine | NextLineStart => true, + Down | Up | StartOfDocument | EndOfDocument | CurrentLine | NextLineStart + | StartOfParagraph | EndOfParagraph => true, EndOfLine | NextWordEnd { .. } | Matching @@ -172,6 +183,8 @@ impl Motion { | Backspace | Right | StartOfLine + | StartOfParagraph + | EndOfParagraph | NextWordStart { .. } | PreviousWordStart { .. } | FirstNonWhitespace @@ -197,6 +210,8 @@ impl Motion { | Backspace | Right | StartOfLine + | StartOfParagraph + | EndOfParagraph | NextWordStart { .. } | PreviousWordStart { .. } | FirstNonWhitespace @@ -235,6 +250,14 @@ impl Motion { FirstNonWhitespace => (first_non_whitespace(map, point), SelectionGoal::None), StartOfLine => (start_of_line(map, point), SelectionGoal::None), EndOfLine => (end_of_line(map, point), SelectionGoal::None), + StartOfParagraph => ( + movement::start_of_paragraph(map, point, times), + SelectionGoal::None, + ), + EndOfParagraph => ( + map.clip_at_line_end(movement::end_of_paragraph(map, point, times)), + SelectionGoal::None, + ), CurrentLine => (end_of_line(map, point), SelectionGoal::None), StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None), EndOfDocument => ( @@ -502,10 +525,13 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint if line_end == point { line_end = map.max_point().to_point(map); } - line_end.column = line_end.column.saturating_sub(1); let line_range = map.prev_line_boundary(point).0..line_end; - let ranges = map.buffer_snapshot.bracket_ranges(line_range.clone()); + let visible_line_range = + line_range.start..Point::new(line_range.end.row, line_range.end.column.saturating_sub(1)); + let ranges = map + .buffer_snapshot + .bracket_ranges(visible_line_range.clone()); if let Some(ranges) = ranges { let line_range = line_range.start.to_offset(&map.buffer_snapshot) ..line_range.end.to_offset(&map.buffer_snapshot); @@ -590,3 +616,131 @@ fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> let new_row = (point.row() + times as u32).min(map.max_buffer_row()); map.clip_point(DisplayPoint::new(new_row, 0), Bias::Left) } + +#[cfg(test)] + +mod test { + + use crate::test::NeovimBackedTestContext; + use indoc::indoc; + + #[gpui::test] + async fn test_start_end_of_paragraph(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + let initial_state = indoc! {r"ˇabc + def + + paragraph + the second + + + + third and + final"}; + + // goes down once + cx.set_shared_state(initial_state).await; + cx.simulate_shared_keystrokes(["}"]).await; + cx.assert_shared_state(indoc! {r"abc + def + ˇ + paragraph + the second + + + + third and + final"}) + .await; + + // goes up once + cx.simulate_shared_keystrokes(["{"]).await; + cx.assert_shared_state(initial_state).await; + + // goes down twice + cx.simulate_shared_keystrokes(["2", "}"]).await; + cx.assert_shared_state(indoc! {r"abc + def + + paragraph + the second + ˇ + + + third and + final"}) + .await; + + // goes down over multiple blanks + cx.simulate_shared_keystrokes(["}"]).await; + cx.assert_shared_state(indoc! {r"abc + def + + paragraph + the second + + + + third and + finaˇl"}) + .await; + + // goes up twice + cx.simulate_shared_keystrokes(["2", "{"]).await; + cx.assert_shared_state(indoc! {r"abc + def + ˇ + paragraph + the second + + + + third and + final"}) + .await + } + + #[gpui::test] + async fn test_matching(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {r"func ˇ(a string) { + do(something(with.and_arrays[0, 2])) + }"}) + .await; + cx.simulate_shared_keystrokes(["%"]).await; + cx.assert_shared_state(indoc! {r"func (a stringˇ) { + do(something(with.and_arrays[0, 2])) + }"}) + .await; + + // test it works on the last character of the line + cx.set_shared_state(indoc! {r"func (a string) ˇ{ + do(something(with.and_arrays[0, 2])) + }"}) + .await; + cx.simulate_shared_keystrokes(["%"]).await; + cx.assert_shared_state(indoc! {r"func (a string) { + do(something(with.and_arrays[0, 2])) + ˇ}"}) + .await; + + // test it works on immediate nesting + cx.set_shared_state("ˇ{()}").await; + cx.simulate_shared_keystrokes(["%"]).await; + cx.assert_shared_state("{()ˇ}").await; + cx.simulate_shared_keystrokes(["%"]).await; + cx.assert_shared_state("ˇ{()}").await; + + // test it works on immediate nesting inside braces + cx.set_shared_state("{\n ˇ{()}\n}").await; + cx.simulate_shared_keystrokes(["%"]).await; + cx.assert_shared_state("{\n {()ˇ}\n}").await; + + // test it jumps to the next paren on a line + cx.set_shared_state("func ˇboop() {\n}").await; + cx.simulate_shared_keystrokes(["%"]).await; + cx.assert_shared_state("func boop(ˇ) {\n}").await; + } +} diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index ba527af0bb..b3e101262d 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -1,29 +1,51 @@ +use editor::scroll::autoscroll::Autoscroll; use gpui::ViewContext; -use language::Point; +use language::{Bias, Point}; use workspace::Workspace; -use crate::{motion::Motion, normal::ChangeCase, Vim}; +use crate::{normal::ChangeCase, state::Mode, Vim}; pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { - let count = vim.pop_number_operator(cx); + let count = vim.pop_number_operator(cx).unwrap_or(1) as u32; 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| { - if selection.start == selection.end { - Motion::Right.expand_selection(map, selection, count, true); + let mut ranges = Vec::new(); + let mut cursor_positions = Vec::new(); + let snapshot = editor.buffer().read(cx).snapshot(cx); + for selection in editor.selections.all::(cx) { + match vim.state.mode { + Mode::Visual { line: true } => { + let start = Point::new(selection.start.row, 0); + let end = + Point::new(selection.end.row, snapshot.line_len(selection.end.row)); + ranges.push(start..end); + cursor_positions.push(start..start); + } + Mode::Visual { line: false } => { + ranges.push(selection.start..selection.end); + cursor_positions.push(selection.start..selection.start); + } + Mode::Insert | Mode::Normal => { + let start = selection.start; + let mut end = start; + for _ in 0..count { + end = snapshot.clip_point(end + Point::new(0, 1), Bias::Right); } - }) - }); - let selections = editor.selections.all::(cx); - for selection in selections.into_iter().rev() { + ranges.push(start..end); + + if end.column == snapshot.line_len(end.row) { + end = snapshot.clip_point(end - Point::new(0, 1), Bias::Left); + } + cursor_positions.push(end..end) + } + } + } + editor.transact(cx, |editor, cx| { + for range in ranges.into_iter().rev() { let snapshot = editor.buffer().read(cx).snapshot(cx); editor.buffer().update(cx, |buffer, cx| { - let range = selection.start..selection.end; let text = snapshot - .text_for_range(selection.start..selection.end) + .text_for_range(range.start..range.end) .flat_map(|s| s.chars()) .flat_map(|c| { if c.is_lowercase() { @@ -37,28 +59,46 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext NeovimBackedTestContext<'a> { context_handle } + pub async fn assert_shared_state(&mut self, marked_text: &str) { + let neovim = self.neovim_state().await; + if neovim != marked_text { + panic!( + indoc! {"Test is incorrect (currently expected != neovim state) + + # currently expected: + {} + # neovim state: + {} + # zed state: + {}"}, + marked_text, + neovim, + self.editor_state(), + ) + } + self.assert_editor_state(marked_text) + } + + pub async fn neovim_state(&mut self) -> String { + generate_marked_text( + self.neovim.text().await.as_str(), + &vec![self.neovim_selection().await], + true, + ) + } + + 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; + } + neovim_selection.to_offset(&self.buffer_snapshot()) + } + pub async fn assert_state_matches(&mut self) { assert_eq!( self.neovim.text().await, @@ -120,13 +158,8 @@ impl<'a> NeovimBackedTestContext<'a> { self.assertion_context() ); - 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 = neovim_selection.to_offset(&self.buffer_snapshot()); - self.assert_editor_selections(vec![neovim_selection]); + 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(),); diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs index aa14e4a065..5bfae4e673 100644 --- a/crates/vim/src/test/neovim_connection.rs +++ b/crates/vim/src/test/neovim_connection.rs @@ -167,15 +167,25 @@ impl NeovimConnection { .await .expect("Could not get neovim window"); - if !selection.is_empty() { - panic!("Setting neovim state with non empty selection not yet supported"); - } let cursor = selection.start; nvim_window .set_cursor((cursor.row as i64 + 1, cursor.column as i64)) .await .expect("Could not set nvim cursor position"); + if !selection.is_empty() { + self.nvim + .input("v") + .await + .expect("could not enter visual mode"); + + let cursor = selection.end; + nvim_window + .set_cursor((cursor.row as i64 + 1, cursor.column as i64)) + .await + .expect("Could not set nvim cursor position"); + } + if let Some(NeovimData::Get { mode, state }) = self.data.back() { if *mode == Some(Mode::Normal) && *state == marked_text { return; diff --git a/crates/vim/test_data/test_change_case.json b/crates/vim/test_data/test_change_case.json new file mode 100644 index 0000000000..1c0cad0b93 --- /dev/null +++ b/crates/vim/test_data/test_change_case.json @@ -0,0 +1,18 @@ +{"Put":{"state":"ˇabC\n"}} +{"Key":"~"} +{"Get":{"state":"AˇbC\n","mode":"Normal"}} +{"Key":"2"} +{"Key":"~"} +{"Get":{"state":"ABˇc\n","mode":"Normal"}} +{"Put":{"state":"a😀C«dÉ1*fˇ»\n"}} +{"Key":"~"} +{"Get":{"state":"a😀CˇDé1*F\n","mode":"Normal"}} +{"Key":"~"} +{"Put":{"state":"aˇC😀é1*F\n"}} +{"Key":"4"} +{"Key":"~"} +{"Get":{"state":"ac😀É1ˇ*F\n","mode":"Normal"}} +{"Put":{"state":"abˇC\n"}} +{"Key":"shift-v"} +{"Key":"~"} +{"Get":{"state":"ˇABc\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_matching.json b/crates/vim/test_data/test_matching.json new file mode 100644 index 0000000000..5c8d7529b9 --- /dev/null +++ b/crates/vim/test_data/test_matching.json @@ -0,0 +1,17 @@ +{"Put":{"state":"func ˇ(a string) {\n do(something(with.and_arrays[0, 2]))\n}"}} +{"Key":"%"} +{"Get":{"state":"func (a stringˇ) {\n do(something(with.and_arrays[0, 2]))\n}","mode":"Normal"}} +{"Put":{"state":"func (a string) ˇ{\ndo(something(with.and_arrays[0, 2]))\n}"}} +{"Key":"%"} +{"Get":{"state":"func (a string) {\ndo(something(with.and_arrays[0, 2]))\nˇ}","mode":"Normal"}} +{"Put":{"state":"ˇ{()}"}} +{"Key":"%"} +{"Get":{"state":"{()ˇ}","mode":"Normal"}} +{"Key":"%"} +{"Get":{"state":"ˇ{()}","mode":"Normal"}} +{"Put":{"state":"{\n ˇ{()}\n}"}} +{"Key":"%"} +{"Get":{"state":"{\n {()ˇ}\n}","mode":"Normal"}} +{"Put":{"state":"func ˇboop() {\n}"}} +{"Key":"%"} +{"Get":{"state":"func boop(ˇ) {\n}","mode":"Normal"}} diff --git a/crates/vim/test_data/test_start_end_of_paragraph.json b/crates/vim/test_data/test_start_end_of_paragraph.json new file mode 100644 index 0000000000..0de4d84f50 --- /dev/null +++ b/crates/vim/test_data/test_start_end_of_paragraph.json @@ -0,0 +1,13 @@ +{"Put":{"state":"ˇabc\ndef\n\nparagraph\nthe second\n\n\n\nthird and\nfinal"}} +{"Key":"}"} +{"Get":{"state":"abc\ndef\nˇ\nparagraph\nthe second\n\n\n\nthird and\nfinal","mode":"Normal"}} +{"Key":"{"} +{"Get":{"state":"ˇabc\ndef\n\nparagraph\nthe second\n\n\n\nthird and\nfinal","mode":"Normal"}} +{"Key":"2"} +{"Key":"}"} +{"Get":{"state":"abc\ndef\n\nparagraph\nthe second\nˇ\n\n\nthird and\nfinal","mode":"Normal"}} +{"Key":"}"} +{"Get":{"state":"abc\ndef\n\nparagraph\nthe second\n\n\n\nthird and\nfinaˇl","mode":"Normal"}} +{"Key":"2"} +{"Key":"{"} +{"Get":{"state":"abc\ndef\nˇ\nparagraph\nthe second\n\n\n\nthird and\nfinal","mode":"Normal"}}