use crate::{Action, App, Context, KeyBinding, SharedString, Window};
use super::super::{InputState, RopeExt as _, Undo};
pub(crate) const VIM_NORMAL_CONTEXT: &str = "VimNormal";
pub(crate) const VIM_VISUAL_CONTEXT: &str = "VimVisual";
pub(crate) const VIM_INSERT_CONTEXT: &str = "VimInsert";
#[derive(Action, Clone, PartialEq, Eq, serde::Deserialize)]
#[action(namespace = vim, no_json)]
pub struct VimKey {
pub key: SharedString,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VimMode {
#[default]
Normal,
Insert,
Visual,
}
impl VimMode {
pub(crate) fn context_id(self) -> &'static str {
match self {
VimMode::Normal => VIM_NORMAL_CONTEXT,
VimMode::Insert => VIM_INSERT_CONTEXT,
VimMode::Visual => VIM_VISUAL_CONTEXT,
}
}
pub fn indicator(self) -> &'static str {
match self {
VimMode::Normal => "NORMAL",
VimMode::Insert => "INSERT",
VimMode::Visual => "VISUAL",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct VimState {
pub(crate) enabled: bool,
pub(crate) mode: VimMode,
pub(crate) pending: Option<char>,
pub(crate) anchor: Option<usize>,
pub(crate) preferred_col: usize,
pub(crate) register: String,
pub(crate) register_linewise: bool,
}
impl Default for VimState {
fn default() -> Self {
Self {
enabled: false,
mode: VimMode::Normal,
pending: None,
anchor: None,
preferred_col: 0,
register: String::new(),
register_linewise: false,
}
}
}
pub(crate) fn init(cx: &mut App) {
let mut bindings = Vec::new();
for key in [
"h",
"j",
"k",
"l",
"w",
"b",
"0",
"$",
"G",
"g",
"x",
"i",
"a",
"o",
"v",
"y",
"d",
"p",
"u",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
":",
"/",
".",
"enter",
"escape",
"backspace",
"delete",
] {
bindings.push(KeyBinding::new(
key,
VimKey { key: key.into() },
Some(VIM_NORMAL_CONTEXT),
));
}
for key in [
"c",
"e",
"f",
"n",
"q",
"r",
"s",
"t",
"z",
"space",
"tab",
"comma",
"semicolon",
"quote",
"bracketleft",
"bracketright",
"backslash",
"minus",
"equal",
] {
bindings.push(KeyBinding::new(
key,
VimKey { key: key.into() },
Some(VIM_NORMAL_CONTEXT),
));
}
for key in [
"h", "j", "k", "l", "w", "b", "0", "$", "G", "y", "d", "x", "v", "escape",
] {
bindings.push(KeyBinding::new(
key,
VimKey { key: key.into() },
Some(VIM_VISUAL_CONTEXT),
));
}
bindings.push(KeyBinding::new(
"escape",
VimKey {
key: "escape".into(),
},
Some(VIM_INSERT_CONTEXT),
));
cx.bind_keys(bindings);
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn line_range(state: &InputState, row: usize) -> (usize, usize) {
let text = state.text();
let start = text.line_start_offset(row);
let next = text.line_start_offset(row + 1);
let end = if next > start && next <= text.len() {
let prev = text.floor_char_boundary(next.saturating_sub(1));
if text.slice(prev..next) == "\n" {
prev
} else {
next.min(text.len())
}
} else {
next.min(text.len())
};
(start, end.min(text.len()))
}
fn line_count(state: &InputState) -> usize {
let text = state.text();
text.offset_to_point(text.len()).row + 1
}
fn char_offset_in_line(state: &InputState, row: usize, col: usize) -> usize {
let (start, end) = line_range(state, row);
let text = state.text();
let mut offset = start;
for (count, ch) in text.slice(start..end).chars().enumerate() {
if count >= col {
break;
}
offset += ch.len_utf8();
}
offset
}
fn cursor_char_col(state: &InputState) -> usize {
let cursor = state.cursor();
let text = state.text();
let row = text.offset_to_point(cursor).row;
let (start, _) = line_range(state, row);
text.slice(start..cursor.min(text.len())).chars().count()
}
fn next_boundary(text: &ropey::Rope, cursor: usize) -> usize {
text.ceil_char_boundary(cursor.saturating_add(1))
.min(text.len())
}
fn prev_boundary(text: &ropey::Rope, cursor: usize) -> usize {
text.floor_char_boundary(cursor.saturating_sub(1))
}
fn goto(state: &mut InputState, offset: usize, cx: &mut Context<InputState>) {
let offset = offset.min(state.text().len());
state.move_to(offset, None, cx);
state.vim.preferred_col = cursor_char_col(state);
}
fn move_vertical(state: &mut InputState, delta: isize, cx: &mut Context<InputState>) {
let cursor = state.cursor();
let text = state.text();
let row = text.offset_to_point(cursor).row;
let target = row
.saturating_add_signed(delta)
.min(line_count(state).saturating_sub(1));
let offset = char_offset_in_line(state, target, state.vim.preferred_col);
state.move_to(offset, None, cx);
}
fn word_forward(state: &InputState, cursor: usize) -> usize {
let text = state.text();
let len = text.len();
let window = text.slice(cursor.min(len)..).to_string();
let mut offset = cursor;
let mut chars = window.chars().peekable();
while let Some(&c) = chars.peek() {
if !is_word_char(c) {
break;
}
offset += c.len_utf8();
chars.next();
}
while let Some(&c) = chars.peek() {
if is_word_char(c) {
break;
}
offset += c.len_utf8();
chars.next();
}
offset.min(len)
}
fn word_backward(state: &InputState, cursor: usize) -> usize {
let text = state.text();
let cursor = cursor.min(text.len());
let mut offsets: Vec<usize> = vec![0];
for ch in text.slice(..cursor).chars() {
offsets.push(offsets.last().copied().unwrap_or(0) + ch.len_utf8());
}
let chars: Vec<char> = text.slice(..cursor).chars().collect();
let mut index = chars.len();
if index > 0 && is_word_char(chars[index - 1]) {
while index > 0 && is_word_char(chars[index - 1]) {
index -= 1;
}
} else {
while index > 0 && !is_word_char(chars[index - 1]) {
index -= 1;
}
while index > 0 && is_word_char(chars[index - 1]) {
index -= 1;
}
}
offsets[index]
}
fn extend_to(state: &mut InputState, offset: usize, cx: &mut Context<InputState>) {
let anchor = state.vim.anchor.unwrap_or_else(|| state.cursor());
let offset = offset.min(state.text().len());
let (start, end) = if anchor <= offset {
(anchor, offset)
} else {
(offset, anchor)
};
state.set_selected_range(start..end, cx);
state.vim.preferred_col = cursor_char_col(state);
}
fn to_normal(state: &mut InputState, cx: &mut Context<InputState>) {
let cursor = state.cursor();
state.vim.mode = VimMode::Normal;
state.vim.pending = None;
state.vim.anchor = None;
state.move_to(cursor, None, cx);
}
fn to_insert(state: &mut InputState, cx: &mut Context<InputState>) {
state.vim.mode = VimMode::Insert;
state.vim.pending = None;
state.vim.anchor = None;
cx.notify();
}
fn delete_range(
state: &mut InputState,
range: std::ops::Range<usize>,
window: &mut Window,
cx: &mut Context<InputState>,
) {
state.set_selected_range(range, cx);
state.replace("", window, cx);
}
fn normal_key(
state: &mut InputState,
key: &str,
window: &mut Window,
cx: &mut Context<InputState>,
) -> bool {
let cursor = state.cursor();
if let Some(pending) = state.vim.pending {
state.vim.pending = None;
match (pending, key) {
('d', "d") => {
delete_current_line(state, window, cx);
return true;
}
('y', "y") => {
yank_current_line(state);
cx.notify();
return true;
}
('g', "g") => {
goto(state, 0, cx);
return true;
}
_ => {} }
}
match key {
"h" => {
let row = state.text().offset_to_point(cursor).row;
let (start, _) = line_range(state, row);
goto(state, prev_boundary(state.text(), cursor).max(start), cx);
}
"l" => {
let row = state.text().offset_to_point(cursor).row;
let (_, end) = line_range(state, row);
goto(state, next_boundary(state.text(), cursor).min(end), cx);
}
"j" => move_vertical(state, 1, cx),
"k" => move_vertical(state, -1, cx),
"w" => goto(state, word_forward(state, cursor), cx),
"b" => goto(state, word_backward(state, cursor), cx),
"0" => {
let row = state.text().offset_to_point(cursor).row;
goto(state, line_range(state, row).0, cx);
}
"$" => {
let row = state.text().offset_to_point(cursor).row;
let (start, end) = line_range(state, row);
goto(state, end.max(start), cx);
}
"G" => {
let last = line_count(state).saturating_sub(1);
goto(state, line_range(state, last).0, cx);
}
"g" | "d" | "y" => {
state.vim.pending = key.chars().next();
cx.notify();
}
"x" => {
let row = state.text().offset_to_point(cursor).row;
let (_, end) = line_range(state, row);
if cursor < end {
delete_range(
state,
cursor..next_boundary(state.text(), cursor),
window,
cx,
);
}
}
"i" => to_insert(state, cx),
"a" => {
let row = state.text().offset_to_point(cursor).row;
let (_, end) = line_range(state, row);
if cursor < end {
state.move_to(next_boundary(state.text(), cursor), None, cx);
}
to_insert(state, cx);
}
"o" => {
let row = state.text().offset_to_point(cursor).row;
let (_, end) = line_range(state, row);
state.set_selected_range(end..end, cx);
state.insert("\n", window, cx);
to_insert(state, cx);
}
"v" => {
state.vim.mode = VimMode::Visual;
state.vim.anchor = Some(cursor);
cx.notify();
}
"p" => put_register(state, window, cx),
"u" => {
state.undo(&Undo, window, cx);
}
"escape" => {
state.vim.pending = None;
goto(state, cursor, cx);
}
"enter" => move_vertical(state, 1, cx),
"backspace" => {
let row = state.text().offset_to_point(cursor).row;
let (start, _) = line_range(state, row);
goto(state, prev_boundary(state.text(), cursor).max(start), cx);
}
"delete" => {
let row = state.text().offset_to_point(cursor).row;
let (_, end) = line_range(state, row);
if cursor < end {
delete_range(
state,
cursor..next_boundary(state.text(), cursor),
window,
cx,
);
}
}
_ => {}
}
true
}
fn delete_current_line(state: &mut InputState, window: &mut Window, cx: &mut Context<InputState>) {
let cursor = state.cursor();
let text = state.text();
let row = text.offset_to_point(cursor).row;
let total = line_count(state);
let (start, end) = line_range(state, row);
if total <= 1 {
delete_range(state, start..end.min(text.len()), window, cx);
} else if row + 1 < total {
let next_start = line_range(state, row + 1).0;
delete_range(state, start..next_start.min(text.len()), window, cx);
} else {
delete_range(state, start.saturating_sub(1)..text.len(), window, cx);
}
let cursor = state.cursor().min(state.text().len());
state.move_to(cursor, None, cx);
}
fn yank_current_line(state: &mut InputState) {
let cursor = state.cursor();
let text = state.text();
let row = text.offset_to_point(cursor).row;
let (start, end) = line_range(state, row);
state.vim.register = text.slice(start..end.min(text.len())).to_string();
state.vim.register_linewise = true;
}
fn put_register(state: &mut InputState, window: &mut Window, cx: &mut Context<InputState>) {
if state.vim.register.is_empty() {
return;
}
if state.vim.register_linewise {
let cursor = state.cursor();
let row = state.text().offset_to_point(cursor).row;
let (_, end) = line_range(state, row);
let text = state.vim.register.clone();
if state.text().len() == 0 {
state.set_selected_range(0..0, cx);
state.insert(text.as_str(), window, cx);
} else {
state.set_selected_range(end..end, cx);
let mut pasted = String::from("\n");
pasted.push_str(&text);
state.insert(pasted.as_str(), window, cx);
}
} else {
let text = state.vim.register.clone();
state.insert(text.as_str(), window, cx);
}
}
fn visual_key(
state: &mut InputState,
key: &str,
window: &mut Window,
cx: &mut Context<InputState>,
) -> bool {
let cursor = state.cursor();
match key {
"h" | "j" | "k" | "l" | "w" | "b" | "0" | "$" | "G" => {
let target = visual_motion_target(state, key, cursor);
extend_to(state, target, cx);
}
"y" => {
let range = state.selected_range();
state.vim.register = state.text().slice(range).to_string();
state.vim.register_linewise = false;
to_normal(state, cx);
}
"d" | "x" => {
let range = state.selected_range();
if !range.is_empty() {
delete_range(state, range, window, cx);
}
to_normal(state, cx);
}
"v" => to_normal(state, cx),
"escape" => to_normal(state, cx),
_ => {}
}
true
}
fn visual_motion_target(state: &InputState, key: &str, cursor: usize) -> usize {
let text = state.text();
let len = text.len();
match key {
"h" => {
let row = text.offset_to_point(cursor).row;
cursor.saturating_sub(1).max(line_range(state, row).0)
}
"l" => {
let row = text.offset_to_point(cursor).row;
(cursor + 1).min(line_range(state, row).1).min(len)
}
"j" => {
let row = text.offset_to_point(cursor).row;
let target = (row + 1).min(line_count(state).saturating_sub(1));
char_offset_in_line(state, target, state.vim.preferred_col)
}
"k" => {
let row = text.offset_to_point(cursor).row;
let target = row.saturating_sub(1);
char_offset_in_line(state, target, state.vim.preferred_col)
}
"w" => word_forward(state, cursor),
"b" => word_backward(state, cursor),
"0" => line_range(state, text.offset_to_point(cursor).row).0,
"$" => {
let row = text.offset_to_point(cursor).row;
let (start, end) = line_range(state, row);
end.max(start)
}
"G" => line_range(state, line_count(state).saturating_sub(1)).0,
_ => cursor,
}
}
pub(crate) fn handle_key(
state: &mut InputState,
key: &str,
window: &mut Window,
cx: &mut Context<InputState>,
) -> bool {
if !state.vim.enabled {
return false;
}
match state.vim.mode {
VimMode::Normal => normal_key(state, key, window, cx),
VimMode::Visual => visual_key(state, key, window, cx),
VimMode::Insert => {
if key == "escape" {
escape_pressed(state, window, cx);
return true;
}
false
}
}
}
pub(crate) fn escape_pressed(
state: &mut InputState,
_window: &mut Window,
cx: &mut Context<InputState>,
) {
if !state.vim.enabled {
return;
}
match state.vim.mode {
VimMode::Insert => {
let cursor = state.cursor();
let row = state.text().offset_to_point(cursor).row;
let (start, _) = line_range(state, row);
state.vim.mode = VimMode::Normal;
state.vim.pending = None;
state.vim.anchor = None;
state.move_to(prev_boundary(state.text(), cursor).max(start), None, cx);
}
VimMode::Visual => to_normal(state, cx),
VimMode::Normal => {
state.vim.pending = None;
cx.notify();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppContext as _;
use crate::{Entity, Window};
struct Probe {
state: Entity<InputState>,
}
impl crate::Render for Probe {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl crate::IntoElement {
crate::div()
}
}
fn vim_view<'a>(
text: &str,
cx: &'a mut crate::TestAppContext,
) -> (Entity<InputState>, &'a mut crate::VisualTestContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
state.update(cx, |state, cx| state.set_value(text, window, cx));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
state.update(cx, |state, cx| {
state.vim.enabled = true;
state.vim.mode = VimMode::Normal;
state.set_selected_range(0..0, cx);
});
});
(state, cx)
}
fn press(state: &Entity<InputState>, key: &str, cx: &mut crate::VisualTestContext) {
cx.update(|window, cx| {
state.update(cx, |state, cx| {
assert!(handle_key(state, key, window, cx));
});
});
}
fn cursor_of(state: &Entity<InputState>, cx: &mut crate::VisualTestContext) -> usize {
state.read_with(cx, |state, _| state.cursor())
}
fn text_of(state: &Entity<InputState>, cx: &mut crate::VisualTestContext) -> String {
state.read_with(cx, |state, _| state.text().to_string())
}
#[rgpui::test]
fn hjkl_move_and_clamp(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
state.update(cx, |state, cx| state.set_value("abc\nde", window, cx));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
state.update(cx, |state, cx| {
state.vim.enabled = true;
state.vim.mode = VimMode::Normal;
state.set_selected_range(0..0, cx);
});
});
press(&state, "l", cx);
press(&state, "l", cx);
assert_eq!(cursor_of(&state, cx), 2);
press(&state, "l", cx);
assert_eq!(cursor_of(&state, cx), 3);
press(&state, "h", cx);
assert_eq!(cursor_of(&state, cx), 2);
press(&state, "j", cx);
assert_eq!(cursor_of(&state, cx), 6);
press(&state, "j", cx);
assert_eq!(cursor_of(&state, cx), 6);
press(&state, "k", cx);
assert_eq!(cursor_of(&state, cx), 2);
}
#[rgpui::test]
fn word_motions(cx: &mut crate::TestAppContext) {
let (state, cx) = vim_view("foo bar\nbaz", cx);
press(&state, "w", cx);
assert_eq!(cursor_of(&state, cx), 4);
press(&state, "w", cx);
assert_eq!(cursor_of(&state, cx), 8);
press(&state, "b", cx);
assert_eq!(cursor_of(&state, cx), 4);
press(&state, "b", cx);
assert_eq!(cursor_of(&state, cx), 0);
}
#[rgpui::test]
fn line_doc_jumps(cx: &mut crate::TestAppContext) {
let (state, cx) = vim_view("abc\nde\nf", cx);
press(&state, "$", cx);
assert_eq!(cursor_of(&state, cx), 3);
press(&state, "j", cx);
press(&state, "$", cx);
assert_eq!(cursor_of(&state, cx), 6);
press(&state, "0", cx);
assert_eq!(cursor_of(&state, cx), 4);
press(&state, "G", cx);
assert_eq!(cursor_of(&state, cx), 7);
press(&state, "g", cx);
press(&state, "g", cx);
assert_eq!(cursor_of(&state, cx), 0);
}
#[rgpui::test]
fn edit_ops(cx: &mut crate::TestAppContext) {
let (state, cx) = vim_view("abc\ndef\n", cx);
press(&state, "l", cx);
press(&state, "x", cx);
assert_eq!(text_of(&state, cx), "ac\ndef\n");
press(&state, "d", cx);
press(&state, "d", cx);
assert_eq!(text_of(&state, cx), "def\n");
press(&state, "y", cx);
press(&state, "y", cx);
press(&state, "p", cx);
assert_eq!(text_of(&state, cx), "def\ndef\n");
}
#[rgpui::test]
fn undo_after_vim_edit(cx: &mut crate::TestAppContext) {
let (state, cx) = vim_view("abc", cx);
press(&state, "x", cx);
assert_eq!(text_of(&state, cx), "bc");
press(&state, "u", cx);
assert_eq!(text_of(&state, cx), "abc");
}
#[rgpui::test]
fn mode_switches(cx: &mut crate::TestAppContext) {
let (state, cx) = vim_view("abc", cx);
let mode = |state: &Entity<InputState>, cx: &mut crate::VisualTestContext| {
state.read_with(cx, |state, _| state.vim.mode)
};
press(&state, "i", cx);
assert_eq!(mode(&state, cx), VimMode::Insert);
cx.update(|window, cx| {
state.update(cx, |state, cx| escape_pressed(state, window, cx));
});
assert_eq!(mode(&state, cx), VimMode::Normal);
press(&state, "v", cx);
assert_eq!(mode(&state, cx), VimMode::Visual);
press(&state, "l", cx);
assert_eq!(state.read_with(cx, |state, _| state.selected_range()), 0..1);
press(&state, "y", cx);
assert_eq!(mode(&state, cx), VimMode::Normal);
press(&state, "0", cx);
press(&state, "p", cx);
assert_eq!(text_of(&state, cx), "aabc");
}
#[rgpui::test]
fn disabled_passes_through(cx: &mut crate::TestAppContext) {
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|window, cx| {
state.update(cx, |state, cx| {
assert!(!handle_key(state, "h", window, cx));
});
});
}
#[rgpui::test]
fn bindings_registered_for_vim_contexts(cx: &mut crate::TestAppContext) {
use crate::{KeyContext, Keystroke};
cx.update(crate::input_ui::init);
let strokes = |keys: &[&str]| {
keys.iter()
.map(|key| Keystroke::parse(key).unwrap())
.collect::<Vec<_>>()
};
let hits: Vec<bool> = cx.read(|cx| {
let keymap = cx.key_bindings();
let keymap = keymap.borrow();
let stack = vec![KeyContext::parse("VimNormal").unwrap()];
["h", "G", "enter", "escape", "$"]
.into_iter()
.map(|key| {
let (bindings, _) = keymap.bindings_for_input(&strokes(&[key]), &stack);
bindings.iter().any(|binding| {
binding
.action
.as_any()
.downcast_ref::<VimKey>()
.is_some_and(|action| action.key.as_ref() == key)
})
})
.collect()
});
assert_eq!(hits, vec![true; 5]);
let insert_hits: Vec<bool> = cx.read(|cx| {
let keymap = cx.key_bindings();
let keymap = keymap.borrow();
let stack = vec![KeyContext::parse("VimInsert").unwrap()];
["h", "x"]
.into_iter()
.map(|key| {
let (bindings, _) = keymap.bindings_for_input(&strokes(&[key]), &stack);
bindings
.iter()
.any(|binding| binding.action.as_any().downcast_ref::<VimKey>().is_some())
})
.collect()
});
assert_eq!(insert_hits, vec![false, false]);
}
}