aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsireliah2024-05-01 20:53:30 +0000
committerJJ2024-05-01 23:06:27 +0000
commit7cf650270c78238b5efd7ea4182205114f53540b (patch)
tree4c70d2a0cc949c41c3fd6fc7cb3358978b86a279
parent0a08d7eb5e5049dee8804dffcb110d7d27503810 (diff)
Add support for moving selections above and below
ref: https://github.com/helix-editor/helix/issues/2245 ref: https://github.com/helix-editor/helix/pull/4545 Co-authored-by: JJ <git@toki.la>
-rw-r--r--helix-term/src/commands.rs225
-rw-r--r--helix-term/src/keymap/default.rs2
-rw-r--r--helix-term/tests/test/commands.rs166
3 files changed, 393 insertions, 0 deletions
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 5c59962d..2094aafe 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -340,6 +340,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 selection up",
+ move_selection_below, "Move current line selection down",
goto_type_definition, "Goto type definition",
goto_implementation, "Goto implementation",
goto_file_start, "Goto line number <n> else file start",
@@ -5719,6 +5721,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<Tendril>,
+ 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<ExtendedChange> = 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<ExtendedChange>,
+ current_changes: Vec<ExtendedChange>,
+ direction: &MoveSelection,
+ ) -> Vec<ExtendedChange> {
+ 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<Vec<ExtendedChange>> = 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<Range> {
+ let mut first_change_len = 0;
+ let mut next_start = 0;
+ let mut acc_cursors: Vec<Range> = 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 fbe2388b..5b165613 100644
--- a/helix-term/src/keymap/default.rs
+++ b/helix-term/src/keymap/default.rs
@@ -328,6 +328,8 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"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 1172a798..9084bf63 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(),
+ "<C-k>",
+ 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(),
+ "<C-j>",
+ 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(),
+ "<C-k>",
+ 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",
+ "<C-j><C-j>",
+ "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(),
+ "<C-k>",
+ 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(),
+ "<C-j>",
+ 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(),
+ "<C-j>",
+ 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()?;