diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index c45a71ff7e7fdf..7c3e8b66c08804 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -408,6 +408,7 @@ "(": "vim::Parentheses", ")": "vim::Parentheses", "b": "vim::Parentheses", + // "b": "vim::AnyBrackets", "[": "vim::SquareBrackets", "]": "vim::SquareBrackets", "r": "vim::SquareBrackets", diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index bb04117742f829..d59d59a26ea5c0 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -29,6 +29,7 @@ pub enum Object { AnyQuotes, DoubleQuotes, VerticalBars, + AnyBrackets, Parentheses, SquareBrackets, CurlyBrackets, @@ -74,6 +75,7 @@ actions!( DoubleQuotes, VerticalBars, Parentheses, + AnyBrackets, SquareBrackets, CurlyBrackets, AngleBrackets, @@ -115,6 +117,9 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, _: &AnyQuotes, window, cx| { vim.object(Object::AnyQuotes, window, cx) }); + Vim::action(editor, cx, |vim, _: &AnyBrackets, window, cx| { + vim.object(Object::AnyBrackets, window, cx) + }); Vim::action(editor, cx, |vim, _: &DoubleQuotes, window, cx| { vim.object(Object::DoubleQuotes, window, cx) }); @@ -186,6 +191,7 @@ impl Object { | Object::DoubleQuotes => false, Object::Sentence | Object::Paragraph + | Object::AnyBrackets | Object::Parentheses | Object::Tag | Object::AngleBrackets @@ -212,6 +218,7 @@ impl Object { | Object::AnyQuotes | Object::DoubleQuotes | Object::VerticalBars + | Object::AnyBrackets | Object::Parentheses | Object::SquareBrackets | Object::Tag @@ -239,6 +246,7 @@ impl Object { } } Object::Parentheses + | Object::AnyBrackets | Object::SquareBrackets | Object::CurlyBrackets | Object::AngleBrackets @@ -306,16 +314,7 @@ impl Object { quote, ) }) - .min_by_key(|range| { - // Calculate proximity of ranges to the cursor - let start_distance = (relative_offset - - range.start.to_offset(map, Bias::Left) as isize) - .abs(); - let end_distance = (relative_offset - - range.end.to_offset(map, Bias::Right) as isize) - .abs(); - start_distance + end_distance - }) + .min_by_key(|range| calculate_range_distance(range, relative_offset, map)) } Object::DoubleQuotes => { surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"') @@ -331,6 +330,24 @@ impl Object { let range = selection.range(); surrounding_html_tag(map, head, range, around) } + Object::AnyBrackets => { + let bracket_pairs = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; + let relative_offset = relative_to.to_offset(map, Bias::Left) as isize; + + bracket_pairs + .iter() + .flat_map(|&(open_bracket, close_bracket)| { + surrounding_markers( + map, + relative_to, + around, + self.is_multiline(), + open_bracket, + close_bracket, + ) + }) + .min_by_key(|range| calculate_range_distance(range, relative_offset, map)) + } Object::SquareBrackets => { surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']') } @@ -584,6 +601,37 @@ fn around_word( } } +/// Calculate distance between a range and a cursor position +/// +/// Returns a score where: +/// - Lower values indicate better matches +/// - Range containing cursor gets priority (returns range length) +/// - For non-containing ranges, uses minimum distance to boundaries as primary factor +/// - Range length is used as secondary factor for tiebreaking +fn calculate_range_distance( + range: &Range, + cursor_offset: isize, + map: &DisplaySnapshot, +) -> isize { + let start_offset = range.start.to_offset(map, Bias::Left) as isize; + let end_offset = range.end.to_offset(map, Bias::Right) as isize; + let range_length = end_offset - start_offset; + + // If cursor is inside the range, return range length + if cursor_offset >= start_offset && cursor_offset <= end_offset { + return range_length; + } + + // Calculate minimum distance to range boundaries + let start_distance = (cursor_offset - start_offset).abs(); + let end_distance = (cursor_offset - end_offset).abs(); + let min_distance = start_distance.min(end_distance); + + // Use min_distance as primary factor, range_length as secondary + // Multiply by large number to ensure distance is primary factor + min_distance * 10000 + range_length +} + fn around_subword( map: &DisplaySnapshot, relative_to: DisplayPoint, @@ -1302,9 +1350,11 @@ fn surrounding_markers( #[cfg(test)] mod test { + use gpui::KeyBinding; use indoc::indoc; use crate::{ + object::AnyBrackets, state::Mode, test::{NeovimBackedTestContext, VimTestContext}, }; @@ -1914,6 +1964,30 @@ mod test { const TEST_CASES: &[(&str, &str, &str, Mode)] = &[ // Single quotes + ( + "c i q", + "Thisˇ is a 'quote' example.", + "This is a 'ˇ' example.", + Mode::Insert, + ), + ( + "c a q", + "Thisˇ is a 'quote' example.", + "This is a ˇexample.", + Mode::Insert, + ), + ( + "c i q", + "This is a \"simple 'qˇuote'\" example.", + "This is a \"simple 'ˇ'\" example.", + Mode::Insert, + ), + ( + "c a q", + "This is a \"simple 'qˇuote'\" example.", + "This is a \"simpleˇ\" example.", + Mode::Insert, + ), ( "c i q", "This is a 'qˇuote' example.", @@ -2022,6 +2096,151 @@ mod test { } } + #[gpui::test] + async fn test_anybrackets_object(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.update(|_, cx| { + cx.bind_keys([KeyBinding::new( + "b", + AnyBrackets, + Some("vim_operator == a || vim_operator == i || vim_operator == cs"), + )]); + }); + + const TEST_CASES: &[(&str, &str, &str, Mode)] = &[ + // Bracket (Parentheses) + ( + "c i b", + "Thisˇ is a (simple [quote]) example.", + "This is a (ˇ) example.", + Mode::Insert, + ), + ( + "c i b", + "This is a [simple (qˇuote)] example.", + "This is a [simple (ˇ)] example.", + Mode::Insert, + ), + ( + "c a b", + "This is a [simple (qˇuote)] example.", + "This is a [simple ˇ] example.", + Mode::Insert, + ), + ( + "c a b", + "Thisˇ is a (simple [quote]) example.", + "This is a ˇ example.", + Mode::Insert, + ), + ( + "c i b", + "This is a (qˇuote) example.", + "This is a (ˇ) example.", + Mode::Insert, + ), + ( + "c a b", + "This is a (qˇuote) example.", + "This is a ˇ example.", + Mode::Insert, + ), + ( + "d i b", + "This is a (qˇuote) example.", + "This is a (ˇ) example.", + Mode::Normal, + ), + ( + "d a b", + "This is a (qˇuote) example.", + "This is a ˇ example.", + Mode::Normal, + ), + // Square brackets + ( + "c i b", + "This is a [qˇuote] example.", + "This is a [ˇ] example.", + Mode::Insert, + ), + ( + "c a b", + "This is a [qˇuote] example.", + "This is a ˇ example.", + Mode::Insert, + ), + ( + "d i b", + "This is a [qˇuote] example.", + "This is a [ˇ] example.", + Mode::Normal, + ), + ( + "d a b", + "This is a [qˇuote] example.", + "This is a ˇ example.", + Mode::Normal, + ), + // Curly brackets + ( + "c i b", + "This is a {qˇuote} example.", + "This is a {ˇ} example.", + Mode::Insert, + ), + ( + "c a b", + "This is a {qˇuote} example.", + "This is a ˇ example.", + Mode::Insert, + ), + ( + "d i b", + "This is a {qˇuote} example.", + "This is a {ˇ} example.", + Mode::Normal, + ), + ( + "d a b", + "This is a {qˇuote} example.", + "This is a ˇ example.", + Mode::Normal, + ), + ]; + + for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES { + cx.set_state(initial_state, Mode::Normal); + + cx.simulate_keystrokes(keystrokes); + + cx.assert_state(expected_state, *expected_mode); + } + + const INVALID_CASES: &[(&str, &str, Mode)] = &[ + ("c i b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket + ("c a b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket + ("d i b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket + ("d a b", "this is a (qˇuote example.", Mode::Normal), // Missing closing bracket + ("c i b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket + ("c a b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket + ("d i b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket + ("d a b", "this is a [qˇuote example.", Mode::Normal), // Missing closing square bracket + ("c i b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket + ("c a b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket + ("d i b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket + ("d a b", "this is a {qˇuote example.", Mode::Normal), // Missing closing curly bracket + ]; + + for (keystrokes, initial_state, mode) in INVALID_CASES { + cx.set_state(initial_state, Mode::Normal); + + cx.simulate_keystrokes(keystrokes); + + cx.assert_state(initial_state, *mode); + } + } + #[gpui::test] async fn test_tags(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new_html(cx).await;