diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 7e15965c1..cac86adbe 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -327,6 +327,8 @@ impl MappableCommand { goto_declaration, "Goto declaration", add_newline_above, "Add newline above", add_newline_below, "Add newline below", + move_selection_above, "Move current line or selection up", + move_selection_below, "Move current line or selection down", goto_type_definition, "Goto type definition", goto_implementation, "Goto implementation", goto_file_start, "Goto line number else file start", @@ -5651,6 +5653,229 @@ fn add_newline_impl(cx: &mut Context, open: Open) { doc.apply(&transaction, view.id); } +#[derive(Debug, PartialEq, Eq)] +pub enum MoveSelection { + Below, + Above, +} + +#[derive(Clone)] +struct ExtendedChange { + line_start: usize, + line_end: usize, + line_text: Option, + line_selection: Option<(usize, usize)>, +} + +/// Move line or block of text in specified direction. +/// The function respects single line, single selection, multiple lines using +/// several cursors and multiple selections. +fn move_selection(cx: &mut Context, direction: MoveSelection) { + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + let text = doc.text(); + let slice = text.slice(..); + let mut last_step_changes: Vec = vec![]; + let mut at_doc_edge = false; + let all_changes = selection.into_iter().map(|range| { + let (start, end) = range.line_range(slice); + let line_start = text.line_to_char(start); + let line_end = line_end_char_index(&slice, end); + let line_text = text.slice(line_start..line_end).to_string(); + + let next_line = match direction { + MoveSelection::Above => start.saturating_sub(1), + MoveSelection::Below => end + 1, + }; + + let rel_pos_anchor = range.anchor - line_start; + let rel_pos_head = range.head - line_start; + let cursor_rel_pos = (rel_pos_anchor, rel_pos_head); + + if next_line == start || next_line >= text.len_lines() || at_doc_edge { + at_doc_edge = true; + let changes = vec![ExtendedChange { + line_start, + line_end, + line_text: Some(line_text.into()), + line_selection: Some(cursor_rel_pos), + }]; + last_step_changes = changes.clone(); + changes + } else { + let next_line_start = text.line_to_char(next_line); + let next_line_end = line_end_char_index(&slice, next_line); + let next_line_text = text.slice(next_line_start..next_line_end).to_string(); + + let changes = match direction { + MoveSelection::Above => vec![ + ExtendedChange { + line_start: next_line_start, + line_end: next_line_end, + line_text: Some(line_text.into()), + line_selection: Some(cursor_rel_pos), + }, + ExtendedChange { + line_start, + line_end, + line_text: Some(next_line_text.into()), + line_selection: None, + }, + ], + MoveSelection::Below => vec![ + ExtendedChange { + line_start, + line_end, + line_text: Some(next_line_text.into()), + line_selection: None, + }, + ExtendedChange { + line_start: next_line_start, + line_end: next_line_end, + line_text: Some(line_text.into()), + line_selection: Some(cursor_rel_pos), + }, + ], + }; + + let changes = if last_step_changes.len() > 1 { + evaluate_changes(last_step_changes.clone(), changes, &direction) + } else { + changes + }; + last_step_changes = changes.clone(); + changes + } + }); + + /// Merge changes from subsequent cursors + fn evaluate_changes( + mut last_changes: Vec, + current_changes: Vec, + direction: &MoveSelection, + ) -> Vec { + let mut current_it = current_changes.into_iter(); + + if let (Some(mut last), Some(mut current_first), Some(current_last)) = + (last_changes.pop(), current_it.next(), current_it.next()) + { + if last.line_start == current_first.line_start { + match direction { + MoveSelection::Above => { + last.line_start = current_last.line_start; + last.line_end = current_last.line_end; + if let Some(first) = last_changes.pop() { + last_changes.push(first) + } + last_changes.extend(vec![current_first, last]); + last_changes + } + MoveSelection::Below => { + current_first.line_start = last_changes[0].line_start; + current_first.line_end = last_changes[0].line_end; + last_changes[0] = current_first; + last_changes.extend(vec![last, current_last]); + last_changes + } + } + } else { + if let Some(first) = last_changes.pop() { + last_changes.push(first) + } + last_changes.extend(vec![last, current_first, current_last]); + last_changes + } + } else { + last_changes + } + } + + let mut flattened: Vec> = all_changes.into_iter().collect(); + let last_changes = flattened.pop().unwrap_or_default(); + + let acc_cursors = get_adjusted_selection(doc, &last_changes, direction, at_doc_edge); + + let changes = last_changes + .into_iter() + .map(|change| (change.line_start, change.line_end, change.line_text)); + + let new_sel = Selection::new(acc_cursors.into(), 0); + let transaction = Transaction::change(doc.text(), changes); + + doc.apply(&transaction, view.id); + doc.set_selection(view.id, new_sel); +} + +/// Returns selection range that is valid for the updated document +/// This logic is necessary because it's not possible to apply changes +/// to the document first and then set selection. +fn get_adjusted_selection( + doc: &Document, + last_changes: &[ExtendedChange], + direction: MoveSelection, + at_doc_edge: bool, +) -> Vec { + let mut first_change_len = 0; + let mut next_start = 0; + let mut acc_cursors: Vec = vec![]; + + for change in last_changes.iter() { + let change_len = change.line_text.as_ref().map_or(0, |x| x.chars().count()); + + if let Some((rel_anchor, rel_head)) = change.line_selection { + let (anchor, head) = if at_doc_edge { + let anchor = change.line_start + rel_anchor; + let head = change.line_start + rel_head; + (anchor, head) + } else { + match direction { + MoveSelection::Above => { + if next_start == 0 { + next_start = change.line_start; + } + let anchor = next_start + rel_anchor; + let head = next_start + rel_head; + + // If there is next cursor below, selection position should be adjusted + // according to the length of the current line. + next_start += change_len + doc.line_ending.len_chars(); + (anchor, head) + } + MoveSelection::Below => { + let anchor = change.line_start + first_change_len + rel_anchor - change_len; + let head = change.line_start + first_change_len + rel_head - change_len; + (anchor, head) + } + } + }; + + let cursor = Range::new(anchor, head); + if let Some(last) = acc_cursors.pop() { + if cursor.overlaps(&last) { + acc_cursors.push(last); + } else { + acc_cursors.push(last); + acc_cursors.push(cursor); + }; + } else { + acc_cursors.push(cursor); + }; + } else { + first_change_len = change.line_text.as_ref().map_or(0, |x| x.chars().count()); + next_start = 0; + }; + } + acc_cursors +} + +fn move_selection_below(cx: &mut Context) { + move_selection(cx, MoveSelection::Below) +} + +fn move_selection_above(cx: &mut Context) { + move_selection(cx, MoveSelection::Above) +} + enum IncrementDirection { Increase, Decrease, diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 58e8fdad8..9b93c5735 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -324,6 +324,8 @@ pub fn default() -> HashMap { "C-a" => increment, "C-x" => decrement, + "C-k" => move_selection_above, + "C-j" => move_selection_below, }); let mut select = normal.clone(); select.merge_nodes(keymap!({ "Select mode" diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index b3e135510..a6c1c9563 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -97,6 +97,172 @@ async fn test_selection_duplication() -> anyhow::Result<()> { Ok(()) } +// Line selection movement tests + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_selection_single_selection_up() -> anyhow::Result<()> { + test(( + platform_line(indoc! {" + aaaaaa + bbbbbb + cc#[|c]#ccc + dddddd + "}) + .as_str(), + "", + platform_line(indoc! {" + aaaaaa + cc#[|c]#ccc + bbbbbb + dddddd + "}) + .as_str(), + )) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_selection_single_selection_down() -> anyhow::Result<()> { + test(( + platform_line(indoc! {" + aa#[|a]#aaa + bbbbbb + cccccc + dddddd + "}) + .as_str(), + "", + platform_line(indoc! {" + bbbbbb + aa#[|a]#aaa + cccccc + dddddd + "}) + .as_str(), + )) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_selection_single_selection_top_up() -> anyhow::Result<()> { + // if already on top of the file and going up, nothing should change + test(( + platform_line(indoc! {" + aa#[|a]#aaa + bbbbbb + cccccc + dddddd"}) + .as_str(), + "", + platform_line(indoc! {" + aa#[|a]#aaa + bbbbbb + cccccc + dddddd"}) + .as_str(), + )) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_selection_single_selection_bottom_down() -> anyhow::Result<()> { + // If going down on the bottom line, nothing should change + // Note that platform_line is not used here, because it inserts trailing + // linebreak, making it impossible to test + test(( + "aaaaaa\nbbbbbb\ncccccc\ndd#[|d]#ddd", + "", + "aaaaaa\nbbbbbb\ncccccc\ndd#[|d]#ddd", + )) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_selection_block_up() -> anyhow::Result<()> { + test(( + platform_line(indoc! {" + aaaaaa + bb#[bbbb + ccc|]#ccc + dddddd + eeeeee + "}) + .as_str(), + "", + platform_line(indoc! {" + bb#[bbbb + ccc|]#ccc + aaaaaa + dddddd + eeeeee + "}) + .as_str(), + )) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_selection_block_down() -> anyhow::Result<()> { + test(( + platform_line(indoc! {" + #[|aaaaaa + bbbbbb + ccc]#ccc + dddddd + eeeeee + "}) + .as_str(), + "", + platform_line(indoc! {" + dddddd + #[|aaaaaa + bbbbbb + ccc]#ccc + eeeeee + "}) + .as_str(), + )) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_two_cursors_down() -> anyhow::Result<()> { + test(( + platform_line(indoc! {" + aaaaaa + bb#[|b]#bbb + cccccc + d#(dd|)#ddd + eeeeee + "}) + .as_str(), + "", + platform_line(indoc! {" + aaaaaa + cccccc + bb#[|b]#bbb + eeeeee + d#(dd|)#ddd + "}) + .as_str(), + )) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn test_goto_file_impl() -> anyhow::Result<()> { let file = tempfile::NamedTempFile::new()?; @@ -188,11 +354,11 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> { "|echo foo", platform_line(indoc! {"\ #[|foo\n]# - + #(|foo\n)# - + #(|foo\n)# - + "}), )) .await?; @@ -226,11 +392,11 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> { "echo foo", platform_line(indoc! {"\ lorem#[|foo\n]# - + ipsum#(|foo\n)# - + dolor#(|foo\n)# - + "}), )) .await?; @@ -274,14 +440,14 @@ async fn test_extend_line() -> anyhow::Result<()> { #[l|]#orem ipsum dolor - + "}), "x2x", platform_line(indoc! {"\ #[lorem ipsum dolor\n|]# - + "}), )) .await?; @@ -291,13 +457,13 @@ async fn test_extend_line() -> anyhow::Result<()> { platform_line(indoc! {"\ #[l|]#orem ipsum - + "}), "2x", platform_line(indoc! {"\ #[lorem ipsum\n|]# - + "}), )) .await?;