392 lines
11 KiB
Rust
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"));
|
|
}
|
|
}
|