vim: Add gU/gu/g~ (#12782)

Conrad Irwin created

Co-Authored-By: ethanmsl@gmail.com

Release Notes:

- vim: Added `gu`/`gU`/`g~` for changing case. (#12565)

Change summary

assets/keymaps/vim.json                           |   3 
crates/vim/src/normal.rs                          |  19 ++
crates/vim/src/normal/case.rs                     | 116 ++++++++++++++++
crates/vim/src/state.rs                           |   7 +
crates/vim/src/vim.rs                             |   3 
crates/vim/test_data/test_change_case_motion.json |  23 +++
6 files changed, 168 insertions(+), 3 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -381,6 +381,9 @@
       "shift-s": "vim::SubstituteLine",
       ">": ["vim::PushOperator", "Indent"],
       "<": ["vim::PushOperator", "Outdent"],
+      "g u": ["vim::PushOperator", "Lowercase"],
+      "g shift-u": ["vim::PushOperator", "Uppercase"],
+      "g ~": ["vim::PushOperator", "OppositeCase"],
       "ctrl-pagedown": "pane::ActivateNextItem",
       "ctrl-pageup": "pane::ActivatePrevItem",
       // tree-sitter related commands

crates/vim/src/normal.rs 🔗

@@ -21,6 +21,7 @@ use crate::{
     surrounds::{check_and_move_to_valid_bracket_pair, SurroundsType},
     Vim,
 };
+use case::{change_case_motion, change_case_object, CaseTarget};
 use collections::BTreeSet;
 use editor::display_map::ToDisplayPoint;
 use editor::scroll::Autoscroll;
@@ -198,6 +199,15 @@ pub fn normal_motion(
             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::Lowercase) => {
+                change_case_motion(vim, motion, times, CaseTarget::Lowercase, cx)
+            }
+            Some(Operator::Uppercase) => {
+                change_case_motion(vim, motion, times, CaseTarget::Uppercase, cx)
+            }
+            Some(Operator::OppositeCase) => {
+                change_case_motion(vim, motion, times, CaseTarget::OppositeCase, cx)
+            }
             Some(operator) => {
                 // Can't do anything for text objects, Ignoring
                 error!("Unexpected normal mode motion operator: {:?}", operator)
@@ -220,6 +230,15 @@ pub fn normal_object(object: Object, cx: &mut WindowContext) {
                 Some(Operator::Outdent) => {
                     indent_object(vim, object, around, IndentDirection::Out, cx)
                 }
+                Some(Operator::Lowercase) => {
+                    change_case_object(vim, object, around, CaseTarget::Lowercase, cx)
+                }
+                Some(Operator::Uppercase) => {
+                    change_case_object(vim, object, around, CaseTarget::Uppercase, cx)
+                }
+                Some(Operator::OppositeCase) => {
+                    change_case_object(vim, object, around, CaseTarget::OppositeCase, cx)
+                }
                 Some(Operator::AddSurrounds { target: None }) => {
                     waiting_operator = Some(Operator::AddSurrounds {
                         target: Some(SurroundsType::Object(object)),

crates/vim/src/normal/case.rs 🔗

@@ -1,13 +1,98 @@
-use editor::scroll::Autoscroll;
+use collections::HashMap;
+use editor::{display_map::ToDisplayPoint, scroll::Autoscroll};
 use gpui::ViewContext;
-use language::{Bias, Point};
+use language::{Bias, Point, SelectionGoal};
 use multi_buffer::MultiBufferRow;
+use ui::WindowContext;
 use workspace::Workspace;
 
 use crate::{
-    normal::ChangeCase, normal::ConvertToLowerCase, normal::ConvertToUpperCase, state::Mode, Vim,
+    motion::Motion,
+    normal::{ChangeCase, ConvertToLowerCase, ConvertToUpperCase},
+    object::Object,
+    state::Mode,
+    Vim,
 };
 
+pub enum CaseTarget {
+    Lowercase,
+    Uppercase,
+    OppositeCase,
+}
+
+pub fn change_case_motion(
+    vim: &mut Vim,
+    motion: Motion,
+    times: Option<usize>,
+    mode: CaseTarget,
+    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::Left);
+                    selection_starts.insert(selection.id, anchor);
+                    motion.expand_selection(map, selection, times, false, &text_layout_details);
+                });
+            });
+            match mode {
+                CaseTarget::Lowercase => editor.convert_to_lower_case(&Default::default(), cx),
+                CaseTarget::Uppercase => editor.convert_to_upper_case(&Default::default(), cx),
+                CaseTarget::OppositeCase => {
+                    editor.convert_to_opposite_case(&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 change_case_object(
+    vim: &mut Vim,
+    object: Object,
+    around: bool,
+    mode: CaseTarget,
+    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| {
+                    object.expand_selection(map, selection, around);
+                    original_positions.insert(
+                        selection.id,
+                        map.display_point_to_anchor(selection.start, Bias::Left),
+                    );
+                });
+            });
+            match mode {
+                CaseTarget::Lowercase => editor.convert_to_lower_case(&Default::default(), cx),
+                CaseTarget::Uppercase => editor.convert_to_upper_case(&Default::default(), cx),
+                CaseTarget::OppositeCase => {
+                    editor.convert_to_opposite_case(&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);
+                });
+            });
+        });
+    });
+}
+
 pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Workspace>) {
     manipulate_text(cx, |c| {
         if c.is_lowercase() {
@@ -180,4 +265,29 @@ mod test {
         cx.simulate_shared_keystrokes("ctrl-v j u").await;
         cx.shared_state().await.assert_eq("ˇaa\nbb\nCc");
     }
+
+    #[gpui::test]
+    async fn test_change_case_motion(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        // works in visual mode
+        cx.set_shared_state("ˇabc def").await;
+        cx.simulate_shared_keystrokes("g shift-u w").await;
+        cx.shared_state().await.assert_eq("ˇABC def");
+
+        cx.simulate_shared_keystrokes("g u w").await;
+        cx.shared_state().await.assert_eq("ˇabc def");
+
+        cx.simulate_shared_keystrokes("g ~ w").await;
+        cx.shared_state().await.assert_eq("ˇABC def");
+
+        cx.simulate_shared_keystrokes(".").await;
+        cx.shared_state().await.assert_eq("ˇabc def");
+
+        cx.set_shared_state("abˇc def").await;
+        cx.simulate_shared_keystrokes("g ~ i w").await;
+        cx.shared_state().await.assert_eq("ˇABC def");
+
+        cx.simulate_shared_keystrokes(".").await;
+        cx.shared_state().await.assert_eq("ˇabc def");
+    }
 }

crates/vim/src/state.rs 🔗

@@ -63,6 +63,10 @@ pub enum Operator {
     Jump { line: bool },
     Indent,
     Outdent,
+
+    Lowercase,
+    Uppercase,
+    OppositeCase,
 }
 
 #[derive(Default, Clone)]
@@ -270,6 +274,9 @@ impl Operator {
             Operator::Jump { line: false } => "`",
             Operator::Indent => ">",
             Operator::Outdent => "<",
+            Operator::Uppercase => "gU",
+            Operator::Lowercase => "gu",
+            Operator::OppositeCase => "g~",
         }
     }
 

crates/vim/src/vim.rs 🔗

@@ -539,6 +539,9 @@ impl Vim {
                 | Operator::Replace
                 | Operator::Indent
                 | Operator::Outdent
+                | Operator::Lowercase
+                | Operator::Uppercase
+                | Operator::OppositeCase
         ) {
             self.start_recording(cx)
         };

crates/vim/test_data/test_change_case_motion.json 🔗

@@ -0,0 +1,23 @@
+{"Put":{"state":"ˇabc def"}}
+{"Key":"g"}
+{"Key":"shift-u"}
+{"Key":"w"}
+{"Get":{"state":"ˇABC def","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"u"}
+{"Key":"w"}
+{"Get":{"state":"ˇabc def","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"~"}
+{"Key":"w"}
+{"Get":{"state":"ˇABC def","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"ˇabc def","mode":"Normal"}}
+{"Put":{"state":"abˇc def"}}
+{"Key":"g"}
+{"Key":"~"}
+{"Key":"i"}
+{"Key":"w"}
+{"Get":{"state":"ˇABC def","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"ˇabc def","mode":"Normal"}}