992 lines
31 KiB
Rust
992 lines
31 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,
|
|
/// Show or hide the frequency-spectrum visualizer in the now-playing
|
|
/// pane (client-side only; the server keeps streaming the bars).
|
|
ToggleSpectrum,
|
|
// Library pane
|
|
LibraryFirst,
|
|
LibraryLast,
|
|
LibraryNext,
|
|
LibraryPrev,
|
|
LibraryJumpDown,
|
|
LibraryJumpUp,
|
|
LibraryAscend,
|
|
LibraryDive,
|
|
LibraryQueueNext,
|
|
LibraryQueueAppend,
|
|
LibraryQueueReplace,
|
|
LibraryToggleMark,
|
|
LibraryYank,
|
|
/// Enter (or leave) visual mode: while active, movement toggles the mark
|
|
/// of every row it sweeps over, so a run of items is marked by `v` then
|
|
/// moving (architecture/visual-mode.md). Entering toggles the current row;
|
|
/// `v`/`V`/`Esc` (or any other action) leaves it, marks kept.
|
|
LibraryVisualMode,
|
|
/// 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 and returns to normal navigation of the filtered view;
|
|
/// `/` again re-opens the input with the current query for editing.
|
|
/// Bound in both the library and queue scopes; the dispatch targets
|
|
/// whichever pane has focus.
|
|
OpenSearch,
|
|
/// Clear the focused pane's active `/` filter (bound to `Esc` in the
|
|
/// library and queue scopes). A no-op when no filter is applied.
|
|
ClearSearch,
|
|
// Queue pane
|
|
QueueToggleMark,
|
|
QueueVisualMode,
|
|
QueueYank,
|
|
QueuePaste,
|
|
QueuePasteBefore,
|
|
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",
|
|
},
|
|
Binding {
|
|
scope: Scope::Global,
|
|
mods: KeyModifiers::NONE,
|
|
code: KeyCode::Char('f'),
|
|
action: Action::ToggleSpectrum,
|
|
description: "Toggle the frequency spectrum",
|
|
},
|
|
// -- 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('v'),
|
|
action: Action::LibraryVisualMode,
|
|
description: "Visual mode: movement toggles marks (v/V; Esc to leave)",
|
|
},
|
|
Binding {
|
|
scope: Scope::Library,
|
|
mods: KeyModifiers::SHIFT,
|
|
code: KeyCode::Char('V'),
|
|
action: Action::LibraryVisualMode,
|
|
description: "Visual mode (same as v)",
|
|
},
|
|
Binding {
|
|
scope: Scope::Library,
|
|
mods: KeyModifiers::NONE,
|
|
code: KeyCode::Char('y'),
|
|
action: Action::LibraryYank,
|
|
description: "Yank selection into the register (paste with p in the queue)",
|
|
},
|
|
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)",
|
|
},
|
|
Binding {
|
|
scope: Scope::Library,
|
|
mods: KeyModifiers::NONE,
|
|
code: KeyCode::Esc,
|
|
action: Action::ClearSearch,
|
|
description: "Clear the search filter",
|
|
},
|
|
// -- 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('s'),
|
|
action: Action::QueueToggleMark,
|
|
description: "Mark/unmark selection",
|
|
},
|
|
Binding {
|
|
scope: Scope::Queue,
|
|
mods: KeyModifiers::NONE,
|
|
code: KeyCode::Char('v'),
|
|
action: Action::QueueVisualMode,
|
|
description: "Visual mode: movement toggles marks (v/V; Esc to leave)",
|
|
},
|
|
Binding {
|
|
scope: Scope::Queue,
|
|
mods: KeyModifiers::SHIFT,
|
|
code: KeyCode::Char('V'),
|
|
action: Action::QueueVisualMode,
|
|
description: "Visual mode (same as v)",
|
|
},
|
|
Binding {
|
|
scope: Scope::Queue,
|
|
mods: KeyModifiers::NONE,
|
|
code: KeyCode::Char('y'),
|
|
action: Action::QueueYank,
|
|
description: "Yank selection into the register",
|
|
},
|
|
Binding {
|
|
scope: Scope::Queue,
|
|
mods: KeyModifiers::NONE,
|
|
code: KeyCode::Char('p'),
|
|
action: Action::QueuePaste,
|
|
description: "Paste the register after this track",
|
|
},
|
|
Binding {
|
|
scope: Scope::Queue,
|
|
mods: KeyModifiers::SHIFT,
|
|
code: KeyCode::Char('P'),
|
|
action: Action::QueuePasteBefore,
|
|
description: "Paste the register before 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)",
|
|
},
|
|
Binding {
|
|
scope: Scope::Queue,
|
|
mods: KeyModifiers::NONE,
|
|
code: KeyCode::Esc,
|
|
action: Action::ClearSearch,
|
|
description: "Clear the search filter",
|
|
},
|
|
// -- 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 spectrum_moved_off_v_to_f() {
|
|
// The frequency-spectrum toggle now lives on Global `f`, in any focus.
|
|
for focus in [UiFocus::Library, UiFocus::Queue] {
|
|
assert_eq!(
|
|
lookup(focus, false, key(KeyCode::Char('f'), KeyModifiers::NONE)),
|
|
Some(Action::ToggleSpectrum)
|
|
);
|
|
}
|
|
// `v`/`V` no longer toggle the spectrum (they are visual mode now).
|
|
assert_ne!(
|
|
lookup(
|
|
UiFocus::Library,
|
|
false,
|
|
key(KeyCode::Char('v'), KeyModifiers::NONE)
|
|
),
|
|
Some(Action::ToggleSpectrum)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn v_and_v_enter_visual_in_both_panes() {
|
|
// Both `v` and `V` (SHIFT, and SHIFT normalized away) enter visual
|
|
// mode, in the library and — since the register landed — the queue.
|
|
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
|
assert_eq!(
|
|
lookup(UiFocus::Library, false, key(KeyCode::Char('v'), mods)),
|
|
Some(Action::LibraryVisualMode)
|
|
);
|
|
assert_eq!(
|
|
lookup(UiFocus::Library, false, key(KeyCode::Char('V'), mods)),
|
|
Some(Action::LibraryVisualMode)
|
|
);
|
|
assert_eq!(
|
|
lookup(UiFocus::Queue, false, key(KeyCode::Char('v'), mods)),
|
|
Some(Action::QueueVisualMode)
|
|
);
|
|
assert_eq!(
|
|
lookup(UiFocus::Queue, false, key(KeyCode::Char('V'), mods)),
|
|
Some(Action::QueueVisualMode)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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() {
|
|
// With help closed, Esc drives the pane's ClearSearch, never the
|
|
// modal's CloseHelp — Help-scope bindings don't leak into navigation.
|
|
for focus in [UiFocus::Library, UiFocus::Queue] {
|
|
assert_eq!(
|
|
lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)),
|
|
Some(Action::ClearSearch)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|