From 7cf650270c78238b5efd7ea4182205114f53540b Mon Sep 17 00:00:00 2001 From: sireliah Date: Wed, 1 May 2024 13:53:30 -0700 Subject: 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 --- helix-term/src/commands.rs | 225 +++++++++++++++++++++++++++++++++++++++ helix-term/src/keymap/default.rs | 2 + 2 files changed, 227 insertions(+) (limited to 'helix-term/src') 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 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, + 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 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 { "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" -- cgit v1.2.3-70-g09d2