diff options
Diffstat (limited to 'src/config/keybinds.rs')
| -rw-r--r-- | src/config/keybinds.rs | 209 |
1 files changed, 180 insertions, 29 deletions
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 5f4b46de..903f85ca 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -10,12 +10,38 @@ use crate::popup_size::PopupSize; pub type KeyCombo = (KeyCode, KeyModifiers); +/// Built-in prefix used when `keys.prefix` is unset or invalid. +pub(crate) const DEFAULT_PREFIX: KeyCombo = (KeyCode::Char('b'), KeyModifiers::CONTROL); + #[derive(Debug, Clone)] pub struct LiveKeybindConfig { - pub prefix: KeyCombo, + /// Every configured prefix key. The first entry is the primary prefix used + /// for compact display; all entries enter prefix mode. + pub prefix: Vec<KeyCombo>, pub keybinds: Keybinds, } +impl LiveKeybindConfig { + /// Whether `key` is one of the configured prefix keys. + pub fn matches_prefix(&self, key: &TerminalKey) -> bool { + self.prefix + .iter() + .any(|combo| terminal_key_matches_combo(key, *combo)) + } + + /// Primary prefix combo, used for the compact status bar. + pub fn primary_prefix(&self) -> Option<KeyCombo> { + self.prefix.first().copied() + } + + /// Primary prefix rendered for the compact status bar. + pub fn primary_prefix_label(&self) -> String { + self.primary_prefix() + .map(format_key_combo) + .unwrap_or_else(|| format_key_combo(DEFAULT_PREFIX)) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(untagged)] pub enum BindingConfig { @@ -45,6 +71,13 @@ impl BindingConfig { } } + pub(crate) fn into_values(self) -> Vec<String> { + match self { + Self::One(value) => vec![value], + Self::Many(values) => values, + } + } + pub(crate) fn has_values(&self) -> bool { self.values().iter().any(|value| !value.trim().is_empty()) } @@ -397,16 +430,19 @@ struct RegisteredBinding { } struct BindingRegistry { - prefix_combo: KeyCombo, + prefix_combos: Vec<KeyCombo>, prefix_source: BindingSource, direct: std::collections::HashMap<KeyCombo, RegisteredBinding>, prefix: std::collections::HashMap<KeyCombo, RegisteredBinding>, } impl BindingRegistry { - fn new(prefix_combo: KeyCombo, prefix_source: BindingSource) -> Self { + fn new(prefix_combos: Vec<KeyCombo>, prefix_source: BindingSource) -> Self { + let mut normalized: Vec<KeyCombo> = + prefix_combos.into_iter().map(normalize_key_combo).collect(); + normalized.dedup(); Self { - prefix_combo: normalize_key_combo(prefix_combo), + prefix_combos: normalized, prefix_source, direct: std::collections::HashMap::new(), prefix: std::collections::HashMap::new(), @@ -422,8 +458,18 @@ impl BindingRegistry { }); } + /// Reserve every configured prefix key as a direct/modifier key so no + /// action can hijack it before prefix mode starts. + fn reserve_prefix_keys(&mut self, field: &str, source: BindingSource) { + let combos = self.prefix_combos.clone(); + for combo in combos { + self.reserve_direct(combo, field, source); + } + } + fn prefix_rhs_is_reserved(&self, combo: KeyCombo) -> bool { - normalize_key_combo(combo) == self.prefix_combo + let combo = normalize_key_combo(combo); + self.prefix_combos.contains(&combo) } fn conflict(&self, binding: &ResolvedBinding) -> Option<&RegisteredBinding> { @@ -450,27 +496,28 @@ impl BindingRegistry { } impl Config { - pub(super) fn validated_keybinds(&self) -> (Option<String>, KeyCombo, Vec<String>, Keybinds) { - let mut diagnostics = Vec::new(); - let (prefix, prefix_diag) = parse_key_combo_with_diagnostic( - &self.keys.prefix, - "keys.prefix", - (KeyCode::Char('b'), KeyModifiers::CONTROL), - ); + pub(super) fn validated_keybinds( + &self, + ) -> (Option<String>, Vec<KeyCombo>, Vec<String>, Keybinds) { + let (prefix_keys, prefix_diag, mut diagnostics) = parse_prefix_keys(&self.keys.prefix); if let Some(diag) = &prefix_diag { warn!(message = %diag, "config diagnostic"); } + for diag in &diagnostics { + warn!(message = %diag, "config diagnostic"); + } let prefix_source = if self.keys.key_field_is_user_configured("prefix") { BindingSource::User } else { BindingSource::Default }; - let mut registry = BindingRegistry::new(prefix, prefix_source); - registry.reserve_direct(prefix, "keys.prefix", prefix_source); - let mut navigate_registry = BindingRegistry::new(prefix, prefix_source); - navigate_registry.reserve_direct(prefix, "keys.prefix", prefix_source); + let mut registry = BindingRegistry::new(prefix_keys.clone(), prefix_source); + registry.reserve_prefix_keys("keys.prefix", prefix_source); + let mut navigate_registry = BindingRegistry::new(prefix_keys.clone(), prefix_source); + navigate_registry.reserve_prefix_keys("keys.prefix", prefix_source); reserve_navigate_runtime_keys(&mut navigate_registry); + let prefix = prefix_keys; macro_rules! empty_action { () => { @@ -1306,19 +1353,45 @@ fn single_key_char(s: &str) -> Option<char> { } } -fn parse_key_combo_with_diagnostic( - s: &str, - field: &str, - fallback: KeyCombo, -) -> (KeyCombo, Option<String>) { - match parse_key_combo(s) { - Some(binding) => (binding, None), - None => { - let diag = format!("invalid keybinding: {field} = {s:?}; using fallback"); - warn!(message = %diag, "config diagnostic"); - (fallback, Some(diag)) +/// Parse the configured prefix keys. Returns the effective list, an optional +/// rejection diagnostic (used when nothing valid remains, so the caller keeps +/// the previous keybinds), and per-entry diagnostics. +fn parse_prefix_keys(config: &BindingConfig) -> (Vec<KeyCombo>, Option<String>, Vec<String>) { + let mut combos: Vec<KeyCombo> = Vec::new(); + let mut diagnostics = Vec::new(); + + for raw in config.values() { + let raw = raw.trim(); + match parse_key_combo(raw) { + Some(combo) if !combos.contains(&combo) => combos.push(combo), + Some(_) => {} + None => diagnostics.push(format!( + "invalid keybinding: keys.prefix = {raw:?}; ignoring prefix" + )), } } + + if combos.is_empty() { + let reject = Some(format!( + "invalid keybinding: keys.prefix; using fallback {}", + format_key_combo(DEFAULT_PREFIX) + )); + return (vec![DEFAULT_PREFIX], reject, diagnostics); + } + + (combos, None, diagnostics) +} + +/// Render all configured prefix keys for the help panel. +pub fn format_prefix_combos(prefixes: &[KeyCombo]) -> String { + if prefixes.is_empty() { + return format_key_combo(DEFAULT_PREFIX); + } + prefixes + .iter() + .map(|combo| format_key_combo(*combo)) + .collect::<Vec<_>>() + .join(" / ") } pub fn normalize_key_combo((mut code, mut modifiers): KeyCombo) -> KeyCombo { @@ -1532,8 +1605,8 @@ prefix = "ö" ) .unwrap(); assert_eq!( - config.prefix_key(), - (KeyCode::Char('ö'), KeyModifiers::empty()) + config.prefix_keys(), + vec![(KeyCode::Char('ö'), KeyModifiers::empty())] ); assert!(config.collect_diagnostics().is_empty()); } @@ -1822,6 +1895,84 @@ help = "prefix+ctrl+b" } #[test] + fn multiple_prefix_keys_parse_reserve_and_share_prefix_bindings() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+space", "ctrl+s"] +help = "prefix+?" +"#, + ) + .unwrap(); + assert_eq!( + config.prefix_keys(), + vec![ + (KeyCode::Char(' '), KeyModifiers::CONTROL), + (KeyCode::Char('s'), KeyModifiers::CONTROL), + ] + ); + assert!(config.collect_diagnostics().is_empty()); + + // A prefix action works regardless of which prefix key started prefix mode. + let help = &config.keybinds().help; + assert!( + help.matches_prefix_key(&TerminalKey::new(KeyCode::Char('?'), KeyModifiers::empty())) + ); + + // Every prefix key is reserved as a prefix-mode right-hand side. + let reserved: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+space", "ctrl+s"] +help = "prefix+ctrl+s" +"#, + ) + .unwrap(); + assert!(reserved.keybinds().help.bindings.is_empty()); + assert!(reserved + .collect_diagnostics() + .iter() + .any(|diag| diag.contains("reserved keybinding"))); + } + + #[test] + fn invalid_prefix_entries_are_dropped_while_valid_ones_survive() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+a", "wat"] +"#, + ) + .unwrap(); + assert_eq!( + config.prefix_keys(), + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] + ); + assert!(config + .collect_diagnostics() + .iter() + .any(|diag| diag.contains("keys.prefix") && diag.contains("ignoring prefix"))); + } + + #[test] + fn empty_prefix_list_is_rejected_and_keeps_the_fallback() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = [] +"#, + ) + .unwrap(); + assert_eq!(config.prefix_keys(), vec![DEFAULT_PREFIX]); + assert!(config + .collect_diagnostics() + .iter() + .any(|diag| diag.contains("keys.prefix"))); + // A reload treats the empty list as invalid and keeps the current keybinds. + assert!(config.live_keybinds_with_diagnostics().is_err()); + } + + #[test] fn navigate_bindings_allow_plain_keys_and_reject_local_conflicts() { let config: Config = toml::from_str( r#" |
