//! Keyboard bindings — the web port of `cbd-tui/src/app/bindings.rs`, //! keyed by browser `KeyboardEvent` values instead of crossterm codes. //! Deliberate differences: there is no `q` (quit) in a browser tab, and //! `Escape` closes the help overlay (the TUI also accepts `q`/`?`). use crate::state::Focus; /// Everything a key can trigger. Mirrors the TUI's `Action` list; the /// components translate these into RPCs or local state changes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Action { OpenHelp, CloseHelp, CycleFocus, TogglePlay, RestartTrack, VolumeUp, VolumeDown, ToggleMute, ToggleShuffle, ToggleRepeat, NextTrack, PrevTrack, LibraryFirst, LibraryLast, LibraryNext, LibraryPrev, LibraryJumpDown, LibraryJumpUp, LibraryAscend, LibraryDive, LibraryToggleMark, LibraryCaptureNode, LibraryDownloadNode, LibraryCreateNode, LibraryEditNode, LibraryDeleteNode, LibraryQueueAppend, LibraryQueueNext, LibraryQueueReplace, QueueFirst, QueueLast, QueueNext, QueuePrev, QueueJumpDown, QueueJumpUp, QueueSelectCurrent, QueuePlaySelected, QueueInsertHere, QueueRemoveTrack, QueueClearKeepCurrent, QueueClearAll, QueueSaveAs, } /// One row of the help overlay: the key label and what it does. pub struct HelpEntry { pub scope: &'static str, pub key: &'static str, pub description: &'static str, } /// The help overlay content, in display order — kept in lockstep with /// [`lookup`] by the unit tests below. pub const HELP: &[HelpEntry] = &[ HelpEntry { scope: "Global", key: "?", description: "Show this help", }, HelpEntry { scope: "Global", key: "Tab", description: "Switch between library and queue", }, HelpEntry { scope: "Global", key: "Space", description: "Play/pause", }, HelpEntry { scope: "Global", key: "r", description: "Restart current track", }, HelpEntry { scope: "Global", key: "K", description: "Volume up", }, HelpEntry { scope: "Global", key: "J", description: "Volume down", }, HelpEntry { scope: "Global", key: "m", description: "Toggle mute", }, HelpEntry { scope: "Global", key: "z", description: "Toggle shuffle", }, HelpEntry { scope: "Global", key: "x", description: "Toggle repeat", }, HelpEntry { scope: "Global", key: "Ctrl-n", description: "Next track", }, HelpEntry { scope: "Global", key: "Ctrl-p", description: "Previous track", }, HelpEntry { scope: "Library", key: "j / k", description: "Select next / previous item", }, HelpEntry { scope: "Library", key: "g / G", description: "Select first / last item", }, HelpEntry { scope: "Library", key: "Ctrl-d / Ctrl-u", description: "Jump 15 items", }, HelpEntry { scope: "Library", key: "h", description: "Go to parent folder", }, HelpEntry { scope: "Library", key: "l", description: "Enter selected folder", }, HelpEntry { scope: "Library", key: "s", description: "Mark/unmark selection", }, HelpEntry { scope: "Library", key: "w", description: "Save selection as bookmark", }, HelpEntry { scope: "Library", key: "W", description: "Download selection as capture (can take long; same name resumes)", }, HelpEntry { scope: "Library", key: "%", description: "Create node here (e.g. search term)", }, HelpEntry { scope: "Library", key: "e", description: "Rename selected node (e.g. search term)", }, HelpEntry { scope: "Library", key: "d", description: "Delete selection (captures ask y/N, and delete files)", }, HelpEntry { scope: "Library", key: "a", description: "Append selection to queue", }, HelpEntry { scope: "Library", key: "L", description: "Queue selection after current track", }, HelpEntry { scope: "Library", key: "Enter", description: "Replace queue with selection", }, HelpEntry { scope: "Queue", key: "j / k", description: "Select next / previous track", }, HelpEntry { scope: "Queue", key: "g / G", description: "Select first / last track", }, HelpEntry { scope: "Queue", key: "Ctrl-d / Ctrl-u", description: "Jump 15 tracks", }, HelpEntry { scope: "Queue", key: "o", description: "Select the playing track", }, HelpEntry { scope: "Queue", key: "Enter", description: "Play selected track", }, HelpEntry { scope: "Queue", key: "p", description: "Insert library selection after this track", }, HelpEntry { scope: "Queue", key: "d", description: "Remove selected track", }, HelpEntry { scope: "Queue", key: "c", description: "Clear queue except current track", }, HelpEntry { scope: "Queue", key: "C", description: "Clear entire queue", }, HelpEntry { scope: "Queue", key: "w", description: "Save queue under a name", }, HelpEntry { scope: "Help", key: "Esc or ?", description: "Close help", }, ]; /// Resolves a browser key event to an action, mirroring the TUI's /// `bindings::lookup`: global chords first, then the focused pane's. /// `key` is `KeyboardEvent.key` (case carries shift for letters); /// `ctrl` is `ctrlKey`. While the help overlay is open only its close /// keys resolve; dialogs bypass this entirely (they are modal). pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option { if help_open { return matches!(key, "?" | "Escape" | "q").then_some(Action::CloseHelp); } if ctrl { return match key { "n" => Some(Action::NextTrack), "p" => Some(Action::PrevTrack), "d" => Some(match focus { Focus::Library => Action::LibraryJumpDown, Focus::Queue => Action::QueueJumpDown, }), "u" => Some(match focus { Focus::Library => Action::LibraryJumpUp, Focus::Queue => Action::QueueJumpUp, }), _ => None, }; } let global = match key { "?" => Some(Action::OpenHelp), "Tab" => Some(Action::CycleFocus), " " => Some(Action::TogglePlay), "r" => Some(Action::RestartTrack), "K" => Some(Action::VolumeUp), "J" => Some(Action::VolumeDown), "m" => Some(Action::ToggleMute), "z" => Some(Action::ToggleShuffle), "x" => Some(Action::ToggleRepeat), _ => None, }; if global.is_some() { return global; } match focus { Focus::Library => match key { "j" | "ArrowDown" => Some(Action::LibraryNext), "k" | "ArrowUp" => Some(Action::LibraryPrev), "g" => Some(Action::LibraryFirst), "G" => Some(Action::LibraryLast), "h" | "ArrowLeft" => Some(Action::LibraryAscend), "l" | "ArrowRight" => Some(Action::LibraryDive), "s" => Some(Action::LibraryToggleMark), "w" => Some(Action::LibraryCaptureNode), "W" => Some(Action::LibraryDownloadNode), "%" => Some(Action::LibraryCreateNode), "e" => Some(Action::LibraryEditNode), "d" => Some(Action::LibraryDeleteNode), "a" => Some(Action::LibraryQueueAppend), "L" => Some(Action::LibraryQueueNext), "Enter" => Some(Action::LibraryQueueReplace), _ => None, }, Focus::Queue => match key { "j" | "ArrowDown" => Some(Action::QueueNext), "k" | "ArrowUp" => Some(Action::QueuePrev), "g" => Some(Action::QueueFirst), "G" => Some(Action::QueueLast), "o" => Some(Action::QueueSelectCurrent), "Enter" => Some(Action::QueuePlaySelected), "p" => Some(Action::QueueInsertHere), "d" => Some(Action::QueueRemoveTrack), "c" => Some(Action::QueueClearKeepCurrent), "C" => Some(Action::QueueClearAll), "w" => Some(Action::QueueSaveAs), _ => None, }, } } #[cfg(test)] mod tests { use super::*; #[test] fn pane_focus_decides_shared_chords() { assert_eq!( lookup(Focus::Library, false, "d", false), Some(Action::LibraryDeleteNode) ); assert_eq!( lookup(Focus::Queue, false, "d", false), Some(Action::QueueRemoveTrack) ); assert_eq!( lookup(Focus::Library, false, "d", true), Some(Action::LibraryJumpDown) ); } #[test] fn globals_win_in_both_panes() { for focus in [Focus::Library, Focus::Queue] { assert_eq!(lookup(focus, false, " ", false), Some(Action::TogglePlay)); assert_eq!( lookup(focus, false, "z", false), Some(Action::ToggleShuffle) ); assert_eq!(lookup(focus, false, "n", true), Some(Action::NextTrack)); } } #[test] fn help_is_modal() { assert_eq!(lookup(Focus::Library, true, "j", false), None); assert_eq!( lookup(Focus::Library, true, "Escape", false), Some(Action::CloseHelp) ); assert_eq!( lookup(Focus::Library, true, "?", false), Some(Action::CloseHelp) ); } #[test] fn arrows_alias_the_vim_movement() { assert_eq!( lookup(Focus::Library, false, "ArrowDown", false), Some(Action::LibraryNext) ); assert_eq!( lookup(Focus::Library, false, "ArrowLeft", false), Some(Action::LibraryAscend) ); assert_eq!( lookup(Focus::Queue, false, "ArrowUp", false), Some(Action::QueuePrev) ); } #[test] fn every_action_reachable_from_help_table() { // The help overlay documents at least every scope we bind. assert!(HELP.iter().any(|h| h.scope == "Global")); assert!(HELP.iter().any(|h| h.scope == "Library")); assert!(HELP.iter().any(|h| h.scope == "Queue")); } }