From d247086b219e29d8f2b9b1d004c61109523d4d69 Mon Sep 17 00:00:00 2001 From: Rom Grk Date: Thu, 7 Mar 2024 21:36:12 -0500 Subject: [PATCH] vim: subword motions (#8725) Add subword motions to vim, inspired by [nvim-spider](https://github.com/chrisgrieser/nvim-spider), [CamelCaseMotion](https://github.com/bkad/CamelCaseMotion). Release Notes: - Added subword motions to vim --- assets/keymaps/vim.json | 20 +- crates/vim/src/motion.rs | 401 ++++++++++++++++--- crates/vim/src/normal.rs | 72 +++- crates/vim/src/normal/change.rs | 62 +-- crates/vim/src/test/vim_test_context.rs | 13 + docs/src/configuring_zed__configuring_vim.md | 16 + 6 files changed, 491 insertions(+), 93 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index cac2e0badba69cfbbd54adbc93ce8fe4815d19ec..5cc0d20038dda851482b375ee4ea064ed1ddd9b3 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -37,30 +37,42 @@ "_": "vim::StartOfLineDownward", "g _": "vim::EndOfLineDownward", "shift-g": "vim::EndOfDocument", - "w": "vim::NextWordStart", "{": "vim::StartOfParagraph", "}": "vim::EndOfParagraph", "|": "vim::GoToColumn", + + // Word motions + "w": "vim::NextWordStart", + "e": "vim::NextWordEnd", + "b": "vim::PreviousWordStart", + "g e": "vim::PreviousWordEnd", + + // Subword motions + // "w": "vim::NextSubwordStart", + // "b": "vim::PreviousSubwordStart", + // "e": "vim::NextSubwordEnd", + // "g e": "vim::PreviousSubwordEnd", + "shift-w": [ "vim::NextWordStart", { "ignorePunctuation": true } ], - "e": "vim::NextWordEnd", "shift-e": [ "vim::NextWordEnd", { "ignorePunctuation": true } ], - "b": "vim::PreviousWordStart", "shift-b": [ "vim::PreviousWordStart", { "ignorePunctuation": true } ], + "g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }], + "n": "search::SelectNextMatch", "shift-n": "search::SelectPrevMatch", "%": "vim::Matching", @@ -117,8 +129,6 @@ "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/vim/src/motion.rs b/crates/vim/src/motion.rs index b46035ce2ee48253d634ca33d3339c3ec0439b2f..37a5ae48d5287b152222dfdef89d3cff2fbfedd2 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -42,6 +42,18 @@ pub enum Motion { PreviousWordEnd { ignore_punctuation: bool, }, + NextSubwordStart { + ignore_punctuation: bool, + }, + NextSubwordEnd { + ignore_punctuation: bool, + }, + PreviousSubwordStart { + ignore_punctuation: bool, + }, + PreviousSubwordEnd { + ignore_punctuation: bool, + }, FirstNonWhitespace { display_lines: bool, }, @@ -110,6 +122,34 @@ struct PreviousWordEnd { ignore_punctuation: bool, } +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NextSubwordStart { + #[serde(default)] + pub(crate) ignore_punctuation: bool, +} + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NextSubwordEnd { + #[serde(default)] + pub(crate) ignore_punctuation: bool, +} + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PreviousSubwordStart { + #[serde(default)] + pub(crate) ignore_punctuation: bool, +} + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PreviousSubwordEnd { + #[serde(default)] + pub(crate) ignore_punctuation: bool, +} + #[derive(Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct Up { @@ -153,10 +193,14 @@ impl_actions!( FirstNonWhitespace, Down, Up, + NextWordStart, + NextWordEnd, PreviousWordStart, PreviousWordEnd, - NextWordEnd, - NextWordStart + NextSubwordStart, + NextSubwordEnd, + PreviousSubwordStart, + PreviousSubwordEnd, ] ); @@ -264,6 +308,31 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext) { &PreviousWordStart { ignore_punctuation }: &PreviousWordStart, cx: _| { motion(Motion::PreviousWordStart { ignore_punctuation }, cx) }, ); + workspace.register_action( + |_: &mut Workspace, &PreviousWordEnd { ignore_punctuation }, cx: _| { + motion(Motion::PreviousWordEnd { ignore_punctuation }, cx) + }, + ); + workspace.register_action( + |_: &mut Workspace, &NextSubwordStart { ignore_punctuation }: &NextSubwordStart, cx: _| { + motion(Motion::NextSubwordStart { ignore_punctuation }, cx) + }, + ); + workspace.register_action( + |_: &mut Workspace, &NextSubwordEnd { ignore_punctuation }: &NextSubwordEnd, cx: _| { + motion(Motion::NextSubwordEnd { ignore_punctuation }, cx) + }, + ); + workspace.register_action( + |_: &mut Workspace, + &PreviousSubwordStart { ignore_punctuation }: &PreviousSubwordStart, + cx: _| { motion(Motion::PreviousSubwordStart { ignore_punctuation }, cx) }, + ); + workspace.register_action( + |_: &mut Workspace, &PreviousSubwordEnd { ignore_punctuation }, cx: _| { + motion(Motion::PreviousSubwordEnd { ignore_punctuation }, cx) + }, + ); workspace.register_action(|_: &mut Workspace, &NextLineStart, cx: _| { motion(Motion::NextLineStart, cx) }); @@ -304,11 +373,6 @@ 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) { @@ -349,7 +413,6 @@ impl Motion { | WindowBottom | EndOfParagraph => true, EndOfLine { .. } - | NextWordEnd { .. } | Matching | FindForward { .. } | Left @@ -360,8 +423,13 @@ impl Motion { | EndOfLineDownward | GoToColumn | NextWordStart { .. } + | NextWordEnd { .. } | PreviousWordStart { .. } | PreviousWordEnd { .. } + | NextSubwordStart { .. } + | NextSubwordEnd { .. } + | PreviousSubwordStart { .. } + | PreviousSubwordEnd { .. } | FirstNonWhitespace { .. } | FindBackward { .. } | RepeatFind { .. } @@ -376,7 +444,6 @@ impl Motion { Down { .. } | Up { .. } | EndOfLine { .. } - | NextWordEnd { .. } | Matching | FindForward { .. } | RepeatFind { .. } @@ -391,14 +458,19 @@ impl Motion { | EndOfLineDownward | GoToColumn | NextWordStart { .. } + | NextWordEnd { .. } | PreviousWordStart { .. } + | PreviousWordEnd { .. } + | NextSubwordStart { .. } + | NextSubwordEnd { .. } + | PreviousSubwordStart { .. } + | PreviousSubwordEnd { .. } | FirstNonWhitespace { .. } | FindBackward { .. } | RepeatFindReversed { .. } | WindowTop | WindowMiddle | WindowBottom - | PreviousWordEnd { .. } | NextLineStart => false, } } @@ -413,13 +485,15 @@ impl Motion { | CurrentLine | EndOfLine { .. } | EndOfLineDownward - | NextWordEnd { .. } | Matching | FindForward { .. } | WindowTop | WindowMiddle | WindowBottom + | NextWordEnd { .. } | PreviousWordEnd { .. } + | NextSubwordEnd { .. } + | PreviousSubwordEnd { .. } | NextLineStart => true, Left | Backspace @@ -432,6 +506,8 @@ impl Motion { | GoToColumn | NextWordStart { .. } | PreviousWordStart { .. } + | NextSubwordStart { .. } + | PreviousSubwordStart { .. } | FirstNonWhitespace { .. } | FindBackward { .. } => false, RepeatFind { last_find: motion } | RepeatFindReversed { last_find: motion } => { @@ -473,7 +549,7 @@ impl Motion { SelectionGoal::None, ), NextWordEnd { ignore_punctuation } => ( - next_word_end(map, point, *ignore_punctuation, times), + next_word_end(map, point, *ignore_punctuation, times, true), SelectionGoal::None, ), PreviousWordStart { ignore_punctuation } => ( @@ -484,6 +560,22 @@ impl Motion { previous_word_end(map, point, *ignore_punctuation, times), SelectionGoal::None, ), + NextSubwordStart { ignore_punctuation } => ( + next_subword_start(map, point, *ignore_punctuation, times), + SelectionGoal::None, + ), + NextSubwordEnd { ignore_punctuation } => ( + next_subword_end(map, point, *ignore_punctuation, times, true), + SelectionGoal::None, + ), + PreviousSubwordStart { ignore_punctuation } => ( + previous_subword_start(map, point, *ignore_punctuation, times), + SelectionGoal::None, + ), + PreviousSubwordEnd { ignore_punctuation } => ( + previous_subword_end(map, point, *ignore_punctuation, times), + SelectionGoal::None, + ), FirstNonWhitespace { display_lines } => ( first_non_whitespace(map, *display_lines, point), SelectionGoal::None, @@ -819,6 +911,25 @@ pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize point } +pub(crate) fn next_char( + map: &DisplaySnapshot, + point: DisplayPoint, + allow_cross_newline: bool, +) -> DisplayPoint { + let mut new_point = point; + let mut max_column = map.line_len(new_point.row()); + if !allow_cross_newline { + max_column -= 1; + } + if new_point.column() < max_column { + *new_point.column_mut() += 1; + } else if new_point < map.max_point() && allow_cross_newline { + *new_point.row_mut() += 1; + *new_point.column_mut() = 0; + } + new_point +} + pub(crate) fn next_word_start( map: &DisplaySnapshot, mut point: DisplayPoint, @@ -848,22 +959,17 @@ pub(crate) fn next_word_start( point } -fn next_word_end( +pub(crate) fn next_word_end( map: &DisplaySnapshot, mut point: DisplayPoint, ignore_punctuation: bool, times: usize, + allow_cross_newline: bool, ) -> DisplayPoint { let scope = map.buffer_snapshot.language_scope_at(point.to_point(map)); for _ in 0..times { - let mut new_point = point; - if new_point.column() < map.line_len(new_point.row()) { - *new_point.column_mut() += 1; - } else if new_point < map.max_point() { - *new_point.row_mut() += 1; - *new_point.column_mut() = 0; - } - + let new_point = next_char(map, point, allow_cross_newline); + let mut need_next_char = false; let new_point = movement::find_boundary_exclusive( map, new_point, @@ -871,10 +977,21 @@ fn next_word_end( |left, right| { let left_kind = coerce_punctuation(char_kind(&scope, left), ignore_punctuation); let right_kind = coerce_punctuation(char_kind(&scope, right), ignore_punctuation); + let at_newline = right == '\n'; + + if !allow_cross_newline && at_newline { + need_next_char = true; + return true; + } left_kind != right_kind && left_kind != CharKind::Whitespace }, ); + let new_point = if need_next_char { + next_char(map, new_point, true) + } else { + new_point + }; let new_point = map.clip_point(new_point, Bias::Left); if point == new_point { break; @@ -913,6 +1030,210 @@ fn previous_word_start( point } +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)) +} + +fn next_subword_start( + map: &DisplaySnapshot, + mut point: DisplayPoint, + ignore_punctuation: bool, + times: usize, +) -> DisplayPoint { + let scope = map.buffer_snapshot.language_scope_at(point.to_point(map)); + for _ in 0..times { + let mut crossed_newline = false; + let new_point = movement::find_boundary(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); + let at_newline = right == '\n'; + + let is_word_start = (left_kind != right_kind) && !left.is_alphanumeric(); + let is_subword_start = + left == '_' && right != '_' || left.is_lowercase() && right.is_uppercase(); + + let found = (!right.is_whitespace() && (is_word_start || is_subword_start)) + || at_newline && crossed_newline + || at_newline && left == '\n'; // Prevents skipping repeated empty lines + + crossed_newline |= at_newline; + found + }); + if point == new_point { + break; + } + point = new_point; + } + point +} + +pub(crate) fn next_subword_end( + map: &DisplaySnapshot, + mut point: DisplayPoint, + ignore_punctuation: bool, + times: usize, + allow_cross_newline: bool, +) -> DisplayPoint { + let scope = map.buffer_snapshot.language_scope_at(point.to_point(map)); + for _ in 0..times { + let new_point = next_char(map, point, allow_cross_newline); + + let mut crossed_newline = false; + let mut need_backtrack = false; + let new_point = + movement::find_boundary(map, new_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); + let at_newline = right == '\n'; + + if !allow_cross_newline && at_newline { + return true; + } + + let is_word_end = (left_kind != right_kind) && !right.is_alphanumeric(); + let is_subword_end = + left != '_' && right == '_' || left.is_lowercase() && right.is_uppercase(); + + let found = !left.is_whitespace() && !at_newline && (is_word_end || is_subword_end); + + if found && (is_word_end || is_subword_end) { + need_backtrack = true; + } + + crossed_newline |= at_newline; + found + }); + let mut new_point = map.clip_point(new_point, Bias::Left); + if need_backtrack { + *new_point.column_mut() -= 1; + } + if point == new_point { + break; + } + point = new_point; + } + point +} + +fn previous_subword_start( + map: &DisplaySnapshot, + mut point: DisplayPoint, + ignore_punctuation: bool, + times: usize, +) -> DisplayPoint { + let scope = map.buffer_snapshot.language_scope_at(point.to_point(map)); + for _ in 0..times { + let mut crossed_newline = false; + // 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_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); + let at_newline = right == '\n'; + + let is_word_start = (left_kind != right_kind) && !left.is_alphanumeric(); + let is_subword_start = + left == '_' && right != '_' || left.is_lowercase() && right.is_uppercase(); + + let found = (!right.is_whitespace() && (is_word_start || is_subword_start)) + || at_newline && crossed_newline + || at_newline && left == '\n'; // Prevents skipping repeated empty lines + + crossed_newline |= at_newline; + + found + }, + ); + if point == new_point { + break; + } + point = new_point; + } + point +} + +fn previous_subword_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); + + let is_subword_end = + left != '_' && right == '_' || left.is_lowercase() && right.is_uppercase(); + + if is_subword_end { + return true; + } + + match (left_kind, right_kind) { + (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)) +} + pub(crate) fn first_non_whitespace( map: &DisplaySnapshot, display_lines: bool, @@ -1217,44 +1538,6 @@ 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 { diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 380cbcb3932824ca585dabd0adc26db943d56d69..0ad47693e54e4ca01d1b9ab911a18c1eb544df56 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -407,11 +407,12 @@ pub(crate) fn normal_replace(text: Arc, cx: &mut WindowContext) { #[cfg(test)] mod test { - use gpui::TestAppContext; + use gpui::{KeyBinding, TestAppContext}; use indoc::indoc; use settings::SettingsStore; use crate::{ + motion, state::Mode::{self}, test::{NeovimBackedTestContext, VimTestContext}, VimSettings, @@ -1045,4 +1046,73 @@ mod test { cx.simulate_shared_keystrokes(["4", "$"]).await; cx.assert_shared_state("aa\nbb\ncˇc").await; } + + #[gpui::test] + async fn test_subword_motions(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.update(|cx| { + cx.bind_keys(vec![ + KeyBinding::new( + "w", + motion::NextSubwordStart { + ignore_punctuation: false, + }, + Some("Editor && VimControl && !VimWaiting && !menu"), + ), + KeyBinding::new( + "b", + motion::PreviousSubwordStart { + ignore_punctuation: false, + }, + Some("Editor && VimControl && !VimWaiting && !menu"), + ), + KeyBinding::new( + "e", + motion::NextSubwordEnd { + ignore_punctuation: false, + }, + Some("Editor && VimControl && !VimWaiting && !menu"), + ), + KeyBinding::new( + "g e", + motion::PreviousSubwordEnd { + ignore_punctuation: false, + }, + Some("Editor && VimControl && !VimWaiting && !menu"), + ), + ]); + }); + + cx.assert_binding_normal( + ["w"], + indoc! {"ˇassert_binding"}, + indoc! {"assert_ˇbinding"}, + ); + // Special case: In 'cw', 'w' acts like 'e' + cx.assert_binding( + ["c", "w"], + indoc! {"ˇassert_binding"}, + Mode::Normal, + indoc! {"ˇ_binding"}, + Mode::Insert, + ); + + cx.assert_binding_normal( + ["e"], + indoc! {"ˇassert_binding"}, + indoc! {"asserˇt_binding"}, + ); + + cx.assert_binding_normal( + ["b"], + indoc! {"assert_ˇbinding"}, + indoc! {"ˇassert_binding"}, + ); + + cx.assert_binding_normal( + ["g", "e"], + indoc! {"assert_bindinˇg"}, + indoc! {"asserˇt_binding"}, + ); + } } diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 08f3202e79926e4a4ee6035c56d072ba91964acc..dd7d6d2652ca4cf2fc7e678125a05e902b0446f5 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -1,15 +1,12 @@ use crate::{ - motion::Motion, + motion::{self, Motion}, object::Object, state::Mode, - utils::{coerce_punctuation, copy_selections_content}, + utils::copy_selections_content, Vim, }; use editor::{ - display_map::DisplaySnapshot, - movement::{self, FindRange, TextLayoutDetails}, - scroll::Autoscroll, - DisplayPoint, + display_map::DisplaySnapshot, movement::TextLayoutDetails, scroll::Autoscroll, DisplayPoint, }; use gpui::WindowContext; use language::{char_kind, CharKind, Selection}; @@ -39,6 +36,16 @@ pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m times, ignore_punctuation, &text_layout_details, + false, + ) + } else if let Motion::NextSubwordStart { ignore_punctuation } = motion { + expand_changed_word_selection( + map, + selection, + times, + ignore_punctuation, + &text_layout_details, + true, ) } else { motion.expand_selection(map, selection, times, false, &text_layout_details) @@ -94,6 +101,7 @@ fn expand_changed_word_selection( times: Option, ignore_punctuation: bool, text_layout_details: &TextLayoutDetails, + use_subword: bool, ) -> bool { if times.is_none() || times.unwrap() == 1 { let scope = map @@ -106,32 +114,30 @@ fn expand_changed_word_selection( .unwrap_or_default(); if in_word { - selection.end = - movement::find_boundary(map, selection.end, 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 && left_kind != CharKind::Whitespace - }); + if !use_subword { + selection.end = + motion::next_word_end(map, selection.end, ignore_punctuation, 1, false); + } else { + selection.end = + motion::next_subword_end(map, selection.end, ignore_punctuation, 1, false); + } + selection.end = motion::next_char(map, selection.end, false); true } else { - Motion::NextWordStart { ignore_punctuation }.expand_selection( - map, - selection, - None, - false, - &text_layout_details, - ) + let motion = if use_subword { + Motion::NextSubwordStart { ignore_punctuation } + } else { + Motion::NextWordStart { ignore_punctuation } + }; + motion.expand_selection(map, selection, None, false, &text_layout_details) } } else { - Motion::NextWordStart { ignore_punctuation }.expand_selection( - map, - selection, - times, - false, - &text_layout_details, - ) + let motion = if use_subword { + Motion::NextSubwordStart { ignore_punctuation } + } else { + Motion::NextWordStart { ignore_punctuation } + }; + motion.expand_selection(map, selection, times, false, &text_layout_details) } } diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index f8cc658394f13f3331d3d54f8c7e96e2f5726c3a..11f9a7884925f3558f3c17e2a08ab6b7e46295a9 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -159,6 +159,19 @@ impl VimTestContext { assert_eq!(self.mode(), mode_after, "{}", self.assertion_context()); assert_eq!(self.active_operator(), None, "{}", self.assertion_context()); } + + pub fn assert_binding_normal( + &mut self, + keystrokes: [&str; COUNT], + initial_state: &str, + state_after: &str, + ) { + self.set_state(initial_state, Mode::Normal); + self.cx.simulate_keystrokes(keystrokes); + self.cx.assert_editor_state(state_after); + assert_eq!(self.mode(), Mode::Normal, "{}", self.assertion_context()); + assert_eq!(self.active_operator(), None, "{}", self.assertion_context()); + } } impl Deref for VimTestContext { diff --git a/docs/src/configuring_zed__configuring_vim.md b/docs/src/configuring_zed__configuring_vim.md index 4ab0a9da9cedb41db1a55090adb6e3f38f80e1ce..c43e324c53a521d6d2479adcac1fd17d553344e2 100644 --- a/docs/src/configuring_zed__configuring_vim.md +++ b/docs/src/configuring_zed__configuring_vim.md @@ -88,6 +88,22 @@ You can see the bindings that are enabled by default in vim mode [here](https:// The details of the context are a little out of scope for this doc, but suffice to say that `menu` is true when a menu is open (e.g. the completions menu), `VimWaiting` is true after you type `f` or `t` when we’re waiting for a new key (and you probably don’t want bindings to happen). Please reach out on [GitHub](https://github.com/zed-industries/zed) if you want help making a key bindings work. +## Subword motion + +Subword motion is not enabled by default. To enable it, add these bindings to your keymap. + +```json + { + "context": "Editor && VimControl && !VimWaiting && !menu", + "bindings": { + "w": "vim::NextSubwordStart", + "b": "vim::PreviousSubwordStart", + "e": "vim::NextSubwordEnd", + "g e": "vim::PreviousSubwordEnd" + } + }, +``` + ## Command palette Vim mode allows you to enable Zed’s command palette with `:`. This means that you can use vim's command palette to run any action that Zed supports.