From 73b45117136eb49781e54130e6a0e323fdc2c2c5 Mon Sep 17 00:00:00 2001 From: Skyler Hawthorne Date: Sun, 9 Oct 2022 18:04:09 -0400 Subject: [PATCH] feat(command): expand_selection_around Introduces a new command `expand_selection_around` that expands the selection to the parent node, like `expand_selection`, except it splits on the selection you start with and continues expansion around this initial selection. --- helix-term/src/commands.rs | 106 +++++++++++++++++++++ helix-term/src/keymap/default.rs | 1 + helix-term/tests/test/commands/movement.rs | 66 +++++++++++++ 3 files changed, 173 insertions(+) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 3f9ffafe3..f9b387356 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -489,6 +489,7 @@ impl MappableCommand { select_prev_sibling, "Select previous sibling the in syntax tree", select_all_siblings, "Select all siblings of the current node", select_all_children, "Select all children of the current node", + expand_selection_around, "Expand selection to parent syntax node, but exclude the selection you started with", jump_forward, "Jump forward on jumplist", jump_backward, "Jump backward on jumplist", save_selection, "Save current selection to jumplist", @@ -5033,6 +5034,8 @@ fn reverse_selection_contents(cx: &mut Context) { // tree sitter node selection const EXPAND_KEY: &str = "expand"; +const EXPAND_AROUND_BASE_KEY: &str = "expand_around_base"; +const PARENTS_KEY: &str = "parents"; fn expand_selection(cx: &mut Context) { let motion = |editor: &mut Editor| { @@ -5076,6 +5079,33 @@ fn shrink_selection(cx: &mut Context) { if let Some(prev_selection) = prev_expansions.pop() { // allow shrinking the selection only if current selection contains the previous object selection doc.set_selection_clear(view.id, prev_selection, false); + + // Do a corresponding pop of the parents from `expand_selection_around` + doc.view_data_mut(view.id) + .object_selections + .entry(PARENTS_KEY) + .and_modify(|parents| { + parents.pop(); + }); + + // need to do this again because borrowing + let prev_expansions = doc + .view_data_mut(view.id) + .object_selections + .entry(EXPAND_KEY) + .or_default(); + + // if we've emptied out the previous expansions, then clear out the + // base history as well so it doesn't get used again erroneously + if prev_expansions.is_empty() { + doc.view_data_mut(view.id) + .object_selections + .entry(EXPAND_AROUND_BASE_KEY) + .and_modify(|base| { + base.clear(); + }); + } + return; } @@ -5090,6 +5120,81 @@ fn shrink_selection(cx: &mut Context) { cx.editor.apply_motion(motion); } +fn expand_selection_around(cx: &mut Context) { + let motion = |editor: &mut Editor| { + let (view, doc) = current!(editor); + + if doc.syntax().is_some() { + // [NOTE] we do this pop and push dance because if we don't take + // ownership of the objects, then we require multiple + // mutable references to the view's object selections + let mut parents_selection = doc + .view_data_mut(view.id) + .object_selections + .entry(PARENTS_KEY) + .or_default() + .pop(); + + let mut base_selection = doc + .view_data_mut(view.id) + .object_selections + .entry(EXPAND_AROUND_BASE_KEY) + .or_default() + .pop(); + + let current_selection = doc.selection(view.id).clone(); + + if parents_selection.is_none() || base_selection.is_none() { + parents_selection = Some(current_selection.clone()); + base_selection = Some(current_selection.clone()); + } + + let text = doc.text().slice(..); + let syntax = doc.syntax().unwrap(); + + let outside_selection = + object::expand_selection(syntax, text, parents_selection.clone().unwrap()); + + let target_selection = match outside_selection + .clone() + .without(&base_selection.clone().unwrap()) + { + Some(sel) => sel, + None => outside_selection.clone(), + }; + + // check if selection is different from the last one + if target_selection != current_selection { + // save current selection so it can be restored using shrink_selection + doc.view_data_mut(view.id) + .object_selections + .entry(EXPAND_KEY) + .or_default() + .push(current_selection); + + doc.set_selection_clear(view.id, target_selection, false); + } + + let parents = doc + .view_data_mut(view.id) + .object_selections + .entry(PARENTS_KEY) + .or_default(); + + parents.push(parents_selection.unwrap()); + parents.push(outside_selection); + + doc.view_data_mut(view.id) + .object_selections + .entry(EXPAND_AROUND_BASE_KEY) + .or_default() + .push(base_selection.unwrap()); + } + }; + + cx.editor.apply_motion(motion); +} + fn select_sibling_impl(cx: &mut Context, sibling_fn: F) where F: Fn(&helix_core::Syntax, RopeSlice, Selection) -> Selection + 'static, @@ -5104,6 +5209,7 @@ where doc.set_selection(view.id, selection); } }; + cx.editor.apply_motion(motion); } diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 5a3e8eed4..f3327fef8 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -86,6 +86,7 @@ pub fn default() -> HashMap { ";" => collapse_selection, "A-;" => flip_selections, "A-o" | "A-up" => expand_selection, + "A-O" => expand_selection_around, "A-i" | "A-down" => shrink_selection, "A-I" | "A-S-down" => select_all_children, "A-p" | "A-left" => select_prev_sibling, diff --git a/helix-term/tests/test/commands/movement.rs b/helix-term/tests/test/commands/movement.rs index dd5a3beef..684f1dcae 100644 --- a/helix-term/tests/test/commands/movement.rs +++ b/helix-term/tests/test/commands/movement.rs @@ -1067,6 +1067,72 @@ async fn expand_shrink_selection() -> anyhow::Result<()> { #[|Some(thing)]#, Some(other_thing), ) + + "##}, + ), + ]; + + for test in tests { + test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn expand_selection_around() -> anyhow::Result<()> { + let tests = vec![ + // single cursor stays single cursor, first goes to end of current + // node, then parent + ( + indoc! {r##" + Some(#[thing|]#) + "##}, + "", + indoc! {r##" + #[Some(|]#thing#()|)# + "##}, + ), + // shrinking restores previous selection + ( + indoc! {r##" + Some(#[thing|]#) + "##}, + "", + indoc! {r##" + Some(#[thing|]#) + "##}, + ), + // multi range collision merges expand as normal, except with the + // original selection removed from the result + ( + indoc! {r##" + ( + Some(#[thing|]#), + Some(#(other_thing|)#), + ) + "##}, + "", + indoc! {r##" + #[( + Some(|]#thing#(), + Some(|)#other_thing#(), + )|)# + "##}, + ), + ( + indoc! {r##" + ( + Some(#[thing|]#), + Some(#(other_thing|)#), + ) + "##}, + "", + indoc! {r##" + ( + Some(#[thing|]#), + Some(#(other_thing|)#), + ) "##}, ), ];