Files
furumi_tui/src/config/keymap.rs
T

609 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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;
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::<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 {
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<char> {
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<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();
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<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)
);
}
#[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)
);
}
}