Add initial vim mode mode switching

Co-authored-by: Nathan Sobo <nathan@zed.dev>
This commit is contained in:
Keith Simmons 2022-03-24 19:24:36 -07:00
parent ccc276da7a
commit bb9b36dccd
16 changed files with 683 additions and 49 deletions

23
crates/vim/Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "vim"
version = "0.1.0"
edition = "2021"
[lib]
path = "src/vim.rs"
doctest = false
[dependencies]
collections = { path = "../collections" }
editor = { path = "../editor" }
gpui = { path = "../gpui" }
language = { path = "../language" }
workspace = { path = "../workspace" }
log = "0.4"
[dev-dependencies]
editor = { path = "../editor", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] }
project = { path = "../project", features = ["test-support"] }
language = { path = "../language", features = ["test-support"] }
workspace = { path = "../workspace", features = ["test-support"] }

View file

@ -0,0 +1,57 @@
use editor::{EditorBlurred, EditorCreated, EditorFocused, EditorMode, EditorReleased};
use gpui::MutableAppContext;
use crate::{mode::Mode, SwitchMode, VimState};
pub fn init(cx: &mut MutableAppContext) {
cx.subscribe_global(editor_created).detach();
cx.subscribe_global(editor_focused).detach();
cx.subscribe_global(editor_blurred).detach();
cx.subscribe_global(editor_released).detach();
}
fn editor_created(EditorCreated(editor): &EditorCreated, cx: &mut MutableAppContext) {
cx.update_default_global(|vim_state: &mut VimState, cx| {
vim_state.editors.insert(editor.id(), editor.downgrade());
if vim_state.enabled {
VimState::update_cursor_shapes(cx);
}
})
}
fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppContext) {
let mode = if matches!(editor.read(cx).mode(), EditorMode::SingleLine) {
Mode::Insert
} else {
Mode::Normal
};
cx.update_default_global(|vim_state: &mut VimState, _| {
vim_state.active_editor = Some(editor.downgrade());
});
VimState::switch_mode(&SwitchMode(mode), cx);
}
fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppContext) {
cx.update_default_global(|vim_state: &mut VimState, _| {
if let Some(previous_editor) = vim_state.active_editor.clone() {
if previous_editor == editor.clone() {
vim_state.active_editor = None;
}
}
});
editor.update(cx, |editor, _| {
editor.remove_keymap_context_layer::<VimState>();
})
}
fn editor_released(EditorReleased(editor): &EditorReleased, cx: &mut MutableAppContext) {
cx.update_default_global(|vim_state: &mut VimState, _| {
vim_state.editors.remove(&editor.id());
if let Some(previous_editor) = vim_state.active_editor.clone() {
if previous_editor == editor.clone() {
vim_state.active_editor = None;
}
}
});
}

View file

@ -0,0 +1,100 @@
use editor::{display_map::DisplaySnapshot, Bias, DisplayPoint, Editor};
use gpui::ViewContext;
use language::{Selection, SelectionGoal};
pub trait VimEditorExt {
fn adjust_selections(self: &mut Self, cx: &mut ViewContext<Self>);
fn adjusted_move_selections(
self: &mut Self,
cx: &mut ViewContext<Self>,
move_selection: impl Fn(&DisplaySnapshot, &mut Selection<DisplayPoint>),
);
fn adjusted_move_selection_heads(
&mut self,
cx: &mut ViewContext<Self>,
update_head: impl Fn(
&DisplaySnapshot,
DisplayPoint,
SelectionGoal,
) -> (DisplayPoint, SelectionGoal),
);
fn adjusted_move_cursors(
self: &mut Self,
cx: &mut ViewContext<Self>,
update_cursor_position: impl Fn(
&DisplaySnapshot,
DisplayPoint,
SelectionGoal,
) -> (DisplayPoint, SelectionGoal),
);
}
pub fn adjust_display_point(
map: &DisplaySnapshot,
mut display_point: DisplayPoint,
) -> DisplayPoint {
let next_char = map.chars_at(display_point).next();
if next_char == Some('\n') || next_char == None {
*display_point.column_mut() = display_point.column().saturating_sub(1);
display_point = map.clip_point(display_point, Bias::Left);
}
display_point
}
impl VimEditorExt for Editor {
fn adjust_selections(self: &mut Self, cx: &mut ViewContext<Self>) {
self.move_selections(cx, |map, selection| {
if selection.is_empty() {
let adjusted_cursor = adjust_display_point(map, selection.start);
selection.collapse_to(adjusted_cursor, selection.goal);
} else {
let adjusted_head = adjust_display_point(map, selection.head());
selection.set_head(adjusted_head, selection.goal);
}
})
}
fn adjusted_move_selections(
self: &mut Self,
cx: &mut ViewContext<Self>,
move_selection: impl Fn(&DisplaySnapshot, &mut Selection<DisplayPoint>),
) {
self.move_selections(cx, |map, selection| {
move_selection(map, selection);
let adjusted_head = adjust_display_point(map, selection.head());
selection.set_head(adjusted_head, selection.goal);
})
}
fn adjusted_move_selection_heads(
&mut self,
cx: &mut ViewContext<Self>,
update_head: impl Fn(
&DisplaySnapshot,
DisplayPoint,
SelectionGoal,
) -> (DisplayPoint, SelectionGoal),
) {
self.adjusted_move_selections(cx, |map, selection| {
let (new_head, new_goal) = update_head(map, selection.head(), selection.goal);
let adjusted_head = adjust_display_point(map, new_head);
selection.set_head(adjusted_head, new_goal);
});
}
fn adjusted_move_cursors(
self: &mut Self,
cx: &mut ViewContext<Self>,
update_cursor_position: impl Fn(
&DisplaySnapshot,
DisplayPoint,
SelectionGoal,
) -> (DisplayPoint, SelectionGoal),
) {
self.move_selections(cx, |map, selection| {
let (cursor, new_goal) = update_cursor_position(map, selection.head(), selection.goal);
let adjusted_cursor = adjust_display_point(map, cursor);
selection.collapse_to(adjusted_cursor, new_goal);
});
}
}

28
crates/vim/src/insert.rs Normal file
View file

@ -0,0 +1,28 @@
use editor::Bias;
use gpui::{action, keymap::Binding, MutableAppContext, ViewContext};
use language::SelectionGoal;
use workspace::Workspace;
use crate::{editor_utils::VimEditorExt, mode::Mode, SwitchMode, VimState};
action!(NormalBefore);
pub fn init(cx: &mut MutableAppContext) {
let context = Some("Editor && vim_mode == insert");
cx.add_bindings(vec![
Binding::new("escape", NormalBefore, context),
Binding::new("ctrl-c", NormalBefore, context),
]);
cx.add_action(normal_before);
}
fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Workspace>) {
VimState::switch_mode(&SwitchMode(Mode::Normal), cx);
VimState::update_active_editor(cx, |editor, cx| {
editor.adjusted_move_cursors(cx, |map, mut cursor, _| {
*cursor.column_mut() = cursor.column().saturating_sub(1);
(map.clip_point(cursor, Bias::Left), SelectionGoal::None)
});
});
}

36
crates/vim/src/mode.rs Normal file
View file

@ -0,0 +1,36 @@
use editor::CursorShape;
use gpui::keymap::Context;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Normal,
Insert,
}
impl Mode {
pub fn cursor_shape(&self) -> CursorShape {
match self {
Mode::Normal => CursorShape::Block,
Mode::Insert => CursorShape::Bar,
}
}
pub fn keymap_context_layer(&self) -> Context {
let mut context = Context::default();
context.map.insert(
"vim_mode".to_string(),
match self {
Self::Normal => "normal",
Self::Insert => "insert",
}
.to_string(),
);
context
}
}
impl Default for Mode {
fn default() -> Self {
Self::Normal
}
}

58
crates/vim/src/normal.rs Normal file
View file

@ -0,0 +1,58 @@
use editor::{movement, Bias};
use gpui::{action, keymap::Binding, MutableAppContext, ViewContext};
use language::SelectionGoal;
use workspace::Workspace;
use crate::{editor_utils::VimEditorExt, Mode, SwitchMode, VimState};
action!(InsertBefore);
action!(MoveLeft);
action!(MoveDown);
action!(MoveUp);
action!(MoveRight);
pub fn init(cx: &mut MutableAppContext) {
let context = Some("Editor && vim_mode == normal");
cx.add_bindings(vec![
Binding::new("i", SwitchMode(Mode::Insert), context),
Binding::new("h", MoveLeft, context),
Binding::new("j", MoveDown, context),
Binding::new("k", MoveUp, context),
Binding::new("l", MoveRight, context),
]);
cx.add_action(move_left);
cx.add_action(move_down);
cx.add_action(move_up);
cx.add_action(move_right);
}
fn move_left(_: &mut Workspace, _: &MoveLeft, cx: &mut ViewContext<Workspace>) {
VimState::update_active_editor(cx, |editor, cx| {
editor.adjusted_move_cursors(cx, |map, mut cursor, _| {
*cursor.column_mut() = cursor.column().saturating_sub(1);
(map.clip_point(cursor, Bias::Left), SelectionGoal::None)
});
});
}
fn move_down(_: &mut Workspace, _: &MoveDown, cx: &mut ViewContext<Workspace>) {
VimState::update_active_editor(cx, |editor, cx| {
editor.adjusted_move_cursors(cx, movement::down);
});
}
fn move_up(_: &mut Workspace, _: &MoveUp, cx: &mut ViewContext<Workspace>) {
VimState::update_active_editor(cx, |editor, cx| {
editor.adjusted_move_cursors(cx, movement::up);
});
}
fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext<Workspace>) {
VimState::update_active_editor(cx, |editor, cx| {
editor.adjusted_move_cursors(cx, |map, mut cursor, _| {
*cursor.column_mut() += 1;
(map.clip_point(cursor, Bias::Right), SelectionGoal::None)
});
});
}

98
crates/vim/src/vim.rs Normal file
View file

@ -0,0 +1,98 @@
mod editor_events;
mod editor_utils;
mod insert;
mod mode;
mod normal;
#[cfg(test)]
mod vim_tests;
use collections::HashMap;
use editor::Editor;
use editor_utils::VimEditorExt;
use gpui::{action, MutableAppContext, ViewContext, WeakViewHandle};
use mode::Mode;
use workspace::{self, Settings, Workspace};
action!(SwitchMode, Mode);
pub fn init(cx: &mut MutableAppContext) {
editor_events::init(cx);
insert::init(cx);
normal::init(cx);
cx.add_action(|_: &mut Workspace, action: &SwitchMode, cx| VimState::switch_mode(action, cx));
cx.observe_global::<Settings, _>(VimState::settings_changed)
.detach();
}
#[derive(Default)]
pub struct VimState {
editors: HashMap<usize, WeakViewHandle<Editor>>,
active_editor: Option<WeakViewHandle<Editor>>,
enabled: bool,
mode: Mode,
}
impl VimState {
fn update_active_editor<S>(
cx: &mut MutableAppContext,
update: impl FnOnce(&mut Editor, &mut ViewContext<Editor>) -> S,
) -> Option<S> {
cx.global::<Self>()
.active_editor
.clone()
.and_then(|ae| ae.upgrade(cx))
.map(|ae| ae.update(cx, update))
}
fn switch_mode(SwitchMode(mode): &SwitchMode, cx: &mut MutableAppContext) {
let active_editor = cx.update_default_global(|this: &mut Self, _| {
this.mode = *mode;
this.active_editor.clone()
});
if let Some(active_editor) = active_editor.and_then(|e| e.upgrade(cx)) {
active_editor.update(cx, |active_editor, cx| {
active_editor.set_keymap_context_layer::<Self>(mode.keymap_context_layer());
active_editor.set_input_enabled(*mode == Mode::Insert);
if *mode != Mode::Insert {
active_editor.adjust_selections(cx);
}
});
}
VimState::update_cursor_shapes(cx);
}
fn settings_changed(cx: &mut MutableAppContext) {
cx.update_default_global(|this: &mut Self, cx| {
let settings = cx.global::<Settings>();
if this.enabled != settings.vim_mode {
this.enabled = settings.vim_mode;
this.mode = if settings.vim_mode {
Mode::Normal
} else {
Mode::Insert
};
Self::update_cursor_shapes(cx);
}
});
}
fn update_cursor_shapes(cx: &mut MutableAppContext) {
cx.defer(move |cx| {
cx.update_default_global(|this: &mut VimState, cx| {
let cursor_shape = this.mode.cursor_shape();
for editor in this.editors.values() {
if let Some(editor) = editor.upgrade(cx) {
editor.update(cx, |editor, cx| {
editor.set_cursor_shape(cursor_shape, cx);
});
}
}
});
});
}
}

152
crates/vim/src/vim_tests.rs Normal file
View file

@ -0,0 +1,152 @@
use std::ops::Deref;
use editor::{display_map::ToDisplayPoint, DisplayPoint};
use gpui::{json::json, keymap::Keystroke, AppContext, ViewHandle};
use language::{Point, Selection};
use workspace::{WorkspaceHandle, WorkspaceParams};
use crate::*;
#[gpui::test]
async fn test_insert_mode(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestAppContext::new(cx, "").await;
assert_eq!(cx.mode(), Mode::Normal);
cx.simulate_keystroke("i");
assert_eq!(cx.mode(), Mode::Insert);
cx.simulate_keystrokes(&["T", "e", "s", "t"]);
assert_eq!(cx.editor_text(), "Test".to_owned());
cx.simulate_keystroke("escape");
assert_eq!(cx.mode(), Mode::Normal);
}
#[gpui::test]
async fn test_normal_hjkl(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestAppContext::new(cx, "Test\nTestTest\nTest").await;
assert_eq!(cx.mode(), Mode::Normal);
cx.simulate_keystroke("l");
assert_eq!(cx.newest_selection().head(), DisplayPoint::new(0, 1));
cx.simulate_keystroke("h");
assert_eq!(cx.newest_selection().head(), DisplayPoint::new(0, 0));
cx.simulate_keystroke("j");
assert_eq!(cx.newest_selection().head(), DisplayPoint::new(1, 0));
cx.simulate_keystroke("k");
assert_eq!(cx.newest_selection().head(), DisplayPoint::new(0, 0));
cx.simulate_keystroke("j");
assert_eq!(cx.newest_selection().head(), DisplayPoint::new(1, 0));
// When moving left, cursor does not wrap to the previous line
cx.simulate_keystroke("h");
assert_eq!(cx.newest_selection().head(), DisplayPoint::new(1, 0));
// When moving right, cursor does not reach the line end or wrap to the next line
for _ in 0..9 {
cx.simulate_keystroke("l");
}
assert_eq!(cx.newest_selection().head(), DisplayPoint::new(1, 7));
// Goal column respects the inability to reach the end of the line
cx.simulate_keystroke("k");
assert_eq!(cx.newest_selection().head(), DisplayPoint::new(0, 3));
cx.simulate_keystroke("j");
assert_eq!(cx.newest_selection().head(), DisplayPoint::new(1, 7));
}
struct VimTestAppContext<'a> {
cx: &'a mut gpui::TestAppContext,
window_id: usize,
editor: ViewHandle<Editor>,
}
impl<'a> VimTestAppContext<'a> {
async fn new(
cx: &'a mut gpui::TestAppContext,
initial_editor_text: &str,
) -> VimTestAppContext<'a> {
cx.update(|cx| {
editor::init(cx);
crate::init(cx);
});
let params = cx.update(WorkspaceParams::test);
params
.fs
.as_fake()
.insert_tree(
"/root",
json!({ "dir": { "test.txt": initial_editor_text } }),
)
.await;
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
params
.project
.update(cx, |project, cx| {
project.find_or_create_local_worktree("/root", true, cx)
})
.await
.unwrap();
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
.await;
let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
let item = workspace
.update(cx, |workspace, cx| workspace.open_path(file, cx))
.await
.expect("Could not open test file");
let editor = cx.update(|cx| {
item.act_as::<Editor>(cx)
.expect("Opened test file wasn't an editor")
});
editor.update(cx, |_, cx| cx.focus_self());
Self {
cx,
window_id,
editor,
}
}
fn newest_selection(&mut self) -> Selection<DisplayPoint> {
self.editor.update(self.cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
editor
.newest_selection::<Point>(cx)
.map(|point| point.to_display_point(&snapshot.display_snapshot))
})
}
fn mode(&mut self) -> Mode {
self.cx.update(|cx| cx.global::<VimState>().mode)
}
fn editor_text(&mut self) -> String {
self.editor
.update(self.cx, |editor, cx| editor.snapshot(cx).text())
}
fn simulate_keystroke(&mut self, keystroke_text: &str) {
let keystroke = Keystroke::parse(keystroke_text).unwrap();
let input = if keystroke.modified() {
None
} else {
Some(keystroke.key.clone())
};
self.cx
.dispatch_keystroke(self.window_id, keystroke, input, false);
}
fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) {
for keystroke_text in keystroke_texts.into_iter() {
self.simulate_keystroke(keystroke_text);
}
}
}
impl<'a> Deref for VimTestAppContext<'a> {
type Target = gpui::TestAppContext;
fn deref(&self) -> &Self::Target {
self.cx
}
}