//! 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, /// Move the playing position back inside the current track by /// [`crate::app::SEEK_STEP_MILLIS`]. Near the start it lands at 0 rather /// than stepping into the previous track (architecture/seek.md A1). SeekBackward, /// Move the playing position forward by the same step; overshooting the /// end lets the track finish, which advances the queue. SeekForward, LibraryFirst, LibraryLast, LibraryNext, LibraryPrev, LibraryJumpDown, LibraryJumpUp, LibraryAscend, LibraryDive, LibraryToggleMark, LibraryVisualMode, LibraryYank, LibraryCaptureNode, LibraryDownloadNode, LibraryCreateNode, LibraryEditNode, LibraryDeleteNode, LibraryQueueAppend, LibraryQueueNext, LibraryQueueReplace, QueueFirst, QueueLast, QueueNext, QueuePrev, QueueJumpDown, QueueJumpUp, QueueSelectCurrent, QueuePlaySelected, QueueToggleMark, QueueVisualMode, QueueYank, QueuePaste, QueuePasteBefore, 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: ", / .", description: "Seek back / forward 15 seconds", }, 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: "< / >", description: "Previous / next 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: "v / V", description: "Visual mode: movement toggles marks", }, HelpEntry { scope: "Library", key: "y", description: "Yank selection into the register", }, 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: "s", description: "Mark/unmark selection", }, HelpEntry { scope: "Queue", key: "v / V", description: "Visual mode: movement toggles marks", }, HelpEntry { scope: "Queue", key: "y", description: "Yank selection into the register", }, HelpEntry { scope: "Queue", key: "p / P", description: "Paste the register after / before 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 { // Mirrors the TUI, where these are the primary track-skip chords. // `Ctrl-p` resolves and is `prevent_default`ed; `Ctrl-n` is reserved // by Chrome and Firefox for "new window" above the page and never // reaches us, which is why `<`/`>` exist. "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), // Two physical keys, all four moves: unshifted seeks 15 seconds, // shifted skips a track. Plain printable characters, so unlike the // control chords they collide with nothing the browser reserves. "," => Some(Action::SeekBackward), "." => Some(Action::SeekForward), "<" => Some(Action::PrevTrack), ">" => Some(Action::NextTrack), "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), "v" | "V" => Some(Action::LibraryVisualMode), "y" => Some(Action::LibraryYank), "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), "s" => Some(Action::QueueToggleMark), "v" | "V" => Some(Action::QueueVisualMode), "y" => Some(Action::QueueYank), "p" => Some(Action::QueuePaste), "P" => Some(Action::QueuePasteBefore), "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) ); } /// Seek is a control chord in either pane, and claiming `Ctrl-f` leaves /// plain `f` alone (the ctrl branch is checked first and separately). #[test] fn seek_is_on_the_control_chords_in_both_panes() { for focus in [Focus::Library, Focus::Queue] { assert_eq!(lookup(focus, false, ".", false), Some(Action::SeekForward)); assert_eq!(lookup(focus, false, ",", false), Some(Action::SeekBackward)); } // Unmodified `b` is unbound; unmodified `f` is not seek. assert_eq!(lookup(Focus::Library, false, "b", false), None); assert_ne!( lookup(Focus::Library, false, "f", false), Some(Action::SeekForward) ); // Help is modal for control chords too. assert_eq!(lookup(Focus::Library, true, "f", true), None); } /// `Ctrl-n` cannot be claimed in a browser — Chrome and Firefox reserve it /// above the page — so track skipping must also be reachable without it. #[test] fn track_skipping_does_not_depend_on_ctrl_n() { for focus in [Focus::Library, Focus::Queue] { assert_eq!(lookup(focus, false, ">", false), Some(Action::NextTrack)); assert_eq!(lookup(focus, false, "<", false), Some(Action::PrevTrack)); } // Ctrl-p is claimable and stays, so TUI habits still work. assert_eq!( lookup(Focus::Library, false, "p", true), Some(Action::PrevTrack) ); // And the help overlay documents the chords that actually work. assert!(HELP.iter().any(|h| h.key == "< / >")); } #[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")); } }