crabidy/cbd-tui/src/app/bindings.rs

852 lines
26 KiB
Rust

//! 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,
// Library pane
LibraryFirst,
LibraryLast,
LibraryNext,
LibraryPrev,
LibraryJumpDown,
LibraryJumpUp,
LibraryAscend,
LibraryDive,
LibraryQueueNext,
LibraryQueueAppend,
LibraryQueueReplace,
LibraryToggleMark,
/// 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/<name>`. 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, `Esc` clears it. Bound in both the library and queue
/// scopes; the dispatch targets whichever pane has focus.
OpenSearch,
// Queue pane
QueueInsertHere,
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/<name>`). No-op while the queue is empty.
QueueSaveAs,
/// Open the input overlay to **download-capture** the current queue
/// into `/crabidy/<name>` (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",
},
// -- 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('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)",
},
// -- 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('p'),
action: Action::QueueInsertHere,
description: "Insert library selection after 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)",
},
// -- 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<Action> {
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 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() {
// Esc is only bound inside the modal.
for focus in [UiFocus::Library, UiFocus::Queue] {
assert_eq!(
lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)),
None
);
}
}
#[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");
}
}