Files
furumi_tui/src/config/keymap.rs
T

600 lines
18 KiB
Rust
Raw Normal View History

2026-06-10 16:11:09 +01:00
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,
2026-07-23 18:48:58 +03:00
#[serde(rename = "settings", alias = "federation")]
2026-07-16 20:29:51 +03:00
Federation,
2026-06-10 16:23:20 +01:00
Logs,
2026-06-10 16:11:09 +01:00
}
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",
2026-07-23 18:48:58 +03:00
KeyContext::Federation => "settings",
2026-06-10 16:23:20 +01:00
KeyContext::Logs => "logs",
2026-06-10 16:11:09 +01:00
}
}
}
#[derive(Debug, Clone)]
pub struct Binding {
pub keys: Vec<KeyCombination>,
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<RawBinding>,
}
#[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<Binding>,
pending: Vec<KeyCombination>,
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<String>) {
let mut bindings =
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
let mut warning = None;
2026-07-16 20:29:51 +03:00
if let Some(path) = user_keymap_path()
2026-07-21 00:12:45 +03:00
&& 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()
));
2026-06-10 16:11:09 +01:00
}
}
2026-07-21 00:12:45 +03:00
}
2026-06-10 16:11:09 +01:00
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)> {
2026-06-10 16:11:09 +01:00
self.bindings
.iter()
.map(|b| (self.format_keys(&b.keys), b.action.clone(), b.context))
2026-06-10 16:11:09 +01:00
.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::<Vec<_>>()
.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 {
2026-07-16 20:29:51 +03:00
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes
2026-07-21 00:12:45 +03:00
&& !c.is_alphabetic()
&& key.modifiers.contains(KeyModifiers::SHIFT)
{
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
}
2026-06-10 16:11:09 +01:00
key
}
/// The Latin character on the same physical key in the standard ЙЦУКЕН
/// layout (lowercase in, lowercase out).
fn qwerty_equivalent(c: char) -> Option<char> {
Some(match c {
'й' => 'q',
'ц' => 'w',
'у' => 'e',
'к' => 'r',
'е' => 't',
'н' => 'y',
'г' => 'u',
'ш' => 'i',
'щ' => 'o',
'з' => 'p',
'х' => '[',
'ъ' => ']',
'ф' => 'a',
'ы' => 's',
'в' => 'd',
'а' => 'f',
'п' => 'g',
'р' => 'h',
'о' => 'j',
'л' => 'k',
'д' => 'l',
'ж' => ';',
2026-06-10 16:11:09 +01:00
'э' => '\'',
'я' => 'z',
'ч' => 'x',
'с' => 'c',
'м' => 'v',
'и' => 'b',
'т' => 'n',
'ь' => 'm',
'б' => ',',
'ю' => '.',
2026-06-10 16:11:09 +01:00
'ё' => '`',
_ => return None,
})
}
pub fn user_keymap_path() -> Option<PathBuf> {
crate::config::project_dirs().map(|dirs| dirs.config_dir().join("keymap.toml"))
}
fn parse_bindings(text: &str) -> Result<Vec<Binding>> {
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<Vec<KeyCombination>> {
let keys: Vec<KeyCombination> = s
.split_whitespace()
.map(parse_chord)
.collect::<Result<_>>()?;
if keys.is_empty() {
bail!("empty key sequence");
}
Ok(keys)
}
fn parse_chord(chord: &str) -> Result<KeyCombination> {
// crokey only parses single-byte characters; non-ASCII keys (Cyrillic
// bindings) are built directly.
let mut chars = chord.chars();
2026-07-16 20:29:51 +03:00
if let (Some(c), None) = (chars.next(), chars.next())
2026-07-21 00:12:45 +03:00
&& !c.is_ascii()
{
let modifiers = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
}
2026-06-10 16:11:09 +01:00
KeyCombination::from_str(chord)
.map_err(|e| anyhow::anyhow!("{e}"))
.map(normalize)
}
fn merge(bindings: &mut Vec<Binding>, user: Vec<Binding>) {
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)
);
2026-07-23 17:35:11 +03:00
}
#[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)
);
}
2026-07-27 14:13:56 +01:00
#[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)
);
}
}
2026-07-23 17:35:11 +03:00
#[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)
);
2026-07-23 18:48:58 +03:00
}
#[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);
2026-06-10 16:11:09 +01:00
}
#[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);
2026-06-10 16:11:09 +01:00
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 })
);
}
}