use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Normal,
Insert,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Quit,
Up,
Down,
Top,
Bottom,
PageUp,
PageDown,
Confirm,
Cancel,
Save,
NextFocus,
PrevFocus,
ToggleMark,
ReorderUp,
ReorderDown,
ExternalEdit,
Raw(KeyEvent),
}
fn is_ctrl_s(key: &KeyEvent) -> bool {
key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL)
}
fn is_ctrl_e(key: &KeyEvent) -> bool {
key.code == KeyCode::Char('e') && key.modifiers.contains(KeyModifiers::CONTROL)
}
fn is_shift(key: &KeyEvent) -> bool {
key.modifiers.contains(KeyModifiers::SHIFT)
}
pub fn control_action(key: KeyEvent) -> Option<Action> {
if is_ctrl_s(&key) {
return Some(Action::Save);
}
match key.code {
KeyCode::Esc => Some(Action::Cancel),
KeyCode::Tab => Some(Action::NextFocus),
KeyCode::BackTab => Some(Action::PrevFocus),
_ => None,
}
}
pub mod help {
pub const MOVE: (&str, &str) = ("j/k, ↓/↑", "move down / up");
pub const TOP_BOTTOM: (&str, &str) = ("gg / G", "jump to top / bottom");
pub const PAGE: (&str, &str) = ("PageDown / PageUp", "scroll a page");
pub const CONFIRM: (&str, &str) = ("Enter", "confirm / open");
pub const QUIT: (&str, &str) = ("q / Esc", "quit / close");
pub const SAVE: (&str, &str) = ("Ctrl+S", "save");
pub const REORDER: (&str, &str) = ("K/J, Shift+↓/↑", "reorder");
pub const TOGGLE_MARK: (&str, &str) = ("Space", "toggle");
pub const HELP: (&str, &str) = ("?", "toggle this help");
pub const CONTROL_ACTION_BINDINGS: &[(&str, &str)] = &[
("Esc", "cancel"),
("Ctrl+S", "submit"),
("Tab / Shift+Tab", "next / previous field"),
];
}
#[derive(Debug, Default)]
pub struct KeyDispatcher {
pending_g: bool,
}
impl KeyDispatcher {
pub fn new() -> Self {
Self::default()
}
pub fn dispatch(&mut self, key: KeyEvent, mode: Mode) -> Action {
if is_ctrl_s(&key) {
self.pending_g = false;
return Action::Save;
}
if is_ctrl_e(&key) {
self.pending_g = false;
return Action::ExternalEdit;
}
match mode {
Mode::Normal => self.dispatch_normal(key),
Mode::Insert => self.dispatch_insert(key),
}
}
fn dispatch_normal(&mut self, key: KeyEvent) -> Action {
let awaiting_g = std::mem::take(&mut self.pending_g);
if awaiting_g {
if key.code == KeyCode::Char('g') {
return Action::Top;
}
} else if key.code == KeyCode::Char('g') {
self.pending_g = true;
return Action::Raw(key);
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => Action::Quit,
KeyCode::Char('G') => Action::Bottom,
KeyCode::Char('K') => Action::ReorderUp,
KeyCode::Char('J') => Action::ReorderDown,
KeyCode::Up if is_shift(&key) => Action::ReorderUp,
KeyCode::Down if is_shift(&key) => Action::ReorderDown,
KeyCode::Down | KeyCode::Char('j') => Action::Down,
KeyCode::Up | KeyCode::Char('k') => Action::Up,
KeyCode::PageDown => Action::PageDown,
KeyCode::PageUp => Action::PageUp,
KeyCode::Enter => Action::Confirm,
KeyCode::Tab => Action::NextFocus,
KeyCode::BackTab => Action::PrevFocus,
KeyCode::Char(' ') => Action::ToggleMark,
_ => Action::Raw(key),
}
}
fn dispatch_insert(&mut self, key: KeyEvent) -> Action {
self.pending_g = false;
match key.code {
KeyCode::Esc => Action::Cancel,
KeyCode::Enter => Action::Confirm,
KeyCode::Tab => Action::NextFocus,
KeyCode::BackTab => Action::PrevFocus,
_ => Action::Raw(key),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
fn shift(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::SHIFT)
}
#[test]
fn normal_mode_hjkl_moves() {
let mut d = KeyDispatcher::new();
assert_eq!(
d.dispatch(key(KeyCode::Char('j')), Mode::Normal),
Action::Down
);
assert_eq!(d.dispatch(key(KeyCode::Down), Mode::Normal), Action::Down);
assert_eq!(
d.dispatch(key(KeyCode::Char('k')), Mode::Normal),
Action::Up
);
assert_eq!(d.dispatch(key(KeyCode::Up), Mode::Normal), Action::Up);
}
#[test]
fn normal_mode_gg_is_top_but_lone_g_is_not() {
let mut d = KeyDispatcher::new();
assert_eq!(
d.dispatch(key(KeyCode::Char('g')), Mode::Normal),
Action::Raw(key(KeyCode::Char('g')))
);
assert_eq!(
d.dispatch(key(KeyCode::Char('g')), Mode::Normal),
Action::Top
);
}
#[test]
fn normal_mode_stray_g_does_not_swallow_the_next_key() {
let mut d = KeyDispatcher::new();
let _ = d.dispatch(key(KeyCode::Char('g')), Mode::Normal);
assert_eq!(
d.dispatch(key(KeyCode::Char('j')), Mode::Normal),
Action::Down
);
}
#[test]
fn normal_mode_capital_g_is_bottom() {
let mut d = KeyDispatcher::new();
assert_eq!(
d.dispatch(key(KeyCode::Char('G')), Mode::Normal),
Action::Bottom
);
}
#[test]
fn normal_mode_reorder_via_capital_letter_or_shift_arrow() {
let mut d = KeyDispatcher::new();
assert_eq!(
d.dispatch(key(KeyCode::Char('K')), Mode::Normal),
Action::ReorderUp
);
assert_eq!(
d.dispatch(key(KeyCode::Char('J')), Mode::Normal),
Action::ReorderDown
);
assert_eq!(
d.dispatch(shift(KeyCode::Up), Mode::Normal),
Action::ReorderUp
);
assert_eq!(
d.dispatch(shift(KeyCode::Down), Mode::Normal),
Action::ReorderDown
);
}
#[test]
fn normal_mode_quit_paging_confirm_and_space() {
let mut d = KeyDispatcher::new();
assert_eq!(
d.dispatch(key(KeyCode::Char('q')), Mode::Normal),
Action::Quit
);
assert_eq!(d.dispatch(key(KeyCode::Esc), Mode::Normal), Action::Quit);
assert_eq!(
d.dispatch(key(KeyCode::PageUp), Mode::Normal),
Action::PageUp
);
assert_eq!(
d.dispatch(key(KeyCode::PageDown), Mode::Normal),
Action::PageDown
);
assert_eq!(
d.dispatch(key(KeyCode::Enter), Mode::Normal),
Action::Confirm
);
assert_eq!(
d.dispatch(key(KeyCode::Char(' ')), Mode::Normal),
Action::ToggleMark
);
}
#[test]
fn ctrl_s_is_save_in_either_mode() {
let mut d = KeyDispatcher::new();
assert_eq!(
d.dispatch(ctrl(KeyCode::Char('s')), Mode::Normal),
Action::Save
);
assert_eq!(
d.dispatch(ctrl(KeyCode::Char('s')), Mode::Insert),
Action::Save
);
}
#[test]
fn ctrl_e_is_external_edit_in_either_mode_and_does_not_leak_g_state() {
let mut d = KeyDispatcher::new();
let _ = d.dispatch(key(KeyCode::Char('g')), Mode::Normal);
assert_eq!(
d.dispatch(ctrl(KeyCode::Char('e')), Mode::Normal),
Action::ExternalEdit
);
assert_eq!(
d.dispatch(key(KeyCode::Char('g')), Mode::Normal),
Action::Raw(key(KeyCode::Char('g')))
);
assert_eq!(
d.dispatch(ctrl(KeyCode::Char('e')), Mode::Insert),
Action::ExternalEdit
);
}
#[test]
fn plain_e_is_not_external_edit() {
let mut d = KeyDispatcher::new();
assert_eq!(
d.dispatch(key(KeyCode::Char('e')), Mode::Normal),
Action::Raw(key(KeyCode::Char('e')))
);
}
#[test]
fn insert_mode_only_intercepts_control_keys() {
let mut d = KeyDispatcher::new();
assert_eq!(d.dispatch(key(KeyCode::Esc), Mode::Insert), Action::Cancel);
assert_eq!(
d.dispatch(key(KeyCode::Enter), Mode::Insert),
Action::Confirm
);
assert_eq!(
d.dispatch(key(KeyCode::Tab), Mode::Insert),
Action::NextFocus
);
assert_eq!(
d.dispatch(key(KeyCode::BackTab), Mode::Insert),
Action::PrevFocus
);
for c in ['g', 'q', 'j', 'k', ' '] {
assert_eq!(
d.dispatch(key(KeyCode::Char(c)), Mode::Insert),
Action::Raw(key(KeyCode::Char(c)))
);
}
}
#[test]
fn insert_mode_does_not_accumulate_g_state() {
let mut d = KeyDispatcher::new();
let _ = d.dispatch(key(KeyCode::Char('g')), Mode::Insert);
assert_eq!(
d.dispatch(key(KeyCode::Char('g')), Mode::Normal),
Action::Raw(key(KeyCode::Char('g')))
);
assert_eq!(
d.dispatch(key(KeyCode::Char('g')), Mode::Normal),
Action::Top
);
}
#[test]
fn control_action_recognizes_only_the_keys_safe_for_text_fields() {
assert_eq!(control_action(key(KeyCode::Esc)), Some(Action::Cancel));
assert_eq!(control_action(ctrl(KeyCode::Char('s'))), Some(Action::Save));
assert_eq!(control_action(key(KeyCode::Tab)), Some(Action::NextFocus));
assert_eq!(
control_action(key(KeyCode::BackTab)),
Some(Action::PrevFocus)
);
for c in ['h', 'j', 'k', 'l', 'g', 'q', ' '] {
assert_eq!(control_action(key(KeyCode::Char(c))), None);
}
assert_eq!(control_action(key(KeyCode::Enter)), None);
}
}