vim: Add sneak motion (#22793)

A (re)continuation of https://github.com/zed-industries/zed/pull/21067. 

This takes the original implementation in
https://github.com/zed-industries/zed/pull/15572 and adds the test in
https://github.com/zed-industries/zed/pull/21067. Then, as requested in
https://github.com/zed-industries/zed/pull/21067#issuecomment-2515469185,
it documents how to map a keybinding instead of having a setting.

Closes #13858

Release Notes:

- Added support for the popular
[vim_sneak](https://github.com/justinmk/vim-sneak) plugin. This is
disabled by default and can be enabled by binding a key to the `Sneak`
and `SneakBackward` operators.

Reference:
https://github.com/justinmk/vim-sneak

---------

Co-authored-by: Kajetan Puchalski <kajetan.puchalski@tuta.io>
Co-authored-by: Aidan Grant <mraidangrant@gmail.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Nico Lehmann 2025-01-10 04:07:32 -03:00 committed by GitHub
parent 0d6a549950
commit 0b105ba8b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 301 additions and 5 deletions

View file

@ -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<Motion>,
},
@ -538,8 +548,10 @@ impl Vim {
}
pub(crate) fn motion(&mut self, motion: Motion, cx: &mut ViewContext<Self>) {
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<DisplayPoint> {
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<DisplayPoint> {
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)

View file

@ -68,6 +68,8 @@ pub enum Operator {
Object { around: bool },
FindForward { before: bool },
FindBackward { after: bool },
Sneak { first_char: Option<char> },
SneakBackward { first_char: Option<char> },
AddSurrounds { target: Option<SurroundsType> },
ChangeSurrounds { target: Option<Object> },
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

View file

@ -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#"<label for="guests">ˇo</label>"#);
}
#[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#"<labelˇ for="guests">
Počet hostů
</label>"#
},
Mode::Normal,
);
cx.simulate_keystrokes("s t ů");
cx.assert_state(
indoc! {
r#"<label for="guests">
Počet hosˇtů
</label>"#
},
Mode::Normal,
);
// Visual sneak backwards multibyte & multiline
cx.simulate_keystrokes("v S < l");
cx.assert_state(
indoc! {
r#"«ˇ<label for="guests">
Počet host»ů
</label>"#
},
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;

View file

@ -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 => {

View file

@ -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: