mirror of
https://github.com/zed-industries/zed.git
synced 2024-12-27 10:59:53 +00:00
Add word and line movement in vim normal mode
Add jump to start and end of the document Move vim tests to relevant vim files Rename VimTestAppContext to VimTestContext for brevity Improve VimTestContext assertions to pretty print locations when selection position assertion panics
This commit is contained in:
parent
3ae5fc74c9
commit
a7a52ef3f7
10 changed files with 766 additions and 278 deletions
|
@ -1281,6 +1281,24 @@ impl Editor {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn replace_selections_with(
|
||||
&mut self,
|
||||
cx: &mut ViewContext<Self>,
|
||||
find_replacement: impl Fn(&DisplaySnapshot) -> DisplayPoint,
|
||||
) {
|
||||
let display_map = self.snapshot(cx);
|
||||
let cursor = find_replacement(&display_map);
|
||||
let selection = Selection {
|
||||
id: post_inc(&mut self.next_selection_id),
|
||||
start: cursor,
|
||||
end: cursor,
|
||||
reversed: false,
|
||||
goal: SelectionGoal::None,
|
||||
}
|
||||
.map(|display_point| display_point.to_point(&display_map));
|
||||
self.update_selections(vec![selection], None, cx);
|
||||
}
|
||||
|
||||
pub fn move_selections(
|
||||
&mut self,
|
||||
cx: &mut ViewContext<Self>,
|
||||
|
@ -1291,21 +1309,9 @@ impl Editor {
|
|||
.local_selections::<Point>(cx)
|
||||
.into_iter()
|
||||
.map(|selection| {
|
||||
let mut selection = Selection {
|
||||
id: selection.id,
|
||||
start: selection.start.to_display_point(&display_map),
|
||||
end: selection.end.to_display_point(&display_map),
|
||||
reversed: selection.reversed,
|
||||
goal: selection.goal,
|
||||
};
|
||||
let mut selection = selection.map(|point| point.to_display_point(&display_map));
|
||||
move_selection(&display_map, &mut selection);
|
||||
Selection {
|
||||
id: selection.id,
|
||||
start: selection.start.to_point(&display_map),
|
||||
end: selection.end.to_point(&display_map),
|
||||
reversed: selection.reversed,
|
||||
goal: selection.goal,
|
||||
}
|
||||
selection.map(|display_point| display_point.to_point(&display_map))
|
||||
})
|
||||
.collect();
|
||||
self.update_selections(selections, Some(Autoscroll::Fit), cx);
|
||||
|
|
|
@ -276,6 +276,16 @@ pub enum CharKind {
|
|||
Word,
|
||||
}
|
||||
|
||||
impl CharKind {
|
||||
pub fn coerce_punctuation(self, treat_punctuation_as_word: bool) -> Self {
|
||||
if treat_punctuation_as_word && self == CharKind::Punctuation {
|
||||
CharKind::Word
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
pub fn new<T: Into<Arc<str>>>(
|
||||
replica_id: ReplicaId,
|
||||
|
|
|
@ -21,7 +21,7 @@ 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| {
|
||||
|
|
|
@ -25,6 +25,23 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Works
|
|||
(map.clip_point(cursor, Bias::Left), SelectionGoal::None)
|
||||
});
|
||||
});
|
||||
state.switch_mode(&SwitchMode(Mode::Normal), cx);
|
||||
state.switch_mode(&SwitchMode(Mode::normal()), cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::{mode::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.assert_editor_state("Test|");
|
||||
cx.simulate_keystroke("escape");
|
||||
assert_eq!(cx.mode(), Mode::normal());
|
||||
cx.assert_editor_state("Tes|t");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,14 +3,14 @@ use gpui::keymap::Context;
|
|||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Normal,
|
||||
Normal(NormalState),
|
||||
Insert,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn cursor_shape(&self) -> CursorShape {
|
||||
match self {
|
||||
Mode::Normal => CursorShape::Block,
|
||||
Mode::Normal(_) => CursorShape::Block,
|
||||
Mode::Insert => CursorShape::Bar,
|
||||
}
|
||||
}
|
||||
|
@ -20,17 +20,53 @@ impl Mode {
|
|||
context.map.insert(
|
||||
"vim_mode".to_string(),
|
||||
match self {
|
||||
Self::Normal => "normal",
|
||||
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
|
||||
Self::Normal(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NormalState {
|
||||
None,
|
||||
GPrefix,
|
||||
}
|
||||
|
||||
impl NormalState {
|
||||
pub fn set_context(&self, context: &mut Context) {
|
||||
let sub_mode = match self {
|
||||
Self::GPrefix => Some("g"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(sub_mode) = sub_mode {
|
||||
context
|
||||
.map
|
||||
.insert("vim_sub_mode".to_string(), sub_mode.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NormalState {
|
||||
fn default() -> Self {
|
||||
NormalState::None
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,30 +1,55 @@
|
|||
use editor::{movement, Bias};
|
||||
mod g_prefix;
|
||||
|
||||
use editor::{char_kind, movement, Bias};
|
||||
use gpui::{action, keymap::Binding, MutableAppContext, ViewContext};
|
||||
use language::SelectionGoal;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::{Mode, SwitchMode, VimState};
|
||||
use crate::{mode::NormalState, Mode, SwitchMode, VimState};
|
||||
|
||||
action!(InsertBefore);
|
||||
action!(GPrefix);
|
||||
action!(MoveLeft);
|
||||
action!(MoveDown);
|
||||
action!(MoveUp);
|
||||
action!(MoveRight);
|
||||
action!(MoveToStartOfLine);
|
||||
action!(MoveToEndOfLine);
|
||||
action!(MoveToEnd);
|
||||
action!(MoveToNextWordStart, bool);
|
||||
action!(MoveToNextWordEnd, bool);
|
||||
action!(MoveToPreviousWordStart, bool);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
let context = Some("Editor && vim_mode == normal");
|
||||
cx.add_bindings(vec![
|
||||
Binding::new("i", SwitchMode(Mode::Insert), context),
|
||||
Binding::new("g", SwitchMode(Mode::Normal(NormalState::GPrefix)), context),
|
||||
Binding::new("h", MoveLeft, context),
|
||||
Binding::new("j", MoveDown, context),
|
||||
Binding::new("k", MoveUp, context),
|
||||
Binding::new("l", MoveRight, context),
|
||||
Binding::new("0", MoveToStartOfLine, context),
|
||||
Binding::new("shift-$", MoveToEndOfLine, context),
|
||||
Binding::new("shift-G", MoveToEnd, context),
|
||||
Binding::new("w", MoveToNextWordStart(false), context),
|
||||
Binding::new("shift-W", MoveToNextWordStart(true), context),
|
||||
Binding::new("e", MoveToNextWordEnd(false), context),
|
||||
Binding::new("shift-E", MoveToNextWordEnd(true), context),
|
||||
Binding::new("b", MoveToPreviousWordStart(false), context),
|
||||
Binding::new("shift-B", MoveToPreviousWordStart(true), context),
|
||||
]);
|
||||
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>) {
|
||||
|
@ -64,3 +89,348 @@ fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext<Workspace>)
|
|||
});
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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_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 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)
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use indoc::indoc;
|
||||
use util::test::marked_text;
|
||||
|
||||
use crate::vim_test_context::VimTestContext;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_hjkl(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true, "Test\nTestTest\nTest").await;
|
||||
cx.simulate_keystroke("l");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
T|est
|
||||
TestTest
|
||||
Test"});
|
||||
cx.simulate_keystroke("h");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
|Test
|
||||
TestTest
|
||||
Test"});
|
||||
cx.simulate_keystroke("j");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Test
|
||||
|TestTest
|
||||
Test"});
|
||||
cx.simulate_keystroke("k");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
|Test
|
||||
TestTest
|
||||
Test"});
|
||||
cx.simulate_keystroke("j");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Test
|
||||
|TestTest
|
||||
Test"});
|
||||
|
||||
// When moving left, cursor does not wrap to the previous line
|
||||
cx.simulate_keystroke("h");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Test
|
||||
|TestTest
|
||||
Test"});
|
||||
|
||||
// When moving right, cursor does not reach the line end or wrap to the next line
|
||||
for _ in 0..9 {
|
||||
cx.simulate_keystroke("l");
|
||||
}
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Test
|
||||
TestTes|t
|
||||
Test"});
|
||||
|
||||
// Goal column respects the inability to reach the end of the line
|
||||
cx.simulate_keystroke("k");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Tes|t
|
||||
TestTest
|
||||
Test"});
|
||||
cx.simulate_keystroke("j");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Test
|
||||
TestTes|t
|
||||
Test"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_jump_to_line_boundaries(cx: &mut gpui::TestAppContext) {
|
||||
let initial_content = indoc! {"
|
||||
Test Test
|
||||
|
||||
T"};
|
||||
let mut cx = VimTestContext::new(cx, true, initial_content).await;
|
||||
|
||||
cx.simulate_keystroke("shift-$");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Test Tes|t
|
||||
|
||||
T"});
|
||||
cx.simulate_keystroke("0");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
|Test Test
|
||||
|
||||
T"});
|
||||
|
||||
cx.simulate_keystroke("j");
|
||||
cx.simulate_keystroke("shift-$");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Test Test
|
||||
|
|
||||
T"});
|
||||
cx.simulate_keystroke("0");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Test Test
|
||||
|
|
||||
T"});
|
||||
|
||||
cx.simulate_keystroke("j");
|
||||
cx.simulate_keystroke("shift-$");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Test Test
|
||||
|
||||
|T"});
|
||||
cx.simulate_keystroke("0");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
Test Test
|
||||
|
||||
|T"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_jump_to_end(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;
|
||||
|
||||
cx.simulate_keystroke("shift-G");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
The quick
|
||||
|
||||
brown fox jumps
|
||||
over the lazy do|g"});
|
||||
|
||||
// Repeat the action doesn't move
|
||||
cx.simulate_keystroke("shift-G");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
The quick
|
||||
|
||||
brown fox jumps
|
||||
over the lazy do|g"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_next_word_start(cx: &mut gpui::TestAppContext) {
|
||||
let (initial_content, cursor_offsets) = marked_text(indoc! {"
|
||||
The |quick|-|brown
|
||||
|
|
||||
|
|
||||
|fox_jumps |over
|
||||
|th||e"});
|
||||
let mut cx = VimTestContext::new(cx, true, &initial_content).await;
|
||||
|
||||
for cursor_offset in cursor_offsets {
|
||||
cx.simulate_keystroke("w");
|
||||
cx.assert_newest_selection_head_offset(cursor_offset);
|
||||
}
|
||||
|
||||
// Reset and test ignoring punctuation
|
||||
cx.simulate_keystrokes(&["g", "g"]);
|
||||
let (_, cursor_offsets) = marked_text(indoc! {"
|
||||
The |quick-brown
|
||||
|
|
||||
|
|
||||
|fox_jumps |over
|
||||
|th||e"});
|
||||
|
||||
for cursor_offset in cursor_offsets {
|
||||
cx.simulate_keystroke("shift-W");
|
||||
cx.assert_newest_selection_head_offset(cursor_offset);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_next_word_end(cx: &mut gpui::TestAppContext) {
|
||||
let (initial_content, cursor_offsets) = marked_text(indoc! {"
|
||||
Th|e quic|k|-brow|n
|
||||
|
||||
|
||||
fox_jump|s ove|r
|
||||
th|e"});
|
||||
let mut cx = VimTestContext::new(cx, true, &initial_content).await;
|
||||
|
||||
for cursor_offset in cursor_offsets {
|
||||
cx.simulate_keystroke("e");
|
||||
cx.assert_newest_selection_head_offset(cursor_offset);
|
||||
}
|
||||
|
||||
// Reset and test ignoring punctuation
|
||||
cx.simulate_keystrokes(&["g", "g"]);
|
||||
let (_, cursor_offsets) = marked_text(indoc! {"
|
||||
Th|e quick-brow|n
|
||||
|
||||
|
||||
fox_jump|s ove|r
|
||||
th||e"});
|
||||
for cursor_offset in cursor_offsets {
|
||||
cx.simulate_keystroke("shift-E");
|
||||
cx.assert_newest_selection_head_offset(cursor_offset);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_previous_word_start(cx: &mut gpui::TestAppContext) {
|
||||
let (initial_content, cursor_offsets) = marked_text(indoc! {"
|
||||
||The |quick|-|brown
|
||||
|
|
||||
|
|
||||
|fox_jumps |over
|
||||
|the"});
|
||||
let mut cx = VimTestContext::new(cx, true, &initial_content).await;
|
||||
cx.simulate_keystroke("shift-G");
|
||||
|
||||
for cursor_offset in cursor_offsets.into_iter().rev() {
|
||||
cx.simulate_keystroke("b");
|
||||
cx.assert_newest_selection_head_offset(cursor_offset);
|
||||
}
|
||||
|
||||
// Reset and test ignoring punctuation
|
||||
cx.simulate_keystroke("shift-G");
|
||||
let (_, cursor_offsets) = marked_text(indoc! {"
|
||||
||The |quick-brown
|
||||
|
|
||||
|
|
||||
|fox_jumps |over
|
||||
|the"});
|
||||
for cursor_offset in cursor_offsets.into_iter().rev() {
|
||||
cx.simulate_keystroke("shift-B");
|
||||
cx.assert_newest_selection_head_offset(cursor_offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
82
crates/vim/src/normal/g_prefix.rs
Normal file
82
crates/vim/src/normal/g_prefix.rs
Normal file
|
@ -0,0 +1,82 @@
|
|||
use gpui::{action, keymap::Binding, MutableAppContext, ViewContext};
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::{mode::Mode, SwitchMode, VimState};
|
||||
|
||||
action!(MoveToStart);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
let context = Some("Editor && vim_mode == normal && vim_sub_mode == g");
|
||||
cx.add_bindings(vec![
|
||||
Binding::new("g", MoveToStart, context),
|
||||
Binding::new("escape", SwitchMode(Mode::normal()), context),
|
||||
]);
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ mod insert;
|
|||
mod mode;
|
||||
mod normal;
|
||||
#[cfg(test)]
|
||||
mod vim_tests;
|
||||
mod vim_test_context;
|
||||
|
||||
use collections::HashMap;
|
||||
use editor::{CursorShape, Editor};
|
||||
|
@ -65,8 +65,9 @@ impl VimState {
|
|||
fn set_enabled(&mut self, enabled: bool, cx: &mut MutableAppContext) {
|
||||
if self.enabled != enabled {
|
||||
self.enabled = enabled;
|
||||
self.mode = Default::default();
|
||||
if enabled {
|
||||
self.mode = Mode::Normal;
|
||||
self.mode = Mode::normal();
|
||||
}
|
||||
self.sync_editor_options(cx);
|
||||
}
|
||||
|
@ -95,3 +96,43 @@ impl VimState {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::{mode::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.assert_editor_state("hjkl|");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true, "").await;
|
||||
|
||||
cx.simulate_keystroke("i");
|
||||
assert_eq!(cx.mode(), Mode::Insert);
|
||||
|
||||
// Editor acts as though vim is disabled
|
||||
cx.disable_vim();
|
||||
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.editor_text(), "hjkl".to_owned());
|
||||
cx.assert_editor_state("hj|kl");
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
179
crates/vim/src/vim_test_context.rs
Normal file
179
crates/vim/src/vim_test_context.rs
Normal file
|
@ -0,0 +1,179 @@
|
|||
use std::ops::Deref;
|
||||
|
||||
use editor::{display_map::ToDisplayPoint, Bias, DisplayPoint};
|
||||
use gpui::{json::json, keymap::Keystroke, ViewHandle};
|
||||
use language::{Point, Selection};
|
||||
use util::test::marked_text;
|
||||
use workspace::{WorkspaceHandle, WorkspaceParams};
|
||||
|
||||
use crate::*;
|
||||
|
||||
pub struct VimTestContext<'a> {
|
||||
cx: &'a mut gpui::TestAppContext,
|
||||
window_id: usize,
|
||||
editor: ViewHandle<Editor>,
|
||||
}
|
||||
|
||||
impl<'a> VimTestContext<'a> {
|
||||
pub async fn new(
|
||||
cx: &'a mut gpui::TestAppContext,
|
||||
enabled: bool,
|
||||
initial_editor_text: &str,
|
||||
) -> VimTestContext<'a> {
|
||||
cx.update(|cx| {
|
||||
editor::init(cx);
|
||||
crate::init(cx);
|
||||
});
|
||||
let params = cx.update(WorkspaceParams::test);
|
||||
|
||||
cx.update(|cx| {
|
||||
cx.update_global(|settings: &mut Settings, _| {
|
||||
settings.vim_mode = enabled;
|
||||
});
|
||||
});
|
||||
|
||||
params
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree(
|
||||
"/root",
|
||||
json!({ "dir": { "test.txt": initial_editor_text } }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
|
||||
params
|
||||
.project
|
||||
.update(cx, |project, cx| {
|
||||
project.find_or_create_local_worktree("/root", true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
|
||||
.await;
|
||||
|
||||
let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
|
||||
let item = workspace
|
||||
.update(cx, |workspace, cx| workspace.open_path(file, cx))
|
||||
.await
|
||||
.expect("Could not open test file");
|
||||
|
||||
let editor = cx.update(|cx| {
|
||||
item.act_as::<Editor>(cx)
|
||||
.expect("Opened test file wasn't an editor")
|
||||
});
|
||||
editor.update(cx, |_, cx| cx.focus_self());
|
||||
|
||||
Self {
|
||||
cx,
|
||||
window_id,
|
||||
editor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enable_vim(&mut self) {
|
||||
self.cx.update(|cx| {
|
||||
cx.update_global(|settings: &mut Settings, _| {
|
||||
settings.vim_mode = true;
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
pub fn disable_vim(&mut self) {
|
||||
self.cx.update(|cx| {
|
||||
cx.update_global(|settings: &mut Settings, _| {
|
||||
settings.vim_mode = false;
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
pub fn newest_selection(&mut self) -> Selection<DisplayPoint> {
|
||||
self.editor.update(self.cx, |editor, cx| {
|
||||
let snapshot = editor.snapshot(cx);
|
||||
editor
|
||||
.newest_selection::<Point>(cx)
|
||||
.map(|point| point.to_display_point(&snapshot.display_snapshot))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mode(&mut self) -> Mode {
|
||||
self.cx.update(|cx| cx.global::<VimState>().mode)
|
||||
}
|
||||
|
||||
pub fn editor_text(&mut self) -> String {
|
||||
self.editor
|
||||
.update(self.cx, |editor, cx| editor.snapshot(cx).text())
|
||||
}
|
||||
|
||||
pub fn simulate_keystroke(&mut self, keystroke_text: &str) {
|
||||
let keystroke = Keystroke::parse(keystroke_text).unwrap();
|
||||
let input = if keystroke.modified() {
|
||||
None
|
||||
} else {
|
||||
Some(keystroke.key.clone())
|
||||
};
|
||||
self.cx
|
||||
.dispatch_keystroke(self.window_id, keystroke, input, false);
|
||||
}
|
||||
|
||||
pub fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) {
|
||||
for keystroke_text in keystroke_texts.into_iter() {
|
||||
self.simulate_keystroke(keystroke_text);
|
||||
}
|
||||
}
|
||||
|
||||
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| {
|
||||
let snapshot = editor.snapshot(cx);
|
||||
(
|
||||
actual_head.to_offset(&snapshot, Bias::Left),
|
||||
expected_offset.to_display_point(&snapshot),
|
||||
)
|
||||
});
|
||||
let mut actual_position_text = self.editor_text();
|
||||
let mut expected_position_text = actual_position_text.clone();
|
||||
actual_position_text.insert(actual_offset, '|');
|
||||
expected_position_text.insert(expected_offset, '|');
|
||||
assert_eq!(
|
||||
actual_head, expected_head,
|
||||
"\nActual Position: {}\nExpected Position: {}",
|
||||
actual_position_text, expected_position_text
|
||||
)
|
||||
}
|
||||
|
||||
pub fn assert_editor_state(&mut self, text: &str) {
|
||||
let (unmarked_text, markers) = marked_text(&text);
|
||||
let editor_text = self.editor_text();
|
||||
assert_eq!(
|
||||
editor_text, unmarked_text,
|
||||
"Unmarked text doesn't match editor text"
|
||||
);
|
||||
let expected_offset = markers[0];
|
||||
let actual_head = self.newest_selection().head();
|
||||
let (actual_offset, expected_head) = self.editor.update(self.cx, |editor, cx| {
|
||||
let snapshot = editor.snapshot(cx);
|
||||
(
|
||||
actual_head.to_offset(&snapshot, Bias::Left),
|
||||
expected_offset.to_display_point(&snapshot),
|
||||
)
|
||||
});
|
||||
let mut actual_position_text = self.editor_text();
|
||||
let mut expected_position_text = actual_position_text.clone();
|
||||
actual_position_text.insert(actual_offset, '|');
|
||||
expected_position_text.insert(expected_offset, '|');
|
||||
assert_eq!(
|
||||
actual_head, expected_head,
|
||||
"\nActual Position: {}\nExpected Position: {}",
|
||||
actual_position_text, expected_position_text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Deref for VimTestContext<'a> {
|
||||
type Target = gpui::TestAppContext;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.cx
|
||||
}
|
||||
}
|
|
@ -1,253 +0,0 @@
|
|||
use indoc::indoc;
|
||||
use std::ops::Deref;
|
||||
|
||||
use editor::{display_map::ToDisplayPoint, DisplayPoint};
|
||||
use gpui::{json::json, keymap::Keystroke, ViewHandle};
|
||||
use language::{Point, Selection};
|
||||
use util::test::marked_text;
|
||||
use workspace::{WorkspaceHandle, WorkspaceParams};
|
||||
|
||||
use crate::*;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_insert_mode(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestAppContext::new(cx, true, "").await;
|
||||
cx.simulate_keystroke("i");
|
||||
assert_eq!(cx.mode(), Mode::Insert);
|
||||
cx.simulate_keystrokes(&["T", "e", "s", "t"]);
|
||||
cx.assert_newest_selection_head("Test|");
|
||||
cx.simulate_keystroke("escape");
|
||||
assert_eq!(cx.mode(), Mode::Normal);
|
||||
cx.assert_newest_selection_head("Tes|t");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_normal_hjkl(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestAppContext::new(cx, true, "Test\nTestTest\nTest").await;
|
||||
cx.simulate_keystroke("l");
|
||||
cx.assert_newest_selection_head(indoc! {"
|
||||
T|est
|
||||
TestTest
|
||||
Test"});
|
||||
cx.simulate_keystroke("h");
|
||||
cx.assert_newest_selection_head(indoc! {"
|
||||
|Test
|
||||
TestTest
|
||||
Test"});
|
||||
cx.simulate_keystroke("j");
|
||||
cx.assert_newest_selection_head(indoc! {"
|
||||
Test
|
||||
|TestTest
|
||||
Test"});
|
||||
cx.simulate_keystroke("k");
|
||||
cx.assert_newest_selection_head(indoc! {"
|
||||
|Test
|
||||
TestTest
|
||||
Test"});
|
||||
cx.simulate_keystroke("j");
|
||||
cx.assert_newest_selection_head(indoc! {"
|
||||
Test
|
||||
|TestTest
|
||||
Test"});
|
||||
|
||||
// When moving left, cursor does not wrap to the previous line
|
||||
cx.simulate_keystroke("h");
|
||||
cx.assert_newest_selection_head(indoc! {"
|
||||
Test
|
||||
|TestTest
|
||||
Test"});
|
||||
|
||||
// When moving right, cursor does not reach the line end or wrap to the next line
|
||||
for _ in 0..9 {
|
||||
cx.simulate_keystroke("l");
|
||||
}
|
||||
cx.assert_newest_selection_head(indoc! {"
|
||||
Test
|
||||
TestTes|t
|
||||
Test"});
|
||||
|
||||
// Goal column respects the inability to reach the end of the line
|
||||
cx.simulate_keystroke("k");
|
||||
cx.assert_newest_selection_head(indoc! {"
|
||||
Tes|t
|
||||
TestTest
|
||||
Test"});
|
||||
cx.simulate_keystroke("j");
|
||||
cx.assert_newest_selection_head(indoc! {"
|
||||
Test
|
||||
TestTes|t
|
||||
Test"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestAppContext::new(cx, true, "").await;
|
||||
|
||||
cx.simulate_keystroke("i");
|
||||
assert_eq!(cx.mode(), Mode::Insert);
|
||||
|
||||
// Editor acts as though vim is disabled
|
||||
cx.disable_vim();
|
||||
cx.simulate_keystrokes(&["h", "j", "k", "l"]);
|
||||
cx.assert_newest_selection_head("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.editor_text(), "hjkl".to_owned());
|
||||
cx.assert_newest_selection_head("hj|kl");
|
||||
cx.simulate_keystrokes(&["i", "T", "e", "s", "t"]);
|
||||
cx.assert_newest_selection_head("hjTest|kl");
|
||||
|
||||
// Disabling and enabling resets to normal mode
|
||||
assert_eq!(cx.mode(), Mode::Insert);
|
||||
cx.disable_vim();
|
||||
assert_eq!(cx.mode(), Mode::Insert);
|
||||
cx.enable_vim();
|
||||
assert_eq!(cx.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestAppContext::new(cx, false, "").await;
|
||||
cx.simulate_keystrokes(&["h", "j", "k", "l"]);
|
||||
cx.assert_newest_selection_head("hjkl|");
|
||||
}
|
||||
|
||||
struct VimTestAppContext<'a> {
|
||||
cx: &'a mut gpui::TestAppContext,
|
||||
window_id: usize,
|
||||
editor: ViewHandle<Editor>,
|
||||
}
|
||||
|
||||
impl<'a> VimTestAppContext<'a> {
|
||||
async fn new(
|
||||
cx: &'a mut gpui::TestAppContext,
|
||||
enabled: bool,
|
||||
initial_editor_text: &str,
|
||||
) -> VimTestAppContext<'a> {
|
||||
cx.update(|cx| {
|
||||
editor::init(cx);
|
||||
crate::init(cx);
|
||||
});
|
||||
let params = cx.update(WorkspaceParams::test);
|
||||
|
||||
cx.update(|cx| {
|
||||
cx.update_global(|settings: &mut Settings, _| {
|
||||
settings.vim_mode = enabled;
|
||||
});
|
||||
});
|
||||
|
||||
params
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree(
|
||||
"/root",
|
||||
json!({ "dir": { "test.txt": initial_editor_text } }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
|
||||
params
|
||||
.project
|
||||
.update(cx, |project, cx| {
|
||||
project.find_or_create_local_worktree("/root", true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
|
||||
.await;
|
||||
|
||||
let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
|
||||
let item = workspace
|
||||
.update(cx, |workspace, cx| workspace.open_path(file, cx))
|
||||
.await
|
||||
.expect("Could not open test file");
|
||||
|
||||
let editor = cx.update(|cx| {
|
||||
item.act_as::<Editor>(cx)
|
||||
.expect("Opened test file wasn't an editor")
|
||||
});
|
||||
editor.update(cx, |_, cx| cx.focus_self());
|
||||
|
||||
Self {
|
||||
cx,
|
||||
window_id,
|
||||
editor,
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_vim(&mut self) {
|
||||
self.cx.update(|cx| {
|
||||
cx.update_global(|settings: &mut Settings, _| {
|
||||
settings.vim_mode = true;
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn disable_vim(&mut self) {
|
||||
self.cx.update(|cx| {
|
||||
cx.update_global(|settings: &mut Settings, _| {
|
||||
settings.vim_mode = false;
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn newest_selection(&mut self) -> Selection<DisplayPoint> {
|
||||
self.editor.update(self.cx, |editor, cx| {
|
||||
let snapshot = editor.snapshot(cx);
|
||||
editor
|
||||
.newest_selection::<Point>(cx)
|
||||
.map(|point| point.to_display_point(&snapshot.display_snapshot))
|
||||
})
|
||||
}
|
||||
|
||||
fn mode(&mut self) -> Mode {
|
||||
self.cx.update(|cx| cx.global::<VimState>().mode)
|
||||
}
|
||||
|
||||
fn editor_text(&mut self) -> String {
|
||||
self.editor
|
||||
.update(self.cx, |editor, cx| editor.snapshot(cx).text())
|
||||
}
|
||||
|
||||
fn simulate_keystroke(&mut self, keystroke_text: &str) {
|
||||
let keystroke = Keystroke::parse(keystroke_text).unwrap();
|
||||
let input = if keystroke.modified() {
|
||||
None
|
||||
} else {
|
||||
Some(keystroke.key.clone())
|
||||
};
|
||||
self.cx
|
||||
.dispatch_keystroke(self.window_id, keystroke, input, false);
|
||||
}
|
||||
|
||||
fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) {
|
||||
for keystroke_text in keystroke_texts.into_iter() {
|
||||
self.simulate_keystroke(keystroke_text);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_newest_selection_head(&mut self, text: &str) {
|
||||
let (unmarked_text, markers) = marked_text(&text);
|
||||
assert_eq!(
|
||||
self.editor_text(),
|
||||
unmarked_text,
|
||||
"Unmarked text doesn't match editor text"
|
||||
);
|
||||
let newest_selection = self.newest_selection();
|
||||
let expected_head = self.editor.update(self.cx, |editor, cx| {
|
||||
markers[0].to_display_point(&editor.snapshot(cx))
|
||||
});
|
||||
assert_eq!(newest_selection.head(), expected_head)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Deref for VimTestAppContext<'a> {
|
||||
type Target = gpui::TestAppContext;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.cx
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue