mirror of
https://github.com/zed-industries/zed.git
synced 2024-12-27 23:59:52 +03:00
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:
parent
670757e5c9
commit
63278041e1
@ -1,58 +1,93 @@
|
||||
{
|
||||
"Editor && vim_mode == insert": {
|
||||
"escape": "vim::NormalBefore",
|
||||
"ctrl-c": "vim::NormalBefore"
|
||||
},
|
||||
"Editor && vim_mode == normal && vim_submode == g": {
|
||||
"g": "vim::MoveToStart",
|
||||
"escape": [
|
||||
"vim::SwitchMode",
|
||||
{
|
||||
"Normal": "None"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Editor && vim_mode == normal": {
|
||||
"Editor && VimControl": {
|
||||
"i": [
|
||||
"vim::SwitchMode",
|
||||
"Insert"
|
||||
],
|
||||
"g": [
|
||||
"vim::SwitchMode",
|
||||
"vim::PushOperator",
|
||||
{
|
||||
"Normal": "GPrefix"
|
||||
"Namespace": "G"
|
||||
}
|
||||
],
|
||||
"h": "vim::MoveLeft",
|
||||
"j": "vim::MoveDown",
|
||||
"k": "vim::MoveUp",
|
||||
"l": "vim::MoveRight",
|
||||
"0": "vim::MoveToStartOfLine",
|
||||
"shift-$": "vim::MoveToEndOfLine",
|
||||
"shift-G": "vim::MoveToEnd",
|
||||
"h": "vim::Left",
|
||||
"j": "vim::Down",
|
||||
"k": "vim::Up",
|
||||
"l": "vim::Right",
|
||||
"0": "vim::StartOfLine",
|
||||
"shift-$": "vim::EndOfLine",
|
||||
"shift-G": "vim::EndOfDocument",
|
||||
"w": "vim::NextWordStart",
|
||||
"shift-W": [
|
||||
"vim::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": [
|
||||
"vim::MoveToNextWordStart",
|
||||
false
|
||||
"vim::NextWordEnd",
|
||||
{
|
||||
"ignorePunctuation": false
|
||||
}
|
||||
],
|
||||
"shift-W": [
|
||||
"vim::MoveToNextWordStart",
|
||||
true
|
||||
"vim::NextWordEnd",
|
||||
{
|
||||
"ignorePunctuation": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Editor && vim_operator == d": {
|
||||
"w": [
|
||||
"vim::NextWordStart",
|
||||
{
|
||||
"ignorePunctuation": false,
|
||||
"stopAtNewline": true
|
||||
}
|
||||
],
|
||||
"e": [
|
||||
"vim::MoveToNextWordEnd",
|
||||
false
|
||||
],
|
||||
"shift-E": [
|
||||
"vim::MoveToNextWordEnd",
|
||||
true
|
||||
],
|
||||
"b": [
|
||||
"vim::MoveToPreviousWordStart",
|
||||
false
|
||||
],
|
||||
"shift-B": [
|
||||
"vim::MoveToPreviousWordStart",
|
||||
true
|
||||
"shift-W": [
|
||||
"vim::NextWordStart",
|
||||
{
|
||||
"ignorePunctuation": true,
|
||||
"stopAtNewline": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
use editor::{EditorBlurred, EditorCreated, EditorFocused, EditorMode, EditorReleased};
|
||||
use gpui::MutableAppContext;
|
||||
|
||||
use crate::{mode::Mode, SwitchMode, VimState};
|
||||
use crate::{state::Mode, Vim};
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
cx.subscribe_global(editor_created).detach();
|
||||
@ -11,9 +11,9 @@ pub fn init(cx: &mut MutableAppContext) {
|
||||
}
|
||||
|
||||
fn editor_created(EditorCreated(editor): &EditorCreated, cx: &mut MutableAppContext) {
|
||||
cx.update_default_global(|vim_state: &mut VimState, cx| {
|
||||
vim_state.editors.insert(editor.id(), editor.downgrade());
|
||||
vim_state.sync_editor_options(cx);
|
||||
cx.update_default_global(|vim: &mut Vim, cx| {
|
||||
vim.editors.insert(editor.id(), editor.downgrade());
|
||||
vim.sync_editor_options(cx);
|
||||
})
|
||||
}
|
||||
|
||||
@ -21,17 +21,17 @@ fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppCont
|
||||
let mode = if matches!(editor.read(cx).mode(), EditorMode::SingleLine) {
|
||||
Mode::Insert
|
||||
} else {
|
||||
Mode::normal()
|
||||
Mode::Normal
|
||||
};
|
||||
|
||||
VimState::update_global(cx, |state, cx| {
|
||||
Vim::update(cx, |state, cx| {
|
||||
state.active_editor = Some(editor.downgrade());
|
||||
state.switch_mode(&SwitchMode(mode), cx);
|
||||
state.switch_mode(mode, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppContext) {
|
||||
VimState::update_global(cx, |state, cx| {
|
||||
Vim::update(cx, |state, cx| {
|
||||
if let Some(previous_editor) = state.active_editor.clone() {
|
||||
if previous_editor == editor.clone() {
|
||||
state.active_editor = None;
|
||||
@ -42,11 +42,11 @@ fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppCont
|
||||
}
|
||||
|
||||
fn editor_released(EditorReleased(editor): &EditorReleased, cx: &mut MutableAppContext) {
|
||||
cx.update_default_global(|vim_state: &mut VimState, _| {
|
||||
vim_state.editors.remove(&editor.id());
|
||||
if let Some(previous_editor) = vim_state.active_editor.clone() {
|
||||
cx.update_default_global(|vim: &mut Vim, _| {
|
||||
vim.editors.remove(&editor.id());
|
||||
if let Some(previous_editor) = vim.active_editor.clone() {
|
||||
if previous_editor == editor.clone() {
|
||||
vim_state.active_editor = None;
|
||||
vim.active_editor = None;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
use crate::{mode::Mode, SwitchMode, VimState};
|
||||
use crate::{state::Mode, Vim};
|
||||
use editor::Bias;
|
||||
use gpui::{actions, MutableAppContext, ViewContext};
|
||||
use language::SelectionGoal;
|
||||
@ -11,30 +11,30 @@ pub fn init(cx: &mut MutableAppContext) {
|
||||
}
|
||||
|
||||
fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Workspace>) {
|
||||
VimState::update_global(cx, |state, cx| {
|
||||
Vim::update(cx, |state, cx| {
|
||||
state.update_active_editor(cx, |editor, cx| {
|
||||
editor.move_cursors(cx, |map, mut cursor, _| {
|
||||
*cursor.column_mut() = cursor.column().saturating_sub(1);
|
||||
(map.clip_point(cursor, Bias::Left), SelectionGoal::None)
|
||||
});
|
||||
});
|
||||
state.switch_mode(&SwitchMode(Mode::normal()), cx);
|
||||
state.switch_mode(Mode::Normal, cx);
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::{mode::Mode, vim_test_context::VimTestContext};
|
||||
use crate::{state::Mode, vim_test_context::VimTestContext};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true, "").await;
|
||||
cx.simulate_keystroke("i");
|
||||
assert_eq!(cx.mode(), Mode::Insert);
|
||||
cx.simulate_keystrokes(&["T", "e", "s", "t"]);
|
||||
cx.simulate_keystrokes(["T", "e", "s", "t"]);
|
||||
cx.assert_editor_state("Test|");
|
||||
cx.simulate_keystroke("escape");
|
||||
assert_eq!(cx.mode(), Mode::normal());
|
||||
assert_eq!(cx.mode(), Mode::Normal);
|
||||
cx.assert_editor_state("Tes|t");
|
||||
}
|
||||
}
|
||||
|
@ -1,73 +0,0 @@
|
||||
use editor::CursorShape;
|
||||
use gpui::keymap::Context;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
|
||||
pub enum Mode {
|
||||
Normal(NormalState),
|
||||
Insert,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn cursor_shape(&self) -> CursorShape {
|
||||
match self {
|
||||
Mode::Normal(_) => CursorShape::Block,
|
||||
Mode::Insert => CursorShape::Bar,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keymap_context_layer(&self) -> Context {
|
||||
let mut context = Context::default();
|
||||
context.map.insert(
|
||||
"vim_mode".to_string(),
|
||||
match self {
|
||||
Self::Normal(_) => "normal",
|
||||
Self::Insert => "insert",
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
match self {
|
||||
Self::Normal(normal_state) => normal_state.set_context(&mut context),
|
||||
_ => {}
|
||||
}
|
||||
context
|
||||
}
|
||||
|
||||
pub fn normal() -> Mode {
|
||||
Mode::Normal(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Mode {
|
||||
fn default() -> Self {
|
||||
Self::Normal(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
|
||||
pub enum NormalState {
|
||||
None,
|
||||
GPrefix,
|
||||
}
|
||||
|
||||
impl NormalState {
|
||||
pub fn set_context(&self, context: &mut Context) {
|
||||
let submode = match self {
|
||||
Self::GPrefix => Some("g"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(submode) = submode {
|
||||
context
|
||||
.map
|
||||
.insert("vim_submode".to_string(), submode.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NormalState {
|
||||
fn default() -> Self {
|
||||
NormalState::None
|
||||
}
|
||||
}
|
296
crates/vim/src/motion.rs
Normal file
296
crates/vim/src/motion.rs
Normal 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)
|
||||
}
|
@ -1,212 +1,77 @@
|
||||
mod g_prefix;
|
||||
|
||||
use crate::VimState;
|
||||
use editor::{char_kind, movement, Bias};
|
||||
use gpui::{actions, impl_actions, MutableAppContext, ViewContext};
|
||||
use crate::{
|
||||
motion::Motion,
|
||||
state::{Mode, Operator},
|
||||
Vim,
|
||||
};
|
||||
use editor::Bias;
|
||||
use gpui::MutableAppContext;
|
||||
use language::SelectionGoal;
|
||||
use serde::Deserialize;
|
||||
use workspace::Workspace;
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
struct MoveToNextWordStart(pub bool);
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
struct MoveToNextWordEnd(pub bool);
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
struct MoveToPreviousWordStart(pub bool);
|
||||
|
||||
impl_actions!(
|
||||
vim,
|
||||
[
|
||||
MoveToNextWordStart,
|
||||
MoveToNextWordEnd,
|
||||
MoveToPreviousWordStart,
|
||||
]
|
||||
);
|
||||
|
||||
actions!(
|
||||
vim,
|
||||
[
|
||||
GPrefix,
|
||||
MoveLeft,
|
||||
MoveDown,
|
||||
MoveUp,
|
||||
MoveRight,
|
||||
MoveToStartOfLine,
|
||||
MoveToEndOfLine,
|
||||
MoveToEnd,
|
||||
]
|
||||
);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
g_prefix::init(cx);
|
||||
cx.add_action(move_left);
|
||||
cx.add_action(move_down);
|
||||
cx.add_action(move_up);
|
||||
cx.add_action(move_right);
|
||||
cx.add_action(move_to_start_of_line);
|
||||
cx.add_action(move_to_end_of_line);
|
||||
cx.add_action(move_to_end);
|
||||
cx.add_action(move_to_next_word_start);
|
||||
cx.add_action(move_to_next_word_end);
|
||||
cx.add_action(move_to_previous_word_start);
|
||||
}
|
||||
|
||||
fn move_left(_: &mut Workspace, _: &MoveLeft, cx: &mut ViewContext<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);
|
||||
});
|
||||
pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
match vim.state.operator_stack.pop() {
|
||||
None => move_cursor(vim, motion, cx),
|
||||
Some(Operator::Change) => change_over(vim, motion, cx),
|
||||
Some(Operator::Delete) => delete_over(vim, motion, cx),
|
||||
Some(Operator::Namespace(_)) => panic!(
|
||||
"Normal mode recieved motion with namespaced operator. Likely this means an invalid keymap was used"),
|
||||
}
|
||||
vim.clear_operator(cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn move_up(_: &mut Workspace, _: &MoveUp, cx: &mut ViewContext<Workspace>) {
|
||||
VimState::update_global(cx, |state, cx| {
|
||||
state.update_active_editor(cx, |editor, cx| {
|
||||
editor.move_cursors(cx, movement::up);
|
||||
});
|
||||
fn move_cursor(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
|
||||
vim.update_active_editor(cx, |editor, cx| {
|
||||
editor.move_cursors(cx, |map, cursor, goal| motion.move_point(map, cursor, goal))
|
||||
});
|
||||
}
|
||||
|
||||
fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext<Workspace>) {
|
||||
VimState::update_global(cx, |state, cx| {
|
||||
state.update_active_editor(cx, |editor, cx| {
|
||||
editor.move_cursors(cx, |map, mut cursor, _| {
|
||||
*cursor.column_mut() += 1;
|
||||
(map.clip_point(cursor, Bias::Right), SelectionGoal::None)
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
|
||||
vim.update_active_editor(cx, |editor, cx| {
|
||||
editor.transact(cx, |editor, cx| {
|
||||
// Don't clip at line ends during change operation
|
||||
editor.set_clip_at_line_ends(false, cx);
|
||||
editor.move_selections(cx, |map, selection| motion.expand_selection(map, selection));
|
||||
editor.set_clip_at_line_ends(true, cx);
|
||||
match motion {
|
||||
Motion::Up => editor.insert(&"\n", cx),
|
||||
Motion::Down => editor.insert(&"\n", cx),
|
||||
_ => editor.insert(&"", cx),
|
||||
}
|
||||
|
||||
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
|
||||
if let Motion::Up = motion {
|
||||
// Position cursor on previous line after change
|
||||
editor.move_cursors(cx, |map, cursor, goal| {
|
||||
Motion::Up.move_point(map, cursor, goal)
|
||||
});
|
||||
(cursor, SelectionGoal::None)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
vim.switch_mode(Mode::Insert, cx)
|
||||
}
|
||||
|
||||
fn move_to_next_word_end(
|
||||
_: &mut Workspace,
|
||||
&MoveToNextWordEnd(treat_punctuation_as_word): &MoveToNextWordEnd,
|
||||
cx: &mut ViewContext<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);
|
||||
fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
|
||||
vim.update_active_editor(cx, |editor, cx| {
|
||||
editor.transact(cx, |editor, cx| {
|
||||
// Don't clip at line ends during delete operation
|
||||
editor.set_clip_at_line_ends(false, cx);
|
||||
editor.move_selections(cx, |map, selection| motion.expand_selection(map, selection));
|
||||
match motion {
|
||||
Motion::Up => editor.insert(&"\n", cx),
|
||||
Motion::Down => editor.insert(&"\n", cx),
|
||||
_ => editor.insert(&"", cx),
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
// Fixup cursor position after the deletion
|
||||
editor.set_clip_at_line_ends(true, cx);
|
||||
editor.move_selection_heads(cx, |map, head, _| {
|
||||
(map.clip_point(head, Bias::Left), SelectionGoal::None)
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -217,7 +82,13 @@ mod test {
|
||||
use indoc::indoc;
|
||||
use util::test::marked_text;
|
||||
|
||||
use crate::vim_test_context::VimTestContext;
|
||||
use crate::{
|
||||
state::{
|
||||
Mode::{self, *},
|
||||
Namespace, Operator,
|
||||
},
|
||||
vim_test_context::VimTestContext,
|
||||
};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_hjkl(cx: &mut gpui::TestAppContext) {
|
||||
@ -362,7 +233,7 @@ mod test {
|
||||
}
|
||||
|
||||
// Reset and test ignoring punctuation
|
||||
cx.simulate_keystrokes(&["g", "g"]);
|
||||
cx.simulate_keystrokes(["g", "g"]);
|
||||
let (_, cursor_offsets) = marked_text(indoc! {"
|
||||
The |quick-brown
|
||||
|
|
||||
@ -392,7 +263,7 @@ mod test {
|
||||
}
|
||||
|
||||
// Reset and test ignoring punctuation
|
||||
cx.simulate_keystrokes(&["g", "g"]);
|
||||
cx.simulate_keystrokes(["g", "g"]);
|
||||
let (_, cursor_offsets) = marked_text(indoc! {"
|
||||
Th|e quick-brow|n
|
||||
|
||||
@ -434,4 +305,232 @@ mod test {
|
||||
cx.assert_newest_selection_head_offset(cursor_offset);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_g_prefix_and_abort(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true, "").await;
|
||||
|
||||
// Can abort with escape to get back to normal mode
|
||||
cx.simulate_keystroke("g");
|
||||
assert_eq!(cx.mode(), Normal);
|
||||
assert_eq!(
|
||||
cx.active_operator(),
|
||||
Some(Operator::Namespace(Namespace::G))
|
||||
);
|
||||
cx.simulate_keystroke("escape");
|
||||
assert_eq!(cx.mode(), Normal);
|
||||
assert_eq!(cx.active_operator(), None);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_move_to_start(cx: &mut gpui::TestAppContext) {
|
||||
let initial_content = indoc! {"
|
||||
The quick
|
||||
|
||||
brown fox jumps
|
||||
over the lazy dog"};
|
||||
let mut cx = VimTestContext::new(cx, true, initial_content).await;
|
||||
|
||||
// Jump to the end to
|
||||
cx.simulate_keystroke("shift-G");
|
||||
cx.assert_editor_state(indoc! {"
|
||||
The quick
|
||||
|
||||
brown fox jumps
|
||||
over the lazy do|g"});
|
||||
|
||||
// Jump to the start
|
||||
cx.simulate_keystrokes(["g", "g"]);
|
||||
cx.assert_editor_state(indoc! {"
|
||||
|The quick
|
||||
|
||||
brown fox jumps
|
||||
over the lazy dog"});
|
||||
assert_eq!(cx.mode(), Normal);
|
||||
assert_eq!(cx.active_operator(), None);
|
||||
|
||||
// Repeat action doesn't change
|
||||
cx.simulate_keystrokes(["g", "g"]);
|
||||
cx.assert_editor_state(indoc! {"
|
||||
|The quick
|
||||
|
||||
brown fox jumps
|
||||
over the lazy dog"});
|
||||
assert_eq!(cx.mode(), Normal);
|
||||
assert_eq!(cx.active_operator(), None);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_change(cx: &mut gpui::TestAppContext) {
|
||||
fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) {
|
||||
cx.assert_binding(
|
||||
["c", motion],
|
||||
initial_state,
|
||||
Mode::Normal,
|
||||
state_after,
|
||||
Mode::Insert,
|
||||
);
|
||||
}
|
||||
let cx = &mut VimTestContext::new(cx, true, "").await;
|
||||
assert("h", "Te|st", "T|st", cx);
|
||||
assert("l", "Te|st", "Te|t", cx);
|
||||
assert("w", "|Test", "|", cx);
|
||||
assert("w", "Te|st", "Te|", cx);
|
||||
assert("w", "Te|st Test", "Te| Test", cx);
|
||||
assert("e", "Te|st Test", "Te| Test", cx);
|
||||
assert("b", "Te|st", "|st", cx);
|
||||
assert("b", "Test Te|st", "Test |st", cx);
|
||||
assert(
|
||||
"w",
|
||||
indoc! {"
|
||||
The quick
|
||||
brown |fox
|
||||
jumps over"},
|
||||
indoc! {"
|
||||
The quick
|
||||
brown |
|
||||
jumps over"},
|
||||
cx,
|
||||
);
|
||||
assert(
|
||||
"shift-W",
|
||||
indoc! {"
|
||||
The quick
|
||||
brown |fox-fox
|
||||
jumps over"},
|
||||
indoc! {"
|
||||
The quick
|
||||
brown |
|
||||
jumps over"},
|
||||
cx,
|
||||
);
|
||||
assert(
|
||||
"k",
|
||||
indoc! {"
|
||||
The quick
|
||||
brown |fox"},
|
||||
indoc! {"
|
||||
|
|
||||
"},
|
||||
cx,
|
||||
);
|
||||
assert(
|
||||
"j",
|
||||
indoc! {"
|
||||
The q|uick
|
||||
brown fox"},
|
||||
indoc! {"
|
||||
|
||||
|"},
|
||||
cx,
|
||||
);
|
||||
assert(
|
||||
"shift-$",
|
||||
indoc! {"
|
||||
The q|uick
|
||||
brown fox"},
|
||||
indoc! {"
|
||||
The q|
|
||||
brown fox"},
|
||||
cx,
|
||||
);
|
||||
assert(
|
||||
"0",
|
||||
indoc! {"
|
||||
The q|uick
|
||||
brown fox"},
|
||||
indoc! {"
|
||||
|uick
|
||||
brown fox"},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_delete(cx: &mut gpui::TestAppContext) {
|
||||
fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) {
|
||||
cx.assert_binding(
|
||||
["d", motion],
|
||||
initial_state,
|
||||
Mode::Normal,
|
||||
state_after,
|
||||
Mode::Normal,
|
||||
);
|
||||
}
|
||||
let cx = &mut VimTestContext::new(cx, true, "").await;
|
||||
assert("h", "Te|st", "T|st", cx);
|
||||
assert("l", "Te|st", "Te|t", cx);
|
||||
assert("w", "|Test", "|", cx);
|
||||
assert("w", "Te|st", "T|e", cx);
|
||||
assert("w", "Te|st Test", "Te|Test", cx);
|
||||
assert("e", "Te|st Test", "Te| Test", cx);
|
||||
assert("b", "Te|st", "|st", cx);
|
||||
assert("b", "Test Te|st", "Test |st", cx);
|
||||
assert(
|
||||
"w",
|
||||
indoc! {"
|
||||
The quick
|
||||
brown |fox
|
||||
jumps over"},
|
||||
// Trailing space after cursor
|
||||
indoc! {"
|
||||
The quick
|
||||
brown|
|
||||
jumps over"},
|
||||
cx,
|
||||
);
|
||||
assert(
|
||||
"shift-W",
|
||||
indoc! {"
|
||||
The quick
|
||||
brown |fox-fox
|
||||
jumps over"},
|
||||
// Trailing space after cursor
|
||||
indoc! {"
|
||||
The quick
|
||||
brown|
|
||||
jumps over"},
|
||||
cx,
|
||||
);
|
||||
assert(
|
||||
"k",
|
||||
indoc! {"
|
||||
The quick
|
||||
brown |fox"},
|
||||
indoc! {"
|
||||
|
|
||||
"},
|
||||
cx,
|
||||
);
|
||||
assert(
|
||||
"j",
|
||||
indoc! {"
|
||||
The q|uick
|
||||
brown fox"},
|
||||
indoc! {"
|
||||
|
||||
|"},
|
||||
cx,
|
||||
);
|
||||
assert(
|
||||
"shift-$",
|
||||
indoc! {"
|
||||
The q|uick
|
||||
brown fox"},
|
||||
indoc! {"
|
||||
The |q
|
||||
brown fox"},
|
||||
cx,
|
||||
);
|
||||
assert(
|
||||
"0",
|
||||
indoc! {"
|
||||
The q|uick
|
||||
brown fox"},
|
||||
indoc! {"
|
||||
|uick
|
||||
brown fox"},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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
82
crates/vim/src/state.rs
Normal 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());
|
||||
}
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
mod editor_events;
|
||||
mod insert;
|
||||
mod mode;
|
||||
mod motion;
|
||||
mod normal;
|
||||
mod state;
|
||||
#[cfg(test)]
|
||||
mod vim_test_context;
|
||||
|
||||
@ -10,41 +11,53 @@ use editor::{CursorShape, Editor};
|
||||
use gpui::{impl_actions, MutableAppContext, ViewContext, WeakViewHandle};
|
||||
use serde::Deserialize;
|
||||
|
||||
use mode::Mode;
|
||||
use settings::Settings;
|
||||
use state::{Mode, Operator, VimState};
|
||||
use workspace::{self, Workspace};
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct SwitchMode(pub Mode);
|
||||
|
||||
impl_actions!(vim, [SwitchMode]);
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct PushOperator(pub Operator);
|
||||
|
||||
impl_actions!(vim, [SwitchMode, PushOperator]);
|
||||
|
||||
pub fn init(cx: &mut MutableAppContext) {
|
||||
editor_events::init(cx);
|
||||
insert::init(cx);
|
||||
normal::init(cx);
|
||||
motion::init(cx);
|
||||
|
||||
cx.add_action(|_: &mut Workspace, action: &SwitchMode, cx| {
|
||||
VimState::update_global(cx, |state, cx| state.switch_mode(action, cx))
|
||||
cx.add_action(|_: &mut Workspace, &SwitchMode(mode): &SwitchMode, cx| {
|
||||
Vim::update(cx, |vim, cx| vim.switch_mode(mode, cx))
|
||||
});
|
||||
cx.add_action(
|
||||
|_: &mut Workspace, &PushOperator(operator): &PushOperator, cx| {
|
||||
Vim::update(cx, |vim, cx| vim.push_operator(operator, cx))
|
||||
},
|
||||
);
|
||||
|
||||
cx.observe_global::<Settings, _>(|settings, cx| {
|
||||
VimState::update_global(cx, |state, cx| state.set_enabled(settings.vim_mode, cx))
|
||||
Vim::update(cx, |state, cx| state.set_enabled(settings.vim_mode, cx))
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct VimState {
|
||||
pub struct Vim {
|
||||
editors: HashMap<usize, WeakViewHandle<Editor>>,
|
||||
active_editor: Option<WeakViewHandle<Editor>>,
|
||||
|
||||
enabled: bool,
|
||||
mode: Mode,
|
||||
state: VimState,
|
||||
}
|
||||
|
||||
impl VimState {
|
||||
fn update_global<F, S>(cx: &mut MutableAppContext, update: F) -> S
|
||||
impl Vim {
|
||||
fn read(cx: &mut MutableAppContext) -> &Self {
|
||||
cx.default_global()
|
||||
}
|
||||
|
||||
fn update<F, S>(cx: &mut MutableAppContext, update: F) -> S
|
||||
where
|
||||
F: FnOnce(&mut Self, &mut MutableAppContext) -> S,
|
||||
{
|
||||
@ -62,33 +75,54 @@ impl VimState {
|
||||
.map(|ae| ae.update(cx, update))
|
||||
}
|
||||
|
||||
fn switch_mode(&mut self, SwitchMode(mode): &SwitchMode, cx: &mut MutableAppContext) {
|
||||
self.mode = *mode;
|
||||
fn switch_mode(&mut self, mode: Mode, cx: &mut MutableAppContext) {
|
||||
self.state.mode = mode;
|
||||
self.state.operator_stack.clear();
|
||||
self.sync_editor_options(cx);
|
||||
}
|
||||
|
||||
fn push_operator(&mut self, operator: Operator, cx: &mut MutableAppContext) {
|
||||
self.state.operator_stack.push(operator);
|
||||
self.sync_editor_options(cx);
|
||||
}
|
||||
|
||||
fn pop_operator(&mut self, cx: &mut MutableAppContext) -> Operator {
|
||||
let popped_operator = self.state.operator_stack.pop().expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
|
||||
self.sync_editor_options(cx);
|
||||
popped_operator
|
||||
}
|
||||
|
||||
fn clear_operator(&mut self, cx: &mut MutableAppContext) {
|
||||
self.state.operator_stack.clear();
|
||||
self.sync_editor_options(cx);
|
||||
}
|
||||
|
||||
fn active_operator(&mut self) -> Option<Operator> {
|
||||
self.state.operator_stack.last().copied()
|
||||
}
|
||||
|
||||
fn set_enabled(&mut self, enabled: bool, cx: &mut MutableAppContext) {
|
||||
if self.enabled != enabled {
|
||||
self.enabled = enabled;
|
||||
self.mode = Default::default();
|
||||
self.state = Default::default();
|
||||
if enabled {
|
||||
self.mode = Mode::normal();
|
||||
self.state.mode = Mode::Normal;
|
||||
}
|
||||
self.sync_editor_options(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_editor_options(&self, cx: &mut MutableAppContext) {
|
||||
let mode = self.mode;
|
||||
let cursor_shape = mode.cursor_shape();
|
||||
let state = &self.state;
|
||||
let cursor_shape = state.cursor_shape();
|
||||
for editor in self.editors.values() {
|
||||
if let Some(editor) = editor.upgrade(cx) {
|
||||
editor.update(cx, |editor, cx| {
|
||||
if self.enabled {
|
||||
editor.set_cursor_shape(cursor_shape, cx);
|
||||
editor.set_clip_at_line_ends(cursor_shape == CursorShape::Block, cx);
|
||||
editor.set_input_enabled(mode == Mode::Insert);
|
||||
let context_layer = mode.keymap_context_layer();
|
||||
editor.set_input_enabled(!state.vim_controlled());
|
||||
let context_layer = state.keymap_context_layer();
|
||||
editor.set_keymap_context_layer::<Self>(context_layer);
|
||||
} else {
|
||||
editor.set_cursor_shape(CursorShape::Bar, cx);
|
||||
@ -104,12 +138,12 @@ impl VimState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::{mode::Mode, vim_test_context::VimTestContext};
|
||||
use crate::{state::Mode, vim_test_context::VimTestContext};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, false, "").await;
|
||||
cx.simulate_keystrokes(&["h", "j", "k", "l"]);
|
||||
cx.simulate_keystrokes(["h", "j", "k", "l"]);
|
||||
cx.assert_editor_state("hjkl|");
|
||||
}
|
||||
|
||||
@ -122,22 +156,22 @@ mod test {
|
||||
|
||||
// Editor acts as though vim is disabled
|
||||
cx.disable_vim();
|
||||
cx.simulate_keystrokes(&["h", "j", "k", "l"]);
|
||||
cx.simulate_keystrokes(["h", "j", "k", "l"]);
|
||||
cx.assert_editor_state("hjkl|");
|
||||
|
||||
// Enabling dynamically sets vim mode again and restores normal mode
|
||||
cx.enable_vim();
|
||||
assert_eq!(cx.mode(), Mode::normal());
|
||||
cx.simulate_keystrokes(&["h", "h", "h", "l"]);
|
||||
assert_eq!(cx.mode(), Mode::Normal);
|
||||
cx.simulate_keystrokes(["h", "h", "h", "l"]);
|
||||
assert_eq!(cx.editor_text(), "hjkl".to_owned());
|
||||
cx.assert_editor_state("hj|kl");
|
||||
cx.simulate_keystrokes(&["i", "T", "e", "s", "t"]);
|
||||
cx.simulate_keystrokes(["i", "T", "e", "s", "t"]);
|
||||
cx.assert_editor_state("hjTest|kl");
|
||||
|
||||
// Disabling and enabling resets to normal mode
|
||||
assert_eq!(cx.mode(), Mode::Insert);
|
||||
cx.disable_vim();
|
||||
cx.enable_vim();
|
||||
assert_eq!(cx.mode(), Mode::normal());
|
||||
assert_eq!(cx.mode(), Mode::Normal);
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ use language::{Point, Selection};
|
||||
use util::test::marked_text;
|
||||
use workspace::{WorkspaceHandle, WorkspaceParams};
|
||||
|
||||
use crate::*;
|
||||
use crate::{state::Operator, *};
|
||||
|
||||
pub struct VimTestContext<'a> {
|
||||
cx: &'a mut gpui::TestAppContext,
|
||||
@ -100,7 +100,12 @@ impl<'a> VimTestContext<'a> {
|
||||
}
|
||||
|
||||
pub fn mode(&mut self) -> Mode {
|
||||
self.cx.update(|cx| cx.global::<VimState>().mode)
|
||||
self.cx.read(|cx| cx.global::<Vim>().state.mode)
|
||||
}
|
||||
|
||||
pub fn active_operator(&mut self) -> Option<Operator> {
|
||||
self.cx
|
||||
.read(|cx| cx.global::<Vim>().state.operator_stack.last().copied())
|
||||
}
|
||||
|
||||
pub fn editor_text(&mut self) -> String {
|
||||
@ -119,12 +124,23 @@ impl<'a> VimTestContext<'a> {
|
||||
.dispatch_keystroke(self.window_id, keystroke, input, false);
|
||||
}
|
||||
|
||||
pub fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) {
|
||||
pub fn simulate_keystrokes<const COUNT: usize>(&mut self, keystroke_texts: [&str; COUNT]) {
|
||||
for keystroke_text in keystroke_texts.into_iter() {
|
||||
self.simulate_keystroke(keystroke_text);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_state(&mut self, text: &str, mode: Mode) {
|
||||
self.cx
|
||||
.update(|cx| Vim::update(cx, |vim, cx| vim.switch_mode(mode, cx)));
|
||||
self.editor.update(self.cx, |editor, cx| {
|
||||
let (unmarked_text, markers) = marked_text(&text);
|
||||
editor.set_text(unmarked_text, cx);
|
||||
let cursor_offset = markers[0];
|
||||
editor.replace_selections_with(cx, |map| cursor_offset.to_display_point(map));
|
||||
})
|
||||
}
|
||||
|
||||
pub fn assert_newest_selection_head_offset(&mut self, expected_offset: usize) {
|
||||
let actual_head = self.newest_selection().head();
|
||||
let (actual_offset, expected_head) = self.editor.update(self.cx, |editor, cx| {
|
||||
@ -171,6 +187,21 @@ impl<'a> VimTestContext<'a> {
|
||||
actual_position_text, expected_position_text
|
||||
)
|
||||
}
|
||||
|
||||
pub fn assert_binding<const COUNT: usize>(
|
||||
&mut self,
|
||||
keystrokes: [&str; COUNT],
|
||||
initial_state: &str,
|
||||
initial_mode: Mode,
|
||||
state_after: &str,
|
||||
mode_after: Mode,
|
||||
) {
|
||||
self.set_state(initial_state, initial_mode);
|
||||
self.simulate_keystrokes(keystrokes);
|
||||
self.assert_editor_state(state_after);
|
||||
assert_eq!(self.mode(), mode_after);
|
||||
assert_eq!(self.active_operator(), None);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Deref for VimTestContext<'a> {
|
||||
|
Loading…
Reference in New Issue
Block a user