diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 3851880d18..7570877b76 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -117,6 +117,8 @@ "ctrl-e": "vim::LineDown", "ctrl-y": "vim::LineUp", // "g" commands + "g e": "vim::PreviousWordEnd", + "g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }], "g g": "vim::StartOfDocument", "g h": "editor::Hover", "g t": "pane::ActivateNextItem", diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 81c6c8c21f..c00d12668b 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -5,6 +5,7 @@ use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint}; use crate::{char_kind, scroll::ScrollAnchor, CharKind, EditorStyle, ToOffset, ToPoint}; use gpui::{px, Pixels, WindowTextSystem}; use language::Point; +use multi_buffer::MultiBufferSnapshot; use std::{ops::Range, sync::Arc}; @@ -254,7 +255,7 @@ pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> Displa let raw_point = point.to_point(map); let scope = map.buffer_snapshot.language_scope_at(raw_point); - find_preceding_boundary(map, point, FindRange::MultiLine, |left, right| { + find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| { (char_kind(&scope, left) != char_kind(&scope, right) && !right.is_whitespace()) || left == '\n' }) @@ -267,7 +268,7 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis let raw_point = point.to_point(map); let scope = map.buffer_snapshot.language_scope_at(raw_point); - find_preceding_boundary(map, point, FindRange::MultiLine, |left, right| { + find_preceding_boundary_display_point(map, point, FindRange::MultiLine, |left, right| { let is_word_start = char_kind(&scope, left) != char_kind(&scope, right) && !right.is_whitespace(); let is_subword_start = @@ -366,16 +367,16 @@ pub fn end_of_paragraph( /// indicated by the given predicate returning true. /// The predicate is called with the character to the left and right of the candidate boundary location. /// If FindRange::SingleLine is specified and no boundary is found before the start of the current line, the start of the current line will be returned. -pub fn find_preceding_boundary( - map: &DisplaySnapshot, - from: DisplayPoint, +pub fn find_preceding_boundary_point( + buffer_snapshot: &MultiBufferSnapshot, + from: Point, find_range: FindRange, mut is_boundary: impl FnMut(char, char) -> bool, -) -> DisplayPoint { +) -> Point { let mut prev_ch = None; - let mut offset = from.to_point(map).to_offset(&map.buffer_snapshot); + let mut offset = from.to_offset(&buffer_snapshot); - for ch in map.buffer_snapshot.reversed_chars_at(offset) { + for ch in buffer_snapshot.reversed_chars_at(offset) { if find_range == FindRange::SingleLine && ch == '\n' { break; } @@ -389,7 +390,26 @@ pub fn find_preceding_boundary( prev_ch = Some(ch); } - map.clip_point(offset.to_display_point(map), Bias::Left) + offset.to_point(&buffer_snapshot) +} + +/// Scans for a boundary preceding the given start point `from` until a boundary is found, +/// indicated by the given predicate returning true. +/// The predicate is called with the character to the left and right of the candidate boundary location. +/// If FindRange::SingleLine is specified and no boundary is found before the start of the current line, the start of the current line will be returned. +pub fn find_preceding_boundary_display_point( + map: &DisplaySnapshot, + from: DisplayPoint, + find_range: FindRange, + is_boundary: impl FnMut(char, char) -> bool, +) -> DisplayPoint { + let result = find_preceding_boundary_point( + &map.buffer_snapshot, + from.to_point(map), + find_range, + is_boundary, + ); + map.clip_point(result.to_display_point(map), Bias::Left) } /// Scans for a boundary following the given start point until a boundary is found, indicated by the @@ -626,7 +646,7 @@ mod tests { ) { let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); assert_eq!( - find_preceding_boundary( + find_preceding_boundary_display_point( &snapshot, display_points[1], FindRange::MultiLine, @@ -700,7 +720,7 @@ mod tests { }); assert_eq!( - find_preceding_boundary( + find_preceding_boundary_display_point( &snapshot, buffer_snapshot.len().to_display_point(&snapshot), FindRange::MultiLine, diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 0c1e96942d..de628a4c55 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1,6 +1,8 @@ use editor::{ display_map::{DisplaySnapshot, FoldPoint, ToDisplayPoint}, - movement::{self, find_boundary, find_preceding_boundary, FindRange, TextLayoutDetails}, + movement::{ + self, find_boundary, find_preceding_boundary_display_point, FindRange, TextLayoutDetails, + }, Bias, DisplayPoint, ToOffset, }; use gpui::{actions, impl_actions, px, ViewContext, WindowContext}; @@ -27,6 +29,7 @@ pub enum Motion { NextWordStart { ignore_punctuation: bool }, NextWordEnd { ignore_punctuation: bool }, PreviousWordStart { ignore_punctuation: bool }, + PreviousWordEnd { ignore_punctuation: bool }, FirstNonWhitespace { display_lines: bool }, CurrentLine, StartOfLine { display_lines: bool }, @@ -70,6 +73,13 @@ struct PreviousWordStart { ignore_punctuation: bool, } +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct PreviousWordEnd { + #[serde(default)] + ignore_punctuation: bool, +} + #[derive(Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct Up { @@ -114,6 +124,7 @@ impl_actions!( Down, Up, PreviousWordStart, + PreviousWordEnd, NextWordEnd, NextWordStart ] @@ -263,6 +274,11 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext) { workspace.register_action(|_: &mut Workspace, &WindowBottom, cx: _| { motion(Motion::WindowBottom, cx) }); + workspace.register_action( + |_: &mut Workspace, &PreviousWordEnd { ignore_punctuation }, cx: _| { + motion(Motion::PreviousWordEnd { ignore_punctuation }, cx) + }, + ); } pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) { @@ -315,6 +331,7 @@ impl Motion { | GoToColumn | NextWordStart { .. } | PreviousWordStart { .. } + | PreviousWordEnd { .. } | FirstNonWhitespace { .. } | FindBackward { .. } | RepeatFind { .. } @@ -351,6 +368,7 @@ impl Motion { | WindowTop | WindowMiddle | WindowBottom + | PreviousWordEnd { .. } | NextLineStart => false, } } @@ -371,6 +389,7 @@ impl Motion { | WindowTop | WindowMiddle | WindowBottom + | PreviousWordEnd { .. } | NextLineStart => true, Left | Backspace @@ -431,6 +450,10 @@ impl Motion { previous_word_start(map, point, *ignore_punctuation, times), SelectionGoal::None, ), + PreviousWordEnd { ignore_punctuation } => ( + previous_word_end(map, point, *ignore_punctuation, times), + SelectionGoal::None, + ), FirstNonWhitespace { display_lines } => ( first_non_whitespace(map, *display_lines, point), SelectionGoal::None, @@ -840,13 +863,17 @@ fn previous_word_start( for _ in 0..times { // This works even though find_preceding_boundary is called for every character in the line containing // cursor because the newline is checked only once. - let new_point = - movement::find_preceding_boundary(map, point, FindRange::MultiLine, |left, right| { + let new_point = movement::find_preceding_boundary_display_point( + map, + point, + FindRange::MultiLine, + |left, right| { let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation); let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation); (left_kind != right_kind && !right.is_whitespace()) || left == '\n' - }); + }, + ); if point == new_point { break; } @@ -1023,7 +1050,9 @@ fn find_backward( for _ in 0..times { let new_to = - find_preceding_boundary(map, to, FindRange::SingleLine, |_, right| right == target); + find_preceding_boundary_display_point(map, to, FindRange::SingleLine, |_, right| { + right == target + }); if to == new_to { break; } @@ -1147,6 +1176,44 @@ fn window_bottom( } } +fn previous_word_end( + map: &DisplaySnapshot, + point: DisplayPoint, + ignore_punctuation: bool, + times: usize, +) -> DisplayPoint { + let scope = map.buffer_snapshot.language_scope_at(point.to_point(map)); + let mut point = point.to_point(map); + + if point.column < map.buffer_snapshot.line_len(point.row) { + point.column += 1; + } + for _ in 0..times { + let new_point = movement::find_preceding_boundary_point( + &map.buffer_snapshot, + point, + FindRange::MultiLine, + |left, right| { + let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation); + let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation); + match (left_kind, right_kind) { + (CharKind::Punctuation, CharKind::Whitespace) + | (CharKind::Punctuation, CharKind::Word) + | (CharKind::Word, CharKind::Whitespace) + | (CharKind::Word, CharKind::Punctuation) => true, + (CharKind::Whitespace, CharKind::Whitespace) => left == '\n' && right == '\n', + _ => false, + } + }, + ); + if new_point == point { + break; + } + point = new_point; + } + movement::saturating_left(map, point.to_display_point(map)) +} + #[cfg(test)] mod test { @@ -1564,4 +1631,98 @@ mod test { "}) .await; } + + #[gpui::test] + async fn test_previous_word_end(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! {r" + 456 5ˇ67 678 + "}) + .await; + cx.simulate_shared_keystrokes(["g", "e"]).await; + cx.assert_shared_state(indoc! {r" + 45ˇ6 567 678 + "}) + .await; + + // Test times + cx.set_shared_state(indoc! {r" + 123 234 345 + 456 5ˇ67 678 + "}) + .await; + cx.simulate_shared_keystrokes(["4", "g", "e"]).await; + cx.assert_shared_state(indoc! {r" + 12ˇ3 234 345 + 456 567 678 + "}) + .await; + + // With punctuation + cx.set_shared_state(indoc! {r" + 123 234 345 + 4;5.6 5ˇ67 678 + 789 890 901 + "}) + .await; + cx.simulate_shared_keystrokes(["g", "e"]).await; + cx.assert_shared_state(indoc! {r" + 123 234 345 + 4;5.ˇ6 567 678 + 789 890 901 + "}) + .await; + + // With punctuation and count + cx.set_shared_state(indoc! {r" + 123 234 345 + 4;5.6 5ˇ67 678 + 789 890 901 + "}) + .await; + cx.simulate_shared_keystrokes(["5", "g", "e"]).await; + cx.assert_shared_state(indoc! {r" + 123 234 345 + ˇ4;5.6 567 678 + 789 890 901 + "}) + .await; + + // newlines + cx.set_shared_state(indoc! {r" + 123 234 345 + + 78ˇ9 890 901 + "}) + .await; + cx.simulate_shared_keystrokes(["g", "e"]).await; + cx.assert_shared_state(indoc! {r" + 123 234 345 + ˇ + 789 890 901 + "}) + .await; + cx.simulate_shared_keystrokes(["g", "e"]).await; + cx.assert_shared_state(indoc! {r" + 123 234 34ˇ5 + + 789 890 901 + "}) + .await; + + // With punctuation + cx.set_shared_state(indoc! {r" + 123 234 345 + 4;5.ˇ6 567 678 + 789 890 901 + "}) + .await; + cx.simulate_shared_keystrokes(["g", "shift-e"]).await; + cx.assert_shared_state(indoc! {r" + 123 234 34ˇ5 + 4;5.6 567 678 + 789 890 901 + "}) + .await; + } } diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 49406cf992..a3106e923e 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -211,7 +211,7 @@ fn in_word( let scope = map .buffer_snapshot .language_scope_at(relative_to.to_point(map)); - let start = movement::find_preceding_boundary( + let start = movement::find_preceding_boundary_display_point( map, right(map, relative_to, 1), movement::FindRange::SingleLine, @@ -281,7 +281,7 @@ fn around_next_word( .buffer_snapshot .language_scope_at(relative_to.to_point(map)); // Get the start of the word - let start = movement::find_preceding_boundary( + let start = movement::find_preceding_boundary_display_point( map, right(map, relative_to, 1), FindRange::SingleLine, diff --git a/crates/vim/test_data/test_previous_word_end.json b/crates/vim/test_data/test_previous_word_end.json new file mode 100644 index 0000000000..11e7552ce9 --- /dev/null +++ b/crates/vim/test_data/test_previous_word_end.json @@ -0,0 +1,29 @@ +{"Put":{"state":"456 5ˇ67 678\n"}} +{"Key":"g"} +{"Key":"e"} +{"Get":{"state":"45ˇ6 567 678\n","mode":"Normal"}} +{"Put":{"state":"123 234 345\n456 5ˇ67 678\n"}} +{"Key":"4"} +{"Key":"g"} +{"Key":"e"} +{"Get":{"state":"12ˇ3 234 345\n456 567 678\n","mode":"Normal"}} +{"Put":{"state":"123 234 345\n4;5.6 5ˇ67 678\n789 890 901\n"}} +{"Key":"g"} +{"Key":"e"} +{"Get":{"state":"123 234 345\n4;5.ˇ6 567 678\n789 890 901\n","mode":"Normal"}} +{"Put":{"state":"123 234 345\n4;5.6 5ˇ67 678\n789 890 901\n"}} +{"Key":"5"} +{"Key":"g"} +{"Key":"e"} +{"Get":{"state":"123 234 345\nˇ4;5.6 567 678\n789 890 901\n","mode":"Normal"}} +{"Put":{"state":"123 234 345\n\n78ˇ9 890 901\n"}} +{"Key":"g"} +{"Key":"e"} +{"Get":{"state":"123 234 345\nˇ\n789 890 901\n","mode":"Normal"}} +{"Key":"g"} +{"Key":"e"} +{"Get":{"state":"123 234 34ˇ5\n\n789 890 901\n","mode":"Normal"}} +{"Put":{"state":"123 234 345\n4;5.ˇ6 567 678\n789 890 901\n"}} +{"Key":"g"} +{"Key":"shift-e"} +{"Get":{"state":"123 234 34ˇ5\n4;5.6 567 678\n789 890 901\n","mode":"Normal"}}