Add emacs mark mode (#23297)

Conrad Irwin and Peter created

Updates #21927
Replaces https://github.com/zed-industries/zed/pull/22904
Closes #8580

Adds actions (default keybinds with emacs keymap):
- editor::SetMark (`ctrl-space` and `ctrl-@`)
- editor::ExchangeMark (`ctrl-x ctrl-x`)

Co-Authored-By: Peter <peter@zed.dev>

Release Notes:

- Add Emacs mark mode (`ctrl-space` / `ctrl-@` to set mark; `ctrl-x
ctrl-x` to swap mark/cursor)
- Breaking change: `selection` keyboard context has been replaced with
`selection_mode`

---------

Co-authored-by: Peter <peter@zed.dev>

Change summary

assets/keymaps/linux/emacs.json | 32 +++++++++++++++++++++++++++
assets/keymaps/macos/emacs.json | 32 +++++++++++++++++++++++++++
crates/editor/src/actions.rs    |  2 +
crates/editor/src/editor.rs     | 40 ++++++++++++++++++++++++++++-------
crates/editor/src/element.rs    |  2 +
5 files changed, 98 insertions(+), 10 deletions(-)

Detailed changes

assets/keymaps/linux/emacs.json 🔗

@@ -15,7 +15,9 @@
       "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer
       "alt-g g": "go_to_line::Toggle", // goto-line
       "alt-g alt-g": "go_to_line::Toggle", // goto-line
-      //"ctrl-space": "editor::SetMark",
+      "ctrl-space": "editor::SetMark", // set-mark
+      "ctrl-@": "editor::SetMark", // set-mark
+      "ctrl-x ctrl-x": "editor::ExchangeMark", // exchange-point-and-mark
       "ctrl-f": "editor::MoveRight", // forward-char
       "ctrl-b": "editor::MoveLeft", // backward-char
       "ctrl-n": "editor::MoveDown", // next-line
@@ -24,6 +26,8 @@
       "end": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": false }], // move-end-of-line
       "ctrl-a": ["editor::MoveToBeginningOfLine", { "stop_at_soft_wraps": false }], // move-beginning-of-line
       "ctrl-e": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": false }], // move-end-of-line
+      "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }], // move-beginning-of-line
+      "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], // move-end-of-line
       "alt-f": "editor::MoveToNextSubwordEnd", // forward-word
       "alt-b": "editor::MoveToPreviousSubwordStart", // backward-word
       "alt-u": "editor::ConvertToUpperCase", // upcase-word
@@ -55,6 +59,32 @@
       "alt-^": "editor::JoinLines" // join-line
     }
   },
+  {
+    "context": "Editor && selection_mode", // region selection
+    "bindings": {
+      "right": "editor::SelectRight",
+      "left": "editor::SelectLeft",
+      "down": "editor::SelectDown",
+      "up": "editor::SelectUp",
+      "alt-left": "editor::SelectToPreviousWordStart",
+      "alt-right": "editor::SelectToNextWordEnd",
+      "pagedown": "editor::SelectPageDown",
+      "pageup": "editor::SelectPageUp",
+      "ctrl-f": "editor::SelectRight",
+      "ctrl-b": "editor::SelectLeft",
+      "ctrl-n": "editor::SelectDown",
+      "ctrl-p": "editor::SelectUp",
+      "home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }],
+      "end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }],
+      "ctrl-a": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }],
+      "ctrl-e": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }],
+      "alt-f": "editor::SelectToNextWordEnd",
+      "alt-b": "editor::SelectToPreviousSubwordStart",
+      "alt-<": "editor::SelectToBeginning",
+      "alt->": "editor::SelectToEnd",
+      "ctrl-g": "editor::Cancel"
+    }
+  },
   {
     "context": "Workspace",
     "bindings": {

assets/keymaps/macos/emacs.json 🔗

@@ -15,7 +15,9 @@
       "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer
       "alt-g g": "go_to_line::Toggle", // goto-line
       "alt-g alt-g": "go_to_line::Toggle", // goto-line
-      //"ctrl-space": "editor::SetMark",
+      "ctrl-space": "editor::SetMark", // set-mark
+      "ctrl-@": "editor::SetMark", // set-mark
+      "ctrl-x ctrl-x": "editor::ExchangeMark", // exchange-point-and-mark
       "ctrl-f": "editor::MoveRight", // forward-char
       "ctrl-b": "editor::MoveLeft", // backward-char
       "ctrl-n": "editor::MoveDown", // next-line
@@ -24,6 +26,8 @@
       "end": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": false }], // move-end-of-line
       "ctrl-a": ["editor::MoveToBeginningOfLine", { "stop_at_soft_wraps": false }], // move-beginning-of-line
       "ctrl-e": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": false }], // move-end-of-line
+      "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }], // move-beginning-of-line
+      "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }], // move-end-of-line
       "alt-f": "editor::MoveToNextSubwordEnd", // forward-word
       "alt-b": "editor::MoveToPreviousSubwordStart", // backward-word
       "alt-u": "editor::ConvertToUpperCase", // upcase-word
@@ -55,6 +59,32 @@
       "alt-^": "editor::JoinLines" // join-line
     }
   },
+  {
+    "context": "Editor && selection_mode", // region selection
+    "bindings": {
+      "right": "editor::SelectRight",
+      "left": "editor::SelectLeft",
+      "down": "editor::SelectDown",
+      "up": "editor::SelectUp",
+      "alt-left": "editor::SelectToPreviousWordStart",
+      "alt-right": "editor::SelectToNextWordEnd",
+      "pagedown": "editor::SelectPageDown",
+      "pageup": "editor::SelectPageUp",
+      "ctrl-f": "editor::SelectRight",
+      "ctrl-b": "editor::SelectLeft",
+      "ctrl-n": "editor::SelectDown",
+      "ctrl-p": "editor::SelectUp",
+      "home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }],
+      "end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }],
+      "ctrl-a": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": false }],
+      "ctrl-e": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": false }],
+      "alt-f": "editor::SelectToNextWordEnd",
+      "alt-b": "editor::SelectToPreviousSubwordStart",
+      "alt-<": "editor::SelectToBeginning",
+      "alt->": "editor::SelectToEnd",
+      "ctrl-g": "editor::Cancel"
+    }
+  },
   {
     "context": "Workspace",
     "bindings": {

crates/editor/src/actions.rs 🔗

@@ -377,6 +377,8 @@ gpui::actions!(
         ToggleInlayHints,
         ToggleInlineCompletions,
         ToggleLineNumbers,
+        ExchangeMark,
+        SetMark,
         ToggleRelativeLineNumbers,
         ToggleSelectionMenu,
         ToggleSoftWrap,

crates/editor/src/editor.rs 🔗

@@ -706,6 +706,7 @@ pub struct Editor {
     next_scroll_position: NextScrollCursorCenterTopBottom,
     addons: HashMap<TypeId, Box<dyn Addon>>,
     registered_buffers: HashMap<BufferId, OpenLspBufferHandle>,
+    selection_mark_mode: bool,
     toggle_fold_multiple_buffers: Task<()>,
     _scroll_cursor_center_top_bottom_task: Task<()>,
 }
@@ -1364,6 +1365,7 @@ impl Editor {
             addons: HashMap::default(),
             registered_buffers: HashMap::default(),
             _scroll_cursor_center_top_bottom_task: Task::ready(()),
+            selection_mark_mode: false,
             toggle_fold_multiple_buffers: Task::ready(()),
             text_style_refinement: None,
         };
@@ -1456,13 +1458,8 @@ impl Editor {
             key_context.add("inline_completion");
         }
 
-        if !self
-            .selections
-            .disjoint
-            .iter()
-            .all(|selection| selection.start == selection.end)
-        {
-            key_context.add("selection");
+        if self.selection_mark_mode {
+            key_context.add("selection_mode");
         }
 
         key_context
@@ -2477,6 +2474,8 @@ impl Editor {
     }
 
     pub fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+        self.selection_mark_mode = false;
+
         if self.clear_expanded_diff_hunks(cx) {
             cx.notify();
             return;
@@ -10622,6 +10621,32 @@ impl Editor {
         }
     }
 
+    pub fn set_mark(&mut self, _: &actions::SetMark, cx: &mut ViewContext<Self>) {
+        if self.selection_mark_mode {
+            self.change_selections(None, cx, |s| {
+                s.move_with(|_, sel| {
+                    sel.collapse_to(sel.head(), SelectionGoal::None);
+                });
+            })
+        }
+        self.selection_mark_mode = true;
+        cx.notify();
+    }
+
+    pub fn exchange_mark(&mut self, _: &actions::ExchangeMark, cx: &mut ViewContext<Self>) {
+        if self.selection_mark_mode {
+            self.change_selections(None, cx, |s| {
+                s.move_with(|_, sel| {
+                    if sel.start != sel.end {
+                        sel.reversed = !sel.reversed
+                    }
+                });
+            })
+        }
+        self.selection_mark_mode = true;
+        cx.notify();
+    }
+
     pub fn toggle_fold(&mut self, _: &actions::ToggleFold, cx: &mut ViewContext<Self>) {
         if self.is_singleton(cx) {
             let selection = self.selections.newest::<Point>(cx);
@@ -15234,7 +15259,6 @@ fn check_multiline_range(buffer: &Buffer, range: Range<usize>) -> Range<usize> {
         range.start..range.start
     }
 }
-
 pub struct KillRing(ClipboardItem);
 impl Global for KillRing {}
 

crates/editor/src/element.rs 🔗

@@ -356,6 +356,8 @@ impl EditorElement {
         register_action(view, cx, Editor::unfold_all);
         register_action(view, cx, Editor::unfold_at);
         register_action(view, cx, Editor::fold_selected_ranges);
+        register_action(view, cx, Editor::set_mark);
+        register_action(view, cx, Editor::exchange_mark);
         register_action(view, cx, Editor::show_completions);
         register_action(view, cx, Editor::toggle_code_actions);
         register_action(view, cx, Editor::open_excerpts);