use super::config_struct::Config;
use crate::types::KeyBinding;
use std::collections::{BTreeSet, HashSet};
pub const ACTION_RENAMES: &[(&str, &str)] = &[
("split_horizontal", "split_down"),
("split_vertical", "split_right"),
("mux-detach", "detach"),
("toggle_tmux_session_picker", "toggle_session_picker"),
];
const MOVED_DEFAULT_CHORDS: &[(&str, &str, &str)] =
&[("toggle_background_shader", "Ctrl+Shift+B", "Ctrl+Alt+B")];
pub fn current_action_id(action: &str) -> &str {
ACTION_RENAMES
.iter()
.find(|(previous, _)| *previous == action)
.map_or(action, |(_, current)| current)
}
pub(crate) fn canonical_chord(key: &str) -> Option<String> {
let parts: Vec<&str> = key.split('+').map(str::trim).collect();
if parts.is_empty() {
return None;
}
let mut modifiers: BTreeSet<&str> = BTreeSet::new();
let mut key_part: Option<&str> = None;
for (i, part) in parts.iter().enumerate() {
let is_last = i == parts.len() - 1;
let part_lower = part.to_lowercase();
let is_modifier = match part_lower.as_str() {
"ctrl" | "control" => modifiers.insert("ctrl"),
"alt" | "option" => modifiers.insert("alt"),
"shift" => modifiers.insert("shift"),
"super" | "cmd" | "command" | "meta" | "win" => modifiers.insert("super"),
"cmdorctrl" => {
if cfg!(target_os = "macos") {
modifiers.insert("super")
} else {
modifiers.insert("ctrl")
}
}
_ => false,
};
if !is_modifier {
if key_part.is_some() {
return None;
}
key_part = Some(part);
} else if is_last {
return None;
}
}
let key = key_part?;
if key.is_empty() {
return None;
}
let mut canonical = String::new();
for modifier in &modifiers {
canonical.push_str(modifier);
canonical.push('+');
}
canonical.push_str(&key.to_lowercase());
Some(canonical)
}
impl Config {
pub(crate) fn merge_default_keybindings(&mut self) {
self.migrate_renamed_keybinding_actions();
if !cfg!(target_os = "macos") {
self.migrate_moved_default_chords_from(MOVED_DEFAULT_CHORDS);
}
self.merge_default_keybindings_from(&crate::defaults::keybindings());
}
fn migrate_moved_default_chords_from(&mut self, moves: &[(&str, &str, &str)]) {
for (action, old, new) in moves {
let (Some(old), Some(new_canonical)) = (canonical_chord(old), canonical_chord(new))
else {
continue;
};
if self
.keybindings
.iter()
.any(|kb| canonical_chord(&kb.key).as_deref() == Some(new_canonical.as_str()))
{
continue;
}
if let Some(binding) = self.keybindings.iter_mut().find(|kb| {
kb.action == *action && canonical_chord(&kb.key).as_deref() == Some(old.as_str())
}) {
log::info!(
"Moving '{}' from its previous default chord '{}' to '{}'",
action,
binding.key,
new
);
binding.key = new.to_string();
}
}
}
pub(crate) fn migrate_renamed_keybinding_actions(&mut self) {
self.migrate_renamed_keybinding_actions_from(ACTION_RENAMES);
}
fn migrate_renamed_keybinding_actions_from(&mut self, renames: &[(&str, &str)]) {
for binding in &mut self.keybindings {
if let Some((_, current)) = renames
.iter()
.find(|(previous, _)| *previous == binding.action)
{
log::info!(
"Migrating keybinding action '{}' -> '{}' (chord '{}' kept)",
binding.action,
current,
binding.key
);
binding.action = current.to_string();
}
}
}
fn merge_default_keybindings_from(&mut self, default_keybindings: &[KeyBinding]) {
let existing_actions: HashSet<String> = self
.keybindings
.iter()
.map(|kb| kb.action.clone())
.collect();
let mut claimed_chords: HashSet<String> = self
.keybindings
.iter()
.filter_map(|kb| canonical_chord(&kb.key))
.collect();
let mut added_count = 0;
for default_kb in default_keybindings {
if existing_actions.contains(&default_kb.action) {
continue;
}
if let Some(canonical) = canonical_chord(&default_kb.key) {
if !claimed_chords.insert(canonical) {
log::warn!(
"Not adding default keybinding {} -> {}: the chord is already bound by \
another action in this config",
default_kb.key,
default_kb.action
);
continue;
}
} else {
}
log::info!(
"Adding new default keybinding: {} -> {}",
default_kb.key,
default_kb.action
);
self.keybindings.push(KeyBinding {
key: default_kb.key.clone(),
action: default_kb.action.clone(),
});
added_count += 1;
}
if added_count > 0 {
log::info!(
"Merged {} new default keybinding(s) into user config",
added_count
);
}
}
pub(crate) fn merge_default_widgets(&mut self) {
let default_widgets = crate::status_bar::default_widgets();
let existing_ids: std::collections::HashSet<crate::status_bar::WidgetId> = self
.status_bar
.status_bar_widgets
.iter()
.map(|w| w.id.clone())
.collect();
let mut added_count = 0;
for default_widget in default_widgets {
if !existing_ids.contains(&default_widget.id) {
log::info!(
"Adding new default status bar widget: {:?}",
default_widget.id
);
self.status_bar.status_bar_widgets.push(default_widget);
added_count += 1;
}
}
if added_count > 0 {
log::info!(
"Merged {} new default status bar widget(s) into user config",
added_count
);
}
}
pub fn generate_snippet_action_keybindings(&mut self) {
use crate::config::KeyBinding;
let mut seen_actions = std::collections::HashSet::new();
let mut added_count = 0;
let mut updated_count = 0;
for snippet in &self.snippets {
if let Some(key) = &snippet.keybinding {
let action = format!("snippet:{}", snippet.id);
seen_actions.insert(action.clone());
if !key.is_empty() && snippet.enabled && snippet.keybinding_enabled {
if let Some(existing) =
self.keybindings.iter_mut().find(|kb| kb.action == action)
{
if existing.key != *key {
log::info!(
"Updating keybinding for snippet '{}': {} -> {} (was: {})",
snippet.title,
key,
action,
existing.key
);
existing.key = key.clone();
updated_count += 1;
}
} else {
log::info!(
"Adding keybinding for snippet '{}': {} -> {} (enabled={}, keybinding_enabled={})",
snippet.title,
key,
action,
snippet.enabled,
snippet.keybinding_enabled
);
self.keybindings.push(KeyBinding {
key: key.clone(),
action,
});
added_count += 1;
}
} else if !key.is_empty() {
log::info!(
"Skipping keybinding for snippet '{}': {} (enabled={}, keybinding_enabled={})",
snippet.title,
key,
snippet.enabled,
snippet.keybinding_enabled
);
}
}
}
for action_config in &self.actions {
if let Some(key) = action_config.keybinding() {
let action = format!("action:{}", action_config.id());
let prefix_only = !self.custom_action_prefix_key.trim().is_empty()
&& action_config.prefix_char().is_none()
&& action_config.single_char_keybinding_follow_up().is_some();
if prefix_only {
log::info!(
"Skipping global keybinding for action '{}': {} is a prefix follow-up",
action_config.title(),
key
);
continue;
}
seen_actions.insert(action.clone());
if !key.is_empty() && action_config.keybinding_enabled() {
if let Some(existing) =
self.keybindings.iter_mut().find(|kb| kb.action == action)
{
if existing.key != key {
log::info!(
"Updating keybinding for action '{}': {} -> {} (was: {})",
action_config.title(),
key,
action,
existing.key
);
existing.key = key.to_string();
updated_count += 1;
}
} else {
log::info!(
"Adding keybinding for action '{}': {} -> {} (keybinding_enabled={})",
action_config.title(),
key,
action,
action_config.keybinding_enabled()
);
self.keybindings.push(KeyBinding {
key: key.to_string(),
action,
});
added_count += 1;
}
} else if !key.is_empty() {
log::info!(
"Skipping keybinding for action '{}': {} (keybinding_enabled={})",
action_config.title(),
key,
action_config.keybinding_enabled()
);
}
}
}
let original_len = self.keybindings.len();
self.keybindings.retain(|kb| {
if !kb.action.starts_with("snippet:") && !kb.action.starts_with("action:") {
return true;
}
seen_actions.contains(&kb.action)
});
let removed_count = original_len - self.keybindings.len();
if added_count > 0 || updated_count > 0 || removed_count > 0 {
log::info!(
"Snippet/Action keybindings: {} added, {} updated, {} removed",
added_count,
updated_count,
removed_count
);
}
}
}
#[cfg(test)]
mod migration_tests {
use super::*;
fn config_with(keybindings: &[(&str, &str)]) -> Config {
Config {
keybindings: keybindings
.iter()
.map(|(key, action)| KeyBinding {
key: key.to_string(),
action: action.to_string(),
})
.collect(),
..Config::default()
}
}
fn bound_chord(config: &Config, action: &str) -> Option<String> {
config
.keybindings
.iter()
.find(|kb| kb.action == action)
.map(|kb| kb.key.clone())
}
fn no_duplicate_chords(config: &Config) -> bool {
let mut seen = HashSet::new();
config
.keybindings
.iter()
.filter_map(|kb| canonical_chord(&kb.key))
.all(|chord| seen.insert(chord))
}
#[test]
fn canonical_chord_folds_synonyms_and_order() {
assert_eq!(
canonical_chord("Ctrl+Shift+T"),
canonical_chord("shift+control+t")
);
assert_eq!(
canonical_chord("CmdOrCtrl+Shift+P"),
canonical_chord("Shift+CmdOrCtrl+P")
);
assert_eq!(canonical_chord("Alt+F5"), canonical_chord("Option+F5"));
assert_eq!(canonical_chord("Cmd+O"), canonical_chord("command+O"));
assert_eq!(
canonical_chord("CmdOrCtrl+O"),
if cfg!(target_os = "macos") {
canonical_chord("Cmd+O")
} else {
canonical_chord("Ctrl+O")
}
);
}
#[test]
fn canonical_chord_rejects_keyless_and_multi_key() {
assert_eq!(canonical_chord("Ctrl+Shift+"), None);
assert_eq!(canonical_chord("Ctrl"), None);
assert_eq!(canonical_chord("Ctrl+D+T"), None);
assert!(canonical_chord("Ctrl+D").is_some());
}
#[test]
fn moved_default_is_not_added_when_chord_claimed() {
let mut config = config_with(&[("CmdOrCtrl+Shift+P", "toggle_profile_drawer")]);
let future_defaults = vec![KeyBinding {
key: "Shift+CmdOrCtrl+P".to_string(), action: "command_palette".to_string(),
}];
config.merge_default_keybindings_from(&future_defaults);
assert!(
bound_chord(&config, "command_palette").is_none(),
"a default must not claim an already-bound chord"
);
assert_eq!(
bound_chord(&config, "toggle_profile_drawer"),
Some("CmdOrCtrl+Shift+P".to_string())
);
}
#[test]
fn split_renames_resolve_to_placement_ids() {
assert_eq!(current_action_id("split_horizontal"), "split_down");
assert_eq!(current_action_id("split_vertical"), "split_right");
assert_eq!(current_action_id("split_right"), "split_right");
assert_eq!(current_action_id("new_tab"), "new_tab");
let mut config = config_with(&[
("Ctrl+Shift+D", "split_horizontal"),
("Ctrl+Shift+E", "split_vertical"),
]);
config.migrate_renamed_keybinding_actions();
assert_eq!(
bound_chord(&config, "split_down"),
Some("Ctrl+Shift+D".to_string())
);
assert_eq!(
bound_chord(&config, "split_right"),
Some("Ctrl+Shift+E".to_string())
);
}
#[test]
fn renamed_action_migrates_in_place() {
let mut config = config_with(&[("CmdOrCtrl+D", "split_horizontal")]);
config.migrate_renamed_keybinding_actions_from(&[("split_horizontal", "split_down")]);
assert_eq!(
bound_chord(&config, "split_down"),
Some("CmdOrCtrl+D".to_string())
);
assert!(bound_chord(&config, "split_horizontal").is_none());
let future_defaults = vec![KeyBinding {
key: "CmdOrCtrl+D".to_string(),
action: "split_down".to_string(),
}];
config.merge_default_keybindings_from(&future_defaults);
assert!(
no_duplicate_chords(&config),
"the renamed binding must not gain a duplicate chord"
);
assert_eq!(
config
.keybindings
.iter()
.filter(|kb| kb.action == "split_down")
.count(),
1
);
}
#[test]
fn previous_release_config_preserves_bindings_without_duplicates() {
let previous_release_yaml = r#"
keybindings:
- key: CmdOrCtrl+D
action: split_horizontal
- key: CmdOrCtrl+Shift+P
action: toggle_profile_drawer
- key: CmdOrCtrl+Shift+F9
action: my_custom_action
"#;
let mut config: Config =
serde_yaml_ng::from_str(previous_release_yaml).expect("fixture YAML parses");
config.merge_default_keybindings();
assert_eq!(
bound_chord(&config, "split_down"),
Some("CmdOrCtrl+D".to_string())
);
assert!(bound_chord(&config, "split_horizontal").is_none());
assert_eq!(
bound_chord(&config, "toggle_profile_drawer"),
Some("CmdOrCtrl+Shift+P".to_string())
);
assert_eq!(
bound_chord(&config, "my_custom_action"),
Some("CmdOrCtrl+Shift+F9".to_string())
);
assert!(
no_duplicate_chords(&config),
"merged config must not contain duplicate chords"
);
assert!(bound_chord(&config, "toggle_background_shader").is_some());
}
#[test]
fn shipped_defaults_bind_each_chord_once() {
let mut owner: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut collisions = Vec::new();
for kb in crate::defaults::keybindings() {
let chord = canonical_chord(&kb.key)
.unwrap_or_else(|| panic!("default chord {:?} does not canonicalize", kb.key));
if let Some(previous) = owner.insert(chord.clone(), kb.action.clone()) {
collisions.push(format!("{chord}: {previous} and {}", kb.action));
}
}
assert!(
collisions.is_empty(),
"default chords bound more than once: {collisions:#?}"
);
}
#[cfg(target_os = "macos")]
#[test]
fn pre_iterm2_alignment_config_keeps_every_moved_chord() {
let previous_release_yaml = r#"
keybindings:
- key: CmdOrCtrl+D
action: split_horizontal
- key: CmdOrCtrl+Shift+D
action: split_vertical
- key: CmdOrCtrl+Shift+W
action: close_pane
- key: CmdOrCtrl+W
action: close_window
- key: CmdOrCtrl+Alt+Shift+Left
action: resize_pane_left
- key: CmdOrCtrl+Ctrl+Alt+Left
action: swap_pane_left
- key: CmdOrCtrl+Shift+T
action: toggle_throughput_mode
- key: CmdOrCtrl+Z
action: reopen_closed_tab
- key: CmdOrCtrl+R
action: toggle_command_history
- key: CmdOrCtrl+Shift+P
action: toggle_profile_drawer
- key: CmdOrCtrl+Alt+T
action: toggle_tmux_session_picker
"#;
let mut config: Config =
serde_yaml_ng::from_str(previous_release_yaml).expect("fixture YAML parses");
config.merge_default_keybindings();
for (chord, action) in [
("CmdOrCtrl+D", "split_down"),
("CmdOrCtrl+Shift+D", "split_right"),
("CmdOrCtrl+Shift+W", "close_pane"),
("CmdOrCtrl+W", "close_window"),
("CmdOrCtrl+Alt+Shift+Left", "resize_pane_left"),
("CmdOrCtrl+Ctrl+Alt+Left", "swap_pane_left"),
("CmdOrCtrl+Shift+T", "toggle_throughput_mode"),
("CmdOrCtrl+Z", "reopen_closed_tab"),
("CmdOrCtrl+R", "toggle_command_history"),
("CmdOrCtrl+Shift+P", "toggle_profile_drawer"),
("CmdOrCtrl+Alt+T", "toggle_session_picker"),
] {
let bound: Vec<&str> = config
.keybindings
.iter()
.filter(|kb| canonical_chord(&kb.key) == canonical_chord(chord))
.map(|kb| kb.action.as_str())
.collect();
assert_eq!(bound, vec![action], "{chord} must keep its saved action");
}
assert!(
no_duplicate_chords(&config),
"merged config must not contain duplicate chords"
);
assert_eq!(
bound_chord(&config, "toggle_command_palette"),
None,
"Cmd+Shift+P is claimed by the saved drawer binding, so the palette \
default must not take it"
);
assert_eq!(
bound_chord(&config, "close_tab"),
Some("CmdOrCtrl+Alt+W".to_string())
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn pre_k1_alignment_config_keeps_every_moved_chord() {
let previous_release_yaml = r#"
keybindings:
- key: Ctrl+Shift+D
action: split_horizontal
- key: Ctrl+Shift+E
action: split_vertical
- key: Ctrl+Shift+X
action: close_pane
- key: Ctrl+Shift+W
action: close_tab
- key: Alt+Shift+Left
action: swap_pane_left
- key: Ctrl+Shift+P
action: toggle_profile_drawer
"#;
let mut config: Config =
serde_yaml_ng::from_str(previous_release_yaml).expect("fixture YAML parses");
config.merge_default_keybindings();
for (chord, action) in [
("Ctrl+Shift+D", "split_down"),
("Ctrl+Shift+E", "split_right"),
("Ctrl+Shift+X", "close_pane"),
("Ctrl+Shift+W", "close_tab"),
("Alt+Shift+Left", "swap_pane_left"),
("Ctrl+Shift+P", "toggle_profile_drawer"),
] {
let bound: Vec<&str> = config
.keybindings
.iter()
.filter(|kb| canonical_chord(&kb.key) == canonical_chord(chord))
.map(|kb| kb.action.as_str())
.collect();
assert_eq!(bound, vec![action], "{chord} must keep its saved action");
}
assert!(
no_duplicate_chords(&config),
"merged config must not contain duplicate chords"
);
assert_eq!(bound_chord(&config, "toggle_command_palette"), None);
}
#[test]
fn pass_to_terminal_row_survives_merge_and_yaml_round_trip() {
let yaml = r#"
keybindings:
- key: Alt+1
action: pass_to_terminal
"#;
let mut config: Config = serde_yaml_ng::from_str(yaml).expect("yaml parses");
assert_eq!(
bound_chord(&config, "pass_to_terminal"),
Some("Alt+1".to_string())
);
let defaults = vec![KeyBinding {
key: "Alt+1".to_string(),
action: "switch_to_tab_1".to_string(),
}];
config.merge_default_keybindings_from(&defaults);
assert!(
bound_chord(&config, "switch_to_tab_1").is_none(),
"a chord claimed by pass_to_terminal must not gain its default back"
);
assert_eq!(
bound_chord(&config, "pass_to_terminal"),
Some("Alt+1".to_string())
);
let rewritten = serde_yaml_ng::to_string(&config).expect("yaml serializes");
let reloaded: Config = serde_yaml_ng::from_str(&rewritten).expect("yaml reloads");
assert_eq!(
bound_chord(&reloaded, "pass_to_terminal"),
Some("Alt+1".to_string())
);
}
#[test]
fn an_old_default_chord_moves_to_its_new_default() {
let mut config = config_with(&[
("Ctrl+Shift+B", "toggle_background_shader"),
("Ctrl+Shift+U", "toggle_cursor_shader"),
]);
config.migrate_moved_default_chords_from(MOVED_DEFAULT_CHORDS);
assert_eq!(
bound_chord(&config, "toggle_background_shader").as_deref(),
Some("Ctrl+Alt+B")
);
assert_eq!(
bound_chord(&config, "toggle_cursor_shader").as_deref(),
Some("Ctrl+Shift+U")
);
assert!(no_duplicate_chords(&config));
}
#[test]
fn a_moved_chord_never_overrides_a_user_choice() {
let mut rebound = config_with(&[("Ctrl+Alt+S", "toggle_background_shader")]);
rebound.migrate_moved_default_chords_from(MOVED_DEFAULT_CHORDS);
assert_eq!(
bound_chord(&rebound, "toggle_background_shader").as_deref(),
Some("Ctrl+Alt+S")
);
let mut taken = config_with(&[
("Ctrl+Shift+B", "toggle_background_shader"),
("Ctrl+Alt+B", "toggle_fps_overlay"),
]);
taken.migrate_moved_default_chords_from(MOVED_DEFAULT_CHORDS);
assert_eq!(
bound_chord(&taken, "toggle_background_shader").as_deref(),
Some("Ctrl+Shift+B"),
"the new chord is claimed, so the row stays where it was"
);
}
#[test]
fn no_default_keybinding_claims_the_leader_chord() {
let leader = canonical_chord(&crate::defaults::leader_key()).expect("leader parses");
let config = Config::default();
let clash: Vec<&KeyBinding> = config
.keybindings
.iter()
.filter(|kb| canonical_chord(&kb.key).as_deref() == Some(leader.as_str()))
.collect();
assert!(
clash.is_empty(),
"defaults bind the leader chord: {clash:?}"
);
}
}