Detailed changes
@@ -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
+ }
]
}
}
@@ -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;
}
}
});
@@ -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<Workspace>) {
- 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");
}
}
@@ -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
- }
-}
@@ -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<DisplayPoint>) {
+ 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)
+}
@@ -1,212 +1,56 @@
-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<Workspace>) {
- 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<Workspace>) {
- 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<Workspace>) {
- 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<Workspace>) {
- 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<Workspace>,
-) {
- 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<Workspace>) {
- 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,
- )
- });
- });
- });
-}
-fn move_to_end(_: &mut Workspace, _: &MoveToEnd, cx: &mut ViewContext<Workspace>) {
- 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));
- });
+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_next_word_start(
- _: &mut Workspace,
- &MoveToNextWordStart(treat_punctuation_as_word): &MoveToNextWordStart,
- cx: &mut ViewContext<Workspace>,
-) {
- 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
- });
- (cursor, SelectionGoal::None)
- });
- });
+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_end(
- _: &mut Workspace,
- &MoveToNextWordEnd(treat_punctuation_as_word): &MoveToNextWordEnd,
- cx: &mut ViewContext<Workspace>,
-) {
- 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()
- });
- // 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 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);
+ editor.insert(&"", cx);
});
});
+ vim.switch_mode(Mode::Insert, cx)
}
-fn move_to_previous_word_start(
- _: &mut Workspace,
- &MoveToPreviousWordStart(treat_punctuation_as_word): &MoveToPreviousWordStart,
- cx: &mut ViewContext<Workspace>,
-) {
- 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)
+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));
+ editor.insert(&"", cx);
+
+ // 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 +61,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 +212,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 +242,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 +284,228 @@ 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,
+ );
+ }
}
@@ -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<Workspace>) {
- 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());
- }
-}
@@ -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<Operator>,
+}
+
+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());
+ }
+}
@@ -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, _>(|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<usize, WeakViewHandle<Editor>>,
active_editor: Option<WeakViewHandle<Editor>>,
enabled: bool,
- mode: Mode,
+ state: VimState,
}
-impl VimState {
- fn update_global<F, S>(cx: &mut MutableAppContext, update: F) -> S
+impl Vim {
+ fn read(cx: &mut MutableAppContext) -> &Self {
+ cx.default_global()
+ }
+
+ fn update<F, S>(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<Operator> {
+ 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::<Self>(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);
}
}
@@ -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::<VimState>().mode)
+ self.cx.read(|cx| cx.global::<Vim>().state.mode)
+ }
+
+ pub fn active_operator(&mut self) -> Option<Operator> {
+ self.cx
+ .read(|cx| cx.global::<Vim>().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<const COUNT: usize>(&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<const COUNT: usize>(
+ &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> {