helix: Apply modification (e.g. switch case) on a single character only in helix mode (#38119)

Romans Malinovskis and Jakub Konka created

Closes #34192

Without selection, only current character would be affected.

Also if #38117 is merged too, then transformations in SelectMode behave
correctly too and selection is not collapsed.

Release Notes:

- helix: Implemented `~`, `` ` ``, `` Alt-` `` correctly in normal and
select modes

---------

Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>

Change summary

assets/keymaps/vim.json          |  5 ++---
crates/vim/src/normal/convert.rs | 24 +++++++++++++++++-------
2 files changed, 19 insertions(+), 10 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -442,9 +442,8 @@
       ">": "vim::Indent",
       "<": "vim::Outdent",
       "=": "vim::AutoIndent",
-      "g u": "vim::PushLowercase",
-      "g shift-u": "vim::PushUppercase",
-      "g ~": "vim::PushOppositeCase",
+      "`": "vim::ConvertToLowerCase",
+      "alt-`": "vim::ConvertToUpperCase",
       "g q": "vim::PushRewrap",
       "g w": "vim::PushRewrap",
       "insert": "vim::InsertBefore",

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

@@ -214,11 +214,10 @@ impl Vim {
 
                     Mode::HelixNormal | Mode::HelixSelect => {
                         if selection.is_empty() {
-                            // Handle empty selection by operating on the whole word
-                            let (word_range, _) = snapshot.surrounding_word(selection.start, false);
-                            let word_start = snapshot.offset_to_point(word_range.start);
-                            let word_end = snapshot.offset_to_point(word_range.end);
-                            ranges.push(word_start..word_end);
+                            // Handle empty selection by operating on single character
+                            let start = selection.start;
+                            let end = snapshot.clip_point(start + Point::new(0, 1), Bias::Right);
+                            ranges.push(start..end);
                             cursor_positions.push(selection.start..selection.start);
                         } else {
                             ranges.push(selection.start..selection.end);
@@ -445,15 +444,26 @@ mod test {
         cx.simulate_keystrokes("~");
         cx.assert_state("«HELLO WORLDˇ»", Mode::HelixNormal);
 
-        // Cursor-only (empty) selection
+        // Cursor-only (empty) selection - switch case
         cx.set_state("The ˇquick brown", Mode::HelixNormal);
         cx.simulate_keystrokes("~");
-        cx.assert_state("The ˇQUICK brown", Mode::HelixNormal);
+        cx.assert_state("The ˇQuick brown", Mode::HelixNormal);
+        cx.simulate_keystrokes("~");
+        cx.assert_state("The ˇquick brown", Mode::HelixNormal);
+
+        // Cursor-only (empty) selection - switch to uppercase and lowercase explicitly
+        cx.set_state("The ˇquick brown", Mode::HelixNormal);
+        cx.simulate_keystrokes("alt-`");
+        cx.assert_state("The ˇQuick brown", Mode::HelixNormal);
+        cx.simulate_keystrokes("`");
+        cx.assert_state("The ˇquick brown", Mode::HelixNormal);
 
         // With `e` motion (which extends selection to end of word in Helix)
         cx.set_state("The ˇquick brown fox", Mode::HelixNormal);
         cx.simulate_keystrokes("e");
         cx.simulate_keystrokes("~");
         cx.assert_state("The «QUICKˇ» brown fox", Mode::HelixNormal);
+
+        // Cursor-only
     }
 }