//! Reserved keymap: the constants every Alloy TUI navigates by, and the //! classifier that turns a raw key event into one of them. //! //! Per the Alloy repo's 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, } /// One reserved key as a user reads it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ReservedKey { /// The keystroke, spelled the way it is printed to the screen. pub key: &'static str, /// What it does, in the imperative and short enough for a footer. pub label: &'static str, /// What [`classify`] turns it into. pub action: Action, } /// The reserved keymap as a table, for anything that has to *show* it. /// /// It lives beside [`classify`] rather than in the help widget because a help /// screen that can disagree with the classifier is worse than no help screen: it /// teaches a key that does nothing. `reserved_table_matches_the_classifier` /// holds the two together, so a key cannot be rebound without this list moving /// with it. /// /// Ordered as a reader learns them: move, act, then leave. pub const RESERVED: &[ReservedKey] = &[ ReservedKey { key: "Tab", label: "next pane", action: Action::NextFocus, }, ReservedKey { key: "Shift-Tab", label: "previous pane", action: Action::PrevFocus, }, ReservedKey { key: "l", label: "next tab", action: Action::NextTab, }, ReservedKey { key: "h", label: "previous tab", action: Action::PrevTab, }, ReservedKey { key: "Enter", label: "activate", action: Action::Activate, }, ReservedKey { key: "Esc", label: "cancel", action: Action::Cancel, }, ReservedKey { key: "Ctrl-S", label: "save", action: Action::Save, }, ReservedKey { key: "/", label: "filter", action: Action::Filter, }, ReservedKey { key: ":", label: "command", action: Action::Command, }, ReservedKey { key: "?", label: "keys", action: Action::Help, }, ReservedKey { key: "q", label: "quit", action: Action::Quit, }, ]; /// 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 sharp. They are ordinary letters that /// appear in almost any typed value, so a view that forwards raw keys to an /// input without this check changes tabs mid-word. 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) } /// The keystroke a [`ReservedKey`] prints, read back as an event. fn parse(spelled: &str) -> KeyEvent { match spelled { "Tab" => key(KeyCode::Tab, KeyModifiers::NONE), "Shift-Tab" => key(KeyCode::BackTab, KeyModifiers::NONE), "Enter" => key(KeyCode::Enter, KeyModifiers::NONE), "Esc" => key(KeyCode::Esc, KeyModifiers::NONE), "Ctrl-S" => key(KeyCode::Char('s'), KeyModifiers::CONTROL), other => { let mut chars = other.chars(); let c = chars.next().expect("a key spelling is not empty"); assert!(chars.next().is_none(), "unhandled spelling: {other}"); key(KeyCode::Char(c), KeyModifiers::NONE) } } } // The help screen reads RESERVED and the app reads classify(). If they can // drift, the help teaches keys that do nothing, which is worse than showing // no help at all. #[test] fn reserved_table_matches_the_classifier() { for entry in RESERVED { assert_eq!( classify(parse(entry.key)), entry.action, "{} is documented as {:?}", entry.key, entry.action ); } } // Every action a user can reach by pressing something is in the table. // Passthrough is the exception by definition: it is the absence of a // reserved meaning. #[test] fn every_reserved_action_is_documented() { for action in [ Action::NextFocus, Action::PrevFocus, Action::NextTab, Action::PrevTab, Action::Activate, Action::Cancel, Action::Save, Action::Help, Action::Quit, Action::Filter, Action::Command, ] { assert!( RESERVED.iter().any(|r| r.action == action), "{action:?} is reachable but absent from RESERVED" ); } } #[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 ); } }