vim: gq (#18156)

Conrad Irwin created

Closes #ISSUE

Release Notes:

- vim: Added gq/gw for rewrapping lines

Change summary

assets/keymaps/vim.json           |  13 +++
crates/editor/src/editor.rs       |   6 +
crates/vim/src/normal.rs          |  30 ++++++++
crates/vim/src/rewrap.rs          | 114 +++++++++++++++++++++++++++++++++
crates/vim/src/state.rs           |   3 
crates/vim/src/vim.rs             |   2 
crates/vim/test_data/test_gq.json |  12 +++
7 files changed, 177 insertions(+), 3 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -124,7 +124,6 @@
       "g i": "vim::InsertAtPrevious",
       "g ,": "vim::ChangeListNewer",
       "g ;": "vim::ChangeListOlder",
-      "g q": "editor::Rewrap",
       "shift-h": "vim::WindowTop",
       "shift-m": "vim::WindowMiddle",
       "shift-l": "vim::WindowBottom",
@@ -240,6 +239,8 @@
       "g shift-u": ["vim::PushOperator", "Uppercase"],
       "g ~": ["vim::PushOperator", "OppositeCase"],
       "\"": ["vim::PushOperator", "Register"],
+      "g q": ["vim::PushOperator", "Rewrap"],
+      "g w": ["vim::PushOperator", "Rewrap"],
       "q": "vim::ToggleRecord",
       "shift-q": "vim::ReplayLastRecording",
       "@": ["vim::PushOperator", "ReplayRegister"],
@@ -301,6 +302,7 @@
       "i": ["vim::PushOperator", { "Object": { "around": false } }],
       "a": ["vim::PushOperator", { "Object": { "around": true } }],
       "g c": "vim::ToggleComments",
+      "g q": "vim::Rewrap",
       "\"": ["vim::PushOperator", "Register"],
       // tree-sitter related commands
       "[ x": "editor::SelectLargerSyntaxNode",
@@ -428,6 +430,15 @@
       "~": "vim::CurrentLine"
     }
   },
+  {
+    "context": "vim_operator == gq",
+    "bindings": {
+      "g q": "vim::CurrentLine",
+      "q": "vim::CurrentLine",
+      "g w": "vim::CurrentLine",
+      "w": "vim::CurrentLine"
+    }
+  },
   {
     "context": "vim_operator == y",
     "bindings": {

crates/editor/src/editor.rs 🔗

@@ -6705,6 +6705,10 @@ impl Editor {
     }
 
     pub fn rewrap(&mut self, _: &Rewrap, cx: &mut ViewContext<Self>) {
+        self.rewrap_impl(true, cx)
+    }
+
+    pub fn rewrap_impl(&mut self, only_text: bool, cx: &mut ViewContext<Self>) {
         let buffer = self.buffer.read(cx).snapshot(cx);
         let selections = self.selections.all::<Point>(cx);
         let mut selections = selections.iter().peekable();
@@ -6725,7 +6729,7 @@ impl Editor {
                 continue;
             }
 
-            let mut should_rewrap = false;
+            let mut should_rewrap = !only_text;
 
             if let Some(language_scope) = buffer.language_scope_at(selection.head()) {
                 match language_scope.language_name().0.as_ref() {

crates/vim/src/normal.rs 🔗

@@ -168,6 +168,7 @@ impl Vim {
             Some(Operator::Yank) => self.yank_motion(motion, times, cx),
             Some(Operator::AddSurrounds { target: None }) => {}
             Some(Operator::Indent) => self.indent_motion(motion, times, IndentDirection::In, cx),
+            Some(Operator::Rewrap) => self.rewrap_motion(motion, times, cx),
             Some(Operator::Outdent) => self.indent_motion(motion, times, IndentDirection::Out, cx),
             Some(Operator::Lowercase) => {
                 self.change_case_motion(motion, times, CaseTarget::Lowercase, cx)
@@ -199,6 +200,7 @@ impl Vim {
                 Some(Operator::Outdent) => {
                     self.indent_object(object, around, IndentDirection::Out, cx)
                 }
+                Some(Operator::Rewrap) => self.rewrap_object(object, around, cx),
                 Some(Operator::Lowercase) => {
                     self.change_case_object(object, around, CaseTarget::Lowercase, cx)
                 }
@@ -478,8 +480,9 @@ impl Vim {
 }
 #[cfg(test)]
 mod test {
-    use gpui::{KeyBinding, TestAppContext};
+    use gpui::{KeyBinding, TestAppContext, UpdateGlobal};
     use indoc::indoc;
+    use language::language_settings::AllLanguageSettings;
     use settings::SettingsStore;
 
     use crate::{
@@ -1386,4 +1389,29 @@ mod test {
         cx.simulate_shared_keystrokes("2 0 r - ").await;
         cx.shared_state().await.assert_eq("ˇhello world\n");
     }
+
+    #[gpui::test]
+    async fn test_gq(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_neovim_option("textwidth=5").await;
+
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |settings, cx| {
+                settings.update_user_settings::<AllLanguageSettings>(cx, |settings| {
+                    settings.defaults.preferred_line_length = Some(5);
+                });
+            })
+        });
+
+        cx.set_shared_state("ˇth th th th th th\n").await;
+        cx.simulate_shared_keystrokes("g q q").await;
+        cx.shared_state().await.assert_eq("th th\nth th\nˇth th\n");
+
+        cx.set_shared_state("ˇth th th th th th\nth th th th th th\n")
+            .await;
+        cx.simulate_shared_keystrokes("v j g q").await;
+        cx.shared_state()
+            .await
+            .assert_eq("th th\nth th\nth th\nth th\nth th\nˇth th\n");
+    }
 }

crates/vim/src/rewrap.rs 🔗

@@ -0,0 +1,114 @@
+use crate::{motion::Motion, object::Object, state::Mode, Vim};
+use collections::HashMap;
+use editor::{display_map::ToDisplayPoint, scroll::Autoscroll, Bias, Editor};
+use gpui::actions;
+use language::SelectionGoal;
+use ui::ViewContext;
+
+actions!(vim, [Rewrap]);
+
+pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
+    Vim::action(editor, cx, |vim, _: &Rewrap, cx| {
+        vim.record_current_action(cx);
+        vim.take_count(cx);
+        vim.store_visual_marks(cx);
+        vim.update_editor(cx, |vim, editor, cx| {
+            editor.transact(cx, |editor, cx| {
+                let mut positions = vim.save_selection_starts(editor, cx);
+                editor.rewrap_impl(false, cx);
+                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                    s.move_with(|map, selection| {
+                        if let Some(anchor) = positions.remove(&selection.id) {
+                            let mut point = anchor.to_display_point(map);
+                            *point.column_mut() = 0;
+                            selection.collapse_to(point, SelectionGoal::None);
+                        }
+                    });
+                });
+            });
+        });
+        if vim.mode.is_visual() {
+            vim.switch_mode(Mode::Normal, true, cx)
+        }
+    });
+}
+
+impl Vim {
+    pub(crate) fn rewrap_motion(
+        &mut self,
+        motion: Motion,
+        times: Option<usize>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.stop_recording(cx);
+        self.update_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);
+                    });
+                });
+                editor.rewrap_impl(false, cx);
+                editor.change_selections(None, cx, |s| {
+                    s.move_with(|map, selection| {
+                        let anchor = selection_starts.remove(&selection.id).unwrap();
+                        let mut point = anchor.to_display_point(map);
+                        *point.column_mut() = 0;
+                        selection.collapse_to(point, SelectionGoal::None);
+                    });
+                });
+            });
+        });
+    }
+
+    pub(crate) fn rewrap_object(
+        &mut self,
+        object: Object,
+        around: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.stop_recording(cx);
+        self.update_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);
+                    });
+                });
+                editor.rewrap_impl(false, cx);
+                editor.change_selections(None, cx, |s| {
+                    s.move_with(|map, selection| {
+                        let anchor = original_positions.remove(&selection.id).unwrap();
+                        let mut point = anchor.to_display_point(map);
+                        *point.column_mut() = 0;
+                        selection.collapse_to(point, SelectionGoal::None);
+                    });
+                });
+            });
+        });
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use crate::test::NeovimBackedTestContext;
+
+    #[gpui::test]
+    async fn test_indent_gv(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_neovim_option("shiftwidth=4").await;
+
+        cx.set_shared_state("ˇhello\nworld\n").await;
+        cx.simulate_shared_keystrokes("v j > g v").await;
+        cx.shared_state()
+            .await
+            .assert_eq("«    hello\n ˇ»   world\n");
+    }
+}

crates/vim/src/state.rs 🔗

@@ -72,6 +72,7 @@ pub enum Operator {
     Jump { line: bool },
     Indent,
     Outdent,
+    Rewrap,
     Lowercase,
     Uppercase,
     OppositeCase,
@@ -454,6 +455,7 @@ impl Operator {
             Operator::Jump { line: true } => "'",
             Operator::Jump { line: false } => "`",
             Operator::Indent => ">",
+            Operator::Rewrap => "gq",
             Operator::Outdent => "<",
             Operator::Uppercase => "gU",
             Operator::Lowercase => "gu",
@@ -482,6 +484,7 @@ impl Operator {
             Operator::Change
             | Operator::Delete
             | Operator::Yank
+            | Operator::Rewrap
             | Operator::Indent
             | Operator::Outdent
             | Operator::Lowercase

crates/vim/src/vim.rs 🔗

@@ -13,6 +13,7 @@ mod motion;
 mod normal;
 mod object;
 mod replace;
+mod rewrap;
 mod state;
 mod surrounds;
 mod visual;
@@ -291,6 +292,7 @@ impl Vim {
             command::register(editor, cx);
             replace::register(editor, cx);
             indent::register(editor, cx);
+            rewrap::register(editor, cx);
             object::register(editor, cx);
             visual::register(editor, cx);
             change_list::register(editor, cx);

crates/vim/test_data/test_gq.json 🔗

@@ -0,0 +1,12 @@
+{"SetOption":{"value":"textwidth=5"}}
+{"Put":{"state":"ˇth th th th th th\n"}}
+{"Key":"g"}
+{"Key":"q"}
+{"Key":"q"}
+{"Get":{"state":"th th\nth th\nˇth th\n","mode":"Normal"}}
+{"Put":{"state":"ˇth th th th th th\nth th th th th th\n"}}
+{"Key":"v"}
+{"Key":"j"}
+{"Key":"g"}
+{"Key":"q"}
+{"Get":{"state":"th th\nth th\nth th\nth th\nth th\nˇth th\n","mode":"Normal"}}