diff --git a/book/src/keymap.md b/book/src/keymap.md index e588ac925..2fbc7b3f9 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -277,6 +277,8 @@ Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaire | `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` | | `]o` | Go to next comment (**TS**) | `goto_next_comment` | | `[o` | Go to previous comment (**TS**) | `goto_prev_comment` | +| `]p` | Go to next paragraph | `goto_next_paragraph` | +| `[p` | Go to previous paragraph | `goto_prev_paragraph` | | `[space` | Add newline above | `add_newline_above` | | `]space` | Add newline below | `add_newline_below` | diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index dec6eeb93..970aff88f 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -153,12 +153,12 @@ fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTar pub fn move_prev_para(slice: RopeSlice, range: Range, count: usize, behavior: Movement) -> Range { let mut line = range.cursor_line(slice); let first_char = slice.line_to_char(line) == range.cursor(slice); + let prev_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1))); let curr_line_empty = rope_is_line_ending(slice.line(line)); - let last_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1))); - let line_to_empty = last_line_empty && !curr_line_empty; + let prev_empty_to_line = prev_line_empty && !curr_line_empty; - // iterate current line if first character after paragraph boundary - if line_to_empty && !first_char { + // skip character before paragraph boundary + if prev_empty_to_line && !first_char { line += 1; } let mut lines = slice.lines_at(line); @@ -176,7 +176,7 @@ pub fn move_prev_para(slice: RopeSlice, range: Range, count: usize, behavior: Mo let head = slice.line_to_char(line); let anchor = if behavior == Movement::Move { // exclude first character after paragraph boundary - if line_to_empty && first_char { + if prev_empty_to_line && first_char { range.cursor(slice) } else { range.head @@ -193,13 +193,12 @@ pub fn move_next_para(slice: RopeSlice, range: Range, count: usize, behavior: Mo prev_grapheme_boundary(slice, slice.line_to_char(line + 1)) == range.cursor(slice); let curr_line_empty = rope_is_line_ending(slice.line(line)); let next_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1))); - let empty_to_line = curr_line_empty && !next_line_empty; + let curr_empty_to_line = curr_line_empty && !next_line_empty; - // iterate current line if first character after paragraph boundary - if empty_to_line && last_char { + // skip character after paragraph boundary + if curr_empty_to_line && last_char { line += 1; } - let mut lines = slice.lines_at(line).map(rope_is_line_ending).peekable(); for _ in 0..count { while lines.next_if(|&e| !e).is_some() { @@ -211,7 +210,7 @@ pub fn move_next_para(slice: RopeSlice, range: Range, count: usize, behavior: Mo } let head = slice.line_to_char(line); let anchor = if behavior == Movement::Move { - if empty_to_line && last_char { + if curr_empty_to_line && last_char { range.head } else { range.cursor(slice) @@ -1256,7 +1255,7 @@ mod test { #[test] fn test_behaviour_when_moving_to_prev_paragraph_single() { let tests = [ - ("^@", "@^"), + ("^@", "^@"), ("^s@tart at\nfirst char\n", "@s^tart at\nfirst char\n"), ("start at\nlast char^\n@", "@start at\nlast char\n^"), ("goto\nfirst\n\n^p@aragraph", "@goto\nfirst\n\n^paragraph"), @@ -1315,7 +1314,7 @@ mod test { #[test] fn test_behaviour_when_moving_to_next_paragraph_single() { let tests = [ - ("^@", "@^"), + ("^@", "^@"), ("^s@tart at\nfirst char\n", "^start at\nfirst char\n@"), ("start at\nlast char^\n@", "start at\nlast char^\n@"), ( diff --git a/helix-core/src/test.rs b/helix-core/src/test.rs index 983c9a578..da4f8facf 100644 --- a/helix-core/src/test.rs +++ b/helix-core/src/test.rs @@ -97,8 +97,9 @@ pub fn plain(s: &str, selection: Selection) -> String { .enumerate() .flat_map(|(i, range)| { [ - (range.anchor, '^'), + // sort like this before reversed so anchor < head later (range.head, if i == primary { '@' } else { '|' }), + (range.anchor, '^'), ] }) .collect(); diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs index 5a55a6f1d..fb6b71427 100644 --- a/helix-core/src/textobject.rs +++ b/helix-core/src/textobject.rs @@ -4,7 +4,8 @@ use ropey::RopeSlice; use tree_sitter::{Node, QueryCursor}; use crate::chars::{categorize_char, char_is_whitespace, CharCategory}; -use crate::graphemes::next_grapheme_boundary; +use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary}; +use crate::line_ending::rope_is_line_ending; use crate::movement::Direction; use crate::surround; use crate::syntax::LanguageConfiguration; @@ -111,6 +112,71 @@ pub fn textobject_word( } } +pub fn textobject_para( + slice: RopeSlice, + range: Range, + textobject: TextObject, + count: usize, +) -> Range { + let mut line = range.cursor_line(slice); + let prev_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1))); + let curr_line_empty = rope_is_line_ending(slice.line(line)); + let next_line_empty = rope_is_line_ending(slice.line(line.saturating_sub(1))); + let last_char = + prev_grapheme_boundary(slice, slice.line_to_char(line + 1)) == range.cursor(slice); + let prev_empty_to_line = prev_line_empty && !curr_line_empty; + let curr_empty_to_line = curr_line_empty && !next_line_empty; + + // skip character before paragraph boundary + let mut line_back = line; // line but backwards + if prev_empty_to_line || curr_empty_to_line { + line_back += 1; + } + let mut lines = slice.lines_at(line_back); + // do not include current paragraph on paragraph end (include next) + if !(curr_empty_to_line && last_char) { + lines.reverse(); + let mut lines = lines.map(rope_is_line_ending).peekable(); + while lines.next_if(|&e| e).is_some() { + line_back -= 1; + } + while lines.next_if(|&e| !e).is_some() { + line_back -= 1; + } + } + + // skip character after paragraph boundary + if curr_empty_to_line && last_char { + line += 1; + } + let mut lines = slice.lines_at(line).map(rope_is_line_ending).peekable(); + for _ in 0..count - 1 { + while lines.next_if(|&e| !e).is_some() { + line += 1; + } + while lines.next_if(|&e| e).is_some() { + line += 1; + } + } + while lines.next_if(|&e| !e).is_some() { + line += 1; + } + // handle last whitespaces part separately depending on textobject + match textobject { + TextObject::Around => { + while lines.next_if(|&e| e).is_some() { + line += 1; + } + } + TextObject::Inside => {} + TextObject::Movement => unreachable!(), + } + + let anchor = slice.line_to_char(line_back); + let head = slice.line_to_char(line); + Range::new(anchor, head) +} + pub fn textobject_surround( slice: RopeSlice, range: Range, @@ -288,6 +354,85 @@ mod test { } } + #[test] + fn test_textobject_paragraph_inside_single() { + let tests = [ + ("^@", "^@"), + ("firs^t@\n\nparagraph\n\n", "^first\n@\nparagraph\n\n"), + ("second\n\npa^r@agraph\n\n", "second\n\n^paragraph\n@\n"), + ("^f@irst char\n\n", "^first char\n@\n"), + ("last char\n^\n@", "last char\n\n^@"), + ( + "empty to line\n^\n@paragraph boundary\n\n", + "empty to line\n\n^paragraph boundary\n@\n", + ), + ( + "line to empty\n\n^p@aragraph boundary\n\n", + "line to empty\n\n^paragraph boundary\n@\n", + ), + ]; + + for (before, expected) in tests { + let (s, selection) = crate::test::print(before); + let text = Rope::from(s.as_str()); + let selection = + selection.transform(|r| textobject_para(text.slice(..), r, TextObject::Inside, 1)); + let actual = crate::test::plain(&s, selection); + assert_eq!(actual, expected, "\nbefore: `{before:?}`"); + } + } + + #[test] + fn test_textobject_paragraph_inside_double() { + let tests = [ + ( + "last two\n\n^p@aragraph\n\nwithout whitespaces\n\n", + "last two\n\n^paragraph\n\nwithout whitespaces\n@\n", + ), + ( + "last two\n^\n@paragraph\n\nwithout whitespaces\n\n", + "last two\n\n^paragraph\n\nwithout whitespaces\n@\n", + ), + ]; + + for (before, expected) in tests { + let (s, selection) = crate::test::print(before); + let text = Rope::from(s.as_str()); + let selection = + selection.transform(|r| textobject_para(text.slice(..), r, TextObject::Inside, 2)); + let actual = crate::test::plain(&s, selection); + assert_eq!(actual, expected, "\nbefore: `{before:?}`"); + } + } + + #[test] + fn test_textobject_paragraph_around_single() { + let tests = [ + ("^@", "^@"), + ("firs^t@\n\nparagraph\n\n", "^first\n\n@paragraph\n\n"), + ("second\n\npa^r@agraph\n\n", "second\n\n^paragraph\n\n@"), + ("^f@irst char\n\n", "^first char\n\n@"), + ("last char\n^\n@", "last char\n\n^@"), + ( + "empty to line\n^\n@paragraph boundary\n\n", + "empty to line\n\n^paragraph boundary\n\n@", + ), + ( + "line to empty\n\n^p@aragraph boundary\n\n", + "line to empty\n\n^paragraph boundary\n\n@", + ), + ]; + + for (before, expected) in tests { + let (s, selection) = crate::test::print(before); + let text = Rope::from(s.as_str()); + let selection = + selection.transform(|r| textobject_para(text.slice(..), r, TextObject::Around, 1)); + let actual = crate::test::plain(&s, selection); + assert_eq!(actual, expected, "\nbefore: `{before:?}`"); + } + } + #[test] fn test_textobject_surround() { // (text, [(cursor position, textobject, final range, surround char, count), ...]) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index beb564ad1..ed3b45ae3 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -3991,6 +3991,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { 'f' => textobject_treesitter("function", range), 'a' => textobject_treesitter("parameter", range), 'o' => textobject_treesitter("comment", range), + 'p' => textobject::textobject_para(text, range, objtype, count), 'm' => { let ch = text.char(range.cursor(text)); if !ch.is_ascii_alphanumeric() {