diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 18:55:41 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 18:55:41 +0300 |
| commit | 836fd7e585af15cc1ccb0d4f04586ffec28d2a3c (patch) | |
| tree | a3bf07e7af7ffd884a38fce3d65f8c2243bc0615 /src/config | |
| parent | be66f33d2acde30d25794bcead991e5e7b7eaff8 (diff) | |
feat: add keybinding v2
refs #154
refs #201
refs #202
refs #219
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/io.rs | 94 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 2002 | ||||
| -rw-r--r-- | src/config/model.rs | 187 |
3 files changed, 1131 insertions, 1152 deletions
diff --git a/src/config/io.rs b/src/config/io.rs index 07b63d37..12176815 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -293,6 +293,58 @@ pub fn remove_section_key(content: &str, section: &str, key: &str) -> String { result.join("\n") + "\n" } +pub fn remove_keybinding_config_sections(content: &str) -> (String, bool) { + let mut result = Vec::new(); + let mut removed = false; + let mut skipping_key_section = false; + let mut in_table = false; + + for line in content.lines() { + let trimmed = line.trim(); + + if let Some(table_name) = toml_table_header_name(trimmed) { + in_table = true; + skipping_key_section = is_keys_table_name(table_name); + if skipping_key_section { + removed = true; + continue; + } + } else if skipping_key_section || (!in_table && is_top_level_keys_assignment(trimmed)) { + removed = true; + continue; + } + + result.push(line.to_string()); + } + + let mut updated = result.join("\n"); + if content.ends_with('\n') || !updated.is_empty() { + updated.push('\n'); + } + (updated, removed) +} + +fn toml_table_header_name(trimmed: &str) -> Option<&str> { + if let Some(name) = trimmed + .strip_prefix("[[") + .and_then(|value| value.strip_suffix("]]")) + { + return Some(name.trim()); + } + trimmed + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .map(str::trim) +} + +fn is_keys_table_name(name: &str) -> bool { + name == "keys" || name.starts_with("keys.") +} + +fn is_top_level_keys_assignment(trimmed: &str) -> bool { + trimmed.starts_with("keys ") || trimmed.starts_with("keys=") || trimmed.starts_with("keys.") +} + fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String { let header = format!("[{section}]"); let assignment = format!("{key} = {value}"); @@ -381,4 +433,46 @@ mod tests { assert!(updated.contains("delivery = \"herdr\"")); assert!(updated.contains("[ui.sound]\nenabled = true")); } + + #[test] + fn remove_keybinding_config_sections_removes_keys_tables_only() { + let content = r#"onboarding = false + +[theme] +name = "catppuccin" + +[keys] +prefix = "ctrl+a" +new_tab = "c" + +[[keys.command]] +key = "g" +command = "lazygit" + +[keys.indexed] +tabs = "ctrl" + +[ui] +mouse_capture = false +"#; + + let (updated, removed) = remove_keybinding_config_sections(content); + + assert!(removed); + assert!(updated.contains("onboarding = false")); + assert!(updated.contains("[theme]\nname = \"catppuccin\"")); + assert!(updated.contains("[ui]\nmouse_capture = false")); + assert!(!updated.contains("[keys]")); + assert!(!updated.contains("[[keys.command]]")); + assert!(!updated.contains("[keys.indexed]")); + assert!(toml::from_str::<toml::Value>(&updated).is_ok()); + } + + #[test] + fn remove_keybinding_config_sections_reports_noop_without_keys() { + let content = "[ui]\nmouse_capture = true\n"; + let (updated, removed) = remove_keybinding_config_sections(content); + assert!(!removed); + assert_eq!(updated, content); + } } diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 13d65ec3..58c26020 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -1,15 +1,50 @@ +#[cfg(test)] +use crossterm::event::KeyEvent; use crossterm::event::{KeyCode, KeyModifiers}; use serde::Deserialize; use tracing::warn; use super::Config; +use crate::input::TerminalKey; + +pub type KeyCombo = (KeyCode, KeyModifiers); #[derive(Debug)] pub struct LiveKeybindConfig { - pub prefix: (KeyCode, KeyModifiers), + pub prefix: KeyCombo, pub keybinds: Keybinds, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum BindingConfig { + One(String), + Many(Vec<String>), +} + +impl Default for BindingConfig { + fn default() -> Self { + Self::One(String::new()) + } +} + +impl BindingConfig { + pub fn one(value: impl Into<String>) -> Self { + Self::One(value.into()) + } + + pub fn empty() -> Self { + Self::One(String::new()) + } + + fn values(&self) -> Vec<&str> { + match self { + Self::One(value) => vec![value.as_str()], + Self::Many(values) => values.iter().map(String::as_str).collect(), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum CommandKeybindType { @@ -21,8 +56,8 @@ pub enum CommandKeybindType { #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct CommandKeybindConfig { - /// Navigate-mode key that runs a command after pressing the prefix key. - pub key: String, + /// Key that runs a command. Use `prefix+g` for prefix mode or a modified chord for direct mode. + pub key: BindingConfig, /// Command executed either in the background shell or inside a pane. pub command: String, /// Command execution mode. Default: "shell". @@ -33,7 +68,7 @@ pub struct CommandKeybindConfig { impl Default for CommandKeybindConfig { fn default() -> Self { Self { - key: String::new(), + key: BindingConfig::empty(), command: String::new(), action_type: CommandKeybindType::Shell, } @@ -46,253 +81,273 @@ pub enum CustomCommandAction { Pane, } -#[derive(Debug, Clone)] -pub struct CustomCommandKeybind { - pub key: (KeyCode, KeyModifiers), +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingTrigger { + Direct(KeyCombo), + Prefix(KeyCombo), +} + +impl BindingTrigger { + pub fn combo(self) -> KeyCombo { + match self { + Self::Direct(combo) | Self::Prefix(combo) => combo, + } + } + + pub fn is_direct(self) -> bool { + matches!(self, Self::Direct(_)) + } + + pub fn is_prefix(self) -> bool { + matches!(self, Self::Prefix(_)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedBinding { + pub trigger: BindingTrigger, pub label: String, - pub command: String, - pub action: CustomCommandAction, } -/// Parsed keybinds for navigate mode actions. -#[derive(Debug, Clone)] -pub struct Keybinds { - pub new_workspace: (KeyCode, KeyModifiers), - pub new_workspace_label: String, - pub rename_workspace: (KeyCode, KeyModifiers), - pub rename_workspace_label: String, - pub close_workspace: (KeyCode, KeyModifiers), - pub close_workspace_label: String, - pub detach: Option<(KeyCode, KeyModifiers)>, - pub detach_label: Option<String>, - pub reload_config: Option<(KeyCode, KeyModifiers)>, - pub reload_config_label: Option<String>, - pub open_notification_target: Option<(KeyCode, KeyModifiers)>, - pub open_notification_target_label: Option<String>, - pub previous_workspace: Option<(KeyCode, KeyModifiers)>, - pub previous_workspace_label: Option<String>, - pub next_workspace: Option<(KeyCode, KeyModifiers)>, - pub next_workspace_label: Option<String>, - pub previous_agent: Option<(KeyCode, KeyModifiers)>, - pub previous_agent_label: Option<String>, - pub next_agent: Option<(KeyCode, KeyModifiers)>, - pub next_agent_label: Option<String>, - pub indexed_tabs: Option<KeyModifiers>, - pub indexed_tabs_label: Option<String>, - pub indexed_workspaces: Option<KeyModifiers>, - pub indexed_workspaces_label: Option<String>, - pub indexed_agents: Option<KeyModifiers>, - pub indexed_agents_label: Option<String>, - pub new_tab: (KeyCode, KeyModifiers), - pub new_tab_label: String, - pub rename_tab: Option<(KeyCode, KeyModifiers)>, - pub rename_tab_label: Option<String>, - pub previous_tab: Option<(KeyCode, KeyModifiers)>, - pub previous_tab_label: Option<String>, - pub next_tab: Option<(KeyCode, KeyModifiers)>, - pub next_tab_label: Option<String>, - pub close_tab: Option<(KeyCode, KeyModifiers)>, - pub close_tab_label: Option<String>, - pub rename_pane: Option<(KeyCode, KeyModifiers)>, - pub rename_pane_label: Option<String>, - pub edit_scrollback: Option<(KeyCode, KeyModifiers)>, - pub edit_scrollback_label: Option<String>, - pub focus_pane_left: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_left_label: Option<String>, - pub focus_pane_down: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_down_label: Option<String>, - pub focus_pane_up: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_up_label: Option<String>, - pub focus_pane_right: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_right_label: Option<String>, - pub split_vertical: (KeyCode, KeyModifiers), - pub split_vertical_label: String, - pub split_horizontal: (KeyCode, KeyModifiers), - pub split_horizontal_label: String, - pub close_pane: (KeyCode, KeyModifiers), - pub close_pane_label: String, - pub zoom: (KeyCode, KeyModifiers), - pub zoom_label: String, - pub resize_mode: (KeyCode, KeyModifiers), - pub resize_mode_label: String, - pub toggle_sidebar: (KeyCode, KeyModifiers), - pub toggle_sidebar_label: String, - pub custom_commands: Vec<CustomCommandKeybind>, +impl ResolvedBinding { + #[cfg(test)] + fn matches_key_event(&self, key: &KeyEvent) -> bool { + key_event_matches_combo(key, self.trigger.combo()) + } + + fn matches_terminal_key(&self, key: TerminalKey) -> bool { + terminal_key_matches_combo(key, self.trigger.combo()) + } } -impl Config { - pub(super) fn validated_keybinds( - &self, - ) -> ( - Option<String>, - (KeyCode, KeyModifiers), - Vec<String>, - Keybinds, - ) { - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - enum BindingScope { - Navigate, - TerminalDirect, +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ActionKeybinds { + pub bindings: Vec<ResolvedBinding>, +} + +impl ActionKeybinds { + #[cfg(test)] + pub fn prefix(label: &str) -> Self { + let raw = if label.starts_with("prefix+") { + label.to_string() + } else { + format!("prefix+{label}") + }; + let trigger = parse_binding_string(&raw) + .and_then(|parsed| match parsed { + ParsedBinding::Single(binding) => Some(binding), + ParsedBinding::Range(_) => None, + }) + .expect("prefix binding should parse"); + Self { + bindings: vec![trigger], } + } - #[derive(Clone)] - struct RequiredBinding<'a> { - scope: BindingScope, - field: &'a str, - label: String, - default_label: &'a str, - value: (KeyCode, KeyModifiers), - default: (KeyCode, KeyModifiers), + #[cfg(test)] + pub fn direct(label: &str) -> Self { + let trigger = parse_binding_string(label) + .and_then(|parsed| match parsed { + ParsedBinding::Single(binding) => Some(binding), + ParsedBinding::Range(_) => None, + }) + .expect("direct binding should parse"); + Self { + bindings: vec![trigger], } + } + + #[cfg(test)] + pub fn matches_prefix(&self, key: &KeyEvent) -> bool { + self.bindings + .iter() + .any(|binding| binding.trigger.is_prefix() && binding.matches_key_event(key)) + } + + pub fn matches_prefix_key(&self, key: TerminalKey) -> bool { + self.bindings + .iter() + .any(|binding| binding.trigger.is_prefix() && binding.matches_terminal_key(key)) + } - struct OptionalBinding { - scopes: Vec<BindingScope>, - field: &'static str, - value: Option<(KeyCode, KeyModifiers)>, - label: Option<String>, + pub fn matches_direct_key(&self, key: TerminalKey) -> bool { + self.bindings + .iter() + .any(|binding| binding.trigger.is_direct() && binding.matches_terminal_key(key)) + } + + pub fn labels(&self) -> Vec<String> { + self.bindings + .iter() + .map(|binding| binding.label.clone()) + .collect() + } + + pub fn label(&self) -> Option<String> { + let labels = self.labels(); + if labels.is_empty() { + None + } else { + Some(labels.join(" / ")) } + } - struct IndexedBinding { - field: &'static str, - value: Option<KeyModifiers>, - label: Option<String>, + pub fn prefix_rhs_label(&self) -> Option<String> { + let labels: Vec<String> = self + .bindings + .iter() + .filter(|binding| binding.trigger.is_prefix()) + .map(|binding| { + binding + .label + .strip_prefix("prefix+") + .unwrap_or(&binding.label) + .to_string() + }) + .collect(); + if labels.is_empty() { + None + } else { + Some(labels.join(" / ")) } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexedKeybind { + pub trigger: BindingTrigger, + pub label: String, +} - #[derive(Default)] - struct BindingRegistry { - seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>, +impl IndexedKeybind { + pub fn matched_index(&self, key: TerminalKey) -> Option<usize> { + let KeyCode::Char(c @ '1'..='9') = key.code else { + return None; + }; + if terminal_key_matches_combo(key, self.trigger.combo()) { + Some((c as usize) - ('1' as usize)) + } else { + None } + } +} - impl BindingRegistry { - fn register( - &mut self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - field: &str, - ) -> Option<String> { - self.seen - .insert((scope, binding.0, binding.1), field.to_string()) - } +#[derive(Debug, Clone)] +pub struct CustomCommandKeybind { + pub bindings: ActionKeybinds, + pub label: String, + pub command: String, + pub action: CustomCommandAction, +} - fn conflict( - &self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - ) -> Option<&str> { - self.seen - .get(&(scope, binding.0, binding.1)) - .map(String::as_str) - } +/// Parsed keybinds for Herdr actions. +#[derive(Debug, Clone)] +pub struct Keybinds { + pub help: ActionKeybinds, + pub settings: ActionKeybinds, + pub quit: ActionKeybinds, + pub new_workspace: ActionKeybinds, + pub rename_workspace: ActionKeybinds, + pub close_workspace: ActionKeybinds, + pub workspace_picker: ActionKeybinds, + pub detach: ActionKeybinds, + pub reload_config: ActionKeybinds, + pub open_notification_target: ActionKeybinds, + pub previous_workspace: ActionKeybinds, + pub next_workspace: ActionKeybinds, + pub previous_agent: ActionKeybinds, + pub next_agent: ActionKeybinds, + pub focus_agent: Vec<IndexedKeybind>, + pub new_tab: ActionKeybinds, + pub rename_tab: ActionKeybinds, + pub previous_tab: ActionKeybinds, + pub next_tab: ActionKeybinds, + pub switch_tab: Vec<IndexedKeybind>, + pub switch_workspace: Vec<IndexedKeybind>, + pub close_tab: ActionKeybinds, + pub rename_pane: ActionKeybinds, + pub edit_scrollback: ActionKeybinds, + pub focus_pane_left: ActionKeybinds, + pub focus_pane_down: ActionKeybinds, + pub focus_pane_up: ActionKeybinds, + pub focus_pane_right: ActionKeybinds, + pub cycle_pane_next: ActionKeybinds, + pub cycle_pane_previous: ActionKeybinds, + pub split_vertical: ActionKeybinds, + pub split_horizontal: ActionKeybinds, + pub close_pane: ActionKeybinds, + pub zoom: ActionKeybinds, + pub resize_mode: ActionKeybinds, + pub toggle_sidebar: ActionKeybinds, + pub custom_commands: Vec<CustomCommandKeybind>, +} - fn reserve_if_unbound( - &mut self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - field: &str, - ) { - self.seen - .entry((scope, binding.0, binding.1)) - .or_insert_with(|| field.to_string()); - } - } +impl Default for Keybinds { + fn default() -> Self { + Config::default().keybinds() + } +} - fn required_binding<'a>( - scope: BindingScope, - field: &'a str, - configured_label: &'a str, - default_label: &'a str, - default: (KeyCode, KeyModifiers), - diagnostics: &mut Vec<String>, - ) -> RequiredBinding<'a> { - let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default); - let label = if let Some(diag) = diag { - diagnostics.push(diag); - default_label.to_string() - } else { - configured_label.to_string() - }; - RequiredBinding { - scope, - field, - label, - default_label, - value, - default, - } +#[derive(Clone)] +enum ParsedBinding { + Single(ResolvedBinding), + Range(Vec<ResolvedBinding>), +} + +struct BindingRegistry { + prefix_combo: KeyCombo, + direct: std::collections::HashMap<KeyCombo, String>, + prefix: std::collections::HashMap<KeyCombo, String>, +} + +impl BindingRegistry { + fn new(prefix_combo: KeyCombo) -> Self { + Self { + prefix_combo: normalize_key_combo(prefix_combo), + direct: std::collections::HashMap::new(), + prefix: std::collections::HashMap::new(), } + } - fn optional_binding( - scopes: Vec<BindingScope>, - field: &'static str, - configured_label: &str, - diagnostics: &mut Vec<String>, - ) -> OptionalBinding { - if configured_label.trim().is_empty() { - return OptionalBinding { - scopes, - field, - value: None, - label: None, - }; - } - match parse_key_combo(configured_label) { - Some(value) => OptionalBinding { - scopes, - field, - value: Some(value), - label: Some(configured_label.to_string()), - }, - None => { - let diag = format!( - "invalid keybinding: {field} = {:?}; disabling binding", - configured_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - OptionalBinding { - scopes, - field, - value: None, - label: None, - } - } - } + fn reserve_direct(&mut self, combo: KeyCombo, field: &str) { + self.direct + .entry(normalize_key_combo(combo)) + .or_insert_with(|| field.to_string()); + } + + fn prefix_rhs_is_reserved(&self, combo: KeyCombo) -> bool { + normalize_key_combo(combo) == self.prefix_combo + } + + fn conflict(&self, binding: &ResolvedBinding) -> Option<&str> { + match binding.trigger { + BindingTrigger::Direct(combo) => self + .direct + .get(&normalize_key_combo(combo)) + .map(String::as_str), + BindingTrigger::Prefix(combo) => self + .prefix + .get(&normalize_key_combo(combo)) + .map(String::as_str), } + } - fn indexed_binding( - field: &'static str, - configured_label: &str, - diagnostics: &mut Vec<String>, - ) -> IndexedBinding { - if configured_label.trim().is_empty() { - return IndexedBinding { - field, - value: None, - label: None, - }; + fn register(&mut self, binding: &ResolvedBinding, field: &str) { + match binding.trigger { + BindingTrigger::Direct(combo) => { + self.direct + .insert(normalize_key_combo(combo), field.to_string()); } - match parse_modifier_combo(configured_label) { - Some(value) => IndexedBinding { - field, - value: Some(value), - label: Some(format!("{}+1..9", configured_label.trim())), - }, - None => { - let diag = format!( - "invalid indexed keybinding: {field} = {:?}; disabling binding", - configured_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - IndexedBinding { - field, - value: None, - label: None, - } - } + BindingTrigger::Prefix(combo) => { + self.prefix + .insert(normalize_key_combo(combo), field.to_string()); } } + } +} +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, @@ -303,394 +358,88 @@ impl Config { warn!(message = %diag, "config diagnostic"); } - let mut bindings = vec![ - required_binding( - BindingScope::Navigate, - "keys.new_workspace", - &self.keys.new_workspace, - "n", - (KeyCode::Char('n'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.rename_workspace", - &self.keys.rename_workspace, - "shift+n", - (KeyCode::Char('n'), KeyModifiers::SHIFT), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.close_workspace", - &self.keys.close_workspace, - "shift+d", - (KeyCode::Char('d'), KeyModifiers::SHIFT), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.new_tab", - &self.keys.new_tab, - "c", - (KeyCode::Char('c'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.split_vertical", - &self.keys.split_vertical, - "v", - (KeyCode::Char('v'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.split_horizontal", - &self.keys.split_horizontal, - "-", - (KeyCode::Char('-'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.close_pane", - &self.keys.close_pane, - "x", - (KeyCode::Char('x'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.zoom", - &self.keys.zoom, - "f", - (KeyCode::Char('f'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.resize_mode", - &self.keys.resize_mode, - "r", - (KeyCode::Char('r'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.toggle_sidebar", - &self.keys.toggle_sidebar, - "b", - (KeyCode::Char('b'), KeyModifiers::empty()), - &mut diagnostics, - ), - ]; - - let navigate_scope = || vec![BindingScope::Navigate]; - let terminal_direct_scope = || vec![BindingScope::TerminalDirect]; - let direct_navigation_scopes = - || vec![BindingScope::Navigate, BindingScope::TerminalDirect]; - - let mut optional_bindings = vec![ - optional_binding( - navigate_scope(), - "keys.detach", - &self.keys.detach, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.reload_config", - &self.keys.reload_config, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.open_notification_target", - &self.keys.open_notification_target, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.previous_workspace", - &self.keys.previous_workspace, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.next_workspace", - &self.keys.next_workspace, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.previous_agent", - &self.keys.previous_agent, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.next_agent", - &self.keys.next_agent, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.rename_tab", - &self.keys.rename_tab, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.previous_tab", - &self.keys.previous_tab, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.next_tab", - &self.keys.next_tab, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.close_tab", - &self.keys.close_tab, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.rename_pane", - &self.keys.rename_pane, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.edit_scrollback", - &self.keys.edit_scrollback, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_left", - &self.keys.focus_pane_left, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_down", - &self.keys.focus_pane_down, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_up", - &self.keys.focus_pane_up, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_right", - &self.keys.focus_pane_right, - &mut diagnostics, - ), - ]; - - let mut indexed_bindings = vec![ - indexed_binding( - "keys.indexed.tabs", - &self.keys.indexed.tabs, - &mut diagnostics, - ), - indexed_binding( - "keys.indexed.workspaces", - &self.keys.indexed.workspaces, - &mut diagnostics, - ), - indexed_binding( - "keys.indexed.agents", - &self.keys.indexed.agents, - &mut diagnostics, - ), - ]; + let mut registry = BindingRegistry::new(prefix); + registry.reserve_direct(prefix, "keys.prefix"); - let mut registry = BindingRegistry::default(); - for binding in &mut bindings { - if let Some(first_field) = registry.conflict(binding.scope, binding.value) { - let diag = format!( - "duplicate keybinding: {} conflicts with {}; using default {}", - binding.field, first_field, binding.default_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - binding.value = binding.default; - binding.label = binding.default_label.to_string(); - } - registry.register(binding.scope, binding.value, binding.field); + macro_rules! action { + ($field:literal, $config:expr) => { + parse_action_bindings($field, $config, false, &mut registry, &mut diagnostics) + }; } - - registry.reserve_if_unbound(BindingScope::TerminalDirect, prefix, "keys.prefix"); - - for binding in &mut optional_bindings { - let Some(value) = binding.value else { - continue; + macro_rules! indexed { + ($field:literal, $config:expr) => { + parse_indexed_bindings($field, $config, &mut registry, &mut diagnostics) }; - - let conflict = binding - .scopes - .iter() - .find_map(|scope| registry.conflict(*scope, value)); - if let Some(first_field) = conflict { - let diag = format!( - "duplicate keybinding: {} conflicts with {}; disabling binding", - binding.field, first_field - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - binding.value = None; - binding.label = None; - continue; - } - for scope in &binding.scopes { - registry.register(*scope, value, binding.field); - } } - registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix"); - for (field, binding) in [ - ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())), - ( - "navigate.open_workspace", - (KeyCode::Enter, KeyModifiers::empty()), - ), - ( - "navigate.settings", - (KeyCode::Char('s'), KeyModifiers::empty()), - ), - ( - "navigate.keybind_help", - (KeyCode::Char('?'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_up", - (KeyCode::Up, KeyModifiers::empty()), - ), - ( - "navigate.workspace_down", - (KeyCode::Down, KeyModifiers::empty()), - ), - ( - "navigate.focus_left", - (KeyCode::Char('h'), KeyModifiers::empty()), - ), - ( - "navigate.focus_down", - (KeyCode::Char('j'), KeyModifiers::empty()), - ), - ( - "navigate.focus_up", - (KeyCode::Char('k'), KeyModifiers::empty()), - ), - ( - "navigate.focus_right", - (KeyCode::Char('l'), KeyModifiers::empty()), - ), - ( - "navigate.arrow_left", - (KeyCode::Left, KeyModifiers::empty()), - ), - ( - "navigate.arrow_right", - (KeyCode::Right, KeyModifiers::empty()), - ), - ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())), - ( - "navigate.tab_prev", - (KeyCode::BackTab, KeyModifiers::empty()), - ), - ( - "navigate.workspace_1", - (KeyCode::Char('1'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_2", - (KeyCode::Char('2'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_3", - (KeyCode::Char('3'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_4", - (KeyCode::Char('4'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_5", - (KeyCode::Char('5'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_6", - (KeyCode::Char('6'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_7", - (KeyCode::Char('7'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_8", - (KeyCode::Char('8'), KeyModifiers::empty()), + let mut keybinds = Keybinds { + help: action!("keys.help", &self.keys.help), + settings: action!("keys.settings", &self.keys.settings), + quit: action!("keys.quit", &self.keys.quit), + new_workspace: action!("keys.new_workspace", &self.keys.new_workspace), + rename_workspace: action!("keys.rename_workspace", &self.keys.rename_workspace), + close_workspace: action!("keys.close_workspace", &self.keys.close_workspace), + workspace_picker: action!("keys.workspace_picker", &self.keys.workspace_picker), + detach: action!("keys.detach", &self.keys.detach), + reload_config: action!("keys.reload_config", &self.keys.reload_config), + open_notification_target: action!( + "keys.open_notification_target", + &self.keys.open_notification_target ), - ( - "navigate.workspace_9", - (KeyCode::Char('9'), KeyModifiers::empty()), + previous_workspace: action!("keys.previous_workspace", &self.keys.previous_workspace), + next_workspace: action!("keys.next_workspace", &self.keys.next_workspace), + previous_agent: action!("keys.previous_agent", &self.keys.previous_agent), + next_agent: action!("keys.next_agent", &self.keys.next_agent), + focus_agent: indexed!("keys.focus_agent", &self.keys.focus_agent), + new_tab: action!("keys.new_tab", &self.keys.new_tab), + rename_tab: action!("keys.rename_tab", &self.keys.rename_tab), + previous_tab: action!("keys.previous_tab", &self.keys.previous_tab), + next_tab: action!("keys.next_tab", &self.keys.next_tab), + switch_tab: indexed!("keys.switch_tab", &self.keys.switch_tab), + switch_workspace: indexed!("keys.switch_workspace", &self.keys.switch_workspace), + close_tab: action!("keys.close_tab", &self.keys.close_tab), + rename_pane: action!("keys.rename_pane", &self.keys.rename_pane), + edit_scrollback: action!("keys.edit_scrollback", &self.keys.edit_scrollback), + focus_pane_left: action!("keys.focus_pane_left", &self.keys.focus_pane_left), + focus_pane_down: action!("keys.focus_pane_down", &self.keys.focus_pane_down), + focus_pane_up: action!("keys.focus_pane_up", &self.keys.focus_pane_up), + focus_pane_right: action!("keys.focus_pane_right", &self.keys.focus_pane_right), + cycle_pane_next: action!("keys.cycle_pane_next", &self.keys.cycle_pane_next), + cycle_pane_previous: action!( + "keys.cycle_pane_previous", + &self.keys.cycle_pane_previous ), - ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())), - ] { - registry.reserve_if_unbound(BindingScope::Navigate, binding, field); - } - - for binding in &mut indexed_bindings { - let Some(modifiers) = binding.value else { - continue; - }; - - let mut conflict = None; - 'indexes: for idx in 1..=9 { - let key = ( - KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), - modifiers, - ); - for scope in [BindingScope::Navigate, BindingScope::TerminalDirect] { - if let Some(first_field) = registry.conflict(scope, key) { - conflict = Some(first_field.to_string()); - break 'indexes; - } - } - } - - if let Some(first_field) = conflict { - let diag = format!( - "duplicate indexed keybinding: {} conflicts with {}; disabling binding", - binding.field, first_field - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - binding.value = None; - binding.label = None; - continue; - } + split_vertical: action!("keys.split_vertical", &self.keys.split_vertical), + split_horizontal: action!("keys.split_horizontal", &self.keys.split_horizontal), + close_pane: action!("keys.close_pane", &self.keys.close_pane), + zoom: action!("keys.zoom", &self.keys.zoom), + resize_mode: action!("keys.resize_mode", &self.keys.resize_mode), + toggle_sidebar: action!("keys.toggle_sidebar", &self.keys.toggle_sidebar), + custom_commands: Vec::new(), + }; - for idx in 1..=9 { - let key = ( - KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), - modifiers, - ); - registry.register(BindingScope::Navigate, key, binding.field); - registry.register(BindingScope::TerminalDirect, key, binding.field); - } - } + append_legacy_indexed_bindings( + &mut keybinds.switch_tab, + "keys.indexed.tabs", + &self.keys.indexed.tabs, + &mut registry, + &mut diagnostics, + ); + append_legacy_indexed_bindings( + &mut keybinds.switch_workspace, + "keys.indexed.workspaces", + &self.keys.indexed.workspaces, + &mut registry, + &mut diagnostics, + ); + append_legacy_indexed_bindings( + &mut keybinds.focus_agent, + "keys.indexed.agents", + &self.keys.indexed.agents, + &mut registry, + &mut diagnostics, + ); - let mut custom_commands = Vec::new(); for (index, command) in self.keys.command.iter().enumerate() { let key_field = format!("keys.command[{index}].key"); let command_field = format!("keys.command[{index}].command"); @@ -703,108 +452,246 @@ impl Config { continue; } - let Some(binding) = parse_key_combo(&command.key) else { - let diag = format!( - "invalid keybinding: {} = {:?}; disabling custom command", - key_field, command.key - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - continue; - }; - - if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) { - let diag = format!( - "duplicate custom keybinding: {} conflicts with {}; disabling custom command", - key_field, first_field - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); + let bindings = parse_action_bindings_owned( + &key_field, + &command.key, + false, + &mut registry, + &mut diagnostics, + ); + if bindings.bindings.is_empty() { continue; } - registry.register(BindingScope::Navigate, binding, &key_field); let action = match command.action_type { CommandKeybindType::Shell => CustomCommandAction::Shell, CommandKeybindType::Pane => CustomCommandAction::Pane, }; - custom_commands.push(CustomCommandKeybind { - key: binding, - label: format_key_combo(binding), + let label = bindings.label().unwrap_or_else(|| "unset".to_string()); + keybinds.custom_commands.push(CustomCommandKeybind { + bindings, + label, command: command.command.clone(), action, }); } - let keybinds = Keybinds { - new_workspace: bindings[0].value, - new_workspace_label: bindings[0].label.clone(), - rename_workspace: bindings[1].value, - rename_workspace_label: bindings[1].label.clone(), - close_workspace: bindings[2].value, - close_workspace_label: bindings[2].label.clone(), - detach: optional_bindings[0].value, - detach_label: optional_bindings[0].label.clone(), - reload_config: optional_bindings[1].value, - reload_config_label: optional_bindings[1].label.clone(), - open_notification_target: optional_bindings[2].value, - open_notification_target_label: optional_bindings[2].label.clone(), - previous_workspace: optional_bindings[3].value, - previous_workspace_label: optional_bindings[3].label.clone(), - next_workspace: optional_bindings[4].value, - next_workspace_label: optional_bindings[4].label.clone(), - previous_agent: optional_bindings[5].value, - previous_agent_label: optional_bindings[5].label.clone(), - next_agent: optional_bindings[6].value, - next_agent_label: optional_bindings[6].label.clone(), - indexed_tabs: indexed_bindings[0].value, - indexed_tabs_label: indexed_bindings[0].label.clone(), - indexed_workspaces: indexed_bindings[1].value, - indexed_workspaces_label: indexed_bindings[1].label.clone(), - indexed_agents: indexed_bindings[2].value, - indexed_agents_label: indexed_bindings[2].label.clone(), - new_tab: bindings[3].value, - new_tab_label: bindings[3].label.clone(), - rename_tab: optional_bindings[7].value, - rename_tab_label: optional_bindings[7].label.clone(), - previous_tab: optional_bindings[8].value, - previous_tab_label: optional_bindings[8].label.clone(), - next_tab: optional_bindings[9].value, - next_tab_label: optional_bindings[9].label.clone(), - close_tab: optional_bindings[10].value, - close_tab_label: optional_bindings[10].label.clone(), - rename_pane: optional_bindings[11].value, - rename_pane_label: optional_bindings[11].label.clone(), - edit_scrollback: optional_bindings[12].value, - edit_scrollback_label: optional_bindings[12].label.clone(), - focus_pane_left: optional_bindings[13].value, - focus_pane_left_label: optional_bindings[13].label.clone(), - focus_pane_down: optional_bindings[14].value, - focus_pane_down_label: optional_bindings[14].label.clone(), - focus_pane_up: optional_bindings[15].value, - focus_pane_up_label: optional_bindings[15].label.clone(), - focus_pane_right: optional_bindings[16].value, - focus_pane_right_label: optional_bindings[16].label.clone(), - split_vertical: bindings[4].value, - split_vertical_label: bindings[4].label.clone(), - split_horizontal: bindings[5].value, - split_horizontal_label: bindings[5].label.clone(), - close_pane: bindings[6].value, - close_pane_label: bindings[6].label.clone(), - zoom: bindings[7].value, - zoom_label: bindings[7].label.clone(), - resize_mode: bindings[8].value, - resize_mode_label: bindings[8].label.clone(), - toggle_sidebar: bindings[9].value, - toggle_sidebar_label: bindings[9].label.clone(), - custom_commands, + (prefix_diag, prefix, diagnostics, keybinds) + } +} + +fn parse_action_bindings( + field: &'static str, + config: &BindingConfig, + allow_ranges: bool, + registry: &mut BindingRegistry, + diagnostics: &mut Vec<String>, +) -> ActionKeybinds { + parse_action_bindings_owned(field, config, allow_ranges, registry, diagnostics) +} + +fn parse_action_bindings_owned( + field: &str, + config: &BindingConfig, + allow_ranges: bool, + registry: &mut BindingRegistry, + diagnostics: &mut Vec<String>, +) -> ActionKeybinds { + let mut bindings = Vec::new(); + for raw in config.values() { + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + match parse_binding_string(raw) { + Some(ParsedBinding::Single(binding)) => { + if reject_binding(field, &binding, registry, diagnostics) { + continue; + } + registry.register(&binding, field); + bindings.push(binding); + } + Some(ParsedBinding::Range(_)) if !allow_ranges => { + let diag = format!("range keybinding is only valid for indexed actions: {field} = {raw:?}; disabling binding"); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + } + Some(ParsedBinding::Range(range)) => { + for binding in range { + if reject_binding(field, &binding, registry, diagnostics) { + continue; + } + registry.register(&binding, field); + bindings.push(binding); + } + } + None => { + let diag = format!("invalid keybinding: {field} = {raw:?}; disabling binding"); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + } + } + } + ActionKeybinds { bindings } +} + +fn parse_indexed_bindings( + field: &'static str, + config: &BindingConfig, + registry: &mut BindingRegistry, + diagnostics: &mut Vec<String>, +) -> Vec<IndexedKeybind> { + parse_action_bindings(field, config, true, registry, diagnostics) + .bindings + .into_iter() + .filter_map(|binding| { + if matches!(binding.trigger.combo().0, KeyCode::Char('1'..='9')) { + Some(IndexedKeybind { + trigger: binding.trigger, + label: binding.label, + }) + } else { + let diag = format!( + "indexed keybinding must use 1..9: {field} = {:?}; disabling binding", + binding.label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + None + } + }) + .collect() +} + +fn append_legacy_indexed_bindings( + target: &mut Vec<IndexedKeybind>, + field: &'static str, + configured_label: &str, + registry: &mut BindingRegistry, + diagnostics: &mut Vec<String>, +) { + if configured_label.trim().is_empty() { + return; + } + let Some(modifiers) = parse_modifier_combo(configured_label) else { + let diag = format!( + "invalid indexed keybinding: {field} = {configured_label:?}; disabling binding" + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return; + }; + + for idx in 1..=9 { + let combo = ( + KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), + modifiers, + ); + let binding = ResolvedBinding { + trigger: BindingTrigger::Direct(combo), + label: format!("{}+{idx}", configured_label.trim()), }; + if reject_binding(field, &binding, registry, diagnostics) { + continue; + } + registry.register(&binding, field); + target.push(IndexedKeybind { + trigger: binding.trigger, + label: binding.label, + }); + } +} - (prefix_diag, prefix, diagnostics, keybinds) +fn reject_binding( + field: &str, + binding: &ResolvedBinding, + registry: &BindingRegistry, + diagnostics: &mut Vec<String>, +) -> bool { + if binding.trigger.is_prefix() && registry.prefix_rhs_is_reserved(binding.trigger.combo()) { + let diag = format!( + "reserved keybinding: {field} = {:?} uses keys.prefix as the prefix-mode key; pressing the prefix twice sends a literal prefix key, so this binding is disabled", + binding.label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } + + if let Some(first_field) = registry.conflict(binding) { + let diag = format!( + "duplicate keybinding: {field} = {:?} conflicts with {}; disabling binding", + binding.label, first_field + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } + + if binding.trigger.is_direct() && is_unmodified_printable(binding.trigger.combo()) { + let suggestion = format!("prefix+{}", binding.label); + let diag = format!( + "unsafe direct keybinding: {field} = {:?} would intercept typing; use {:?} to require the prefix; disabling binding", + binding.label, suggestion + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; } + + false } -pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { +fn parse_binding_string(raw: &str) -> Option<ParsedBinding> { + let trimmed = raw.trim(); + let (trigger_prefix, body) = if let Some(rest) = trimmed.strip_prefix("prefix+") { + (true, rest) + } else { + (false, trimmed) + }; + + if let Some(range_modifiers) = parse_range_modifiers(body) { + let bindings = (1..=9) + .map(|idx| { + let combo = ( + KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), + range_modifiers, + ); + let key_label = format_key_combo(combo); + ResolvedBinding { + trigger: if trigger_prefix { + BindingTrigger::Prefix(combo) + } else { + BindingTrigger::Direct(combo) + }, + label: if trigger_prefix { + format!("prefix+{key_label}") + } else { + key_label + }, + } + }) + .collect(); + return Some(ParsedBinding::Range(bindings)); + } + + let combo = parse_key_combo(body)?; + let label = if trigger_prefix { + format!("prefix+{}", format_key_combo(combo)) + } else { + format_key_combo(combo) + }; + Some(ParsedBinding::Single(ResolvedBinding { + trigger: if trigger_prefix { + BindingTrigger::Prefix(combo) + } else { + BindingTrigger::Direct(combo) + }, + label, + })) +} + +pub fn format_key_combo(binding: KeyCombo) -> String { let (code, modifiers) = binding; let mut parts = Vec::new(); if modifiers.contains(KeyModifiers::CONTROL) { @@ -813,7 +700,7 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { if modifiers.contains(KeyModifiers::ALT) { parts.push("alt".to_string()); } - if modifiers.contains(KeyModifiers::SHIFT) { + if modifiers.contains(KeyModifiers::SHIFT) && !matches!(code, KeyCode::BackTab) { parts.push("shift".to_string()); } if modifiers.contains(KeyModifiers::SUPER) { @@ -846,7 +733,7 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { return if parts.is_empty() { key } else { - format!("{}+tab", parts.join("+")) + format!("{}+{key}", parts.join("+")) }; } @@ -873,6 +760,23 @@ fn parse_modifier_token(token: &str) -> Option<KeyModifiers> { } } +fn parse_range_modifiers(s: &str) -> Option<KeyModifiers> { + let mut modifiers = KeyModifiers::empty(); + let mut saw_range = false; + for part in s.split('+') { + let trimmed = part.trim(); + if trimmed == "1..9" { + if saw_range { + return None; + } + saw_range = true; + } else { + modifiers |= parse_modifier_token(trimmed)?; + } + } + saw_range.then_some(modifiers) +} + fn parse_modifier_combo(s: &str) -> Option<KeyModifiers> { let mut modifiers = KeyModifiers::empty(); let parts: Vec<&str> = s.split('+').collect(); @@ -895,7 +799,7 @@ fn parse_modifier_combo(s: &str) -> Option<KeyModifiers> { } } -pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { +pub(super) fn parse_key_combo(s: &str) -> Option<KeyCombo> { let parts: Vec<&str> = s.split('+').collect(); let mut modifiers = KeyModifiers::empty(); let mut key_str: Option<&str> = None; @@ -907,27 +811,42 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { } if let Some(modifier) = parse_modifier_token(trimmed) { modifiers |= modifier; + } else if key_str.is_some() { + return None; } else { - if key_str.is_some() { - return None; - } key_str = Some(trimmed); } } let key_str = key_str?; - let lower = key_str.to_lowercase(); let code = match lower.as_str() { "space" | " " => KeyCode::Char(' '), "enter" | "return" => KeyCode::Enter, "esc" | "escape" => KeyCode::Esc, + "tab" if modifiers.contains(KeyModifiers::SHIFT) => { + modifiers.remove(KeyModifiers::SHIFT); + KeyCode::BackTab + } "tab" => KeyCode::Tab, "backspace" | "bs" => KeyCode::Backspace, "left" => KeyCode::Left, "right" => KeyCode::Right, "up" => KeyCode::Up, "down" => KeyCode::Down, + "minus" => KeyCode::Char('-'), + "comma" => KeyCode::Char(','), + "period" => KeyCode::Char('.'), + "slash" => KeyCode::Char('/'), + "backslash" => KeyCode::Char('\\'), + "quote" => KeyCode::Char('\''), + "double_quote" | "double-quote" => KeyCode::Char('"'), + "semicolon" => KeyCode::Char(';'), + "colon" => KeyCode::Char(':'), + "percent" => KeyCode::Char('%'), + "ampersand" => KeyCode::Char('&'), + "backtick" => KeyCode::Char('`'), + "plus" => KeyCode::Char('+'), s if s.len() == 1 => { let ch = key_str.chars().next().unwrap(); if ch.is_ascii_uppercase() { @@ -941,14 +860,14 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { _ => return None, }; - Some((code, modifiers)) + Some(normalize_key_combo((code, modifiers))) } fn parse_key_combo_with_diagnostic( s: &str, field: &str, - fallback: (KeyCode, KeyModifiers), -) -> ((KeyCode, KeyModifiers), Option<String>) { + fallback: KeyCombo, +) -> (KeyCombo, Option<String>) { match parse_key_combo(s) { Some(binding) => (binding, None), None => { @@ -959,489 +878,426 @@ fn parse_key_combo_with_diagnostic( } } +pub fn normalize_key_combo((mut code, mut modifiers): KeyCombo) -> KeyCombo { + if matches!(code, KeyCode::Tab) && modifiers.contains(KeyModifiers::SHIFT) { + code = KeyCode::BackTab; + modifiers.remove(KeyModifiers::SHIFT); + } else if matches!(code, KeyCode::BackTab) { + modifiers.remove(KeyModifiers::SHIFT); + } + (code, modifiers) +} + #[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; +pub fn key_event_matches_combo(key: &KeyEvent, combo: KeyCombo) -> bool { + key_parts_match_combo(key.code, key.modifiers, None, combo) +} - #[test] - fn parse_simple_char() { - assert_eq!( - parse_key_combo("v"), - Some((KeyCode::Char('v'), KeyModifiers::empty())) - ); - } +pub fn terminal_key_matches_combo(key: TerminalKey, combo: KeyCombo) -> bool { + key_parts_match_combo(key.code, key.modifiers, key.shifted_codepoint, combo) +} - #[test] - fn parse_ctrl_combo() { - assert_eq!( - parse_key_combo("ctrl+b"), - Some((KeyCode::Char('b'), KeyModifiers::CONTROL)) - ); +fn key_parts_match_combo( + actual_code: KeyCode, + actual_modifiers: KeyModifiers, + shifted_codepoint: Option<u32>, + combo: KeyCombo, +) -> bool { + let (actual_code, actual_modifiers) = normalize_key_combo((actual_code, actual_modifiers)); + let (expected_code, expected_modifiers) = normalize_key_combo(combo); + + if actual_modifiers == expected_modifiers + && key_codes_match(actual_code, expected_code, shifted_codepoint) + { + return true; } - #[test] - fn parse_cmd_combo() { - assert_eq!( - parse_key_combo("cmd+1"), - Some((KeyCode::Char('1'), KeyModifiers::SUPER)) - ); + let actual_without_shift = actual_modifiers.difference(KeyModifiers::SHIFT); + actual_modifiers.contains(KeyModifiers::SHIFT) + && actual_without_shift == expected_modifiers + && shifted_char_matches_expected(actual_code, shifted_codepoint, expected_code) +} + +fn key_codes_match(actual: KeyCode, expected: KeyCode, shifted_codepoint: Option<u32>) -> bool { + match (actual, expected) { + (KeyCode::Char(actual), KeyCode::Char(expected)) + if actual.is_ascii_alphabetic() && expected.is_ascii_alphabetic() => + { + actual.eq_ignore_ascii_case(&expected) + } + (KeyCode::Char(actual), KeyCode::Char(expected)) => { + actual == expected + || shifted_char_matches_expected( + KeyCode::Char(actual), + shifted_codepoint, + KeyCode::Char(expected), + ) + } + (actual, expected) => actual == expected, } +} - #[test] - fn parse_modifier_combo_for_indexed_bindings() { - assert_eq!( - parse_modifier_combo("ctrl+shift"), - Some(KeyModifiers::CONTROL | KeyModifiers::SHIFT) - ); - assert_eq!(parse_modifier_combo("1"), None); +fn shifted_char_matches_expected( + actual_code: KeyCode, + shifted_codepoint: Option<u32>, + expected_code: KeyCode, +) -> bool { + let KeyCode::Char(expected) = expected_code else { + return false; + }; + if shifted_codepoint.and_then(char::from_u32) == Some(expected) { + return true; } + matches!(actual_code, KeyCode::Char(actual) if actual == expected && is_shifted_punctuation(expected)) +} - #[test] - fn parse_special_key() { - assert_eq!( - parse_key_combo("enter"), - Some((KeyCode::Enter, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("tab"), - Some((KeyCode::Tab, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("esc"), - Some((KeyCode::Esc, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("left"), - Some((KeyCode::Left, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("alt+right"), - Some((KeyCode::Right, KeyModifiers::ALT)) - ); +fn is_shifted_punctuation(ch: char) -> bool { + matches!( + ch, + '!' | '@' + | '#' + | '$' + | '%' + | '^' + | '&' + | '*' + | '(' + | ')' + | '_' + | '+' + | '{' + | '}' + | '|' + | ':' + | '"' + | '<' + | '>' + | '?' + | '~' + ) +} + +fn is_unmodified_printable(combo: KeyCombo) -> bool { + matches!(combo.0, KeyCode::Char(ch) if !ch.is_control()) + && combo.1.difference(KeyModifiers::SHIFT).is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{config::Config, input::TerminalKey}; + + fn binding_triggers(bindings: &ActionKeybinds) -> Vec<BindingTrigger> { + bindings + .bindings + .iter() + .map(|binding| binding.trigger) + .collect() } #[test] - fn parse_ctrl_shift() { + fn parse_simple_char_combo() { assert_eq!( - parse_key_combo("ctrl+shift+a"), - Some(( - KeyCode::Char('a'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT - )) + parse_key_combo("v"), + Some((KeyCode::Char('v'), KeyModifiers::empty())) ); } #[test] - fn parse_f_key() { + fn parse_shift_tab_as_backtab() { assert_eq!( - parse_key_combo("f5"), - Some((KeyCode::F(5), KeyModifiers::empty())) + parse_key_combo("shift+tab"), + Some((KeyCode::BackTab, KeyModifiers::empty())) ); } #[test] - fn parse_punctuation_key() { + fn parse_named_punctuation() { assert_eq!( - parse_key_combo("ctrl+`"), - Some((KeyCode::Char('`'), KeyModifiers::CONTROL)) + parse_key_combo("minus"), + Some((KeyCode::Char('-'), KeyModifiers::empty())) ); - } - - #[test] - fn uppercase_char_implies_shift() { assert_eq!( - parse_key_combo("D"), - Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) + parse_key_combo("comma"), + Some((KeyCode::Char(','), KeyModifiers::empty())) ); - } - - #[test] - fn explicit_shift_and_uppercase_do_not_double_apply_shift() { assert_eq!( - parse_key_combo("shift+D"), - Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) + parse_key_combo("ampersand"), + Some((KeyCode::Char('&'), KeyModifiers::empty())) ); } #[test] - fn invalid_keybinding_is_rejected() { - assert_eq!(parse_key_combo("ctrl+foo+bar"), None); - assert_eq!(parse_key_combo("ctrl+"), None); - } - - #[test] - fn default_keybinds_parse() { - let config = Config::default(); + fn prefix_binding_is_not_direct_binding() { + let config: Config = toml::from_str( + r#" +[keys] +next_tab = "prefix+n" +"#, + ) + .unwrap(); let kb = config.keybinds(); - assert_eq!(kb.new_workspace.0, KeyCode::Char('n')); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) - ); assert_eq!( - kb.close_workspace, - (KeyCode::Char('d'), KeyModifiers::SHIFT) + binding_triggers(&kb.next_tab), + vec![BindingTrigger::Prefix(( + KeyCode::Char('n'), + KeyModifiers::empty() + ))] ); - assert_eq!(kb.detach, None); - assert_eq!(kb.previous_agent, None); - assert_eq!(kb.next_agent, None); - assert_eq!(kb.indexed_tabs, None); - assert_eq!(kb.indexed_workspaces, None); - assert_eq!(kb.indexed_agents, None); - assert_eq!(kb.split_vertical.0, KeyCode::Char('v')); - assert_eq!(kb.split_horizontal.0, KeyCode::Char('-')); - assert_eq!(kb.close_pane.0, KeyCode::Char('x')); - assert_eq!(kb.edit_scrollback, None); - assert_eq!(kb.zoom.0, KeyCode::Char('f')); - assert_eq!(kb.resize_mode.0, KeyCode::Char('r')); - assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b')); - assert!(kb.custom_commands.is_empty()); } #[test] - fn custom_keybinds_from_toml() { - let toml = r#" + fn array_bindings_allow_prefix_and_modified_direct() { + let config: Config = toml::from_str( + r#" [keys] -prefix = "ctrl+a" -new_workspace = "c" -rename_workspace = "shift+r" -close_workspace = "ctrl+d" -split_vertical = "s" -split_horizontal = "shift+s" -close_pane = "ctrl+w" -zoom = "z" -resize_mode = "ctrl+r" -toggle_sidebar = "tab" -previous_agent = "alt+a" -next_agent = "alt+d" -edit_scrollback = "e" -focus_pane_left = "alt+h" -focus_pane_right = "alt+right" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let (code, mods) = config.prefix_key(); - assert_eq!(code, KeyCode::Char('a')); - assert_eq!(mods, KeyModifiers::CONTROL); - +next_tab = ["prefix+n", "ctrl+alt+]"] +"#, + ) + .unwrap(); let kb = config.keybinds(); assert_eq!( - kb.new_workspace, - (KeyCode::Char('c'), KeyModifiers::empty()) - ); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('r'), KeyModifiers::SHIFT) - ); - assert_eq!( - kb.close_workspace, - (KeyCode::Char('d'), KeyModifiers::CONTROL) - ); - assert_eq!(kb.split_vertical.0, KeyCode::Char('s')); - assert_eq!( - kb.split_horizontal, - (KeyCode::Char('s'), KeyModifiers::SHIFT) + binding_triggers(&kb.next_tab), + vec![ + BindingTrigger::Prefix((KeyCode::Char('n'), KeyModifiers::empty())), + BindingTrigger::Direct(( + KeyCode::Char(']'), + KeyModifiers::CONTROL | KeyModifiers::ALT + )), + ] ); - assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL)); - assert_eq!(kb.zoom.0, KeyCode::Char('z')); - assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL)); - assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty())); - assert_eq!( - kb.previous_agent, - Some((KeyCode::Char('a'), KeyModifiers::ALT)) - ); - assert_eq!(kb.next_agent, Some((KeyCode::Char('d'), KeyModifiers::ALT))); - assert_eq!( - kb.edit_scrollback, - Some((KeyCode::Char('e'), KeyModifiers::empty())) - ); - assert_eq!( - kb.focus_pane_left, - Some((KeyCode::Char('h'), KeyModifiers::ALT)) - ); - assert_eq!( - kb.focus_pane_right, - Some((KeyCode::Right, KeyModifiers::ALT)) - ); - assert_eq!(kb.focus_pane_down, None); - assert_eq!(kb.focus_pane_up, None); + assert_eq!(kb.next_tab.prefix_rhs_label().as_deref(), Some("n")); } #[test] - fn legacy_fullscreen_keybind_alias_maps_to_zoom() { - let toml = r#" + fn unsafe_direct_printable_binding_is_disabled_with_diagnostic() { + let config: Config = toml::from_str( + r#" [keys] -fullscreen = "z" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - assert_eq!(kb.zoom.0, KeyCode::Char('z')); +new_tab = "c" +close_tab = "X" +"#, + ) + .unwrap(); + let diagnostics = config.collect_diagnostics(); + let keybinds = config.keybinds(); + assert!(keybinds.new_tab.bindings.is_empty()); + assert!(keybinds.close_tab.bindings.is_empty()); + assert!( + diagnostics + .iter() + .any(|diag| diag.contains("unsafe direct keybinding") + && diag.contains("keys.new_tab")) + ); + assert!(diagnostics.iter().any( + |diag| diag.contains("unsafe direct keybinding") && diag.contains("keys.close_tab") + )); } #[test] - fn uppercase_keybind_from_toml_flows_into_shift_combo() { - let toml = r#" -[keys] -close_pane = "X" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - assert_eq!(kb.close_pane, (KeyCode::Char('x'), KeyModifiers::SHIFT)); + fn shifted_letter_binding_matches_uppercase_key_event() { + let bindings = ActionKeybinds::prefix("shift+n"); + assert!(bindings.matches_prefix(&KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT))); } #[test] - fn invalid_keybinding_produces_diagnostic_and_falls_back() { - let toml = r#" -[keys] -rename_workspace = "wat" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("keys.rename_workspace"))); + fn shifted_tab_inputs_match_backtab_canonical_binding() { + let bindings = ActionKeybinds::prefix("shift+tab"); + assert!( + bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::empty())) + ); + assert!( + bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::SHIFT)) + ); + assert!(bindings.matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT))); + assert!(!ActionKeybinds::prefix("tab") + .matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT))); assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) + normalize_key_combo((KeyCode::Tab, KeyModifiers::CONTROL | KeyModifiers::SHIFT)), + (KeyCode::BackTab, KeyModifiers::CONTROL) ); - assert_eq!(kb.rename_workspace_label, "shift+n"); } #[test] - fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() { - let toml = r#" -[keys] -new_workspace = "g" -rename_workspace = "g" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("duplicate keybinding"))); + fn format_modified_backtab_keeps_shift_label() { assert_eq!( - kb.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) + format_key_combo((KeyCode::BackTab, KeyModifiers::CONTROL)), + "ctrl+shift+tab" ); assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) + format_key_combo((KeyCode::BackTab, KeyModifiers::CONTROL | KeyModifiers::ALT)), + "ctrl+alt+shift+tab" ); - assert_eq!(kb.rename_workspace_label, "shift+n"); } #[test] - fn duplicate_optional_keybinding_is_disabled_with_diagnostic() { - let toml = r#" -[keys] -new_workspace = "g" -rename_tab = "g" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding"))); - assert_eq!( - kb.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - assert_eq!(kb.rename_tab, None); + fn shifted_punctuation_matches_enhanced_input() { + let help = ActionKeybinds::prefix("?"); + assert!(help.matches_prefix_key(TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT))); + assert!(help.matches_prefix_key( + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_shifted_codepoint('?' as u32) + )); + + let bang = ActionKeybinds::prefix("!"); + assert!(bang.matches_prefix_key( + TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) + .with_shifted_codepoint('!' as u32) + )); } #[test] - fn custom_command_keybinds_parse_from_toml() { - let toml = r#" -[[keys.command]] -key = "g" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); + fn prefix_rhs_equal_to_configured_prefix_is_rejected() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +help = "prefix+ctrl+a" +"#, + ) + .unwrap(); + let diagnostics = config.collect_diagnostics(); + assert!(config.keybinds().help.bindings.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("reserved keybinding") + && diag.contains("keys.help") + && diag.contains("keys.prefix") + })); - assert_eq!(kb.custom_commands.len(), 1); - assert_eq!( - kb.custom_commands[0].key, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - assert_eq!(kb.custom_commands[0].label, "g"); - assert_eq!(kb.custom_commands[0].command, "echo hi"); - assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell); + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +help = "prefix+ctrl+b" +"#, + ) + .unwrap(); + assert!(!config.keybinds().help.bindings.is_empty()); } #[test] - fn pane_custom_command_keybinds_parse_from_toml() { - let toml = r#" -[[keys.command]] -key = "g" -type = "pane" -command = "lazygit" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); + fn custom_command_prefix_rhs_equal_to_configured_prefix_is_rejected() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+b" - assert_eq!(kb.custom_commands.len(), 1); - assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane); +[[keys.command]] +key = "prefix+ctrl+b" +command = "echo no" +"#, + ) + .unwrap(); + let diagnostics = config.collect_diagnostics(); + assert!(config.keybinds().custom_commands.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("reserved keybinding") && diag.contains("keys.command[0].key") + })); } #[test] - fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() { - let toml = r#" + fn direct_custom_printable_binding_is_rejected_as_unsafe() { + let config: Config = toml::from_str( + r#" [keys] -new_workspace = "g" [[keys.command]] key = "g" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); +command = "echo no" +"#, + ) + .unwrap(); let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate custom keybinding") - && d.contains("keys.command[0].key") - && d.contains("keys.new_workspace") + assert!(config.keybinds().custom_commands.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("unsafe direct keybinding") && diag.contains("keys.command[0].key") })); - assert!(kb.custom_commands.is_empty()); } #[test] - fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() { - let toml = r#" + fn direct_custom_binding_conflicting_with_builtin_is_disabled() { + let config: Config = toml::from_str( + r#" +[keys] +new_tab = "ctrl+alt+g" + [[keys.command]] -key = "q" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); +key = "ctrl+alt+g" +command = "echo no" +"#, + ) + .unwrap(); let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate custom keybinding") - && d.contains("keys.command[0].key") - && d.contains("navigate.quit") + let keybinds = config.keybinds(); + assert!(!keybinds.new_tab.bindings.is_empty()); + assert!(keybinds.custom_commands.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("duplicate keybinding") && diag.contains("keys.command[0].key") })); - assert!(kb.custom_commands.is_empty()); } #[test] - fn indexed_keybinds_parse_from_toml() { - let toml = r#" -[keys.indexed] -tabs = "ctrl" -workspaces = "ctrl+shift" -agents = "alt" -"#; - let config: Config = toml::from_str(toml).unwrap(); + fn prefixed_indexed_bindings_support_modifiers() { + let config: Config = toml::from_str( + r#" +[keys] +switch_workspace = "prefix+shift+1..9" +"#, + ) + .unwrap(); let kb = config.keybinds(); - - assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL)); + assert_eq!(kb.switch_workspace.len(), 9); assert_eq!( - kb.indexed_workspaces, - Some(KeyModifiers::CONTROL | KeyModifiers::SHIFT) + kb.switch_workspace[0].trigger, + BindingTrigger::Prefix((KeyCode::Char('1'), KeyModifiers::SHIFT)) ); - assert_eq!(kb.indexed_agents, Some(KeyModifiers::ALT)); - assert_eq!(kb.indexed_tabs_label.as_deref(), Some("ctrl+1..9")); - } - - #[test] - fn indexed_keybinding_conflict_disables_later_family() { - let toml = r#" -[keys.indexed] -tabs = "ctrl" -workspaces = "ctrl" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL)); - assert_eq!(kb.indexed_workspaces, None); - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate indexed keybinding") - && d.contains("keys.indexed.workspaces") - && d.contains("keys.indexed.tabs") - })); + assert_eq!(kb.switch_workspace[0].label, "prefix+shift+1"); } #[test] - fn terminal_direct_keybinding_conflict_disables_later_binding() { - let toml = r#" -[keys] -previous_tab = "alt+h" -focus_pane_left = "alt+h" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - + fn default_keymap_is_prefix_first_and_tab_centered() { + let kb = Config::default().keybinds(); assert_eq!( - kb.previous_tab, - Some((KeyCode::Char('h'), KeyModifiers::ALT)) + binding_triggers(&kb.next_tab), + vec![BindingTrigger::Prefix(( + KeyCode::Char('n'), + KeyModifiers::empty() + ))] ); - assert_eq!(kb.focus_pane_left, None); - assert!(diagnostics + assert_eq!( + binding_triggers(&kb.previous_tab), + vec![BindingTrigger::Prefix(( + KeyCode::Char('p'), + KeyModifiers::empty() + ))] + ); + assert_eq!(kb.switch_tab.len(), 9); + assert!(kb + .switch_tab .iter() - .any(|d| { d.contains("keys.focus_pane_left") && d.contains("keys.previous_tab") })); - } - - #[test] - fn terminal_direct_keybinding_conflicting_with_prefix_is_disabled() { - let toml = r#" -[keys] -prefix = "ctrl+b" -previous_tab = "ctrl+b" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert_eq!(kb.previous_tab, None); - assert!(diagnostics + .all(|binding| binding.trigger.is_prefix())); + assert!(kb + .new_tab + .bindings .iter() - .any(|d| { d.contains("keys.previous_tab") && d.contains("keys.prefix") })); + .all(|binding| binding.trigger.is_prefix())); } #[test] - fn live_keybinds_reject_invalid_keybinding() { + fn duplicate_prefix_binding_disables_later_binding() { let config: Config = toml::from_str( r#" [keys] -rename_workspace = "wat" +next_tab = "prefix+n" +new_workspace = "prefix+n" "#, ) .unwrap(); - - let diagnostics = config.live_keybinds().unwrap_err(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + assert!(kb.next_tab.bindings.is_empty() || kb.new_workspace.bindings.is_empty()); assert!(diagnostics .iter() - .any(|d| d.contains("keys.rename_workspace"))); - } - - #[test] - fn live_keybinds_ignore_non_key_diagnostics() { - let config: Config = toml::from_str( - r#" -[keys] -new_workspace = "g" - -[ui.sound] -done_path = "sounds/missing.mp3" -"#, - ) - .unwrap(); - - let live = config.live_keybinds().unwrap(); - assert_eq!( - live.keybinds.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); + .any(|diag| diag.contains("duplicate keybinding"))); } } diff --git a/src/config/model.rs b/src/config/model.rs index ff3a7b22..10e80417 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Deserializer, Serialize}; -use super::{CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES}; +use super::{ + BindingConfig, CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "lowercase")] @@ -90,63 +92,81 @@ pub struct LoadedConfig { #[derive(Debug, Deserialize)] #[serde(default)] pub struct KeysConfig { - /// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc"). + /// Prefix key to enter prefix mode (e.g. "ctrl+b", "f12", "esc"). pub prefix: String, - /// Create a new workspace. Default: "n" - pub new_workspace: String, - /// Rename the selected workspace. Default: "shift+n" - pub rename_workspace: String, - /// Close the selected workspace. Default: "shift+d" - pub close_workspace: String, - /// Optional explicit detach shortcut in server/client mode. Unset by default. - pub detach: String, - /// Reload config.toml in the running app/server. Unset by default. - pub reload_config: String, - /// Focus the currently visible notification target. Unset by default. - pub open_notification_target: String, + /// Open keybinding help. Default: "prefix+?" + pub help: BindingConfig, + /// Open settings. Default: "prefix+s" + pub settings: BindingConfig, + /// Quit or detach. Default: "prefix+q" + pub quit: BindingConfig, + /// Create a new workspace. Default: "prefix+shift+n" + pub new_workspace: BindingConfig, + /// Rename the selected workspace. Default: "prefix+shift+w" + pub rename_workspace: BindingConfig, + /// Close the selected workspace. Default: "prefix+shift+d" + pub close_workspace: BindingConfig, + /// Open the workspace navigation surface. Default: "prefix+w" + pub workspace_picker: BindingConfig, + /// Optional explicit detach shortcut in server/client mode. Default: "prefix+d". + pub detach: BindingConfig, + /// Reload config.toml in the running app/server. Default: "prefix+shift+r". + pub reload_config: BindingConfig, + /// Focus the currently visible notification target. Default: "prefix+o". + pub open_notification_target: BindingConfig, /// Select the previous workspace. Unset by default. - pub previous_workspace: String, + pub previous_workspace: BindingConfig, /// Select the next workspace. Unset by default. - pub next_workspace: String, + pub next_workspace: BindingConfig, /// Focus the previous agent shown in the agent panel. Unset by default. - pub previous_agent: String, + pub previous_agent: BindingConfig, /// Focus the next agent shown in the agent panel. Unset by default. - pub next_agent: String, - /// Create a new tab in the active workspace. Default: "c" - pub new_tab: String, - /// Rename the active tab. Unset by default. - pub rename_tab: String, - /// Select the previous tab. Unset by default. - pub previous_tab: String, - /// Select the next tab. Unset by default. - pub next_tab: String, - /// Close the active tab. Unset by default. - pub close_tab: String, - /// Rename the focused pane. Unset by default. - pub rename_pane: String, - /// Open the focused pane scrollback in $EDITOR. Unset by default. - pub edit_scrollback: String, - /// Focus the pane to the left in terminal mode. Unset by default. - pub focus_pane_left: String, - /// Focus the pane below in terminal mode. Unset by default. - pub focus_pane_down: String, - /// Focus the pane above in terminal mode. Unset by default. - pub focus_pane_up: String, - /// Focus the pane to the right in terminal mode. Unset by default. - pub focus_pane_right: String, - /// Split pane vertically (side by side). Default: "v" - pub split_vertical: String, - /// Split pane horizontally (stacked). Default: "-" - pub split_horizontal: String, - /// Close the focused pane. Default: "x" - pub close_pane: String, - /// Toggle zoom for the focused pane. Default: "f" + pub next_agent: BindingConfig, + /// Focus an agent by index 1-9. Unset by default. + pub focus_agent: BindingConfig, + /// Create a new tab in the active workspace. Default: "prefix+c" + pub new_tab: BindingConfig, + /// Rename the active tab. Default: "prefix+shift+t". + pub rename_tab: BindingConfig, + /// Select the previous tab. Default: "prefix+p". + pub previous_tab: BindingConfig, + /// Select the next tab. Default: "prefix+n". + pub next_tab: BindingConfig, + /// Switch to tab 1-9. Default: "prefix+1..9". + pub switch_tab: BindingConfig, + /// Switch to workspace 1-9 from prefix mode. Unset by default. + pub switch_workspace: BindingConfig, + /// Close the active tab. Default: "prefix+shift+x". + pub close_tab: BindingConfig, + /// Rename the focused pane. Default: "prefix+shift+p". + pub rename_pane: BindingConfig, + /// Open the focused pane scrollback in $EDITOR. Default: "prefix+e". + pub edit_scrollback: BindingConfig, + /// Focus the pane to the left. Default: "prefix+h". + pub focus_pane_left: BindingConfig, + /// Focus the pane below. Default: "prefix+j". + pub focus_pane_down: BindingConfig, + /// Focus the pane above. Default: "prefix+k". + pub focus_pane_up: BindingConfig, + /// Focus the pane to the right. Default: "prefix+l". + pub focus_pane_right: BindingConfig, + /// Cycle to the next pane. Default: "prefix+tab". + pub cycle_pane_next: BindingConfig, + /// Cycle to the previous pane. Default: "prefix+shift+tab". + pub cycle_pane_previous: BindingConfig, + /// Split pane vertically (side by side). Default: "prefix+v" + pub split_vertical: BindingConfig, + /// Split pane horizontally (stacked). Default: "prefix+minus" + pub split_horizontal: BindingConfig, + /// Close the focused pane. Default: "prefix+x" + pub close_pane: BindingConfig, + /// Toggle zoom for the focused pane. Default: "prefix+z" #[serde(alias = "fullscreen")] - pub zoom: String, - /// Enter resize mode. Default: "r" - pub resize_mode: String, - /// Toggle sidebar collapse. Default: "b" - pub toggle_sidebar: String, + pub zoom: BindingConfig, + /// Enter resize mode. Default: "prefix+r" + pub resize_mode: BindingConfig, + /// Toggle sidebar collapse. Default: "prefix+b" + pub toggle_sidebar: BindingConfig, /// Optional indexed shortcuts expanded over number keys 1-9. pub indexed: IndexedKeysConfig, /// Prefix-mode custom command bindings. @@ -212,33 +232,42 @@ impl Default for KeysConfig { fn default() -> Self { Self { prefix: "ctrl+b".into(), - new_workspace: "n".into(), - rename_workspace: "shift+n".into(), - close_workspace: "shift+d".into(), - detach: "".into(), - reload_config: "".into(), - open_notification_target: "".into(), - previous_workspace: "".into(), - next_workspace: "".into(), - previous_agent: "".into(), - next_agent: "".into(), - new_tab: "c".into(), - rename_tab: "".into(), - previous_tab: "".into(), - next_tab: "".into(), - close_tab: "".into(), - rename_pane: "".into(), - edit_scrollback: "".into(), - focus_pane_left: "".into(), - focus_pane_down: "".into(), - focus_pane_up: "".into(), - focus_pane_right: "".into(), - split_vertical: "v".into(), - split_horizontal: "-".into(), - close_pane: "x".into(), - zoom: "f".into(), - resize_mode: "r".into(), - toggle_sidebar: "b".into(), + help: BindingConfig::one("prefix+?"), + settings: BindingConfig::one("prefix+s"), + quit: BindingConfig::one("prefix+q"), + new_workspace: BindingConfig::one("prefix+shift+n"), + rename_workspace: BindingConfig::one("prefix+shift+w"), + close_workspace: BindingConfig::one("prefix+shift+d"), + workspace_picker: BindingConfig::one("prefix+w"), + detach: BindingConfig::one("prefix+d"), + reload_config: BindingConfig::one("prefix+shift+r"), + open_notification_target: BindingConfig::one("prefix+o"), + previous_workspace: BindingConfig::empty(), + next_workspace: BindingConfig::empty(), + previous_agent: BindingConfig::empty(), + next_agent: BindingConfig::empty(), + focus_agent: BindingConfig::empty(), + new_tab: BindingConfig::one("prefix+c"), + rename_tab: BindingConfig::one("prefix+shift+t"), + previous_tab: BindingConfig::one("prefix+p"), + next_tab: BindingConfig::one("prefix+n"), + switch_tab: BindingConfig::one("prefix+1..9"), + switch_workspace: BindingConfig::empty(), + close_tab: BindingConfig::one("prefix+shift+x"), + rename_pane: BindingConfig::one("prefix+shift+p"), + edit_scrollback: BindingConfig::one("prefix+e"), + focus_pane_left: BindingConfig::one("prefix+h"), + focus_pane_down: BindingConfig::one("prefix+j"), + focus_pane_up: BindingConfig::one("prefix+k"), + focus_pane_right: BindingConfig::one("prefix+l"), + cycle_pane_next: BindingConfig::one("prefix+tab"), + cycle_pane_previous: BindingConfig::one("prefix+shift+tab"), + split_vertical: BindingConfig::one("prefix+v"), + split_horizontal: BindingConfig::one("prefix+minus"), + close_pane: BindingConfig::one("prefix+x"), + zoom: BindingConfig::one("prefix+z"), + resize_mode: BindingConfig::one("prefix+r"), + toggle_sidebar: BindingConfig::one("prefix+b"), indexed: IndexedKeysConfig::default(), command: Vec::new(), } |
