vim: Respect count for paragraphs (#33489)

Closes #32462 

Release Notes:

- vim: Paragraph objects now support counts (`d2ap`, `v2ap`, etc.)

---------

Co-authored-by: Rift <no@e.mail>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Rift 2025-06-28 00:05:47 -04:00 committed by GitHub
parent ba4fc1bcfc
commit 97c5c5a6e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 182 additions and 54 deletions

View file

@ -1609,7 +1609,7 @@ impl Vim {
let snapshot = editor.snapshot(window, cx);
let start = editor.selections.newest_display(cx);
let range = object
.range(&snapshot, start.clone(), around)
.range(&snapshot, start.clone(), around, None)
.unwrap_or(start.range());
if range.start != start.start {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {

View file

@ -122,6 +122,7 @@ impl Vim {
object: Object,
around: bool,
dir: IndentDirection,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@ -133,7 +134,7 @@ impl Vim {
s.move_with(|map, selection| {
let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
original_positions.insert(selection.id, anchor);
object.expand_selection(map, selection, around);
object.expand_selection(map, selection, around, times);
});
});
match dir {

View file

@ -277,40 +277,51 @@ impl Vim {
self.exit_temporary_normal(window, cx);
}
pub fn normal_object(&mut self, object: Object, window: &mut Window, cx: &mut Context<Self>) {
pub fn normal_object(
&mut self,
object: Object,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut waiting_operator: Option<Operator> = None;
match self.maybe_pop_operator() {
Some(Operator::Object { around }) => match self.maybe_pop_operator() {
Some(Operator::Change) => self.change_object(object, around, window, cx),
Some(Operator::Delete) => self.delete_object(object, around, window, cx),
Some(Operator::Yank) => self.yank_object(object, around, window, cx),
Some(Operator::Change) => self.change_object(object, around, times, window, cx),
Some(Operator::Delete) => self.delete_object(object, around, times, window, cx),
Some(Operator::Yank) => self.yank_object(object, around, times, window, cx),
Some(Operator::Indent) => {
self.indent_object(object, around, IndentDirection::In, window, cx)
self.indent_object(object, around, IndentDirection::In, times, window, cx)
}
Some(Operator::Outdent) => {
self.indent_object(object, around, IndentDirection::Out, window, cx)
self.indent_object(object, around, IndentDirection::Out, times, window, cx)
}
Some(Operator::AutoIndent) => {
self.indent_object(object, around, IndentDirection::Auto, window, cx)
self.indent_object(object, around, IndentDirection::Auto, times, window, cx)
}
Some(Operator::ShellCommand) => {
self.shell_command_object(object, around, window, cx);
}
Some(Operator::Rewrap) => self.rewrap_object(object, around, window, cx),
Some(Operator::Rewrap) => self.rewrap_object(object, around, times, window, cx),
Some(Operator::Lowercase) => {
self.convert_object(object, around, ConvertTarget::LowerCase, window, cx)
self.convert_object(object, around, ConvertTarget::LowerCase, times, window, cx)
}
Some(Operator::Uppercase) => {
self.convert_object(object, around, ConvertTarget::UpperCase, window, cx)
}
Some(Operator::OppositeCase) => {
self.convert_object(object, around, ConvertTarget::OppositeCase, window, cx)
self.convert_object(object, around, ConvertTarget::UpperCase, times, window, cx)
}
Some(Operator::OppositeCase) => self.convert_object(
object,
around,
ConvertTarget::OppositeCase,
times,
window,
cx,
),
Some(Operator::Rot13) => {
self.convert_object(object, around, ConvertTarget::Rot13, window, cx)
self.convert_object(object, around, ConvertTarget::Rot13, times, window, cx)
}
Some(Operator::Rot47) => {
self.convert_object(object, around, ConvertTarget::Rot47, window, cx)
self.convert_object(object, around, ConvertTarget::Rot47, times, window, cx)
}
Some(Operator::AddSurrounds { target: None }) => {
waiting_operator = Some(Operator::AddSurrounds {
@ -318,7 +329,7 @@ impl Vim {
});
}
Some(Operator::ToggleComments) => {
self.toggle_comments_object(object, around, window, cx)
self.toggle_comments_object(object, around, times, window, cx)
}
Some(Operator::ReplaceWithRegister) => {
self.replace_with_register_object(object, around, window, cx)

View file

@ -105,6 +105,7 @@ impl Vim {
&mut self,
object: Object,
around: bool,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@ -115,7 +116,7 @@ impl Vim {
editor.transact(window, cx, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
objects_found |= object.expand_selection(map, selection, around);
objects_found |= object.expand_selection(map, selection, around, times);
});
});
if objects_found {

View file

@ -82,6 +82,7 @@ impl Vim {
object: Object,
around: bool,
mode: ConvertTarget,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@ -92,7 +93,7 @@ impl Vim {
let mut original_positions: HashMap<_, _> = Default::default();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(|map, selection| {
object.expand_selection(map, selection, around);
object.expand_selection(map, selection, around, times);
original_positions.insert(
selection.id,
map.display_point_to_anchor(selection.start, Bias::Left),

View file

@ -91,6 +91,7 @@ impl Vim {
&mut self,
object: Object,
around: bool,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@ -103,7 +104,7 @@ impl Vim {
let mut should_move_to_start: HashSet<_> = Default::default();
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
object.expand_selection(map, selection, around);
object.expand_selection(map, selection, around, times);
let offset_range = selection.map(|p| p.to_offset(map, Bias::Left)).range();
let mut move_selection_start_to_previous_line =
|map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>| {

View file

@ -240,7 +240,7 @@ impl Vim {
editor.set_clip_at_line_ends(false, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(|map, selection| {
object.expand_selection(map, selection, around);
object.expand_selection(map, selection, around, None);
});
});

View file

@ -46,6 +46,7 @@ impl Vim {
&mut self,
object: Object,
around: bool,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@ -57,7 +58,7 @@ impl Vim {
s.move_with(|map, selection| {
let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
original_positions.insert(selection.id, anchor);
object.expand_selection(map, selection, around);
object.expand_selection(map, selection, around, times);
});
});
editor.toggle_comments(&Default::default(), window, cx);

View file

@ -66,6 +66,7 @@ impl Vim {
&mut self,
object: Object,
around: bool,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@ -75,7 +76,7 @@ impl Vim {
let mut start_positions: HashMap<_, _> = Default::default();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.move_with(|map, selection| {
object.expand_selection(map, selection, around);
object.expand_selection(map, selection, around, times);
let start_position = (selection.start, selection.goal);
start_positions.insert(selection.id, start_position);
});

View file

@ -373,10 +373,12 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
impl Vim {
fn object(&mut self, object: Object, window: &mut Window, cx: &mut Context<Self>) {
let count = Self::take_count(cx);
match self.mode {
Mode::Normal => self.normal_object(object, window, cx),
Mode::Normal => self.normal_object(object, count, window, cx),
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
self.visual_object(object, window, cx)
self.visual_object(object, count, window, cx)
}
Mode::Insert | Mode::Replace | Mode::HelixNormal => {
// Shouldn't execute a text object in insert mode. Ignoring
@ -485,6 +487,7 @@ impl Object {
map: &DisplaySnapshot,
selection: Selection<DisplayPoint>,
around: bool,
times: Option<usize>,
) -> Option<Range<DisplayPoint>> {
let relative_to = selection.head();
match self {
@ -503,7 +506,8 @@ impl Object {
}
}
Object::Sentence => sentence(map, relative_to, around),
Object::Paragraph => paragraph(map, relative_to, around),
//change others later
Object::Paragraph => paragraph(map, relative_to, around, times.unwrap_or(1)),
Object::Quotes => {
surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'')
}
@ -692,8 +696,9 @@ impl Object {
map: &DisplaySnapshot,
selection: &mut Selection<DisplayPoint>,
around: bool,
times: Option<usize>,
) -> bool {
if let Some(range) = self.range(map, selection.clone(), around) {
if let Some(range) = self.range(map, selection.clone(), around, times) {
selection.start = range.start;
selection.end = range.end;
true
@ -1399,30 +1404,37 @@ fn paragraph(
map: &DisplaySnapshot,
relative_to: DisplayPoint,
around: bool,
times: usize,
) -> Option<Range<DisplayPoint>> {
let mut paragraph_start = start_of_paragraph(map, relative_to);
let mut paragraph_end = end_of_paragraph(map, relative_to);
let paragraph_end_row = paragraph_end.row();
let paragraph_ends_with_eof = paragraph_end_row == map.max_point().row();
let point = relative_to.to_point(map);
let current_line_is_empty = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row));
for i in 0..times {
let paragraph_end_row = paragraph_end.row();
let paragraph_ends_with_eof = paragraph_end_row == map.max_point().row();
let point = relative_to.to_point(map);
let current_line_is_empty = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row));
if around {
if paragraph_ends_with_eof {
if current_line_is_empty {
return None;
}
if around {
if paragraph_ends_with_eof {
if current_line_is_empty {
return None;
}
let paragraph_start_row = paragraph_start.row();
if paragraph_start_row.0 != 0 {
let previous_paragraph_last_line_start =
DisplayPoint::new(paragraph_start_row - 1, 0);
paragraph_start = start_of_paragraph(map, previous_paragraph_last_line_start);
let paragraph_start_row = paragraph_start.row();
if paragraph_start_row.0 != 0 {
let previous_paragraph_last_line_start =
Point::new(paragraph_start_row.0 - 1, 0).to_display_point(map);
paragraph_start = start_of_paragraph(map, previous_paragraph_last_line_start);
}
} else {
let mut start_row = paragraph_end_row.0 + 1;
if i > 0 {
start_row += 1;
}
let next_paragraph_start = Point::new(start_row, 0).to_display_point(map);
paragraph_end = end_of_paragraph(map, next_paragraph_start);
}
} else {
let next_paragraph_start = DisplayPoint::new(paragraph_end_row + 1, 0);
paragraph_end = end_of_paragraph(map, next_paragraph_start);
}
}

View file

@ -144,7 +144,7 @@ impl Vim {
editor.set_clip_at_line_ends(false, cx);
let mut selection = editor.selections.newest_display(cx);
let snapshot = editor.snapshot(window, cx);
object.expand_selection(&snapshot, &mut selection, around);
object.expand_selection(&snapshot, &mut selection, around, None);
let start = snapshot
.buffer_snapshot
.anchor_before(selection.start.to_point(&snapshot));

View file

@ -89,6 +89,7 @@ impl Vim {
&mut self,
object: Object,
around: bool,
times: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@ -100,7 +101,7 @@ impl Vim {
s.move_with(|map, selection| {
let anchor = map.display_point_to_anchor(selection.head(), Bias::Right);
original_positions.insert(selection.id, anchor);
object.expand_selection(map, selection, around);
object.expand_selection(map, selection, around, times);
});
});
editor.rewrap_impl(

View file

@ -52,7 +52,7 @@ impl Vim {
for selection in &display_selections {
let range = match &target {
SurroundsType::Object(object, around) => {
object.range(&display_map, selection.clone(), *around)
object.range(&display_map, selection.clone(), *around, None)
}
SurroundsType::Motion(motion) => {
motion
@ -150,7 +150,9 @@ impl Vim {
for selection in &display_selections {
let start = selection.start.to_offset(&display_map, Bias::Left);
if let Some(range) = pair_object.range(&display_map, selection.clone(), true) {
if let Some(range) =
pair_object.range(&display_map, selection.clone(), true, None)
{
// If the current parenthesis object is single-line,
// then we need to filter whether it is the current line or not
if !pair_object.is_multiline() {
@ -247,7 +249,9 @@ impl Vim {
for selection in &selections {
let start = selection.start.to_offset(&display_map, Bias::Left);
if let Some(range) = target.range(&display_map, selection.clone(), true) {
if let Some(range) =
target.range(&display_map, selection.clone(), true, None)
{
if !target.is_multiline() {
let is_same_row = selection.start.row() == range.start.row()
&& selection.end.row() == range.end.row();
@ -348,7 +352,9 @@ impl Vim {
for selection in &selections {
let start = selection.start.to_offset(&display_map, Bias::Left);
if let Some(range) = object.range(&display_map, selection.clone(), true) {
if let Some(range) =
object.range(&display_map, selection.clone(), true, None)
{
// If the current parenthesis object is single-line,
// then we need to filter whether it is the current line or not
if object.is_multiline()

View file

@ -2031,3 +2031,43 @@ async fn test_delete_unmatched_brace(cx: &mut gpui::TestAppContext) {
.await
.assert_eq(" oth(wow)\n oth(wow)\n");
}
#[gpui::test]
async fn test_paragraph_multi_delete(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {
"
Emacs is
ˇa great
operating system
all it lacks
is a
decent text editor
"
})
.await;
cx.simulate_shared_keystrokes("2 d a p").await;
cx.shared_state().await.assert_eq(indoc! {
"
ˇall it lacks
is a
decent text editor
"
});
cx.simulate_shared_keystrokes("d a p").await;
cx.shared_clipboard()
.await
.assert_eq("all it lacks\nis a\n\n");
//reset to initial state
cx.simulate_shared_keystrokes("2 u").await;
cx.simulate_shared_keystrokes("4 d a p").await;
cx.shared_state().await.assert_eq(indoc! {"ˇ"});
}

View file

@ -364,7 +364,13 @@ impl Vim {
})
}
pub fn visual_object(&mut self, object: Object, window: &mut Window, cx: &mut Context<Vim>) {
pub fn visual_object(
&mut self,
object: Object,
count: Option<usize>,
window: &mut Window,
cx: &mut Context<Vim>,
) {
if let Some(Operator::Object { around }) = self.active_operator() {
self.pop_operator(window, cx);
let current_mode = self.mode;
@ -390,7 +396,7 @@ impl Vim {
);
}
if let Some(range) = object.range(map, mut_selection, around) {
if let Some(range) = object.range(map, mut_selection, around, count) {
if !range.is_empty() {
let expand_both_ways = object.always_expands_both_ways()
|| selection.is_empty()
@ -402,7 +408,7 @@ impl Vim {
&& object.always_expands_both_ways()
{
if let Some(range) =
object.range(map, selection.clone(), around)
object.range(map, selection.clone(), around, count)
{
selection.start = range.start;
selection.end = range.end;
@ -1761,4 +1767,26 @@ mod test {
});
cx.shared_clipboard().await.assert_eq("quick\n");
}
#[gpui::test]
async fn test_v2ap(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {
"The
quicˇk
brown
fox"
})
.await;
cx.simulate_shared_keystrokes("v 2 a p").await;
cx.shared_state().await.assert_eq(indoc! {
"«The
quick
brown
»ox"
});
}
}

View file

@ -0,0 +1,18 @@
{"Put":{"state":"Emacs is\nˇa great\n\noperating system\n\nall it lacks\nis a\n\ndecent text editor\n"}}
{"Key":"2"}
{"Key":"d"}
{"Key":"a"}
{"Key":"p"}
{"Get":{"state":"ˇall it lacks\nis a\n\ndecent text editor\n","mode":"Normal"}}
{"Key":"d"}
{"Key":"a"}
{"Key":"p"}
{"Get":{"state":"ˇdecent text editor\n","mode":"Normal"}}
{"ReadRegister":{"name":"\"","value":"all it lacks\nis a\n\n"}}
{"Key":"2"}
{"Key":"u"}
{"Key":"4"}
{"Key":"d"}
{"Key":"a"}
{"Key":"p"}
{"Get":{"state":"ˇ","mode":"Normal"}}

View file

@ -0,0 +1,6 @@
{"Put":{"state":"The\nquicˇk\n\nbrown\nfox"}}
{"Key":"v"}
{"Key":"2"}
{"Key":"a"}
{"Key":"p"}
{"Get":{"state":"«The\nquick\n\nbrown\nfˇ»ox","mode":"VisualLine"}}