diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index f8273286df0f52afae8a6eae6c004f102b455452..312416f42cd114ca1e2e313b7c9c1a4d8e4e62e3 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -1,58 +1,93 @@ { - "Editor && vim_mode == insert": { - "escape": "vim::NormalBefore", - "ctrl-c": "vim::NormalBefore" - }, - "Editor && vim_mode == normal && vim_submode == g": { - "g": "vim::MoveToStart", - "escape": [ - "vim::SwitchMode", - { - "Normal": "None" - } - ] - }, - "Editor && vim_mode == normal": { + "Editor && VimControl": { "i": [ "vim::SwitchMode", "Insert" ], "g": [ - "vim::SwitchMode", + "vim::PushOperator", { - "Normal": "GPrefix" + "Namespace": "G" } ], - "h": "vim::MoveLeft", - "j": "vim::MoveDown", - "k": "vim::MoveUp", - "l": "vim::MoveRight", - "0": "vim::MoveToStartOfLine", - "shift-$": "vim::MoveToEndOfLine", - "shift-G": "vim::MoveToEnd", - "w": [ - "vim::MoveToNextWordStart", - false - ], + "h": "vim::Left", + "j": "vim::Down", + "k": "vim::Up", + "l": "vim::Right", + "0": "vim::StartOfLine", + "shift-$": "vim::EndOfLine", + "shift-G": "vim::EndOfDocument", + "w": "vim::NextWordStart", "shift-W": [ - "vim::MoveToNextWordStart", - true - ], - "e": [ - "vim::MoveToNextWordEnd", - false + "vim::NextWordStart", + { + "ignorePunctuation": true + } ], + "e": "vim::NextWordEnd", "shift-E": [ - "vim::MoveToNextWordEnd", - true - ], - "b": [ - "vim::MoveToPreviousWordStart", - false + "vim::NextWordEnd", + { + "ignorePunctuation": true + } ], + "b": "vim::PreviousWordStart", "shift-B": [ - "vim::MoveToPreviousWordStart", - true + "vim::PreviousWordStart", + { + "ignorePunctuation": true + } + ], + "escape": [ + "vim::SwitchMode", + "Normal" + ] + }, + "Editor && vim_operator == g": { + "g": "vim::StartOfDocument" + }, + "Editor && vim_mode == insert": { + "escape": "vim::NormalBefore", + "ctrl-c": "vim::NormalBefore" + }, + "Editor && vim_mode == normal": { + "c": [ + "vim::PushOperator", + "Change" + ], + "d": [ + "vim::PushOperator", + "Delete" + ] + }, + "Editor && vim_operator == c": { + "w": [ + "vim::NextWordEnd", + { + "ignorePunctuation": false + } + ], + "shift-W": [ + "vim::NextWordEnd", + { + "ignorePunctuation": true + } + ] + }, + "Editor && vim_operator == d": { + "w": [ + "vim::NextWordStart", + { + "ignorePunctuation": false, + "stopAtNewline": true + } + ], + "shift-W": [ + "vim::NextWordStart", + { + "ignorePunctuation": true, + "stopAtNewline": true + } ] } } \ No newline at end of file diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index e4035197fd8036922cc4fa1cc5006f15948bb948..12b90ec8e6213c12e7cfecacb1c3c77635457c7f 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -1,7 +1,7 @@ use editor::{EditorBlurred, EditorCreated, EditorFocused, EditorMode, EditorReleased}; use gpui::MutableAppContext; -use crate::{mode::Mode, SwitchMode, VimState}; +use crate::{state::Mode, Vim}; pub fn init(cx: &mut MutableAppContext) { cx.subscribe_global(editor_created).detach(); @@ -11,9 +11,9 @@ pub fn init(cx: &mut MutableAppContext) { } 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()); - vim_state.sync_editor_options(cx); + cx.update_default_global(|vim: &mut Vim, cx| { + vim.editors.insert(editor.id(), editor.downgrade()); + vim.sync_editor_options(cx); }) } @@ -21,17 +21,17 @@ fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppCont let mode = if matches!(editor.read(cx).mode(), EditorMode::SingleLine) { Mode::Insert } else { - Mode::normal() + Mode::Normal }; - VimState::update_global(cx, |state, cx| { + Vim::update(cx, |state, cx| { state.active_editor = Some(editor.downgrade()); - state.switch_mode(&SwitchMode(mode), cx); + state.switch_mode(mode, cx); }); } fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppContext) { - VimState::update_global(cx, |state, cx| { + Vim::update(cx, |state, cx| { if let Some(previous_editor) = state.active_editor.clone() { if previous_editor == editor.clone() { state.active_editor = None; @@ -42,11 +42,11 @@ fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppCont } 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() { + cx.update_default_global(|vim: &mut Vim, _| { + vim.editors.remove(&editor.id()); + if let Some(previous_editor) = vim.active_editor.clone() { if previous_editor == editor.clone() { - vim_state.active_editor = None; + vim.active_editor = None; } } }); diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 597832f95ad2d48241645df6ca4b4bcec0631996..c98c9db8411e13a2d3c57a196dc9bc17d89f6135 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -1,4 +1,4 @@ -use crate::{mode::Mode, SwitchMode, VimState}; +use crate::{state::Mode, Vim}; use editor::Bias; use gpui::{actions, MutableAppContext, ViewContext}; use language::SelectionGoal; @@ -11,30 +11,30 @@ pub fn init(cx: &mut MutableAppContext) { } fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext) { - VimState::update_global(cx, |state, cx| { + Vim::update(cx, |state, cx| { state.update_active_editor(cx, |editor, cx| { editor.move_cursors(cx, |map, mut cursor, _| { *cursor.column_mut() = cursor.column().saturating_sub(1); (map.clip_point(cursor, Bias::Left), SelectionGoal::None) }); }); - state.switch_mode(&SwitchMode(Mode::normal()), cx); + state.switch_mode(Mode::Normal, cx); }) } #[cfg(test)] mod test { - use crate::{mode::Mode, vim_test_context::VimTestContext}; + use crate::{state::Mode, vim_test_context::VimTestContext}; #[gpui::test] async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true, "").await; cx.simulate_keystroke("i"); assert_eq!(cx.mode(), Mode::Insert); - cx.simulate_keystrokes(&["T", "e", "s", "t"]); + cx.simulate_keystrokes(["T", "e", "s", "t"]); cx.assert_editor_state("Test|"); cx.simulate_keystroke("escape"); - assert_eq!(cx.mode(), Mode::normal()); + assert_eq!(cx.mode(), Mode::Normal); cx.assert_editor_state("Tes|t"); } } diff --git a/crates/vim/src/mode.rs b/crates/vim/src/mode.rs deleted file mode 100644 index ccebf0ad68ed7cad0f8d0c4828a5610fe8dbfd0f..0000000000000000000000000000000000000000 --- a/crates/vim/src/mode.rs +++ /dev/null @@ -1,73 +0,0 @@ -use editor::CursorShape; -use gpui::keymap::Context; -use serde::Deserialize; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] -pub enum Mode { - Normal(NormalState), - 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(), - ); - - match self { - Self::Normal(normal_state) => normal_state.set_context(&mut context), - _ => {} - } - context - } - - pub fn normal() -> Mode { - Mode::Normal(Default::default()) - } -} - -impl Default for Mode { - fn default() -> Self { - Self::Normal(Default::default()) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] -pub enum NormalState { - None, - GPrefix, -} - -impl NormalState { - pub fn set_context(&self, context: &mut Context) { - let submode = match self { - Self::GPrefix => Some("g"), - _ => None, - }; - - if let Some(submode) = submode { - context - .map - .insert("vim_submode".to_string(), submode.to_string()); - } - } -} - -impl Default for NormalState { - fn default() -> Self { - NormalState::None - } -} diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs new file mode 100644 index 0000000000000000000000000000000000000000..beea48ef886b93a662c1a155b37631fc65729777 --- /dev/null +++ b/crates/vim/src/motion.rs @@ -0,0 +1,296 @@ +use editor::{ + char_kind, + display_map::{DisplaySnapshot, ToDisplayPoint}, + movement, Bias, DisplayPoint, +}; +use gpui::{actions, impl_actions, MutableAppContext}; +use language::{Selection, SelectionGoal}; +use serde::Deserialize; +use workspace::Workspace; + +use crate::{ + normal::normal_motion, + state::{Mode, Operator}, + Vim, +}; + +#[derive(Copy, Clone)] +pub enum Motion { + Left, + Down, + Up, + Right, + NextWordStart { + ignore_punctuation: bool, + stop_at_newline: bool, + }, + NextWordEnd { + ignore_punctuation: bool, + }, + PreviousWordStart { + ignore_punctuation: bool, + }, + StartOfLine, + EndOfLine, + StartOfDocument, + EndOfDocument, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NextWordStart { + #[serde(default)] + ignore_punctuation: bool, + #[serde(default)] + stop_at_newline: bool, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NextWordEnd { + #[serde(default)] + ignore_punctuation: bool, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PreviousWordStart { + #[serde(default)] + ignore_punctuation: bool, +} + +actions!( + vim, + [ + Left, + Down, + Up, + Right, + StartOfLine, + EndOfLine, + StartOfDocument, + EndOfDocument + ] +); +impl_actions!(vim, [NextWordStart, NextWordEnd, PreviousWordStart]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(|_: &mut Workspace, _: &Left, cx: _| motion(Motion::Left, cx)); + cx.add_action(|_: &mut Workspace, _: &Down, cx: _| motion(Motion::Down, cx)); + cx.add_action(|_: &mut Workspace, _: &Up, cx: _| motion(Motion::Up, cx)); + cx.add_action(|_: &mut Workspace, _: &Right, cx: _| motion(Motion::Right, cx)); + cx.add_action(|_: &mut Workspace, _: &StartOfLine, cx: _| motion(Motion::StartOfLine, cx)); + cx.add_action(|_: &mut Workspace, _: &EndOfLine, cx: _| motion(Motion::EndOfLine, cx)); + cx.add_action(|_: &mut Workspace, _: &StartOfDocument, cx: _| { + motion(Motion::StartOfDocument, cx) + }); + cx.add_action(|_: &mut Workspace, _: &EndOfDocument, cx: _| motion(Motion::EndOfDocument, cx)); + + cx.add_action( + |_: &mut Workspace, + &NextWordStart { + ignore_punctuation, + stop_at_newline, + }: &NextWordStart, + cx: _| { + motion( + Motion::NextWordStart { + ignore_punctuation, + stop_at_newline, + }, + cx, + ) + }, + ); + cx.add_action( + |_: &mut Workspace, &NextWordEnd { ignore_punctuation }: &NextWordEnd, cx: _| { + motion(Motion::NextWordEnd { ignore_punctuation }, cx) + }, + ); + cx.add_action( + |_: &mut Workspace, + &PreviousWordStart { ignore_punctuation }: &PreviousWordStart, + cx: _| { motion(Motion::PreviousWordStart { ignore_punctuation }, cx) }, + ); +} + +fn motion(motion: Motion, cx: &mut MutableAppContext) { + Vim::update(cx, |vim, cx| { + if let Some(Operator::Namespace(_)) = vim.active_operator() { + vim.pop_operator(cx); + } + }); + match Vim::read(cx).state.mode { + Mode::Normal => normal_motion(motion, cx), + Mode::Insert => panic!("motion bindings in insert mode interfere with normal typing"), + } +} + +impl Motion { + pub fn move_point( + self, + map: &DisplaySnapshot, + point: DisplayPoint, + goal: SelectionGoal, + ) -> (DisplayPoint, SelectionGoal) { + use Motion::*; + match self { + Left => (left(map, point), SelectionGoal::None), + Down => movement::down(map, point, goal), + Up => movement::up(map, point, goal), + Right => (right(map, point), SelectionGoal::None), + NextWordStart { + ignore_punctuation, + stop_at_newline, + } => ( + next_word_start(map, point, ignore_punctuation, stop_at_newline), + SelectionGoal::None, + ), + NextWordEnd { ignore_punctuation } => ( + next_word_end(map, point, ignore_punctuation, true), + SelectionGoal::None, + ), + PreviousWordStart { ignore_punctuation } => ( + previous_word_start(map, point, ignore_punctuation), + SelectionGoal::None, + ), + StartOfLine => ( + movement::line_beginning(map, point, false), + SelectionGoal::None, + ), + EndOfLine => ( + map.clip_point(movement::line_end(map, point, false), Bias::Left), + SelectionGoal::None, + ), + StartOfDocument => (start_of_document(map), SelectionGoal::None), + EndOfDocument => (end_of_document(map), SelectionGoal::None), + } + } + + pub fn expand_selection(self, map: &DisplaySnapshot, selection: &mut Selection) { + use Motion::*; + match self { + Up => { + let (start, _) = Up.move_point(map, selection.start, SelectionGoal::None); + // Cursor at top of file. Return early rather + if start == selection.start { + return; + } + let (start, _) = StartOfLine.move_point(map, start, SelectionGoal::None); + let (end, _) = EndOfLine.move_point(map, selection.end, SelectionGoal::None); + selection.start = start; + selection.end = end; + // TODO: Make sure selection goal is correct here + selection.goal = SelectionGoal::None; + } + Down => { + let (end, _) = Down.move_point(map, selection.end, SelectionGoal::None); + // Cursor at top of file. Return early rather + if end == selection.start { + return; + } + let (start, _) = StartOfLine.move_point(map, selection.start, SelectionGoal::None); + let (end, _) = EndOfLine.move_point(map, end, SelectionGoal::None); + selection.start = start; + selection.end = end; + // TODO: Make sure selection goal is correct here + selection.goal = SelectionGoal::None; + } + NextWordEnd { ignore_punctuation } => { + selection.set_head( + next_word_end(map, selection.head(), ignore_punctuation, false), + SelectionGoal::None, + ); + } + _ => { + let (head, goal) = self.move_point(map, selection.head(), selection.goal); + selection.set_head(head, goal); + } + } + } +} + +fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { + *point.column_mut() = point.column().saturating_sub(1); + map.clip_point(point, Bias::Left) +} + +fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { + *point.column_mut() += 1; + map.clip_point(point, Bias::Right) +} + +fn next_word_start( + map: &DisplaySnapshot, + point: DisplayPoint, + ignore_punctuation: bool, + stop_at_newline: bool, +) -> DisplayPoint { + let mut crossed_newline = false; + movement::find_boundary(map, point, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + let at_newline = right == '\n'; + + let found = (left_kind != right_kind && !right.is_whitespace()) + || (at_newline && (crossed_newline || stop_at_newline)) + || (at_newline && left == '\n'); // Prevents skipping repeated empty lines + + if at_newline { + crossed_newline = true; + } + found + }) +} + +fn next_word_end( + map: &DisplaySnapshot, + mut point: DisplayPoint, + ignore_punctuation: bool, + before_end_character: bool, +) -> DisplayPoint { + *point.column_mut() += 1; + point = movement::find_boundary(map, point, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + + left_kind != right_kind && !left.is_whitespace() + }); + // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know + // we have backtraced already + if before_end_character + && !map + .chars_at(point) + .skip(1) + .next() + .map(|c| c == '\n') + .unwrap_or(true) + { + *point.column_mut() = point.column().saturating_sub(1); + } + map.clip_point(point, Bias::Left) +} + +fn previous_word_start( + map: &DisplaySnapshot, + mut point: DisplayPoint, + ignore_punctuation: bool, +) -> DisplayPoint { + // This works even though find_preceding_boundary is called for every character in the line containing + // cursor because the newline is checked only once. + point = movement::find_preceding_boundary(map, point, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + + (left_kind != right_kind && !right.is_whitespace()) || left == '\n' + }); + point +} + +fn start_of_document(map: &DisplaySnapshot) -> DisplayPoint { + 0usize.to_display_point(map) +} + +fn end_of_document(map: &DisplaySnapshot) -> DisplayPoint { + map.clip_point(map.max_point(), Bias::Left) +} diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index b6752544ae48ebbb451f80f3fe0aaaec8d4d132d..235eece06f455eae8f92f9f6cc9e191a5ad39ebe 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -1,212 +1,77 @@ -mod g_prefix; - -use crate::VimState; -use editor::{char_kind, movement, Bias}; -use gpui::{actions, impl_actions, MutableAppContext, ViewContext}; +use crate::{ + motion::Motion, + state::{Mode, Operator}, + Vim, +}; +use editor::Bias; +use gpui::MutableAppContext; use language::SelectionGoal; -use serde::Deserialize; -use workspace::Workspace; - -#[derive(Clone, Deserialize)] -struct MoveToNextWordStart(pub bool); - -#[derive(Clone, Deserialize)] -struct MoveToNextWordEnd(pub bool); - -#[derive(Clone, Deserialize)] -struct MoveToPreviousWordStart(pub bool); - -impl_actions!( - vim, - [ - MoveToNextWordStart, - MoveToNextWordEnd, - MoveToPreviousWordStart, - ] -); - -actions!( - vim, - [ - GPrefix, - MoveLeft, - MoveDown, - MoveUp, - MoveRight, - MoveToStartOfLine, - MoveToEndOfLine, - MoveToEnd, - ] -); - -pub fn init(cx: &mut MutableAppContext) { - g_prefix::init(cx); - cx.add_action(move_left); - cx.add_action(move_down); - cx.add_action(move_up); - cx.add_action(move_right); - cx.add_action(move_to_start_of_line); - cx.add_action(move_to_end_of_line); - cx.add_action(move_to_end); - cx.add_action(move_to_next_word_start); - cx.add_action(move_to_next_word_end); - cx.add_action(move_to_previous_word_start); -} - -fn move_left(_: &mut Workspace, _: &MoveLeft, cx: &mut ViewContext) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.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) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.move_cursors(cx, movement::down); - }); - }); -} - -fn move_up(_: &mut Workspace, _: &MoveUp, cx: &mut ViewContext) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.move_cursors(cx, movement::up); - }); - }); -} - -fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.move_cursors(cx, |map, mut cursor, _| { - *cursor.column_mut() += 1; - (map.clip_point(cursor, Bias::Right), SelectionGoal::None) - }); - }); - }); -} - -fn move_to_start_of_line( - _: &mut Workspace, - _: &MoveToStartOfLine, - cx: &mut ViewContext, -) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.move_cursors(cx, |map, cursor, _| { - ( - movement::line_beginning(map, cursor, false), - SelectionGoal::None, - ) - }); - }); - }); -} -fn move_to_end_of_line(_: &mut Workspace, _: &MoveToEndOfLine, cx: &mut ViewContext) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.move_cursors(cx, |map, cursor, _| { - ( - map.clip_point(movement::line_end(map, cursor, false), Bias::Left), - SelectionGoal::None, - ) - }); - }); +pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) { + Vim::update(cx, |vim, cx| { + match vim.state.operator_stack.pop() { + None => move_cursor(vim, motion, cx), + Some(Operator::Change) => change_over(vim, motion, cx), + Some(Operator::Delete) => delete_over(vim, motion, cx), + Some(Operator::Namespace(_)) => panic!( + "Normal mode recieved motion with namespaced operator. Likely this means an invalid keymap was used"), + } + vim.clear_operator(cx); }); } -fn move_to_end(_: &mut Workspace, _: &MoveToEnd, cx: &mut ViewContext) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.replace_selections_with(cx, |map| map.clip_point(map.max_point(), Bias::Left)); - }); +fn move_cursor(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { + vim.update_active_editor(cx, |editor, cx| { + editor.move_cursors(cx, |map, cursor, goal| motion.move_point(map, cursor, goal)) }); } -fn move_to_next_word_start( - _: &mut Workspace, - &MoveToNextWordStart(treat_punctuation_as_word): &MoveToNextWordStart, - cx: &mut ViewContext, -) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.move_cursors(cx, |map, mut cursor, _| { - let mut crossed_newline = false; - cursor = movement::find_boundary(map, cursor, |left, right| { - let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word); - let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word); - let at_newline = right == '\n'; - - let found = (left_kind != right_kind && !right.is_whitespace()) - || (at_newline && crossed_newline) - || (at_newline && left == '\n'); // Prevents skipping repeated empty lines - - if at_newline { - crossed_newline = true; - } - found +fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + // Don't clip at line ends during change operation + editor.set_clip_at_line_ends(false, cx); + editor.move_selections(cx, |map, selection| motion.expand_selection(map, selection)); + editor.set_clip_at_line_ends(true, cx); + match motion { + Motion::Up => editor.insert(&"\n", cx), + Motion::Down => editor.insert(&"\n", cx), + _ => editor.insert(&"", cx), + } + + if let Motion::Up = motion { + // Position cursor on previous line after change + editor.move_cursors(cx, |map, cursor, goal| { + Motion::Up.move_point(map, cursor, goal) }); - (cursor, SelectionGoal::None) - }); + } }); }); + vim.switch_mode(Mode::Insert, cx) } -fn move_to_next_word_end( - _: &mut Workspace, - &MoveToNextWordEnd(treat_punctuation_as_word): &MoveToNextWordEnd, - cx: &mut ViewContext, -) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.move_cursors(cx, |map, mut cursor, _| { - *cursor.column_mut() += 1; - cursor = movement::find_boundary(map, cursor, |left, right| { - let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word); - let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word); - - left_kind != right_kind && !left.is_whitespace() +fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + // Don't clip at line ends during delete operation + editor.set_clip_at_line_ends(false, cx); + editor.move_selections(cx, |map, selection| motion.expand_selection(map, selection)); + match motion { + Motion::Up => editor.insert(&"\n", cx), + Motion::Down => editor.insert(&"\n", cx), + _ => editor.insert(&"", cx), + } + + if let Motion::Up = motion { + // Position cursor on previous line after change + editor.move_cursors(cx, |map, cursor, goal| { + Motion::Up.move_point(map, cursor, goal) }); - // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know - // we have backtraced already - if !map - .chars_at(cursor) - .skip(1) - .next() - .map(|c| c == '\n') - .unwrap_or(true) - { - *cursor.column_mut() = cursor.column().saturating_sub(1); - } - (map.clip_point(cursor, Bias::Left), SelectionGoal::None) - }); - }); - }); -} - -fn move_to_previous_word_start( - _: &mut Workspace, - &MoveToPreviousWordStart(treat_punctuation_as_word): &MoveToPreviousWordStart, - cx: &mut ViewContext, -) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.move_cursors(cx, |map, mut cursor, _| { - // This works even though find_preceding_boundary is called for every character in the line containing - // cursor because the newline is checked only once. - cursor = movement::find_preceding_boundary(map, cursor, |left, right| { - let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word); - let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word); - - (left_kind != right_kind && !right.is_whitespace()) || left == '\n' - }); - (cursor, SelectionGoal::None) + } + // Fixup cursor position after the deletion + editor.set_clip_at_line_ends(true, cx); + editor.move_selection_heads(cx, |map, head, _| { + (map.clip_point(head, Bias::Left), SelectionGoal::None) }); }); }); @@ -217,7 +82,13 @@ mod test { use indoc::indoc; use util::test::marked_text; - use crate::vim_test_context::VimTestContext; + use crate::{ + state::{ + Mode::{self, *}, + Namespace, Operator, + }, + vim_test_context::VimTestContext, + }; #[gpui::test] async fn test_hjkl(cx: &mut gpui::TestAppContext) { @@ -362,7 +233,7 @@ mod test { } // Reset and test ignoring punctuation - cx.simulate_keystrokes(&["g", "g"]); + cx.simulate_keystrokes(["g", "g"]); let (_, cursor_offsets) = marked_text(indoc! {" The |quick-brown | @@ -392,7 +263,7 @@ mod test { } // Reset and test ignoring punctuation - cx.simulate_keystrokes(&["g", "g"]); + cx.simulate_keystrokes(["g", "g"]); let (_, cursor_offsets) = marked_text(indoc! {" Th|e quick-brow|n @@ -434,4 +305,232 @@ mod test { cx.assert_newest_selection_head_offset(cursor_offset); } } + + #[gpui::test] + async fn test_g_prefix_and_abort(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true, "").await; + + // Can abort with escape to get back to normal mode + cx.simulate_keystroke("g"); + assert_eq!(cx.mode(), Normal); + assert_eq!( + cx.active_operator(), + Some(Operator::Namespace(Namespace::G)) + ); + cx.simulate_keystroke("escape"); + assert_eq!(cx.mode(), Normal); + assert_eq!(cx.active_operator(), None); + } + + #[gpui::test] + async fn test_move_to_start(cx: &mut gpui::TestAppContext) { + let initial_content = indoc! {" + The quick + + brown fox jumps + over the lazy dog"}; + let mut cx = VimTestContext::new(cx, true, initial_content).await; + + // Jump to the end to + cx.simulate_keystroke("shift-G"); + cx.assert_editor_state(indoc! {" + The quick + + brown fox jumps + over the lazy do|g"}); + + // Jump to the start + cx.simulate_keystrokes(["g", "g"]); + cx.assert_editor_state(indoc! {" + |The quick + + brown fox jumps + over the lazy dog"}); + assert_eq!(cx.mode(), Normal); + assert_eq!(cx.active_operator(), None); + + // Repeat action doesn't change + cx.simulate_keystrokes(["g", "g"]); + cx.assert_editor_state(indoc! {" + |The quick + + brown fox jumps + over the lazy dog"}); + assert_eq!(cx.mode(), Normal); + assert_eq!(cx.active_operator(), None); + } + + #[gpui::test] + async fn test_change(cx: &mut gpui::TestAppContext) { + fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) { + cx.assert_binding( + ["c", motion], + initial_state, + Mode::Normal, + state_after, + Mode::Insert, + ); + } + let cx = &mut VimTestContext::new(cx, true, "").await; + assert("h", "Te|st", "T|st", cx); + assert("l", "Te|st", "Te|t", cx); + assert("w", "|Test", "|", cx); + assert("w", "Te|st", "Te|", cx); + assert("w", "Te|st Test", "Te| Test", cx); + assert("e", "Te|st Test", "Te| Test", cx); + assert("b", "Te|st", "|st", cx); + assert("b", "Test Te|st", "Test |st", cx); + assert( + "w", + indoc! {" + The quick + brown |fox + jumps over"}, + indoc! {" + The quick + brown | + jumps over"}, + cx, + ); + assert( + "shift-W", + indoc! {" + The quick + brown |fox-fox + jumps over"}, + indoc! {" + The quick + brown | + jumps over"}, + cx, + ); + assert( + "k", + indoc! {" + The quick + brown |fox"}, + indoc! {" + | + "}, + cx, + ); + assert( + "j", + indoc! {" + The q|uick + brown fox"}, + indoc! {" + + |"}, + cx, + ); + assert( + "shift-$", + indoc! {" + The q|uick + brown fox"}, + indoc! {" + The q| + brown fox"}, + cx, + ); + assert( + "0", + indoc! {" + The q|uick + brown fox"}, + indoc! {" + |uick + brown fox"}, + cx, + ); + } + + #[gpui::test] + async fn test_delete(cx: &mut gpui::TestAppContext) { + fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) { + cx.assert_binding( + ["d", motion], + initial_state, + Mode::Normal, + state_after, + Mode::Normal, + ); + } + let cx = &mut VimTestContext::new(cx, true, "").await; + assert("h", "Te|st", "T|st", cx); + assert("l", "Te|st", "Te|t", cx); + assert("w", "|Test", "|", cx); + assert("w", "Te|st", "T|e", cx); + assert("w", "Te|st Test", "Te|Test", cx); + assert("e", "Te|st Test", "Te| Test", cx); + assert("b", "Te|st", "|st", cx); + assert("b", "Test Te|st", "Test |st", cx); + assert( + "w", + indoc! {" + The quick + brown |fox + jumps over"}, + // Trailing space after cursor + indoc! {" + The quick + brown| + jumps over"}, + cx, + ); + assert( + "shift-W", + indoc! {" + The quick + brown |fox-fox + jumps over"}, + // Trailing space after cursor + indoc! {" + The quick + brown| + jumps over"}, + cx, + ); + assert( + "k", + indoc! {" + The quick + brown |fox"}, + indoc! {" + | + "}, + cx, + ); + assert( + "j", + indoc! {" + The q|uick + brown fox"}, + indoc! {" + + |"}, + cx, + ); + assert( + "shift-$", + indoc! {" + The q|uick + brown fox"}, + indoc! {" + The |q + brown fox"}, + cx, + ); + assert( + "0", + indoc! {" + The q|uick + brown fox"}, + indoc! {" + |uick + brown fox"}, + cx, + ); + } } diff --git a/crates/vim/src/normal/g_prefix.rs b/crates/vim/src/normal/g_prefix.rs deleted file mode 100644 index 5fecbc4c5bdb974f2482129a3e98c2801c72b94b..0000000000000000000000000000000000000000 --- a/crates/vim/src/normal/g_prefix.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::{mode::Mode, SwitchMode, VimState}; -use gpui::{actions, MutableAppContext, ViewContext}; -use workspace::Workspace; - -actions!(vim, [MoveToStart]); - -pub fn init(cx: &mut MutableAppContext) { - cx.add_action(move_to_start); -} - -fn move_to_start(_: &mut Workspace, _: &MoveToStart, cx: &mut ViewContext) { - VimState::update_global(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { - editor.move_to_beginning(&editor::MoveToBeginning, cx); - }); - state.switch_mode(&SwitchMode(Mode::normal()), cx); - }) -} - -#[cfg(test)] -mod test { - use indoc::indoc; - - use crate::{ - mode::{Mode, NormalState}, - vim_test_context::VimTestContext, - }; - - #[gpui::test] - async fn test_g_prefix_and_abort(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true, "").await; - - // Can abort with escape to get back to normal mode - cx.simulate_keystroke("g"); - assert_eq!(cx.mode(), Mode::Normal(NormalState::GPrefix)); - cx.simulate_keystroke("escape"); - assert_eq!(cx.mode(), Mode::normal()); - } - - #[gpui::test] - async fn test_move_to_start(cx: &mut gpui::TestAppContext) { - let initial_content = indoc! {" - The quick - - brown fox jumps - over the lazy dog"}; - let mut cx = VimTestContext::new(cx, true, initial_content).await; - - // Jump to the end to - cx.simulate_keystroke("shift-G"); - cx.assert_editor_state(indoc! {" - The quick - - brown fox jumps - over the lazy do|g"}); - - // Jump to the start - cx.simulate_keystrokes(&["g", "g"]); - cx.assert_editor_state(indoc! {" - |The quick - - brown fox jumps - over the lazy dog"}); - assert_eq!(cx.mode(), Mode::normal()); - - // Repeat action doesn't change - cx.simulate_keystrokes(&["g", "g"]); - cx.assert_editor_state(indoc! {" - |The quick - - brown fox jumps - over the lazy dog"}); - assert_eq!(cx.mode(), Mode::normal()); - } -} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs new file mode 100644 index 0000000000000000000000000000000000000000..73769eafbcc99a18e104316c593deb328d59e4b2 --- /dev/null +++ b/crates/vim/src/state.rs @@ -0,0 +1,82 @@ +use editor::CursorShape; +use gpui::keymap::Context; +use serde::Deserialize; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +pub enum Mode { + Normal, + Insert, +} + +impl Default for Mode { + fn default() -> Self { + Self::Normal + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)] +pub enum Namespace { + G, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)] +pub enum Operator { + Namespace(Namespace), + Change, + Delete, +} + +#[derive(Default)] +pub struct VimState { + pub mode: Mode, + pub operator_stack: Vec, +} + +impl VimState { + pub fn cursor_shape(&self) -> CursorShape { + match self.mode { + Mode::Normal => CursorShape::Block, + Mode::Insert => CursorShape::Bar, + } + } + + pub fn vim_controlled(&self) -> bool { + !matches!(self.mode, Mode::Insert) + } + + pub fn keymap_context_layer(&self) -> Context { + let mut context = Context::default(); + context.map.insert( + "vim_mode".to_string(), + match self.mode { + Mode::Normal => "normal", + Mode::Insert => "insert", + } + .to_string(), + ); + + if self.vim_controlled() { + context.set.insert("VimControl".to_string()); + } + + if let Some(operator) = &self.operator_stack.last() { + operator.set_context(&mut context); + } + context + } +} + +impl Operator { + pub fn set_context(&self, context: &mut Context) { + let operator_context = match self { + Operator::Namespace(Namespace::G) => "g", + Operator::Change => "c", + Operator::Delete => "d", + } + .to_owned(); + + context + .map + .insert("vim_operator".to_string(), operator_context.to_string()); + } +} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index adcf2fb130a61a137c9226b809daccdec60b381e..65acce7a42606ef17bf2b750de92b212c756c85d 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1,7 +1,8 @@ mod editor_events; mod insert; -mod mode; +mod motion; mod normal; +mod state; #[cfg(test)] mod vim_test_context; @@ -10,41 +11,53 @@ use editor::{CursorShape, Editor}; use gpui::{impl_actions, MutableAppContext, ViewContext, WeakViewHandle}; use serde::Deserialize; -use mode::Mode; use settings::Settings; +use state::{Mode, Operator, VimState}; use workspace::{self, Workspace}; #[derive(Clone, Deserialize)] pub struct SwitchMode(pub Mode); -impl_actions!(vim, [SwitchMode]); +#[derive(Clone, Deserialize)] +pub struct PushOperator(pub Operator); + +impl_actions!(vim, [SwitchMode, PushOperator]); pub fn init(cx: &mut MutableAppContext) { editor_events::init(cx); insert::init(cx); - normal::init(cx); + motion::init(cx); - cx.add_action(|_: &mut Workspace, action: &SwitchMode, cx| { - VimState::update_global(cx, |state, cx| state.switch_mode(action, cx)) + cx.add_action(|_: &mut Workspace, &SwitchMode(mode): &SwitchMode, cx| { + Vim::update(cx, |vim, cx| vim.switch_mode(mode, cx)) }); + cx.add_action( + |_: &mut Workspace, &PushOperator(operator): &PushOperator, cx| { + Vim::update(cx, |vim, cx| vim.push_operator(operator, cx)) + }, + ); cx.observe_global::(|settings, cx| { - VimState::update_global(cx, |state, cx| state.set_enabled(settings.vim_mode, cx)) + Vim::update(cx, |state, cx| state.set_enabled(settings.vim_mode, cx)) }) .detach(); } #[derive(Default)] -pub struct VimState { +pub struct Vim { editors: HashMap>, active_editor: Option>, enabled: bool, - mode: Mode, + state: VimState, } -impl VimState { - fn update_global(cx: &mut MutableAppContext, update: F) -> S +impl Vim { + fn read(cx: &mut MutableAppContext) -> &Self { + cx.default_global() + } + + fn update(cx: &mut MutableAppContext, update: F) -> S where F: FnOnce(&mut Self, &mut MutableAppContext) -> S, { @@ -62,33 +75,54 @@ impl VimState { .map(|ae| ae.update(cx, update)) } - fn switch_mode(&mut self, SwitchMode(mode): &SwitchMode, cx: &mut MutableAppContext) { - self.mode = *mode; + fn switch_mode(&mut self, mode: Mode, cx: &mut MutableAppContext) { + self.state.mode = mode; + self.state.operator_stack.clear(); self.sync_editor_options(cx); } + fn push_operator(&mut self, operator: Operator, cx: &mut MutableAppContext) { + self.state.operator_stack.push(operator); + self.sync_editor_options(cx); + } + + fn pop_operator(&mut self, cx: &mut MutableAppContext) -> Operator { + let popped_operator = self.state.operator_stack.pop().expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config"); + self.sync_editor_options(cx); + popped_operator + } + + fn clear_operator(&mut self, cx: &mut MutableAppContext) { + self.state.operator_stack.clear(); + self.sync_editor_options(cx); + } + + fn active_operator(&mut self) -> Option { + self.state.operator_stack.last().copied() + } + fn set_enabled(&mut self, enabled: bool, cx: &mut MutableAppContext) { if self.enabled != enabled { self.enabled = enabled; - self.mode = Default::default(); + self.state = Default::default(); if enabled { - self.mode = Mode::normal(); + self.state.mode = Mode::Normal; } self.sync_editor_options(cx); } } fn sync_editor_options(&self, cx: &mut MutableAppContext) { - let mode = self.mode; - let cursor_shape = mode.cursor_shape(); + let state = &self.state; + let cursor_shape = state.cursor_shape(); for editor in self.editors.values() { if let Some(editor) = editor.upgrade(cx) { editor.update(cx, |editor, cx| { if self.enabled { editor.set_cursor_shape(cursor_shape, cx); editor.set_clip_at_line_ends(cursor_shape == CursorShape::Block, cx); - editor.set_input_enabled(mode == Mode::Insert); - let context_layer = mode.keymap_context_layer(); + editor.set_input_enabled(!state.vim_controlled()); + let context_layer = state.keymap_context_layer(); editor.set_keymap_context_layer::(context_layer); } else { editor.set_cursor_shape(CursorShape::Bar, cx); @@ -104,12 +138,12 @@ impl VimState { #[cfg(test)] mod test { - use crate::{mode::Mode, vim_test_context::VimTestContext}; + use crate::{state::Mode, vim_test_context::VimTestContext}; #[gpui::test] async fn test_initially_disabled(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, false, "").await; - cx.simulate_keystrokes(&["h", "j", "k", "l"]); + cx.simulate_keystrokes(["h", "j", "k", "l"]); cx.assert_editor_state("hjkl|"); } @@ -122,22 +156,22 @@ mod test { // Editor acts as though vim is disabled cx.disable_vim(); - cx.simulate_keystrokes(&["h", "j", "k", "l"]); + cx.simulate_keystrokes(["h", "j", "k", "l"]); cx.assert_editor_state("hjkl|"); // Enabling dynamically sets vim mode again and restores normal mode cx.enable_vim(); - assert_eq!(cx.mode(), Mode::normal()); - cx.simulate_keystrokes(&["h", "h", "h", "l"]); + assert_eq!(cx.mode(), Mode::Normal); + cx.simulate_keystrokes(["h", "h", "h", "l"]); assert_eq!(cx.editor_text(), "hjkl".to_owned()); cx.assert_editor_state("hj|kl"); - cx.simulate_keystrokes(&["i", "T", "e", "s", "t"]); + cx.simulate_keystrokes(["i", "T", "e", "s", "t"]); cx.assert_editor_state("hjTest|kl"); // Disabling and enabling resets to normal mode assert_eq!(cx.mode(), Mode::Insert); cx.disable_vim(); cx.enable_vim(); - assert_eq!(cx.mode(), Mode::normal()); + assert_eq!(cx.mode(), Mode::Normal); } } diff --git a/crates/vim/src/vim_test_context.rs b/crates/vim/src/vim_test_context.rs index 2e87115af25f10abb9fdc9e4aa2bdd30394fc8df..1e10b5e206260c7593b7aff160fbdc3b1e327ccb 100644 --- a/crates/vim/src/vim_test_context.rs +++ b/crates/vim/src/vim_test_context.rs @@ -6,7 +6,7 @@ use language::{Point, Selection}; use util::test::marked_text; use workspace::{WorkspaceHandle, WorkspaceParams}; -use crate::*; +use crate::{state::Operator, *}; pub struct VimTestContext<'a> { cx: &'a mut gpui::TestAppContext, @@ -100,7 +100,12 @@ impl<'a> VimTestContext<'a> { } pub fn mode(&mut self) -> Mode { - self.cx.update(|cx| cx.global::().mode) + self.cx.read(|cx| cx.global::().state.mode) + } + + pub fn active_operator(&mut self) -> Option { + self.cx + .read(|cx| cx.global::().state.operator_stack.last().copied()) } pub fn editor_text(&mut self) -> String { @@ -119,12 +124,23 @@ impl<'a> VimTestContext<'a> { .dispatch_keystroke(self.window_id, keystroke, input, false); } - pub fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) { + pub fn simulate_keystrokes(&mut self, keystroke_texts: [&str; COUNT]) { for keystroke_text in keystroke_texts.into_iter() { self.simulate_keystroke(keystroke_text); } } + pub fn set_state(&mut self, text: &str, mode: Mode) { + self.cx + .update(|cx| Vim::update(cx, |vim, cx| vim.switch_mode(mode, cx))); + self.editor.update(self.cx, |editor, cx| { + let (unmarked_text, markers) = marked_text(&text); + editor.set_text(unmarked_text, cx); + let cursor_offset = markers[0]; + editor.replace_selections_with(cx, |map| cursor_offset.to_display_point(map)); + }) + } + pub fn assert_newest_selection_head_offset(&mut self, expected_offset: usize) { let actual_head = self.newest_selection().head(); let (actual_offset, expected_head) = self.editor.update(self.cx, |editor, cx| { @@ -171,6 +187,21 @@ impl<'a> VimTestContext<'a> { actual_position_text, expected_position_text ) } + + pub fn assert_binding( + &mut self, + keystrokes: [&str; COUNT], + initial_state: &str, + initial_mode: Mode, + state_after: &str, + mode_after: Mode, + ) { + self.set_state(initial_state, initial_mode); + self.simulate_keystrokes(keystrokes); + self.assert_editor_state(state_after); + assert_eq!(self.mode(), mode_after); + assert_eq!(self.active_operator(), None); + } } impl<'a> Deref for VimTestContext<'a> {