Skip to content

Commit

Permalink
add anchored movement commands
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
pantos9000 committed Apr 23, 2024
1 parent e44163a commit acbfa40
Show file tree
Hide file tree
Showing 2 changed files with 334 additions and 2 deletions.
253 changes: 252 additions & 1 deletion helix-core/src/movement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -188,6 +188,131 @@ 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 {
fn set_stay_on_newline_indicator(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));
}

fn get_stay_on_newline_indicator(range: &Range) -> bool {
match range.old_visual_position {
None => false,
Some((_, u32::MAX)) => true,
Some((_, _)) => false,
}
}

// Get the features of the position we want to move from.
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 = rope_is_line_ending(slice.line(line));
let pos_is_at_end_of_line = pos == end_char_index;

// Get the features of the position we would normally move to.
let new_range = move_fn(slice, range, dir, count, behaviour, text_fmt, annotations);
let new_pos = new_range.cursor(slice);
let new_line = slice.char_to_line(new_pos);
let new_end_char_index = line_end_char_index(&slice, new_line);
let new_pos_is_in_empty_line = rope_is_line_ending(slice.line(new_line));
let new_pos_is_at_end_of_line = new_pos == new_end_char_index;

// Get the position of the next newline character, in direction of movement.
// Note: We can't use the given `move_fn` here. If we move visually 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 newline_range = move_vertically(slice, range, dir, count, behaviour, text_fmt, annotations);
let newline_pos = newline_range.cursor(slice);
let newline_line = slice.char_to_line(newline_pos);
let newline_pos = line_end_char_index(&slice, newline_line);

// 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_stay_on_newline_indicator(&range);
let stay_on_newline =
pos_is_at_end_of_line && (stayed_on_newline_before || !pos_is_in_empty_line);

if stay_on_newline {
// if we are already on a newline character, we want to navigate to the
// newline character on the new line that we move to
let mut updated_range =
new_range.put_cursor(slice, newline_pos, behaviour == Movement::Extend);
set_stay_on_newline_indicator(&mut updated_range);
updated_range
} else {
// we were not on a newline character, so we also want to avoid it in the new line
if new_pos_is_at_end_of_line && !new_pos_is_in_empty_line {
// we would end up on the newline character of a non-empty line, so we have to
// move away from it
let updated_pos = prev_grapheme_boundary(slice, new_end_char_index);
let old_visual_position = new_range.old_visual_position;
let mut updated_range =
new_range.put_cursor(slice, updated_pos, behaviour == Movement::Extend);
updated_range.old_visual_position = old_visual_position;
updated_range
} else {
// the new position is fine if we were not on a newline character or we
// end up in an empty line
new_range
}
}
}

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)
}
Expand Down Expand Up @@ -700,6 +825,132 @@ mod test {
);
}

#[test]
fn test_vertical_anchored_move_from_newline_stays_on_newline() {
let text = Rope::from("aaa\na\n\naaaa\na");
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| {
move_vertically_anchored(
slice,
range,
direction,
1,
Movement::Move,
&TextFormat::default(),
&mut TextAnnotations::default(),
)
};

range = vmove(range, Direction::Forward);
assert_eq!(coords_at_pos(slice, range.head), (1, 1).into());
range = vmove(range, Direction::Forward);
assert_eq!(coords_at_pos(slice, range.head), (2, 0).into());
range = vmove(range, Direction::Forward);
assert_eq!(coords_at_pos(slice, range.head), (3, 4).into());
range = vmove(range, Direction::Backward);
assert_eq!(coords_at_pos(slice, range.head), (2, 0).into());
range = vmove(range, Direction::Backward);
assert_eq!(coords_at_pos(slice, range.head), (1, 1).into());
}

#[test]
fn test_vertical_anchored_move_not_from_newline_avoids_newline() {
let text = Rope::from("aaa\na\n\naaaa\na");
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| {
move_vertically_anchored(
slice,
range,
direction,
1,
Movement::Move,
&TextFormat::default(),
&mut TextAnnotations::default(),
)
};

range = vmove(range, Direction::Forward);
assert_eq!(coords_at_pos(slice, range.head), (1, 0).into());
range = vmove(range, Direction::Forward);
assert_eq!(coords_at_pos(slice, range.head), (2, 0).into());
range = vmove(range, Direction::Forward);
assert_eq!(coords_at_pos(slice, range.head), (3, 2).into());
range = vmove(range, Direction::Backward);
assert_eq!(coords_at_pos(slice, range.head), (2, 0).into());
range = vmove(range, Direction::Backward);
assert_eq!(coords_at_pos(slice, range.head), (1, 0).into());
}

#[test]
fn test_vertical_visual_anchored_move_from_newline_stays_on_newline() {
let mut text_fmt = TextFormat::default();
text_fmt.soft_wrap = true;
text_fmt.viewport_width = 4;
let text = Rope::from("aaaabb\naaaab\n\na");
let slice = text.slice(..);
let pos = pos_at_coords(slice, (0, 6).into(), true);
let mut range = Range::new(pos, pos);

let vvmove = |range, direction| -> Range {
move_vertically_anchored_visual(
slice,
range,
direction,
1,
Movement::Move,
&text_fmt,
&mut TextAnnotations::default(),
)
};

range = vvmove(range, Direction::Forward);
assert_eq!(coords_at_pos(slice, range.head), (1, 5).into());
range = vvmove(range, Direction::Forward);
assert_eq!(coords_at_pos(slice, range.head), (2, 0).into());
range = vvmove(range, Direction::Backward);
assert_eq!(coords_at_pos(slice, range.head), (1, 5).into());
range = vvmove(range, Direction::Backward);
assert_eq!(coords_at_pos(slice, range.head), (0, 6).into());
}

#[test]
fn test_vertical_visual_anchored_move_not_from_newline_avoids_newline() {
let mut text_fmt = TextFormat::default();
text_fmt.soft_wrap = true;
text_fmt.viewport_width = 4;
let text = Rope::from("aaaabb\naa\n\n");
let slice = text.slice(..);
let pos = pos_at_coords(slice, (0, 5).into(), true);
let mut range = Range::new(pos, pos);

let vvmove = |range, direction| -> Range {
move_vertically_anchored_visual(
slice,
range,
direction,
1,
Movement::Move,
&text_fmt,
&mut TextAnnotations::default(),
)
};

range = vvmove(range, Direction::Forward);
assert_eq!(coords_at_pos(slice, range.head), (1, 1).into());
range = vvmove(range, Direction::Forward);
assert_eq!(coords_at_pos(slice, range.head), (2, 0).into());
range = vvmove(range, Direction::Backward);
assert_eq!(coords_at_pos(slice, range.head), (1, 1).into());
range = vvmove(range, Direction::Backward);
assert_eq!(coords_at_pos(slice, range.head), (0, 5).into());
}

#[test]
fn test_horizontal_movement_in_same_line() {
let text = Rope::from("a\na\na");
Expand Down

0 comments on commit acbfa40

Please sign in to comment.