diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 1f43c266..0ae68f91 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod shellwords; mod state; pub mod surround; pub mod syntax; +pub mod test; pub mod textobject; mod transaction; diff --git a/helix-core/src/line_ending.rs b/helix-core/src/line_ending.rs index 06ec2a45..f0cf3b10 100644 --- a/helix-core/src/line_ending.rs +++ b/helix-core/src/line_ending.rs @@ -119,6 +119,11 @@ pub fn str_is_line_ending(s: &str) -> bool { LineEnding::from_str(s).is_some() } +#[inline] +pub fn rope_is_line_ending(r: RopeSlice) -> bool { + r.chunks().all(str_is_line_ending) +} + /// Attempts to detect what line ending the passed document uses. pub fn auto_detect_line_ending(doc: &Rope) -> Option { // Return first matched line ending. Not all possible line endings diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index e559f1ea..21d16931 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -10,6 +10,7 @@ use crate::{ next_grapheme_boundary, nth_next_grapheme_boundary, nth_prev_grapheme_boundary, prev_grapheme_boundary, }, + line_ending::{rope_is_line_ending, str_is_line_ending}, pos_at_coords, syntax::LanguageConfiguration, textobject::TextObject, @@ -149,6 +150,63 @@ 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 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; + + // iterate current line if first character after paragraph boundary + if line_to_empty && !first_char { + line += 1; + } + let mut lines = slice.lines_at(line); + lines.reverse(); + let mut lines = lines.map(rope_is_line_ending).peekable(); + for _ in 0..count { + while lines.next_if(|&e| e).is_some() { + line -= 1; + } + while lines.next_if(|&e| !e).is_some() { + line -= 1; + } + } + + 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 { + range.cursor(slice) + } else { + range.head + } + } else { + range.put_cursor(slice, head, true).anchor + }; + Range::new(anchor, head) +} + +pub fn move_next_para(slice: RopeSlice, range: Range, count: usize, behavior: Movement) -> Range { + let mut line = slice.char_to_line(range.head); + let lines = slice.lines_at(line); + let mut lines = lines.map(|l| l.chunks().all(str_is_line_ending)).peekable(); + for _ in 0..count { + while lines.next_if(|&e| !e).is_some() { + line += 1; + } + while lines.next_if(|&e| e).is_some() { + line += 1; + } + } + let anchor = if behavior == Movement::Move { + range.cursor(slice) + } else { + range.anchor + }; + Range::new(anchor, slice.line_to_char(line)) +} + // ---- util ------------ #[inline] @@ -1179,4 +1237,67 @@ 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"), + ("goto\nfirst\n^\n@paragraph", "@goto\nfirst\n\n^paragraph"), + ("goto\nsecond\n\np^a@ragraph", "goto\nsecond\n\n@pa^ragraph"), + ( + "here\n\nhave\nmultiple\nparagraph\n\n\n\n\n^@", + "here\n\n@have\nmultiple\nparagraph\n\n\n\n\n^", + ), + ]; + + for (actual, expected) in tests { + let (s, selection) = crate::test::print(actual); + let text = Rope::from(s.as_str()); + let selection = + selection.transform(|r| move_prev_para(text.slice(..), r, 1, Movement::Move)); + let actual = crate::test::plain(&s, selection); + assert_eq!(actual, expected); + } + } + + #[ignore] + #[test] + fn test_behaviour_when_moving_to_prev_paragraph_double() {} + + #[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@"), + ( + "a\nb\n\n^g@oto\nthird\n\nparagraph", + "a\nb\n\n^goto\nthird\n\n@paragraph", + ), + ( + "a\nb\n^\n@goto\nthird\n\nparagraph", + "a\nb\n\n^goto\nthird\n\n@paragraph", + ), + ( + "a\nb^\n@\ngoto\nsecond\n\nparagraph", + "a\nb^\n\n@goto\nsecond\n\nparagraph", + ), + ( + "here\n\nhave\n^m@ultiple\nparagraph\n\n\n\n\n", + "here\n\nhave\n^multiple\nparagraph\n\n\n\n\n@", + ), + ]; + + for (actual, expected) in tests { + let (s, selection) = crate::test::print(actual); + let text = Rope::from(s.as_str()); + let selection = + selection.transform(|r| move_next_para(text.slice(..), r, 1, Movement::Move)); + let actual = crate::test::plain(&s, selection); + assert_eq!(actual, expected); + } + } } diff --git a/helix-core/src/test.rs b/helix-core/src/test.rs new file mode 100644 index 00000000..983c9a57 --- /dev/null +++ b/helix-core/src/test.rs @@ -0,0 +1,111 @@ +//! Test helpers. +use crate::{Range, Selection}; +use smallvec::SmallVec; +use std::cmp::Reverse; + +/// Convert annotated test string to test string and selection. +/// +/// `^` for `anchor` and `|` for head (`@` for primary), both must appear +/// or otherwise it will panic. +/// +/// # Examples +/// +/// ``` +/// use helix_core::{Range, Selection, test::print}; +/// use smallvec::smallvec; +/// +/// assert_eq!( +/// print("^a@b|c^"), +/// ("abc".to_owned(), Selection::new(smallvec![Range::new(0, 1), Range::new(3, 2)], 0)) +/// ); +/// ``` +/// +/// # Panics +/// +/// Panics when missing primary or appeared more than once. +/// Panics when missing head or anchor. +/// Panics when head come after head or anchor come after anchor. +pub fn print(s: &str) -> (String, Selection) { + let mut anchor = None; + let mut head = None; + let mut primary = None; + let mut ranges = SmallVec::new(); + let mut i = 0; + let s = s + .chars() + .filter(|c| { + match c { + '^' if anchor != None => panic!("anchor without head {s:?}"), + '^' if head == None => anchor = Some(i), + '^' => ranges.push(Range::new(i, head.take().unwrap())), + '|' if head != None => panic!("head without anchor {s:?}"), + '|' if anchor == None => head = Some(i), + '|' => ranges.push(Range::new(anchor.take().unwrap(), i)), + '@' if primary != None => panic!("head (primary) already appeared {s:?}"), + '@' if head != None => panic!("head (primary) without anchor {s:?}"), + '@' if anchor == None => { + primary = Some(ranges.len()); + head = Some(i); + } + '@' => { + primary = Some(ranges.len()); + ranges.push(Range::new(anchor.take().unwrap(), i)); + } + _ => { + i += 1; + return true; + } + }; + false + }) + .collect(); + if head.is_some() { + panic!("missing anchor (|) {s:?}"); + } + if anchor.is_some() { + panic!("missing head (^) {s:?}"); + } + let primary = match primary { + Some(i) => i, + None => panic!("missing primary (@) {s:?}"), + }; + let selection = Selection::new(ranges, primary); + (s, selection) +} + +/// Convert test string and selection to annotated test string. +/// +/// `^` for `anchor` and `|` for head (`@` for primary). +/// +/// # Examples +/// +/// ``` +/// use helix_core::{Range, Selection, test::plain}; +/// use smallvec::smallvec; +/// +/// assert_eq!( +/// plain("abc", Selection::new(smallvec![Range::new(0, 1), Range::new(3, 2)], 0)), +/// "^a@b|c^".to_owned() +/// ); +/// ``` +pub fn plain(s: &str, selection: Selection) -> String { + let primary = selection.primary_index(); + let mut out = String::with_capacity(s.len() + 2 * selection.len()); + out.push_str(s); + let mut insertion: Vec<_> = selection + .iter() + .enumerate() + .flat_map(|(i, range)| { + [ + (range.anchor, '^'), + (range.head, if i == primary { '@' } else { '|' }), + ] + }) + .collect(); + // insert in reverse order + insertion.sort_unstable_by_key(|k| Reverse(k.0)); + for (i, c) in insertion { + out.insert(i, c); + } + out +} diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 29648039..beb564ad 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -209,6 +209,8 @@ impl MappableCommand { move_next_long_word_start, "Move to beginning of next long word", move_prev_long_word_start, "Move to beginning of previous long word", move_next_long_word_end, "Move to end of next long word", + move_prev_para, "Move to previous paragraph", + move_next_para, "Move to next paragraph", extend_next_word_start, "Extend to beginning of next word", extend_prev_word_start, "Extend to beginning of previous word", extend_next_long_word_start, "Extend to beginning of next long word", @@ -902,6 +904,34 @@ fn move_next_long_word_end(cx: &mut Context) { move_word_impl(cx, movement::move_next_long_word_end) } +fn move_para_impl(cx: &mut Context, move_fn: F) +where + F: Fn(RopeSlice, Range, usize, Movement) -> Range, +{ + let count = cx.count(); + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let behavior = if doc.mode == Mode::Select { + Movement::Extend + } else { + Movement::Move + }; + + let selection = doc + .selection(view.id) + .clone() + .transform(|range| move_fn(text, range, count, behavior)); + doc.set_selection(view.id, selection); +} + +fn move_prev_para(cx: &mut Context) { + move_para_impl(cx, movement::move_prev_para) +} + +fn move_next_para(cx: &mut Context) { + move_para_impl(cx, movement::move_next_para) +} + fn goto_file_start(cx: &mut Context) { if cx.count.is_some() { goto_line(cx); diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index b5685082..a7c1f1de 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -104,6 +104,7 @@ pub fn default() -> HashMap { "c" => goto_prev_class, "a" => goto_prev_parameter, "o" => goto_prev_comment, + "p" => move_prev_para, "space" => add_newline_above, }, "]" => { "Right bracket" @@ -113,6 +114,7 @@ pub fn default() -> HashMap { "c" => goto_next_class, "a" => goto_next_parameter, "o" => goto_next_comment, + "p" => move_next_para, "space" => add_newline_below, },