use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use edtui::{EditorEventHandler, EditorMode, EditorState};
#[derive(Default)]
pub struct Vim {
buf: String,
last: Option<(char, bool, bool)>,
}
#[derive(Clone, Copy)]
enum Motion {
Find(char, bool, bool),
Repeat(bool),
End,
Start,
FirstNonBlank,
WordEnd,
WordBack,
}
fn class(c: char) -> u8 {
if c.is_whitespace() {
0
} else if c.is_alphanumeric() || c == '_' {
1
} else {
2
}
}
fn target(
cs: &[char],
col: usize,
m: Motion,
n: usize,
last: Option<(char, bool, bool)>,
) -> Option<usize> {
let (m, repeat) = match m {
Motion::Repeat(rev) => {
let (ch, fwd, till) = last?;
(Motion::Find(ch, fwd != rev, till), true)
}
m => (m, false),
};
match m {
Motion::Find(ch, fwd, till) => {
let skip = usize::from(repeat && till);
let hits: Vec<usize> = if fwd {
(col + 1 + skip..cs.len())
.filter(|&i| cs[i] == ch)
.collect()
} else {
(0..col.saturating_sub(skip))
.rev()
.filter(|&i| cs[i] == ch)
.collect()
};
let p = *hits.get(n - 1)?;
Some(match (fwd, till) {
(true, true) => p - 1,
(false, true) => p + 1,
_ => p,
})
}
Motion::Repeat(_) => None,
Motion::End => cs.len().checked_sub(1),
Motion::Start => Some(0),
Motion::FirstNonBlank => Some(
cs.iter()
.position(|c| !c.is_whitespace())
.unwrap_or(cs.len().saturating_sub(1)),
),
Motion::WordEnd => {
let mut i = col;
for _ in 0..n {
i += 1;
while cs.get(i).is_some_and(|c| c.is_whitespace()) {
i += 1;
}
let k = class(*cs.get(i)?);
while cs.get(i + 1).is_some_and(|&c| class(c) == k) {
i += 1;
}
}
Some(i)
}
Motion::WordBack => {
let mut i = col;
for _ in 0..n {
i = i.checked_sub(1)?;
while cs[i].is_whitespace() {
i = i.checked_sub(1)?;
}
let k = class(cs[i]);
while i > 0 && class(cs[i - 1]) == k {
i -= 1;
}
}
Some(i)
}
}
}
fn range(
col: usize,
t: usize,
m: Motion,
last: Option<(char, bool, bool)>,
) -> Option<(usize, usize)> {
let forward = match m {
Motion::Find(_, fwd, _) => fwd,
Motion::Repeat(rev) => last.is_some_and(|(_, fwd, _)| fwd != rev),
Motion::End | Motion::WordEnd => true,
Motion::Start | Motion::FirstNonBlank | Motion::WordBack => false,
};
if forward {
(t >= col).then_some((col, t))
} else {
(t < col).then_some((t, col - 1))
}
}
fn press(h: &mut EditorEventHandler, ed: &mut EditorState, c: char) {
let m = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
h.on_key_event(KeyEvent::new(KeyCode::Char(c), m), ed);
}
impl Vim {
pub fn pending(&self) -> bool {
!self.buf.is_empty()
}
pub fn reset(&mut self) {
self.buf.clear();
}
pub fn key(&mut self, h: &mut EditorEventHandler, ed: &mut EditorState, ke: KeyEvent) -> bool {
let KeyCode::Char(c) = ke.code else {
return false;
};
if ke
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return false;
}
let starts = matches!(
c,
'1'..='9' | 'F' | 'T' | 'f' | 't' | ';' | ',' | '^' | 'd' | 'c' | 'y' | 'C'
);
if !self.pending() && !starts {
return false;
}
self.buf.push(c);
let digits = self.buf.chars().take_while(char::is_ascii_digit).count();
let n: usize = self.buf[..digits].parse().unwrap_or(1).max(1);
let rest: Vec<char> = self.buf[digits..].chars().collect();
match self.parse(&rest) {
Parse::Wait => {}
Parse::Pass => self.replay(h, ed),
Parse::Go(op, m) => {
self.buf.clear();
self.run(h, ed, op, m, n);
}
}
true
}
fn replay(&mut self, h: &mut EditorEventHandler, ed: &mut EditorState) {
for c in std::mem::take(&mut self.buf).chars() {
press(h, ed, c);
}
}
fn parse(&self, r: &[char]) -> Parse {
let motion = |r: &[char]| -> Option<Option<Motion>> {
Some(Some(match *r.first()? {
'$' => Motion::End,
'0' => Motion::Start,
'^' => Motion::FirstNonBlank,
'e' => Motion::WordEnd,
'b' => Motion::WordBack,
';' => Motion::Repeat(false),
',' => Motion::Repeat(true),
k @ ('f' | 'F' | 't' | 'T') => {
let ch = *r.get(1)?;
Motion::Find(ch, k.is_ascii_lowercase(), k.eq_ignore_ascii_case(&'t'))
}
_ => return Some(None),
}))
};
match r.first() {
None => Parse::Wait,
Some('d' | 'c' | 'y') => match motion(&r[1..]) {
None if r.len() == 1 || r[1..].len() == 1 && "fFtT".contains(r[1]) => Parse::Wait,
Some(Some(m)) => Parse::Go(Some(r[0]), m),
_ => Parse::Pass,
},
Some('C') => Parse::Go(Some('c'), Motion::End),
_ => match motion(r) {
None => Parse::Wait,
Some(Some(m)) => Parse::Go(None, m),
Some(None) => Parse::Pass,
},
}
}
fn run(
&mut self,
h: &mut EditorEventHandler,
ed: &mut EditorState,
op: Option<char>,
m: Motion,
n: usize,
) {
if let Motion::Find(ch, fwd, till) = m {
self.last = Some((ch, fwd, till));
}
let line: Vec<char> = String::from(ed.lines.clone())
.split('\n')
.nth(ed.cursor.row)
.unwrap_or("")
.chars()
.collect();
let col = ed.cursor.col.min(line.len().saturating_sub(1));
let Some(t) = target(&line, col, m, n, self.last) else {
return;
};
let Some(op) = op else {
ed.cursor.col = t;
return;
};
let Some((lo, hi)) = range(col, t, m, self.last) else {
return;
};
ed.cursor.col = lo;
press(h, ed, 'v');
for _ in lo..hi {
press(h, ed, 'l');
}
press(h, ed, op);
if op == 'c' && ed.mode != EditorMode::Insert {
press(h, ed, 'i');
}
}
}
enum Parse {
Wait,
Pass,
Go(Option<char>, Motion),
}
#[cfg(test)]
mod tests {
use super::*;
use edtui::Lines;
const T: &str = "foo bar Baz qux.quux";
fn run(start: usize, keys: &str) -> (String, usize) {
let mut ed = EditorState::new(Lines::from(T));
ed.cursor.col = start;
let (mut h, mut v) = (EditorEventHandler::default(), Vim::default());
for c in keys.chars() {
let m = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
let code = if c == '\x1b' {
KeyCode::Esc
} else {
KeyCode::Char(c)
};
let ke = KeyEvent::new(code, m);
if !v.key(&mut h, &mut ed, ke) {
h.on_key_event(ke, &mut ed);
}
}
(String::from(ed.lines.clone()), ed.cursor.col)
}
#[test]
fn find_motions() {
assert_eq!(run(19, "Fq").1, 16);
assert_eq!(run(19, "FB").1, 8);
assert_eq!(run(19, "TB").1, 9);
assert_eq!(run(0, "fq;").1, 16);
assert_eq!(run(0, "tq;").1, 15);
assert_eq!(run(19, "Fq;").1, 12);
assert_eq!(run(4, "^").1, 0);
}
#[test]
fn operators() {
assert_eq!(run(4, "d$").0, "foo ");
assert_eq!(run(4, "d0").0, "bar Baz qux.quux");
assert_eq!(run(8, "db").0, "foo Baz qux.quux");
assert_eq!(run(0, "de").0, " bar Baz qux.quux");
assert_eq!(run(0, "dtB").0, "Baz qux.quux");
assert_eq!(run(0, "dfB").0, "az qux.quux");
assert_eq!(
run(19, "dFq").0,
"foo bar Baz qux.quux"[..16].to_string() + "x"
);
assert_eq!(run(19, "dTq").0, "foo bar Baz qux.qx");
assert_eq!(run(4, "C!\x1b").0, "foo !");
assert_eq!(run(0, "ctBX\x1b").0, "XBaz qux.quux");
}
#[test]
fn falls_through_to_edtui() {
assert_eq!(run(0, "dw").0, "bar Baz qux.quux");
assert_eq!(run(0, "2dw").0, "bar Baz qux.quux");
assert_eq!(run(0, "dd").0, "");
}
}