use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::rope_buffer::{CursorMove, RopeBuffer};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
Move {
to: CursorMove,
extend: bool,
},
SelectAll,
DeleteWordBack,
DeleteWordForward,
Insert(char),
InsertNewline,
DeleteBack,
DeleteForward,
}
pub fn operation(key: KeyEvent) -> Option<Operation> {
let extend = key.modifiers.contains(KeyModifiers::SHIFT);
let chord = key.modifiers & !KeyModifiers::SHIFT;
let motion = |to| Some(Operation::Move { to, extend });
match (chord, key.code) {
(KeyModifiers::NONE, KeyCode::Left) => motion(CursorMove::Back),
(KeyModifiers::NONE, KeyCode::Right) => motion(CursorMove::Forward),
(KeyModifiers::NONE, KeyCode::Up) => motion(CursorMove::Up),
(KeyModifiers::NONE, KeyCode::Down) => motion(CursorMove::Down),
(KeyModifiers::NONE, KeyCode::Home) => motion(CursorMove::Head),
(KeyModifiers::NONE, KeyCode::End) => motion(CursorMove::End),
(KeyModifiers::NONE, KeyCode::PageUp) => motion(CursorMove::ParagraphBack),
(KeyModifiers::NONE, KeyCode::PageDown) => motion(CursorMove::ParagraphForward),
(KeyModifiers::CONTROL, KeyCode::Left) => motion(CursorMove::WordBack),
(KeyModifiers::CONTROL, KeyCode::Right) => motion(CursorMove::WordForward),
(KeyModifiers::CONTROL, KeyCode::Home) => motion(CursorMove::Top),
(KeyModifiers::CONTROL, KeyCode::End) => motion(CursorMove::Bottom),
(KeyModifiers::ALT, KeyCode::Left) => motion(CursorMove::WordBack),
(KeyModifiers::ALT, KeyCode::Right) => motion(CursorMove::WordForward),
(KeyModifiers::ALT, KeyCode::Char('b' | 'B')) => motion(CursorMove::WordBack),
(KeyModifiers::ALT, KeyCode::Char('f' | 'F')) => motion(CursorMove::WordForward),
(KeyModifiers::SUPER, KeyCode::Left) => motion(CursorMove::Head),
(KeyModifiers::SUPER, KeyCode::Right) => motion(CursorMove::End),
(KeyModifiers::SUPER, KeyCode::Up) => motion(CursorMove::Top),
(KeyModifiers::SUPER, KeyCode::Down) => motion(CursorMove::Bottom),
(KeyModifiers::CONTROL, KeyCode::Char('a')) => Some(Operation::SelectAll),
(KeyModifiers::CONTROL, KeyCode::Backspace) | (KeyModifiers::ALT, KeyCode::Backspace) => {
Some(Operation::DeleteWordBack)
}
(KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
Some(Operation::DeleteWordForward)
}
(KeyModifiers::NONE, KeyCode::Char(c)) => Some(Operation::Insert(c)),
(KeyModifiers::NONE, KeyCode::Enter) => Some(Operation::InsertNewline),
(KeyModifiers::NONE, KeyCode::Backspace) => Some(Operation::DeleteBack),
(KeyModifiers::NONE, KeyCode::Delete) => Some(Operation::DeleteForward),
_ => None,
}
}
pub fn apply(op: Operation, buf: &mut RopeBuffer) -> bool {
match op {
Operation::Move { to, extend } => {
if extend {
if buf.selection_range().is_none() {
buf.start_selection();
}
} else {
buf.cancel_selection();
}
buf.move_cursor(to);
false
}
Operation::SelectAll => {
buf.select_all();
false
}
Operation::DeleteWordBack => buf.delete_word(),
Operation::DeleteWordForward => buf.delete_next_word(),
Operation::Insert(c) => {
buf.insert_char(c);
true
}
Operation::InsertNewline => {
buf.insert_newline();
true
}
Operation::DeleteBack => buf.delete_char(),
Operation::DeleteForward => buf.delete_next_char(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ropetext::Text;
fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
fn plain(code: KeyCode) -> Option<Operation> {
operation(key(code, KeyModifiers::NONE))
}
fn with(code: KeyCode, modifiers: KeyModifiers) -> Option<Operation> {
operation(key(code, modifiers))
}
#[test]
fn arrows_move_without_extending() {
assert_eq!(
plain(KeyCode::Left),
Some(Operation::Move {
to: CursorMove::Back,
extend: false
})
);
assert_eq!(
plain(KeyCode::Down),
Some(Operation::Move {
to: CursorMove::Down,
extend: false
})
);
}
#[test]
fn shift_extends_whatever_it_is_held_with() {
for (code, to) in [
(KeyCode::Left, CursorMove::Back),
(KeyCode::Home, CursorMove::Head),
(KeyCode::PageDown, CursorMove::ParagraphForward),
] {
assert_eq!(
with(code, KeyModifiers::SHIFT),
Some(Operation::Move { to, extend: true }),
"{code:?}"
);
}
assert_eq!(
with(KeyCode::Left, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
Some(Operation::Move {
to: CursorMove::WordBack,
extend: true
}),
"shift composes with a chord rather than replacing it"
);
}
#[test]
fn control_moves_by_word_and_to_the_ends() {
assert_eq!(
with(KeyCode::Right, KeyModifiers::CONTROL),
Some(Operation::Move {
to: CursorMove::WordForward,
extend: false
})
);
assert_eq!(
with(KeyCode::End, KeyModifiers::CONTROL),
Some(Operation::Move {
to: CursorMove::Bottom,
extend: false
})
);
}
#[test]
fn either_modifier_deletes_a_word() {
for modifier in [KeyModifiers::CONTROL, KeyModifiers::ALT] {
assert_eq!(
with(KeyCode::Backspace, modifier),
Some(Operation::DeleteWordBack)
);
assert_eq!(
with(KeyCode::Delete, modifier),
Some(Operation::DeleteWordForward)
);
}
}
#[test]
fn unmodified_keys_type() {
assert_eq!(plain(KeyCode::Char('x')), Some(Operation::Insert('x')));
assert_eq!(plain(KeyCode::Enter), Some(Operation::InsertNewline));
assert_eq!(plain(KeyCode::Tab), None);
assert_eq!(plain(KeyCode::Backspace), Some(Operation::DeleteBack));
assert_eq!(plain(KeyCode::Delete), Some(Operation::DeleteForward));
assert_eq!(
with(KeyCode::Char('X'), KeyModifiers::SHIFT),
Some(Operation::Insert('X')),
"a shifted character is still a character"
);
}
#[test]
fn the_macos_conventions_are_here_too() {
assert_eq!(
with(KeyCode::Left, KeyModifiers::ALT),
Some(Operation::Move {
to: CursorMove::WordBack,
extend: false
})
);
assert_eq!(
with(KeyCode::Char('f'), KeyModifiers::ALT),
Some(Operation::Move {
to: CursorMove::WordForward,
extend: false
})
);
assert_eq!(
with(KeyCode::Char('B'), KeyModifiers::ALT | KeyModifiers::SHIFT),
Some(Operation::Move {
to: CursorMove::WordBack,
extend: true
}),
"the uppercase letter arrives with SHIFT set; the modifier decides"
);
assert_eq!(
with(KeyCode::Up, KeyModifiers::SUPER),
Some(Operation::Move {
to: CursorMove::Top,
extend: false
})
);
}
#[test]
fn undo_and_redo_are_not_bound_here() {
assert_eq!(with(KeyCode::Char('u'), KeyModifiers::CONTROL), None);
assert_eq!(with(KeyCode::Char('r'), KeyModifiers::CONTROL), None);
}
#[test]
fn keys_that_mean_nothing_here_mean_nothing() {
assert_eq!(plain(KeyCode::F(1)), None);
assert_eq!(plain(KeyCode::Null), None);
assert_eq!(plain(KeyCode::Esc), None);
assert_eq!(with(KeyCode::Char('v'), KeyModifiers::CONTROL), None);
assert_eq!(
with(KeyCode::Char('q'), KeyModifiers::ALT),
None,
"an unclaimed chord must not type its character"
);
}
fn buffer(text: &str) -> RopeBuffer {
RopeBuffer::new(Text::from(text))
}
#[test]
fn a_move_reports_no_text_change() {
let mut buf = buffer("hello");
let changed = apply(
Operation::Move {
to: CursorMove::Forward,
extend: false,
},
&mut buf,
);
assert!(!changed);
assert_eq!(buf.cursor(), (0, 1));
}
#[test]
fn extending_grows_a_selection_and_moving_drops_it() {
let mut buf = buffer("hello");
for _ in 0..2 {
apply(
Operation::Move {
to: CursorMove::Forward,
extend: true,
},
&mut buf,
);
}
assert_eq!(buf.selection_range(), Some(((0, 0), (0, 2))));
apply(
Operation::Move {
to: CursorMove::Forward,
extend: false,
},
&mut buf,
);
assert!(buf.selection_range().is_none());
}
#[test]
fn a_delete_with_nothing_to_delete_reports_no_change() {
let mut buf = buffer("");
assert!(!apply(Operation::DeleteBack, &mut buf));
assert!(!apply(Operation::DeleteForward, &mut buf));
assert!(!apply(Operation::DeleteWordBack, &mut buf));
}
#[test]
fn typing_reports_a_change() {
let mut buf = buffer("");
assert!(apply(Operation::Insert('a'), &mut buf));
assert!(apply(Operation::InsertNewline, &mut buf));
assert_eq!(buf.text().to_string(), "a\n");
}
}