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