Skip to content

Commit 5095e8b

Browse files
sireliahomentic
andcommitted
Add support for moving selections above and below
ref: helix-editor/helix#2245 ref: helix-editor/helix#4545 Co-authored-by: JJ <[email protected]>
1 parent 97b1993 commit 5095e8b

File tree

4 files changed

+349
-0
lines changed

4 files changed

+349
-0
lines changed

book/src/generated/static-cmd.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@
124124
| `goto_declaration` | Goto declaration | normal: `` gD ``, select: `` gD `` |
125125
| `add_newline_above` | Add newline above | normal: `` [<space> ``, select: `` [<space> `` |
126126
| `add_newline_below` | Add newline below | normal: `` ]<space> ``, select: `` ]<space> `` |
127+
| `move_selection_above` | Move current line selection up | normal: `` <C-k> ``, select: `` <C-k> `` |
128+
| `move_selection_below` | Move current line selection down | normal: `` <C-j> ``, select: `` <C-j> `` |
127129
| `goto_type_definition` | Goto type definition | normal: `` gy ``, select: `` gy `` |
128130
| `goto_implementation` | Goto implementation | normal: `` gi ``, select: `` gi `` |
129131
| `goto_file_start` | Goto line number <n> else file start | normal: `` gg ``, select: `` gg `` |

helix-term/src/commands.rs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@ impl MappableCommand {
417417
goto_declaration, "Goto declaration",
418418
add_newline_above, "Add newline above",
419419
add_newline_below, "Add newline below",
420+
move_selection_above, "Move current line selection up",
421+
move_selection_below, "Move current line selection down",
420422
goto_type_definition, "Goto type definition",
421423
goto_implementation, "Goto implementation",
422424
goto_file_start, "Goto line number <n> else file start",
@@ -6253,6 +6255,209 @@ fn add_newline_impl(cx: &mut Context, open: Open) {
62536255
doc.apply(&transaction, view.id);
62546256
}
62556257

6258+
#[derive(Debug, PartialEq, Eq)]
6259+
pub enum MoveSelection {
6260+
Below,
6261+
Above,
6262+
}
6263+
6264+
#[derive(Clone)]
6265+
struct ExtendedChange {
6266+
line_start: usize,
6267+
line_end: usize,
6268+
line_text: Option<Tendril>,
6269+
line_selection: Option<(usize, usize)>,
6270+
}
6271+
/// Move line or block of text in specified direction.
6272+
/// The function respects single line, single selection, multiple lines using
6273+
/// several cursors and multiple selections.
6274+
fn move_selection(cx: &mut Context, direction: MoveSelection) {
6275+
let (view, doc) = current!(cx.editor);
6276+
let selection = doc.selection(view.id);
6277+
let text = doc.text();
6278+
let slice = text.slice(..);
6279+
let mut last_step_changes: Vec<ExtendedChange> = vec![];
6280+
let mut at_doc_edge = false;
6281+
let all_changes = selection.into_iter().map(|range| {
6282+
let (start, end) = range.line_range(slice);
6283+
let line_start = text.line_to_char(start);
6284+
let line_end = line_end_char_index(&slice, end);
6285+
let line_text = text.slice(line_start..line_end).to_string();
6286+
let next_line = match direction {
6287+
MoveSelection::Above => start.saturating_sub(1),
6288+
MoveSelection::Below => end + 1,
6289+
};
6290+
let rel_pos_anchor = range.anchor - line_start;
6291+
let rel_pos_head = range.head - line_start;
6292+
let cursor_rel_pos = (rel_pos_anchor, rel_pos_head);
6293+
if next_line == start || next_line >= text.len_lines() || at_doc_edge {
6294+
at_doc_edge = true;
6295+
let changes = vec![ExtendedChange {
6296+
line_start,
6297+
line_end,
6298+
line_text: Some(line_text.into()),
6299+
line_selection: Some(cursor_rel_pos),
6300+
}];
6301+
last_step_changes = changes.clone();
6302+
changes
6303+
} else {
6304+
let next_line_start = text.line_to_char(next_line);
6305+
let next_line_end = line_end_char_index(&slice, next_line);
6306+
let next_line_text = text.slice(next_line_start..next_line_end).to_string();
6307+
let changes = match direction {
6308+
MoveSelection::Above => vec![
6309+
ExtendedChange {
6310+
line_start: next_line_start,
6311+
line_end: next_line_end,
6312+
line_text: Some(line_text.into()),
6313+
line_selection: Some(cursor_rel_pos),
6314+
},
6315+
ExtendedChange {
6316+
line_start,
6317+
line_end,
6318+
line_text: Some(next_line_text.into()),
6319+
line_selection: None,
6320+
},
6321+
],
6322+
MoveSelection::Below => vec![
6323+
ExtendedChange {
6324+
line_start,
6325+
line_end,
6326+
line_text: Some(next_line_text.into()),
6327+
line_selection: None,
6328+
},
6329+
ExtendedChange {
6330+
line_start: next_line_start,
6331+
line_end: next_line_end,
6332+
line_text: Some(line_text.into()),
6333+
line_selection: Some(cursor_rel_pos),
6334+
},
6335+
],
6336+
};
6337+
let changes = if last_step_changes.len() > 1 {
6338+
evaluate_changes(last_step_changes.clone(), changes, &direction)
6339+
} else {
6340+
changes
6341+
};
6342+
last_step_changes = changes.clone();
6343+
changes
6344+
}
6345+
});
6346+
/// Merge changes from subsequent cursors
6347+
fn evaluate_changes(
6348+
mut last_changes: Vec<ExtendedChange>,
6349+
current_changes: Vec<ExtendedChange>,
6350+
direction: &MoveSelection,
6351+
) -> Vec<ExtendedChange> {
6352+
let mut current_it = current_changes.into_iter();
6353+
if let (Some(mut last), Some(mut current_first), Some(current_last)) =
6354+
(last_changes.pop(), current_it.next(), current_it.next())
6355+
{
6356+
if last.line_start == current_first.line_start {
6357+
match direction {
6358+
MoveSelection::Above => {
6359+
last.line_start = current_last.line_start;
6360+
last.line_end = current_last.line_end;
6361+
if let Some(first) = last_changes.pop() {
6362+
last_changes.push(first)
6363+
}
6364+
last_changes.extend(vec![current_first, last]);
6365+
last_changes
6366+
}
6367+
MoveSelection::Below => {
6368+
current_first.line_start = last_changes[0].line_start;
6369+
current_first.line_end = last_changes[0].line_end;
6370+
last_changes[0] = current_first;
6371+
last_changes.extend(vec![last, current_last]);
6372+
last_changes
6373+
}
6374+
}
6375+
} else {
6376+
if let Some(first) = last_changes.pop() {
6377+
last_changes.push(first)
6378+
}
6379+
last_changes.extend(vec![last, current_first, current_last]);
6380+
last_changes
6381+
}
6382+
} else {
6383+
last_changes
6384+
}
6385+
}
6386+
let mut flattened: Vec<Vec<ExtendedChange>> = all_changes.into_iter().collect();
6387+
let last_changes = flattened.pop().unwrap_or_default();
6388+
let acc_cursors = get_adjusted_selection(doc, &last_changes, direction, at_doc_edge);
6389+
let changes = last_changes
6390+
.into_iter()
6391+
.map(|change| (change.line_start, change.line_end, change.line_text));
6392+
let new_sel = Selection::new(acc_cursors.into(), 0);
6393+
let transaction = Transaction::change(doc.text(), changes);
6394+
doc.apply(&transaction, view.id);
6395+
doc.set_selection(view.id, new_sel);
6396+
}
6397+
/// Returns selection range that is valid for the updated document
6398+
/// This logic is necessary because it's not possible to apply changes
6399+
/// to the document first and then set selection.
6400+
fn get_adjusted_selection(
6401+
doc: &Document,
6402+
last_changes: &[ExtendedChange],
6403+
direction: MoveSelection,
6404+
at_doc_edge: bool,
6405+
) -> Vec<Range> {
6406+
let mut first_change_len = 0;
6407+
let mut next_start = 0;
6408+
let mut acc_cursors: Vec<Range> = vec![];
6409+
for change in last_changes.iter() {
6410+
let change_len = change.line_text.as_ref().map_or(0, |x| x.chars().count());
6411+
if let Some((rel_anchor, rel_head)) = change.line_selection {
6412+
let (anchor, head) = if at_doc_edge {
6413+
let anchor = change.line_start + rel_anchor;
6414+
let head = change.line_start + rel_head;
6415+
(anchor, head)
6416+
} else {
6417+
match direction {
6418+
MoveSelection::Above => {
6419+
if next_start == 0 {
6420+
next_start = change.line_start;
6421+
}
6422+
let anchor = next_start + rel_anchor;
6423+
let head = next_start + rel_head;
6424+
// If there is next cursor below, selection position should be adjusted
6425+
// according to the length of the current line.
6426+
next_start += change_len + doc.line_ending.len_chars();
6427+
(anchor, head)
6428+
}
6429+
MoveSelection::Below => {
6430+
let anchor = change.line_start + first_change_len + rel_anchor - change_len;
6431+
let head = change.line_start + first_change_len + rel_head - change_len;
6432+
(anchor, head)
6433+
}
6434+
}
6435+
};
6436+
let cursor = Range::new(anchor, head);
6437+
if let Some(last) = acc_cursors.pop() {
6438+
if cursor.overlaps(&last) {
6439+
acc_cursors.push(last);
6440+
} else {
6441+
acc_cursors.push(last);
6442+
acc_cursors.push(cursor);
6443+
};
6444+
} else {
6445+
acc_cursors.push(cursor);
6446+
};
6447+
} else {
6448+
first_change_len = change.line_text.as_ref().map_or(0, |x| x.chars().count());
6449+
next_start = 0;
6450+
};
6451+
}
6452+
acc_cursors
6453+
}
6454+
fn move_selection_below(cx: &mut Context) {
6455+
move_selection(cx, MoveSelection::Below)
6456+
}
6457+
fn move_selection_above(cx: &mut Context) {
6458+
move_selection(cx, MoveSelection::Above)
6459+
}
6460+
62566461
enum IncrementDirection {
62576462
Increase,
62586463
Decrease,

helix-term/src/keymap/default.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
186186
"C-f" | "pagedown" => page_down,
187187
"C-u" => page_cursor_half_up,
188188
"C-d" => page_cursor_half_down,
189+
"C-k" => move_selection_above,
190+
"C-j" => move_selection_below,
189191

190192
"C-w" => { "Window"
191193
"C-w" | "w" => rotate_view,

helix-term/tests/test/commands.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,3 +793,143 @@ fn foo() {
793793

794794
Ok(())
795795
}
796+
797+
// Line selection movement tests
798+
#[tokio::test(flavor = "multi_thread")]
799+
async fn test_move_selection_single_selection_up() -> anyhow::Result<()> {
800+
test((
801+
indoc! {"
802+
aaaaaa
803+
bbbbbb
804+
cc#[|c]#ccc
805+
dddddd
806+
"},
807+
"<C-k>",
808+
indoc! {"
809+
aaaaaa
810+
cc#[|c]#ccc
811+
bbbbbb
812+
dddddd
813+
"},
814+
))
815+
.await?;
816+
Ok(())
817+
}
818+
#[tokio::test(flavor = "multi_thread")]
819+
async fn test_move_selection_single_selection_down() -> anyhow::Result<()> {
820+
test((
821+
indoc! {"
822+
aa#[|a]#aaa
823+
bbbbbb
824+
cccccc
825+
dddddd
826+
"},
827+
"<C-j>",
828+
indoc! {"
829+
bbbbbb
830+
aa#[|a]#aaa
831+
cccccc
832+
dddddd
833+
"},
834+
))
835+
.await?;
836+
Ok(())
837+
}
838+
#[tokio::test(flavor = "multi_thread")]
839+
async fn test_move_selection_single_selection_top_up() -> anyhow::Result<()> {
840+
// if already on top of the file and going up, nothing should change
841+
test((
842+
indoc! {"
843+
aa#[|a]#aaa
844+
bbbbbb
845+
cccccc
846+
dddddd"},
847+
"<C-k>",
848+
indoc! {"
849+
aa#[|a]#aaa
850+
bbbbbb
851+
cccccc
852+
dddddd"},
853+
))
854+
.await?;
855+
Ok(())
856+
}
857+
// #[tokio::test(flavor = "multi_thread")]
858+
// async fn test_move_selection_single_selection_bottom_down() -> anyhow::Result<()> {
859+
// // If going down on the bottom line, nothing should change
860+
// // Note that this test is broken, due to the testing framework
861+
// // implicitly entering a trailing newline. How to fix?
862+
// test((
863+
// "aaaaaa\nbbbbbb\ncccccc\ndd#[|d]#ddd",
864+
// "<C-j><C-j>",
865+
// "aaaaaa\nbbbbbb\ncccccc\ndd#[|d]#ddd",
866+
// ))
867+
// .await?;
868+
// Ok(())
869+
// }
870+
#[tokio::test(flavor = "multi_thread")]
871+
async fn test_move_selection_block_up() -> anyhow::Result<()> {
872+
test((
873+
indoc! {"
874+
aaaaaa
875+
bb#[bbbb
876+
ccc|]#ccc
877+
dddddd
878+
eeeeee
879+
"},
880+
"<C-k>",
881+
indoc! {"
882+
bb#[bbbb
883+
ccc|]#ccc
884+
aaaaaa
885+
dddddd
886+
eeeeee
887+
"},
888+
))
889+
.await?;
890+
Ok(())
891+
}
892+
#[tokio::test(flavor = "multi_thread")]
893+
async fn test_move_selection_block_down() -> anyhow::Result<()> {
894+
test((
895+
indoc! {"
896+
#[|aaaaaa
897+
bbbbbb
898+
ccc]#ccc
899+
dddddd
900+
eeeeee
901+
"},
902+
"<C-j>",
903+
indoc! {"
904+
dddddd
905+
#[|aaaaaa
906+
bbbbbb
907+
ccc]#ccc
908+
eeeeee
909+
"},
910+
))
911+
.await?;
912+
Ok(())
913+
}
914+
#[tokio::test(flavor = "multi_thread")]
915+
async fn test_move_two_cursors_down() -> anyhow::Result<()> {
916+
test((
917+
indoc! {"
918+
aaaaaa
919+
bb#[|b]#bbb
920+
cccccc
921+
d#(dd|)#ddd
922+
eeeeee
923+
"},
924+
"<C-j>",
925+
indoc! {"
926+
aaaaaa
927+
cccccc
928+
bb#[|b]#bbb
929+
eeeeee
930+
d#(dd|)#ddd
931+
"},
932+
))
933+
.await?;
934+
Ok(())
935+
}

0 commit comments

Comments
 (0)