diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-15 20:31:01 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-15 20:31:01 +0300 |
| commit | 4cbe72ec2d8a174e3c4f9bde2101aa189c7f38e7 (patch) | |
| tree | 2de696538e494d3c14a920c20877bcd6d4ac39dc /src/config | |
| parent | 43d60fc6629d222ae63813ebcf4531067ba5bc9d (diff) | |
feat: add indexed keybind families
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/keybinds.rs | 338 | ||||
| -rw-r--r-- | src/config/model.rs | 14 |
2 files changed, 319 insertions, 33 deletions
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index a18dcbc3..de4f03a9 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -77,6 +77,12 @@ pub struct Keybinds { 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)>, @@ -138,12 +144,18 @@ impl Config { } struct OptionalBinding { - scope: BindingScope, + scopes: Vec<BindingScope>, field: &'static str, value: Option<(KeyCode, KeyModifiers)>, label: Option<String>, } + struct IndexedBinding { + field: &'static str, + value: Option<KeyModifiers>, + label: Option<String>, + } + #[derive(Default)] struct BindingRegistry { seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>, @@ -208,14 +220,14 @@ impl Config { } fn optional_binding( - scope: BindingScope, + scopes: Vec<BindingScope>, field: &'static str, configured_label: &str, diagnostics: &mut Vec<String>, ) -> OptionalBinding { if configured_label.trim().is_empty() { return OptionalBinding { - scope, + scopes, field, value: None, label: None, @@ -223,7 +235,7 @@ impl Config { } match parse_key_combo(configured_label) { Some(value) => OptionalBinding { - scope, + scopes, field, value: Some(value), label: Some(configured_label.to_string()), @@ -236,7 +248,41 @@ impl Config { warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); OptionalBinding { - scope, + scopes, + field, + value: None, + label: None, + } + } + } + } + + 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, + }; + } + 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, @@ -338,105 +384,128 @@ impl Config { ), ]; + 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( - BindingScope::Navigate, + navigate_scope(), "keys.detach", &self.keys.detach, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + navigate_scope(), "keys.reload_config", &self.keys.reload_config, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + navigate_scope(), "keys.open_notification_target", &self.keys.open_notification_target, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + direct_navigation_scopes(), "keys.previous_workspace", &self.keys.previous_workspace, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + direct_navigation_scopes(), "keys.next_workspace", &self.keys.next_workspace, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + direct_navigation_scopes(), "keys.previous_agent", &self.keys.previous_agent, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + direct_navigation_scopes(), "keys.next_agent", &self.keys.next_agent, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + navigate_scope(), "keys.rename_tab", &self.keys.rename_tab, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + direct_navigation_scopes(), "keys.previous_tab", &self.keys.previous_tab, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + direct_navigation_scopes(), "keys.next_tab", &self.keys.next_tab, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + navigate_scope(), "keys.close_tab", &self.keys.close_tab, &mut diagnostics, ), optional_binding( - BindingScope::Navigate, + navigate_scope(), "keys.rename_pane", &self.keys.rename_pane, &mut diagnostics, ), optional_binding( - BindingScope::TerminalDirect, + terminal_direct_scope(), "keys.focus_pane_left", &self.keys.focus_pane_left, &mut diagnostics, ), optional_binding( - BindingScope::TerminalDirect, + terminal_direct_scope(), "keys.focus_pane_down", &self.keys.focus_pane_down, &mut diagnostics, ), optional_binding( - BindingScope::TerminalDirect, + terminal_direct_scope(), "keys.focus_pane_up", &self.keys.focus_pane_up, &mut diagnostics, ), optional_binding( - BindingScope::TerminalDirect, + 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::default(); for binding in &mut bindings { if let Some(first_field) = registry.conflict(binding.scope, binding.value) { @@ -452,11 +521,18 @@ impl Config { registry.register(binding.scope, binding.value, binding.field); } + registry.reserve_if_unbound(BindingScope::TerminalDirect, prefix, "keys.prefix"); + for binding in &mut optional_bindings { let Some(value) = binding.value else { continue; }; - if let Some(first_field) = registry.conflict(binding.scope, value) { + + 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 @@ -467,7 +543,9 @@ impl Config { binding.label = None; continue; } - registry.register(binding.scope, value, binding.field); + for scope in &binding.scopes { + registry.register(*scope, value, binding.field); + } } registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix"); @@ -563,6 +641,47 @@ impl Config { 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; + } + + 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); + } + } + let mut custom_commands = Vec::new(); for (index, command) in self.keys.command.iter().enumerate() { let key_field = format!("keys.command[{index}].key"); @@ -630,6 +749,12 @@ impl Config { 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, @@ -681,6 +806,15 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { if modifiers.contains(KeyModifiers::SHIFT) { parts.push("shift".to_string()); } + if modifiers.contains(KeyModifiers::SUPER) { + parts.push(super_modifier_label().to_string()); + } + if modifiers.contains(KeyModifiers::HYPER) { + parts.push("hyper".to_string()); + } + if modifiers.contains(KeyModifiers::META) { + parts.push("meta".to_string()); + } let key = match code { KeyCode::Char(' ') => "space".to_string(), @@ -710,6 +844,47 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { parts.join("+") } +fn super_modifier_label() -> &'static str { + if cfg!(target_os = "macos") { + "cmd" + } else { + "super" + } +} + +fn parse_modifier_token(token: &str) -> Option<KeyModifiers> { + match token.to_lowercase().as_str() { + "ctrl" | "control" => Some(KeyModifiers::CONTROL), + "shift" => Some(KeyModifiers::SHIFT), + "alt" | "option" | "meta" => Some(KeyModifiers::ALT), + "cmd" | "command" | "super" => Some(KeyModifiers::SUPER), + "hyper" => Some(KeyModifiers::HYPER), + _ => None, + } +} + +fn parse_modifier_combo(s: &str) -> Option<KeyModifiers> { + let mut modifiers = KeyModifiers::empty(); + let parts: Vec<&str> = s.split('+').collect(); + if parts.is_empty() { + return None; + } + + for part in &parts { + let trimmed = part.trim(); + if trimmed.is_empty() { + return None; + } + modifiers |= parse_modifier_token(trimmed)?; + } + + if modifiers.is_empty() { + None + } else { + Some(modifiers) + } +} + pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { let parts: Vec<&str> = s.split('+').collect(); let mut modifiers = KeyModifiers::empty(); @@ -717,17 +892,16 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { for part in &parts { let trimmed = part.trim(); - match trimmed.to_lowercase().as_str() { - "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, - "shift" => modifiers |= KeyModifiers::SHIFT, - "alt" | "meta" => modifiers |= KeyModifiers::ALT, - _ if trimmed.is_empty() => return None, - _ => { - if key_str.is_some() { - return None; - } - key_str = Some(trimmed); + if trimmed.is_empty() { + return None; + } + if let Some(modifier) = parse_modifier_token(trimmed) { + modifiers |= modifier; + } else { + if key_str.is_some() { + return None; } + key_str = Some(trimmed); } } @@ -797,6 +971,23 @@ mod tests { } #[test] + fn parse_cmd_combo() { + assert_eq!( + parse_key_combo("cmd+1"), + Some((KeyCode::Char('1'), KeyModifiers::SUPER)) + ); + } + + #[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); + } + + #[test] fn parse_special_key() { assert_eq!( parse_key_combo("enter"), @@ -885,6 +1076,9 @@ mod tests { 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')); @@ -1111,6 +1305,84 @@ command = "echo hi" } #[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(); + let kb = config.keybinds(); + + assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL)); + assert_eq!( + kb.indexed_workspaces, + Some(KeyModifiers::CONTROL | 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") + })); + } + + #[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(); + + assert_eq!( + kb.previous_tab, + Some((KeyCode::Char('h'), KeyModifiers::ALT)) + ); + assert_eq!(kb.focus_pane_left, None); + assert!(diagnostics + .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 + .iter() + .any(|d| { d.contains("keys.previous_tab") && d.contains("keys.prefix") })); + } + + #[test] fn live_keybinds_reject_invalid_keybinding() { let config: Config = toml::from_str( r#" diff --git a/src/config/model.rs b/src/config/model.rs index 8ea52724..ed8c4dd5 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -123,10 +123,23 @@ pub struct KeysConfig { pub resize_mode: String, /// Toggle sidebar collapse. Default: "b" pub toggle_sidebar: String, + /// Optional indexed shortcuts expanded over number keys 1-9. + pub indexed: IndexedKeysConfig, /// Prefix-mode custom command bindings. pub command: Vec<CommandKeybindConfig>, } +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct IndexedKeysConfig { + /// Modifier combo for tab shortcuts 1-9. Unset by default. + pub tabs: String, + /// Modifier combo for workspace shortcuts 1-9. Unset by default. + pub workspaces: String, + /// Modifier combo for agent shortcuts 1-9. Unset by default. + pub agents: String, +} + #[derive(Debug, Deserialize)] #[serde(default)] pub struct UiConfig { @@ -195,6 +208,7 @@ impl Default for KeysConfig { fullscreen: "f".into(), resize_mode: "r".into(), toggle_sidebar: "b".into(), + indexed: IndexedKeysConfig::default(), command: Vec::new(), } } |
