From 3f528493d377e44adc3631c45b98fdbb3dfde7d3 Mon Sep 17 00:00:00 2001 From: Pantos Date: Tue, 23 Apr 2024 21:33:13 +0200 Subject: [PATCH] add `anchored` movement commands Commands added: * `move_anchored_line_up` * `move_anchored_line_down` * `move_anchored_visual_line_up` * `move_anchored_visual_line_down` * `extend_anchored_line_up` * `extend_anchored_line_down` * `extend_anchored_visual_line_up` * `extend_anchored_visual_line_down` These new commands move cursors vertically. A cursor will move depending on its position: * If it is on a newline character of a non-empty line, the cursor will stay on newlines (i.e. on a line's last character). * If it is on a non-newline character of a non-empty line, the cursor will try to avoid newline characters. It will move normally, but if it would end up on a newline, instead it will be moved one position left of it (i.e. the line's second to last character). * If it is on the newline character of an empty line (that contains nothing except the newline character), the cursor will continue to move like before: If it stayed on newline before, it will continue to do so. Otherwise it will try to avoid them (except on empty lines). --- helix-core/src/movement.rs | 409 ++++++++++++++++++++++++++++++++++++- helix-term/src/commands.rs | 83 +++++++- 2 files changed, 490 insertions(+), 2 deletions(-) diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 0c2c7048a..07871b678 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -11,7 +11,7 @@ use crate::{ next_grapheme_boundary, nth_next_grapheme_boundary, nth_prev_grapheme_boundary, prev_grapheme_boundary, }, - line_ending::rope_is_line_ending, + line_ending::{line_end_char_index, rope_is_line_ending}, position::char_idx_at_visual_block_offset, syntax::LanguageConfiguration, text_annotations::TextAnnotations, @@ -199,6 +199,190 @@ pub fn move_vertically( new_range } +type MoveFn = + fn(RopeSlice, Range, Direction, usize, Movement, &TextFormat, &mut TextAnnotations) -> Range; + +#[allow(clippy::too_many_arguments)] // just an internal helper function +fn move_anchored( + move_fn: MoveFn, + slice: RopeSlice, + range: Range, + dir: Direction, + count: usize, + behaviour: Movement, + text_fmt: &TextFormat, + annotations: &mut TextAnnotations, +) -> Range { + /// Store an indicator in a given [`Range`] to be able to remember the previous strategy + /// (stay on newlines or avoid them) after encountering a newline. + fn set_indicator_to_stay_on_newline(range: &mut Range) { + let softwrapped_lines: u32 = range.old_visual_position.unzip().0.unwrap_or(0); + range.old_visual_position = Some((softwrapped_lines, u32::MAX)); + } + + /// Retrieve the indicator that might previously have been set with + /// [`set_indicator_to_stay_on_newline()`]. + fn get_indicator_to_stay_on_newline(range: &Range) -> bool { + match range.old_visual_position { + None => false, + Some((_, u32::MAX)) => true, + Some((_, _)) => false, + } + } + + /// Figure out if a certain position/index is in a visual empty line. + /// + /// If the given `pos` is a newline character and it is alone in its line or visual line, + /// this function will return `true`, otherwise `false. + fn is_in_visual_empty_line( + slice: RopeSlice, + text_fmt: &TextFormat, + annotations: &TextAnnotations, + pos: usize, + ) -> bool { + let line = slice.char_to_line(pos); + + // if this line only contains a newline char, it is empty + if rope_is_line_ending(slice.line(line)) { + return true; + } + + // if we got here without soft wrap, this line is not empty + if !text_fmt.soft_wrap { + return false; + } + + // if pos is not the last character, there have to be other chars in the same visual line + if pos != line_end_char_index(&slice, line) { + return false; + } + + // if the previous char (has to exist) is not in the same row, this is an empty visual line + let prev = prev_grapheme_boundary(slice, pos); + let pos_visual_row = visual_offset_from_block(slice, pos, pos, text_fmt, annotations) + .0 + .row; + let prev_visual_row = visual_offset_from_block(slice, prev, prev, text_fmt, annotations) + .0 + .row; + pos_visual_row != prev_visual_row + } + + /// Move to the next newline character, in direction of movement. + fn move_to_next_newline( + slice: RopeSlice, + range: Range, + dir: Direction, + count: usize, + behaviour: Movement, + text_fmt: &TextFormat, + annotations: &mut TextAnnotations, + ) -> Range { + // Move to new position. + // Note: We can't use the given `move_fn` here. If we move visually backwards and soft-wrap + // is enabled, we would end up in the same line, and get the same newline character that we + // are actually coming from. + let new_range = move_vertically(slice, range, dir, count, behaviour, text_fmt, annotations); + let new_pos = new_range.cursor(slice); + let new_line = slice.char_to_line(new_pos); + + // move to newline char in this line + let newline_pos = line_end_char_index(&slice, new_line); + new_range.put_cursor(slice, newline_pos, behaviour == Movement::Extend) + } + + /// Move a range's cursor to the previous grapheme in the same line, if the cursor is on a + /// newline character in a non-empty (visual or non-visual) line. + fn try_to_avoid_newline( + slice: RopeSlice, + range: Range, + behaviour: Movement, + text_fmt: &TextFormat, + annotations: &mut TextAnnotations, + ) -> Range { + let pos = range.cursor(slice); + let line = slice.char_to_line(pos); + let end_char_index = line_end_char_index(&slice, line); + let pos_is_in_empty_line = is_in_visual_empty_line(slice, text_fmt, annotations, pos); + let pos_is_at_end_of_line = pos == end_char_index; + + if !pos_is_at_end_of_line || pos_is_in_empty_line { + return range; + } + + // move away from newline character + let new_pos = prev_grapheme_boundary(slice, end_char_index); + let old_visual_position = range.old_visual_position; + let mut new_range = range.put_cursor(slice, new_pos, behaviour == Movement::Extend); + new_range.old_visual_position = old_visual_position; + new_range + } + + let pos = range.cursor(slice); + let line = slice.char_to_line(pos); + let pos_is_at_end_of_line = pos == line_end_char_index(&slice, line); + let pos_is_in_empty_line = is_in_visual_empty_line(slice, text_fmt, annotations, pos); + + let new_range = move_fn(slice, range, dir, count, behaviour, text_fmt, annotations); + + // Stay on newline characters if the cursor currently is on one. If the current line is empty + // (i.e. it only contains a newline character), only stay on newlines if also done so before. + let stayed_on_newline_before = get_indicator_to_stay_on_newline(&range); + let stay_on_newline = + pos_is_at_end_of_line && (stayed_on_newline_before || !pos_is_in_empty_line); + + if stay_on_newline { + let mut updated_range = + move_to_next_newline(slice, range, dir, count, behaviour, text_fmt, annotations); + set_indicator_to_stay_on_newline(&mut updated_range); + updated_range + } else { + try_to_avoid_newline(slice, new_range, behaviour, text_fmt, annotations) + } +} + +pub fn move_vertically_anchored( + slice: RopeSlice, + range: Range, + dir: Direction, + count: usize, + behaviour: Movement, + text_fmt: &TextFormat, + annotations: &mut TextAnnotations, +) -> Range { + move_anchored( + move_vertically, + slice, + range, + dir, + count, + behaviour, + text_fmt, + annotations, + ) +} + +pub fn move_vertically_anchored_visual( + slice: RopeSlice, + range: Range, + dir: Direction, + count: usize, + behaviour: Movement, + text_fmt: &TextFormat, + annotations: &mut TextAnnotations, +) -> Range { + move_anchored( + move_vertically_visual, + slice, + range, + dir, + count, + behaviour, + text_fmt, + annotations, + ) +} + pub fn move_next_word_start(slice: RopeSlice, range: Range, count: usize) -> Range { word_move(slice, range, count, WordMotionTarget::NextWordStart) } @@ -772,6 +956,229 @@ mod test { ); } + #[test] + fn vertical_anchored_move_from_newline_stays_on_newline() { + let text = Rope::from("aaa\na\n\naaaa\n"); + let slice = text.slice(..); + let pos = pos_at_coords(slice, (0, 3).into(), true); + let mut range = Range::new(pos, pos); + + let vmove = |range, direction, count| { + move_vertically_anchored( + slice, + range, + direction, + count, + Movement::Move, + &TextFormat::default(), + &mut TextAnnotations::default(), + ) + }; + + range = vmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (1, 1).into()); + range = vmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (2, 0).into()); + range = vmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (3, 4).into()); + range = vmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (4, 0).into()); + range = vmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (4, 0).into()); + + range = vmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (3, 4).into()); + range = vmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (2, 0).into()); + range = vmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (1, 1).into()); + range = vmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (0, 3).into()); + range = vmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (0, 3).into()); + + range = vmove(range, Direction::Forward, 3); + assert_eq!(coords_at_pos(slice, range.head), (3, 4).into()); + range = vmove(range, Direction::Backward, 3); + assert_eq!(coords_at_pos(slice, range.head), (0, 3).into()); + } + + #[test] + fn vertical_anchored_move_from_non_newline_avoids_newline() { + let text = Rope::from("aaa\na\n\naaaa\n"); + let slice = text.slice(..); + let pos = pos_at_coords(slice, (0, 2).into(), true); + let mut range = Range::new(pos, pos); + + let vmove = |range, direction, count| { + move_vertically_anchored( + slice, + range, + direction, + count, + Movement::Move, + &TextFormat::default(), + &mut TextAnnotations::default(), + ) + }; + + range = vmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (1, 0).into()); + range = vmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (2, 0).into()); + range = vmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (3, 2).into()); + range = vmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (4, 0).into()); + range = vmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (4, 0).into()); + + range = vmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (3, 2).into()); + range = vmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (2, 0).into()); + range = vmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (1, 0).into()); + range = vmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (0, 2).into()); + range = vmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (0, 2).into()); + + range = vmove(range, Direction::Forward, 3); + assert_eq!(coords_at_pos(slice, range.head), (3, 2).into()); + range = vmove(range, Direction::Backward, 3); + assert_eq!(coords_at_pos(slice, range.head), (0, 2).into()); + } + + #[test] + fn vertical_visual_anchored_move_from_newline_stays_on_newline() { + let text_fmt = TextFormat { + soft_wrap: true, + viewport_width: 6, + ..Default::default() + }; + + let text = Rope::from("a\naaaaaabb\naaaaaab\n\naa\n"); + let slice = text.slice(..); + let pos = pos_at_coords(slice, (0, 1).into(), true); + let mut range = Range::new(pos, pos); + + let vvmove = |range, direction, count| -> Range { + move_vertically_anchored_visual( + slice, + range, + direction, + count, + Movement::Move, + &text_fmt, + &mut TextAnnotations::default(), + ) + }; + + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (1, 8).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (2, 7).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (3, 0).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (4, 2).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (5, 0).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (5, 0).into()); + + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (4, 2).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (3, 0).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (2, 7).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (1, 8).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (0, 1).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (0, 1).into()); + + range = vvmove(range, Direction::Forward, 4); + assert_eq!(coords_at_pos(slice, range.head), (4, 2).into()); + range = vvmove(range, Direction::Backward, 3); + assert_eq!(coords_at_pos(slice, range.head), (1, 8).into()); + } + + #[test] + fn vertical_visual_anchored_move_from_non_newline_avoids_newline() { + let text_fmt = TextFormat { + soft_wrap: true, + viewport_width: 6, + ..Default::default() + }; + + let text = Rope::from("aaaaaabb\naaa\n\naaaaaa\n aaaabb\na"); + let slice = text.slice(..); + let pos = pos_at_coords(slice, (0, 3).into(), true); + let mut range = Range::new(pos, pos); + + let vvmove = |range, direction, count| -> Range { + move_vertically_anchored_visual( + slice, + range, + direction, + count, + Movement::Move, + &text_fmt, + &mut TextAnnotations::default(), + ) + }; + + range = vvmove(range, Direction::Forward, 1); + // wrapped word, stay in same line + assert_eq!(coords_at_pos(slice, range.head), (0, 7).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (1, 2).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (2, 0).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (3, 3).into()); + range = vvmove(range, Direction::Forward, 1); + // wrapped newline, stay in same line + assert_eq!(coords_at_pos(slice, range.head), (3, 6).into()); + range = vvmove(range, Direction::Forward, 1); + // line was visually empty, continue avoiding newlines + assert_eq!(coords_at_pos(slice, range.head), (4, 3).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (4, 6).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (5, 0).into()); + range = vvmove(range, Direction::Forward, 1); + assert_eq!(coords_at_pos(slice, range.head), (5, 0).into()); + + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (4, 6).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (4, 3).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (3, 6).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (3, 3).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (2, 0).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (1, 2).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (0, 7).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (0, 3).into()); + range = vvmove(range, Direction::Backward, 1); + assert_eq!(coords_at_pos(slice, range.head), (0, 3).into()); + + range = vvmove(range, Direction::Forward, 4); + assert_eq!(coords_at_pos(slice, range.head), (3, 3).into()); + range = vvmove(range, Direction::Backward, 3); + assert_eq!(coords_at_pos(slice, range.head), (0, 7).into()); + } + #[test] fn horizontal_movement_in_same_line() { let text = Rope::from("a\na\naaaa"); diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 850247fb8..55663cb92 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -282,16 +282,24 @@ impl MappableCommand { move_same_line_char_right, "Move right within in the same line only", move_line_up, "Move up", move_line_down, "Move down", + move_anchored_line_up, "Move up with newline anchoring behaviour", + move_anchored_line_down, "Move down with newline anchoring behaviour", move_visual_line_up, "Move up", move_visual_line_down, "Move down", + move_anchored_visual_line_up, "Move up with newline anchoring behaviour", + move_anchored_visual_line_down, "Move down with newline anchoring behaviour", extend_char_left, "Extend left", extend_char_right, "Extend right", extend_same_line_char_left, "Extend left within the same line only", extend_same_line_char_right, "Extend right within the same line only", extend_line_up, "Extend up", extend_line_down, "Extend down", + extend_anchored_line_up, "Extend up with newline anchoring behaviour", + extend_anchored_line_down, "Extend down with newline anchoring behaviour", extend_visual_line_up, "Extend up", extend_visual_line_down, "Extend down", + extend_anchored_visual_line_up, "Extend up with newline anchoring behaviour", + extend_anchored_visual_line_down, "Extend down with newline anchoring behaviour", copy_selection_on_next_line, "Copy selection on next line", copy_selection_on_prev_line, "Copy selection on previous line", move_next_word_start, "Move to start of next word", @@ -701,7 +709,8 @@ fn move_impl(cx: &mut Context, move_fn: MoveFn, dir: Direction, behaviour: Movem } use helix_core::movement::{ - move_horizontally, move_horizontally_same_line, move_vertically, move_vertically_visual, + move_horizontally, move_horizontally_same_line, move_vertically, move_vertically_anchored, + move_vertically_anchored_visual, move_vertically_visual, }; fn move_char_left(cx: &mut Context) { @@ -738,6 +747,24 @@ fn move_line_down(cx: &mut Context) { move_impl(cx, move_vertically, Direction::Forward, Movement::Move) } +fn move_anchored_line_up(cx: &mut Context) { + move_impl( + cx, + move_vertically_anchored, + Direction::Backward, + Movement::Move, + ) +} + +fn move_anchored_line_down(cx: &mut Context) { + move_impl( + cx, + move_vertically_anchored, + Direction::Forward, + Movement::Move, + ) +} + fn move_visual_line_up(cx: &mut Context) { move_impl( cx, @@ -756,6 +783,24 @@ fn move_visual_line_down(cx: &mut Context) { ) } +fn move_anchored_visual_line_up(cx: &mut Context) { + move_impl( + cx, + move_vertically_anchored_visual, + Direction::Backward, + Movement::Move, + ) +} + +fn move_anchored_visual_line_down(cx: &mut Context) { + move_impl( + cx, + move_vertically_anchored_visual, + Direction::Forward, + Movement::Move, + ) +} + fn extend_char_left(cx: &mut Context) { move_impl(cx, move_horizontally, Direction::Backward, Movement::Extend) } @@ -790,6 +835,24 @@ fn extend_line_down(cx: &mut Context) { move_impl(cx, move_vertically, Direction::Forward, Movement::Extend) } +fn extend_anchored_line_up(cx: &mut Context) { + move_impl( + cx, + move_vertically_anchored, + Direction::Backward, + Movement::Extend, + ) +} + +fn extend_anchored_line_down(cx: &mut Context) { + move_impl( + cx, + move_vertically_anchored, + Direction::Forward, + Movement::Extend, + ) +} + fn extend_visual_line_up(cx: &mut Context) { move_impl( cx, @@ -808,6 +871,24 @@ fn extend_visual_line_down(cx: &mut Context) { ) } +fn extend_anchored_visual_line_up(cx: &mut Context) { + move_impl( + cx, + move_vertically_anchored_visual, + Direction::Backward, + Movement::Extend, + ) +} + +fn extend_anchored_visual_line_down(cx: &mut Context) { + move_impl( + cx, + move_vertically_anchored_visual, + Direction::Forward, + Movement::Extend, + ) +} + fn goto_line_end_impl(view: &mut View, doc: &mut Document, movement: Movement) { let text = doc.text().slice(..);