diff options
| -rw-r--r-- | docs/next/website/src/content/docs/configuration.mdx | 9 | ||||
| -rw-r--r-- | docs/next/website/src/data/config-reference.json | 4 | ||||
| -rw-r--r-- | src/app/mod.rs | 39 | ||||
| -rw-r--r-- | src/app/state.rs | 6 | ||||
| -rw-r--r-- | src/client/shell/config.rs | 12 | ||||
| -rw-r--r-- | src/client/shell/input.rs | 10 | ||||
| -rw-r--r-- | src/client/shell/overlays.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/render.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/tests/copy.rs | 4 | ||||
| -rw-r--r-- | src/client/shell/tests/input.rs | 25 | ||||
| -rw-r--r-- | src/client/shell/tests/keybindings_settings.rs | 4 | ||||
| -rw-r--r-- | src/config.rs | 45 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 209 | ||||
| -rw-r--r-- | src/config/model.rs | 43 | ||||
| -rw-r--r-- | src/input/keybind_help.rs | 18 | ||||
| -rw-r--r-- | src/server/keybindings.rs | 5 |
16 files changed, 347 insertions, 90 deletions
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 1a0ce53c..33644e1c 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -142,6 +142,15 @@ navigate_pane_down = "ctrl+j" split_horizontal = "prefix+minus" ``` +`keys.prefix` also accepts an array when you need to enter prefix mode from more than one key, for example to share one config between macOS and a remote Linux machine: + +```toml +[keys] +prefix = ["ctrl+space", "ctrl+s"] +``` + +Every configured prefix enters the same prefix mode and starts every `prefix+...` action. The first entry is the primary prefix shown in the status bar; the in-app help panel at `prefix+?` lists all of them. + The default keymap is prefix-first so Herdr does not steal input from shells, editors, tmux, or terminal apps. Search `keys.` in the [Config reference](/docs/config-reference/) to see every action and default binding. The in-app help panel at `prefix+?` shows the active bindings. A binding may also be an array when one action needs multiple shortcuts: diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 299c2ced..eb7e08d4 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -477,9 +477,9 @@ "keys": [ { "key": "keys.prefix", - "type": "string", + "type": "keybinding", "default": "\"ctrl+b\"", - "description": "Prefix key to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\")." + "description": "Prefix key(s) to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\", or an array to accept several)." }, { "key": "keys.help", diff --git a/src/app/mod.rs b/src/app/mod.rs index e358a562..8fad961c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -361,7 +361,7 @@ impl App { api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, event_hub: crate::api::EventHub, ) -> Self { - let (prefix_code, prefix_mods) = config.prefix_key(); + let prefix_keys = config.prefix_keys(); crate::kitty_graphics::set_enabled(config.kitty_graphics_enabled()); let (event_tx, event_rx) = mpsc::channel::<AppEvent>(APP_EVENT_CHANNEL_CAPACITY); let render_notify = Arc::new(Notify::new()); @@ -479,8 +479,7 @@ impl App { toast: None, pending_agent_notifications: std::collections::HashMap::new(), outer_terminal_focus: None, - prefix_code, - prefix_mods, + prefix_keys, headless_size: config.headless_size(), agent_panel_sort, agent_view_override: None, @@ -799,8 +798,7 @@ impl App { if !invalid_section("keys") { match config.live_keybinds_with_diagnostics() { Ok((live, keybind_diagnostics)) => { - self.state.prefix_code = live.prefix.0; - self.state.prefix_mods = live.prefix.1; + self.state.prefix_keys = live.prefix; self.state.keybinds = live.keybinds; match config.local_keybindings_profile_toml() { Ok(profile) => self.client_shell_keybindings_profile = Some(profile), @@ -1706,8 +1704,10 @@ mod tests { assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); assert_eq!(app.state.headless_size, (160, 50)); - assert_eq!(app.state.prefix_code, KeyCode::Char('a')); - assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL); + assert_eq!( + app.state.prefix_keys, + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] + ); assert!(app .state .keybinds @@ -1804,7 +1804,10 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.prefix_code, KeyCode::Char('a')); + assert_eq!( + app.state.prefix_keys, + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] + ); assert!(app.state.request_client_config_reload); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -1959,17 +1962,14 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let original_prefix = (app.state.prefix_code, app.state.prefix_mods); + let original_prefix = app.state.prefix_keys.clone(); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); assert!(report.diagnostics.iter().any(|diagnostic| { diagnostic.contains("keys.new_workspace") && diagnostic.contains("disabling binding") })); - assert_eq!( - (app.state.prefix_code, app.state.prefix_mods), - original_prefix - ); + assert_eq!(app.state.prefix_keys, original_prefix); assert!(app.state.keybinds.new_workspace.bindings.is_empty()); assert_eq!( app.state.toast_config.delivery, @@ -2027,8 +2027,10 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.prefix_code, KeyCode::Char(' ')); - assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL); + assert_eq!( + app.state.prefix_keys, + vec![(KeyCode::Char(' '), KeyModifiers::CONTROL)] + ); assert!(app .state .keybinds @@ -2117,16 +2119,13 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let original_prefix = (app.state.prefix_code, app.state.prefix_mods); + let original_prefix = app.state.prefix_keys.clone(); let original_keybinds = app.state.keybinds.new_workspace.clone(); let original_toast_delivery = app.state.toast_config.delivery; let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Failed); - assert_eq!( - (app.state.prefix_code, app.state.prefix_mods), - original_prefix - ); + assert_eq!(app.state.prefix_keys, original_prefix); assert_eq!(app.state.keybinds.new_workspace, original_keybinds); assert_eq!(app.state.toast_config.delivery, original_toast_delivery); assert!(app diff --git a/src/app/state.rs b/src/app/state.rs index 892ec5e9..65c06a6c 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -820,8 +820,7 @@ pub struct AppState { /// None means unsupported or not yet reported, which preserves active-pane suppression. pub outer_terminal_focus: Option<bool>, // Config - pub prefix_code: KeyCode, - pub prefix_mods: KeyModifiers, + pub prefix_keys: Vec<(KeyCode, KeyModifiers)>, /// Virtual terminal size (columns, rows) used when no client is attached. pub(crate) headless_size: (u16, u16), pub agent_panel_sort: AgentPanelSort, @@ -1046,8 +1045,7 @@ impl AppState { toast: None, pending_agent_notifications: std::collections::HashMap::new(), outer_terminal_focus: None, - prefix_code: KeyCode::Char('b'), - prefix_mods: KeyModifiers::CONTROL, + prefix_keys: vec![(KeyCode::Char('b'), KeyModifiers::CONTROL)], headless_size: ( crate::config::DEFAULT_HEADLESS_COLS, crate::config::DEFAULT_HEADLESS_ROWS, diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs index 2d16b00f..bfa1fb2f 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -139,7 +139,7 @@ impl ClientShellConfig { .live_keybinds_with_diagnostics() .map(|(keybinds, _diagnostics)| keybinds) .unwrap_or_else(|_diagnostics| LiveKeybindConfig { - prefix: config.prefix_key(), + prefix: config.prefix_keys(), keybinds: config.keybinds(), }), local_keys: config.keys.clone(), @@ -459,7 +459,7 @@ mod tests { next.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; next.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; next.ui.sidebar.agents = toml::from_str("rows = [[{ token = 'machine', rules = [{ equals = 'Local', bold = true }] }]]\nrow_gap = 2").unwrap(); - next.keys.prefix = "ctrl+a".to_owned(); + next.keys.prefix = crate::config::BindingConfig::one("ctrl+a"); let diagnostics = shell.apply_live_config(&next, &[], &[]); @@ -491,7 +491,7 @@ mod tests { assert_eq!(shell.agents, previous); assert_eq!( shell.keybinds.prefix, - (KeyCode::Char('a'), KeyModifiers::CONTROL) + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] ); } @@ -523,19 +523,19 @@ mod tests { fn live_reload_preserves_invalid_client_owned_sections() { let mut initial = Config::default(); initial.ui.sidebar_width = 29; - initial.keys.prefix = "ctrl+x".to_owned(); + initial.keys.prefix = crate::config::BindingConfig::one("ctrl+x"); let mut shell = ClientShellConfig::from_config(&initial); let mut invalid = Config::default(); invalid.ui.sidebar_width = 35; - invalid.keys.prefix = "ctrl+a".to_owned(); + invalid.keys.prefix = crate::config::BindingConfig::one("ctrl+a"); let invalid_sections = vec!["ui".to_owned(), "keys".to_owned()]; shell.apply_live_config(&invalid, &[], &invalid_sections); assert_eq!(shell.sidebar_width, 29); assert_eq!( shell.keybinds.prefix, - (KeyCode::Char('x'), KeyModifiers::CONTROL) + vec![(KeyCode::Char('x'), KeyModifiers::CONTROL)] ); } } diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 0e14e18c..413e29f5 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -569,7 +569,7 @@ impl ClientShellState { self.record_binding(binding, outcome); return None; } - if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + if self.config.keybinds.matches_prefix(key) { self.mode = ClientShellMode::Prefix; outcome.repaint = true; return None; @@ -584,7 +584,7 @@ impl ClientShellState { } else { ClientShellMode::Terminal }; - if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + if self.config.keybinds.matches_prefix(key) { self.mode = return_mode; outcome.repaint = true; return self.focused_pane_id().map(ClientInputTarget::Pane); @@ -619,7 +619,7 @@ impl ClientShellState { .copy_mode .as_ref() .is_none_or(|copy_mode| copy_mode.search_prompt.is_none()) - && crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) + && self.config.keybinds.matches_prefix(key) { self.mode = ClientShellMode::Prefix; outcome.repaint = true; @@ -649,9 +649,7 @@ impl ClientShellState { use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch}; self.pending_workspace_highlight = None; - if key.code == KeyCode::Esc - || crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) - { + if key.code == KeyCode::Esc || self.config.keybinds.matches_prefix(key) { self.mode = self.copy_or_terminal_mode(); self.navigate_workspace_id = None; outcome.repaint = true; diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index c8eee5f4..7116713b 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -1044,7 +1044,7 @@ fn help_lines( use ratatui::text::{Line, Span}; let groups = crate::input::filter_keybind_help_groups( - crate::input::keybind_help_groups(&keybinds.keybinds, keybinds.prefix), + crate::input::keybind_help_groups(&keybinds.keybinds, &keybinds.prefix), query, ); let key_width = groups diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index 82cb2ac4..248216c1 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -68,7 +68,7 @@ pub(super) fn render_mode_bar( palette.accent }) .add_modifier(Modifier::BOLD); - let prefix = crate::config::format_key_combo(keybinds.prefix); + let prefix = keybinds.primary_prefix_label(); let prefix_rhs = |bindings: &crate::config::ActionKeybinds| { bindings .prefix_rhs_label() diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index c0d528e1..317950cf 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -1856,7 +1856,7 @@ fn copy_mode_survives_mouse_motion_and_parks_across_focus_changes() { .as_ref() .is_some_and(|copy_mode| copy_mode.selection.is_some())); - let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix[0]; state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( prefix_key, prefix_modifiers, @@ -2038,7 +2038,7 @@ fn queued_copy_keys_preserve_prefix_order() { let origin = state.copy_mode.as_ref().expect("copy mode").cursor; let motion = state.handle_input_bytes(b"w"); state.handle_input_bytes(b"l"); - let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix[0]; state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( prefix_key, prefix_modifiers, diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs index b54548f2..145c7ae6 100644 --- a/src/client/shell/tests/input.rs +++ b/src/client/shell/tests/input.rs @@ -650,3 +650,28 @@ fn styled_client_composition_preserves_pane_hyperlinks() { let link = frame.cells[index].hyperlink.expect("linked cell") as usize; assert_eq!(frame.hyperlinks[link], "https://example.test"); } + +#[test] +fn every_configured_prefix_enters_prefix_mode() { + let mut config = Config::default(); + config.keys.prefix = + crate::config::BindingConfig::Many(vec!["ctrl+space".to_owned(), "ctrl+s".to_owned()]); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + + for combo in [ + (KeyCode::Char(' '), KeyModifiers::CONTROL), + (KeyCode::Char('s'), KeyModifiers::CONTROL), + ] { + let _ = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + combo.0, combo.1, + ))]); + assert_eq!(state.mode, ClientShellMode::Prefix); + + let _ = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + } +} diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index e1b43a01..dd60fe11 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -282,7 +282,7 @@ command = "local-only" .unwrap(); let remote_local = ClientShellConfig::from_config(&local) .with_keybinding_source(ClientShellKeybindingSource::RemoteLocal); - assert_eq!(remote_local.keybinds.prefix.0, KeyCode::Char('a')); + assert_eq!(remote_local.keybinds.prefix[0].0, KeyCode::Char('a')); assert!(remote_local.keybinds.keybinds.custom_commands.is_empty()); assert_eq!( remote_local.keybinds.keybinds.new_tab.label().as_deref(), @@ -377,7 +377,7 @@ new_tab = "prefix+n" }); state.set_snapshot(Box::new(projection)); - assert_eq!(state.config.keybinds.prefix.0, KeyCode::Char('x')); + assert_eq!(state.config.keybinds.prefix[0].0, KeyCode::Char('x')); assert_eq!( state.config.keybinds.keybinds.new_tab.label().as_deref(), Some("prefix+n") diff --git a/src/config.rs b/src/config.rs index 60a22a1e..8550097d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,9 +17,9 @@ pub use self::{ upsert_section_value, }, keybinds::{ - format_key_combo, normalize_key_combo, terminal_key_matches_combo, ActionKeybinds, + format_prefix_combos, normalize_key_combo, terminal_key_matches_combo, ActionKeybinds, BindingConfig, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, - IndexedKeybind, Keybinds, LiveKeybindConfig, + IndexedKeybind, KeyCombo, Keybinds, LiveKeybindConfig, }, model::{ validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport, @@ -102,7 +102,7 @@ impl Config { .unwrap_or(true) } - pub fn prefix_key(&self) -> (KeyCode, KeyModifiers) { + pub fn prefix_keys(&self) -> Vec<(KeyCode, KeyModifiers)> { self.validated_keybinds().1 } @@ -182,7 +182,7 @@ impl Config { } let mut keys = self.keys.local_profile(&self.keybinds()); - keys.set_prefix(format_key_combo(self.prefix_key())); + keys.set_prefixes(&self.prefix_keys()); toml::to_string_pretty(&KeysProfile { keys }) } } @@ -239,7 +239,42 @@ prefix = "ctrl+" let keybinds = keybindings_from_profile_toml(&profile).unwrap(); assert!(profile.contains("prefix = \"ctrl+b\"")); - assert_eq!(keybinds.prefix, config.prefix_key()); + assert_eq!(keybinds.prefix, config.prefix_keys()); + } + + #[test] + fn local_keybindings_profile_publishes_additional_prefixes_for_old_clients() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+space", "ctrl+s"] +"#, + ) + .unwrap(); + + let profile = config.local_keybindings_profile_toml().unwrap(); + + // Generation-1 clients parse `prefix` as a single string; the extra + // prefixes ride in the optional `extra_prefixes` field they ignore. + assert!(profile.contains("prefix = \"ctrl+space\"")); + assert!(!profile.contains("prefix = [")); + assert!(profile.contains("extra_prefixes = [\"ctrl+s\"]")); + + let keybinds = keybindings_from_profile_toml(&profile).unwrap(); + assert_eq!(keybinds.prefix, config.prefix_keys()); + } + + #[test] + fn local_keybindings_profile_omits_extra_prefixes_for_a_single_prefix() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+b" +"#, + ) + .unwrap(); + let profile = config.local_keybindings_profile_toml().unwrap(); + assert!(!profile.contains("extra_prefixes")); } #[test] 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#" diff --git a/src/config/model.rs b/src/config/model.rs index ce59eb2e..0e2e57ce 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -334,8 +334,9 @@ pub struct LoadedConfig { #[derive(Debug, Clone, Serialize)] pub struct KeysConfig { - /// Prefix key to enter prefix mode (e.g. "ctrl+b", "f12", "esc"). - pub prefix: String, + /// Prefix key(s) to enter prefix mode (e.g. "ctrl+b", "f12", "esc", or an + /// array to accept several). + pub prefix: BindingConfig, /// Open keybinding help. Default: "prefix+?" pub help: BindingConfig, /// Open settings. Default: "prefix+s" @@ -467,7 +468,12 @@ pub struct KeysConfig { #[serde(default)] pub(crate) struct KeysConfigOverlay { #[serde(skip_serializing_if = "Option::is_none")] - prefix: Option<String>, + prefix: Option<BindingConfig>, + /// Additional prefix keys published for cross-version compatibility. + /// Older clients parse `prefix` as a single string and ignore this field; + /// new clients merge it into the effective prefix list. + #[serde(skip_serializing_if = "Option::is_none")] + extra_prefixes: Option<BindingConfig>, #[serde(skip_serializing_if = "Option::is_none")] help: Option<BindingConfig>, #[serde(skip_serializing_if = "Option::is_none")] @@ -592,8 +598,13 @@ pub(crate) struct KeysConfigOverlay { } impl KeysConfigOverlay { - pub(crate) fn set_prefix(&mut self, prefix: String) { - self.prefix = Some(prefix); + pub(crate) fn set_prefixes(&mut self, prefixes: &[super::keybinds::KeyCombo]) { + let mut labels = prefixes + .iter() + .map(|combo| super::keybinds::format_key_combo(*combo)); + self.prefix = Some(BindingConfig::One(labels.next().unwrap_or_default())); + let extra: Vec<String> = labels.collect(); + self.extra_prefixes = (!extra.is_empty()).then_some(BindingConfig::Many(extra)); } } @@ -605,6 +616,25 @@ impl<'de> Deserialize<'de> for KeysConfig { let input = KeysConfigOverlay::deserialize(deserializer)?; let mut keys = KeysConfig::default(); + let prefix_was_supplied = input.prefix.is_some() || input.extra_prefixes.is_some(); + let mut prefix_values = Vec::new(); + if let Some(prefix) = input.prefix { + prefix_values.extend(prefix.into_values()); + } + if let Some(extra) = input.extra_prefixes { + prefix_values.extend(extra.into_values()); + } + if prefix_was_supplied { + // An explicitly empty list stays empty so prefix validation rejects + // it and a reload keeps the current keybindings. + keys.prefix = match prefix_values.len() { + 0 => BindingConfig::Many(Vec::new()), + 1 => BindingConfig::One(prefix_values.remove(0)), + _ => BindingConfig::Many(prefix_values), + }; + keys.user_fields.insert("prefix"); + } + macro_rules! apply_field { ($field:ident) => { if let Some(value) = input.$field { @@ -614,7 +644,6 @@ impl<'de> Deserialize<'de> for KeysConfig { }; } - apply_field!(prefix); apply_field!(help); apply_field!(settings); apply_field!(new_workspace); @@ -1088,7 +1117,7 @@ pub struct ExperimentalConfig { impl Default for KeysConfig { fn default() -> Self { Self { - prefix: "ctrl+b".into(), + prefix: BindingConfig::one("ctrl+b"), help: BindingConfig::one("prefix+?"), settings: BindingConfig::one("prefix+s"), new_workspace: BindingConfig::one("prefix+shift+n"), diff --git a/src/input/keybind_help.rs b/src/input/keybind_help.rs index 2f89b861..cb1fdb99 100644 --- a/src/input/keybind_help.rs +++ b/src/input/keybind_help.rs @@ -63,13 +63,13 @@ fn indexed_range_prefix(bindings: &[IndexedKeybind]) -> Option<&str> { pub(crate) fn keybind_help_groups( keybinds: &Keybinds, - prefix: (crossterm::event::KeyCode, crossterm::event::KeyModifiers), + prefixes: &[crate::config::KeyCombo], ) -> Vec<KeybindHelpGroup> { let mut groups = vec![ ( "global", vec![ - entry(crate::config::format_key_combo(prefix), "prefix mode"), + entry(crate::config::format_prefix_combos(prefixes), "prefix mode"), entry(binding_label(&keybinds.help), "keybinds"), entry(binding_label(&keybinds.settings), "settings"), entry(binding_label(&keybinds.detach), "detach"), @@ -267,4 +267,18 @@ mod tests { assert_eq!(filtered[0].1[0].1, "close pane"); assert!(filter_keybind_help_groups(groups(), "panes").is_empty()); } + + #[test] + fn help_lists_every_configured_prefix() { + let groups = keybind_help_groups( + &Keybinds::default(), + &[ + (KeyCode::Char(' '), KeyModifiers::CONTROL), + (KeyCode::Char('s'), KeyModifiers::CONTROL), + ], + ); + let global = &groups[0].1; + assert_eq!(global[0].0, "ctrl+space / ctrl+s"); + assert_eq!(global[0].1, "prefix mode"); + } } diff --git a/src/server/keybindings.rs b/src/server/keybindings.rs index 262463ff..08046ba4 100644 --- a/src/server/keybindings.rs +++ b/src/server/keybindings.rs @@ -2,7 +2,7 @@ use crate::app; pub(crate) fn app_keybindings(app: &app::App) -> crate::config::LiveKeybindConfig { crate::config::LiveKeybindConfig { - prefix: (app.state.prefix_code, app.state.prefix_mods), + prefix: app.state.prefix_keys.clone(), keybinds: app.state.keybinds.clone(), } } @@ -11,7 +11,6 @@ pub(crate) fn apply_keybindings( app: &mut app::App, keybindings: &crate::config::LiveKeybindConfig, ) { - app.state.prefix_code = keybindings.prefix.0; - app.state.prefix_mods = keybindings.prefix.1; + app.state.prefix_keys = keybindings.prefix.clone(); app.state.keybinds = keybindings.keybinds.clone(); } |
