Add initial vim mode mode switching

Keith Simmons and Nathan Sobo created

Co-authored-by: Nathan Sobo <nathan@zed.dev>

Change summary

Cargo.lock                       |  14 +++
crates/editor/src/editor.rs      |  43 ++++++++-
crates/gpui/src/app.rs           |  88 +++++++++++--------
crates/gpui/src/keymap.rs        |  14 ++-
crates/text/src/selection.rs     |  13 ++
crates/vim/Cargo.toml            |  23 +++++
crates/vim/src/editor_events.rs  |  57 ++++++++++++
crates/vim/src/editor_utils.rs   | 100 ++++++++++++++++++++++
crates/vim/src/insert.rs         |  28 ++++++
crates/vim/src/mode.rs           |  36 ++++++++
crates/vim/src/normal.rs         |  58 ++++++++++++
crates/vim/src/vim.rs            |  98 +++++++++++++++++++++
crates/vim/src/vim_tests.rs      | 152 ++++++++++++++++++++++++++++++++++
crates/workspace/src/settings.rs |   6 +
crates/zed/Cargo.toml            |   1 
crates/zed/src/main.rs           |   1 
16 files changed, 683 insertions(+), 49 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5673,6 +5673,19 @@ version = "0.9.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed"
 
+[[package]]
+name = "vim"
+version = "0.1.0"
+dependencies = [
+ "collections",
+ "editor",
+ "gpui",
+ "language",
+ "log",
+ "project",
+ "workspace",
+]
+
 [[package]]
 name = "waker-fn"
 version = "1.1.0"
@@ -6003,6 +6016,7 @@ dependencies = [
  "unindent",
  "url",
  "util",
+ "vim",
  "workspace",
 ]
 

crates/editor/src/editor.rs 🔗

@@ -456,6 +456,8 @@ pub struct Editor {
     pending_rename: Option<RenameState>,
     searchable: bool,
     cursor_shape: CursorShape,
+    keymap_context_layers: BTreeMap<TypeId, gpui::keymap::Context>,
+    input_enabled: bool,
     leader_replica_id: Option<u16>,
 }
 
@@ -932,6 +934,8 @@ impl Editor {
             searchable: true,
             override_text_style: None,
             cursor_shape: Default::default(),
+            keymap_context_layers: Default::default(),
+            input_enabled: true,
             leader_replica_id: None,
         };
         this.end_selection(cx);
@@ -1000,6 +1004,10 @@ impl Editor {
         )
     }
 
+    pub fn mode(&self) -> EditorMode {
+        self.mode
+    }
+
     pub fn set_placeholder_text(
         &mut self,
         placeholder_text: impl Into<Arc<str>>,
@@ -1063,6 +1071,19 @@ impl Editor {
         cx.notify();
     }
 
+    pub fn set_keymap_context_layer<Tag: 'static>(&mut self, context: gpui::keymap::Context) {
+        self.keymap_context_layers
+            .insert(TypeId::of::<Tag>(), context);
+    }
+
+    pub fn remove_keymap_context_layer<Tag: 'static>(&mut self) {
+        self.keymap_context_layers.remove(&TypeId::of::<Tag>());
+    }
+
+    pub fn set_input_enabled(&mut self, input_enabled: bool) {
+        self.input_enabled = input_enabled;
+    }
+
     pub fn scroll_position(&self, cx: &mut ViewContext<Self>) -> Vector2F {
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         compute_scroll_position(&display_map, self.scroll_position, &self.scroll_top_anchor)
@@ -1742,6 +1763,11 @@ impl Editor {
     }
 
     pub fn handle_input(&mut self, action: &Input, cx: &mut ViewContext<Self>) {
+        if !self.input_enabled {
+            cx.propagate_action();
+            return;
+        }
+
         let text = action.0.as_ref();
         if !self.skip_autoclose_end(text, cx) {
             self.transact(cx, |this, cx| {
@@ -5733,26 +5759,31 @@ impl View for Editor {
     }
 
     fn keymap_context(&self, _: &AppContext) -> gpui::keymap::Context {
-        let mut cx = Self::default_keymap_context();
+        let mut context = Self::default_keymap_context();
         let mode = match self.mode {
             EditorMode::SingleLine => "single_line",
             EditorMode::AutoHeight { .. } => "auto_height",
             EditorMode::Full => "full",
         };
-        cx.map.insert("mode".into(), mode.into());
+        context.map.insert("mode".into(), mode.into());
         if self.pending_rename.is_some() {
-            cx.set.insert("renaming".into());
+            context.set.insert("renaming".into());
         }
         match self.context_menu.as_ref() {
             Some(ContextMenu::Completions(_)) => {
-                cx.set.insert("showing_completions".into());
+                context.set.insert("showing_completions".into());
             }
             Some(ContextMenu::CodeActions(_)) => {
-                cx.set.insert("showing_code_actions".into());
+                context.set.insert("showing_code_actions".into());
             }
             None => {}
         }
-        cx
+
+        for layer in self.keymap_context_layers.values() {
+            context.extend(layer);
+        }
+
+        context
     }
 }
 

crates/gpui/src/app.rs 🔗

@@ -442,13 +442,32 @@ impl TestAppContext {
     }
 
     pub fn dispatch_keystroke(
-        &self,
+        &mut self,
         window_id: usize,
-        responder_chain: Vec<usize>,
-        keystroke: &Keystroke,
-    ) -> Result<bool> {
-        let mut state = self.cx.borrow_mut();
-        state.dispatch_keystroke(window_id, responder_chain, keystroke)
+        keystroke: Keystroke,
+        input: Option<String>,
+        is_held: bool,
+    ) {
+        self.cx.borrow_mut().update(|cx| {
+            let presenter = cx
+                .presenters_and_platform_windows
+                .get(&window_id)
+                .unwrap()
+                .0
+                .clone();
+            let responder_chain = presenter.borrow().dispatch_path(cx.as_ref());
+
+            if !cx.dispatch_keystroke(window_id, responder_chain, &keystroke) {
+                presenter.borrow_mut().dispatch_event(
+                    Event::KeyDown {
+                        keystroke,
+                        input,
+                        is_held,
+                    },
+                    cx,
+                );
+            }
+        });
     }
 
     pub fn add_model<T, F>(&mut self, build_model: F) -> ModelHandle<T>
@@ -503,7 +522,7 @@ impl TestAppContext {
 
     pub fn update<T, F: FnOnce(&mut MutableAppContext) -> T>(&mut self, callback: F) -> T {
         let mut state = self.cx.borrow_mut();
-        // Don't increment pending flushes in order to effects to be flushed before the callback
+        // Don't increment pending flushes in order for effects to be flushed before the callback
         // completes, which is helpful in tests.
         let result = callback(&mut *state);
         // Flush effects after the callback just in case there are any. This can happen in edge
@@ -1250,9 +1269,9 @@ impl MutableAppContext {
         }
     }
 
-    fn defer(&mut self, callback: Box<dyn FnOnce(&mut MutableAppContext)>) {
+    pub fn defer(&mut self, callback: impl 'static + FnOnce(&mut MutableAppContext)) {
         self.pending_effects.push_back(Effect::Deferred {
-            callback,
+            callback: Box::new(callback),
             after_window_update: false,
         })
     }
@@ -1379,17 +1398,15 @@ impl MutableAppContext {
         window_id: usize,
         responder_chain: Vec<usize>,
         keystroke: &Keystroke,
-    ) -> Result<bool> {
+    ) -> bool {
         let mut context_chain = Vec::new();
         for view_id in &responder_chain {
-            if let Some(view) = self.cx.views.get(&(window_id, *view_id)) {
-                context_chain.push(view.keymap_context(self.as_ref()));
-            } else {
-                return Err(anyhow!(
-                    "View {} in responder chain does not exist",
-                    view_id
-                ));
-            }
+            let view = self
+                .cx
+                .views
+                .get(&(window_id, *view_id))
+                .expect("view in responder chain does not exist");
+            context_chain.push(view.keymap_context(self.as_ref()));
         }
 
         let mut pending = false;
@@ -1404,13 +1421,13 @@ impl MutableAppContext {
                     if self.dispatch_action_any(window_id, &responder_chain[0..=i], action.as_ref())
                     {
                         self.keystroke_matcher.clear_pending();
-                        return Ok(true);
+                        return true;
                     }
                 }
             }
         }
 
-        Ok(pending)
+        pending
     }
 
     pub fn default_global<T: 'static + Default>(&mut self) -> &T {
@@ -1540,14 +1557,11 @@ impl MutableAppContext {
             window.on_event(Box::new(move |event| {
                 app.update(|cx| {
                     if let Event::KeyDown { keystroke, .. } = &event {
-                        if cx
-                            .dispatch_keystroke(
-                                window_id,
-                                presenter.borrow().dispatch_path(cx.as_ref()),
-                                keystroke,
-                            )
-                            .unwrap()
-                        {
+                        if cx.dispatch_keystroke(
+                            window_id,
+                            presenter.borrow().dispatch_path(cx.as_ref()),
+                            keystroke,
+                        ) {
                             return;
                         }
                     }
@@ -2711,11 +2725,11 @@ impl<'a, T: Entity> ModelContext<'a, T> {
 
     pub fn defer(&mut self, callback: impl 'static + FnOnce(&mut T, &mut ModelContext<T>)) {
         let handle = self.handle();
-        self.app.defer(Box::new(move |cx| {
+        self.app.defer(move |cx| {
             handle.update(cx, |model, cx| {
                 callback(model, cx);
             })
-        }))
+        })
     }
 
     pub fn emit(&mut self, payload: T::Event) {
@@ -3064,11 +3078,11 @@ impl<'a, T: View> ViewContext<'a, T> {
 
     pub fn defer(&mut self, callback: impl 'static + FnOnce(&mut T, &mut ViewContext<T>)) {
         let handle = self.handle();
-        self.app.defer(Box::new(move |cx| {
+        self.app.defer(move |cx| {
             handle.update(cx, |view, cx| {
                 callback(view, cx);
             })
-        }))
+        })
     }
 
     pub fn after_window_update(
@@ -3678,9 +3692,9 @@ impl<T: View> ViewHandle<T> {
         F: 'static + FnOnce(&mut T, &mut ViewContext<T>),
     {
         let this = self.clone();
-        cx.as_mut().defer(Box::new(move |cx| {
+        cx.as_mut().defer(move |cx| {
             this.update(cx, |view, cx| update(view, cx));
-        }));
+        });
     }
 
     pub fn is_focused(&self, cx: &AppContext) -> bool {
@@ -5921,8 +5935,7 @@ mod tests {
             window_id,
             vec![view_1.id(), view_2.id(), view_3.id()],
             &Keystroke::parse("a").unwrap(),
-        )
-        .unwrap();
+        );
 
         assert_eq!(&*actions.borrow(), &["2 a"]);
 
@@ -5931,8 +5944,7 @@ mod tests {
             window_id,
             vec![view_1.id(), view_2.id(), view_3.id()],
             &Keystroke::parse("b").unwrap(),
-        )
-        .unwrap();
+        );
 
         assert_eq!(&*actions.borrow(), &["3 b", "2 b", "1 b", "global b"]);
     }

crates/gpui/src/keymap.rs 🔗

@@ -224,15 +224,19 @@ impl Keystroke {
             key: key.unwrap(),
         })
     }
+
+    pub fn modified(&self) -> bool {
+        self.ctrl || self.alt || self.shift || self.cmd
+    }
 }
 
 impl Context {
-    pub fn extend(&mut self, other: Context) {
-        for v in other.set {
-            self.set.insert(v);
+    pub fn extend(&mut self, other: &Context) {
+        for v in &other.set {
+            self.set.insert(v.clone());
         }
-        for (k, v) in other.map {
-            self.map.insert(k, v);
+        for (k, v) in &other.map {
+            self.map.insert(k.clone(), v.clone());
         }
     }
 }

crates/text/src/selection.rs 🔗

@@ -40,6 +40,19 @@ impl<T: Clone> Selection<T> {
             self.start.clone()
         }
     }
+
+    pub fn map<F, S>(&self, f: F) -> Selection<S>
+    where
+        F: Fn(T) -> S,
+    {
+        Selection::<S> {
+            id: self.id,
+            start: f(self.start.clone()),
+            end: f(self.end.clone()),
+            reversed: self.reversed,
+            goal: self.goal,
+        }
+    }
 }
 
 impl<T: Copy + Ord> Selection<T> {

crates/vim/Cargo.toml 🔗

@@ -0,0 +1,23 @@
+[package]
+name = "vim"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/vim.rs"
+doctest = false
+
+[dependencies]
+collections = { path = "../collections" }
+editor = { path = "../editor" }
+gpui = { path = "../gpui" }
+language = { path = "../language" }
+workspace = { path = "../workspace" }
+log = "0.4"
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"] }
+project = { path = "../project", features = ["test-support"] }
+language = { path = "../language", features = ["test-support"] }
+workspace = { path = "../workspace", features = ["test-support"] }

crates/vim/src/editor_events.rs 🔗

@@ -0,0 +1,57 @@
+use editor::{EditorBlurred, EditorCreated, EditorFocused, EditorMode, EditorReleased};
+use gpui::MutableAppContext;
+
+use crate::{mode::Mode, SwitchMode, VimState};
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.subscribe_global(editor_created).detach();
+    cx.subscribe_global(editor_focused).detach();
+    cx.subscribe_global(editor_blurred).detach();
+    cx.subscribe_global(editor_released).detach();
+}
+
+fn editor_created(EditorCreated(editor): &EditorCreated, cx: &mut MutableAppContext) {
+    cx.update_default_global(|vim_state: &mut VimState, cx| {
+        vim_state.editors.insert(editor.id(), editor.downgrade());
+        if vim_state.enabled {
+            VimState::update_cursor_shapes(cx);
+        }
+    })
+}
+
+fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppContext) {
+    let mode = if matches!(editor.read(cx).mode(), EditorMode::SingleLine) {
+        Mode::Insert
+    } else {
+        Mode::Normal
+    };
+
+    cx.update_default_global(|vim_state: &mut VimState, _| {
+        vim_state.active_editor = Some(editor.downgrade());
+    });
+    VimState::switch_mode(&SwitchMode(mode), cx);
+}
+
+fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppContext) {
+    cx.update_default_global(|vim_state: &mut VimState, _| {
+        if let Some(previous_editor) = vim_state.active_editor.clone() {
+            if previous_editor == editor.clone() {
+                vim_state.active_editor = None;
+            }
+        }
+    });
+    editor.update(cx, |editor, _| {
+        editor.remove_keymap_context_layer::<VimState>();
+    })
+}
+
+fn editor_released(EditorReleased(editor): &EditorReleased, cx: &mut MutableAppContext) {
+    cx.update_default_global(|vim_state: &mut VimState, _| {
+        vim_state.editors.remove(&editor.id());
+        if let Some(previous_editor) = vim_state.active_editor.clone() {
+            if previous_editor == editor.clone() {
+                vim_state.active_editor = None;
+            }
+        }
+    });
+}

crates/vim/src/editor_utils.rs 🔗

@@ -0,0 +1,100 @@
+use editor::{display_map::DisplaySnapshot, Bias, DisplayPoint, Editor};
+use gpui::ViewContext;
+use language::{Selection, SelectionGoal};
+
+pub trait VimEditorExt {
+    fn adjust_selections(self: &mut Self, cx: &mut ViewContext<Self>);
+    fn adjusted_move_selections(
+        self: &mut Self,
+        cx: &mut ViewContext<Self>,
+        move_selection: impl Fn(&DisplaySnapshot, &mut Selection<DisplayPoint>),
+    );
+    fn adjusted_move_selection_heads(
+        &mut self,
+        cx: &mut ViewContext<Self>,
+        update_head: impl Fn(
+            &DisplaySnapshot,
+            DisplayPoint,
+            SelectionGoal,
+        ) -> (DisplayPoint, SelectionGoal),
+    );
+    fn adjusted_move_cursors(
+        self: &mut Self,
+        cx: &mut ViewContext<Self>,
+        update_cursor_position: impl Fn(
+            &DisplaySnapshot,
+            DisplayPoint,
+            SelectionGoal,
+        ) -> (DisplayPoint, SelectionGoal),
+    );
+}
+
+pub fn adjust_display_point(
+    map: &DisplaySnapshot,
+    mut display_point: DisplayPoint,
+) -> DisplayPoint {
+    let next_char = map.chars_at(display_point).next();
+    if next_char == Some('\n') || next_char == None {
+        *display_point.column_mut() = display_point.column().saturating_sub(1);
+        display_point = map.clip_point(display_point, Bias::Left);
+    }
+    display_point
+}
+
+impl VimEditorExt for Editor {
+    fn adjust_selections(self: &mut Self, cx: &mut ViewContext<Self>) {
+        self.move_selections(cx, |map, selection| {
+            if selection.is_empty() {
+                let adjusted_cursor = adjust_display_point(map, selection.start);
+                selection.collapse_to(adjusted_cursor, selection.goal);
+            } else {
+                let adjusted_head = adjust_display_point(map, selection.head());
+                selection.set_head(adjusted_head, selection.goal);
+            }
+        })
+    }
+
+    fn adjusted_move_selections(
+        self: &mut Self,
+        cx: &mut ViewContext<Self>,
+        move_selection: impl Fn(&DisplaySnapshot, &mut Selection<DisplayPoint>),
+    ) {
+        self.move_selections(cx, |map, selection| {
+            move_selection(map, selection);
+            let adjusted_head = adjust_display_point(map, selection.head());
+            selection.set_head(adjusted_head, selection.goal);
+        })
+    }
+
+    fn adjusted_move_selection_heads(
+        &mut self,
+        cx: &mut ViewContext<Self>,
+        update_head: impl Fn(
+            &DisplaySnapshot,
+            DisplayPoint,
+            SelectionGoal,
+        ) -> (DisplayPoint, SelectionGoal),
+    ) {
+        self.adjusted_move_selections(cx, |map, selection| {
+            let (new_head, new_goal) = update_head(map, selection.head(), selection.goal);
+            let adjusted_head = adjust_display_point(map, new_head);
+            selection.set_head(adjusted_head, new_goal);
+        });
+    }
+
+    fn adjusted_move_cursors(
+        self: &mut Self,
+        cx: &mut ViewContext<Self>,
+        update_cursor_position: impl Fn(
+            &DisplaySnapshot,
+            DisplayPoint,
+            SelectionGoal,
+        ) -> (DisplayPoint, SelectionGoal),
+    ) {
+        self.move_selections(cx, |map, selection| {
+            let (cursor, new_goal) = update_cursor_position(map, selection.head(), selection.goal);
+            let adjusted_cursor = adjust_display_point(map, cursor);
+            selection.collapse_to(adjusted_cursor, new_goal);
+        });
+    }
+}

crates/vim/src/insert.rs 🔗

@@ -0,0 +1,28 @@
+use editor::Bias;
+use gpui::{action, keymap::Binding, MutableAppContext, ViewContext};
+use language::SelectionGoal;
+use workspace::Workspace;
+
+use crate::{editor_utils::VimEditorExt, mode::Mode, SwitchMode, VimState};
+
+action!(NormalBefore);
+
+pub fn init(cx: &mut MutableAppContext) {
+    let context = Some("Editor && vim_mode == insert");
+    cx.add_bindings(vec![
+        Binding::new("escape", NormalBefore, context),
+        Binding::new("ctrl-c", NormalBefore, context),
+    ]);
+
+    cx.add_action(normal_before);
+}
+
+fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Workspace>) {
+    VimState::switch_mode(&SwitchMode(Mode::Normal), cx);
+    VimState::update_active_editor(cx, |editor, cx| {
+        editor.adjusted_move_cursors(cx, |map, mut cursor, _| {
+            *cursor.column_mut() = cursor.column().saturating_sub(1);
+            (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
+        });
+    });
+}

crates/vim/src/mode.rs 🔗

@@ -0,0 +1,36 @@
+use editor::CursorShape;
+use gpui::keymap::Context;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Mode {
+    Normal,
+    Insert,
+}
+
+impl Mode {
+    pub fn cursor_shape(&self) -> CursorShape {
+        match self {
+            Mode::Normal => CursorShape::Block,
+            Mode::Insert => CursorShape::Bar,
+        }
+    }
+
+    pub fn keymap_context_layer(&self) -> Context {
+        let mut context = Context::default();
+        context.map.insert(
+            "vim_mode".to_string(),
+            match self {
+                Self::Normal => "normal",
+                Self::Insert => "insert",
+            }
+            .to_string(),
+        );
+        context
+    }
+}
+
+impl Default for Mode {
+    fn default() -> Self {
+        Self::Normal
+    }
+}

crates/vim/src/normal.rs 🔗

@@ -0,0 +1,58 @@
+use editor::{movement, Bias};
+use gpui::{action, keymap::Binding, MutableAppContext, ViewContext};
+use language::SelectionGoal;
+use workspace::Workspace;
+
+use crate::{editor_utils::VimEditorExt, Mode, SwitchMode, VimState};
+
+action!(InsertBefore);
+action!(MoveLeft);
+action!(MoveDown);
+action!(MoveUp);
+action!(MoveRight);
+
+pub fn init(cx: &mut MutableAppContext) {
+    let context = Some("Editor && vim_mode == normal");
+    cx.add_bindings(vec![
+        Binding::new("i", SwitchMode(Mode::Insert), context),
+        Binding::new("h", MoveLeft, context),
+        Binding::new("j", MoveDown, context),
+        Binding::new("k", MoveUp, context),
+        Binding::new("l", MoveRight, context),
+    ]);
+
+    cx.add_action(move_left);
+    cx.add_action(move_down);
+    cx.add_action(move_up);
+    cx.add_action(move_right);
+}
+
+fn move_left(_: &mut Workspace, _: &MoveLeft, cx: &mut ViewContext<Workspace>) {
+    VimState::update_active_editor(cx, |editor, cx| {
+        editor.adjusted_move_cursors(cx, |map, mut cursor, _| {
+            *cursor.column_mut() = cursor.column().saturating_sub(1);
+            (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
+        });
+    });
+}
+
+fn move_down(_: &mut Workspace, _: &MoveDown, cx: &mut ViewContext<Workspace>) {
+    VimState::update_active_editor(cx, |editor, cx| {
+        editor.adjusted_move_cursors(cx, movement::down);
+    });
+}
+
+fn move_up(_: &mut Workspace, _: &MoveUp, cx: &mut ViewContext<Workspace>) {
+    VimState::update_active_editor(cx, |editor, cx| {
+        editor.adjusted_move_cursors(cx, movement::up);
+    });
+}
+
+fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext<Workspace>) {
+    VimState::update_active_editor(cx, |editor, cx| {
+        editor.adjusted_move_cursors(cx, |map, mut cursor, _| {
+            *cursor.column_mut() += 1;
+            (map.clip_point(cursor, Bias::Right), SelectionGoal::None)
+        });
+    });
+}

crates/vim/src/vim.rs 🔗

@@ -0,0 +1,98 @@
+mod editor_events;
+mod editor_utils;
+mod insert;
+mod mode;
+mod normal;
+#[cfg(test)]
+mod vim_tests;
+
+use collections::HashMap;
+use editor::Editor;
+use editor_utils::VimEditorExt;
+use gpui::{action, MutableAppContext, ViewContext, WeakViewHandle};
+
+use mode::Mode;
+use workspace::{self, Settings, Workspace};
+
+action!(SwitchMode, Mode);
+
+pub fn init(cx: &mut MutableAppContext) {
+    editor_events::init(cx);
+    insert::init(cx);
+    normal::init(cx);
+
+    cx.add_action(|_: &mut Workspace, action: &SwitchMode, cx| VimState::switch_mode(action, cx));
+
+    cx.observe_global::<Settings, _>(VimState::settings_changed)
+        .detach();
+}
+
+#[derive(Default)]
+pub struct VimState {
+    editors: HashMap<usize, WeakViewHandle<Editor>>,
+    active_editor: Option<WeakViewHandle<Editor>>,
+
+    enabled: bool,
+    mode: Mode,
+}
+
+impl VimState {
+    fn update_active_editor<S>(
+        cx: &mut MutableAppContext,
+        update: impl FnOnce(&mut Editor, &mut ViewContext<Editor>) -> S,
+    ) -> Option<S> {
+        cx.global::<Self>()
+            .active_editor
+            .clone()
+            .and_then(|ae| ae.upgrade(cx))
+            .map(|ae| ae.update(cx, update))
+    }
+
+    fn switch_mode(SwitchMode(mode): &SwitchMode, cx: &mut MutableAppContext) {
+        let active_editor = cx.update_default_global(|this: &mut Self, _| {
+            this.mode = *mode;
+            this.active_editor.clone()
+        });
+
+        if let Some(active_editor) = active_editor.and_then(|e| e.upgrade(cx)) {
+            active_editor.update(cx, |active_editor, cx| {
+                active_editor.set_keymap_context_layer::<Self>(mode.keymap_context_layer());
+                active_editor.set_input_enabled(*mode == Mode::Insert);
+                if *mode != Mode::Insert {
+                    active_editor.adjust_selections(cx);
+                }
+            });
+        }
+        VimState::update_cursor_shapes(cx);
+    }
+
+    fn settings_changed(cx: &mut MutableAppContext) {
+        cx.update_default_global(|this: &mut Self, cx| {
+            let settings = cx.global::<Settings>();
+            if this.enabled != settings.vim_mode {
+                this.enabled = settings.vim_mode;
+                this.mode = if settings.vim_mode {
+                    Mode::Normal
+                } else {
+                    Mode::Insert
+                };
+                Self::update_cursor_shapes(cx);
+            }
+        });
+    }
+
+    fn update_cursor_shapes(cx: &mut MutableAppContext) {
+        cx.defer(move |cx| {
+            cx.update_default_global(|this: &mut VimState, cx| {
+                let cursor_shape = this.mode.cursor_shape();
+                for editor in this.editors.values() {
+                    if let Some(editor) = editor.upgrade(cx) {
+                        editor.update(cx, |editor, cx| {
+                            editor.set_cursor_shape(cursor_shape, cx);
+                        });
+                    }
+                }
+            });
+        });
+    }
+}

crates/vim/src/vim_tests.rs 🔗

@@ -0,0 +1,152 @@
+use std::ops::Deref;
+
+use editor::{display_map::ToDisplayPoint, DisplayPoint};
+use gpui::{json::json, keymap::Keystroke, AppContext, ViewHandle};
+use language::{Point, Selection};
+use workspace::{WorkspaceHandle, WorkspaceParams};
+
+use crate::*;
+
+#[gpui::test]
+async fn test_insert_mode(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestAppContext::new(cx, "").await;
+    assert_eq!(cx.mode(), Mode::Normal);
+    cx.simulate_keystroke("i");
+    assert_eq!(cx.mode(), Mode::Insert);
+    cx.simulate_keystrokes(&["T", "e", "s", "t"]);
+    assert_eq!(cx.editor_text(), "Test".to_owned());
+    cx.simulate_keystroke("escape");
+    assert_eq!(cx.mode(), Mode::Normal);
+}
+
+#[gpui::test]
+async fn test_normal_hjkl(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestAppContext::new(cx, "Test\nTestTest\nTest").await;
+    assert_eq!(cx.mode(), Mode::Normal);
+    cx.simulate_keystroke("l");
+    assert_eq!(cx.newest_selection().head(), DisplayPoint::new(0, 1));
+    cx.simulate_keystroke("h");
+    assert_eq!(cx.newest_selection().head(), DisplayPoint::new(0, 0));
+    cx.simulate_keystroke("j");
+    assert_eq!(cx.newest_selection().head(), DisplayPoint::new(1, 0));
+    cx.simulate_keystroke("k");
+    assert_eq!(cx.newest_selection().head(), DisplayPoint::new(0, 0));
+
+    cx.simulate_keystroke("j");
+    assert_eq!(cx.newest_selection().head(), DisplayPoint::new(1, 0));
+
+    // When moving left, cursor does not wrap to the previous line
+    cx.simulate_keystroke("h");
+    assert_eq!(cx.newest_selection().head(), DisplayPoint::new(1, 0));
+
+    // When moving right, cursor does not reach the line end or wrap to the next line
+    for _ in 0..9 {
+        cx.simulate_keystroke("l");
+    }
+    assert_eq!(cx.newest_selection().head(), DisplayPoint::new(1, 7));
+
+    // Goal column respects the inability to reach the end of the line
+    cx.simulate_keystroke("k");
+    assert_eq!(cx.newest_selection().head(), DisplayPoint::new(0, 3));
+    cx.simulate_keystroke("j");
+    assert_eq!(cx.newest_selection().head(), DisplayPoint::new(1, 7));
+}
+
+struct VimTestAppContext<'a> {
+    cx: &'a mut gpui::TestAppContext,
+    window_id: usize,
+    editor: ViewHandle<Editor>,
+}
+
+impl<'a> VimTestAppContext<'a> {
+    async fn new(
+        cx: &'a mut gpui::TestAppContext,
+        initial_editor_text: &str,
+    ) -> VimTestAppContext<'a> {
+        cx.update(|cx| {
+            editor::init(cx);
+            crate::init(cx);
+        });
+        let params = cx.update(WorkspaceParams::test);
+        params
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/root",
+                json!({ "dir": { "test.txt": initial_editor_text } }),
+            )
+            .await;
+
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
+        params
+            .project
+            .update(cx, |project, cx| {
+                project.find_or_create_local_worktree("/root", true, cx)
+            })
+            .await
+            .unwrap();
+        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
+            .await;
+
+        let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
+        let item = workspace
+            .update(cx, |workspace, cx| workspace.open_path(file, cx))
+            .await
+            .expect("Could not open test file");
+
+        let editor = cx.update(|cx| {
+            item.act_as::<Editor>(cx)
+                .expect("Opened test file wasn't an editor")
+        });
+        editor.update(cx, |_, cx| cx.focus_self());
+
+        Self {
+            cx,
+            window_id,
+            editor,
+        }
+    }
+
+    fn newest_selection(&mut self) -> Selection<DisplayPoint> {
+        self.editor.update(self.cx, |editor, cx| {
+            let snapshot = editor.snapshot(cx);
+            editor
+                .newest_selection::<Point>(cx)
+                .map(|point| point.to_display_point(&snapshot.display_snapshot))
+        })
+    }
+
+    fn mode(&mut self) -> Mode {
+        self.cx.update(|cx| cx.global::<VimState>().mode)
+    }
+
+    fn editor_text(&mut self) -> String {
+        self.editor
+            .update(self.cx, |editor, cx| editor.snapshot(cx).text())
+    }
+
+    fn simulate_keystroke(&mut self, keystroke_text: &str) {
+        let keystroke = Keystroke::parse(keystroke_text).unwrap();
+        let input = if keystroke.modified() {
+            None
+        } else {
+            Some(keystroke.key.clone())
+        };
+        self.cx
+            .dispatch_keystroke(self.window_id, keystroke, input, false);
+    }
+
+    fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) {
+        for keystroke_text in keystroke_texts.into_iter() {
+            self.simulate_keystroke(keystroke_text);
+        }
+    }
+}
+
+impl<'a> Deref for VimTestAppContext<'a> {
+    type Target = gpui::TestAppContext;
+
+    fn deref(&self) -> &Self::Target {
+        self.cx
+    }
+}

crates/workspace/src/settings.rs 🔗

@@ -17,6 +17,7 @@ use util::ResultExt;
 pub struct Settings {
     pub buffer_font_family: FamilyId,
     pub buffer_font_size: f32,
+    pub vim_mode: bool,
     pub tab_size: usize,
     pub soft_wrap: SoftWrap,
     pub preferred_line_length: u32,
@@ -48,6 +49,8 @@ struct SettingsFileContent {
     buffer_font_family: Option<String>,
     #[serde(default)]
     buffer_font_size: Option<f32>,
+    #[serde(default)]
+    vim_mode: Option<bool>,
     #[serde(flatten)]
     editor: LanguageOverride,
     #[serde(default)]
@@ -130,6 +133,7 @@ impl Settings {
         Ok(Self {
             buffer_font_family: font_cache.load_family(&[buffer_font_family])?,
             buffer_font_size: 15.,
+            vim_mode: false,
             tab_size: 4,
             soft_wrap: SoftWrap::None,
             preferred_line_length: 80,
@@ -174,6 +178,7 @@ impl Settings {
         Settings {
             buffer_font_family: cx.font_cache().load_family(&["Monaco"]).unwrap(),
             buffer_font_size: 14.,
+            vim_mode: false,
             tab_size: 4,
             soft_wrap: SoftWrap::None,
             preferred_line_length: 80,
@@ -200,6 +205,7 @@ impl Settings {
         }
 
         merge(&mut self.buffer_font_size, data.buffer_font_size);
+        merge(&mut self.vim_mode, data.vim_mode);
         merge(&mut self.soft_wrap, data.editor.soft_wrap);
         merge(&mut self.tab_size, data.editor.tab_size);
         merge(

crates/zed/Cargo.toml 🔗

@@ -55,6 +55,7 @@ text = { path = "../text" }
 theme = { path = "../theme" }
 theme_selector = { path = "../theme_selector" }
 util = { path = "../util" }
+vim = { path = "../vim" }
 workspace = { path = "../workspace" }
 anyhow = "1.0.38"
 async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] }

crates/zed/src/main.rs 🔗

@@ -78,6 +78,7 @@ fn main() {
         project_panel::init(cx);
         diagnostics::init(cx);
         search::init(cx);
+        vim::init(cx);
         cx.spawn({
             let client = client.clone();
             |cx| async move {