aboutsummaryrefslogtreecommitdiff
path: root/src/config/keybinds.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/config/keybinds.rs')
-rw-r--r--src/config/keybinds.rs209
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#"