Add a mode indicator for vim (#2763)

Conrad Irwin created

Release Notes:

- vim: add a mode indicator
([#409](https://github.com/zed-industries/community/issues/409))

Now updated screenshots with @iamnbutler 
<img width="1043" alt="Screenshot 2023-07-25 at 11 11 57"
src="https://github.com/zed-industries/zed/assets/94272/8301479a-8b58-42d8-81a1-bc40e1e0a4df">
<img width="1043" alt="Screenshot 2023-07-25 at 11 12 00"
src="https://github.com/zed-industries/zed/assets/94272/89c3b8bd-9cbc-4fd7-ad10-dac5538ed3a3">
<img width="1043" alt="Screenshot 2023-07-25 at 11 12 12"
src="https://github.com/zed-industries/zed/assets/94272/adc87fe3-a720-4779-853b-df9443407046">

Change summary

Cargo.lock                          |  2 +
crates/theme/src/theme.rs           |  1 
crates/vim/Cargo.toml               |  3 +
crates/vim/src/mode_indicator.rs    | 58 +++++++++++++++++++++++++++++++
crates/vim/src/test.rs              | 58 ++++++++++++++++++++++++++++++
crates/vim/src/vim.rs               | 47 +++++++++++++++++++++++++
crates/workspace/src/status_bar.rs  | 56 +++++++++++++++++++++++++++++
crates/zed/src/zed.rs               |  1 
styles/src/style_tree/status_bar.ts | 30 +++++++--------
9 files changed, 238 insertions(+), 18 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8533,6 +8533,7 @@ dependencies = [
  "indoc",
  "itertools",
  "language",
+ "language_selector",
  "log",
  "nvim-rs",
  "parking_lot 0.11.2",
@@ -8542,6 +8543,7 @@ dependencies = [
  "serde_derive",
  "serde_json",
  "settings",
+ "theme",
  "tokio",
  "util",
  "workspace",

crates/theme/src/theme.rs 🔗

@@ -402,6 +402,7 @@ pub struct StatusBar {
     pub height: f32,
     pub item_spacing: f32,
     pub cursor_position: TextStyle,
+    pub vim_mode_indicator: ContainedText,
     pub active_language: Interactive<ContainedText>,
     pub auto_update_progress_message: TextStyle,
     pub auto_update_done_message: TextStyle,

crates/vim/Cargo.toml 🔗

@@ -32,6 +32,8 @@ language = { path = "../language" }
 search = { path = "../search" }
 settings = { path = "../settings" }
 workspace = { path = "../workspace" }
+theme = { path = "../theme" }
+language_selector = { path = "../language_selector"}
 
 [dev-dependencies]
 indoc.workspace = true
@@ -44,3 +46,4 @@ project = { path = "../project", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 settings = { path = "../settings" }
 workspace = { path = "../workspace", features = ["test-support"] }
+theme = { path = "../theme", features = ["test-support"] }

crates/vim/src/mode_indicator.rs 🔗

@@ -0,0 +1,58 @@
+use gpui::{elements::Label, AnyElement, Element, Entity, View, ViewContext};
+use workspace::{item::ItemHandle, StatusItemView};
+
+use crate::state::Mode;
+
+pub struct ModeIndicator {
+    pub mode: Mode,
+}
+
+impl ModeIndicator {
+    pub fn new(mode: Mode) -> Self {
+        Self { mode }
+    }
+
+    pub fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext<Self>) {
+        if mode != self.mode {
+            self.mode = mode;
+            cx.notify();
+        }
+    }
+}
+
+impl Entity for ModeIndicator {
+    type Event = ();
+}
+
+impl View for ModeIndicator {
+    fn ui_name() -> &'static str {
+        "ModeIndicatorView"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let theme = &theme::current(cx).workspace.status_bar;
+        // we always choose text to be 12 monospace characters
+        // so that as the mode indicator changes, the rest of the
+        // UI stays still.
+        let text = match self.mode {
+            Mode::Normal => "-- NORMAL --",
+            Mode::Insert => "-- INSERT --",
+            Mode::Visual { line: false } => "-- VISUAL --",
+            Mode::Visual { line: true } => "VISUAL LINE ",
+        };
+        Label::new(text, theme.vim_mode_indicator.text.clone())
+            .contained()
+            .with_style(theme.vim_mode_indicator.container)
+            .into_any()
+    }
+}
+
+impl StatusItemView for ModeIndicator {
+    fn set_active_pane_item(
+        &mut self,
+        _active_pane_item: Option<&dyn ItemHandle>,
+        _cx: &mut ViewContext<Self>,
+    ) {
+        // nothing to do.
+    }
+}

crates/vim/src/test.rs 🔗

@@ -4,6 +4,8 @@ mod neovim_connection;
 mod vim_binding_test_context;
 mod vim_test_context;
 
+use std::sync::Arc;
+
 use command_palette::CommandPalette;
 use editor::DisplayPoint;
 pub use neovim_backed_binding_test_context::*;
@@ -14,7 +16,7 @@ pub use vim_test_context::*;
 use indoc::indoc;
 use search::BufferSearchBar;
 
-use crate::state::Mode;
+use crate::{state::Mode, ModeIndicator};
 
 #[gpui::test]
 async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
@@ -195,3 +197,57 @@ async fn test_selection_on_search(cx: &mut gpui::TestAppContext) {
     cx.simulate_keystrokes(["shift-n"]);
     cx.assert_state(indoc! {"aa\nbb\nˇcc\ncc\ncc\n"}, Mode::Normal);
 }
+
+#[gpui::test]
+async fn test_status_indicator(
+    cx: &mut gpui::TestAppContext,
+    deterministic: Arc<gpui::executor::Deterministic>,
+) {
+    let mut cx = VimTestContext::new(cx, true).await;
+    deterministic.run_until_parked();
+
+    let mode_indicator = cx.workspace(|workspace, cx| {
+        let status_bar = workspace.status_bar().read(cx);
+        let mode_indicator = status_bar.item_of_type::<ModeIndicator>();
+        assert!(mode_indicator.is_some());
+        mode_indicator.unwrap()
+    });
+
+    assert_eq!(
+        cx.workspace(|_, cx| mode_indicator.read(cx).mode),
+        Mode::Normal
+    );
+
+    // shows the correct mode
+    cx.simulate_keystrokes(["i"]);
+    deterministic.run_until_parked();
+    assert_eq!(
+        cx.workspace(|_, cx| mode_indicator.read(cx).mode),
+        Mode::Insert
+    );
+
+    // shows even in search
+    cx.simulate_keystrokes(["escape", "v", "/"]);
+    deterministic.run_until_parked();
+    assert_eq!(
+        cx.workspace(|_, cx| mode_indicator.read(cx).mode),
+        Mode::Visual { line: false }
+    );
+
+    // hides if vim mode is disabled
+    cx.disable_vim();
+    deterministic.run_until_parked();
+    cx.workspace(|workspace, cx| {
+        let status_bar = workspace.status_bar().read(cx);
+        let mode_indicator = status_bar.item_of_type::<ModeIndicator>();
+        assert!(mode_indicator.is_none());
+    });
+
+    cx.enable_vim();
+    deterministic.run_until_parked();
+    cx.workspace(|workspace, cx| {
+        let status_bar = workspace.status_bar().read(cx);
+        let mode_indicator = status_bar.item_of_type::<ModeIndicator>();
+        assert!(mode_indicator.is_some());
+    });
+}

crates/vim/src/vim.rs 🔗

@@ -3,6 +3,7 @@ mod test;
 
 mod editor_events;
 mod insert;
+mod mode_indicator;
 mod motion;
 mod normal;
 mod object;
@@ -18,6 +19,7 @@ use gpui::{
     Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
 use language::CursorShape;
+pub use mode_indicator::ModeIndicator;
 use motion::Motion;
 use normal::normal_replace;
 use serde::Deserialize;
@@ -119,6 +121,7 @@ pub fn observe_keystrokes(cx: &mut WindowContext) {
 pub struct Vim {
     active_editor: Option<WeakViewHandle<Editor>>,
     editor_subscription: Option<Subscription>,
+    mode_indicator: Option<ViewHandle<ModeIndicator>>,
 
     enabled: bool,
     state: VimState,
@@ -178,6 +181,10 @@ impl Vim {
         self.state.mode = mode;
         self.state.operator_stack.clear();
 
+        if let Some(mode_indicator) = &self.mode_indicator {
+            mode_indicator.update(cx, |mode_indicator, cx| mode_indicator.set_mode(mode, cx))
+        }
+
         // Sync editor settings like clip mode
         self.sync_vim_settings(cx);
 
@@ -264,6 +271,44 @@ impl Vim {
         }
     }
 
+    fn sync_mode_indicator(cx: &mut WindowContext) {
+        let Some(workspace) = cx.root_view()
+            .downcast_ref::<Workspace>()
+            .map(|workspace| workspace.downgrade()) else {
+                return;
+            };
+
+        cx.spawn(|mut cx| async move {
+            workspace.update(&mut cx, |workspace, cx| {
+                Vim::update(cx, |vim, cx| {
+                    workspace.status_bar().update(cx, |status_bar, cx| {
+                        let current_position = status_bar.position_of_item::<ModeIndicator>();
+
+                        if vim.enabled && current_position.is_none() {
+                            if vim.mode_indicator.is_none() {
+                                vim.mode_indicator =
+                                    Some(cx.add_view(|_| ModeIndicator::new(vim.state.mode)));
+                            };
+                            let mode_indicator = vim.mode_indicator.as_ref().unwrap();
+                            let position = status_bar
+                                .position_of_item::<language_selector::ActiveBufferLanguage>();
+                            if let Some(position) = position {
+                                status_bar.insert_item_after(position, mode_indicator.clone(), cx)
+                            } else {
+                                status_bar.add_left_item(mode_indicator.clone(), cx)
+                            }
+                        } else if !vim.enabled {
+                            if let Some(position) = current_position {
+                                status_bar.remove_item_at(position, cx)
+                            }
+                        }
+                    })
+                })
+            })
+        })
+        .detach_and_log_err(cx);
+    }
+
     fn set_enabled(&mut self, enabled: bool, cx: &mut AppContext) {
         if self.enabled != enabled {
             self.enabled = enabled;
@@ -314,6 +359,8 @@ impl Vim {
                 self.unhook_vim_settings(editor, cx);
             }
         });
+
+        Vim::sync_mode_indicator(cx);
     }
 
     fn unhook_vim_settings(&self, editor: &mut Editor, cx: &mut ViewContext<Editor>) {

crates/workspace/src/status_bar.rs 🔗

@@ -27,6 +27,7 @@ trait StatusItemViewHandle {
         active_pane_item: Option<&dyn ItemHandle>,
         cx: &mut WindowContext,
     );
+    fn ui_name(&self) -> &'static str;
 }
 
 pub struct StatusBar {
@@ -57,7 +58,6 @@ impl View for StatusBar {
                         .with_margin_right(theme.item_spacing)
                 }))
                 .into_any(),
-
             right: Flex::row()
                 .with_children(self.right_items.iter().rev().map(|i| {
                     ChildView::new(i.as_any(), cx)
@@ -96,6 +96,56 @@ impl StatusBar {
         cx.notify();
     }
 
+    pub fn item_of_type<T: StatusItemView>(&self) -> Option<ViewHandle<T>> {
+        self.left_items
+            .iter()
+            .chain(self.right_items.iter())
+            .find_map(|item| item.as_any().clone().downcast())
+    }
+
+    pub fn position_of_item<T>(&self) -> Option<usize>
+    where
+        T: StatusItemView,
+    {
+        for (index, item) in self.left_items.iter().enumerate() {
+            if item.as_ref().ui_name() == T::ui_name() {
+                return Some(index);
+            }
+        }
+        for (index, item) in self.right_items.iter().enumerate() {
+            if item.as_ref().ui_name() == T::ui_name() {
+                return Some(index + self.left_items.len());
+            }
+        }
+        return None;
+    }
+
+    pub fn insert_item_after<T>(
+        &mut self,
+        position: usize,
+        item: ViewHandle<T>,
+        cx: &mut ViewContext<Self>,
+    ) where
+        T: 'static + StatusItemView,
+    {
+        if position < self.left_items.len() {
+            self.left_items.insert(position + 1, Box::new(item))
+        } else {
+            self.right_items
+                .insert(position + 1 - self.left_items.len(), Box::new(item))
+        }
+        cx.notify()
+    }
+
+    pub fn remove_item_at(&mut self, position: usize, cx: &mut ViewContext<Self>) {
+        if position < self.left_items.len() {
+            self.left_items.remove(position);
+        } else {
+            self.right_items.remove(position - self.left_items.len());
+        }
+        cx.notify();
+    }
+
     pub fn add_right_item<T>(&mut self, item: ViewHandle<T>, cx: &mut ViewContext<Self>)
     where
         T: 'static + StatusItemView,
@@ -133,6 +183,10 @@ impl<T: StatusItemView> StatusItemViewHandle for ViewHandle<T> {
             this.set_active_pane_item(active_pane_item, cx)
         });
     }
+
+    fn ui_name(&self) -> &'static str {
+        T::ui_name()
+    }
 }
 
 impl From<&dyn StatusItemViewHandle> for AnyViewHandle {

crates/zed/src/zed.rs 🔗

@@ -315,6 +315,7 @@ pub fn initialize_workspace(
             workspace.status_bar().update(cx, |status_bar, cx| {
                 status_bar.add_left_item(diagnostic_summary, cx);
                 status_bar.add_left_item(activity_indicator, cx);
+
                 status_bar.add_right_item(feedback_button, cx);
                 status_bar.add_right_item(copilot, cx);
                 status_bar.add_right_item(active_buffer_language, cx);

styles/src/style_tree/status_bar.ts 🔗

@@ -1,6 +1,8 @@
 import { background, border, foreground, text } from "./components"
 import { interactive, toggleable } from "../element"
 import { useTheme } from "../common"
+import { text_button } from "../component/text_button"
+
 export default function status_bar(): any {
     const theme = useTheme()
 
@@ -26,20 +28,16 @@ export default function status_bar(): any {
             right: 6,
         },
         border: border(layer, { top: true, overlay: true }),
-        cursor_position: text(layer, "sans", "variant"),
-        active_language: interactive({
-            base: {
-                padding: { left: 6, right: 6 },
-                ...text(layer, "sans", "variant"),
-            },
-            state: {
-                hovered: {
-                    ...text(layer, "sans", "on"),
-                },
-            },
+        cursor_position: text(layer, "sans", "variant", { size: "xs" }),
+        vim_mode_indicator: {
+            margin: { left: 6 },
+            ...text(layer, "mono", "variant", { size: "xs" }),
+        },
+        active_language: text_button({
+            color: "variant"
         }),
-        auto_update_progress_message: text(layer, "sans", "variant"),
-        auto_update_done_message: text(layer, "sans", "variant"),
+        auto_update_progress_message: text(layer, "sans", "variant", { size: "xs" }),
+        auto_update_done_message: text(layer, "sans", "variant", { size: "xs" }),
         lsp_status: interactive({
             base: {
                 ...diagnostic_status_container,
@@ -59,9 +57,9 @@ export default function status_bar(): any {
         }),
         diagnostic_message: interactive({
             base: {
-                ...text(layer, "sans"),
+                ...text(layer, "sans", { size: "xs" }),
             },
-            state: { hovered: text(layer, "sans", "hovered") },
+            state: { hovered: text(layer, "sans", "hovered", { size: "xs" }) },
         }),
         diagnostic_summary: interactive({
             base: {
@@ -117,7 +115,7 @@ export default function status_bar(): any {
                         icon_color: foreground(layer, "variant"),
                         label: {
                             margin: { left: 6 },
-                            ...text(layer, "sans", { size: "sm" }),
+                            ...text(layer, "sans", { size: "xs" }),
                         },
                     },
                     state: {