diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index bd092752e6..9f8b087cb8 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -379,8 +379,8 @@ "r": ["vim::PushOperator", "Replace"], "s": "vim::Substitute", "shift-s": "vim::SubstituteLine", - "> >": "vim::Indent", - "< <": "vim::Outdent", + ">": ["vim::PushOperator", "Indent"], + "<": ["vim::PushOperator", "Outdent"], "ctrl-pagedown": "pane::ActivateNextItem", "ctrl-pageup": "pane::ActivatePrevItem", // tree-sitter related commands @@ -459,6 +459,18 @@ "s": "vim::CurrentLine" } }, + { + "context": "Editor && vim_operator == >", + "bindings": { + ">": "vim::CurrentLine" + } + }, + { + "context": "Editor && vim_operator == <", + "bindings": { + "<": "vim::CurrentLine" + } + }, { "context": "Editor && VimObject", "bindings": { diff --git a/crates/gpui/src/keymap/context.rs b/crates/gpui/src/keymap/context.rs index 6ac22d2162..17dba496bd 100644 --- a/crates/gpui/src/keymap/context.rs +++ b/crates/gpui/src/keymap/context.rs @@ -304,6 +304,14 @@ impl KeyBindingContextPredicate { source, )) } + _ if is_vim_operator_char(next) => { + let (operator, rest) = source.split_at(1); + source = skip_whitespace(rest); + Ok(( + KeyBindingContextPredicate::Identifier(operator.to_string().into()), + source, + )) + } _ => Err(anyhow!("unexpected character {next:?}")), } } @@ -347,6 +355,10 @@ fn is_identifier_char(c: char) -> bool { c.is_alphanumeric() || c == '_' || c == '-' } +fn is_vim_operator_char(c: char) -> bool { + c == '>' || c == '<' +} + fn skip_whitespace(source: &str) -> &str { let len = source .find(|c: char| !c.is_whitespace()) diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 53fd33d8f5..db1bd8fd54 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -2,6 +2,7 @@ mod case; mod change; mod delete; mod increment; +mod indent; pub(crate) mod mark; mod paste; pub(crate) mod repeat; @@ -32,6 +33,7 @@ use self::{ case::{change_case, convert_to_lower_case, convert_to_upper_case}, change::{change_motion, change_object}, delete::{delete_motion, delete_object}, + indent::{indent_motion, indent_object, IndentDirection}, yank::{yank_motion, yank_object}, }; @@ -182,6 +184,8 @@ pub fn normal_motion( Some(Operator::Delete) => delete_motion(vim, motion, times, cx), Some(Operator::Yank) => yank_motion(vim, motion, times, cx), Some(Operator::AddSurrounds { target: None }) => {} + Some(Operator::Indent) => indent_motion(vim, motion, times, IndentDirection::In, cx), + Some(Operator::Outdent) => indent_motion(vim, motion, times, IndentDirection::Out, cx), Some(operator) => { // Can't do anything for text objects, Ignoring error!("Unexpected normal mode motion operator: {:?}", operator) @@ -198,6 +202,12 @@ pub fn normal_object(object: Object, cx: &mut WindowContext) { Some(Operator::Change) => change_object(vim, object, around, cx), Some(Operator::Delete) => delete_object(vim, object, around, cx), Some(Operator::Yank) => yank_object(vim, object, around, cx), + Some(Operator::Indent) => { + indent_object(vim, object, around, IndentDirection::In, cx) + } + Some(Operator::Outdent) => { + indent_object(vim, object, around, IndentDirection::Out, cx) + } Some(Operator::AddSurrounds { target: None }) => { waiting_operator = Some(Operator::AddSurrounds { target: Some(SurroundsType::Object(object)), diff --git a/crates/vim/src/normal/indent.rs b/crates/vim/src/normal/indent.rs new file mode 100644 index 0000000000..c69b0712b3 --- /dev/null +++ b/crates/vim/src/normal/indent.rs @@ -0,0 +1,78 @@ +use crate::{motion::Motion, object::Object, Vim}; +use collections::HashMap; +use editor::{display_map::ToDisplayPoint, Bias}; +use gpui::WindowContext; +use language::SelectionGoal; + +#[derive(PartialEq, Eq)] +pub(super) enum IndentDirection { + In, + Out, +} + +pub fn indent_motion( + vim: &mut Vim, + motion: Motion, + times: Option, + dir: IndentDirection, + cx: &mut WindowContext, +) { + vim.stop_recording(); + vim.update_active_editor(cx, |_, editor, cx| { + let text_layout_details = editor.text_layout_details(cx); + editor.transact(cx, |editor, cx| { + let mut selection_starts: HashMap<_, _> = Default::default(); + editor.change_selections(None, cx, |s| { + s.move_with(|map, selection| { + let anchor = map.display_point_to_anchor(selection.head(), Bias::Right); + selection_starts.insert(selection.id, anchor); + motion.expand_selection(map, selection, times, false, &text_layout_details); + }); + }); + if dir == IndentDirection::In { + editor.indent(&Default::default(), cx); + } else { + editor.outdent(&Default::default(), cx); + } + editor.change_selections(None, cx, |s| { + s.move_with(|map, selection| { + let anchor = selection_starts.remove(&selection.id).unwrap(); + selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None); + }); + }); + }); + }); +} + +pub fn indent_object( + vim: &mut Vim, + object: Object, + around: bool, + dir: IndentDirection, + cx: &mut WindowContext, +) { + vim.stop_recording(); + vim.update_active_editor(cx, |_, editor, cx| { + editor.transact(cx, |editor, cx| { + let mut original_positions: HashMap<_, _> = Default::default(); + editor.change_selections(None, cx, |s| { + s.move_with(|map, selection| { + let anchor = map.display_point_to_anchor(selection.head(), Bias::Right); + original_positions.insert(selection.id, anchor); + object.expand_selection(map, selection, around); + }); + }); + if dir == IndentDirection::In { + editor.indent(&Default::default(), cx); + } else { + editor.outdent(&Default::default(), cx); + } + editor.change_selections(None, cx, |s| { + s.move_with(|map, selection| { + let anchor = original_positions.remove(&selection.id).unwrap(); + selection.collapse_to(anchor.to_display_point(map), SelectionGoal::None); + }); + }); + }); + }); +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index c143935299..4d606a56e7 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -61,6 +61,8 @@ pub enum Operator { DeleteSurrounds, Mark, Jump { line: bool }, + Indent, + Outdent, } #[derive(Default, Clone)] @@ -266,6 +268,8 @@ impl Operator { Operator::Mark => "m", Operator::Jump { line: true } => "'", Operator::Jump { line: false } => "`", + Operator::Indent => ">", + Operator::Outdent => "<", } } diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 6775b9d014..ccfeb5b81e 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -180,6 +180,33 @@ async fn test_indent_outdent(cx: &mut gpui::TestAppContext) { // works in visual mode cx.simulate_keystrokes("shift-v down >"); cx.assert_editor_state("aa\n bb\n cˇc"); + + // works as operator + cx.set_state("aa\nbˇb\ncc\n", Mode::Normal); + cx.simulate_keystrokes("> j"); + cx.assert_editor_state("aa\n bˇb\n cc\n"); + cx.simulate_keystrokes("< k"); + cx.assert_editor_state("aa\nbˇb\n cc\n"); + cx.simulate_keystrokes("> i p"); + cx.assert_editor_state(" aa\n bˇb\n cc\n"); + cx.simulate_keystrokes("< i p"); + cx.assert_editor_state("aa\nbˇb\n cc\n"); + cx.simulate_keystrokes("< i p"); + cx.assert_editor_state("aa\nbˇb\ncc\n"); + + cx.set_state("ˇaa\nbb\ncc\n", Mode::Normal); + cx.simulate_keystrokes("> 2 j"); + cx.assert_editor_state(" ˇaa\n bb\n cc\n"); + + cx.set_state("aa\nbb\nˇcc\n", Mode::Normal); + cx.simulate_keystrokes("> 2 k"); + cx.assert_editor_state(" aa\n bb\n ˇcc\n"); + + cx.set_state("a\nb\nccˇc\n", Mode::Normal); + cx.simulate_keystrokes("> 2 k"); + cx.assert_editor_state(" a\n b\n ccˇc\n"); + cx.simulate_keystrokes("."); + cx.assert_editor_state(" a\n b\n ccˇc\n"); } #[gpui::test] diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index fe287bd46b..c38e65fad8 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -534,7 +534,11 @@ impl Vim { fn push_operator(&mut self, operator: Operator, cx: &mut WindowContext) { if matches!( operator, - Operator::Change | Operator::Delete | Operator::Replace + Operator::Change + | Operator::Delete + | Operator::Replace + | Operator::Indent + | Operator::Outdent ) { self.start_recording(cx) };