Add c and d operators to vim normal mode

Extracted motions from normal mode
Changed vim_submode to be vim_operator to enable better composition of operators
This commit is contained in:
Keith Simmons 2022-04-15 16:00:44 -07:00
parent 670757e5c9
commit 63278041e1
10 changed files with 862 additions and 433 deletions

View File

@ -1,58 +1,93 @@
{ {
"Editor && vim_mode == insert": { "Editor && VimControl": {
"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": {
"i": [ "i": [
"vim::SwitchMode", "vim::SwitchMode",
"Insert" "Insert"
], ],
"g": [ "g": [
"vim::SwitchMode", "vim::PushOperator",
{ {
"Normal": "GPrefix" "Namespace": "G"
} }
], ],
"h": "vim::MoveLeft", "h": "vim::Left",
"j": "vim::MoveDown", "j": "vim::Down",
"k": "vim::MoveUp", "k": "vim::Up",
"l": "vim::MoveRight", "l": "vim::Right",
"0": "vim::MoveToStartOfLine", "0": "vim::StartOfLine",
"shift-$": "vim::MoveToEndOfLine", "shift-$": "vim::EndOfLine",
"shift-G": "vim::MoveToEnd", "shift-G": "vim::EndOfDocument",
"w": "vim::NextWordStart",
"shift-W": [
"vim::NextWordStart",
{
"ignorePunctuation": true
}
],
"e": "vim::NextWordEnd",
"shift-E": [
"vim::NextWordEnd",
{
"ignorePunctuation": true
}
],
"b": "vim::PreviousWordStart",
"shift-B": [
"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": [ "w": [
"vim::MoveToNextWordStart", "vim::NextWordEnd",
false {
"ignorePunctuation": false
}
], ],
"shift-W": [ "shift-W": [
"vim::MoveToNextWordStart", "vim::NextWordEnd",
true {
"ignorePunctuation": true
}
]
},
"Editor && vim_operator == d": {
"w": [
"vim::NextWordStart",
{
"ignorePunctuation": false,
"stopAtNewline": true
}
], ],
"e": [ "shift-W": [
"vim::MoveToNextWordEnd", "vim::NextWordStart",
false {
], "ignorePunctuation": true,
"shift-E": [ "stopAtNewline": true
"vim::MoveToNextWordEnd", }
true
],
"b": [
"vim::MoveToPreviousWordStart",
false
],
"shift-B": [
"vim::MoveToPreviousWordStart",
true
] ]
} }
} }

View File

@ -1,7 +1,7 @@
use editor::{EditorBlurred, EditorCreated, EditorFocused, EditorMode, EditorReleased}; use editor::{EditorBlurred, EditorCreated, EditorFocused, EditorMode, EditorReleased};
use gpui::MutableAppContext; use gpui::MutableAppContext;
use crate::{mode::Mode, SwitchMode, VimState}; use crate::{state::Mode, Vim};
pub fn init(cx: &mut MutableAppContext) { pub fn init(cx: &mut MutableAppContext) {
cx.subscribe_global(editor_created).detach(); 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) { fn editor_created(EditorCreated(editor): &EditorCreated, cx: &mut MutableAppContext) {
cx.update_default_global(|vim_state: &mut VimState, cx| { cx.update_default_global(|vim: &mut Vim, cx| {
vim_state.editors.insert(editor.id(), editor.downgrade()); vim.editors.insert(editor.id(), editor.downgrade());
vim_state.sync_editor_options(cx); 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) { let mode = if matches!(editor.read(cx).mode(), EditorMode::SingleLine) {
Mode::Insert Mode::Insert
} else { } else {
Mode::normal() Mode::Normal
}; };
VimState::update_global(cx, |state, cx| { Vim::update(cx, |state, cx| {
state.active_editor = Some(editor.downgrade()); 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) { 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 let Some(previous_editor) = state.active_editor.clone() {
if previous_editor == editor.clone() { if previous_editor == editor.clone() {
state.active_editor = None; 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) { fn editor_released(EditorReleased(editor): &EditorReleased, cx: &mut MutableAppContext) {
cx.update_default_global(|vim_state: &mut VimState, _| { cx.update_default_global(|vim: &mut Vim, _| {
vim_state.editors.remove(&editor.id()); vim.editors.remove(&editor.id());
if let Some(previous_editor) = vim_state.active_editor.clone() { if let Some(previous_editor) = vim.active_editor.clone() {
if previous_editor == editor.clone() { if previous_editor == editor.clone() {
vim_state.active_editor = None; vim.active_editor = None;
} }
} }
}); });

View File

@ -1,4 +1,4 @@
use crate::{mode::Mode, SwitchMode, VimState}; use crate::{state::Mode, Vim};
use editor::Bias; use editor::Bias;
use gpui::{actions, MutableAppContext, ViewContext}; use gpui::{actions, MutableAppContext, ViewContext};
use language::SelectionGoal; use language::SelectionGoal;
@ -11,30 +11,30 @@ pub fn init(cx: &mut MutableAppContext) {
} }
fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Workspace>) { 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| { state.update_active_editor(cx, |editor, cx| {
editor.move_cursors(cx, |map, mut cursor, _| { editor.move_cursors(cx, |map, mut cursor, _| {
*cursor.column_mut() = cursor.column().saturating_sub(1); *cursor.column_mut() = cursor.column().saturating_sub(1);
(map.clip_point(cursor, Bias::Left), SelectionGoal::None) (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
}); });
}); });
state.switch_mode(&SwitchMode(Mode::normal()), cx); state.switch_mode(Mode::Normal, cx);
}) })
} }
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::{mode::Mode, vim_test_context::VimTestContext}; use crate::{state::Mode, vim_test_context::VimTestContext};
#[gpui::test] #[gpui::test]
async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) { async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true, "").await; let mut cx = VimTestContext::new(cx, true, "").await;
cx.simulate_keystroke("i"); cx.simulate_keystroke("i");
assert_eq!(cx.mode(), Mode::Insert); 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.assert_editor_state("Test|");
cx.simulate_keystroke("escape"); cx.simulate_keystroke("escape");
assert_eq!(cx.mode(), Mode::normal()); assert_eq!(cx.mode(), Mode::Normal);
cx.assert_editor_state("Tes|t"); cx.assert_editor_state("Tes|t");
} }
} }

View File

@ -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
}
}

296
crates/vim/src/motion.rs Normal file
View File

@ -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)
}

View File

@ -1,212 +1,77 @@
mod g_prefix; use crate::{
motion::Motion,
use crate::VimState; state::{Mode, Operator},
use editor::{char_kind, movement, Bias}; Vim,
use gpui::{actions, impl_actions, MutableAppContext, ViewContext}; };
use editor::Bias;
use gpui::MutableAppContext;
use language::SelectionGoal; use language::SelectionGoal;
use serde::Deserialize;
use workspace::Workspace;
#[derive(Clone, Deserialize)] pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) {
struct MoveToNextWordStart(pub bool); Vim::update(cx, |vim, cx| {
match vim.state.operator_stack.pop() {
#[derive(Clone, Deserialize)] None => move_cursor(vim, motion, cx),
struct MoveToNextWordEnd(pub bool); Some(Operator::Change) => change_over(vim, motion, cx),
Some(Operator::Delete) => delete_over(vim, motion, cx),
#[derive(Clone, Deserialize)] Some(Operator::Namespace(_)) => panic!(
struct MoveToPreviousWordStart(pub bool); "Normal mode recieved motion with namespaced operator. Likely this means an invalid keymap was used"),
}
impl_actions!( vim.clear_operator(cx);
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>) { fn move_cursor(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
VimState::update_global(cx, |state, cx| { vim.update_active_editor(cx, |editor, cx| {
state.update_active_editor(cx, |editor, cx| { editor.move_cursors(cx, |map, cursor, goal| motion.move_point(map, cursor, goal))
editor.move_cursors(cx, movement::up);
});
}); });
} }
fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext<Workspace>) { fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
VimState::update_global(cx, |state, cx| { vim.update_active_editor(cx, |editor, cx| {
state.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| {
editor.move_cursors(cx, |map, mut cursor, _| { // Don't clip at line ends during change operation
*cursor.column_mut() += 1; editor.set_clip_at_line_ends(false, cx);
(map.clip_point(cursor, Bias::Right), SelectionGoal::None) 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),
}
fn move_to_start_of_line( if let Motion::Up = motion {
_: &mut Workspace, // Position cursor on previous line after change
_: &MoveToStartOfLine, editor.move_cursors(cx, |map, cursor, goal| {
cx: &mut ViewContext<Workspace>, Motion::Up.move_point(map, cursor, goal)
) {
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));
});
});
}
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) }
});
}); });
}); });
vim.switch_mode(Mode::Insert, cx)
} }
fn move_to_next_word_end( fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
_: &mut Workspace, vim.update_active_editor(cx, |editor, cx| {
&MoveToNextWordEnd(treat_punctuation_as_word): &MoveToNextWordEnd, editor.transact(cx, |editor, cx| {
cx: &mut ViewContext<Workspace>, // Don't clip at line ends during delete operation
) { editor.set_clip_at_line_ends(false, cx);
VimState::update_global(cx, |state, cx| { editor.move_selections(cx, |map, selection| motion.expand_selection(map, selection));
state.update_active_editor(cx, |editor, cx| { match motion {
editor.move_cursors(cx, |map, mut cursor, _| { Motion::Up => editor.insert(&"\n", cx),
*cursor.column_mut() += 1; Motion::Down => editor.insert(&"\n", cx),
cursor = movement::find_boundary(map, cursor, |left, right| { _ => editor.insert(&"", cx),
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() 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 // Fixup cursor position after the deletion
if !map editor.set_clip_at_line_ends(true, cx);
.chars_at(cursor) editor.move_selection_heads(cx, |map, head, _| {
.skip(1) (map.clip_point(head, Bias::Left), SelectionGoal::None)
.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<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)
}); });
}); });
}); });
@ -217,7 +82,13 @@ mod test {
use indoc::indoc; use indoc::indoc;
use util::test::marked_text; use util::test::marked_text;
use crate::vim_test_context::VimTestContext; use crate::{
state::{
Mode::{self, *},
Namespace, Operator,
},
vim_test_context::VimTestContext,
};
#[gpui::test] #[gpui::test]
async fn test_hjkl(cx: &mut gpui::TestAppContext) { async fn test_hjkl(cx: &mut gpui::TestAppContext) {
@ -362,7 +233,7 @@ mod test {
} }
// Reset and test ignoring punctuation // Reset and test ignoring punctuation
cx.simulate_keystrokes(&["g", "g"]); cx.simulate_keystrokes(["g", "g"]);
let (_, cursor_offsets) = marked_text(indoc! {" let (_, cursor_offsets) = marked_text(indoc! {"
The |quick-brown The |quick-brown
| |
@ -392,7 +263,7 @@ mod test {
} }
// Reset and test ignoring punctuation // Reset and test ignoring punctuation
cx.simulate_keystrokes(&["g", "g"]); cx.simulate_keystrokes(["g", "g"]);
let (_, cursor_offsets) = marked_text(indoc! {" let (_, cursor_offsets) = marked_text(indoc! {"
Th|e quick-brow|n Th|e quick-brow|n
@ -434,4 +305,232 @@ mod test {
cx.assert_newest_selection_head_offset(cursor_offset); 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,
);
}
} }

View File

@ -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());
}
}

82
crates/vim/src/state.rs Normal file
View File

@ -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());
}
}

View File

@ -1,7 +1,8 @@
mod editor_events; mod editor_events;
mod insert; mod insert;
mod mode; mod motion;
mod normal; mod normal;
mod state;
#[cfg(test)] #[cfg(test)]
mod vim_test_context; mod vim_test_context;
@ -10,41 +11,53 @@ use editor::{CursorShape, Editor};
use gpui::{impl_actions, MutableAppContext, ViewContext, WeakViewHandle}; use gpui::{impl_actions, MutableAppContext, ViewContext, WeakViewHandle};
use serde::Deserialize; use serde::Deserialize;
use mode::Mode;
use settings::Settings; use settings::Settings;
use state::{Mode, Operator, VimState};
use workspace::{self, Workspace}; use workspace::{self, Workspace};
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
pub struct SwitchMode(pub Mode); 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) { pub fn init(cx: &mut MutableAppContext) {
editor_events::init(cx); editor_events::init(cx);
insert::init(cx); insert::init(cx);
normal::init(cx); motion::init(cx);
cx.add_action(|_: &mut Workspace, action: &SwitchMode, cx| { cx.add_action(|_: &mut Workspace, &SwitchMode(mode): &SwitchMode, cx| {
VimState::update_global(cx, |state, cx| state.switch_mode(action, 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| { 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(); .detach();
} }
#[derive(Default)] #[derive(Default)]
pub struct VimState { pub struct Vim {
editors: HashMap<usize, WeakViewHandle<Editor>>, editors: HashMap<usize, WeakViewHandle<Editor>>,
active_editor: Option<WeakViewHandle<Editor>>, active_editor: Option<WeakViewHandle<Editor>>,
enabled: bool, enabled: bool,
mode: Mode, state: VimState,
} }
impl VimState { impl Vim {
fn update_global<F, S>(cx: &mut MutableAppContext, update: F) -> S fn read(cx: &mut MutableAppContext) -> &Self {
cx.default_global()
}
fn update<F, S>(cx: &mut MutableAppContext, update: F) -> S
where where
F: FnOnce(&mut Self, &mut MutableAppContext) -> S, F: FnOnce(&mut Self, &mut MutableAppContext) -> S,
{ {
@ -62,33 +75,54 @@ impl VimState {
.map(|ae| ae.update(cx, update)) .map(|ae| ae.update(cx, update))
} }
fn switch_mode(&mut self, SwitchMode(mode): &SwitchMode, cx: &mut MutableAppContext) { fn switch_mode(&mut self, mode: Mode, cx: &mut MutableAppContext) {
self.mode = *mode; self.state.mode = mode;
self.state.operator_stack.clear();
self.sync_editor_options(cx); 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) { fn set_enabled(&mut self, enabled: bool, cx: &mut MutableAppContext) {
if self.enabled != enabled { if self.enabled != enabled {
self.enabled = enabled; self.enabled = enabled;
self.mode = Default::default(); self.state = Default::default();
if enabled { if enabled {
self.mode = Mode::normal(); self.state.mode = Mode::Normal;
} }
self.sync_editor_options(cx); self.sync_editor_options(cx);
} }
} }
fn sync_editor_options(&self, cx: &mut MutableAppContext) { fn sync_editor_options(&self, cx: &mut MutableAppContext) {
let mode = self.mode; let state = &self.state;
let cursor_shape = mode.cursor_shape(); let cursor_shape = state.cursor_shape();
for editor in self.editors.values() { for editor in self.editors.values() {
if let Some(editor) = editor.upgrade(cx) { if let Some(editor) = editor.upgrade(cx) {
editor.update(cx, |editor, cx| { editor.update(cx, |editor, cx| {
if self.enabled { if self.enabled {
editor.set_cursor_shape(cursor_shape, cx); editor.set_cursor_shape(cursor_shape, cx);
editor.set_clip_at_line_ends(cursor_shape == CursorShape::Block, cx); editor.set_clip_at_line_ends(cursor_shape == CursorShape::Block, cx);
editor.set_input_enabled(mode == Mode::Insert); editor.set_input_enabled(!state.vim_controlled());
let context_layer = mode.keymap_context_layer(); let context_layer = state.keymap_context_layer();
editor.set_keymap_context_layer::<Self>(context_layer); editor.set_keymap_context_layer::<Self>(context_layer);
} else { } else {
editor.set_cursor_shape(CursorShape::Bar, cx); editor.set_cursor_shape(CursorShape::Bar, cx);
@ -104,12 +138,12 @@ impl VimState {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::{mode::Mode, vim_test_context::VimTestContext}; use crate::{state::Mode, vim_test_context::VimTestContext};
#[gpui::test] #[gpui::test]
async fn test_initially_disabled(cx: &mut gpui::TestAppContext) { async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, false, "").await; 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|"); cx.assert_editor_state("hjkl|");
} }
@ -122,22 +156,22 @@ mod test {
// Editor acts as though vim is disabled // Editor acts as though vim is disabled
cx.disable_vim(); cx.disable_vim();
cx.simulate_keystrokes(&["h", "j", "k", "l"]); cx.simulate_keystrokes(["h", "j", "k", "l"]);
cx.assert_editor_state("hjkl|"); cx.assert_editor_state("hjkl|");
// Enabling dynamically sets vim mode again and restores normal mode // Enabling dynamically sets vim mode again and restores normal mode
cx.enable_vim(); cx.enable_vim();
assert_eq!(cx.mode(), Mode::normal()); assert_eq!(cx.mode(), Mode::Normal);
cx.simulate_keystrokes(&["h", "h", "h", "l"]); cx.simulate_keystrokes(["h", "h", "h", "l"]);
assert_eq!(cx.editor_text(), "hjkl".to_owned()); assert_eq!(cx.editor_text(), "hjkl".to_owned());
cx.assert_editor_state("hj|kl"); 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"); cx.assert_editor_state("hjTest|kl");
// Disabling and enabling resets to normal mode // Disabling and enabling resets to normal mode
assert_eq!(cx.mode(), Mode::Insert); assert_eq!(cx.mode(), Mode::Insert);
cx.disable_vim(); cx.disable_vim();
cx.enable_vim(); cx.enable_vim();
assert_eq!(cx.mode(), Mode::normal()); assert_eq!(cx.mode(), Mode::Normal);
} }
} }

View File

@ -6,7 +6,7 @@ use language::{Point, Selection};
use util::test::marked_text; use util::test::marked_text;
use workspace::{WorkspaceHandle, WorkspaceParams}; use workspace::{WorkspaceHandle, WorkspaceParams};
use crate::*; use crate::{state::Operator, *};
pub struct VimTestContext<'a> { pub struct VimTestContext<'a> {
cx: &'a mut gpui::TestAppContext, cx: &'a mut gpui::TestAppContext,
@ -100,7 +100,12 @@ impl<'a> VimTestContext<'a> {
} }
pub fn mode(&mut self) -> Mode { 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 { pub fn editor_text(&mut self) -> String {
@ -119,12 +124,23 @@ impl<'a> VimTestContext<'a> {
.dispatch_keystroke(self.window_id, keystroke, input, false); .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() { for keystroke_text in keystroke_texts.into_iter() {
self.simulate_keystroke(keystroke_text); 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) { pub fn assert_newest_selection_head_offset(&mut self, expected_offset: usize) {
let actual_head = self.newest_selection().head(); let actual_head = self.newest_selection().head();
let (actual_offset, expected_head) = self.editor.update(self.cx, |editor, cx| { 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 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> { impl<'a> Deref for VimTestContext<'a> {