diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 0d25320621728..4a3f05ba64dcb 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -417,6 +417,7 @@ impl MappableCommand { reverse_selection_contents, "Reverse selections contents", expand_selection, "Expand selection to parent syntax node", shrink_selection, "Shrink selection to previously expanded syntax node", + expand_selection_around, "Expand selection to parent syntax node, but exclude the selection you started with", select_next_sibling, "Select next sibling in syntax tree", select_prev_sibling, "Select previous sibling in syntax tree", jump_forward, "Jump forward on jumplist", @@ -4512,6 +4513,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| { @@ -4555,6 +4558,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; } @@ -4569,6 +4599,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: &'static F) where F: Fn(Node) -> Option, @@ -4584,6 +4689,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 763ed4ae71ce5..9441398593379 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -85,6 +85,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-p" | "A-left" => select_prev_sibling, "A-n" | "A-right" => select_next_sibling, diff --git a/helix-term/tests/test/commands/movement.rs b/helix-term/tests/test/commands/movement.rs index 3bd7e6248521d..2c4a0347aae20 100644 --- a/helix-term/tests/test/commands/movement.rs +++ b/helix-term/tests/test/commands/movement.rs @@ -580,3 +580,68 @@ async fn expand_shrink_selection() -> anyhow::Result<()> { 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 + ( + helpers::platform_line(indoc! {r##" + Some(#[thing|]#) + "##}), + "", + helpers::platform_line(indoc! {r##" + #[Some(|]#thing#()|)# + "##}), + ), + // shrinking restores previous selection + ( + helpers::platform_line(indoc! {r##" + Some(#[thing|]#) + "##}), + "", + helpers::platform_line(indoc! {r##" + Some(#[thing|]#) + "##}), + ), + // multi range collision merges expand as normal, except with the + // original selection removed from the result + ( + helpers::platform_line(indoc! {r##" + ( + Some(#[thing|]#), + Some(#(other_thing|)#), + ) + "##}), + "", + helpers::platform_line(indoc! {r##" + #[( + Some(|]#thing#(), + Some(|)#other_thing#(), + )|)# + "##}), + ), + ( + helpers::platform_line(indoc! {r##" + ( + Some(#[thing|]#), + Some(#(other_thing|)#), + ) + "##}), + "", + helpers::platform_line(indoc! {r##" + ( + Some(#[thing|]#), + Some(#(other_thing|)#), + ) + "##}), + ), + ]; + + for test in tests { + test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?; + } + + Ok(()) +}