aboutsummaryrefslogtreecommitdiff
path: root/src/config/keybinds.rs
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-28 11:58:09 +0200
committerGitHub <noreply@github.com>2026-09-28 11:58:09 +0200
commit7f89b11a11160874ba89f3be7c64b29960bd2df3 (patch)
tree0e875b3fbbe5a701cfb0182bda0ce0994267584c /src/config/keybinds.rs
parent0d5d6f1f317e238c8297076bc6ab5c3a0cd56283 (diff)
feat: support multiple prefix keys (#4653)
* feat: support multiple prefix keys keys.prefix accepts an array in addition to a single string so one config can define several prefix keys. Every configured prefix enters the same prefix mode, and the help panel lists them all. Published keybinding profiles keep the primary prefix as a scalar and carry the rest in an optional extra_prefixes field so older clients keep working with the primary prefix. * fix: reject an empty prefix list on reload An explicitly empty keys.prefix array now stays empty through config parsing so validation rejects it and a reload keeps the current keybindings instead of silently falling back to ctrl+b.
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#"