vim: Sentence motion (#17425)

Closes #12161

Release Notes:

- vim: Added `(` and `)` for sentence motion
This commit is contained in:
Conrad Irwin 2024-09-05 11:18:52 -06:00 committed by GitHub
parent 01e40928d8
commit 1e09884a22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 276 additions and 2 deletions

View file

@ -29,6 +29,8 @@
"shift-g": "vim::EndOfDocument",
"{": "vim::StartOfParagraph",
"}": "vim::EndOfParagraph",
"(": "vim::SentenceBackward",
")": "vim::SentenceForward",
"|": "vim::GoToColumn",
// Word motions
"w": "vim::NextWordStart",

View file

@ -65,6 +65,8 @@ pub enum Motion {
EndOfLine {
display_lines: bool,
},
SentenceBackward,
SentenceForward,
StartOfParagraph,
EndOfParagraph,
StartOfDocument,
@ -228,6 +230,8 @@ actions!(
Right,
Space,
CurrentLine,
SentenceForward,
SentenceBackward,
StartOfParagraph,
EndOfParagraph,
StartOfDocument,
@ -306,6 +310,13 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
Vim::action(editor, cx, |vim, _: &EndOfParagraph, cx| {
vim.motion(Motion::EndOfParagraph, cx)
});
Vim::action(editor, cx, |vim, _: &SentenceForward, cx| {
vim.motion(Motion::SentenceForward, cx)
});
Vim::action(editor, cx, |vim, _: &SentenceBackward, cx| {
vim.motion(Motion::SentenceBackward, cx)
});
Vim::action(editor, cx, |vim, _: &StartOfDocument, cx| {
vim.motion(Motion::StartOfDocument, cx)
});
@ -483,12 +494,14 @@ impl Motion {
| NextLineStart
| PreviousLineStart
| StartOfLineDownward
| SentenceBackward
| SentenceForward
| StartOfParagraph
| EndOfParagraph
| WindowTop
| WindowMiddle
| WindowBottom
| Jump { line: true, .. }
| EndOfParagraph => true,
| Jump { line: true, .. } => true,
EndOfLine { .. }
| Matching
| FindForward { .. }
@ -533,6 +546,8 @@ impl Motion {
| StartOfLine { .. }
| StartOfParagraph
| EndOfParagraph
| SentenceBackward
| SentenceForward
| StartOfLineDownward
| EndOfLineDownward
| GoToColumn
@ -586,6 +601,8 @@ impl Motion {
| StartOfLineDownward
| StartOfParagraph
| EndOfParagraph
| SentenceBackward
| SentenceForward
| GoToColumn
| NextWordStart { .. }
| PreviousWordStart { .. }
@ -673,6 +690,8 @@ impl Motion {
end_of_line(map, *display_lines, point, times),
SelectionGoal::None,
),
SentenceBackward => (sentence_backwards(map, point, times), SelectionGoal::None),
SentenceForward => (sentence_forwards(map, point, times), SelectionGoal::None),
StartOfParagraph => (
movement::start_of_paragraph(map, point, times),
SelectionGoal::None,
@ -1534,6 +1553,129 @@ pub(crate) fn end_of_line(
}
}
fn sentence_backwards(
map: &DisplaySnapshot,
point: DisplayPoint,
mut times: usize,
) -> DisplayPoint {
let mut start = point.to_point(&map).to_offset(&map.buffer_snapshot);
let mut chars = map.reverse_buffer_chars_at(start).peekable();
let mut was_newline = map
.buffer_chars_at(start)
.next()
.is_some_and(|(c, _)| c == '\n');
while let Some((ch, offset)) = chars.next() {
let start_of_next_sentence = if was_newline && ch == '\n' {
Some(offset + ch.len_utf8())
} else if ch == '\n' && chars.peek().is_some_and(|(c, _)| *c == '\n') {
Some(next_non_blank(map, offset + ch.len_utf8()))
} else if ch == '.' || ch == '?' || ch == '!' {
start_of_next_sentence(map, offset + ch.len_utf8())
} else {
None
};
if let Some(start_of_next_sentence) = start_of_next_sentence {
if start_of_next_sentence < start {
times = times.saturating_sub(1);
}
if times == 0 || offset == 0 {
return map.clip_point(
start_of_next_sentence
.to_offset(&map.buffer_snapshot)
.to_display_point(&map),
Bias::Left,
);
}
}
if was_newline {
start = offset;
}
was_newline = ch == '\n';
}
return DisplayPoint::zero();
}
fn sentence_forwards(map: &DisplaySnapshot, point: DisplayPoint, mut times: usize) -> DisplayPoint {
let start = point.to_point(&map).to_offset(&map.buffer_snapshot);
let mut chars = map.buffer_chars_at(start).peekable();
let mut was_newline = map
.reverse_buffer_chars_at(start)
.next()
.is_some_and(|(c, _)| c == '\n')
&& chars.peek().is_some_and(|(c, _)| *c == '\n');
while let Some((ch, offset)) = chars.next() {
if was_newline && ch == '\n' {
continue;
}
let start_of_next_sentence = if was_newline {
Some(next_non_blank(map, offset))
} else if ch == '\n' && chars.peek().is_some_and(|(c, _)| *c == '\n') {
Some(next_non_blank(map, offset + ch.len_utf8()))
} else if ch == '.' || ch == '?' || ch == '!' {
start_of_next_sentence(map, offset + ch.len_utf8())
} else {
None
};
if let Some(start_of_next_sentence) = start_of_next_sentence {
times = times.saturating_sub(1);
if times == 0 {
return map.clip_point(
start_of_next_sentence
.to_offset(&map.buffer_snapshot)
.to_display_point(&map),
Bias::Right,
);
}
}
was_newline = ch == '\n' && chars.peek().is_some_and(|(c, _)| *c == '\n');
}
return map.max_point();
}
fn next_non_blank(map: &DisplaySnapshot, start: usize) -> usize {
for (c, o) in map.buffer_chars_at(start) {
if c == '\n' || !c.is_whitespace() {
return o;
}
}
return map.buffer_snapshot.len();
}
// given the offset after a ., !, or ? find the start of the next sentence.
// if this is not a sentence boundary, returns None.
fn start_of_next_sentence(map: &DisplaySnapshot, end_of_sentence: usize) -> Option<usize> {
let mut chars = map.buffer_chars_at(end_of_sentence);
let mut seen_space = false;
while let Some((char, offset)) = chars.next() {
if !seen_space && (char == ')' || char == ']' || char == '"' || char == '\'') {
continue;
}
if char == '\n' && seen_space {
return Some(offset);
} else if char.is_whitespace() {
seen_space = true;
} else if seen_space {
return Some(offset);
} else {
return None;
}
}
return Some(map.buffer_snapshot.len());
}
fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint, line: usize) -> DisplayPoint {
let mut new_point = Point::new((line - 1) as u32, 0).to_display_point(map);
*new_point.column_mut() = point.column();

View file

@ -1426,3 +1426,93 @@ async fn test_record_replay_recursion(cx: &mut gpui::TestAppContext) {
cx.simulate_shared_keystrokes(".").await;
cx.shared_state().await.assert_eq("ˇhello world"); // takes a _long_ time
}
#[gpui::test]
async fn test_sentence_backwards(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("one\n\ntwo\nthree\nˇ\nfour").await;
cx.simulate_shared_keystrokes("(").await;
cx.shared_state()
.await
.assert_eq("one\n\nˇtwo\nthree\n\nfour");
cx.set_shared_state("hello.\n\n\nworˇld.").await;
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq("hello.\n\n\nˇworld.");
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq("hello.\n\nˇ\nworld.");
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq("ˇhello.\n\n\nworld.");
cx.set_shared_state("hello. worlˇd.").await;
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq("hello. ˇworld.");
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq("ˇhello. world.");
cx.set_shared_state(". helˇlo.").await;
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq(". ˇhello.");
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq(". ˇhello.");
cx.set_shared_state(indoc! {
"{
hello_world();
ˇ}"
})
.await;
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq(indoc! {
"ˇ{
hello_world();
}"
});
cx.set_shared_state(indoc! {
"Hello! World..?
\tHello! World... ˇ"
})
.await;
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq(indoc! {
"Hello! World..?
\tHello! ˇWorld... "
});
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq(indoc! {
"Hello! World..?
\tˇHello! World... "
});
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq(indoc! {
"Hello! World..?
ˇ
\tHello! World... "
});
cx.simulate_shared_keystrokes("(").await;
cx.shared_state().await.assert_eq(indoc! {
"Hello! ˇWorld..?
\tHello! World... "
});
}
#[gpui::test]
async fn test_sentence_forwards(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state("helˇlo.\n\n\nworld.").await;
cx.simulate_shared_keystrokes(")").await;
cx.shared_state().await.assert_eq("hello.\nˇ\n\nworld.");
cx.simulate_shared_keystrokes(")").await;
cx.shared_state().await.assert_eq("hello.\n\n\nˇworld.");
cx.simulate_shared_keystrokes(")").await;
cx.shared_state().await.assert_eq("hello.\n\n\nworldˇ.");
cx.set_shared_state("helˇlo.\n\n\nworld.").await;
}

View file

@ -0,0 +1,32 @@
{"Put":{"state":"one\n\ntwo\nthree\nˇ\nfour"}}
{"Key":"("}
{"Get":{"state":"one\n\nˇtwo\nthree\n\nfour","mode":"Normal"}}
{"Put":{"state":"hello.\n\n\nworˇld."}}
{"Key":"("}
{"Get":{"state":"hello.\n\n\nˇworld.","mode":"Normal"}}
{"Key":"("}
{"Get":{"state":"hello.\n\nˇ\nworld.","mode":"Normal"}}
{"Key":"("}
{"Get":{"state":"ˇhello.\n\n\nworld.","mode":"Normal"}}
{"Put":{"state":"hello. worlˇd."}}
{"Key":"("}
{"Get":{"state":"hello. ˇworld.","mode":"Normal"}}
{"Key":"("}
{"Get":{"state":"ˇhello. world.","mode":"Normal"}}
{"Put":{"state":". helˇlo."}}
{"Key":"("}
{"Get":{"state":". ˇhello.","mode":"Normal"}}
{"Key":"("}
{"Get":{"state":". ˇhello.","mode":"Normal"}}
{"Put":{"state":"{\n hello_world();\nˇ}"}}
{"Key":"("}
{"Get":{"state":"ˇ{\n hello_world();\n}","mode":"Normal"}}
{"Put":{"state":"Hello! World..?\n\n\tHello! World... ˇ"}}
{"Key":"("}
{"Get":{"state":"Hello! World..?\n\n\tHello! ˇWorld... ","mode":"Normal"}}
{"Key":"("}
{"Get":{"state":"Hello! World..?\n\n\tˇHello! World... ","mode":"Normal"}}
{"Key":"("}
{"Get":{"state":"Hello! World..?\nˇ\n\tHello! World... ","mode":"Normal"}}
{"Key":"("}
{"Get":{"state":"Hello! ˇWorld..?\n\n\tHello! World... ","mode":"Normal"}}

View file

@ -0,0 +1,8 @@
{"Put":{"state":"helˇlo.\n\n\nworld."}}
{"Key":")"}
{"Get":{"state":"hello.\nˇ\n\nworld.","mode":"Normal"}}
{"Key":")"}
{"Get":{"state":"hello.\n\n\nˇworld.","mode":"Normal"}}
{"Key":")"}
{"Get":{"state":"hello.\n\n\nworldˇ.","mode":"Normal"}}
{"Put":{"state":"helˇlo.\n\n\nworld."}}