//! Declarative keyboard bindings: the single source of truth for both key //! dispatch (`lookup`) and the help modal (which renders [`BINDINGS`]). //! //! Pure data and pure functions — no I/O, no ratatui types — so the whole //! table is unit-testable without a terminal. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::UiFocus; /// Where a binding applies. /// /// `Global` entries match regardless of which pane is focused. `Library` and /// `Queue` entries match only while that pane is focused. `Help` entries /// match only while the help modal is open — while it is open, *no* other /// scope matches (the modal is strictly modal). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Scope { Global, Library, Queue, Help, } /// Every user-visible operation a key press can trigger. /// /// Pane-specific variants are prefixed with their pane so the enum stays /// collision-free as panes grow. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Action { // Global /// Open the help modal (closing it is `CloseHelp`, scope `Help`). OpenHelp, Quit, CycleFocus, TogglePlay, RestartTrack, VolumeUp, VolumeDown, ToggleMute, ToggleShuffle, ToggleRepeat, NextTrack, PrevTrack, /// Show or hide the frequency-spectrum visualizer in the now-playing /// pane (client-side only; the server keeps streaming the bars). ToggleSpectrum, // Library pane LibraryFirst, LibraryLast, LibraryNext, LibraryPrev, LibraryJumpDown, LibraryJumpUp, LibraryAscend, LibraryDive, LibraryQueueNext, LibraryQueueAppend, LibraryQueueReplace, LibraryToggleMark, LibraryYank, /// Enter (or leave) visual mode: while active, movement toggles the mark /// of every row it sweeps over, so a run of items is marked by `v` then /// moving (architecture/visual-mode.md). Entering toggles the current row; /// `v`/`V`/`Esc` (or any other action) leaves it, marks kept. LibraryVisualMode, /// Open the input overlay to create a child of the currently open /// library node. No-op unless that node `is_creatable` (e.g. /// /tidal/search). While the overlay is open, keys bypass this table /// entirely (see `App::handle_input_key`). LibraryCreateNode, /// Open the input overlay prefilled with the selected item's title to /// rename it. No-op unless the selection `is_editable` (e.g. a search /// term, whose rename re-runs the search). LibraryEditNode, /// Delete the selected item. No-op unless the selection `is_deletable`. /// Deletes go through directly (no confirmation): a `/crabidy` delete /// removes only the metadata toml, never the shared content store /// (architecture/crabidy-store.md D7). LibraryDeleteNode, /// Open the input overlay (prefilled with the selection's title) to /// save the selected queueable subtree as a link bookmark under /// `/crabidy/`. No-op unless the bare selection `is_queable`. LibraryCaptureNode, /// Like [`Self::LibraryCaptureNode`], but the capture **downloads** /// every track's audio into the shared content store (de-duplicated). /// No-op unless the bare selection is queueable *and* downloadable /// (e.g. Tidal subtrees). LibraryDownloadNode, /// Open the `/` search input for the focused pane. Typing filters the /// pane's items live (case-insensitive substring); `Enter` keeps the /// filter and returns to normal navigation of the filtered view; /// `/` again re-opens the input with the current query for editing. /// Bound in both the library and queue scopes; the dispatch targets /// whichever pane has focus. OpenSearch, /// Clear the focused pane's active `/` filter (bound to `Esc` in the /// library and queue scopes). A no-op when no filter is applied. ClearSearch, // Queue pane QueueToggleMark, QueueVisualMode, QueueYank, QueuePaste, QueuePasteBefore, QueueFirst, QueueLast, QueueNext, QueuePrev, QueueJumpDown, QueueJumpUp, QueueSelectCurrent, QueuePlaySelected, QueueRemoveTrack, QueueClearKeepCurrent, QueueClearAll, /// Open the input overlay asking for a name to save the queue under /// (a link save at `/crabidy/`). No-op while the queue is empty. QueueSaveAs, /// Open the input overlay to **download-capture** the current queue /// into `/crabidy/` (the queue equivalent of the library's `W`), /// audio de-duplicated into the shared store. No-op while the queue is /// empty. QueueDownloadCapture, // Help modal CloseHelp, } /// One key binding: a key chord, the scope it applies in, the action it /// triggers, and the human-readable description the help modal shows. pub struct Binding { pub scope: Scope, pub mods: KeyModifiers, pub code: KeyCode, pub action: Action, /// Shown verbatim in the help modal. Imperative mood, no trailing period. pub description: &'static str, } /// All bindings, in help-modal display order (grouped by scope; `Global` /// first, then `Library`, `Queue`, `Help`). /// /// Invariant: within one scope, each `(mods, code)` chord appears at most /// once, except that shifted characters may carry either `SHIFT` or `NONE` /// (terminals disagree); `lookup` must treat those as equal for `Char` codes. pub const BINDINGS: &[Binding] = &[ // -- Global ---------------------------------------------------------- Binding { scope: Scope::Global, mods: KeyModifiers::NONE, code: KeyCode::Char('?'), action: Action::OpenHelp, description: "Show this help", }, Binding { scope: Scope::Global, mods: KeyModifiers::NONE, code: KeyCode::Char('q'), action: Action::Quit, description: "Quit", }, Binding { scope: Scope::Global, mods: KeyModifiers::NONE, code: KeyCode::Tab, action: Action::CycleFocus, description: "Switch between library and queue", }, Binding { scope: Scope::Global, mods: KeyModifiers::NONE, code: KeyCode::Char(' '), action: Action::TogglePlay, description: "Play/pause", }, Binding { scope: Scope::Global, mods: KeyModifiers::NONE, code: KeyCode::Char('r'), action: Action::RestartTrack, description: "Restart current track", }, Binding { scope: Scope::Global, mods: KeyModifiers::SHIFT, code: KeyCode::Char('K'), action: Action::VolumeUp, description: "Volume up", }, Binding { scope: Scope::Global, mods: KeyModifiers::SHIFT, code: KeyCode::Char('J'), action: Action::VolumeDown, description: "Volume down", }, Binding { scope: Scope::Global, mods: KeyModifiers::NONE, code: KeyCode::Char('m'), action: Action::ToggleMute, description: "Toggle mute", }, Binding { scope: Scope::Global, mods: KeyModifiers::NONE, code: KeyCode::Char('z'), action: Action::ToggleShuffle, description: "Toggle shuffle", }, Binding { scope: Scope::Global, mods: KeyModifiers::NONE, code: KeyCode::Char('x'), action: Action::ToggleRepeat, description: "Toggle repeat", }, Binding { scope: Scope::Global, mods: KeyModifiers::CONTROL, code: KeyCode::Char('n'), action: Action::NextTrack, description: "Next track", }, Binding { scope: Scope::Global, mods: KeyModifiers::CONTROL, code: KeyCode::Char('p'), action: Action::PrevTrack, description: "Previous track", }, Binding { scope: Scope::Global, mods: KeyModifiers::NONE, code: KeyCode::Char('f'), action: Action::ToggleSpectrum, description: "Toggle the frequency spectrum", }, // -- Library --------------------------------------------------------- Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('j'), action: Action::LibraryNext, description: "Select next item", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('k'), action: Action::LibraryPrev, description: "Select previous item", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('g'), action: Action::LibraryFirst, description: "Select first item", }, Binding { scope: Scope::Library, mods: KeyModifiers::SHIFT, code: KeyCode::Char('G'), action: Action::LibraryLast, description: "Select last item", }, Binding { scope: Scope::Library, mods: KeyModifiers::CONTROL, code: KeyCode::Char('d'), action: Action::LibraryJumpDown, description: "Jump 15 items down", }, Binding { scope: Scope::Library, mods: KeyModifiers::CONTROL, code: KeyCode::Char('u'), action: Action::LibraryJumpUp, description: "Jump 15 items up", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('h'), action: Action::LibraryAscend, description: "Go to parent folder", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('l'), action: Action::LibraryDive, description: "Enter selected folder", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('s'), action: Action::LibraryToggleMark, description: "Mark/unmark selection", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('v'), action: Action::LibraryVisualMode, description: "Visual mode: movement toggles marks (v/V; Esc to leave)", }, Binding { scope: Scope::Library, mods: KeyModifiers::SHIFT, code: KeyCode::Char('V'), action: Action::LibraryVisualMode, description: "Visual mode (same as v)", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('y'), action: Action::LibraryYank, description: "Yank selection into the register (paste with p in the queue)", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('w'), action: Action::LibraryCaptureNode, description: "Save selection as bookmark", }, Binding { scope: Scope::Library, mods: KeyModifiers::SHIFT, code: KeyCode::Char('W'), action: Action::LibraryDownloadNode, description: "Capture selection into /crabidy (downloads audio; can take long)", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('%'), action: Action::LibraryCreateNode, description: "Create node here (e.g. search term)", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('e'), action: Action::LibraryEditNode, description: "Rename selected node (e.g. search term)", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('d'), action: Action::LibraryDeleteNode, description: "Delete selection", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('a'), action: Action::LibraryQueueAppend, description: "Append selection to queue", }, Binding { scope: Scope::Library, mods: KeyModifiers::SHIFT, code: KeyCode::Char('L'), action: Action::LibraryQueueNext, description: "Queue selection after current track", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Enter, action: Action::LibraryQueueReplace, description: "Replace queue with selection", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Char('/'), action: Action::OpenSearch, description: "Filter this view (type to search, Enter keeps, Esc clears)", }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, code: KeyCode::Esc, action: Action::ClearSearch, description: "Clear the search filter", }, // -- Queue ----------------------------------------------------------- Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('j'), action: Action::QueueNext, description: "Select next track", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('k'), action: Action::QueuePrev, description: "Select previous track", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('g'), action: Action::QueueFirst, description: "Select first track", }, Binding { scope: Scope::Queue, mods: KeyModifiers::SHIFT, code: KeyCode::Char('G'), action: Action::QueueLast, description: "Select last track", }, Binding { scope: Scope::Queue, mods: KeyModifiers::CONTROL, code: KeyCode::Char('d'), action: Action::QueueJumpDown, description: "Jump 15 tracks down", }, Binding { scope: Scope::Queue, mods: KeyModifiers::CONTROL, code: KeyCode::Char('u'), action: Action::QueueJumpUp, description: "Jump 15 tracks up", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('o'), action: Action::QueueSelectCurrent, description: "Select the playing track", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Enter, action: Action::QueuePlaySelected, description: "Play selected track", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('s'), action: Action::QueueToggleMark, description: "Mark/unmark selection", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('v'), action: Action::QueueVisualMode, description: "Visual mode: movement toggles marks (v/V; Esc to leave)", }, Binding { scope: Scope::Queue, mods: KeyModifiers::SHIFT, code: KeyCode::Char('V'), action: Action::QueueVisualMode, description: "Visual mode (same as v)", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('y'), action: Action::QueueYank, description: "Yank selection into the register", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('p'), action: Action::QueuePaste, description: "Paste the register after this track", }, Binding { scope: Scope::Queue, mods: KeyModifiers::SHIFT, code: KeyCode::Char('P'), action: Action::QueuePasteBefore, description: "Paste the register before this track", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('d'), action: Action::QueueRemoveTrack, description: "Remove selected track", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('c'), action: Action::QueueClearKeepCurrent, description: "Clear queue except current track", }, Binding { scope: Scope::Queue, mods: KeyModifiers::SHIFT, code: KeyCode::Char('C'), action: Action::QueueClearAll, description: "Clear entire queue", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('w'), action: Action::QueueSaveAs, description: "Save queue under a name", }, Binding { scope: Scope::Queue, mods: KeyModifiers::SHIFT, code: KeyCode::Char('W'), action: Action::QueueDownloadCapture, description: "Capture the queue into /crabidy (downloads audio)", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Char('/'), action: Action::OpenSearch, description: "Filter this view (type to search, Enter keeps, Esc clears)", }, Binding { scope: Scope::Queue, mods: KeyModifiers::NONE, code: KeyCode::Esc, action: Action::ClearSearch, description: "Clear the search filter", }, // -- Help modal ------------------------------------------------------ Binding { scope: Scope::Help, mods: KeyModifiers::NONE, code: KeyCode::Char('?'), action: Action::CloseHelp, description: "Close help", }, Binding { scope: Scope::Help, mods: KeyModifiers::NONE, code: KeyCode::Esc, action: Action::CloseHelp, description: "Close help", }, Binding { scope: Scope::Help, mods: KeyModifiers::NONE, code: KeyCode::Char('q'), action: Action::CloseHelp, description: "Close help", }, ]; /// Translate a key event into an action, honoring modality and focus. /// /// While `help_open` is true only `Scope::Help` entries can match — every /// other key press is swallowed. Otherwise `Scope::Global` entries match in /// any focus and pane entries match only their own `UiFocus`. /// /// For `KeyCode::Char` codes the `SHIFT` modifier is ignored during /// comparison (terminals disagree on whether shifted characters like `?` or /// `G` report `SHIFT`); all other modifiers must match exactly. pub fn lookup(focus: UiFocus, help_open: bool, key: KeyEvent) -> Option { let scope_matches = |scope: Scope| match (help_open, scope) { (true, s) => s == Scope::Help, (false, Scope::Help) => false, (false, Scope::Global) => true, (false, Scope::Library) => matches!(focus, UiFocus::Library), (false, Scope::Queue) => matches!(focus, UiFocus::Queue), }; let chord_matches = |b: &Binding| { if b.code != key.code { return false; } if matches!(b.code, KeyCode::Char(_)) { b.mods.difference(KeyModifiers::SHIFT) == key.modifiers.difference(KeyModifiers::SHIFT) } else { b.mods == key.modifiers } }; BINDINGS .iter() .find(|b| scope_matches(b.scope) && chord_matches(b)) .map(|b| b.action) } /// Human-readable label for a binding's key chord, e.g. `"Space"`, /// `"Ctrl+d"`, `"?"`. Used by the help modal so displayed keys are derived /// from the same data dispatch uses. pub fn key_label(mods: KeyModifiers, code: KeyCode) -> String { let key = match code { KeyCode::Char(' ') => "Space".to_string(), KeyCode::Char(c) => c.to_string(), KeyCode::Tab => "Tab".to_string(), KeyCode::Enter => "Enter".to_string(), KeyCode::Esc => "Esc".to_string(), other => format!("{other:?}"), }; if mods.contains(KeyModifiers::CONTROL) { format!("Ctrl+{key}") } else { // SHIFT is already visible in the character itself ('K', '?', …). key } } #[cfg(test)] mod tests { use super::*; fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { KeyEvent::new(code, mods) } #[test] fn global_bindings_match_in_any_focus() { for focus in [UiFocus::Library, UiFocus::Queue] { assert_eq!( lookup(focus, false, key(KeyCode::Char('?'), KeyModifiers::NONE)), Some(Action::OpenHelp) ); assert_eq!( lookup(focus, false, key(KeyCode::Char('q'), KeyModifiers::NONE)), Some(Action::Quit) ); assert_eq!( lookup(focus, false, key(KeyCode::Tab, KeyModifiers::NONE)), Some(Action::CycleFocus) ); assert_eq!( lookup(focus, false, key(KeyCode::Char(' '), KeyModifiers::NONE)), Some(Action::TogglePlay) ); } } #[test] fn pane_bindings_only_match_their_own_pane() { // 'h' ascends in the library but is unbound in the queue. assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Char('h'), KeyModifiers::NONE) ), Some(Action::LibraryAscend) ); assert_eq!( lookup( UiFocus::Queue, false, key(KeyCode::Char('h'), KeyModifiers::NONE) ), None ); // 'c' clears the queue there but is unbound in the library. assert_eq!( lookup( UiFocus::Queue, false, key(KeyCode::Char('c'), KeyModifiers::NONE) ), Some(Action::QueueClearKeepCurrent) ); assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Char('c'), KeyModifiers::NONE) ), None ); } #[test] fn w_saves_the_queue_or_captures_the_selection_per_pane() { assert_eq!( lookup( UiFocus::Queue, false, key(KeyCode::Char('w'), KeyModifiers::NONE) ), Some(Action::QueueSaveAs) ); assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Char('w'), KeyModifiers::NONE) ), Some(Action::LibraryCaptureNode) ); // Shift-w is the download capture in each pane: the library // selection, or the whole queue. assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Char('W'), KeyModifiers::SHIFT) ), Some(Action::LibraryDownloadNode) ); assert_eq!( lookup( UiFocus::Queue, false, key(KeyCode::Char('W'), KeyModifiers::SHIFT) ), Some(Action::QueueDownloadCapture) ); } #[test] fn same_chord_resolves_per_pane() { assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Char('j'), KeyModifiers::NONE) ), Some(Action::LibraryNext) ); assert_eq!( lookup( UiFocus::Queue, false, key(KeyCode::Char('j'), KeyModifiers::NONE) ), Some(Action::QueueNext) ); assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Enter, KeyModifiers::NONE) ), Some(Action::LibraryQueueReplace) ); assert_eq!( lookup( UiFocus::Queue, false, key(KeyCode::Enter, KeyModifiers::NONE) ), Some(Action::QueuePlaySelected) ); } #[test] fn shift_is_ignored_for_char_codes() { // Terminals disagree on whether shifted characters report SHIFT. for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] { assert_eq!( lookup(UiFocus::Library, false, key(KeyCode::Char('?'), mods)), Some(Action::OpenHelp) ); assert_eq!( lookup(UiFocus::Library, false, key(KeyCode::Char('G'), mods)), Some(Action::LibraryLast) ); assert_eq!( lookup(UiFocus::Queue, false, key(KeyCode::Char('K'), mods)), Some(Action::VolumeUp) ); } } #[test] fn percent_creates_only_in_the_library() { // '%' is shifted on most layouts; both modifier reports must match. for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] { assert_eq!( lookup(UiFocus::Library, false, key(KeyCode::Char('%'), mods)), Some(Action::LibraryCreateNode) ); assert_eq!( lookup(UiFocus::Queue, false, key(KeyCode::Char('%'), mods)), None ); } } #[test] fn control_must_match_exactly() { assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Char('n'), KeyModifiers::CONTROL) ), Some(Action::NextTrack) ); assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Char('n'), KeyModifiers::NONE) ), None ); assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Char('d'), KeyModifiers::CONTROL) ), Some(Action::LibraryJumpDown) ); } #[test] fn edit_and_delete_bind_in_the_library_scope_only() { assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Char('e'), KeyModifiers::NONE) ), Some(Action::LibraryEditNode) ); // Plain 'd' renames per scope: library delete vs queue remove-track; // Ctrl+d stays the jump (see control_must_match_exactly). assert_eq!( lookup( UiFocus::Library, false, key(KeyCode::Char('d'), KeyModifiers::NONE) ), Some(Action::LibraryDeleteNode) ); assert_eq!( lookup( UiFocus::Queue, false, key(KeyCode::Char('d'), KeyModifiers::NONE) ), Some(Action::QueueRemoveTrack) ); assert_eq!( lookup( UiFocus::Queue, false, key(KeyCode::Char('e'), KeyModifiers::NONE) ), None ); } #[test] fn spectrum_moved_off_v_to_f() { // The frequency-spectrum toggle now lives on Global `f`, in any focus. for focus in [UiFocus::Library, UiFocus::Queue] { assert_eq!( lookup(focus, false, key(KeyCode::Char('f'), KeyModifiers::NONE)), Some(Action::ToggleSpectrum) ); } // `v`/`V` no longer toggle the spectrum (they are visual mode now). assert_ne!( lookup( UiFocus::Library, false, key(KeyCode::Char('v'), KeyModifiers::NONE) ), Some(Action::ToggleSpectrum) ); } #[test] fn v_and_v_enter_visual_in_both_panes() { // Both `v` and `V` (SHIFT, and SHIFT normalized away) enter visual // mode, in the library and — since the register landed — the queue. for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] { assert_eq!( lookup(UiFocus::Library, false, key(KeyCode::Char('v'), mods)), Some(Action::LibraryVisualMode) ); assert_eq!( lookup(UiFocus::Library, false, key(KeyCode::Char('V'), mods)), Some(Action::LibraryVisualMode) ); assert_eq!( lookup(UiFocus::Queue, false, key(KeyCode::Char('v'), mods)), Some(Action::QueueVisualMode) ); assert_eq!( lookup(UiFocus::Queue, false, key(KeyCode::Char('V'), mods)), Some(Action::QueueVisualMode) ); } } #[test] fn open_help_swallows_everything_but_close() { for focus in [UiFocus::Library, UiFocus::Queue] { // The three close chords work... for code in [KeyCode::Char('?'), KeyCode::Esc, KeyCode::Char('q')] { assert_eq!( lookup(focus, true, key(code, KeyModifiers::NONE)), Some(Action::CloseHelp) ); } // ...and every other binding is inert while help is open. assert_eq!( lookup(focus, true, key(KeyCode::Char(' '), KeyModifiers::NONE)), None ); assert_eq!( lookup(focus, true, key(KeyCode::Char('j'), KeyModifiers::NONE)), None ); assert_eq!( lookup(focus, true, key(KeyCode::Tab, KeyModifiers::NONE)), None ); } } #[test] fn help_scope_never_matches_while_help_is_closed() { // With help closed, Esc drives the pane's ClearSearch, never the // modal's CloseHelp — Help-scope bindings don't leak into navigation. for focus in [UiFocus::Library, UiFocus::Queue] { assert_eq!( lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)), Some(Action::ClearSearch) ); } } #[test] fn chords_are_unique_within_scope() { // Normalize SHIFT away for Char codes, mirroring lookup's comparison. fn norm(b: &Binding) -> (Scope, KeyModifiers, KeyCode) { let mods = if matches!(b.code, KeyCode::Char(_)) { b.mods.difference(KeyModifiers::SHIFT) } else { b.mods }; (b.scope, mods, b.code) } let mut seen = std::collections::HashSet::new(); for b in BINDINGS { assert!( seen.insert(norm(b)), "duplicate chord in scope {:?}: {:?}+{:?}", b.scope, b.mods, b.code ); } } #[test] fn every_binding_has_a_description() { for b in BINDINGS { assert!( !b.description.trim().is_empty(), "empty description for {:?}", b.action ); } } #[test] fn key_labels_are_human_readable() { assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('q')), "q"); assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char('?')), "?"); assert_eq!(key_label(KeyModifiers::SHIFT, KeyCode::Char('K')), "K"); assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Char(' ')), "Space"); assert_eq!( key_label(KeyModifiers::CONTROL, KeyCode::Char('d')), "Ctrl+d" ); assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Tab), "Tab"); assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Enter), "Enter"); assert_eq!(key_label(KeyModifiers::NONE, KeyCode::Esc), "Esc"); } }