Implement Indent & Outdent as operators (#12430)

Paul Eguisier created

Release Notes:

- Fixes [#9697](https://github.com/zed-industries/zed/issues/9697).

Implements `>` and `<` with motions and text objects.
Works with repeat action `.`

Change summary

assets/keymaps/vim.json           | 16 +++++
crates/gpui/src/keymap/context.rs | 12 +++++
crates/vim/src/normal.rs          | 10 ++++
crates/vim/src/normal/indent.rs   | 78 +++++++++++++++++++++++++++++++++
crates/vim/src/state.rs           |  4 +
crates/vim/src/test.rs            | 27 +++++++++++
crates/vim/src/vim.rs             |  6 ++
7 files changed, 150 insertions(+), 3 deletions(-)

Detailed changes

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": {

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())

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)),

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<usize>,
+    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);
+                });
+            });
+        });
+    });
+}

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 => "<",
         }
     }
 

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]

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)
         };