crabidy/cbd-web/src/keymap.rs

392 lines
11 KiB
Rust

//! 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<Action> {
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"));
}
}