use std::{fs, path::PathBuf, str::FromStr}; use anyhow::{Context as _, Result, bail}; use crokey::{KeyCombination, KeyCombinationFormat, key}; use crossterm::event::{KeyCode, KeyModifiers}; use serde::Deserialize; use crate::app::action::Action; const DEFAULT_KEYMAP: &str = include_str!("default_keymap.toml"); /// Input context a binding applies to. `Global` bindings work everywhere; /// view-specific bindings shadow global ones for the same key sequence. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] #[serde(rename_all = "snake_case")] pub enum KeyContext { #[default] Global, Library, Search, Playlists, Queue, #[serde(rename = "settings", alias = "federation")] Federation, Logs, } impl KeyContext { pub fn label(self) -> &'static str { match self { KeyContext::Global => "global", KeyContext::Library => "library", KeyContext::Search => "search", KeyContext::Playlists => "playlists", KeyContext::Queue => "queue", KeyContext::Federation => "settings", KeyContext::Logs => "logs", } } } #[derive(Debug, Clone)] pub struct Binding { pub keys: Vec, pub action: Action, pub context: KeyContext, } #[derive(Debug, Deserialize)] struct RawBinding { key_sequence: String, command: Action, #[serde(default)] context: KeyContext, } #[derive(Debug, Default, Deserialize)] struct KeymapFile { #[serde(default)] keymaps: Vec, } #[derive(Debug, PartialEq, Eq)] pub enum KeyResolution { Action(Action), /// The pressed keys are a prefix of a longer sequence; the formatted /// pending chord is returned for display in the status bar. Pending(String), Unmatched, } pub struct Keymap { bindings: Vec, pending: Vec, format: KeyCombinationFormat, } impl Keymap { /// Load defaults merged with the user's keymap.toml. A broken user file /// must not brick the app: it is ignored and reported as a warning. pub fn load() -> (Self, Option) { let mut bindings = parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse"); let mut warning = None; if let Some(path) = user_keymap_path() && path.exists() { match fs::read_to_string(&path) .map_err(anyhow::Error::from) .and_then(|text| parse_bindings(&text)) { Ok(user) => merge(&mut bindings, user), Err(err) => { warning = Some(format!( "{} ignored: {err:#}; using default keybindings", path.display() )); } } } let keymap = Self { bindings, pending: Vec::new(), format: KeyCombinationFormat::default(), }; (keymap, warning) } /// Feed one key combination; returns an action, a pending-chord state, or /// nothing. Esc clears a pending chord instead of resolving. pub fn resolve(&mut self, key: KeyCombination, context: KeyContext) -> KeyResolution { let key = self.localize(normalize(key)); if key == key!(esc) && !self.pending.is_empty() { self.pending.clear(); return KeyResolution::Unmatched; } self.pending.push(key); match self.lookup(context) { Lookup::Exact(action) => { self.pending.clear(); KeyResolution::Action(action) } Lookup::Prefix => KeyResolution::Pending(self.format_pending()), Lookup::Nothing => { let retry = self.pending.len() > 1; self.pending.clear(); if retry { // The aborted chord's last key may start a new sequence. self.resolve(key, context) } else { KeyResolution::Unmatched } } } } /// All bindings as (formatted keys, action, context) for the help view. pub fn help_entries(&self) -> Vec<(String, Action, KeyContext)> { self.bindings .iter() .map(|b| (self.format_keys(&b.keys), b.action.clone(), b.context)) .collect() } fn lookup(&self, context: KeyContext) -> Lookup { let mut exact_ctx: Option<&Binding> = None; let mut exact_global: Option<&Binding> = None; let mut has_prefix = false; for b in &self.bindings { if b.context != KeyContext::Global && b.context != context { continue; } if b.keys.len() < self.pending.len() || b.keys[..self.pending.len()] != self.pending { continue; } if b.keys.len() == self.pending.len() { if b.context == KeyContext::Global { exact_global.get_or_insert(b); } else { exact_ctx.get_or_insert(b); } } else { has_prefix = true; } } // An exact match fires immediately even if a longer sequence shares // the prefix — don't bind both "g" and "g g". if let Some(b) = exact_ctx.or(exact_global) { Lookup::Exact(b.action.clone()) } else if has_prefix { Lookup::Prefix } else { Lookup::Nothing } } /// Layout fallback (vim langmap style): a Cyrillic key that no binding /// uses directly is translated to the Latin key in the same physical /// position (ЙЦУКЕН ↔ QWERTY), so bindings work in the Russian layout. /// Text input is unaffected — this runs only inside keymap resolution. fn localize(&self, key: KeyCombination) -> KeyCombination { let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes else { return key; }; let lower = c.to_lowercase().next().unwrap_or(c); let Some(latin) = qwerty_equivalent(lower) else { return key; }; // A binding that mentions the Cyrillic key directly wins. if self.bindings.iter().any(|b| b.keys.contains(&key)) { return key; } let mapped = if c.is_uppercase() || key.modifiers.contains(KeyModifiers::SHIFT) { KeyCode::Char(latin.to_ascii_uppercase()) } else { KeyCode::Char(latin) }; normalize(KeyCombination::new(mapped, key.modifiers)) } fn format_keys(&self, keys: &[KeyCombination]) -> String { keys.iter() .map(|k| self.format.to_string(*k)) .collect::>() .join(" ") } fn format_pending(&self) -> String { self.format_keys(&self.pending) } } enum Lookup { Exact(Action), Prefix, Nothing, } /// Terminals report SHIFT alongside symbol keys ('?', '+', ...) inconsistently. /// Letters (of any alphabet) keep SHIFT (that is how "shift-g" works); /// symbols drop it so a "?" binding matches everywhere. fn normalize(key: KeyCombination) -> KeyCombination { if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes && !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) { return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT); } key } /// The Latin character on the same physical key in the standard ЙЦУКЕН /// layout (lowercase in, lowercase out). fn qwerty_equivalent(c: char) -> Option { Some(match c { 'й' => 'q', 'ц' => 'w', 'у' => 'e', 'к' => 'r', 'е' => 't', 'н' => 'y', 'г' => 'u', 'ш' => 'i', 'щ' => 'o', 'з' => 'p', 'х' => '[', 'ъ' => ']', 'ф' => 'a', 'ы' => 's', 'в' => 'd', 'а' => 'f', 'п' => 'g', 'р' => 'h', 'о' => 'j', 'л' => 'k', 'д' => 'l', 'ж' => ';', 'э' => '\'', 'я' => 'z', 'ч' => 'x', 'с' => 'c', 'м' => 'v', 'и' => 'b', 'т' => 'n', 'ь' => 'm', 'б' => ',', 'ю' => '.', 'ё' => '`', _ => return None, }) } pub fn user_keymap_path() -> Option { crate::config::project_dirs().map(|dirs| dirs.config_dir().join("keymap.toml")) } fn parse_bindings(text: &str) -> Result> { let file: KeymapFile = toml::from_str(text).context("invalid TOML")?; file.keymaps .into_iter() .map(|raw| { let keys = parse_sequence(&raw.key_sequence) .with_context(|| format!("bad key_sequence {:?}", raw.key_sequence))?; Ok(Binding { keys, action: raw.command, context: raw.context, }) }) .collect() } fn parse_sequence(s: &str) -> Result> { let keys: Vec = s .split_whitespace() .map(parse_chord) .collect::>()?; if keys.is_empty() { bail!("empty key sequence"); } Ok(keys) } fn parse_chord(chord: &str) -> Result { // crokey only parses single-byte characters; non-ASCII keys (Cyrillic // bindings) are built directly. let mut chars = chord.chars(); if let (Some(c), None) = (chars.next(), chars.next()) && !c.is_ascii() { let modifiers = if c.is_uppercase() { KeyModifiers::SHIFT } else { KeyModifiers::NONE }; return Ok(KeyCombination::new(KeyCode::Char(c), modifiers)); } KeyCombination::from_str(chord) .map_err(|e| anyhow::anyhow!("{e}")) .map(normalize) } fn merge(bindings: &mut Vec, user: Vec) { for b in user { bindings.retain(|d| !(d.keys == b.keys && d.context == b.context)); bindings.push(b); } } #[cfg(test)] mod tests { use super::*; use crokey::key; fn keymap_from(toml: &str) -> Keymap { Keymap { bindings: parse_bindings(toml).unwrap(), pending: Vec::new(), format: KeyCombinationFormat::default(), } } #[test] fn default_keymap_parses() { let bindings = parse_bindings(DEFAULT_KEYMAP).unwrap(); assert!(bindings.len() > 20); } #[test] fn single_key_resolves() { let mut km = keymap_from(DEFAULT_KEYMAP); assert_eq!( km.resolve(key!(q), KeyContext::Library), KeyResolution::Action(Action::Quit) ); } #[test] fn chord_sequence_resolves() { let mut km = keymap_from(DEFAULT_KEYMAP); assert!(matches!( km.resolve(key!(g), KeyContext::Library), KeyResolution::Pending(_) )); assert_eq!( km.resolve(key!(g), KeyContext::Library), KeyResolution::Action(Action::SelectFirst) ); } #[test] fn aborted_chord_retries_last_key() { let mut km = keymap_from(DEFAULT_KEYMAP); km.resolve(key!(g), KeyContext::Library); assert_eq!( km.resolve(key!(q), KeyContext::Library), KeyResolution::Action(Action::Quit) ); } #[test] fn esc_clears_pending_chord() { let mut km = keymap_from(DEFAULT_KEYMAP); km.resolve(key!(g), KeyContext::Library); assert_eq!( km.resolve(key!(esc), KeyContext::Library), KeyResolution::Unmatched ); // Esc with no pending chord is a normal binding (Back). assert_eq!( km.resolve(key!(esc), KeyContext::Library), KeyResolution::Action(Action::Back) ); } #[test] fn context_binding_shadows_global() { let mut km = keymap_from( r#" [[keymaps]] key_sequence = "n" command = "NextTrack" [[keymaps]] key_sequence = "n" command = "MoveDown" context = "search" "#, ); assert_eq!( km.resolve(key!(n), KeyContext::Search), KeyResolution::Action(Action::MoveDown) ); assert_eq!( km.resolve(key!(n), KeyContext::Library), KeyResolution::Action(Action::NextTrack) ); } #[test] fn default_playlist_context_keeps_next_track_on_n() { let mut km = keymap_from(DEFAULT_KEYMAP); assert_eq!( km.resolve(key!(n), KeyContext::Playlists), KeyResolution::Action(Action::NextTrack) ); } #[test] fn default_source_mode_key_resolves_on_content_tabs() { let mut km = keymap_from(DEFAULT_KEYMAP); for context in [ KeyContext::Library, KeyContext::Playlists, KeyContext::Queue, ] { assert_eq!( km.resolve(key!(m), context), KeyResolution::Action(Action::CycleSourceMode) ); } } #[test] fn default_shift_n_is_unbound() { let mut km = keymap_from(DEFAULT_KEYMAP); let shift_n = KeyCombination::new(KeyCode::Char('N'), KeyModifiers::SHIFT); assert_eq!( km.resolve(shift_n, KeyContext::Library), KeyResolution::Unmatched ); assert_eq!( km.resolve(shift_n, KeyContext::Playlists), KeyResolution::Unmatched ); } #[test] fn default_add_to_playlist_key_is_global() { let mut km = keymap_from(DEFAULT_KEYMAP); let shift_p = KeyCombination::new(KeyCode::Char('P'), KeyModifiers::SHIFT); assert_eq!( km.resolve(shift_p, KeyContext::Library), KeyResolution::Action(Action::AddToPlaylist) ); } #[test] fn default_current_track_info_key_resolves() { let mut km = keymap_from(DEFAULT_KEYMAP); let shift_i = KeyCombination::new(KeyCode::Char('I'), KeyModifiers::SHIFT); assert_eq!( km.resolve(shift_i, KeyContext::Library), KeyResolution::Action(Action::OpenCurrentTrackInfo) ); } #[test] fn default_visualizer_key_resolves() { let mut km = keymap_from(DEFAULT_KEYMAP); let shift_l = KeyCombination::new(KeyCode::Char('L'), KeyModifiers::SHIFT); assert_eq!( km.resolve(shift_l, KeyContext::Library), KeyResolution::Action(Action::ToggleVisualizer) ); } #[test] fn settings_context_keeps_federation_alias() { let settings = parse_bindings( r#" [[keymaps]] key_sequence = "z" command = "ToggleHelp" context = "settings" [[keymaps]] key_sequence = "x" command = "ToggleHelp" context = "federation" "#, ) .unwrap(); assert_eq!(settings[0].context, KeyContext::Federation); assert_eq!(settings[1].context, KeyContext::Federation); } #[test] fn user_binding_overrides_default() { let mut bindings = parse_bindings(DEFAULT_KEYMAP).unwrap(); let user = parse_bindings( r#" [[keymaps]] key_sequence = "q" command = "Back" "#, ) .unwrap(); merge(&mut bindings, user); let mut km = Keymap { bindings, pending: Vec::new(), format: KeyCombinationFormat::default(), }; assert_eq!( km.resolve(key!(q), KeyContext::Library), KeyResolution::Action(Action::Back) ); } #[test] fn shift_symbol_normalizes() { let mut km = keymap_from(DEFAULT_KEYMAP); let question_with_shift = KeyCombination::new(KeyCode::Char('?'), KeyModifiers::SHIFT); assert_eq!( km.resolve(question_with_shift, KeyContext::Library), KeyResolution::Action(Action::ToggleHelp) ); } #[test] fn russian_layout_maps_to_physical_keys() { let mut km = keymap_from(DEFAULT_KEYMAP); // physical J → 'о' in ЙЦУКЕН let o = KeyCombination::new(KeyCode::Char('о'), KeyModifiers::NONE); assert_eq!( km.resolve(o, KeyContext::Library), KeyResolution::Action(Action::MoveDown) ); // physical Shift+G → 'П' let cap_pe = KeyCombination::new(KeyCode::Char('П'), KeyModifiers::SHIFT); assert_eq!( km.resolve(cap_pe, KeyContext::Library), KeyResolution::Action(Action::SelectLast) ); // chord: 'п п' = physical "g g" let pe = KeyCombination::new(KeyCode::Char('п'), KeyModifiers::NONE); assert!(matches!( km.resolve(pe, KeyContext::Library), KeyResolution::Pending(_) )); assert_eq!( km.resolve(pe, KeyContext::Library), KeyResolution::Action(Action::SelectFirst) ); // punctuation positions: 'ю' sits on the '.' key (SeekForward) let yu = KeyCombination::new(KeyCode::Char('ю'), KeyModifiers::NONE); assert_eq!( km.resolve(yu, KeyContext::Library), KeyResolution::Action(Action::SeekForward { seconds: 10 }) ); } #[test] fn explicit_cyrillic_binding_wins_over_layout_fallback() { let mut km = keymap_from( r#" [[keymaps]] key_sequence = "о" command = "Quit" "#, ); let o = KeyCombination::new(KeyCode::Char('о'), KeyModifiers::NONE); assert_eq!( km.resolve(o, KeyContext::Library), KeyResolution::Action(Action::Quit) ); } #[test] fn parameterized_command_parses() { let mut km = keymap_from(DEFAULT_KEYMAP); let dot: KeyCombination = ".".parse().unwrap(); assert_eq!( km.resolve(dot, KeyContext::Library), KeyResolution::Action(Action::SeekForward { seconds: 10 }) ); } #[test] fn default_listening_history_key_resolves() { let mut km = keymap_from(DEFAULT_KEYMAP); assert_eq!( km.resolve(key!(shift - h), KeyContext::Library), KeyResolution::Action(Action::OpenListenHistory) ); } }