diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 38b7684566fa2..51a4a092e8d13 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -396,6 +396,7 @@ impl MappableCommand { rotate_selection_contents_backward, "Rotate selections contents backward", 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", @@ -4356,6 +4357,8 @@ fn rotate_selection_contents_backward(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| { @@ -4400,6 +4403,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; } @@ -4415,6 +4445,82 @@ fn shrink_selection(cx: &mut Context) { cx.editor.last_motion = Some(Motion(Box::new(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()); + } + }; + + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); +} + fn select_sibling_impl(cx: &mut Context, sibling_fn: &'static F) where F: Fn(Node) -> Option, diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 9bd002809d613..b1afbd97f03b2 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -84,6 +84,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 6aa231d82a1f8..c9b87c1bd2f9e 100644 --- a/helix-term/tests/test/commands/movement.rs +++ b/helix-term/tests/test/commands/movement.rs @@ -129,3 +129,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(()) +}