#[cfg(test)] use crossterm::event::KeyEvent; use crossterm::event::{KeyCode, KeyModifiers}; use serde::{Deserialize, Serialize}; use tracing::warn; use super::Config; use crate::input::TerminalKey; 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 { /// Every configured prefix key. The first entry is the primary prefix used /// for compact display; all entries enter prefix mode. pub prefix: Vec, 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 { 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 { One(String), Many(Vec), } impl Default for BindingConfig { fn default() -> Self { Self::One(String::new()) } } impl BindingConfig { pub fn one(value: impl Into) -> 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(), } } pub(crate) fn into_values(self) -> Vec { 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()) } pub(crate) fn indexed_labels(&self) -> Vec { let mut labels = Vec::new(); for raw in self.values() { let raw = raw.trim(); if raw.is_empty() { continue; } match parse_binding_string(raw) { Some(ParsedBinding::Single(binding)) => { if matches!(binding.trigger.combo().0, KeyCode::Char('1'..='9')) { labels.push(binding.label); } } Some(ParsedBinding::Range(range)) => { labels.extend(range.into_iter().filter_map(|binding| { matches!(binding.trigger.combo().0, KeyCode::Char('1'..='9')) .then_some(binding.label) })); } None => {} } } labels } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "snake_case")] pub enum CommandKeybindType { #[default] Shell, Pane, Popup, PluginAction, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct CommandKeybindConfig { /// 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". #[serde(rename = "type")] pub action_type: CommandKeybindType, /// Optional user-defined description for this custom command. pub description: Option, /// Optional popup width as cells or a percentage string when type = "popup". pub width: Option, /// Optional popup height as cells or a percentage string when type = "popup". pub height: Option, } impl Default for CommandKeybindConfig { fn default() -> Self { Self { key: BindingConfig::empty(), command: String::new(), action_type: CommandKeybindType::Shell, description: None, width: None, height: None, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CustomCommandAction { Shell, Pane, Popup, PluginAction, } #[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, } 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()) } } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ActionKeybinds { pub bindings: Vec, } impl ActionKeybinds { pub(crate) fn from_labels(labels: &[String]) -> Result { let mut bindings = Vec::new(); for label in labels { match parse_binding_string(label) { Some(ParsedBinding::Single(binding)) => bindings.push(binding), Some(ParsedBinding::Range(range)) => bindings.extend(range), None => return Err(format!("invalid endpoint command binding: {label}")), } } Ok(Self { bindings }) } #[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], } } #[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)) } 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 { self.bindings .iter() .map(|binding| binding.label.clone()) .collect() } pub fn label(&self) -> Option { let labels = self.labels(); if labels.is_empty() { None } else { Some(labels.join(" / ")) } } pub fn prefix_rhs_label(&self) -> Option { let labels: Vec = 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, } impl IndexedKeybind { pub fn matched_index(&self, key: &TerminalKey) -> Option { let combo = self.trigger.combo(); let (expected_code, _) = normalize_key_combo(combo); let KeyCode::Char(key_number @ '1'..='9') = expected_code else { return None; }; let legacy_shifted_number = matches!(key.code, KeyCode::Char(c) if shifted_number_symbol(c) == Some(key_number) && indexed_shifted_number_matches(key, combo, key_number)); if terminal_key_matches_combo(key, combo) || legacy_shifted_number { Some((key_number as usize) - ('1' as usize)) } else { None } } } #[derive(Debug, Clone)] pub struct CustomCommandKeybind { pub bindings: ActionKeybinds, pub label: String, pub command: String, pub action: CustomCommandAction, pub description: Option, pub width: Option, pub height: Option, } /// Parsed keybinds for Herdr actions. #[derive(Debug, Clone)] pub struct NavigateKeybinds { pub workspace_up: ActionKeybinds, pub workspace_down: ActionKeybinds, pub pane_left: ActionKeybinds, pub pane_down: ActionKeybinds, pub pane_up: ActionKeybinds, pub pane_right: ActionKeybinds, } /// Parsed keybinds for Herdr actions. #[derive(Debug, Clone)] pub struct Keybinds { pub navigate: NavigateKeybinds, pub help: ActionKeybinds, pub settings: ActionKeybinds, pub new_workspace: ActionKeybinds, pub new_worktree: ActionKeybinds, pub open_worktree: ActionKeybinds, pub remove_worktree: ActionKeybinds, pub rename_workspace: ActionKeybinds, pub close_workspace: ActionKeybinds, pub workspace_picker: ActionKeybinds, pub goto: 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, pub new_tab: ActionKeybinds, pub rename_tab: ActionKeybinds, pub previous_tab: ActionKeybinds, pub next_tab: ActionKeybinds, pub move_tab_previous: ActionKeybinds, pub move_tab_next: ActionKeybinds, pub switch_tab: Vec, pub switch_workspace: Vec, pub close_tab: ActionKeybinds, pub rename_pane: ActionKeybinds, pub edit_scrollback: ActionKeybinds, pub clear_pane: ActionKeybinds, pub copy_mode: ActionKeybinds, pub focus_pane_left: ActionKeybinds, pub focus_pane_down: ActionKeybinds, pub focus_pane_up: ActionKeybinds, pub focus_pane_right: ActionKeybinds, pub swap_pane_left: ActionKeybinds, pub swap_pane_down: ActionKeybinds, pub swap_pane_up: ActionKeybinds, pub swap_pane_right: ActionKeybinds, pub cycle_pane_next: ActionKeybinds, pub cycle_pane_previous: ActionKeybinds, pub last_pane: ActionKeybinds, pub split_vertical: ActionKeybinds, pub split_horizontal: ActionKeybinds, pub close_pane: ActionKeybinds, pub zoom: ActionKeybinds, pub resize_mode: ActionKeybinds, pub resize_pane_left: ActionKeybinds, pub resize_pane_down: ActionKeybinds, pub resize_pane_up: ActionKeybinds, pub resize_pane_right: ActionKeybinds, pub toggle_sidebar: ActionKeybinds, pub custom_commands: Vec, } impl Default for Keybinds { fn default() -> Self { Config::default().keybinds() } } #[derive(Clone)] enum ParsedBinding { Single(ResolvedBinding), Range(Vec), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum BindingSource { Default, User, } struct RegisteredBinding { field: String, source: BindingSource, } struct BindingRegistry { prefix_combos: Vec, prefix_source: BindingSource, direct: std::collections::HashMap, prefix: std::collections::HashMap, } impl BindingRegistry { fn new(prefix_combos: Vec, prefix_source: BindingSource) -> Self { let mut normalized: Vec = prefix_combos.into_iter().map(normalize_key_combo).collect(); normalized.dedup(); Self { prefix_combos: normalized, prefix_source, direct: std::collections::HashMap::new(), prefix: std::collections::HashMap::new(), } } fn reserve_direct(&mut self, combo: KeyCombo, field: &str, source: BindingSource) { self.direct .entry(normalize_key_combo(combo)) .or_insert_with(|| RegisteredBinding { field: field.to_string(), source, }); } /// 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 { let combo = normalize_key_combo(combo); self.prefix_combos.contains(&combo) } fn conflict(&self, binding: &ResolvedBinding) -> Option<&RegisteredBinding> { match binding.trigger { BindingTrigger::Direct(combo) => self.direct.get(&normalize_key_combo(combo)), BindingTrigger::Prefix(combo) => self.prefix.get(&normalize_key_combo(combo)), } } fn register(&mut self, binding: &ResolvedBinding, field: &str, source: BindingSource) { let registered = || RegisteredBinding { field: field.to_string(), source, }; match binding.trigger { BindingTrigger::Direct(combo) => { self.direct.insert(normalize_key_combo(combo), registered()); } BindingTrigger::Prefix(combo) => { self.prefix.insert(normalize_key_combo(combo), registered()); } } } } impl Config { pub(super) fn validated_keybinds( &self, ) -> (Option, Vec, Vec, 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_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 { () => { ActionKeybinds::default() }; } let mut keybinds = Keybinds { navigate: NavigateKeybinds { workspace_up: empty_action!(), workspace_down: empty_action!(), pane_left: empty_action!(), pane_down: empty_action!(), pane_up: empty_action!(), pane_right: empty_action!(), }, help: empty_action!(), settings: empty_action!(), new_workspace: empty_action!(), new_worktree: empty_action!(), open_worktree: empty_action!(), remove_worktree: empty_action!(), rename_workspace: empty_action!(), close_workspace: empty_action!(), workspace_picker: empty_action!(), goto: empty_action!(), detach: empty_action!(), reload_config: empty_action!(), open_notification_target: empty_action!(), previous_workspace: empty_action!(), next_workspace: empty_action!(), previous_agent: empty_action!(), next_agent: empty_action!(), focus_agent: Vec::new(), new_tab: empty_action!(), rename_tab: empty_action!(), previous_tab: empty_action!(), next_tab: empty_action!(), move_tab_previous: empty_action!(), move_tab_next: empty_action!(), switch_tab: Vec::new(), switch_workspace: Vec::new(), close_tab: empty_action!(), rename_pane: empty_action!(), edit_scrollback: empty_action!(), clear_pane: empty_action!(), copy_mode: empty_action!(), focus_pane_left: empty_action!(), focus_pane_down: empty_action!(), focus_pane_up: empty_action!(), focus_pane_right: empty_action!(), swap_pane_left: empty_action!(), swap_pane_down: empty_action!(), swap_pane_up: empty_action!(), swap_pane_right: empty_action!(), cycle_pane_next: empty_action!(), cycle_pane_previous: empty_action!(), last_pane: empty_action!(), split_vertical: empty_action!(), split_horizontal: empty_action!(), close_pane: empty_action!(), zoom: empty_action!(), resize_mode: empty_action!(), resize_pane_left: empty_action!(), resize_pane_down: empty_action!(), resize_pane_up: empty_action!(), resize_pane_right: empty_action!(), toggle_sidebar: empty_action!(), custom_commands: Vec::new(), }; macro_rules! field_source { ($field:ident) => { if self.keys.key_field_is_user_configured(stringify!($field)) { BindingSource::User } else { BindingSource::Default } }; } macro_rules! apply_action { ($target:expr, $field:ident, $source:expr) => { if field_source!($field) == $source { $target = parse_action_bindings( concat!("keys.", stringify!($field)), &self.keys.$field, &mut registry, &mut diagnostics, $source, ); } }; } macro_rules! apply_indexed { ( $target:expr, $field:ident, $legacy_config:expr, $source:expr ) => { if field_source!($field) == $source { if $source == BindingSource::Default && !$legacy_config.trim().is_empty() { // A legacy [keys.indexed] entry is user configuration for // this target and should displace the modern default. } else { $target = parse_indexed_bindings( concat!("keys.", stringify!($field)), &self.keys.$field, &mut registry, &mut diagnostics, $source, ); } } }; } macro_rules! apply_navigate { ($target:expr, $field:ident, $source:expr) => { if field_source!($field) == $source { $target = parse_navigate_bindings( concat!("keys.", stringify!($field)), &self.keys.$field, &mut navigate_registry, &mut diagnostics, $source, ); } }; } for source in [BindingSource::User, BindingSource::Default] { apply_navigate!( keybinds.navigate.workspace_up, navigate_workspace_up, source ); apply_navigate!( keybinds.navigate.workspace_down, navigate_workspace_down, source ); apply_navigate!(keybinds.navigate.pane_left, navigate_pane_left, source); apply_navigate!(keybinds.navigate.pane_down, navigate_pane_down, source); apply_navigate!(keybinds.navigate.pane_up, navigate_pane_up, source); apply_navigate!(keybinds.navigate.pane_right, navigate_pane_right, source); apply_action!(keybinds.help, help, source); apply_action!(keybinds.settings, settings, source); apply_action!(keybinds.new_workspace, new_workspace, source); apply_action!(keybinds.new_worktree, new_worktree, source); apply_action!(keybinds.open_worktree, open_worktree, source); apply_action!(keybinds.remove_worktree, remove_worktree, source); apply_action!(keybinds.rename_workspace, rename_workspace, source); apply_action!(keybinds.close_workspace, close_workspace, source); apply_action!(keybinds.workspace_picker, workspace_picker, source); apply_action!(keybinds.goto, goto, source); apply_action!(keybinds.detach, detach, source); apply_action!(keybinds.reload_config, reload_config, source); apply_action!( keybinds.open_notification_target, open_notification_target, source ); apply_action!(keybinds.previous_workspace, previous_workspace, source); apply_action!(keybinds.next_workspace, next_workspace, source); apply_action!(keybinds.previous_agent, previous_agent, source); apply_action!(keybinds.next_agent, next_agent, source); apply_indexed!( keybinds.focus_agent, focus_agent, &self.keys.indexed.agents, source ); apply_action!(keybinds.new_tab, new_tab, source); apply_action!(keybinds.rename_tab, rename_tab, source); apply_action!(keybinds.previous_tab, previous_tab, source); apply_action!(keybinds.next_tab, next_tab, source); apply_action!(keybinds.move_tab_previous, move_tab_previous, source); apply_action!(keybinds.move_tab_next, move_tab_next, source); apply_indexed!( keybinds.switch_tab, switch_tab, &self.keys.indexed.tabs, source ); apply_indexed!( keybinds.switch_workspace, switch_workspace, &self.keys.indexed.workspaces, source ); apply_action!(keybinds.close_tab, close_tab, source); apply_action!(keybinds.rename_pane, rename_pane, source); apply_action!(keybinds.edit_scrollback, edit_scrollback, source); apply_action!(keybinds.clear_pane, clear_pane, source); apply_action!(keybinds.copy_mode, copy_mode, source); apply_action!(keybinds.focus_pane_left, focus_pane_left, source); apply_action!(keybinds.focus_pane_down, focus_pane_down, source); apply_action!(keybinds.focus_pane_up, focus_pane_up, source); apply_action!(keybinds.focus_pane_right, focus_pane_right, source); apply_action!(keybinds.swap_pane_left, swap_pane_left, source); apply_action!(keybinds.swap_pane_down, swap_pane_down, source); apply_action!(keybinds.swap_pane_up, swap_pane_up, source); apply_action!(keybinds.swap_pane_right, swap_pane_right, source); apply_action!(keybinds.last_pane, last_pane, source); apply_action!(keybinds.cycle_pane_next, cycle_pane_next, source); apply_action!(keybinds.cycle_pane_previous, cycle_pane_previous, source); apply_action!(keybinds.split_vertical, split_vertical, source); apply_action!(keybinds.split_horizontal, split_horizontal, source); apply_action!(keybinds.close_pane, close_pane, source); apply_action!(keybinds.zoom, zoom, source); apply_action!(keybinds.resize_mode, resize_mode, source); apply_action!(keybinds.resize_pane_left, resize_pane_left, source); apply_action!(keybinds.resize_pane_down, resize_pane_down, source); apply_action!(keybinds.resize_pane_up, resize_pane_up, source); apply_action!(keybinds.resize_pane_right, resize_pane_right, source); apply_action!(keybinds.toggle_sidebar, toggle_sidebar, source); if source == field_source!(indexed) { append_legacy_indexed_bindings( &mut keybinds.switch_tab, "keys.indexed.tabs", &self.keys.indexed.tabs, &mut registry, &mut diagnostics, source, ); append_legacy_indexed_bindings( &mut keybinds.switch_workspace, "keys.indexed.workspaces", &self.keys.indexed.workspaces, &mut registry, &mut diagnostics, source, ); append_legacy_indexed_bindings( &mut keybinds.focus_agent, "keys.indexed.agents", &self.keys.indexed.agents, &mut registry, &mut diagnostics, source, ); } if source == BindingSource::User { append_custom_command_bindings( self, &mut keybinds, &mut registry, &mut diagnostics, ); } } (prefix_diag, prefix, diagnostics, keybinds) } } fn reserve_navigate_runtime_keys(registry: &mut BindingRegistry) { for combo in [ (KeyCode::Esc, KeyModifiers::empty()), (KeyCode::Enter, KeyModifiers::empty()), (KeyCode::Tab, KeyModifiers::empty()), (KeyCode::BackTab, KeyModifiers::empty()), (KeyCode::Tab, KeyModifiers::SHIFT), (KeyCode::Left, KeyModifiers::empty()), (KeyCode::Right, KeyModifiers::empty()), ] { registry.reserve_direct(combo, "navigate reserved keys", BindingSource::Default); } for idx in '1'..='9' { registry.reserve_direct( (KeyCode::Char(idx), KeyModifiers::empty()), "navigate reserved keys", BindingSource::Default, ); } } fn append_custom_command_bindings( config: &Config, keybinds: &mut Keybinds, registry: &mut BindingRegistry, diagnostics: &mut Vec, ) { for (index, command) in config.keys.command.iter().enumerate() { let key_field = format!("keys.command[{index}].key"); let command_field = format!("keys.command[{index}].command"); if command.command.trim().is_empty() { let diag = format!("empty custom command: {command_field}; disabling custom command"); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); continue; } let bindings = parse_action_bindings( &key_field, &command.key, registry, diagnostics, BindingSource::User, ); if bindings.bindings.is_empty() { continue; } let action = match command.action_type { CommandKeybindType::Shell => CustomCommandAction::Shell, CommandKeybindType::Pane => CustomCommandAction::Pane, CommandKeybindType::Popup => CustomCommandAction::Popup, CommandKeybindType::PluginAction => CustomCommandAction::PluginAction, }; let (width, height) = if action == CustomCommandAction::Popup { (command.width, command.height) } else { if command.width.is_some() || command.height.is_some() { let diag = format!( "popup size on non-popup custom command: keys.command[{index}]; ignoring width and height" ); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); } (None, None) }; let label = bindings.label().unwrap_or_else(|| "unset".to_string()); keybinds.custom_commands.push(CustomCommandKeybind { bindings, label, command: command.command.clone(), action, description: command.description.clone(), width, height, }); } } fn parse_action_bindings( field: &str, config: &BindingConfig, registry: &mut BindingRegistry, diagnostics: &mut Vec, source: BindingSource, ) -> 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, source) { continue; } registry.register(&binding, field, source); bindings.push(binding); } Some(ParsedBinding::Range(_)) => { let diag = format!("range keybinding is only valid for indexed actions: {field} = {raw:?}; disabling binding"); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); } None => { let diag = format!("invalid keybinding: {field} = {raw:?}; disabling binding"); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); } } } ActionKeybinds { bindings } } fn parse_navigate_bindings( field: &'static str, config: &BindingConfig, registry: &mut BindingRegistry, diagnostics: &mut Vec, source: BindingSource, ) -> 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_navigate_binding(field, &binding, registry, diagnostics, source) { continue; } registry.register(&binding, field, source); bindings.push(binding); } Some(ParsedBinding::Range(_)) => { let diag = format!("range keybinding is only valid for indexed actions: {field} = {raw:?}; disabling binding"); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); } 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, source: BindingSource, ) -> Vec { 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)) => { push_indexed_binding(field, binding, registry, diagnostics, source, &mut bindings); } Some(ParsedBinding::Range(range)) => { for binding in range { push_indexed_binding( field, binding, registry, diagnostics, source, &mut bindings, ); } } None => { let diag = format!("invalid keybinding: {field} = {raw:?}; disabling binding"); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); } } } bindings } fn push_indexed_binding( field: &str, binding: ResolvedBinding, registry: &mut BindingRegistry, diagnostics: &mut Vec, source: BindingSource, bindings: &mut Vec, ) { if !matches!(binding.trigger.combo().0, KeyCode::Char('1'..='9')) { let diag = format!( "indexed keybinding must use 1..9: {field} = {:?}; disabling binding", binding.label ); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); return; } if reject_binding(field, &binding, registry, diagnostics, source) { return; } registry.register(&binding, field, source); bindings.push(IndexedKeybind { trigger: binding.trigger, label: binding.label, }); } fn append_legacy_indexed_bindings( target: &mut Vec, field: &'static str, configured_label: &str, registry: &mut BindingRegistry, diagnostics: &mut Vec, source: BindingSource, ) { 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, source) { continue; } registry.register(&binding, field, source); target.push(IndexedKeybind { trigger: binding.trigger, label: binding.label, }); } } fn reject_navigate_binding( field: &str, binding: &ResolvedBinding, registry: &BindingRegistry, diagnostics: &mut Vec, source: BindingSource, ) -> bool { if binding.trigger.is_prefix() { let diag = format!( "navigate keybinding must not include prefix: {field} = {:?}; disabling binding", binding.label ); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); return true; } if matches!(normalize_key_combo(binding.trigger.combo()).0, KeyCode::Esc) { let diag = format!( "navigate keybinding cannot use esc: {field} = {:?}; disabling binding", binding.label ); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); return true; } if let Some(first_binding) = registry.conflict(binding) { if source == BindingSource::Default && first_binding.source == BindingSource::User { return true; } let first_field = &first_binding.field; let diag = format!("{}: kept {first_field}, disabled {field}", binding.label); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); return true; } false } fn reject_binding( field: &str, binding: &ResolvedBinding, registry: &BindingRegistry, diagnostics: &mut Vec, source: BindingSource, ) -> bool { if binding.trigger.is_prefix() && registry.prefix_rhs_is_reserved(binding.trigger.combo()) { if source == BindingSource::Default && registry.prefix_source == BindingSource::User { return true; } 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_binding) = registry.conflict(binding) { if source == BindingSource::Default && first_binding.source == BindingSource::User { return true; } let first_field = &first_binding.field; let diag = format!("{}: kept {first_field}, disabled {field}", binding.label); 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 } fn parse_binding_string(raw: &str) -> Option { 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) { parts.push("ctrl".to_string()); } if modifiers.contains(KeyModifiers::ALT) { parts.push("alt".to_string()); } if modifiers.contains(KeyModifiers::SHIFT) && !matches!(code, KeyCode::BackTab) { 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(), KeyCode::Char(c) => c.to_string(), KeyCode::Enter => "enter".to_string(), KeyCode::Esc => "esc".to_string(), KeyCode::Tab => "tab".to_string(), KeyCode::BackTab => "shift+tab".to_string(), KeyCode::Backspace => "backspace".to_string(), KeyCode::Left => "left".to_string(), KeyCode::Right => "right".to_string(), KeyCode::Up => "up".to_string(), KeyCode::Down => "down".to_string(), KeyCode::F(n) => format!("f{n}"), _ => format!("{:?}", code).to_lowercase(), }; if matches!(code, KeyCode::BackTab) { return if parts.is_empty() { key } else { format!("{}+{key}", parts.join("+")) }; } parts.push(key); parts.join("+") } fn super_modifier_label() -> &'static str { if cfg!(target_os = "macos") { "cmd" } else { "super" } } fn parse_modifier_token(token: &str) -> Option { 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_range_modifiers(s: &str) -> Option { 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 { 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(crate) fn parse_key_combo(s: &str) -> Option { let parts: Vec<&str> = s.split('+').collect(); let mut modifiers = KeyModifiers::empty(); let mut key_str: Option<&str> = None; for part in &parts { let trimmed = part.trim(); if trimmed.is_empty() { return None; } if let Some(modifier) = parse_modifier_token(trimmed) { modifiers |= modifier; } else if key_str.is_some() { return None; } else { key_str = Some(trimmed); } } let key_str = key_str?; let single_char = single_key_char(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('+'), _ if single_char.is_some() => { let ch = single_char?; if ch.is_ascii_uppercase() { modifiers |= KeyModifiers::SHIFT; KeyCode::Char(ch.to_ascii_lowercase()) } else { KeyCode::Char(ch) } } s if s.starts_with('f') => s[1..].parse::().ok().map(KeyCode::F)?, _ => return None, }; Some(normalize_key_combo((code, modifiers))) } fn single_key_char(s: &str) -> Option { let mut chars = s.chars(); let ch = chars.next()?; if chars.next().is_none() { Some(ch) } else { None } } /// 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, Option, Vec) { let mut combos: Vec = 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::>() .join(" / ") } 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)] pub fn key_event_matches_combo(key: &KeyEvent, combo: KeyCombo) -> bool { key_parts_match_combo(key.code, key.modifiers, None, combo) } pub fn terminal_key_matches_combo(key: &TerminalKey, combo: KeyCombo) -> bool { key_parts_match_combo(key.code, key.modifiers, key.shifted_codepoint, combo) } fn key_parts_match_combo( actual_code: KeyCode, actual_modifiers: KeyModifiers, shifted_codepoint: Option, 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, actual_modifiers, expected_code, expected_modifiers, shifted_codepoint, ) { return true; } 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) || legacy_shifted_ascii_letter_matches( actual_code, actual_modifiers, expected_code, expected_modifiers, ) } fn key_codes_match( actual: KeyCode, actual_modifiers: KeyModifiers, expected: KeyCode, expected_modifiers: KeyModifiers, shifted_codepoint: Option, ) -> bool { match (actual, expected) { (KeyCode::Char(actual), KeyCode::Char(expected)) if actual.is_ascii_alphabetic() && expected.is_ascii_alphabetic() => { actual == expected || actual_modifiers.contains(KeyModifiers::SHIFT) && expected_modifiers.contains(KeyModifiers::SHIFT) && 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, } } fn legacy_shifted_ascii_letter_matches( actual_code: KeyCode, actual_modifiers: KeyModifiers, expected_code: KeyCode, expected_modifiers: KeyModifiers, ) -> bool { if actual_modifiers.contains(KeyModifiers::SHIFT) { return false; } let (KeyCode::Char(actual), KeyCode::Char(expected)) = (actual_code, expected_code) else { return false; }; actual.is_ascii_uppercase() && expected.is_ascii_lowercase() && actual.to_ascii_lowercase() == expected && actual_modifiers | KeyModifiers::SHIFT == expected_modifiers } const SHIFTED_NUMBER_SYMBOLS: [(char, char); 9] = [ ('1', '!'), ('2', '@'), ('3', '#'), ('4', '$'), ('5', '%'), ('6', '^'), ('7', '&'), ('8', '*'), ('9', '('), ]; fn shifted_number_symbol(ch: char) -> Option { SHIFTED_NUMBER_SYMBOLS .iter() .find_map(|(number, symbol)| (*symbol == ch).then_some(*number)) } fn indexed_shifted_number_matches(key: &TerminalKey, combo: KeyCombo, number: char) -> bool { let (expected_code, expected_modifiers) = normalize_key_combo(combo); matches!(expected_code, KeyCode::Char(expected) if expected == number) && expected_modifiers.contains(KeyModifiers::SHIFT) && key.modifiers == expected_modifiers.difference(KeyModifiers::SHIFT) } fn shifted_char_matches_expected( actual_code: KeyCode, shifted_codepoint: Option, expected_code: KeyCode, ) -> bool { let KeyCode::Char(expected) = expected_code else { return false; }; if let Some(shifted) = shifted_codepoint.and_then(char::from_u32) { return shifted == expected; } matches!(actual_code, KeyCode::Char(actual) if actual == expected && is_shifted_punctuation(expected)) } 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 { bindings .bindings .iter() .map(|binding| binding.trigger) .collect() } #[test] fn parse_simple_char_combo() { assert_eq!( parse_key_combo("v"), Some((KeyCode::Char('v'), KeyModifiers::empty())) ); } #[test] fn parse_unicode_char_combo() { assert_eq!( parse_key_combo("ö"), Some((KeyCode::Char('ö'), KeyModifiers::empty())) ); assert_eq!( parse_key_combo("alt+é"), Some((KeyCode::Char('é'), KeyModifiers::ALT)) ); } #[test] fn unicode_prefix_config_is_valid() { let config: Config = toml::from_str( r#" [keys] prefix = "ö" "#, ) .unwrap(); assert_eq!( config.prefix_keys(), vec![(KeyCode::Char('ö'), KeyModifiers::empty())] ); assert!(config.collect_diagnostics().is_empty()); } #[test] fn parse_shift_tab_as_backtab() { assert_eq!( parse_key_combo("shift+tab"), Some((KeyCode::BackTab, KeyModifiers::empty())) ); } #[test] fn parse_named_punctuation() { assert_eq!( parse_key_combo("minus"), Some((KeyCode::Char('-'), KeyModifiers::empty())) ); assert_eq!( parse_key_combo("comma"), Some((KeyCode::Char(','), KeyModifiers::empty())) ); assert_eq!( parse_key_combo("ampersand"), Some((KeyCode::Char('&'), KeyModifiers::empty())) ); } #[test] 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!( binding_triggers(&kb.next_tab), vec![BindingTrigger::Prefix(( KeyCode::Char('n'), KeyModifiers::empty() ))] ); } #[test] fn new_worktree_defaults_to_prefix_shift_g() { let kb = Config::default().keybinds(); assert_eq!( binding_triggers(&kb.new_worktree), vec![BindingTrigger::Prefix(( KeyCode::Char('g'), KeyModifiers::SHIFT ))] ); } #[test] fn goto_defaults_to_prefix_g() { let kb = Config::default().keybinds(); assert_eq!( binding_triggers(&kb.goto), vec![BindingTrigger::Prefix(( KeyCode::Char('g'), KeyModifiers::empty() ))] ); } #[test] fn open_and_remove_worktree_keybinds_are_unset_by_default() { let kb = Config::default().keybinds(); assert!(kb.open_worktree.bindings.is_empty()); assert!(kb.remove_worktree.bindings.is_empty()); } #[test] fn copy_mode_uses_tmux_prefix_bracket_by_default() { let kb = Config::default().keybinds(); assert_eq!( binding_triggers(&kb.copy_mode), vec![BindingTrigger::Prefix(( KeyCode::Char('['), KeyModifiers::empty() ))] ); } #[test] fn back_and_forth_keybinds_are_unset_by_default() { let kb = Config::default().keybinds(); assert!(kb.last_pane.bindings.is_empty()); } #[test] fn array_bindings_allow_prefix_and_modified_direct() { let config: Config = toml::from_str( r#" [keys] next_tab = ["prefix+n", "ctrl+alt+]"] "#, ) .unwrap(); let kb = config.keybinds(); assert_eq!( binding_triggers(&kb.next_tab), vec![ BindingTrigger::Prefix((KeyCode::Char('n'), KeyModifiers::empty())), BindingTrigger::Direct(( KeyCode::Char(']'), KeyModifiers::CONTROL | KeyModifiers::ALT )), ] ); assert_eq!(kb.next_tab.prefix_rhs_label().as_deref(), Some("n")); } #[test] fn unsafe_direct_printable_binding_is_disabled_with_diagnostic() { let config: Config = toml::from_str( r#" [keys] 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 unicode_prefix_bindings_match_non_us_keys() { for ch in ['ğ', 'ç', 'ş', 'ı', 'é', 'ø'] { let bindings = ActionKeybinds::prefix(&ch.to_string()); assert!(bindings .matches_prefix_key(&TerminalKey::new(KeyCode::Char(ch), KeyModifiers::empty(),))); } } #[test] fn shifted_unicode_prefix_bindings_match_layout_aware_input() { for (base, shifted) in [('ğ', 'Ğ'), ('ç', 'Ç'), ('ş', 'Ş'), ('ı', 'I'), ('ø', 'Ø')] { let bindings = ActionKeybinds::prefix(&format!("shift+{base}")); assert!(bindings.matches_prefix_key( &TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT) .with_shifted_codepoint(shifted as u32) )); } } #[test] 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 shifted_letter_binding_matches_legacy_uppercase_key_event() { let bindings = ActionKeybinds::prefix("shift+n"); assert!(bindings .matches_prefix_key(&TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); } #[test] fn shifted_letter_direct_binding_matches_legacy_uppercase_key_event() { let bindings = ActionKeybinds::direct("shift+n"); assert!(bindings .matches_direct_key(&TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); } #[test] fn shifted_letter_binding_matches_modern_modified_key_event() { let bindings = ActionKeybinds::direct("cmd+shift+j"); assert!(bindings.matches_direct_key(&TerminalKey::new( KeyCode::Char('J'), KeyModifiers::SUPER | KeyModifiers::SHIFT, ))); } #[test] fn legacy_uppercase_key_event_does_not_match_unshifted_letter_binding() { let bindings = ActionKeybinds::prefix("n"); assert!(!bindings .matches_prefix_key(&TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); } #[test] fn legacy_uppercase_shift_fallback_is_limited_to_ascii_letters() { let shifted_number = ActionKeybinds::prefix("shift+1"); assert!(!shifted_number .matches_prefix_key(&TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty(),))); let shifted_non_ascii = ActionKeybinds::prefix("shift+ö"); assert!(!shifted_non_ascii .matches_prefix_key(&TerminalKey::new(KeyCode::Char('Ö'), KeyModifiers::empty(),))); } #[test] 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!( normalize_key_combo((KeyCode::Tab, KeyModifiers::CONTROL | KeyModifiers::SHIFT)), (KeyCode::BackTab, KeyModifiers::CONTROL) ); } #[test] fn format_modified_backtab_keeps_shift_label() { assert_eq!( format_key_combo((KeyCode::BackTab, KeyModifiers::CONTROL)), "ctrl+shift+tab" ); assert_eq!( format_key_combo((KeyCode::BackTab, KeyModifiers::CONTROL | KeyModifiers::ALT)), "ctrl+alt+shift+tab" ); } #[test] 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 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") })); 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 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#" [keys] navigate_workspace_up = "j" navigate_workspace_down = "j" navigate_pane_down = "ctrl+j" "#, ) .unwrap(); let keybinds = config.keybinds(); let diagnostics = config.collect_diagnostics(); assert!(keybinds .navigate .workspace_up .matches_direct_key(&TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()))); assert!(keybinds.navigate.workspace_down.bindings.is_empty()); assert!(keybinds .navigate .pane_down .matches_direct_key(&TerminalKey::new(KeyCode::Char('j'), KeyModifiers::CONTROL))); assert!(diagnostics.iter().any(|diag| { diag.contains("kept keys.navigate_workspace_up") && diag.contains("disabled keys.navigate_workspace_down") })); } #[test] fn navigate_bindings_reject_runtime_reserved_keys() { let config: Config = toml::from_str( r#" [keys] navigate_workspace_up = ["esc", "alt+esc", "enter", "1", "tab", "shift+tab", "left", "right"] "#, ) .unwrap(); let keybinds = config.keybinds(); let diagnostics = config.collect_diagnostics(); assert!(keybinds.navigate.workspace_up.bindings.is_empty()); assert_eq!( diagnostics .iter() .filter(|diag| { (diag.contains("navigate reserved keys") || diag.contains("navigate keybinding cannot use esc")) && diag.contains("keys.navigate_workspace_up") }) .count(), 8 ); } #[test] fn navigate_bindings_can_reuse_navigate_mode_prefix_rhs_keys() { let config: Config = toml::from_str( r#" [keys] navigate_workspace_down = ["n", "f"] [[keys.command]] key = "prefix+f" command = "echo hi" "#, ) .unwrap(); let keybinds = config.keybinds(); let diagnostics = config.collect_diagnostics(); assert!(keybinds .navigate .workspace_down .matches_direct_key(&TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty()))); assert!(keybinds .navigate .workspace_down .matches_direct_key(&TerminalKey::new(KeyCode::Char('f'), KeyModifiers::empty()))); assert!(!keybinds.custom_commands.is_empty()); assert!(!diagnostics.iter().any(|diag| { diag.contains("disabled keys.navigate_workspace_down") && (diag.contains("keys.next_tab") || diag.contains("keys.command")) })); } #[test] fn navigate_bindings_do_not_conflict_with_general_focus_pane_bindings() { let config: Config = toml::from_str( r#" [keys] navigate_pane_down = "j" "#, ) .unwrap(); let keybinds = config.keybinds(); assert!(keybinds .navigate .pane_down .matches_direct_key(&TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()))); } #[test] fn navigate_bindings_reject_prefix_syntax_and_prefix_key() { let config: Config = toml::from_str( r#" [keys] prefix = "ctrl+a" navigate_workspace_up = "prefix+j" navigate_workspace_down = "ctrl+a" "#, ) .unwrap(); let keybinds = config.keybinds(); let diagnostics = config.collect_diagnostics(); assert!(keybinds.navigate.workspace_up.bindings.is_empty()); assert!(keybinds.navigate.workspace_down.bindings.is_empty()); assert!(diagnostics.iter().any(|diag| { diag.contains("navigate keybinding must not include prefix") && diag.contains("keys.navigate_workspace_up") })); assert!(diagnostics.iter().any(|diag| { diag.contains("kept keys.prefix") && diag.contains("keys.navigate_workspace_down") })); } #[test] fn custom_command_prefix_rhs_equal_to_configured_prefix_is_rejected() { let config: Config = toml::from_str( r#" [keys] prefix = "ctrl+b" [[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 direct_custom_printable_binding_is_rejected_as_unsafe() { let config: Config = toml::from_str( r#" [keys] [[keys.command]] key = "g" command = "echo no" "#, ) .unwrap(); let diagnostics = config.collect_diagnostics(); assert!(config.keybinds().custom_commands.is_empty()); assert!(diagnostics.iter().any(|diag| { diag.contains("unsafe direct keybinding") && diag.contains("keys.command[0].key") })); } #[test] 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 = "ctrl+alt+g" command = "echo no" "#, ) .unwrap(); let diagnostics = config.collect_diagnostics(); let keybinds = config.keybinds(); assert!(!keybinds.new_tab.bindings.is_empty()); assert!(keybinds.custom_commands.is_empty()); assert!(diagnostics.iter().any(|diag| { diag.contains("kept keys.new_tab") && diag.contains("disabled keys.command[0].key") })); } #[test] 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.switch_workspace.len(), 9); assert_eq!( kb.switch_workspace[0].trigger, BindingTrigger::Prefix((KeyCode::Char('1'), KeyModifiers::SHIFT)) ); assert_eq!(kb.switch_workspace[0].label, "prefix+shift+1"); } #[test] fn legacy_indexed_user_bindings_displace_modern_defaults() { let config: Config = toml::from_str( r#" [keys.indexed] workspaces = "ctrl" "#, ) .unwrap(); let diagnostics = config.collect_diagnostics(); let kb = config.keybinds(); assert!(diagnostics.is_empty(), "{diagnostics:?}"); assert_eq!(kb.switch_workspace.len(), 9); assert_eq!( kb.switch_workspace[0].trigger, BindingTrigger::Direct((KeyCode::Char('1'), KeyModifiers::CONTROL)) ); assert_eq!(kb.switch_workspace[0].label, "ctrl+1"); } #[test] fn invalid_legacy_indexed_user_binding_displaces_modern_default() { let config: Config = toml::from_str( r#" [keys.indexed] tabs = "bogus" "#, ) .unwrap(); let diagnostics = config.collect_diagnostics(); let kb = config.keybinds(); assert!(kb.switch_tab.is_empty()); assert!(diagnostics.iter().any(|diag| { diag.contains("invalid indexed keybinding") && diag.contains("keys.indexed.tabs") })); } #[test] fn invalid_indexed_binding_does_not_displace_default_binding() { let config: Config = toml::from_str( r#" [keys] switch_tab = "prefix+?" "#, ) .unwrap(); let diagnostics = config.collect_diagnostics(); let kb = config.keybinds(); assert!(kb.switch_tab.is_empty()); assert_eq!( binding_triggers(&kb.help), vec![BindingTrigger::Prefix(( KeyCode::Char('?'), KeyModifiers::empty() ))] ); assert!(diagnostics.iter().any(|diag| { diag.contains("indexed keybinding must use 1..9") && diag.contains("keys.switch_tab") })); assert!(!diagnostics.iter().any(|diag| { diag.contains("kept keys.switch_tab") && diag.contains("disabled keys.help") })); } #[test] fn default_keymap_is_prefix_first_and_tab_centered() { let kb = Config::default().keybinds(); assert_eq!( binding_triggers(&kb.next_tab), vec![BindingTrigger::Prefix(( KeyCode::Char('n'), KeyModifiers::empty() ))] ); 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() .all(|binding| binding.trigger.is_prefix())); assert!(kb .new_tab .bindings .iter() .all(|binding| binding.trigger.is_prefix())); assert_eq!( binding_triggers(&kb.swap_pane_left), vec![BindingTrigger::Prefix(( KeyCode::Char('h'), KeyModifiers::SHIFT ))] ); assert_eq!( binding_triggers(&kb.swap_pane_down), vec![BindingTrigger::Prefix(( KeyCode::Char('j'), KeyModifiers::SHIFT ))] ); assert_eq!( binding_triggers(&kb.swap_pane_up), vec![BindingTrigger::Prefix(( KeyCode::Char('k'), KeyModifiers::SHIFT ))] ); assert_eq!( binding_triggers(&kb.swap_pane_right), vec![BindingTrigger::Prefix(( KeyCode::Char('l'), KeyModifiers::SHIFT ))] ); } #[test] fn duplicate_prefix_binding_disables_later_binding() { let config: Config = toml::from_str( r#" [keys] next_tab = "prefix+n" new_workspace = "prefix+n" "#, ) .unwrap(); 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(|diag| { diag.contains("kept keys.new_workspace") && diag.contains("disabled keys.next_tab") })); } #[test] fn user_binding_silently_displaces_default_binding() { let config: Config = toml::from_str( r#" [keys] previous_workspace = "prefix+shift+l" "#, ) .unwrap(); let diagnostics = config.collect_diagnostics(); let kb = config.keybinds(); assert!(diagnostics.is_empty(), "{diagnostics:?}"); assert_eq!( binding_triggers(&kb.previous_workspace), vec![BindingTrigger::Prefix(( KeyCode::Char('l'), KeyModifiers::SHIFT ))] ); assert!(kb.swap_pane_right.bindings.is_empty()); } #[test] fn user_prefix_silently_displaces_default_prefix_rhs_binding() { let config: Config = toml::from_str( r#" [keys] prefix = "n" "#, ) .unwrap(); let diagnostics = config.collect_diagnostics(); let kb = config.keybinds(); assert!(diagnostics.is_empty(), "{diagnostics:?}"); assert!(kb.next_tab.bindings.is_empty()); } #[test] fn duplicate_user_binding_still_reports_conflict() { let config: Config = toml::from_str( r#" [keys] previous_workspace = "prefix+shift+l" swap_pane_right = "prefix+shift+l" "#, ) .unwrap(); let diagnostics = config.collect_diagnostics(); let kb = config.keybinds(); assert_eq!( binding_triggers(&kb.previous_workspace), vec![BindingTrigger::Prefix(( KeyCode::Char('l'), KeyModifiers::SHIFT ))] ); assert!(kb.swap_pane_right.bindings.is_empty()); assert!(diagnostics.iter().any(|diag| { diag.contains("kept keys.previous_workspace") && diag.contains("disabled keys.swap_pane_right") })); } #[test] fn custom_command_with_description_parses() { let config: Config = toml::from_str( r#" [[keys.command]] key = "prefix+y" command = "echo hello" description = "say hello" "#, ) .unwrap(); let keybinds = config.keybinds(); assert_eq!(keybinds.custom_commands.len(), 1); assert_eq!( keybinds.custom_commands[0].description, Some("say hello".to_string()) ); } #[test] fn custom_popup_command_parses() { let config: Config = toml::from_str( r#" [[keys.command]] key = "prefix+g" command = "lazygit" type = "popup" width = 90 height = "80%" "#, ) .unwrap(); let keybinds = config.keybinds(); assert_eq!(keybinds.custom_commands.len(), 1); assert_eq!( keybinds.custom_commands[0].action, CustomCommandAction::Popup ); assert_eq!( keybinds.custom_commands[0].width, Some(PopupSize::Cells(90)) ); assert_eq!( keybinds.custom_commands[0].height, Some(PopupSize::Percent(80)) ); } #[test] fn non_popup_custom_command_ignores_popup_size_with_diagnostic() { let config: Config = toml::from_str( r#" [[keys.command]] key = "prefix+g" command = "lazygit" type = "pane" width = "80%" "#, ) .unwrap(); let keybinds = config.keybinds(); assert_eq!(keybinds.custom_commands[0].width, None); assert!(config .collect_diagnostics() .iter() .any(|diag| diag.contains("popup size on non-popup custom command"))); } }