//! Central action metadata shared by dispatch, contextual help, and command mode. use crossterm::event::{KeyCode, KeyModifiers}; use crate::app::Mode; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Action { Quit, Help, Command, Cancel, Lock, Refresh, Next, Previous, PageDown, PageUp, First, Last, Parent, Child, Activate, Filter, NextMatch, PreviousMatch, FocusNext, FocusPrevious, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct KeyBinding { pub code: KeyCode, pub modifiers: KeyModifiers, pub display: &'static str, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ActionSpec { pub action: Action, pub label: &'static str, pub command: &'static str, pub bindings: &'static [KeyBinding], modes: &'static [Mode], } impl ActionSpec { pub fn is_available(self, mode: Mode) -> bool { self.modes.contains(&mode) } } const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer]; const UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor]; const OVERLAYS: &[Mode] = &[Mode::Help, Mode::Command, Mode::Dialog]; #[cfg(test)] const ALL: &[Mode] = &[ Mode::Browser, Mode::Viewer, Mode::Editor, Mode::Dialog, Mode::Help, Mode::Command, Mode::Locked, ]; macro_rules! keys { ($(($code:expr, $modifiers:expr, $display:expr)),+ $(,)?) => { &[$(KeyBinding { code: $code, modifiers: $modifiers, display: $display }),+] }; } pub static ACTIONS: &[ActionSpec] = &[ ActionSpec { action: Action::Quit, label: "quit", command: "quit", bindings: keys!((KeyCode::Char('q'), KeyModifiers::NONE, "q")), modes: BROWSER_LIKE, }, ActionSpec { action: Action::Help, label: "help", command: "help", bindings: keys!((KeyCode::Char('?'), KeyModifiers::NONE, "?")), modes: UNLOCKED, }, ActionSpec { action: Action::Command, label: "command", command: "command", bindings: keys!((KeyCode::Char(':'), KeyModifiers::NONE, ":")), modes: UNLOCKED, }, ActionSpec { action: Action::Cancel, label: "back", command: "cancel", bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")), modes: OVERLAYS, }, ActionSpec { action: Action::Lock, label: "lock", command: "lock", bindings: keys!((KeyCode::Char('l'), KeyModifiers::CONTROL, "C-l")), modes: UNLOCKED, }, ActionSpec { action: Action::Refresh, label: "refresh", command: "refresh", bindings: keys!((KeyCode::Char('r'), KeyModifiers::NONE, "r")), modes: BROWSER_LIKE, }, ActionSpec { action: Action::Next, label: "next", command: "next", bindings: keys!( (KeyCode::Char('j'), KeyModifiers::NONE, "j"), (KeyCode::Down, KeyModifiers::NONE, "↓"), ), modes: &[Mode::Browser], }, ActionSpec { action: Action::Previous, label: "previous", command: "previous", bindings: keys!( (KeyCode::Char('k'), KeyModifiers::NONE, "k"), (KeyCode::Up, KeyModifiers::NONE, "↑"), ), modes: &[Mode::Browser], }, ActionSpec { action: Action::PageDown, label: "page down", command: "page-down", bindings: keys!((KeyCode::PageDown, KeyModifiers::NONE, "PgDn")), modes: &[Mode::Browser], }, ActionSpec { action: Action::PageUp, label: "page up", command: "page-up", bindings: keys!((KeyCode::PageUp, KeyModifiers::NONE, "PgUp")), modes: &[Mode::Browser], }, ActionSpec { action: Action::First, label: "first", command: "first", bindings: keys!((KeyCode::Home, KeyModifiers::NONE, "Home")), modes: &[Mode::Browser], }, ActionSpec { action: Action::Last, label: "last", command: "last", bindings: keys!((KeyCode::End, KeyModifiers::NONE, "End")), modes: &[Mode::Browser], }, ActionSpec { action: Action::Parent, label: "parent", command: "parent", bindings: keys!( (KeyCode::Char('h'), KeyModifiers::NONE, "h"), (KeyCode::Left, KeyModifiers::NONE, "←"), ), modes: &[Mode::Browser], }, ActionSpec { action: Action::Child, label: "child", command: "child", bindings: keys!( (KeyCode::Char('l'), KeyModifiers::NONE, "l"), (KeyCode::Right, KeyModifiers::NONE, "→"), ), modes: &[Mode::Browser], }, ActionSpec { action: Action::Activate, label: "open/toggle", command: "open", bindings: keys!((KeyCode::Enter, KeyModifiers::NONE, "Enter")), modes: &[Mode::Browser], }, ActionSpec { action: Action::Filter, label: "filter", command: "filter", bindings: keys!((KeyCode::Char('/'), KeyModifiers::NONE, "/")), modes: &[Mode::Browser], }, ActionSpec { action: Action::NextMatch, label: "next match", command: "next-match", bindings: keys!((KeyCode::Char('n'), KeyModifiers::NONE, "n")), modes: &[Mode::Browser], }, ActionSpec { action: Action::PreviousMatch, label: "previous match", command: "previous-match", bindings: keys!((KeyCode::Char('N'), KeyModifiers::SHIFT, "N")), modes: &[Mode::Browser], }, ActionSpec { action: Action::FocusNext, label: "next pane", command: "focus-next", bindings: keys!((KeyCode::Tab, KeyModifiers::NONE, "Tab")), modes: UNLOCKED, }, ActionSpec { action: Action::FocusPrevious, label: "previous pane", command: "focus-previous", bindings: keys!((KeyCode::BackTab, KeyModifiers::SHIFT, "S-Tab")), modes: UNLOCKED, }, ]; pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option { ACTIONS .iter() .find(|spec| { spec.is_available(mode) && spec .bindings .iter() .any(|binding| binding.code == code && binding.modifiers == modifiers) }) .map(|spec| spec.action) } pub fn available_actions(mode: Mode) -> impl Iterator { ACTIONS.iter().filter(move |spec| spec.is_available(mode)) } #[cfg(test)] mod tests { use super::*; #[test] fn registry_has_unique_commands_and_bindings_per_mode() { for (index, action) in ACTIONS.iter().enumerate() { assert!(!action.command.is_empty()); assert!(!action.label.is_empty()); assert!(!action.modes.is_empty()); for other in &ACTIONS[index + 1..] { assert_ne!(action.command, other.command); for mode in ALL { if action.is_available(*mode) && other.is_available(*mode) { for binding in action.bindings { for other_binding in other.bindings { assert_ne!(binding, other_binding); } } } } } } } #[test] fn resolution_and_help_consume_the_same_registry() { for mode in ALL { for spec in available_actions(*mode) { for binding in spec.bindings { assert_eq!( resolve_key(*mode, binding.code, binding.modifiers), Some(spec.action) ); } } } assert_eq!( resolve_key(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE), None ); } }