diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index acce32b24ddf2..43c4397e8b272 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -91,6 +91,16 @@ pub enum Motion { mode: FindRange, smartcase: bool, }, + Sneak { + first_char: char, + second_char: char, + smartcase: bool, + }, + SneakBackward { + first_char: char, + second_char: char, + smartcase: bool, + }, RepeatFind { last_find: Box, }, @@ -538,8 +548,10 @@ impl Vim { } pub(crate) fn motion(&mut self, motion: Motion, cx: &mut ViewContext) { - if let Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. }) = - self.active_operator() + if let Some(Operator::FindForward { .. }) + | Some(Operator::Sneak { .. }) + | Some(Operator::SneakBackward { .. }) + | Some(Operator::FindBackward { .. }) = self.active_operator() { self.pop_operator(cx); } @@ -625,6 +637,8 @@ impl Motion { | PreviousSubwordEnd { .. } | FirstNonWhitespace { .. } | FindBackward { .. } + | Sneak { .. } + | SneakBackward { .. } | RepeatFind { .. } | RepeatFindReversed { .. } | Jump { line: false, .. } @@ -666,6 +680,8 @@ impl Motion { | PreviousSubwordEnd { .. } | FirstNonWhitespace { .. } | FindBackward { .. } + | Sneak { .. } + | SneakBackward { .. } | RepeatFindReversed { .. } | WindowTop | WindowMiddle @@ -727,6 +743,8 @@ impl Motion { | PreviousSubwordStart { .. } | FirstNonWhitespace { .. } | FindBackward { .. } + | Sneak { .. } + | SneakBackward { .. } | Jump { .. } | NextSectionStart | NextSectionEnd @@ -862,6 +880,22 @@ impl Motion { find_backward(map, point, *after, *char, times, *mode, *smartcase), SelectionGoal::None, ), + Sneak { + first_char, + second_char, + smartcase, + } => { + return sneak(map, point, *first_char, *second_char, times, *smartcase) + .map(|new_point| (new_point, SelectionGoal::None)); + } + SneakBackward { + first_char, + second_char, + smartcase, + } => { + return sneak_backward(map, point, *first_char, *second_char, times, *smartcase) + .map(|new_point| (new_point, SelectionGoal::None)); + } // ; -- repeat the last find done with t, f, T, F RepeatFind { last_find } => match **last_find { Motion::FindForward { @@ -895,9 +929,44 @@ impl Motion { (new_point, SelectionGoal::None) } + Motion::Sneak { + first_char, + second_char, + smartcase, + } => { + let mut new_point = + sneak(map, point, first_char, second_char, times, smartcase); + if new_point == Some(point) { + new_point = + sneak(map, point, first_char, second_char, times + 1, smartcase); + } + + return new_point.map(|new_point| (new_point, SelectionGoal::None)); + } + + Motion::SneakBackward { + first_char, + second_char, + smartcase, + } => { + let mut new_point = + sneak_backward(map, point, first_char, second_char, times, smartcase); + if new_point == Some(point) { + new_point = sneak_backward( + map, + point, + first_char, + second_char, + times + 1, + smartcase, + ); + } + + return new_point.map(|new_point| (new_point, SelectionGoal::None)); + } _ => return None, }, - // , -- repeat the last find done with t, f, T, F, in opposite direction + // , -- repeat the last find done with t, f, T, F, s, S, in opposite direction RepeatFindReversed { last_find } => match **last_find { Motion::FindForward { before, @@ -930,6 +999,42 @@ impl Motion { return new_point.map(|new_point| (new_point, SelectionGoal::None)); } + + Motion::Sneak { + first_char, + second_char, + smartcase, + } => { + let mut new_point = + sneak_backward(map, point, first_char, second_char, times, smartcase); + if new_point == Some(point) { + new_point = sneak_backward( + map, + point, + first_char, + second_char, + times + 1, + smartcase, + ); + } + + return new_point.map(|new_point| (new_point, SelectionGoal::None)); + } + + Motion::SneakBackward { + first_char, + second_char, + smartcase, + } => { + let mut new_point = + sneak(map, point, first_char, second_char, times, smartcase); + if new_point == Some(point) { + new_point = + sneak(map, point, first_char, second_char, times + 1, smartcase); + } + + return new_point.map(|new_point| (new_point, SelectionGoal::None)); + } _ => return None, }, NextLineStart => (next_line_start(map, point, times), SelectionGoal::None), @@ -2134,6 +2239,74 @@ fn is_character_match(target: char, other: char, smartcase: bool) -> bool { } } +fn sneak( + map: &DisplaySnapshot, + from: DisplayPoint, + first_target: char, + second_target: char, + times: usize, + smartcase: bool, +) -> Option { + let mut to = from; + let mut found = false; + + for _ in 0..times { + found = false; + let new_to = find_boundary( + map, + movement::right(map, to), + FindRange::MultiLine, + |left, right| { + found = is_character_match(first_target, left, smartcase) + && is_character_match(second_target, right, smartcase); + found + }, + ); + if to == new_to { + break; + } + to = new_to; + } + + if found { + Some(movement::left(map, to)) + } else { + None + } +} + +fn sneak_backward( + map: &DisplaySnapshot, + from: DisplayPoint, + first_target: char, + second_target: char, + times: usize, + smartcase: bool, +) -> Option { + let mut to = from; + let mut found = false; + + for _ in 0..times { + found = false; + let new_to = + find_preceding_boundary_display_point(map, to, FindRange::MultiLine, |left, right| { + found = is_character_match(first_target, left, smartcase) + && is_character_match(second_target, right, smartcase); + found + }); + if to == new_to { + break; + } + to = new_to; + } + + if found { + Some(movement::left(map, to)) + } else { + None + } +} + fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint { let correct_line = start_of_relative_buffer_row(map, point, times as isize); first_non_whitespace(map, false, correct_line) diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index e93eeef40484e..f7a617a6ab55f 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -68,6 +68,8 @@ pub enum Operator { Object { around: bool }, FindForward { before: bool }, FindBackward { after: bool }, + Sneak { first_char: Option }, + SneakBackward { first_char: Option }, AddSurrounds { target: Option }, ChangeSurrounds { target: Option }, DeleteSurrounds, @@ -460,6 +462,8 @@ impl Operator { Operator::Literal { .. } => "^V", Operator::FindForward { before: false } => "f", Operator::FindForward { before: true } => "t", + Operator::Sneak { .. } => "s", + Operator::SneakBackward { .. } => "S", Operator::FindBackward { after: false } => "F", Operator::FindBackward { after: true } => "T", Operator::AddSurrounds { .. } => "ys", @@ -502,6 +506,8 @@ impl Operator { | Operator::Mark | Operator::Jump { .. } | Operator::FindBackward { .. } + | Operator::Sneak { .. } + | Operator::SneakBackward { .. } | Operator::Register | Operator::RecordRegister | Operator::ReplayRegister diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 25488c9146caa..ffefe0459afee 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -17,7 +17,12 @@ use indoc::indoc; use search::BufferSearchBar; use workspace::WorkspaceSettings; -use crate::{insert::NormalBefore, motion, state::Mode}; +use crate::{ + insert::NormalBefore, + motion, + state::{Mode, Operator}, + PushOperator, +}; #[gpui::test] async fn test_initially_disabled(cx: &mut gpui::TestAppContext) { @@ -1332,6 +1337,68 @@ async fn test_find_multibyte(cx: &mut gpui::TestAppContext) { .assert_eq(r#""#); } +#[gpui::test] +async fn test_sneak(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.update(|cx| { + cx.bind_keys([ + KeyBinding::new( + "s", + PushOperator(Operator::Sneak { first_char: None }), + Some("vim_mode == normal"), + ), + KeyBinding::new( + "S", + PushOperator(Operator::SneakBackward { first_char: None }), + Some("vim_mode == normal"), + ), + KeyBinding::new( + "S", + PushOperator(Operator::SneakBackward { first_char: None }), + Some("vim_mode == visual"), + ), + ]) + }); + + // Sneak forwards multibyte & multiline + cx.set_state( + indoc! { + r#" + Počet hostů + "# + }, + Mode::Normal, + ); + cx.simulate_keystrokes("s t ů"); + cx.assert_state( + indoc! { + r#""# + }, + Mode::Normal, + ); + + // Visual sneak backwards multibyte & multiline + cx.simulate_keystrokes("v S < l"); + cx.assert_state( + indoc! { + r#"«ˇ"# + }, + Mode::Visual, + ); + + // Sneak backwards repeated + cx.set_state(r#"11 12 13 ˇ14"#, Mode::Normal); + cx.simulate_keystrokes("S space 1"); + cx.assert_state(r#"11 12ˇ 13 14"#, Mode::Normal); + cx.simulate_keystrokes(";"); + cx.assert_state(r#"11ˇ 12 13 14"#, Mode::Normal); +} + #[gpui::test] async fn test_plus_minus(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 5e64d1c93ec00..dbc3a25ce320c 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -80,9 +80,11 @@ actions!( InnerObject, FindForward, FindBackward, - OpenDefaultKeymap, MaximizePane, + OpenDefaultKeymap, ResetPaneSizes, + Sneak, + SneakBackward, ] ); @@ -1093,6 +1095,40 @@ impl Vim { Vim::globals(cx).last_find = Some(find.clone()); self.motion(find, cx) } + Some(Operator::Sneak { first_char }) => { + if let Some(first_char) = first_char { + if let Some(second_char) = text.chars().next() { + let sneak = Motion::Sneak { + first_char, + second_char, + smartcase: VimSettings::get_global(cx).use_smartcase_find, + }; + Vim::globals(cx).last_find = Some((&sneak).clone()); + self.motion(sneak, cx) + } + } else { + let first_char = text.chars().next(); + self.pop_operator(cx); + self.push_operator(Operator::Sneak { first_char }, cx); + } + } + Some(Operator::SneakBackward { first_char }) => { + if let Some(first_char) = first_char { + if let Some(second_char) = text.chars().next() { + let sneak = Motion::SneakBackward { + first_char, + second_char, + smartcase: VimSettings::get_global(cx).use_smartcase_find, + }; + Vim::globals(cx).last_find = Some((&sneak).clone()); + self.motion(sneak, cx) + } + } else { + let first_char = text.chars().next(); + self.pop_operator(cx); + self.push_operator(Operator::SneakBackward { first_char }, cx); + } + } Some(Operator::Replace) => match self.mode { Mode::Normal => self.normal_replace(text, cx), Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { diff --git a/docs/src/vim.md b/docs/src/vim.md index 22c6ab737c19c..a0bc604818604 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -408,6 +408,20 @@ Vim mode comes with shortcuts to surround the selection in normal mode (`ys`), b } ``` +The [Sneak motion](https://github.com/justinmk/vim-sneak) feature allows for quick navigation to any two-character sequence in your text. You can enable it by adding the following keybindings to your keymap. By default, the `s` key is mapped to `vim::Substitute`. Adding these bindings will override that behavior, so ensure this change aligns with your workflow preferences. + +```json +[ + { + "context": "vim_mode == normal || vim_mode == visual", + "bindings": { + "s": ["vim::PushOperator", { "Sneak": {} }], + "S": ["vim::PushOperator", { "SneakBackward": {} }] + } + } +] +``` + ### Restoring common text editing keybindings If you're using vim mode on Linux or Windows, you may find it overrides keybindings you can't live without: `ctrl+v` to copy, `ctrl+f` to search, etc. You can restore them by copying this data into your keymap: