Skip to content

Commit

Permalink
Add support for moving selections above and below
Browse files Browse the repository at this point in the history
ref: helix-editor/helix#2245
ref: helix-editor/helix#4545

Co-authored-by: sireliah <sajuukk@gmail.com>
  • Loading branch information
omentic and sireliah committed Nov 1, 2023
1 parent 7260812 commit 696fa18
Show file tree
Hide file tree
Showing 3 changed files with 403 additions and 10 deletions.
225 changes: 225 additions & 0 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n> else file start",
Expand Down Expand Up @@ -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<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,
Expand Down
2 changes: 2 additions & 0 deletions helix-term/src/keymap/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,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"
Expand Down
Loading

0 comments on commit 696fa18

Please sign in to comment.