From a46db95dc5cdae4deca31b8e021805768d1a8390 Mon Sep 17 00:00:00 2001 From: JJ Date: Sat, 15 Jul 2023 17:55:37 -0700 Subject: [PATCH 1/2] Add support for moving lines and selections above and below ref: https://github.com/helix-editor/helix/pull/4545 --- helix-term/src/commands.rs | 213 ++++++++++++++++++++++++++++++- helix-term/src/keymap/default.rs | 2 + 2 files changed, 214 insertions(+), 1 deletion(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 58c17296..dc8b5b5c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -28,7 +28,7 @@ textobject, tree_sitter::Node, unicode::width::UnicodeWidthChar, - visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeGraphemes, + visual_offset_from_block, Change, Deletion, LineEnding, Position, Range, Rope, RopeGraphemes, RopeReader, RopeSlice, Selection, SmallVec, Tendril, Transaction, }; use helix_view::{ @@ -326,6 +326,8 @@ pub fn doc(&self) -> &str { 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", @@ -5510,6 +5512,215 @@ fn add_newline_impl(cx: &mut Context, open: Open) { doc.apply(&transaction, view.id); } +#[derive(Debug, PartialEq, Eq)] +pub enum MoveSelection { + Below, + Above, +} + +type ExtendedChange = (usize, usize, Option, 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.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; + + if next_line == start || next_line >= text.len_lines() || at_doc_edge { + at_doc_edge = true; + let cursor_rel_pos = (rel_pos_anchor, rel_pos_head); + let changes = vec![( + line_start, + line_end, + Some(line.into()), + 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 cursor_rel_pos = (rel_pos_anchor, rel_pos_head); + let changes = match direction { + MoveSelection::Above => vec![ + ( + next_line_start, + next_line_end, + Some(line.into()), + Some(cursor_rel_pos), + ), + (line_start, line_end, Some(next_line_text.into()), None), + ], + MoveSelection::Below => vec![ + (line_start, line_end, Some(next_line_text.into()), None), + ( + next_line_start, + next_line_end, + Some(line.into()), + Some(cursor_rel_pos), + ), + ], + }; + + let changes = if last_step_changes.len() > 1 { + evaluate_changes(last_step_changes.clone(), changes.clone(), &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.0 == current_first.0 { + match direction { + MoveSelection::Above => { + last.0 = current_last.0; + last.1 = current_last.1; + if let Some(first) = last_changes.pop() { + last_changes.push(first) + } + last_changes.extend(vec![current_first, last.to_owned()]); + last_changes + } + MoveSelection::Below => { + current_first.0 = last_changes[0].0; + current_first.1 = last_changes[0].1; + last_changes[0] = current_first; + last_changes.extend(vec![last.to_owned(), current_last]); + last_changes + } + } + } else { + if let Some(first) = last_changes.pop() { + last_changes.push(first) + } + last_changes.extend(vec![last.to_owned(), 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(vec![]); + + let acc_cursors = get_adjusted_selection(&doc, &last_changes, direction, at_doc_edge); + + let changes: Vec = last_changes + .into_iter() + .map(|change| (change.0, change.1, change.2.to_owned())) + .collect(); + + let new_sel = Selection::new(acc_cursors.into(), 0); + let transaction = Transaction::change(doc.text(), changes.into_iter()); + + 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: &Vec, + 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.2.as_ref().map_or(0, |x| x.len()); + + if let Some((rel_anchor, rel_head)) = change.3 { + let (anchor, head) = if at_doc_edge { + let anchor = change.0 + rel_anchor; + let head = change.0 + rel_head; + (anchor, head) + } else { + match direction { + MoveSelection::Above => { + if next_start == 0 { + next_start = change.0; + } + 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.0 + first_change_len + rel_anchor - change_len; + let head = change.0 + 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.2.as_ref().map_or(0, |x| x.len()); + 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 c84c616c..f384c868 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -321,6 +321,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" -- 2.41.0 From 3888d20b74bf8a723af276973485493231ab0f85 Mon Sep 17 00:00:00 2001 From: JJ Date: Sat, 15 Jul 2023 17:55:58 -0700 Subject: [PATCH 2/2] Unit test moving lines and selections --- helix-term/tests/test/commands.rs | 186 ++++++++++++++++++++++++++++-- 1 file changed, 176 insertions(+), 10 deletions(-) diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index b13c37bc..4ca7e9f2 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -96,6 +96,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()?; @@ -187,11 +353,11 @@ async fn test_multi_selection_shell_commands() -> anyhow::Result<()> { "|echo foo", platform_line(indoc! {"\ #[|foo\n]# - + #(|foo\n)# - + #(|foo\n)# - + "}), )) .await?; @@ -225,11 +391,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?; @@ -273,14 +439,14 @@ async fn test_extend_line() -> anyhow::Result<()> { #[l|]#orem ipsum dolor - + "}), "x2x", platform_line(indoc! {"\ #[lorem ipsum dolor\n|]# - + "}), )) .await?; @@ -290,13 +456,13 @@ async fn test_extend_line() -> anyhow::Result<()> { platform_line(indoc! {"\ #[l|]#orem ipsum - + "}), "2x", platform_line(indoc! {"\ #[lorem ipsum\n|]# - + "}), )) .await?; -- 2.41.0