Detailed changes
@@ -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",
@@ -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,
@@ -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"] }
@@ -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.
+ }
+}
@@ -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());
+ });
+}
@@ -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>) {
@@ -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 {
@@ -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);
@@ -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: {