//! Reserved keymap: the constants every Alloy TUI navigates by, and the //! classifier that turns a raw key event into one of them. //! //! Per docs/COMPONENT-LIBRARY.md the reserved keys live in exactly one place so //! apps match against `Action` rather than hardcoding keycodes: `Tab` / //! `Shift-Tab` move focus, `l` / `h` move between tabs, `Enter` activates, //! `Esc` cancels, `Ctrl-S` saves, `q` quits, `?` opens help, `/` filters, `:` //! opens the command line. //! //! Tabs get `l` / `h` rather than `Tab` because `Tab` already means focus and //! that meaning is documented across every view. Two navigation axes need two //! keys, and the vim pair reads as horizontal movement, which is what a tab bar //! is. //! //! Descended from sysop-tui's `keys.rs`, widened from that crate's six actions //! to the full reserved set the console's form surfaces need. use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; /// A reserved key's meaning. `Passthrough` means the key is not reserved and /// belongs to whatever view currently holds focus. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { NextFocus, PrevFocus, NextTab, PrevTab, Activate, Cancel, Save, Help, Quit, Filter, Command, Passthrough, } /// Classify a key event against the reserved keymap. /// /// This is a pure classifier with no notion of mode: it reports what a key /// *means* in the reserved map, not whether the app should honor it. Two /// caller obligations follow from that: /// /// - **Filter to `KeyEventKind::Press` first.** Windows terminals deliver both /// press and release for every key, so an unfiltered event loop performs /// each action twice. /// - **Ignore the character actions while text entry has focus.** `q`, `/`, /// `:`, `h`, and `l` are literal characters a user types into a field; a view /// holding an active text input should route keys to the input and consult /// this classifier only for `Cancel`, `Save`, and the focus movers. /// /// `h` and `l` make this obligation sharper than it was. The earlier /// character actions were punctuation and one letter that rarely opens a /// word; `h` and `l` are ordinary letters that appear in almost any typed /// value, so a view that forwards raw keys to an input without this check /// now changes tabs mid-word rather than merely on a stray `q`. pub fn classify(key: KeyEvent) -> Action { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let shift = key.modifiers.contains(KeyModifiers::SHIFT); match key.code { KeyCode::Char('s' | 'S') if ctrl => Action::Save, KeyCode::BackTab => Action::PrevFocus, KeyCode::Tab if shift => Action::PrevFocus, KeyCode::Tab => Action::NextFocus, KeyCode::Char('l') => Action::NextTab, KeyCode::Char('h') => Action::PrevTab, KeyCode::Enter => Action::Activate, KeyCode::Esc => Action::Cancel, KeyCode::Char('?') => Action::Help, KeyCode::Char('q') => Action::Quit, KeyCode::Char('/') => Action::Filter, KeyCode::Char(':') => Action::Command, _ => Action::Passthrough, } } #[cfg(test)] mod tests { use super::*; fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { KeyEvent::new(code, mods) } #[test] fn ctrl_s_saves_but_bare_s_does_not() { assert_eq!( classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)), Action::Save ); assert_eq!( classify(key(KeyCode::Char('s'), KeyModifiers::NONE)), Action::Passthrough ); } // Terminals disagree on how they report Shift-Tab: some send BackTab with no // modifier, others send Tab with SHIFT. Both must reach PrevFocus, or focus // navigation silently becomes one-directional on half the terminal emulators // in the stack. #[test] fn both_shift_tab_encodings_move_focus_backward() { assert_eq!( classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus ); assert_eq!( classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)), Action::PrevFocus ); assert_eq!( classify(key(KeyCode::Tab, KeyModifiers::SHIFT)), Action::PrevFocus ); assert_eq!( classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus ); } #[test] fn unreserved_keys_pass_through() { assert_eq!( classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough ); assert_eq!( classify(key(KeyCode::Down, KeyModifiers::NONE)), Action::Passthrough ); } #[test] fn h_and_l_move_between_tabs() { assert_eq!( classify(key(KeyCode::Char('l'), KeyModifiers::NONE)), Action::NextTab ); assert_eq!( classify(key(KeyCode::Char('h'), KeyModifiers::NONE)), Action::PrevTab ); } // Tabs and focus are two navigation axes and must stay on separate keys. // docs/COMPONENT-LIBRARY.md documents Tab as focus movement across every // view, so a tab bar claiming Tab would silently redefine it everywhere. #[test] fn tab_key_still_means_focus_not_tabs() { assert_eq!( classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus ); assert_eq!( classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus ); } // j/k stay unreserved so a list cursor keeps them. Only the horizontal half // of the vim pair is spoken for. #[test] fn vertical_vim_keys_are_not_claimed_by_tabs() { assert_eq!( classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough ); assert_eq!( classify(key(KeyCode::Char('k'), KeyModifiers::NONE)), Action::Passthrough ); } }