use super::snapshot::EditorMode;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui_textarea::{CursorMove, TextArea};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VimHostAction {
OpenPalette, OpenSearch { forward: bool }, SearchNext, SearchPrev, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VimKeyOutcome {
TextMutated,
CursorOnly,
NoOp,
PassThrough,
Host(VimHostAction),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Motion {
Left,
Right,
Up,
Down,
WordForward,
WordBack,
WordEnd,
WordForwardBig, WordBackBig, WordEndBig, WordEndBack { big: bool }, LineStart,
FirstNonBlank,
LastNonBlank, LineEnd,
FileStart,
FileEnd,
GotoLine(usize), ParagraphForward,
ParagraphBack,
MatchingPair, FindChar { ch: char, till: bool, forward: bool }, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operator {
Delete,
Change,
Yank,
Indent,
Outdent,
Lowercase, Uppercase, ToggleCase, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpanKind {
Exclusive,
Inclusive,
Linewise,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextObject {
Word {
around: bool,
},
Pair {
open: char,
close: char,
around: bool,
},
Quote {
ch: char,
around: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InsertEntry {
Here, After, LineStart, LineEnd, OpenBelow, OpenAbove, }
#[derive(Debug, Clone)]
pub enum Command {
Move(Motion, usize),
OperateMotion(Operator, Motion, usize), OperateLine(Operator, usize), OperateObject(Operator, TextObject), OperateToLineEnd(Operator), IndentLines { outdent: bool, count: usize }, DeleteChar { forward: bool, count: usize }, ReplaceChar(char), SubstituteChar(usize), SubstituteLine, JoinLines { count: usize, spaced: bool }, ToggleCase(usize), Paste { after: bool, count: usize }, Undo(usize), Redo(usize), EnterInsert(InsertEntry), EnterReplace, EnterVisual { line: bool }, Repeat, }
enum GKey {
GotoLine,
Motion(Motion),
CaseOp(Operator),
Join,
}
enum Parsed {
Pending,
Cmd(Command),
Host(VimHostAction),
Cancel,
Nothing,
}
#[derive(Debug, Clone, Copy)]
struct PendingFind {
operator: Option<Operator>,
till: bool,
forward: bool,
}
#[derive(Debug, Clone, Copy)]
enum Awaiting {
G,
ReplaceChar,
Find(PendingFind),
ObjectScope { around: bool },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RegisterKind {
Charwise,
Linewise,
}
#[derive(Debug, Clone)]
struct RegisterValue {
text: String,
kind: RegisterKind,
}
#[derive(Debug, Default)]
struct Registers {
unnamed: Option<RegisterValue>,
}
impl Registers {
fn fill(&mut self, text: String, kind: RegisterKind) {
if text.is_empty() {
return;
}
self.unnamed = Some(RegisterValue { text, kind });
}
fn read(&self) -> Option<&RegisterValue> {
self.unnamed.as_ref()
}
}
#[derive(Debug, Clone)]
struct Change {
command: Command,
inserted: Option<String>,
}
#[derive(Debug, Clone)]
struct InsertCapture {
command: Command,
start: (usize, usize),
}
#[derive(Debug)]
pub struct VimEngine {
mode: EditorMode,
pending_count: Option<usize>,
pending_op_count: Option<usize>,
pending_operator: Option<Operator>,
awaiting: Option<Awaiting>,
last_find: Option<(char, bool, bool)>, registers: Registers,
last_change: Option<Change>,
insert_capture: Option<InsertCapture>,
replace_stack: Vec<Option<char>>,
}
impl Default for VimEngine {
fn default() -> Self {
Self {
mode: EditorMode::Normal,
pending_count: None,
pending_op_count: None,
pending_operator: None,
awaiting: None,
last_find: None,
registers: Registers::default(),
last_change: None,
insert_capture: None,
replace_stack: Vec::new(),
}
}
}
impl VimEngine {
pub fn mode(&self) -> &EditorMode {
&self.mode
}
pub fn mode_label(&self) -> String {
self.mode.label().to_string()
}
pub fn pending_hint(&self) -> Option<String> {
if self.pending_count.is_none()
&& self.pending_op_count.is_none()
&& self.pending_operator.is_none()
&& self.awaiting.is_none()
{
return None;
}
let mut s = String::new();
if let Some(n) = self.pending_op_count {
s.push_str(&n.to_string());
}
if let Some(op) = self.pending_operator {
s.push_str(match op {
Operator::Delete => "d",
Operator::Change => "c",
Operator::Yank => "y",
Operator::Indent => ">",
Operator::Outdent => "<",
Operator::Lowercase => "gu",
Operator::Uppercase => "gU",
Operator::ToggleCase => "g~",
});
}
if let Some(n) = self.pending_count {
s.push_str(&n.to_string());
}
match self.awaiting {
Some(Awaiting::G) => s.push('g'),
Some(Awaiting::ReplaceChar) => s.push('r'),
Some(Awaiting::Find(pf)) => s.push(match (pf.till, pf.forward) {
(false, true) => 'f',
(false, false) => 'F',
(true, true) => 't',
(true, false) => 'T',
}),
Some(Awaiting::ObjectScope { around }) => s.push(if around { 'a' } else { 'i' }),
None => {}
}
if s.is_empty() { None } else { Some(s) }
}
pub fn reset_to_normal(&mut self) {
self.mode = EditorMode::Normal;
self.clear_pending();
self.insert_capture = None;
}
pub fn sync_mouse_selection(&mut self, has_selection: bool) {
match (has_selection, &self.mode) {
(true, EditorMode::Normal) => self.mode = EditorMode::Visual,
(false, EditorMode::Visual) | (false, EditorMode::VisualLine) => {
self.mode = EditorMode::Normal
}
_ => {}
}
}
pub fn space_leads(&self) -> bool {
self.mode == EditorMode::Normal
&& self.pending_count.is_none()
&& self.pending_op_count.is_none()
&& self.pending_operator.is_none()
&& self.awaiting.is_none()
}
pub fn handle_key(&mut self, key: &KeyEvent, ta: &mut TextArea<'static>) -> VimKeyOutcome {
match self.mode {
EditorMode::Insert => self.handle_insert(key, ta),
EditorMode::Replace => self.handle_replace(key, ta),
EditorMode::Visual | EditorMode::VisualLine => self.handle_visual(key, ta),
_ => self.handle_normal(key, ta),
}
}
fn handle_visual(&mut self, key: &KeyEvent, ta: &mut TextArea<'static>) -> VimKeyOutcome {
match self.awaiting {
Some(Awaiting::Find(pf)) => {
self.awaiting = None;
if let KeyCode::Char(ch) = key.code {
self.last_find = Some((ch, pf.till, pf.forward));
let cnt = self.take_count();
let motion = Motion::FindChar {
ch,
till: pf.till,
forward: pf.forward,
};
self.apply_motion(motion, cnt, ta);
return VimKeyOutcome::CursorOnly;
}
self.clear_pending();
return VimKeyOutcome::NoOp;
}
Some(Awaiting::ObjectScope { around }) => {
self.awaiting = None;
if let KeyCode::Char(ch) = key.code
&& let Some(obj) = Self::object_for_char(ch, around)
{
Self::select_object_visual(obj, ta);
self.clear_pending();
return VimKeyOutcome::CursorOnly;
}
self.clear_pending();
return VimKeyOutcome::NoOp;
}
_ => {}
}
if key.code == KeyCode::Esc {
ta.cancel_selection();
self.mode = EditorMode::Normal;
self.clear_pending();
return VimKeyOutcome::CursorOnly;
}
let plain = key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT;
let KeyCode::Char(c) = key.code else {
match key.code {
KeyCode::Left => {
ta.move_cursor(CursorMove::Back);
return VimKeyOutcome::CursorOnly;
}
KeyCode::Right => {
ta.move_cursor(CursorMove::Forward);
return VimKeyOutcome::CursorOnly;
}
KeyCode::Up => {
ta.move_cursor(CursorMove::Up);
return VimKeyOutcome::CursorOnly;
}
KeyCode::Down => {
ta.move_cursor(CursorMove::Down);
return VimKeyOutcome::CursorOnly;
}
_ => return VimKeyOutcome::NoOp,
}
};
if !plain {
return VimKeyOutcome::NoOp;
}
if self.accumulate_count(c) {
return VimKeyOutcome::NoOp;
}
let op = match c {
'd' | 'x' => Some(Operator::Delete),
'c' | 's' => Some(Operator::Change),
'y' => Some(Operator::Yank),
'u' => Some(Operator::Lowercase),
'U' => Some(Operator::Uppercase),
_ => None,
};
if let Some(op) = op {
return self.visual_operate(op, ta);
}
if c == 'p' || c == 'P' {
let Some(reg) = self.registers.read().cloned() else {
ta.cancel_selection();
self.mode = EditorMode::Normal;
return VimKeyOutcome::CursorOnly;
};
let text = reg.text;
if self.mode == EditorMode::VisualLine {
let (start_row, end_row) = if let Some(((sr, _), (er, _))) = ta.selection_range() {
(sr, er)
} else {
let (r, _) = super::cursor_tuple(ta);
(r, r)
};
ta.cancel_selection();
ta.move_cursor(CursorMove::Jump(start_row as u16, 0));
let count = end_row - start_row + 1;
self.apply_operator_linewise(Operator::Delete, count, None, ta);
let body = text.strip_suffix('\n').unwrap_or(&text);
ta.move_cursor(CursorMove::Head);
ta.insert_str(body);
ta.insert_newline();
ta.move_cursor(CursorMove::Up);
} else {
if let Some((start, end)) = ta.selection_range() {
ta.cancel_selection();
Self::select_range(ta, start, end, true);
}
ta.cut(); self.fill_from_textarea(ta, RegisterKind::Charwise);
let paste_start = super::cursor_tuple(ta);
ta.insert_str(&text); ta.move_cursor(CursorMove::Jump(paste_start.0 as u16, paste_start.1 as u16));
}
self.mode = EditorMode::Normal;
self.clear_pending();
return VimKeyOutcome::TextMutated;
}
if c == 'o' {
if let Some((start, end)) = ta.selection_range() {
let cur = super::cursor_tuple(ta);
let other = if cur == end { start } else { end };
ta.cancel_selection();
ta.move_cursor(CursorMove::Jump(cur.0 as u16, cur.1 as u16));
ta.start_selection();
ta.move_cursor(CursorMove::Jump(other.0 as u16, other.1 as u16));
}
return VimKeyOutcome::CursorOnly;
}
if c == '>' || c == '<' {
let outdent = c == '<';
let line_count = if let Some(((sr, _), (er, _))) = ta.selection_range() {
er.saturating_sub(sr) + 1
} else {
1
};
let start_row = if let Some(((sr, _), _)) = ta.selection_range() {
sr
} else {
super::cursor_tuple(ta).0
};
ta.cancel_selection();
ta.move_cursor(CursorMove::Jump(start_row as u16, 0));
self.indent_lines(outdent, line_count, ta);
self.mode = EditorMode::Normal;
self.clear_pending();
return VimKeyOutcome::TextMutated;
}
if !matches!(self.awaiting, Some(Awaiting::G))
&& matches!(
c,
'(' | '[' | '{' | '<' | '"' | '\'' | '`' | '*' | '_' | '~'
)
{
self.mode = EditorMode::Normal;
return VimKeyOutcome::PassThrough;
}
if c == 'g' && !matches!(self.awaiting, Some(Awaiting::G)) {
self.awaiting = Some(Awaiting::G);
return VimKeyOutcome::NoOp;
}
if matches!(self.awaiting, Some(Awaiting::G)) {
self.awaiting = None;
return match Self::g_key_for(c) {
Some(GKey::GotoLine) => {
let m = match self.pending_count.take() {
Some(n) => Motion::GotoLine(n),
None => Motion::FileStart,
};
self.apply_motion(m, 1, ta);
self.clear_pending();
VimKeyOutcome::CursorOnly
}
Some(GKey::Motion(m)) => {
let cnt = self.take_count();
self.apply_motion(m, cnt, ta);
self.clear_pending();
VimKeyOutcome::CursorOnly
}
Some(GKey::CaseOp(op)) => self.visual_operate(op, ta),
Some(GKey::Join) => self.visual_join(false, ta),
None => {
self.clear_pending();
VimKeyOutcome::NoOp
}
};
}
if c == 'J' {
return self.visual_join(true, ta);
}
if let Some((till, forward)) = Self::find_spec_for(c) {
self.awaiting = Some(Awaiting::Find(PendingFind {
operator: None,
till,
forward,
}));
return VimKeyOutcome::NoOp;
}
if c == ';' || c == ',' {
if let Some(motion) = self.repeat_find_motion(c) {
let cnt = self.take_count();
self.apply_motion(motion, cnt, ta);
}
self.clear_pending();
return VimKeyOutcome::CursorOnly;
}
if (c == 'i' || c == 'a') && self.mode == EditorMode::Visual {
self.awaiting = Some(Awaiting::ObjectScope { around: c == 'a' });
return VimKeyOutcome::NoOp;
}
if let Some(m) = Self::motion_for_char(c) {
let m = if c == 'G' {
match self.pending_count.take() {
Some(n) => Motion::GotoLine(n),
None => m,
}
} else {
m
};
let count = self.take_count();
self.apply_motion(m, count, ta);
self.clear_pending();
return VimKeyOutcome::CursorOnly;
}
self.clear_pending();
VimKeyOutcome::NoOp
}
fn visual_join(&mut self, spaced: bool, ta: &mut TextArea<'static>) -> VimKeyOutcome {
let (start_row, end_row) = if let Some(((sr, _), (er, _))) = ta.selection_range() {
(sr, er)
} else {
let (r, _) = super::cursor_tuple(ta);
(r, r)
};
ta.cancel_selection();
ta.move_cursor(CursorMove::Jump(start_row as u16, 0));
let joins = end_row.saturating_sub(start_row).max(1);
for _ in 0..joins {
Self::join_line(ta, spaced);
}
self.mode = EditorMode::Normal;
self.clear_pending();
VimKeyOutcome::TextMutated
}
fn visual_operate(&mut self, op: Operator, ta: &mut TextArea<'static>) -> VimKeyOutcome {
if self.mode == EditorMode::VisualLine {
let (start_row, end_row) = if let Some(((sr, _), (er, _))) = ta.selection_range() {
(sr, er)
} else {
let (r, _) = super::cursor_tuple(ta);
(r, r)
};
ta.cancel_selection();
ta.move_cursor(CursorMove::Jump(start_row as u16, 0));
let count = end_row - start_row + 1;
self.apply_operator_linewise(op, count, None, ta);
} else {
let range = ta.selection_range();
if let Some((start, end)) = range {
ta.cancel_selection();
Self::select_range(ta, start, end, true);
}
if op == Operator::Change {
let capture_cmd = match range {
Some(((sr, sc), (er, ec))) if sr == er => Command::OperateMotion(
Operator::Change,
Motion::Right,
ec.saturating_sub(sc) + 1,
),
Some(((sr, _), (er, _))) => {
Command::OperateLine(Operator::Change, er.saturating_sub(sr) + 1)
}
None => Command::OperateMotion(Operator::Change, Motion::Right, 1),
};
ta.cut();
self.fill_from_textarea(ta, RegisterKind::Charwise);
self.finish_insert_entry(&capture_cmd, None, ta);
} else {
self.apply_operator_on_selection(op, ta);
}
}
if op != Operator::Change {
self.mode = EditorMode::Normal;
}
self.clear_pending();
Self::outcome_for(op)
}
fn select_object_visual(obj: TextObject, ta: &mut TextArea<'static>) {
let Some((row, start, end)) = Self::object_range_at_cursor(ta, obj) else {
return;
};
if start >= end {
return;
}
ta.cancel_selection();
Self::select_range(ta, (row, start), (row, end - 1), false);
}
fn handle_insert(&mut self, key: &KeyEvent, ta: &mut TextArea<'static>) -> VimKeyOutcome {
if key.code == KeyCode::Esc {
return self.exit_to_normal(ta);
}
VimKeyOutcome::PassThrough
}
fn handle_replace(&mut self, key: &KeyEvent, ta: &mut TextArea<'static>) -> VimKeyOutcome {
if ta.selection_range().is_some() {
ta.cancel_selection();
}
let plain = key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT;
match key.code {
KeyCode::Esc => self.exit_to_normal(ta),
KeyCode::Enter => {
ta.insert_newline();
self.replace_stack.clear();
VimKeyOutcome::TextMutated
}
KeyCode::Backspace => {
if super::cursor_tuple(ta).1 > 0 {
ta.move_cursor(CursorMove::Back);
match self.replace_stack.pop() {
Some(Some(orig)) => {
ta.delete_next_char();
ta.insert_char(orig);
ta.move_cursor(CursorMove::Back);
return VimKeyOutcome::TextMutated;
}
Some(None) => {
ta.delete_next_char();
return VimKeyOutcome::TextMutated;
}
None => {}
}
}
VimKeyOutcome::CursorOnly
}
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => {
ta.move_cursor(match key.code {
KeyCode::Left => CursorMove::Back,
KeyCode::Right => CursorMove::Forward,
KeyCode::Up => CursorMove::Up,
_ => CursorMove::Down,
});
let here = super::cursor_tuple(ta);
if let Some(cap) = self.insert_capture.as_mut() {
cap.start = here;
}
self.replace_stack.clear();
VimKeyOutcome::CursorOnly
}
KeyCode::Char(c) if plain => {
let (row, col) = super::cursor_tuple(ta);
let orig = ta.lines().get(row).and_then(|l| l.chars().nth(col));
self.replace_stack.push(orig);
Self::overwrite_char(ta, c);
VimKeyOutcome::TextMutated
}
_ => VimKeyOutcome::NoOp,
}
}
fn overwrite_char(ta: &mut TextArea<'static>, ch: char) {
if ch == '\n' {
ta.insert_newline();
return;
}
let (row, col) = super::cursor_tuple(ta);
let len = ta.lines().get(row).map(|l| l.chars().count()).unwrap_or(0);
if col < len {
ta.delete_next_char();
}
ta.insert_char(ch);
}
fn exit_to_normal(&mut self, ta: &mut TextArea<'static>) -> VimKeyOutcome {
self.mode = EditorMode::Normal;
self.replace_stack.clear();
ta.cancel_selection();
if let Some(cap) = self.insert_capture.take() {
let end = super::cursor_tuple(ta);
let inserted = Self::text_between(ta.lines(), cap.start, end);
if !inserted.is_empty() || Self::records_when_empty(&cap.command) {
self.last_change = Some(Change {
command: cap.command,
inserted: Some(inserted),
});
}
}
if super::cursor_tuple(ta).1 > 0 {
ta.move_cursor(CursorMove::Back);
}
VimKeyOutcome::CursorOnly
}
fn handle_normal(&mut self, key: &KeyEvent, ta: &mut TextArea<'static>) -> VimKeyOutcome {
match self.parse_normal(key) {
Parsed::Pending | Parsed::Nothing => VimKeyOutcome::NoOp,
Parsed::Cancel => {
ta.cancel_selection();
VimKeyOutcome::CursorOnly
}
Parsed::Host(action) => {
self.clear_pending();
VimKeyOutcome::Host(action)
}
Parsed::Cmd(cmd) => self.execute(cmd, ta),
}
}
fn parse_normal(&mut self, key: &KeyEvent) -> Parsed {
if let Some(aw) = self.awaiting.take() {
return self.parse_awaiting(aw, key);
}
if key.code == KeyCode::Esc {
self.clear_pending();
return Parsed::Cancel;
}
if key.code == KeyCode::Char('r') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Parsed::Cmd(Command::Redo(self.take_total_count()));
}
let plain = key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT;
match key.code {
KeyCode::Char(c) if plain => self.parse_normal_char(c),
KeyCode::Left => Parsed::Cmd(Command::Move(Motion::Left, 1)),
KeyCode::Right => Parsed::Cmd(Command::Move(Motion::Right, 1)),
KeyCode::Up => Parsed::Cmd(Command::Move(Motion::Up, 1)),
KeyCode::Down => Parsed::Cmd(Command::Move(Motion::Down, 1)),
_ => Parsed::Nothing,
}
}
fn parse_awaiting(&mut self, aw: Awaiting, key: &KeyEvent) -> Parsed {
let KeyCode::Char(c) = key.code else {
self.clear_pending();
return if key.code == KeyCode::Esc {
Parsed::Cancel
} else {
Parsed::Nothing
};
};
match aw {
Awaiting::ReplaceChar => Parsed::Cmd(Command::ReplaceChar(c)),
Awaiting::Find(pf) => {
self.last_find = Some((c, pf.till, pf.forward));
let motion = Motion::FindChar {
ch: c,
till: pf.till,
forward: pf.forward,
};
match pf.operator {
Some(op) => {
Parsed::Cmd(Command::OperateMotion(op, motion, self.take_total_count()))
}
None => Parsed::Cmd(Command::Move(motion, self.take_count())),
}
}
Awaiting::G => self.parse_g_key(c),
Awaiting::ObjectScope { around } => {
if let Some(obj) = Self::object_for_char(c, around)
&& let Some(op) = self.pending_operator.take()
{
self.clear_pending();
return Parsed::Cmd(Command::OperateObject(op, obj));
}
self.clear_pending();
Parsed::Nothing
}
}
}
fn parse_g_key(&mut self, c: char) -> Parsed {
match Self::g_key_for(c) {
Some(GKey::GotoLine) => {
let target = self
.pending_count
.take()
.or_else(|| self.pending_op_count.take());
let m = match target {
Some(n) => Motion::GotoLine(n),
None => Motion::FileStart,
};
match self.pending_operator.take() {
Some(op) => Parsed::Cmd(Command::OperateMotion(op, m, 1)),
None => Parsed::Cmd(Command::Move(m, 1)),
}
}
Some(GKey::Motion(m)) => match self.pending_operator.take() {
Some(op) => Parsed::Cmd(Command::OperateMotion(op, m, self.take_total_count())),
None => Parsed::Cmd(Command::Move(m, self.take_count())),
},
Some(GKey::CaseOp(op)) => {
if self.pending_operator == Some(op) {
self.pending_operator = None;
return Parsed::Cmd(Command::OperateLine(op, self.take_total_count()));
}
self.pending_operator = Some(op);
self.pending_op_count = self.pending_count.take();
Parsed::Pending
}
Some(GKey::Join) => Parsed::Cmd(Command::JoinLines {
count: self.take_count().max(2) - 1,
spaced: false,
}),
None => {
self.clear_pending();
Parsed::Nothing
}
}
}
fn parse_normal_char(&mut self, c: char) -> Parsed {
if self.accumulate_count(c) {
return Parsed::Pending;
}
if c == 'g' {
self.awaiting = Some(Awaiting::G);
return Parsed::Pending;
}
if let Some(op) = self.pending_operator {
let doubles = matches!(
(op, c),
(Operator::Lowercase, 'u')
| (Operator::Uppercase, 'U')
| (Operator::ToggleCase, '~')
);
if doubles {
self.pending_operator = None;
return Parsed::Cmd(Command::OperateLine(op, self.take_total_count()));
}
}
let op_for_char = match c {
'd' => Some(Operator::Delete),
'c' => Some(Operator::Change),
'y' => Some(Operator::Yank),
_ => None,
};
if let Some(op) = op_for_char {
if self.pending_operator == Some(op) {
return Parsed::Cmd(Command::OperateLine(op, self.take_total_count()));
}
if self.pending_operator.is_some() {
self.clear_pending();
return Parsed::Nothing;
}
self.pending_operator = Some(op);
self.pending_op_count = self.pending_count.take();
return Parsed::Pending;
}
if let Some(op) = match c {
'D' => Some(Operator::Delete),
'C' => Some(Operator::Change),
'Y' => Some(Operator::Yank),
_ => None,
} {
if self.pending_operator.is_some() {
self.clear_pending();
return Parsed::Nothing; }
return Parsed::Cmd(Command::OperateToLineEnd(op));
}
if c == '>' || c == '<' {
let outdent = c == '<';
if (outdent && self.pending_operator == Some(Operator::Outdent))
|| (!outdent && self.pending_operator == Some(Operator::Indent))
{
self.pending_operator = None;
return Parsed::Cmd(Command::IndentLines {
outdent,
count: self.take_total_count(),
});
}
if self.pending_operator.is_some() {
self.clear_pending();
return Parsed::Nothing; }
self.pending_operator = Some(if outdent {
Operator::Outdent
} else {
Operator::Indent
});
self.pending_op_count = self.pending_count.take();
return Parsed::Pending;
}
if c == 'p' || c == 'P' {
if self.pending_operator.is_some() {
self.clear_pending();
return Parsed::Nothing; }
return Parsed::Cmd(Command::Paste {
after: c == 'p',
count: self.take_count(),
});
}
if let Some((till, forward)) = Self::find_spec_for(c) {
self.awaiting = Some(Awaiting::Find(PendingFind {
operator: self.pending_operator.take(),
till,
forward,
}));
return Parsed::Pending;
}
if c == ';' || c == ',' {
if let Some(motion) = self.repeat_find_motion(c) {
return match self.pending_operator.take() {
Some(op) => {
Parsed::Cmd(Command::OperateMotion(op, motion, self.take_total_count()))
}
None => Parsed::Cmd(Command::Move(motion, self.take_count())),
};
}
self.clear_pending();
return Parsed::Nothing;
}
if self.pending_operator.is_some() && (c == 'i' || c == 'a') {
self.awaiting = Some(Awaiting::ObjectScope { around: c == 'a' });
return Parsed::Pending;
}
if let Some(m) = Self::motion_for_char(c) {
if c == 'G' {
let target = self
.pending_count
.take()
.or_else(|| self.pending_op_count.take());
let m = match target {
Some(n) => Motion::GotoLine(n),
None => m,
};
return match self.pending_operator.take() {
Some(op) => Parsed::Cmd(Command::OperateMotion(op, m, 1)),
None => Parsed::Cmd(Command::Move(m, 1)),
};
}
return match self.pending_operator.take() {
Some(op) => Parsed::Cmd(Command::OperateMotion(op, m, self.take_total_count())),
None => Parsed::Cmd(Command::Move(m, self.take_count())),
};
}
if self.pending_operator.is_some() {
self.clear_pending();
return Parsed::Nothing;
}
let cmd = match c {
'x' => Command::DeleteChar {
forward: true,
count: self.take_count(),
},
'X' => Command::DeleteChar {
forward: false,
count: self.take_count(),
},
'r' => {
self.awaiting = Some(Awaiting::ReplaceChar);
return Parsed::Pending;
}
's' => Command::SubstituteChar(self.take_count()),
'S' => Command::SubstituteLine,
'R' => Command::EnterReplace,
'J' => Command::JoinLines {
count: self.take_count().max(2) - 1,
spaced: true,
},
'~' => Command::ToggleCase(self.take_count()),
'u' => Command::Undo(self.take_count()),
'.' => Command::Repeat,
'v' => Command::EnterVisual { line: false },
'V' => Command::EnterVisual { line: true },
'i' => Command::EnterInsert(InsertEntry::Here),
'a' => Command::EnterInsert(InsertEntry::After),
'I' => Command::EnterInsert(InsertEntry::LineStart),
'A' => Command::EnterInsert(InsertEntry::LineEnd),
'o' => Command::EnterInsert(InsertEntry::OpenBelow),
'O' => Command::EnterInsert(InsertEntry::OpenAbove),
':' => return Parsed::Host(VimHostAction::OpenPalette),
'/' => return Parsed::Host(VimHostAction::OpenSearch { forward: true }),
'?' => return Parsed::Host(VimHostAction::OpenSearch { forward: false }),
'n' => return Parsed::Host(VimHostAction::SearchNext),
'N' => return Parsed::Host(VimHostAction::SearchPrev),
_ => {
self.clear_pending();
return Parsed::Nothing;
}
};
Parsed::Cmd(cmd)
}
fn execute(&mut self, cmd: Command, ta: &mut TextArea<'static>) -> VimKeyOutcome {
let outcome = self.apply(&cmd, None, ta);
if outcome != VimKeyOutcome::NoOp && Self::repeatable(&cmd) && self.insert_capture.is_none()
{
self.record(cmd);
}
self.clear_pending();
outcome
}
fn repeatable(cmd: &Command) -> bool {
match cmd {
Command::Move(..)
| Command::Undo(_)
| Command::Redo(_)
| Command::EnterVisual { .. }
| Command::Repeat => false,
Command::OperateMotion(op, ..)
| Command::OperateLine(op, _)
| Command::OperateObject(op, _)
| Command::OperateToLineEnd(op) => *op != Operator::Yank,
Command::IndentLines { .. }
| Command::DeleteChar { .. }
| Command::ReplaceChar(_)
| Command::SubstituteChar(_)
| Command::SubstituteLine
| Command::JoinLines { .. }
| Command::ToggleCase(_)
| Command::Paste { .. }
| Command::EnterInsert(_)
| Command::EnterReplace => true,
}
}
fn records_when_empty(cmd: &Command) -> bool {
match cmd {
Command::EnterInsert(
InsertEntry::Here
| InsertEntry::After
| InsertEntry::LineStart
| InsertEntry::LineEnd,
)
| Command::EnterReplace => false,
Command::EnterInsert(InsertEntry::OpenBelow | InsertEntry::OpenAbove)
| Command::Move(..)
| Command::OperateMotion(..)
| Command::OperateLine(..)
| Command::OperateObject(..)
| Command::OperateToLineEnd(_)
| Command::IndentLines { .. }
| Command::DeleteChar { .. }
| Command::ReplaceChar(_)
| Command::SubstituteChar(_)
| Command::SubstituteLine
| Command::JoinLines { .. }
| Command::ToggleCase(_)
| Command::Paste { .. }
| Command::Undo(_)
| Command::Redo(_)
| Command::EnterVisual { .. }
| Command::Repeat => true,
}
}
fn apply(
&mut self,
cmd: &Command,
inserted: Option<&str>,
ta: &mut TextArea<'static>,
) -> VimKeyOutcome {
match *cmd {
Command::Move(m, n) => {
self.apply_motion(m, n, ta);
VimKeyOutcome::CursorOnly
}
Command::OperateMotion(op, m, n) => {
if self.apply_operator_motion(op, m, n, inserted, ta) {
Self::outcome_for(op)
} else {
VimKeyOutcome::NoOp
}
}
Command::OperateLine(op, n) => {
self.apply_operator_linewise(op, n, inserted, ta);
Self::outcome_for(op)
}
Command::OperateObject(op, obj) => {
if self.apply_operator_object(op, obj, inserted, ta) {
Self::outcome_for(op)
} else {
VimKeyOutcome::NoOp
}
}
Command::OperateToLineEnd(op) => {
self.apply_operator_to_line_end(op, inserted, ta);
Self::outcome_for(op)
}
Command::IndentLines { outdent, count } => {
self.indent_lines(outdent, count, ta);
VimKeyOutcome::TextMutated
}
Command::DeleteChar { forward, count } => {
if self.delete_chars(forward, count, ta) {
VimKeyOutcome::TextMutated
} else {
VimKeyOutcome::NoOp
}
}
Command::ReplaceChar(c) => self.replace_char(c, ta),
Command::SubstituteChar(n) => {
let deleted = self.delete_chars(true, n, ta);
self.finish_insert_entry(cmd, inserted, ta);
if deleted || inserted.is_some() {
VimKeyOutcome::TextMutated
} else {
VimKeyOutcome::CursorOnly
}
}
Command::SubstituteLine => {
let (row, _) = super::cursor_tuple(ta);
if let Some(text) = ta.lines().get(row).map(|l| format!("{l}\n")) {
self.registers.fill(text, RegisterKind::Linewise);
}
ta.move_cursor(CursorMove::Head);
ta.start_selection();
ta.move_cursor(CursorMove::End);
ta.cut();
self.finish_insert_entry(cmd, inserted, ta);
VimKeyOutcome::TextMutated
}
Command::JoinLines { count, spaced } => {
for _ in 0..count.max(1) {
Self::join_line(ta, spaced);
}
VimKeyOutcome::TextMutated
}
Command::ToggleCase(n) => {
for _ in 0..n {
Self::toggle_case_at_cursor(ta);
}
VimKeyOutcome::TextMutated
}
Command::Paste { after, count } => {
if self.paste(after, count, ta) {
VimKeyOutcome::TextMutated
} else {
VimKeyOutcome::NoOp
}
}
Command::Undo(n) => {
for _ in 0..n {
ta.undo();
}
VimKeyOutcome::TextMutated
}
Command::Redo(n) => {
for _ in 0..n {
ta.redo();
}
VimKeyOutcome::TextMutated
}
Command::EnterInsert(entry) => self.apply_enter_insert(entry, cmd, inserted, ta),
Command::EnterReplace => match inserted {
Some(text) => {
for ch in text.chars() {
Self::overwrite_char(ta, ch);
}
self.mode = EditorMode::Normal;
if super::cursor_tuple(ta).1 > 0 {
ta.move_cursor(CursorMove::Back);
}
VimKeyOutcome::TextMutated
}
None => {
self.enter_insert_capture(cmd.clone(), ta);
self.mode = EditorMode::Replace; self.replace_stack.clear();
VimKeyOutcome::CursorOnly
}
},
Command::EnterVisual { line } => {
if line {
ta.move_cursor(CursorMove::Head);
ta.start_selection();
ta.move_cursor(CursorMove::End);
self.mode = EditorMode::VisualLine;
} else {
ta.start_selection();
self.mode = EditorMode::Visual;
}
VimKeyOutcome::CursorOnly
}
Command::Repeat => match self.last_change.clone() {
Some(change) => self.apply(&change.command, change.inserted.as_deref(), ta),
None => VimKeyOutcome::NoOp,
},
}
}
fn finish_insert_entry(
&mut self,
cmd: &Command,
inserted: Option<&str>,
ta: &mut TextArea<'static>,
) {
match inserted {
Some(text) => {
ta.insert_str(text);
self.mode = EditorMode::Normal;
}
None => self.enter_insert_capture(cmd.clone(), ta),
}
}
fn apply_enter_insert(
&mut self,
entry: InsertEntry,
cmd: &Command,
inserted: Option<&str>,
ta: &mut TextArea<'static>,
) -> VimKeyOutcome {
let opened_line = match entry {
InsertEntry::Here => false,
InsertEntry::After => {
ta.move_cursor(CursorMove::Forward);
false
}
InsertEntry::LineStart => {
Self::first_non_blank(ta);
false
}
InsertEntry::LineEnd => {
ta.move_cursor(CursorMove::End);
false
}
InsertEntry::OpenBelow => {
ta.move_cursor(CursorMove::End);
ta.insert_newline();
true
}
InsertEntry::OpenAbove => {
ta.move_cursor(CursorMove::Head);
ta.insert_newline();
ta.move_cursor(CursorMove::Up);
true
}
};
match inserted {
Some(text) => {
ta.insert_str(text);
self.mode = EditorMode::Normal;
if super::cursor_tuple(ta).1 > 0 {
ta.move_cursor(CursorMove::Back);
}
VimKeyOutcome::TextMutated
}
None => {
self.enter_insert_capture(cmd.clone(), ta);
if opened_line {
VimKeyOutcome::TextMutated
} else {
VimKeyOutcome::CursorOnly
}
}
}
}
fn motion_for_char(c: char) -> Option<Motion> {
match c {
'h' => Some(Motion::Left),
'l' => Some(Motion::Right),
'k' => Some(Motion::Up),
'j' => Some(Motion::Down),
'w' => Some(Motion::WordForward),
'W' => Some(Motion::WordForwardBig),
'b' => Some(Motion::WordBack),
'B' => Some(Motion::WordBackBig),
'e' => Some(Motion::WordEnd),
'E' => Some(Motion::WordEndBig),
'0' => Some(Motion::LineStart),
'^' => Some(Motion::FirstNonBlank),
'$' => Some(Motion::LineEnd),
'G' => Some(Motion::FileEnd),
'{' => Some(Motion::ParagraphBack),
'}' => Some(Motion::ParagraphForward),
'%' => Some(Motion::MatchingPair),
_ => None,
}
}
fn g_key_for(c: char) -> Option<GKey> {
match c {
'g' => Some(GKey::GotoLine), 'e' => Some(GKey::Motion(Motion::WordEndBack { big: false })),
'E' => Some(GKey::Motion(Motion::WordEndBack { big: true })),
'_' => Some(GKey::Motion(Motion::LastNonBlank)),
'u' => Some(GKey::CaseOp(Operator::Lowercase)),
'U' => Some(GKey::CaseOp(Operator::Uppercase)),
'~' => Some(GKey::CaseOp(Operator::ToggleCase)),
'J' => Some(GKey::Join),
_ => None,
}
}
fn find_spec_for(c: char) -> Option<(bool, bool)> {
match c {
'f' => Some((false, true)),
'F' => Some((false, false)),
't' => Some((true, true)),
'T' => Some((true, false)),
_ => None,
}
}
fn repeat_find_motion(&self, c: char) -> Option<Motion> {
let (ch, till, fwd) = self.last_find?;
let forward = if c == ';' { fwd } else { !fwd };
Some(Motion::FindChar { ch, till, forward })
}
fn take_count(&mut self) -> usize {
self.pending_count.take().unwrap_or(1)
}
fn take_total_count(&mut self) -> usize {
let op_n = self.pending_op_count.take().unwrap_or(1);
op_n * self.pending_count.take().unwrap_or(1)
}
fn clear_pending(&mut self) {
self.pending_count = None;
self.pending_op_count = None;
self.pending_operator = None;
self.awaiting = None;
}
fn accumulate_count(&mut self, c: char) -> bool {
if c.is_ascii_digit() {
if c == '0' && self.pending_count.is_none() {
return false;
}
let d = c as usize - '0' as usize;
self.pending_count = Some(self.pending_count.unwrap_or(0) * 10 + d);
return true;
}
false
}
fn resolve_motion(
&self,
motion: Motion,
count: usize,
ta: &mut TextArea<'static>,
) -> (usize, usize) {
let saved = super::cursor_tuple(ta);
self.apply_motion(motion, count, ta);
let target = super::cursor_tuple(ta);
ta.move_cursor(CursorMove::Jump(saved.0 as u16, saved.1 as u16));
target
}
fn kind_of(motion: Motion) -> SpanKind {
match motion {
Motion::Up
| Motion::Down
| Motion::FileStart
| Motion::FileEnd
| Motion::GotoLine(_) => SpanKind::Linewise,
Motion::WordEnd
| Motion::WordEndBig
| Motion::WordEndBack { .. }
| Motion::MatchingPair => SpanKind::Inclusive,
Motion::LineEnd | Motion::LastNonBlank => SpanKind::Inclusive,
Motion::FindChar { forward: true, .. } => SpanKind::Inclusive,
_ => SpanKind::Exclusive,
}
}
fn select_range(
ta: &mut TextArea<'static>,
start: (usize, usize),
end: (usize, usize),
inclusive: bool,
) {
let (er, ec) = end;
let end_col = if inclusive {
let len = ta.lines().get(er).map(|l| l.chars().count()).unwrap_or(ec);
(ec + 1).min(len)
} else {
ec
};
ta.move_cursor(CursorMove::Jump(start.0 as u16, start.1 as u16));
ta.start_selection();
ta.move_cursor(CursorMove::Jump(er as u16, end_col as u16));
}
fn apply_motion(&self, motion: Motion, count: usize, ta: &mut TextArea<'static>) {
if let Motion::FindChar { ch, till, forward } = motion {
Self::find_char_count(ta, ch, till, forward, count);
return;
}
for _ in 0..count.max(1) {
match motion {
Motion::Left => ta.move_cursor(CursorMove::Back),
Motion::Right => ta.move_cursor(CursorMove::Forward),
Motion::Up => ta.move_cursor(CursorMove::Up),
Motion::Down => ta.move_cursor(CursorMove::Down),
Motion::WordForward => ta.move_cursor(CursorMove::WordForward),
Motion::WordBack => ta.move_cursor(CursorMove::WordBack),
Motion::WordEnd => ta.move_cursor(CursorMove::WordEnd),
Motion::WordForwardBig => {
let (r, c) = Self::word_forward_big(ta.lines(), super::cursor_tuple(ta));
ta.move_cursor(CursorMove::Jump(r as u16, c as u16));
}
Motion::WordBackBig => {
let (r, c) = Self::word_back_big(ta.lines(), super::cursor_tuple(ta));
ta.move_cursor(CursorMove::Jump(r as u16, c as u16));
}
Motion::WordEndBig => {
if let Some((r, c)) = Self::word_end_big(ta.lines(), super::cursor_tuple(ta)) {
ta.move_cursor(CursorMove::Jump(r as u16, c as u16));
}
}
Motion::WordEndBack { big } => {
if let Some((r, c)) =
Self::word_end_back(ta.lines(), super::cursor_tuple(ta), big)
{
ta.move_cursor(CursorMove::Jump(r as u16, c as u16));
}
}
Motion::LineStart => ta.move_cursor(CursorMove::Head),
Motion::FirstNonBlank => Self::first_non_blank(ta),
Motion::LastNonBlank => Self::last_non_blank(ta),
Motion::LineEnd => ta.move_cursor(CursorMove::End),
Motion::FileStart => ta.move_cursor(CursorMove::Top),
Motion::FileEnd => ta.move_cursor(CursorMove::Bottom),
Motion::GotoLine(n) => {
let last = ta.lines().len().saturating_sub(1);
let row = n.saturating_sub(1).min(last);
ta.move_cursor(CursorMove::Jump(row as u16, 0));
}
Motion::ParagraphForward => ta.move_cursor(CursorMove::ParagraphForward),
Motion::ParagraphBack => ta.move_cursor(CursorMove::ParagraphBack),
Motion::MatchingPair => Self::match_pair(ta),
Motion::FindChar { .. } => unreachable!("handled atomically above"),
}
}
}
fn first_non_blank(ta: &mut TextArea<'static>) {
let (row, _) = super::cursor_tuple(ta);
if let Some(line) = ta.lines().get(row) {
let n = line.chars().take_while(|c| c.is_whitespace()).count();
ta.move_cursor(CursorMove::Jump(row as u16, n as u16));
}
}
fn last_non_blank(ta: &mut TextArea<'static>) {
let (row, _) = super::cursor_tuple(ta);
let idx = ta.lines().get(row).and_then(|line| {
line.chars()
.enumerate()
.filter(|(_, c)| !c.is_whitespace())
.map(|(i, _)| i)
.last()
});
if let Some(idx) = idx {
ta.move_cursor(CursorMove::Jump(row as u16, idx as u16));
}
}
fn char_class(c: char) -> u8 {
if c.is_whitespace() {
0
} else if c.is_alphanumeric() || c == '_' {
1
} else {
2
}
}
fn word_forward_big(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
let (mut row, mut col) = pos;
let last = lines.len().saturating_sub(1);
let mut chars: Vec<char> = lines[row].chars().collect();
while col < chars.len() && !chars[col].is_whitespace() {
col += 1;
}
loop {
if col >= chars.len() {
if row == last {
break; }
row += 1;
chars = lines[row].chars().collect();
col = 0;
if chars.is_empty() {
break;
}
continue;
}
if chars[col].is_whitespace() {
col += 1;
} else {
break;
}
}
(row, col)
}
fn word_back_big(lines: &[String], pos: (usize, usize)) -> (usize, usize) {
let (mut row, mut col) = pos;
let mut chars: Vec<char> = lines[row].chars().collect();
loop {
if col == 0 {
if row == 0 {
return pos; }
row -= 1;
chars = lines[row].chars().collect();
if chars.is_empty() {
return (row, 0);
}
col = chars.len() - 1;
} else {
col -= 1;
}
if !chars[col].is_whitespace() {
break;
}
}
while col > 0 && !chars[col - 1].is_whitespace() {
col -= 1;
}
(row, col)
}
fn word_end_big(lines: &[String], pos: (usize, usize)) -> Option<(usize, usize)> {
let (mut row, mut col) = pos;
let last = lines.len().saturating_sub(1);
let mut chars: Vec<char> = lines[row].chars().collect();
col += 1;
loop {
if col >= chars.len() {
if row == last {
return None; }
row += 1;
chars = lines[row].chars().collect();
col = 0;
continue;
}
if chars[col].is_whitespace() {
col += 1;
} else {
break;
}
}
while col + 1 < chars.len() && !chars[col + 1].is_whitespace() {
col += 1;
}
Some((row, col))
}
fn word_end_back(lines: &[String], pos: (usize, usize), big: bool) -> Option<(usize, usize)> {
let (mut row, mut col) = pos;
let mut chars: Vec<char> = lines[row].chars().collect();
if !chars.is_empty() && col >= chars.len() {
col = chars.len() - 1;
}
loop {
if col == 0 {
if row == 0 {
return None;
}
row -= 1;
chars = lines[row].chars().collect();
col = chars.len();
continue;
}
col -= 1;
let ch = chars[col];
if ch.is_whitespace() {
continue;
}
let is_end = match chars.get(col + 1) {
None => true, Some(&n) => {
n.is_whitespace() || (!big && Self::char_class(n) != Self::char_class(ch))
}
};
if is_end {
return Some((row, col));
}
}
}
fn match_pair(ta: &mut TextArea<'static>) {
let (row, col) = super::cursor_tuple(ta);
let lines = ta.lines();
let here = match lines.get(row).and_then(|l| l.chars().nth(col)) {
Some(c) => c,
None => return,
};
let pairs = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')];
let target = if let Some(&(_, close)) = pairs.iter().find(|&&(o, _)| o == here) {
let mut depth = 0i32;
let mut found = None;
'fwd: for (r, line) in lines.iter().enumerate().skip(row) {
let start = if r == row { col } else { 0 };
for (i, ch) in line.chars().enumerate().skip(start) {
if ch == here {
depth += 1;
} else if ch == close {
depth -= 1;
if depth == 0 {
found = Some((r, i));
break 'fwd;
}
}
}
}
found
} else if let Some(&(open, _)) = pairs.iter().find(|&&(_, c)| c == here) {
let mut depth = 0i32;
let mut found = None;
'back: for r in (0..=row).rev() {
let chars: Vec<char> = lines[r].chars().collect();
let last = if r == row {
col
} else {
chars.len().saturating_sub(1)
};
if chars.is_empty() {
continue;
}
for i in (0..=last.min(chars.len() - 1)).rev() {
if chars[i] == here {
depth += 1;
} else if chars[i] == open {
depth -= 1;
if depth == 0 {
found = Some((r, i));
break 'back;
}
}
}
}
found
} else {
None
};
if let Some((r, c)) = target {
ta.move_cursor(CursorMove::Jump(r as u16, c as u16));
}
}
fn find_char_count(
ta: &mut TextArea<'static>,
ch: char,
till: bool,
forward: bool,
count: usize,
) {
let (row, col) = super::cursor_tuple(ta);
let Some(line) = ta.lines().get(row).cloned() else {
return;
};
let chars: Vec<char> = line.chars().collect();
let n = count.max(1);
let pos = if forward {
((col + 1)..chars.len())
.filter(|&i| chars[i] == ch)
.nth(n - 1)
} else {
(0..col).rev().filter(|&i| chars[i] == ch).nth(n - 1)
};
let Some(pos) = pos else { return };
let target = if till {
if forward {
pos.saturating_sub(1)
} else {
pos + 1
}
} else {
pos
};
ta.move_cursor(CursorMove::Jump(row as u16, target as u16));
}
fn outcome_for(op: Operator) -> VimKeyOutcome {
match op {
Operator::Yank => VimKeyOutcome::CursorOnly, _ => VimKeyOutcome::TextMutated,
}
}
fn apply_operator_motion(
&mut self,
op: Operator,
m: Motion,
count: usize,
inserted: Option<&str>,
ta: &mut TextArea<'static>,
) -> bool {
let effective_motion = if op == Operator::Change {
match m {
Motion::WordForward => Motion::WordEnd,
Motion::WordForwardBig => Motion::WordEndBig, other => other,
}
} else {
m
};
let origin = super::cursor_tuple(ta);
let target = self.resolve_motion(effective_motion, count, ta);
match Self::kind_of(effective_motion) {
SpanKind::Linewise => {
if matches!(effective_motion, Motion::Up | Motion::Down)
&& origin.0.abs_diff(target.0) < count
{
return false;
}
let top = origin.0.min(target.0);
let lines = origin.0.abs_diff(target.0) + 1;
ta.move_cursor(CursorMove::Jump(top as u16, 0));
self.apply_operator_linewise(op, lines, inserted, ta);
true
}
kind => {
if target == origin
&& (kind == SpanKind::Exclusive
|| matches!(
effective_motion,
Motion::FindChar { .. }
| Motion::MatchingPair
| Motion::WordEndBack { .. }
| Motion::WordEndBig
))
{
return false;
}
let (start, end) = if target < origin {
(target, origin)
} else {
(origin, target)
};
Self::select_range(ta, start, end, kind == SpanKind::Inclusive);
if op == Operator::Change {
ta.cut();
self.fill_from_textarea(ta, RegisterKind::Charwise);
self.finish_insert_entry(&Command::OperateMotion(op, m, count), inserted, ta);
} else {
self.apply_operator_on_selection(op, ta);
}
true
}
}
}
fn apply_operator_linewise(
&mut self,
op: Operator,
count: usize,
inserted: Option<&str>,
ta: &mut TextArea<'static>,
) {
let (r0, _) = super::cursor_tuple(ta);
let last = ta.lines().len().saturating_sub(1);
let r1 = (r0 + count.saturating_sub(1)).min(last);
let body: String = ta.lines()[r0..=r1].join("\n");
let register_text = format!("{body}\n");
match op {
Operator::Yank => {
self.registers.fill(register_text, RegisterKind::Linewise);
ta.move_cursor(CursorMove::Jump(r0 as u16, 0));
}
Operator::Delete | Operator::Change => {
if r1 < last {
ta.move_cursor(CursorMove::Jump(r0 as u16, 0));
ta.start_selection();
ta.move_cursor(CursorMove::Jump((r1 + 1) as u16, 0));
} else if r0 > 0 {
let prev_end = ta.lines()[r0 - 1].chars().count();
ta.move_cursor(CursorMove::Jump((r0 - 1) as u16, prev_end as u16));
ta.start_selection();
let end = ta.lines()[r1].chars().count();
ta.move_cursor(CursorMove::Jump(r1 as u16, end as u16));
} else {
ta.move_cursor(CursorMove::Jump(0, 0));
ta.start_selection();
let end = ta.lines()[r1].chars().count();
ta.move_cursor(CursorMove::Jump(r1 as u16, end as u16));
}
ta.cut();
self.registers.fill(register_text, RegisterKind::Linewise);
if op == Operator::Change {
if r0 == 0 && r1 == last {
ta.move_cursor(CursorMove::Jump(0, 0));
} else if r0 > 0 && r1 == last {
ta.move_cursor(CursorMove::End);
ta.insert_newline();
} else {
ta.insert_newline();
ta.move_cursor(CursorMove::Up);
}
self.finish_insert_entry(&Command::OperateLine(op, count), inserted, ta);
}
}
Operator::Indent | Operator::Outdent => {
let outdent = op == Operator::Outdent;
self.indent_lines(outdent, count, ta);
}
Operator::Lowercase | Operator::Uppercase | Operator::ToggleCase => {
let transformed = ta.lines()[r0..=r1]
.iter()
.map(|l| Self::transform_case(l, op))
.collect::<Vec<_>>()
.join("\n");
let end_len = ta.lines()[r1].chars().count();
ta.move_cursor(CursorMove::Jump(r0 as u16, 0));
ta.start_selection();
ta.move_cursor(CursorMove::Jump(r1 as u16, end_len as u16));
ta.cut();
ta.insert_str(&transformed);
ta.move_cursor(CursorMove::Jump(r0 as u16, 0));
}
}
}
fn apply_operator_to_line_end(
&mut self,
op: Operator,
inserted: Option<&str>,
ta: &mut TextArea<'static>,
) {
ta.start_selection();
ta.move_cursor(CursorMove::End);
if op == Operator::Change {
ta.cut();
self.fill_from_textarea(ta, RegisterKind::Charwise);
self.finish_insert_entry(&Command::OperateToLineEnd(op), inserted, ta);
} else {
self.apply_operator_on_selection(op, ta);
}
}
fn indent_lines(&self, outdent: bool, count: usize, ta: &mut TextArea<'static>) {
let (start_row, start_col) = super::cursor_tuple(ta);
let mut first_line_delta = 0usize; for i in 0..count.max(1) {
ta.move_cursor(CursorMove::Head);
if outdent {
let (row, _) = super::cursor_tuple(ta);
let n = ta
.lines()
.get(row)
.map(|l| l.chars().take(4).take_while(|c| *c == ' ').count())
.unwrap_or(0);
if i == 0 {
first_line_delta = n;
}
for _ in 0..n {
ta.delete_next_char();
}
} else {
if i == 0 {
first_line_delta = 4;
}
ta.insert_str(" ");
}
ta.move_cursor(CursorMove::Down);
}
let col = if outdent {
start_col.saturating_sub(first_line_delta)
} else {
start_col + first_line_delta
};
ta.move_cursor(CursorMove::Jump(start_row as u16, col as u16));
}
fn fill_from_textarea(&mut self, ta: &TextArea<'static>, kind: RegisterKind) {
self.registers.fill(ta.yank_text(), kind);
}
fn apply_operator_on_selection(&mut self, op: Operator, ta: &mut TextArea<'static>) {
match op {
Operator::Yank => {
let start = ta.selection_range().map(|(s, _)| s);
ta.copy();
self.fill_from_textarea(ta, RegisterKind::Charwise);
ta.cancel_selection();
if let Some((r, c)) = start {
ta.move_cursor(CursorMove::Jump(r as u16, c as u16));
}
}
Operator::Delete | Operator::Change => {
ta.cut();
self.fill_from_textarea(ta, RegisterKind::Charwise);
}
Operator::Indent | Operator::Outdent => {
let outdent = op == Operator::Outdent;
let (rows, start_row) = if let Some(((sr, _), (er, _))) = ta.selection_range() {
(er.saturating_sub(sr) + 1, sr)
} else {
let (r, _) = super::cursor_tuple(ta);
(1, r)
};
ta.cancel_selection();
ta.move_cursor(CursorMove::Jump(start_row as u16, 0));
self.indent_lines(outdent, rows, ta);
}
Operator::Lowercase | Operator::Uppercase | Operator::ToggleCase => {
let start = ta.selection_range().map(|(s, _)| s);
ta.cut();
let transformed = Self::transform_case(&ta.yank_text(), op);
ta.insert_str(&transformed);
if let Some((r, c)) = start {
ta.move_cursor(CursorMove::Jump(r as u16, c as u16));
}
}
}
}
fn flip_case(ch: char) -> String {
if ch.is_uppercase() {
ch.to_lowercase().collect()
} else {
ch.to_uppercase().collect()
}
}
fn transform_case(text: &str, op: Operator) -> String {
match op {
Operator::Lowercase => text.to_lowercase(),
Operator::Uppercase => text.to_uppercase(),
_ => text.chars().map(Self::flip_case).collect(),
}
}
fn text_between(lines: &[String], start: (usize, usize), end: (usize, usize)) -> String {
if end <= start {
return String::new();
}
let (sr, sc) = start;
let (er, ec) = end;
if sr == er {
return lines
.get(sr)
.map(|l| l.chars().skip(sc).take(ec.saturating_sub(sc)).collect())
.unwrap_or_default();
}
let mut out = String::new();
if let Some(l) = lines.get(sr) {
out.extend(l.chars().skip(sc));
}
out.push('\n');
for r in (sr + 1)..er {
if let Some(l) = lines.get(r) {
out.push_str(l);
}
out.push('\n');
}
if let Some(l) = lines.get(er) {
out.extend(l.chars().take(ec));
}
out
}
fn enter_insert_capture(&mut self, command: Command, ta: &TextArea<'static>) {
self.mode = EditorMode::Insert;
self.insert_capture = Some(InsertCapture {
command,
start: super::cursor_tuple(ta),
});
}
fn record(&mut self, command: Command) {
self.last_change = Some(Change {
command,
inserted: None,
});
}
fn paste(&mut self, after: bool, count: usize, ta: &mut TextArea<'static>) -> bool {
let Some(reg) = self.registers.read() else {
return false;
};
let text = ®.text;
match reg.kind {
RegisterKind::Linewise => {
let body = text.strip_suffix('\n').unwrap_or(text);
let n = count.max(1);
if after {
ta.move_cursor(CursorMove::End);
for _ in 0..n {
ta.insert_newline();
ta.insert_str(body);
}
} else {
ta.move_cursor(CursorMove::Head);
for _ in 0..n {
ta.insert_str(body);
ta.insert_newline();
}
}
}
RegisterKind::Charwise => {
if after {
let (row, col) = super::cursor_tuple(ta);
let len = ta
.lines()
.get(row)
.map(|l| l.chars().count())
.unwrap_or(col);
ta.move_cursor(CursorMove::Jump(row as u16, (col + 1).min(len) as u16));
}
for _ in 0..count.max(1) {
ta.insert_str(text);
}
}
}
true
}
fn object_for_char(c: char, around: bool) -> Option<TextObject> {
match c {
'w' => Some(TextObject::Word { around }),
'(' | ')' | 'b' => Some(TextObject::Pair {
open: '(',
close: ')',
around,
}),
'{' | '}' | 'B' => Some(TextObject::Pair {
open: '{',
close: '}',
around,
}),
'[' | ']' => Some(TextObject::Pair {
open: '[',
close: ']',
around,
}),
'<' | '>' => Some(TextObject::Pair {
open: '<',
close: '>',
around,
}),
'"' => Some(TextObject::Quote { ch: '"', around }),
'\'' => Some(TextObject::Quote { ch: '\'', around }),
'`' => Some(TextObject::Quote { ch: '`', around }),
_ => None,
}
}
fn object_range_at_cursor(
ta: &TextArea<'static>,
obj: TextObject,
) -> Option<(usize, usize, usize)> {
let (row, col) = super::cursor_tuple(ta);
let line = ta.lines().get(row)?;
let chars: Vec<char> = line.chars().collect();
let (start, end) = Self::object_range(&chars, col, obj)?;
Some((row, start, end))
}
fn apply_operator_object(
&mut self,
op: Operator,
obj: TextObject,
inserted: Option<&str>,
ta: &mut TextArea<'static>,
) -> bool {
let Some((row, start, end)) = Self::object_range_at_cursor(ta, obj) else {
return false;
};
Self::select_range(ta, (row, start), (row, end), false);
if op == Operator::Change {
ta.cut();
self.fill_from_textarea(ta, RegisterKind::Charwise);
self.finish_insert_entry(&Command::OperateObject(op, obj), inserted, ta);
} else {
self.apply_operator_on_selection(op, ta);
}
true
}
fn find_enclosing_pair(
chars: &[char],
col: usize,
open: char,
close: char,
) -> Option<(usize, usize)> {
let open_idx = if chars.get(col) == Some(&open) {
col
} else {
let mut depth = 0usize;
let mut found = None;
for i in (0..col).rev() {
if chars[i] == close {
depth += 1;
} else if chars[i] == open {
if depth == 0 {
found = Some(i);
break;
}
depth -= 1;
}
}
found?
};
let mut depth = 0usize;
let mut close_idx = None;
for (i, &ch) in chars.iter().enumerate().skip(open_idx + 1) {
if ch == open {
depth += 1;
} else if ch == close {
if depth == 0 {
close_idx = Some(i);
break;
}
depth -= 1;
}
}
Some((open_idx, close_idx?))
}
fn object_range(chars: &[char], col: usize, obj: TextObject) -> Option<(usize, usize)> {
if chars.is_empty() || col >= chars.len() {
return None;
}
match obj {
TextObject::Word { around } => {
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let mut s = col;
while s > 0 && is_word(chars[s - 1]) {
s -= 1;
}
let mut e = col;
while e < chars.len() && is_word(chars[e]) {
e += 1;
}
if around {
while e < chars.len() && chars[e].is_whitespace() {
e += 1;
}
}
Some((s, e))
}
TextObject::Quote { ch, around } => {
let positions: Vec<usize> = chars
.iter()
.enumerate()
.filter(|&(_, &c)| c == ch)
.map(|(i, _)| i)
.collect();
let pair = positions
.chunks(2)
.find(|p| p.len() == 2 && p[0] <= col && col <= p[1])?;
let (o, c) = (pair[0], pair[1]);
if around {
Some((o, c + 1))
} else {
Some((o + 1, c))
}
}
TextObject::Pair {
open,
close,
around,
} => {
let (o, c) = Self::find_enclosing_pair(chars, col, open, close)?;
if around {
Some((o, c + 1))
} else {
Some((o + 1, c))
}
}
}
}
fn delete_chars(&mut self, forward: bool, count: usize, ta: &mut TextArea<'static>) -> bool {
let (row, col) = super::cursor_tuple(ta);
let Some(line) = ta.lines().get(row) else {
return false;
};
let line_len = line.chars().count();
let (n, start) = if forward {
(count.min(line_len.saturating_sub(col)), col)
} else {
let n = count.min(col);
(n, col - n)
};
let deleted: String = line.chars().skip(start).take(n).collect();
self.registers.fill(deleted, RegisterKind::Charwise);
for _ in 0..n {
if forward {
ta.delete_next_char();
} else {
ta.delete_char();
}
}
n > 0
}
fn replace_char(&mut self, c: char, ta: &mut TextArea<'static>) -> VimKeyOutcome {
if ta.delete_next_char() {
ta.insert_char(c);
ta.move_cursor(CursorMove::Back);
VimKeyOutcome::TextMutated
} else {
VimKeyOutcome::NoOp
}
}
fn join_line(ta: &mut TextArea<'static>, spaced: bool) {
let (row, _) = super::cursor_tuple(ta);
let lines = ta.lines();
if row + 1 >= lines.len() {
return;
}
let cur_empty = lines[row].is_empty();
let cur_ends_ws = lines[row].chars().last().is_some_and(|c| c.is_whitespace());
ta.move_cursor(CursorMove::End);
ta.delete_next_char(); if !spaced {
return;
}
let (r, c) = super::cursor_tuple(ta);
let strip = ta.lines()[r]
.chars()
.skip(c)
.take_while(|ch| ch.is_whitespace())
.count();
for _ in 0..strip {
ta.delete_next_char();
}
let rest_nonempty = ta.lines()[r].chars().count() > c;
if !cur_empty && !cur_ends_ws && rest_nonempty {
ta.insert_char(' ');
ta.move_cursor(CursorMove::Back);
}
}
fn toggle_case_at_cursor(ta: &mut TextArea<'static>) {
let (row, col) = super::cursor_tuple(ta);
let flipped = ta
.lines()
.get(row)
.and_then(|line| line.chars().nth(col))
.map(Self::flip_case);
if let Some(flipped) = flipped {
ta.delete_next_char();
ta.insert_str(&flipped);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui_textarea::TextArea;
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn esc() -> KeyEvent {
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
}
fn ta() -> TextArea<'static> {
TextArea::from(["hello world", "second line"])
}
#[test]
fn i_enters_insert_mode() {
let mut e = VimEngine::default();
let mut t = ta();
let out = e.handle_key(&key('i'), &mut t);
assert_eq!(*e.mode(), EditorMode::Insert);
assert_eq!(out, VimKeyOutcome::CursorOnly);
}
#[test]
fn esc_returns_to_normal_and_steps_back() {
let mut e = VimEngine::default();
let mut t = ta();
e.handle_key(&key('i'), &mut t);
t.move_cursor(ratatui_textarea::CursorMove::Forward);
t.move_cursor(ratatui_textarea::CursorMove::Forward);
let col_before = super::super::cursor_tuple(&t).1;
let out = e.handle_key(&esc(), &mut t);
assert_eq!(*e.mode(), EditorMode::Normal);
assert_eq!(out, VimKeyOutcome::CursorOnly);
assert_eq!(super::super::cursor_tuple(&t).1, col_before - 1);
}
#[test]
fn insert_mode_passes_through() {
let mut e = VimEngine::default();
let mut t = ta();
e.handle_key(&key('i'), &mut t);
let out = e.handle_key(&key('x'), &mut t);
assert_eq!(out, VimKeyOutcome::PassThrough);
}
#[test]
fn l_moves_right_cursor_only() {
let mut e = VimEngine::default();
let mut t = ta();
let out = e.handle_key(&key('l'), &mut t);
assert_eq!(out, VimKeyOutcome::CursorOnly);
assert_eq!(super::super::cursor_tuple(&t), (0, 1));
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn a_enters_insert_after_cursor() {
let mut e = VimEngine::default();
let mut t = ta();
e.handle_key(&key('a'), &mut t);
assert_eq!(*e.mode(), EditorMode::Insert);
assert_eq!(super::super::cursor_tuple(&t), (0, 1));
}
#[test]
fn o_opens_line_below_in_insert() {
let mut e = VimEngine::default();
let mut t = ta();
let out = e.handle_key(&key('o'), &mut t);
assert_eq!(*e.mode(), EditorMode::Insert);
assert_eq!(out, VimKeyOutcome::TextMutated);
assert_eq!(t.lines().len(), 3);
assert_eq!(super::super::cursor_tuple(&t).0, 1);
}
#[test]
fn reset_returns_to_normal_from_insert() {
let mut e = VimEngine::default();
let mut t = ta();
e.handle_key(&key('i'), &mut t);
assert_eq!(*e.mode(), EditorMode::Insert);
e.reset_to_normal();
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn unknown_normal_key_is_noop() {
let mut e = VimEngine::default();
let mut t = ta();
let out = e.handle_key(&key('z'), &mut t);
assert_eq!(out, VimKeyOutcome::NoOp);
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn count_accumulates_then_moves() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abcdef"]);
e.handle_key(&key('3'), &mut t);
e.handle_key(&key('l'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 3));
e.handle_key(&key('l'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 4));
}
#[test]
fn zero_without_count_is_line_start() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abcdef"]);
e.handle_key(&key('l'), &mut t);
e.handle_key(&key('l'), &mut t);
e.handle_key(&key('0'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 0));
}
#[test]
#[allow(non_snake_case)]
fn gg_and_G_jump_file_ends() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('G'), &mut t);
assert_eq!(super::super::cursor_tuple(&t).0, 2);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('g'), &mut t);
assert_eq!(super::super::cursor_tuple(&t).0, 0);
}
#[test]
fn pending_g_cancels_on_unmapped_key() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('G'), &mut t); assert_eq!(super::super::cursor_tuple(&t).0, 2);
e.handle_key(&key('g'), &mut t); e.handle_key(&key('z'), &mut t); e.handle_key(&key('g'), &mut t); assert_eq!(
super::super::cursor_tuple(&t).0,
2,
"stray g after cancelled prefix must not jump to file start"
);
}
#[test]
fn pending_g_cleared_through_insert() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('G'), &mut t);
e.handle_key(&key('g'), &mut t); e.handle_key(&key('a'), &mut t); e.handle_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut t);
e.handle_key(&key('g'), &mut t); assert_eq!(
super::super::cursor_tuple(&t).0,
2,
"g after insert must not complete a stale gg"
);
}
#[test]
fn dw_deletes_word() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello world"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('w'), &mut t);
assert_eq!(t.lines(), &["world"]);
}
#[test]
fn dd_deletes_line_linewise() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('d'), &mut t);
assert_eq!(t.lines(), &["two", "three"]);
let reg = e.registers.read().expect("dd must fill the register");
assert_eq!(reg.kind, RegisterKind::Linewise);
assert_eq!(reg.text, "one\n");
}
#[test]
fn yy_then_p_duplicates_line() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two"]);
e.handle_key(&key('y'), &mut t);
e.handle_key(&key('y'), &mut t);
e.handle_key(&key('p'), &mut t);
assert_eq!(t.lines(), &["one", "one", "two"]);
}
#[test]
fn cw_deletes_word_and_enters_insert() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello world"]);
e.handle_key(&key('c'), &mut t);
e.handle_key(&key('w'), &mut t);
assert_eq!(*e.mode(), EditorMode::Insert);
assert_eq!(t.lines(), &[" world"]);
}
#[test]
fn charwise_p_pastes_after_cursor() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('y'), &mut t);
e.handle_key(&key('l'), &mut t);
e.handle_key(&key('p'), &mut t);
assert_eq!(t.lines(), &["aabc"]);
}
#[test]
fn dd_on_last_line_removes_it() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('G'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('d'), &mut t);
assert_eq!(t.lines(), &["one", "two"]);
}
#[test]
fn dd_on_only_line_leaves_empty() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["only"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('d'), &mut t);
assert_eq!(t.lines(), &[""]);
}
#[test]
fn linewise_2p_inserts_two_copies() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two"]);
e.handle_key(&key('y'), &mut t);
e.handle_key(&key('y'), &mut t); e.handle_key(&key('2'), &mut t);
e.handle_key(&key('p'), &mut t);
assert_eq!(t.lines(), &["one", "one", "one", "two"]);
}
#[test]
fn yy_last_line_then_p_duplicates() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two"]);
e.handle_key(&key('G'), &mut t); e.handle_key(&key('y'), &mut t);
e.handle_key(&key('y'), &mut t);
e.handle_key(&key('p'), &mut t);
assert_eq!(t.lines(), &["one", "two", "two"]);
}
#[test]
fn x_deletes_char_under_cursor() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('x'), &mut t);
assert_eq!(t.lines(), &["bc"]);
}
#[test]
fn r_replaces_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('r'), &mut t);
e.handle_key(&key('Z'), &mut t);
assert_eq!(t.lines(), &["Zbc"]);
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn u_undoes_last_edit() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('x'), &mut t);
e.handle_key(&key('u'), &mut t);
assert_eq!(t.lines(), &["abc"]);
}
#[test]
fn tilde_toggles_case() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('~'), &mut t);
assert_eq!(t.lines(), &["Abc"]);
}
#[test]
fn f_moves_to_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello, world"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key(','), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 5));
}
#[test]
fn df_deletes_through_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello, world"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key(','), &mut t);
assert_eq!(t.lines(), &[" world"]);
}
#[test]
fn t_stops_before_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello, world"]);
e.handle_key(&key('t'), &mut t);
e.handle_key(&key(','), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 4)); }
#[test]
fn semicolon_repeats_find() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["a.b.c.d"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('.'), &mut t);
assert_eq!(super::super::cursor_tuple(&t).1, 1);
e.handle_key(&key(';'), &mut t);
assert_eq!(super::super::cursor_tuple(&t).1, 3);
}
#[test]
fn diw_deletes_inner_word() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar baz"]);
e.handle_key(&key('w'), &mut t);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('w'), &mut t);
assert_eq!(t.lines(), &["foo baz"]);
}
#[test]
fn ci_quote_changes_inside_quotes() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["say \"hi\" now"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('h'), &mut t);
e.handle_key(&key('c'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('"'), &mut t);
assert_eq!(t.lines(), &["say \"\" now"]);
assert_eq!(*e.mode(), EditorMode::Insert);
}
#[test]
fn di_paren_deletes_inside_parens() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo(bar)baz"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('('), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('('), &mut t);
assert_eq!(t.lines(), &["foo()baz"]);
}
#[test]
fn daw_deletes_word_and_trailing_space() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar baz"]);
e.handle_key(&key('w'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('a'), &mut t);
e.handle_key(&key('w'), &mut t);
assert_eq!(t.lines(), &["foo baz"]);
}
#[test]
fn percent_jumps_to_matching_paren() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo(bar)baz"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('('), &mut t); e.handle_key(&key('%'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 7)); }
#[test]
fn percent_jumps_back_from_close() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo(bar)baz"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key(')'), &mut t); e.handle_key(&key('%'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 3)); }
#[test]
fn percent_handles_nested() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["(a(b)c)"]);
e.handle_key(&key('%'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 6)); }
#[test]
fn v_motion_d_deletes_selection() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello"]);
e.handle_key(&key('v'), &mut t); e.handle_key(&key('l'), &mut t); e.handle_key(&key('l'), &mut t); e.handle_key(&key('d'), &mut t); assert_eq!(t.lines(), &["lo"]); assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
#[allow(non_snake_case)]
fn V_then_d_deletes_line() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two"]);
e.handle_key(&key('V'), &mut t);
e.handle_key(&key('d'), &mut t);
assert_eq!(t.lines(), &["two"]);
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn visual_y_yanks_and_returns_to_normal() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello"]);
e.handle_key(&key('v'), &mut t); e.handle_key(&key('l'), &mut t); e.handle_key(&key('y'), &mut t); assert_eq!(*e.mode(), EditorMode::Normal);
let before_len: usize = t.lines().iter().map(|l| l.len()).sum();
e.handle_key(&key('p'), &mut t);
let after_len: usize = t.lines().iter().map(|l| l.len()).sum();
assert_eq!(after_len, before_len + 2);
}
#[test]
fn visual_esc_cancels_and_returns_normal() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('l'), &mut t);
assert_eq!(*e.mode(), EditorMode::Visual);
e.handle_key(&esc(), &mut t);
assert_eq!(*e.mode(), EditorMode::Normal);
assert!(t.selection_range().is_none());
}
#[test]
fn visual_c_enters_insert_after_delete() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello"]);
e.handle_key(&key('v'), &mut t); e.handle_key(&key('l'), &mut t); e.handle_key(&key('c'), &mut t); assert_eq!(*e.mode(), EditorMode::Insert);
assert_eq!(t.lines(), &["llo"]); }
#[test]
fn indent_line_adds_spaces() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["x"]);
e.handle_key(&key('>'), &mut t);
e.handle_key(&key('>'), &mut t);
assert_eq!(t.lines(), &[" x"]);
}
#[test]
fn indent_keeps_cursor_over_same_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('>'), &mut t);
e.handle_key(&key('>'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 5)); e.handle_key(&key('2'), &mut t);
e.handle_key(&key('>'), &mut t);
e.handle_key(&key('>'), &mut t);
assert_eq!(super::super::cursor_tuple(&t).0, 0);
}
#[test]
fn outdent_keeps_cursor_over_same_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from([" x"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('x'), &mut t); e.handle_key(&key('<'), &mut t);
e.handle_key(&key('<'), &mut t);
assert_eq!(t.lines(), &["x"]);
assert_eq!(super::super::cursor_tuple(&t).1, 0); }
#[test]
fn outdent_removes_spaces() {
let mut e = VimEngine::default();
let mut t = TextArea::from([" x"]); e.handle_key(&key('<'), &mut t);
e.handle_key(&key('<'), &mut t);
assert_eq!(t.lines(), &[" x"]); }
#[test]
fn pending_hint_shows_operator_and_count() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('2'), &mut t);
e.handle_key(&key('d'), &mut t);
assert_eq!(e.pending_hint().as_deref(), Some("2d"));
}
#[test]
fn dot_repeats_x() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abcdef"]);
e.handle_key(&key('x'), &mut t);
e.handle_key(&key('.'), &mut t);
assert_eq!(t.lines(), &["cdef"]);
}
#[test]
fn dot_repeats_dw() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one two three four"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('w'), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["three four"]);
}
#[test]
fn dot_repeats_change_with_typed_text() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar"]);
e.handle_key(&key('c'), &mut t);
e.handle_key(&key('w'), &mut t); t.insert_str("X");
e.handle_key(&esc(), &mut t); e.handle_key(&key('w'), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["X X"]);
}
#[test]
fn dot_repeats_multiline_change() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar"]);
e.handle_key(&key('c'), &mut t);
e.handle_key(&key('w'), &mut t); t.insert_str("a");
t.insert_newline();
t.insert_str("b"); e.handle_key(&esc(), &mut t); assert_eq!(t.lines(), &["a", "b bar"]);
e.handle_key(&key('w'), &mut t); e.handle_key(&key('.'), &mut t); assert!(
t.lines().len() >= 3,
"replay of multiline insert should produce >=3 lines: {:?}",
t.lines()
);
}
#[test]
fn space_leads_only_in_clean_normal() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["x"]);
assert!(e.space_leads());
e.handle_key(&key('d'), &mut t); assert!(!e.space_leads());
e.handle_key(&key('w'), &mut t); assert!(e.space_leads());
e.handle_key(&key('i'), &mut t); assert!(!e.space_leads());
}
#[test]
fn colon_emits_open_palette() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["x"]);
assert_eq!(
e.handle_key(&key(':'), &mut t),
VimKeyOutcome::Host(VimHostAction::OpenPalette)
);
}
#[test]
fn slash_emits_open_search_forward() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["x"]);
assert_eq!(
e.handle_key(&key('/'), &mut t),
VimKeyOutcome::Host(VimHostAction::OpenSearch { forward: true })
);
}
#[test]
#[allow(non_snake_case)]
fn n_and_N_emit_search_nav() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["x"]);
assert_eq!(
e.handle_key(&key('n'), &mut t),
VimKeyOutcome::Host(VimHostAction::SearchNext)
);
assert_eq!(
e.handle_key(&key('N'), &mut t),
VimKeyOutcome::Host(VimHostAction::SearchPrev)
);
}
#[test]
fn mouse_selection_enters_and_leaves_visual() {
let mut e = VimEngine::default();
e.sync_mouse_selection(true);
assert_eq!(*e.mode(), EditorMode::Visual);
e.sync_mouse_selection(false);
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn mouse_no_selection_in_normal_stays_normal() {
let mut e = VimEngine::default();
e.sync_mouse_selection(false);
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn mouse_does_not_disturb_insert() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["x"]);
e.handle_key(&key('i'), &mut t); e.sync_mouse_selection(true);
assert_eq!(*e.mode(), EditorMode::Insert); }
#[test]
fn di_paren_on_empty_line_does_not_panic() {
let mut e = VimEngine::default();
let mut t = TextArea::from([""]); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('('), &mut t); assert_eq!(t.lines(), &[""]);
}
#[test]
fn esc_clears_pending_g_in_normal() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('G'), &mut t); assert_eq!(super::super::cursor_tuple(&t).0, 2);
e.handle_key(&key('g'), &mut t); e.handle_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut t); e.handle_key(&key('g'), &mut t); assert_eq!(
super::super::cursor_tuple(&t).0,
2,
"Esc must cancel pending g"
);
}
#[test]
fn esc_clears_pending_operator_object_in_normal() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar baz"]);
e.handle_key(&key('d'), &mut t); e.handle_key(&key('i'), &mut t); e.handle_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut t); assert_eq!(t.lines(), &["foo bar baz"]);
e.handle_key(&key('w'), &mut t);
assert_eq!(*e.mode(), EditorMode::Normal);
assert_eq!(
t.lines(),
&["foo bar baz"],
"w after Esc must be a motion, not diw"
);
}
#[test]
fn di_paren_nested_selects_inner_of_outer() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["((x))"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('('), &mut t);
assert_eq!(t.lines(), &["()"]); }
#[test]
fn di_paren_from_inside_nested() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["((x))"]);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('l'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('('), &mut t);
assert_eq!(t.lines(), &["(())"]); }
#[test]
fn di_quote_in_gap_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["\"foo\" \"bar\""]);
for _ in 0..5 {
e.handle_key(&key('l'), &mut t);
}
assert_eq!(super::super::cursor_tuple(&t).1, 5);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('"'), &mut t);
assert_eq!(t.lines(), &["\"foo\" \"bar\""]); }
#[test]
fn di_quote_inside_still_works() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["\"foo\" \"bar\""]);
for _ in 0..7 {
e.handle_key(&key('l'), &mut t);
} e.handle_key(&key('d'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('"'), &mut t);
assert_eq!(t.lines(), &["\"foo\" \"\""]); }
#[test]
fn df_last_char_does_not_join_next_line() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc", "xyz"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('c'), &mut t);
assert_eq!(t.lines(), &["", "xyz"]); }
#[test]
fn cc_single_line_leaves_one_empty_line() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello"]);
e.handle_key(&key('c'), &mut t);
e.handle_key(&key('c'), &mut t);
assert_eq!(t.lines(), &[""]);
assert_eq!(*e.mode(), EditorMode::Insert);
}
#[test]
fn cc_middle_line_still_works() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('j'), &mut t); e.handle_key(&key('c'), &mut t);
e.handle_key(&key('c'), &mut t);
assert_eq!(t.lines(), &["one", "", "three"]);
assert_eq!(*e.mode(), EditorMode::Insert);
}
#[test]
fn r_on_empty_line_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from([""]);
e.handle_key(&key('r'), &mut t);
let out = e.handle_key(&key('Z'), &mut t);
assert_eq!(out, VimKeyOutcome::NoOp);
assert_eq!(t.lines(), &[""]);
}
#[test]
fn visual_v_then_d_deletes_char_under_cursor() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('v'), &mut t); e.handle_key(&key('d'), &mut t);
assert_eq!(t.lines(), &["bc"]); }
#[test]
fn visual_e_then_d_inclusive() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello world"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('e'), &mut t); e.handle_key(&key('d'), &mut t);
assert_eq!(t.lines(), &[" world"]); }
#[test]
fn e_lands_on_last_word_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello world"]);
e.handle_key(&key('e'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 4)); }
#[test]
fn e_twice_reaches_second_word_end() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello world"]);
e.handle_key(&key('e'), &mut t);
e.handle_key(&key('e'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 10)); }
#[test]
fn de_deletes_to_word_end_inclusive() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello world"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('e'), &mut t);
assert_eq!(t.lines(), &[" world"]); }
#[test]
fn visual_y_leaves_cursor_at_selection_start() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar", "baz"]);
for _ in 0..4 {
e.handle_key(&key('l'), &mut t);
} e.handle_key(&key('v'), &mut t);
e.handle_key(&key('e'), &mut t); e.handle_key(&key('y'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 4)); }
#[test]
fn charwise_p_after_eol_word_does_not_touch_next_line() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar", "baz"]);
for _ in 0..4 {
e.handle_key(&key('l'), &mut t);
} e.handle_key(&key('v'), &mut t);
e.handle_key(&key('e'), &mut t); e.handle_key(&key('y'), &mut t); e.handle_key(&key('p'), &mut t); assert_eq!(t.lines()[1], "baz"); assert_eq!(t.lines().len(), 2); assert_eq!(t.lines()[0], "foo bbarar"); }
#[test]
fn charwise_p_at_line_end_appends_same_line() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["ab", "cd"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('e'), &mut t); e.handle_key(&key('y'), &mut t); e.handle_key(&key('$'), &mut t); e.handle_key(&key('p'), &mut t); assert_eq!(t.lines()[0], "abab");
assert_eq!(t.lines()[1], "cd"); }
#[test]
fn visual_p_replaces_charwise_selection() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('e'), &mut t);
e.handle_key(&key('y'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 0));
for _ in 0..4 {
e.handle_key(&key('l'), &mut t);
} e.handle_key(&key('v'), &mut t);
e.handle_key(&key('e'), &mut t); e.handle_key(&key('p'), &mut t);
assert_eq!(t.lines(), &["foo foo"]); assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn visual_p_yanks_replaced_selection() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('e'), &mut t);
e.handle_key(&key('y'), &mut t); for _ in 0..4 {
e.handle_key(&key('l'), &mut t);
} e.handle_key(&key('v'), &mut t);
e.handle_key(&key('e'), &mut t);
e.handle_key(&key('p'), &mut t); assert_eq!(t.lines(), &["foo foo"]);
e.handle_key(&key('$'), &mut t); e.handle_key(&key('p'), &mut t); assert_eq!(t.lines(), &["foo foobar"]);
}
#[test]
fn g_underscore_jumps_to_last_non_blank() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hi there "]);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('_'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 7)); }
#[test]
fn d_g_underscore_deletes_through_last_non_blank() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar "]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('_'), &mut t);
assert_eq!(t.lines(), &[" "]); }
#[test]
#[allow(non_snake_case)]
fn count_G_and_count_gg_go_to_line() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["1", "2", "3", "4", "5", "6"]);
e.handle_key(&key('5'), &mut t);
e.handle_key(&key('G'), &mut t);
assert_eq!(super::super::cursor_tuple(&t).0, 4); e.handle_key(&key('2'), &mut t);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('g'), &mut t);
assert_eq!(super::super::cursor_tuple(&t).0, 1); }
#[test]
#[allow(non_snake_case)]
fn d_count_G_deletes_lines_through_target() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('2'), &mut t);
e.handle_key(&key('G'), &mut t); assert_eq!(t.lines(), &["three"]);
}
#[test]
fn ge_jumps_to_previous_word_end() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar"]);
e.handle_key(&key('$'), &mut t); e.handle_key(&key('g'), &mut t);
e.handle_key(&key('e'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 2)); }
#[test]
fn ge_stops_at_class_change() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo.bar"]);
e.handle_key(&key('$'), &mut t); e.handle_key(&key('g'), &mut t);
e.handle_key(&key('e'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 3)); }
#[test]
#[allow(non_snake_case)]
fn gE_ignores_punctuation_boundaries() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["aa bb.cc dd"]);
e.handle_key(&key('$'), &mut t); e.handle_key(&key('g'), &mut t);
e.handle_key(&key('E'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 7)); }
#[test]
fn ge_at_buffer_start_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo"]);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('e'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 0));
}
#[test]
fn dge_deletes_backward_inclusive_of_cursor() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc def"]);
e.handle_key(&key('$'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('e'), &mut t);
assert_eq!(t.lines(), &["ab"]);
}
#[test]
#[allow(non_snake_case)]
fn W_treats_punctuated_run_as_one_word() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo.bar baz"]);
e.handle_key(&key('W'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 8)); }
#[test]
#[allow(non_snake_case)]
fn E_jumps_to_end_of_WORD() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo.bar baz"]);
e.handle_key(&key('E'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 6)); }
#[test]
#[allow(non_snake_case)]
fn B_jumps_to_WORD_start() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo.bar baz"]);
e.handle_key(&key('W'), &mut t); e.handle_key(&key('B'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 0));
}
#[test]
#[allow(non_snake_case)]
fn W_crosses_lines_and_stops_at_empty_line() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo", "", "bar"]);
e.handle_key(&key('W'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (1, 0)); e.handle_key(&key('W'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (2, 0));
}
#[test]
#[allow(non_snake_case)]
fn dW_deletes_whole_WORD() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo.bar baz"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('W'), &mut t);
assert_eq!(t.lines(), &["baz"]);
}
#[test]
#[allow(non_snake_case)]
fn cW_acts_like_cE() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo.bar baz"]);
e.handle_key(&key('c'), &mut t);
e.handle_key(&key('W'), &mut t);
assert_eq!(*e.mode(), EditorMode::Insert);
assert_eq!(t.lines(), &[" baz"]); }
#[test]
fn hint_shows_object_scope_mid_sequence() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('i'), &mut t);
assert_eq!(e.pending_hint().as_deref(), Some("di"));
}
#[test]
fn hint_shows_actual_find_key() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo"]);
e.handle_key(&key('T'), &mut t);
assert_eq!(e.pending_hint().as_deref(), Some("T")); }
#[test]
fn replace_backspace_restores_overwritten_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('R'), &mut t);
e.handle_key(&key('X'), &mut t); e.handle_key(&key('Y'), &mut t); e.handle_key(
&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
&mut t,
);
e.handle_key(&esc(), &mut t);
assert_eq!(t.lines(), &["Xbc"]); }
#[test]
fn replace_backspace_removes_appended_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["a"]);
e.handle_key(&key('R'), &mut t);
e.handle_key(&key('X'), &mut t); e.handle_key(&key('Y'), &mut t); e.handle_key(
&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
&mut t,
);
e.handle_key(&esc(), &mut t);
assert_eq!(t.lines(), &["X"]); }
fn buf(lines: &[&str]) -> Vec<String> {
lines.iter().map(|s| s.to_string()).collect()
}
#[test]
fn pure_word_forward_big_positions() {
let b = buf(&["foo.bar baz"]);
assert_eq!(VimEngine::word_forward_big(&b, (0, 0)), (0, 8));
let b = buf(&["foo", "", "bar"]);
assert_eq!(VimEngine::word_forward_big(&b, (0, 0)), (1, 0)); assert_eq!(VimEngine::word_forward_big(&b, (1, 0)), (2, 0));
}
#[test]
fn pure_word_back_big_positions() {
let b = buf(&["foo.bar baz"]);
assert_eq!(VimEngine::word_back_big(&b, (0, 8)), (0, 0));
assert_eq!(VimEngine::word_back_big(&b, (0, 0)), (0, 0)); }
#[test]
fn pure_word_end_big_positions() {
let b = buf(&["foo.bar baz"]);
assert_eq!(VimEngine::word_end_big(&b, (0, 0)), Some((0, 6)));
assert_eq!(VimEngine::word_end_big(&b, (0, 10)), None); }
#[test]
fn pure_word_end_back_positions() {
let b = buf(&["foo.bar"]);
assert_eq!(VimEngine::word_end_back(&b, (0, 6), false), Some((0, 3))); assert_eq!(VimEngine::word_end_back(&b, (0, 6), true), None); assert_eq!(VimEngine::word_end_back(&b, (0, 0), false), None); }
#[test]
fn visual_counted_motion_extends_by_count() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abcdef"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('3'), &mut t);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('d'), &mut t);
assert_eq!(t.lines(), &["ef"]);
}
#[test]
#[allow(non_snake_case)]
fn gUu_aborts_without_running_undo() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["ab"]);
e.handle_key(&key('x'), &mut t); e.handle_key(&key('g'), &mut t);
e.handle_key(&key('U'), &mut t); e.handle_key(&key('u'), &mut t); assert_eq!(t.lines(), &["b"]); }
#[test]
fn dx_and_dp_abort_with_operator_pending() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('y'), &mut t);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('x'), &mut t); assert_eq!(t.lines(), &["abc"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('p'), &mut t); assert_eq!(t.lines(), &["abc"]);
}
#[test]
fn dge_at_buffer_start_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('e'), &mut t); assert_eq!(t.lines(), &["foo"]);
}
#[test]
fn gugu_doubled_g_form_runs_linewise() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["ABC def"]);
for c in "gugu".chars() {
e.handle_key(&key(c), &mut t);
}
assert_eq!(t.lines(), &["abc def"]);
}
#[test]
#[allow(non_snake_case)]
fn visual_J_joins_selected_lines_with_space() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["a", "b", "c"]);
e.handle_key(&key('V'), &mut t);
e.handle_key(&key('j'), &mut t);
e.handle_key(&key('j'), &mut t); e.handle_key(&key('J'), &mut t);
assert_eq!(t.lines(), &["a b c"]);
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
#[allow(non_snake_case)]
fn visual_gJ_joins_selected_lines_raw() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["a", " b"]);
e.handle_key(&key('V'), &mut t);
e.handle_key(&key('j'), &mut t);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('J'), &mut t);
assert_eq!(t.lines(), &["a b"]); }
#[test]
#[allow(non_snake_case)]
fn replace_mode_arrows_move_cursor() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abcd"]);
e.handle_key(&key('R'), &mut t);
e.handle_key(&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut t);
e.handle_key(&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &mut t);
e.handle_key(&key('X'), &mut t); e.handle_key(&esc(), &mut t);
assert_eq!(t.lines(), &["abXd"]);
e.handle_key(&key('0'), &mut t);
e.handle_key(&key('.'), &mut t);
assert_eq!(t.lines(), &["XbXd"]);
}
#[test]
fn esc_from_insert_clears_stray_selection() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello"]);
e.handle_key(&key('i'), &mut t);
t.start_selection();
t.move_cursor(ratatui_textarea::CursorMove::Forward);
e.handle_key(&esc(), &mut t);
assert!(
t.selection_range().is_none(),
"Esc must drop the stray selection"
);
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn guu_undoes_in_one_step() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["Mixed Case Line"]);
for c in "guu".chars() {
e.handle_key(&key(c), &mut t);
}
assert_eq!(t.lines(), &["mixed case line"]);
e.handle_key(&key('u'), &mut t); e.handle_key(&key('u'), &mut t); assert_eq!(t.lines(), &["Mixed Case Line"]);
}
#[test]
fn visual_g_tilde_toggles_case_of_selection() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["FooBar"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('e'), &mut t); e.handle_key(&key('g'), &mut t);
e.handle_key(&key('~'), &mut t);
assert_eq!(t.lines(), &["fOObAR"]);
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn visual_bare_tilde_still_passes_through_for_surround() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["FooBar"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('e'), &mut t);
let out = e.handle_key(&key('~'), &mut t);
assert_eq!(out, VimKeyOutcome::PassThrough); assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn guw_lowercases_word() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["HELLO world"]);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('u'), &mut t);
e.handle_key(&key('w'), &mut t);
assert_eq!(t.lines(), &["hello world"]);
assert_eq!(super::super::cursor_tuple(&t), (0, 0)); }
#[test]
#[allow(non_snake_case)]
fn gU_iw_uppercases_inner_word() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar baz"]);
e.handle_key(&key('w'), &mut t); e.handle_key(&key('g'), &mut t);
e.handle_key(&key('U'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('w'), &mut t);
assert_eq!(t.lines(), &["foo BAR baz"]);
}
#[test]
fn g_tilde_toggles_case_to_word_end() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["FooBar baz"]);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('~'), &mut t);
e.handle_key(&key('e'), &mut t); assert_eq!(t.lines(), &["fOObAR baz"]);
}
#[test]
fn guu_lowercases_whole_line() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["HELLO World", "NEXT"]);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('u'), &mut t);
e.handle_key(&key('u'), &mut t);
assert_eq!(t.lines(), &["hello world", "NEXT"]);
}
#[test]
#[allow(non_snake_case)]
fn visual_U_uppercases_selection() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('l'), &mut t);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('U'), &mut t);
assert_eq!(t.lines(), &["HELlo"]);
assert_eq!(*e.mode(), EditorMode::Normal);
}
#[test]
fn case_op_does_not_touch_register() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["keep CHANGE"]);
e.handle_key(&key('y'), &mut t);
e.handle_key(&key('e'), &mut t); e.handle_key(&key('w'), &mut t); e.handle_key(&key('g'), &mut t);
e.handle_key(&key('u'), &mut t);
e.handle_key(&key('w'), &mut t); assert_eq!(e.registers.read().unwrap().text, "keep"); }
#[test]
#[allow(non_snake_case)]
fn dot_repeats_gU_word() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one two"]);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('U'), &mut t);
e.handle_key(&key('e'), &mut t); e.handle_key(&key('w'), &mut t); e.handle_key(&key('.'), &mut t);
assert_eq!(t.lines(), &["ONE TWO"]);
}
#[test]
#[allow(non_snake_case)]
fn R_overwrites_chars() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abcdef"]);
e.handle_key(&key('R'), &mut t);
assert_eq!(*e.mode(), EditorMode::Replace);
e.handle_key(&key('X'), &mut t);
e.handle_key(&key('Y'), &mut t);
e.handle_key(&esc(), &mut t);
assert_eq!(t.lines(), &["XYcdef"]); assert_eq!(*e.mode(), EditorMode::Normal);
assert_eq!(super::super::cursor_tuple(&t), (0, 1)); }
#[test]
#[allow(non_snake_case)]
fn R_appends_past_line_end() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["ab"]);
e.handle_key(&key('R'), &mut t);
for c in "XYZ".chars() {
e.handle_key(&key(c), &mut t);
}
e.handle_key(&esc(), &mut t);
assert_eq!(t.lines(), &["XYZ"]); }
#[test]
#[allow(non_snake_case)]
fn R_is_dot_repeatable() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["aaaa bbbb"]);
e.handle_key(&key('R'), &mut t);
e.handle_key(&key('X'), &mut t);
e.handle_key(&key('X'), &mut t);
e.handle_key(&esc(), &mut t); e.handle_key(&key('w'), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["XXaa XXbb"]);
}
#[test]
#[allow(non_snake_case)]
fn aborted_R_keeps_dot_register() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('x'), &mut t); e.handle_key(&key('R'), &mut t);
e.handle_key(&esc(), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["c"]);
}
#[test]
#[allow(non_snake_case)]
fn R_mode_does_not_pass_through() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["ab"]);
e.handle_key(&key('R'), &mut t);
let out = e.handle_key(&key('('), &mut t);
assert_eq!(out, VimKeyOutcome::TextMutated); assert_eq!(t.lines()[0].chars().next(), Some('(')); }
#[test]
#[allow(non_snake_case)]
fn J_joins_with_single_space_stripping_indent() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo", " bar"]);
e.handle_key(&key('J'), &mut t);
assert_eq!(t.lines(), &["foo bar"]);
assert_eq!(super::super::cursor_tuple(&t), (0, 3));
}
#[test]
#[allow(non_snake_case)]
fn J_adds_no_extra_space_when_line_ends_in_whitespace() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo ", "bar"]);
e.handle_key(&key('J'), &mut t);
assert_eq!(t.lines(), &["foo bar"]);
}
#[test]
#[allow(non_snake_case)]
fn gJ_joins_without_space() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo", " bar"]);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('J'), &mut t);
assert_eq!(t.lines(), &["foo bar"]); }
#[test]
#[allow(non_snake_case)]
fn three_J_joins_three_lines() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["a", "b", "c"]);
e.handle_key(&key('3'), &mut t);
e.handle_key(&key('J'), &mut t);
assert_eq!(t.lines(), &["a b c"]);
}
#[test]
#[allow(non_snake_case)]
fn I_inserts_at_first_non_blank() {
let mut e = VimEngine::default();
let mut t = TextArea::from([" indented"]);
e.handle_key(&key('$'), &mut t); e.handle_key(&key('I'), &mut t);
assert_eq!(*e.mode(), EditorMode::Insert);
assert_eq!(super::super::cursor_tuple(&t), (0, 4)); }
#[test]
fn percent_matches_across_lines() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo (bar", "baz) qux"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('('), &mut t); e.handle_key(&key('%'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (1, 3)); e.handle_key(&key('%'), &mut t); assert_eq!(super::super::cursor_tuple(&t), (0, 4));
}
#[test]
fn percent_nested_across_lines() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["{a {b", "c}", "d}"]);
e.handle_key(&key('%'), &mut t); assert_eq!(super::super::cursor_tuple(&t), (2, 1)); }
#[test]
fn d_percent_deletes_across_lines_inclusive() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["a(b", "c)d"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('('), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('%'), &mut t); assert_eq!(t.lines(), &["ad"]);
}
#[test]
fn percent_unmatched_across_buffer_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["(a", "b"]);
e.handle_key(&key('%'), &mut t); assert_eq!(super::super::cursor_tuple(&t), (0, 0));
}
#[test]
fn visual_c_dot_repeats_same_width() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abcde fghij"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('l'), &mut t);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('c'), &mut t); t.insert_str("X");
e.handle_key(&esc(), &mut t); e.handle_key(&key('w'), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["Xde Xij"]);
}
#[test]
fn count_find_is_atomic() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["a x b"]);
e.handle_key(&key('2'), &mut t);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('x'), &mut t);
assert_eq!(super::super::cursor_tuple(&t), (0, 0)); let mut t2 = TextArea::from(["axbx"]);
e.handle_key(&key('2'), &mut t2);
e.handle_key(&key('f'), &mut t2);
e.handle_key(&key('x'), &mut t2);
assert_eq!(super::super::cursor_tuple(&t2), (0, 3));
}
#[test]
fn d2fx_with_one_x_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["a x b"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('2'), &mut t);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('x'), &mut t); assert_eq!(t.lines(), &["a x b"]);
}
#[test]
fn reset_to_normal_clears_insert_capture() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo bar"]);
e.handle_key(&key('c'), &mut t);
e.handle_key(&key('w'), &mut t); e.reset_to_normal(); e.handle_key(&key('x'), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["ar"]); }
#[test]
fn dj_on_last_line_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["only line"]);
e.handle_key(&key('y'), &mut t);
e.handle_key(&key('y'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('j'), &mut t); assert_eq!(t.lines(), &["only line"]);
assert_eq!(e.registers.read().unwrap().text, "only line\n"); }
#[test]
fn dk_on_first_line_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('k'), &mut t);
assert_eq!(t.lines(), &["one", "two"]);
}
#[test]
fn failed_find_op_does_not_clobber_dot() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abcdef"]);
e.handle_key(&key('x'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('z'), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["cdef"]);
}
#[test]
fn noop_x_does_not_clobber_dot() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one two three", ""]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('w'), &mut t); e.handle_key(&key('j'), &mut t); let out = e.handle_key(&key('x'), &mut t); assert_eq!(out, VimKeyOutcome::NoOp); e.handle_key(&key('k'), &mut t);
e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["three", ""]);
}
#[test]
fn d_percent_without_pair_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('%'), &mut t); assert_eq!(t.lines(), &["abc"]);
e.handle_key(&key('c'), &mut t);
e.handle_key(&key('%'), &mut t);
assert_eq!(*e.mode(), EditorMode::Normal); }
#[test]
fn visual_inner_empty_pair_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo()bar"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('('), &mut t); e.handle_key(&key('v'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('('), &mut t); e.handle_key(&esc(), &mut t);
assert_eq!(t.lines(), &["foo()bar"]);
}
#[test]
fn aborted_insert_keeps_dot_register() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('x'), &mut t); e.handle_key(&key('i'), &mut t); e.handle_key(&esc(), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["c"]);
}
#[test]
fn o_then_esc_is_still_dot_repeatable() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["x"]);
e.handle_key(&key('o'), &mut t);
e.handle_key(&esc(), &mut t);
e.handle_key(&key('.'), &mut t);
assert_eq!(t.lines().len(), 3);
}
#[test]
fn visual_inner_object_then_delete() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["foo(bar)baz"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('a'), &mut t); e.handle_key(&key('v'), &mut t);
e.handle_key(&key('i'), &mut t);
e.handle_key(&key('('), &mut t); e.handle_key(&key('d'), &mut t);
assert_eq!(t.lines(), &["foo()baz"]);
}
#[test]
fn visual_around_quote_then_yank() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["say \"hi\" now"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('h'), &mut t); e.handle_key(&key('v'), &mut t);
e.handle_key(&key('a'), &mut t);
e.handle_key(&key('"'), &mut t); e.handle_key(&key('y'), &mut t);
let reg = e.registers.read().unwrap();
assert_eq!(reg.text, "\"hi\"");
}
#[test]
fn visual_find_extends_selection() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello, world"]);
e.handle_key(&key('v'), &mut t);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key(','), &mut t); e.handle_key(&key('d'), &mut t);
assert_eq!(t.lines(), &[" world"]);
}
#[test]
fn visual_gg_extends_to_file_start() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('j'), &mut t);
e.handle_key(&key('j'), &mut t); e.handle_key(&key('v'), &mut t);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('g'), &mut t); e.handle_key(&key('d'), &mut t); assert_eq!(t.lines(), &["hree"]);
}
#[test]
fn visual_o_swaps_selection_ends() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abcde"]);
e.handle_key(&key('l'), &mut t);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('v'), &mut t);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('o'), &mut t); assert_eq!(super::super::cursor_tuple(&t), (0, 2));
e.handle_key(&key('h'), &mut t); e.handle_key(&key('d'), &mut t); assert_eq!(t.lines(), &["ae"]);
}
#[test]
fn dot_repeats_cc_with_typed_text() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two"]);
e.handle_key(&key('c'), &mut t);
e.handle_key(&key('c'), &mut t); t.insert_str("X");
e.handle_key(&esc(), &mut t); e.handle_key(&key('j'), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["X", "X"]);
}
#[test]
fn dot_repeats_substitute_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["ab cd"]);
e.handle_key(&key('s'), &mut t); t.insert_str("Z");
e.handle_key(&esc(), &mut t); e.handle_key(&key('w'), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["Zb Zd"]);
}
#[test]
fn dot_repeats_plain_insert() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["world"]);
e.handle_key(&key('i'), &mut t);
t.insert_str("ab");
e.handle_key(&esc(), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["aabbworld"]);
}
#[test]
fn dot_repeats_indent() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["x"]);
e.handle_key(&key('>'), &mut t);
e.handle_key(&key('>'), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &[" x"]);
}
#[test]
fn dot_does_not_repeat_yank() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('x'), &mut t); e.handle_key(&key('y'), &mut t);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('.'), &mut t); assert_eq!(t.lines(), &["c"]);
}
#[test]
fn counts_before_and_after_operator_multiply() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["a b c d e f g"]);
e.handle_key(&key('2'), &mut t);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('3'), &mut t);
e.handle_key(&key('w'), &mut t);
assert_eq!(t.lines(), &["g"]); }
#[test]
fn dj_deletes_two_whole_lines_linewise() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('j'), &mut t);
assert_eq!(t.lines(), &["three"]);
let reg = e.registers.read().unwrap();
assert_eq!(reg.kind, RegisterKind::Linewise);
assert_eq!(reg.text, "one\ntwo\n");
}
#[test]
fn dk_deletes_two_whole_lines_upward() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('j'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('k'), &mut t);
assert_eq!(t.lines(), &["three"]);
}
#[test]
#[allow(non_snake_case)]
fn dG_deletes_to_file_end_linewise() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('j'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('G'), &mut t);
assert_eq!(t.lines(), &["one"]);
}
#[test]
fn d_gg_deletes_to_file_start_linewise() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('j'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key('g'), &mut t);
e.handle_key(&key('g'), &mut t);
assert_eq!(t.lines(), &["three"]);
}
#[test]
fn dt_deletes_up_to_but_not_including_target() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abx"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('t'), &mut t);
e.handle_key(&key('x'), &mut t);
assert_eq!(t.lines(), &["x"]);
}
#[test]
fn failed_find_with_operator_is_noop() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["hello"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('z'), &mut t); assert_eq!(t.lines(), &["hello"]); e.handle_key(&key('c'), &mut t);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('z'), &mut t);
assert_eq!(*e.mode(), EditorMode::Normal); }
#[test]
fn d_semicolon_repeats_find_as_operator_range() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["a.b.c"]);
e.handle_key(&key('f'), &mut t);
e.handle_key(&key('.'), &mut t); e.handle_key(&key('d'), &mut t);
e.handle_key(&key(';'), &mut t); assert_eq!(t.lines(), &["ac"]);
}
#[test]
fn cj_changes_two_lines_and_enters_insert() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two", "three"]);
e.handle_key(&key('c'), &mut t);
e.handle_key(&key('j'), &mut t);
assert_eq!(*e.mode(), EditorMode::Insert);
assert_eq!(t.lines(), &["", "three"]); }
#[test]
fn x_then_p_swaps_chars() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["ab"]);
e.handle_key(&key('x'), &mut t); e.handle_key(&key('p'), &mut t); assert_eq!(t.lines(), &["ba"]);
}
#[test]
fn x_at_line_end_does_not_join_next_line() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["ab", "cd"]);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('3'), &mut t);
e.handle_key(&key('x'), &mut t); assert_eq!(t.lines(), &["a", "cd"]);
}
#[test]
fn s_fills_register_with_deleted_char() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["abc"]);
e.handle_key(&key('s'), &mut t); assert_eq!(*e.mode(), EditorMode::Insert);
let reg = e.registers.read().expect("s must fill the register");
assert_eq!(reg.text, "a");
assert_eq!(reg.kind, RegisterKind::Charwise);
}
#[test]
#[allow(non_snake_case)]
fn S_fills_register_linewise_no_kind_desync() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one", "two"]);
e.handle_key(&key('y'), &mut t);
e.handle_key(&key('y'), &mut t); e.handle_key(&key('j'), &mut t);
e.handle_key(&key('S'), &mut t); let reg = e.registers.read().expect("S must fill the register");
assert_eq!(reg.text, "two\n");
assert_eq!(reg.kind, RegisterKind::Linewise);
}
#[test]
fn dw_fills_register_charwise() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["one two"]);
e.handle_key(&key('d'), &mut t);
e.handle_key(&key('w'), &mut t); let reg = e.registers.read().expect("dw must fill the register");
assert_eq!(reg.text, "one ");
assert_eq!(reg.kind, RegisterKind::Charwise);
e.handle_key(&key('p'), &mut t);
assert_eq!(t.lines(), &["tone wo"]); }
#[test]
fn empty_delete_keeps_previous_register() {
let mut e = VimEngine::default();
let mut t = TextArea::from(["ab", ""]);
e.handle_key(&key('y'), &mut t);
e.handle_key(&key('l'), &mut t); e.handle_key(&key('j'), &mut t); e.handle_key(&key('x'), &mut t); let reg = e
.registers
.read()
.expect("register must survive a no-op delete");
assert_eq!(reg.text, "a");
}
#[test]
fn esc_in_normal_clears_stray_selection() {
let mut e = VimEngine::default(); let mut t = TextArea::from(["hello world"]);
t.start_selection();
t.move_cursor(ratatui_textarea::CursorMove::Forward);
t.move_cursor(ratatui_textarea::CursorMove::Forward);
assert!(t.selection_range().is_some());
let out = e.handle_key(&esc(), &mut t);
assert!(
t.selection_range().is_none(),
"Esc in Normal must cancel a stray selection"
);
assert_eq!(out, VimKeyOutcome::CursorOnly);
assert_eq!(*e.mode(), EditorMode::Normal);
}
}