diff --git a/Cargo.lock b/Cargo.lock index 3aa77e2ce1..1579251776 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 81ae7a65ca..4766f636f3 100644 --- a/crates/theme/src/theme.rs +++ b/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, pub auto_update_progress_message: TextStyle, pub auto_update_done_message: TextStyle, diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 47a85f4ed3..2d394e3dcf 100644 --- a/crates/vim/Cargo.toml +++ b/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"] } diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs new file mode 100644 index 0000000000..e0d2b65955 --- /dev/null +++ b/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) { + 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) -> AnyElement { + 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, + ) { + // nothing to do. + } +} diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 8ed649e61b..96d6a2b690 100644 --- a/crates/vim/src/test.rs +++ b/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, +) { + 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::(); + 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::(); + 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::(); + assert!(mode_indicator.is_some()); + }); +} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index e31fa4addd..d8edf1a667 100644 --- a/crates/vim/src/vim.rs +++ b/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>, editor_subscription: Option, + mode_indicator: Option>, 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::() + .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::(); + + 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::(); + 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) { diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 6fc1467566..8726eaf569 100644 --- a/crates/workspace/src/status_bar.rs +++ b/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(&self) -> Option> { + self.left_items + .iter() + .chain(self.right_items.iter()) + .find_map(|item| item.as_any().clone().downcast()) + } + + pub fn position_of_item(&self) -> Option + 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( + &mut self, + position: usize, + item: ViewHandle, + cx: &mut ViewContext, + ) 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) { + 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(&mut self, item: ViewHandle, cx: &mut ViewContext) where T: 'static + StatusItemView, @@ -133,6 +183,10 @@ impl StatusItemViewHandle for ViewHandle { this.set_active_pane_item(active_pane_item, cx) }); } + + fn ui_name(&self) -> &'static str { + T::ui_name() + } } impl From<&dyn StatusItemViewHandle> for AnyViewHandle { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index db7c57a89c..84cef99f81 100644 --- a/crates/zed/src/zed.rs +++ b/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); diff --git a/styles/src/style_tree/status_bar.ts b/styles/src/style_tree/status_bar.ts index 9aeea866f3..06afc37823 100644 --- a/styles/src/style_tree/status_bar.ts +++ b/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: {