aboutsummaryrefslogtreecommitdiff
path: root/src/config/keybinds.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/config/keybinds.rs')
-rw-r--r--src/config/keybinds.rs1089
1 files changed, 1089 insertions, 0 deletions
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
new file mode 100644
index 00000000..07c682ba
--- /dev/null
+++ b/src/config/keybinds.rs
@@ -0,0 +1,1089 @@
+use crossterm::event::{KeyCode, KeyModifiers};
+use serde::Deserialize;
+use tracing::warn;
+
+use super::Config;
+
+#[derive(Debug)]
+pub struct LiveKeybindConfig {
+ pub prefix: (KeyCode, KeyModifiers),
+ pub keybinds: Keybinds,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum CommandKeybindType {
+ #[default]
+ Shell,
+ Pane,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct CommandKeybindConfig {
+ /// Navigate-mode key that runs a command after pressing the prefix key.
+ pub key: String,
+ /// 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,
+}
+
+impl Default for CommandKeybindConfig {
+ fn default() -> Self {
+ Self {
+ key: String::new(),
+ command: String::new(),
+ action_type: CommandKeybindType::Shell,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CustomCommandAction {
+ Shell,
+ Pane,
+}
+
+#[derive(Debug, Clone)]
+pub struct CustomCommandKeybind {
+ pub key: (KeyCode, KeyModifiers),
+ pub label: String,
+ pub command: String,
+ pub action: CustomCommandAction,
+}
+
+/// Parsed keybinds for navigate mode actions.
+#[derive(Debug, Clone)]
+pub struct Keybinds {
+ pub new_workspace: (KeyCode, KeyModifiers),
+ pub new_workspace_label: String,
+ pub rename_workspace: (KeyCode, KeyModifiers),
+ pub rename_workspace_label: String,
+ pub close_workspace: (KeyCode, KeyModifiers),
+ pub close_workspace_label: String,
+ pub detach: Option<(KeyCode, KeyModifiers)>,
+ pub detach_label: Option<String>,
+ pub previous_workspace: Option<(KeyCode, KeyModifiers)>,
+ pub previous_workspace_label: Option<String>,
+ pub next_workspace: Option<(KeyCode, KeyModifiers)>,
+ pub next_workspace_label: Option<String>,
+ pub new_tab: (KeyCode, KeyModifiers),
+ pub new_tab_label: String,
+ pub rename_tab: Option<(KeyCode, KeyModifiers)>,
+ pub rename_tab_label: Option<String>,
+ pub previous_tab: Option<(KeyCode, KeyModifiers)>,
+ pub previous_tab_label: Option<String>,
+ pub next_tab: Option<(KeyCode, KeyModifiers)>,
+ pub next_tab_label: Option<String>,
+ pub close_tab: Option<(KeyCode, KeyModifiers)>,
+ pub close_tab_label: Option<String>,
+ pub focus_pane_left: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_left_label: Option<String>,
+ pub focus_pane_down: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_down_label: Option<String>,
+ pub focus_pane_up: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_up_label: Option<String>,
+ pub focus_pane_right: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_right_label: Option<String>,
+ pub split_vertical: (KeyCode, KeyModifiers),
+ pub split_vertical_label: String,
+ pub split_horizontal: (KeyCode, KeyModifiers),
+ pub split_horizontal_label: String,
+ pub close_pane: (KeyCode, KeyModifiers),
+ pub close_pane_label: String,
+ pub fullscreen: (KeyCode, KeyModifiers),
+ pub fullscreen_label: String,
+ pub resize_mode: (KeyCode, KeyModifiers),
+ pub resize_mode_label: String,
+ pub toggle_sidebar: (KeyCode, KeyModifiers),
+ pub toggle_sidebar_label: String,
+ pub custom_commands: Vec<CustomCommandKeybind>,
+}
+
+impl Config {
+ pub(super) fn validated_keybinds(
+ &self,
+ ) -> (
+ Option<String>,
+ (KeyCode, KeyModifiers),
+ Vec<String>,
+ Keybinds,
+ ) {
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+ enum BindingScope {
+ Navigate,
+ TerminalDirect,
+ }
+
+ #[derive(Clone)]
+ struct RequiredBinding<'a> {
+ scope: BindingScope,
+ field: &'a str,
+ label: String,
+ default_label: &'a str,
+ value: (KeyCode, KeyModifiers),
+ default: (KeyCode, KeyModifiers),
+ }
+
+ struct OptionalBinding {
+ scope: BindingScope,
+ field: &'static str,
+ value: Option<(KeyCode, KeyModifiers)>,
+ label: Option<String>,
+ }
+
+ #[derive(Default)]
+ struct BindingRegistry {
+ seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>,
+ }
+
+ impl BindingRegistry {
+ fn register(
+ &mut self,
+ scope: BindingScope,
+ binding: (KeyCode, KeyModifiers),
+ field: &str,
+ ) -> Option<String> {
+ self.seen
+ .insert((scope, binding.0, binding.1), field.to_string())
+ }
+
+ fn conflict(
+ &self,
+ scope: BindingScope,
+ binding: (KeyCode, KeyModifiers),
+ ) -> Option<&str> {
+ self.seen
+ .get(&(scope, binding.0, binding.1))
+ .map(String::as_str)
+ }
+
+ fn reserve_if_unbound(
+ &mut self,
+ scope: BindingScope,
+ binding: (KeyCode, KeyModifiers),
+ field: &str,
+ ) {
+ self.seen
+ .entry((scope, binding.0, binding.1))
+ .or_insert_with(|| field.to_string());
+ }
+ }
+
+ fn required_binding<'a>(
+ scope: BindingScope,
+ field: &'a str,
+ configured_label: &'a str,
+ default_label: &'a str,
+ default: (KeyCode, KeyModifiers),
+ diagnostics: &mut Vec<String>,
+ ) -> RequiredBinding<'a> {
+ let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default);
+ let label = if let Some(diag) = diag {
+ diagnostics.push(diag);
+ default_label.to_string()
+ } else {
+ configured_label.to_string()
+ };
+ RequiredBinding {
+ scope,
+ field,
+ label,
+ default_label,
+ value,
+ default,
+ }
+ }
+
+ fn optional_binding(
+ scope: BindingScope,
+ field: &'static str,
+ configured_label: &str,
+ diagnostics: &mut Vec<String>,
+ ) -> OptionalBinding {
+ if configured_label.trim().is_empty() {
+ return OptionalBinding {
+ scope,
+ field,
+ value: None,
+ label: None,
+ };
+ }
+ match parse_key_combo(configured_label) {
+ Some(value) => OptionalBinding {
+ scope,
+ field,
+ value: Some(value),
+ label: Some(configured_label.to_string()),
+ },
+ None => {
+ let diag = format!(
+ "invalid keybinding: {field} = {:?}; disabling binding",
+ configured_label
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ OptionalBinding {
+ scope,
+ field,
+ value: None,
+ label: None,
+ }
+ }
+ }
+ }
+
+ let mut diagnostics = Vec::new();
+ let (prefix, prefix_diag) = parse_key_combo_with_diagnostic(
+ &self.keys.prefix,
+ "keys.prefix",
+ (KeyCode::Char('b'), KeyModifiers::CONTROL),
+ );
+ if let Some(diag) = &prefix_diag {
+ warn!(message = %diag, "config diagnostic");
+ }
+
+ let mut bindings = vec![
+ required_binding(
+ BindingScope::Navigate,
+ "keys.new_workspace",
+ &self.keys.new_workspace,
+ "n",
+ (KeyCode::Char('n'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.rename_workspace",
+ &self.keys.rename_workspace,
+ "shift+n",
+ (KeyCode::Char('n'), KeyModifiers::SHIFT),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.close_workspace",
+ &self.keys.close_workspace,
+ "shift+d",
+ (KeyCode::Char('d'), KeyModifiers::SHIFT),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.new_tab",
+ &self.keys.new_tab,
+ "c",
+ (KeyCode::Char('c'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.split_vertical",
+ &self.keys.split_vertical,
+ "v",
+ (KeyCode::Char('v'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.split_horizontal",
+ &self.keys.split_horizontal,
+ "-",
+ (KeyCode::Char('-'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.close_pane",
+ &self.keys.close_pane,
+ "x",
+ (KeyCode::Char('x'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.fullscreen",
+ &self.keys.fullscreen,
+ "f",
+ (KeyCode::Char('f'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.resize_mode",
+ &self.keys.resize_mode,
+ "r",
+ (KeyCode::Char('r'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.toggle_sidebar",
+ &self.keys.toggle_sidebar,
+ "b",
+ (KeyCode::Char('b'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ ];
+
+ let mut optional_bindings = vec![
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.detach",
+ &self.keys.detach,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.previous_workspace",
+ &self.keys.previous_workspace,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.next_workspace",
+ &self.keys.next_workspace,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.rename_tab",
+ &self.keys.rename_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.previous_tab",
+ &self.keys.previous_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.next_tab",
+ &self.keys.next_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.close_tab",
+ &self.keys.close_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::TerminalDirect,
+ "keys.focus_pane_left",
+ &self.keys.focus_pane_left,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::TerminalDirect,
+ "keys.focus_pane_down",
+ &self.keys.focus_pane_down,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::TerminalDirect,
+ "keys.focus_pane_up",
+ &self.keys.focus_pane_up,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::TerminalDirect,
+ "keys.focus_pane_right",
+ &self.keys.focus_pane_right,
+ &mut diagnostics,
+ ),
+ ];
+
+ let mut registry = BindingRegistry::default();
+ for binding in &mut bindings {
+ if let Some(first_field) = registry.conflict(binding.scope, binding.value) {
+ let diag = format!(
+ "duplicate keybinding: {} conflicts with {}; using default {}",
+ binding.field, first_field, binding.default_label
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ binding.value = binding.default;
+ binding.label = binding.default_label.to_string();
+ }
+ registry.register(binding.scope, binding.value, binding.field);
+ }
+
+ for binding in &mut optional_bindings {
+ let Some(value) = binding.value else {
+ continue;
+ };
+ if let Some(first_field) = registry.conflict(binding.scope, value) {
+ let diag = format!(
+ "duplicate keybinding: {} conflicts with {}; disabling binding",
+ binding.field, first_field
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ binding.value = None;
+ binding.label = None;
+ continue;
+ }
+ registry.register(binding.scope, value, binding.field);
+ }
+
+ registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix");
+ for (field, binding) in [
+ ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())),
+ (
+ "navigate.open_workspace",
+ (KeyCode::Enter, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.settings",
+ (KeyCode::Char('s'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.keybind_help",
+ (KeyCode::Char('?'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_up",
+ (KeyCode::Up, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_down",
+ (KeyCode::Down, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_left",
+ (KeyCode::Char('h'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_down",
+ (KeyCode::Char('j'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_up",
+ (KeyCode::Char('k'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_right",
+ (KeyCode::Char('l'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.arrow_left",
+ (KeyCode::Left, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.arrow_right",
+ (KeyCode::Right, KeyModifiers::empty()),
+ ),
+ ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())),
+ (
+ "navigate.tab_prev",
+ (KeyCode::BackTab, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_1",
+ (KeyCode::Char('1'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_2",
+ (KeyCode::Char('2'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_3",
+ (KeyCode::Char('3'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_4",
+ (KeyCode::Char('4'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_5",
+ (KeyCode::Char('5'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_6",
+ (KeyCode::Char('6'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_7",
+ (KeyCode::Char('7'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_8",
+ (KeyCode::Char('8'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_9",
+ (KeyCode::Char('9'), KeyModifiers::empty()),
+ ),
+ ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())),
+ ] {
+ registry.reserve_if_unbound(BindingScope::Navigate, 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");
+ 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 Some(binding) = parse_key_combo(&command.key) else {
+ let diag = format!(
+ "invalid keybinding: {} = {:?}; disabling custom command",
+ key_field, command.key
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ continue;
+ };
+
+ if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) {
+ let diag = format!(
+ "duplicate custom keybinding: {} conflicts with {}; disabling custom command",
+ key_field, first_field
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ continue;
+ }
+
+ registry.register(BindingScope::Navigate, binding, &key_field);
+ let action = match command.action_type {
+ CommandKeybindType::Shell => CustomCommandAction::Shell,
+ CommandKeybindType::Pane => CustomCommandAction::Pane,
+ };
+ custom_commands.push(CustomCommandKeybind {
+ key: binding,
+ label: format_key_combo(binding),
+ command: command.command.clone(),
+ action,
+ });
+ }
+
+ let keybinds = Keybinds {
+ new_workspace: bindings[0].value,
+ new_workspace_label: bindings[0].label.clone(),
+ rename_workspace: bindings[1].value,
+ rename_workspace_label: bindings[1].label.clone(),
+ close_workspace: bindings[2].value,
+ close_workspace_label: bindings[2].label.clone(),
+ detach: optional_bindings[0].value,
+ detach_label: optional_bindings[0].label.clone(),
+ previous_workspace: optional_bindings[1].value,
+ previous_workspace_label: optional_bindings[1].label.clone(),
+ next_workspace: optional_bindings[2].value,
+ next_workspace_label: optional_bindings[2].label.clone(),
+ new_tab: bindings[3].value,
+ new_tab_label: bindings[3].label.clone(),
+ rename_tab: optional_bindings[3].value,
+ rename_tab_label: optional_bindings[3].label.clone(),
+ previous_tab: optional_bindings[4].value,
+ previous_tab_label: optional_bindings[4].label.clone(),
+ next_tab: optional_bindings[5].value,
+ next_tab_label: optional_bindings[5].label.clone(),
+ close_tab: optional_bindings[6].value,
+ close_tab_label: optional_bindings[6].label.clone(),
+ focus_pane_left: optional_bindings[7].value,
+ focus_pane_left_label: optional_bindings[7].label.clone(),
+ focus_pane_down: optional_bindings[8].value,
+ focus_pane_down_label: optional_bindings[8].label.clone(),
+ focus_pane_up: optional_bindings[9].value,
+ focus_pane_up_label: optional_bindings[9].label.clone(),
+ focus_pane_right: optional_bindings[10].value,
+ focus_pane_right_label: optional_bindings[10].label.clone(),
+ split_vertical: bindings[4].value,
+ split_vertical_label: bindings[4].label.clone(),
+ split_horizontal: bindings[5].value,
+ split_horizontal_label: bindings[5].label.clone(),
+ close_pane: bindings[6].value,
+ close_pane_label: bindings[6].label.clone(),
+ fullscreen: bindings[7].value,
+ fullscreen_label: bindings[7].label.clone(),
+ resize_mode: bindings[8].value,
+ resize_mode_label: bindings[8].label.clone(),
+ toggle_sidebar: bindings[9].value,
+ toggle_sidebar_label: bindings[9].label.clone(),
+ custom_commands,
+ };
+
+ (prefix_diag, prefix, diagnostics, keybinds)
+ }
+}
+
+pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> 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) {
+ parts.push("shift".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!("{}+tab", parts.join("+"))
+ };
+ }
+
+ parts.push(key);
+ parts.join("+")
+}
+
+pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
+ 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();
+ 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);
+ }
+ }
+ }
+
+ let key_str = key_str?;
+
+ let lower = key_str.to_lowercase();
+ let code = match lower.as_str() {
+ "space" | " " => KeyCode::Char(' '),
+ "enter" | "return" => KeyCode::Enter,
+ "esc" | "escape" => KeyCode::Esc,
+ "tab" => KeyCode::Tab,
+ "backspace" | "bs" => KeyCode::Backspace,
+ "left" => KeyCode::Left,
+ "right" => KeyCode::Right,
+ "up" => KeyCode::Up,
+ "down" => KeyCode::Down,
+ s if s.len() == 1 => {
+ let ch = key_str.chars().next().unwrap();
+ 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::<u8>().ok().map(KeyCode::F)?,
+ _ => return None,
+ };
+
+ Some((code, modifiers))
+}
+
+fn parse_key_combo_with_diagnostic(
+ s: &str,
+ field: &str,
+ fallback: (KeyCode, KeyModifiers),
+) -> ((KeyCode, KeyModifiers), Option<String>) {
+ match parse_key_combo(s) {
+ Some(binding) => (binding, None),
+ None => {
+ let diag = format!("invalid keybinding: {field} = {s:?}; using fallback");
+ warn!(message = %diag, "config diagnostic");
+ (fallback, Some(diag))
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::Config;
+
+ #[test]
+ fn parse_simple_char() {
+ assert_eq!(
+ parse_key_combo("v"),
+ Some((KeyCode::Char('v'), KeyModifiers::empty()))
+ );
+ }
+
+ #[test]
+ fn parse_ctrl_combo() {
+ assert_eq!(
+ parse_key_combo("ctrl+b"),
+ Some((KeyCode::Char('b'), KeyModifiers::CONTROL))
+ );
+ }
+
+ #[test]
+ fn parse_special_key() {
+ assert_eq!(
+ parse_key_combo("enter"),
+ Some((KeyCode::Enter, KeyModifiers::empty()))
+ );
+ assert_eq!(
+ parse_key_combo("tab"),
+ Some((KeyCode::Tab, KeyModifiers::empty()))
+ );
+ assert_eq!(
+ parse_key_combo("esc"),
+ Some((KeyCode::Esc, KeyModifiers::empty()))
+ );
+ assert_eq!(
+ parse_key_combo("left"),
+ Some((KeyCode::Left, KeyModifiers::empty()))
+ );
+ assert_eq!(
+ parse_key_combo("alt+right"),
+ Some((KeyCode::Right, KeyModifiers::ALT))
+ );
+ }
+
+ #[test]
+ fn parse_ctrl_shift() {
+ assert_eq!(
+ parse_key_combo("ctrl+shift+a"),
+ Some((
+ KeyCode::Char('a'),
+ KeyModifiers::CONTROL | KeyModifiers::SHIFT
+ ))
+ );
+ }
+
+ #[test]
+ fn parse_f_key() {
+ assert_eq!(
+ parse_key_combo("f5"),
+ Some((KeyCode::F(5), KeyModifiers::empty()))
+ );
+ }
+
+ #[test]
+ fn parse_punctuation_key() {
+ assert_eq!(
+ parse_key_combo("ctrl+`"),
+ Some((KeyCode::Char('`'), KeyModifiers::CONTROL))
+ );
+ }
+
+ #[test]
+ fn uppercase_char_implies_shift() {
+ assert_eq!(
+ parse_key_combo("D"),
+ Some((KeyCode::Char('d'), KeyModifiers::SHIFT))
+ );
+ }
+
+ #[test]
+ fn explicit_shift_and_uppercase_do_not_double_apply_shift() {
+ assert_eq!(
+ parse_key_combo("shift+D"),
+ Some((KeyCode::Char('d'), KeyModifiers::SHIFT))
+ );
+ }
+
+ #[test]
+ fn invalid_keybinding_is_rejected() {
+ assert_eq!(parse_key_combo("ctrl+foo+bar"), None);
+ assert_eq!(parse_key_combo("ctrl+"), None);
+ }
+
+ #[test]
+ fn default_keybinds_parse() {
+ let config = Config::default();
+ let kb = config.keybinds();
+ assert_eq!(kb.new_workspace.0, KeyCode::Char('n'));
+ assert_eq!(
+ kb.rename_workspace,
+ (KeyCode::Char('n'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(
+ kb.close_workspace,
+ (KeyCode::Char('d'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(kb.detach, None);
+ assert_eq!(kb.split_vertical.0, KeyCode::Char('v'));
+ assert_eq!(kb.split_horizontal.0, KeyCode::Char('-'));
+ assert_eq!(kb.close_pane.0, KeyCode::Char('x'));
+ assert_eq!(kb.fullscreen.0, KeyCode::Char('f'));
+ assert_eq!(kb.resize_mode.0, KeyCode::Char('r'));
+ assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b'));
+ assert!(kb.custom_commands.is_empty());
+ }
+
+ #[test]
+ fn custom_keybinds_from_toml() {
+ let toml = r#"
+[keys]
+prefix = "ctrl+a"
+new_workspace = "c"
+rename_workspace = "shift+r"
+close_workspace = "ctrl+d"
+split_vertical = "s"
+split_horizontal = "shift+s"
+close_pane = "ctrl+w"
+fullscreen = "z"
+resize_mode = "ctrl+r"
+toggle_sidebar = "tab"
+focus_pane_left = "alt+h"
+focus_pane_right = "alt+right"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let (code, mods) = config.prefix_key();
+ assert_eq!(code, KeyCode::Char('a'));
+ assert_eq!(mods, KeyModifiers::CONTROL);
+
+ let kb = config.keybinds();
+ assert_eq!(
+ kb.new_workspace,
+ (KeyCode::Char('c'), KeyModifiers::empty())
+ );
+ assert_eq!(
+ kb.rename_workspace,
+ (KeyCode::Char('r'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(
+ kb.close_workspace,
+ (KeyCode::Char('d'), KeyModifiers::CONTROL)
+ );
+ assert_eq!(kb.split_vertical.0, KeyCode::Char('s'));
+ assert_eq!(
+ kb.split_horizontal,
+ (KeyCode::Char('s'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL));
+ assert_eq!(kb.fullscreen.0, KeyCode::Char('z'));
+ assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL));
+ assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty()));
+ assert_eq!(
+ kb.focus_pane_left,
+ Some((KeyCode::Char('h'), KeyModifiers::ALT))
+ );
+ assert_eq!(
+ kb.focus_pane_right,
+ Some((KeyCode::Right, KeyModifiers::ALT))
+ );
+ assert_eq!(kb.focus_pane_down, None);
+ assert_eq!(kb.focus_pane_up, None);
+ }
+
+ #[test]
+ fn uppercase_keybind_from_toml_flows_into_shift_combo() {
+ let toml = r#"
+[keys]
+close_pane = "X"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let kb = config.keybinds();
+ assert_eq!(kb.close_pane, (KeyCode::Char('x'), KeyModifiers::SHIFT));
+ }
+
+ #[test]
+ fn invalid_keybinding_produces_diagnostic_and_falls_back() {
+ let toml = r#"
+[keys]
+rename_workspace = "wat"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics
+ .iter()
+ .any(|d| d.contains("keys.rename_workspace")));
+ assert_eq!(
+ kb.rename_workspace,
+ (KeyCode::Char('n'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(kb.rename_workspace_label, "shift+n");
+ }
+
+ #[test]
+ fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() {
+ let toml = r#"
+[keys]
+new_workspace = "g"
+rename_workspace = "g"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics
+ .iter()
+ .any(|d| d.contains("duplicate keybinding")));
+ assert_eq!(
+ kb.new_workspace,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ assert_eq!(
+ kb.rename_workspace,
+ (KeyCode::Char('n'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(kb.rename_workspace_label, "shift+n");
+ }
+
+ #[test]
+ fn duplicate_optional_keybinding_is_disabled_with_diagnostic() {
+ let toml = r#"
+[keys]
+new_workspace = "g"
+rename_tab = "g"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics
+ .iter()
+ .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding")));
+ assert_eq!(
+ kb.new_workspace,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ assert_eq!(kb.rename_tab, None);
+ }
+
+ #[test]
+ fn custom_command_keybinds_parse_from_toml() {
+ let toml = r#"
+[[keys.command]]
+key = "g"
+command = "echo hi"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let kb = config.keybinds();
+
+ assert_eq!(kb.custom_commands.len(), 1);
+ assert_eq!(
+ kb.custom_commands[0].key,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ assert_eq!(kb.custom_commands[0].label, "g");
+ assert_eq!(kb.custom_commands[0].command, "echo hi");
+ assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell);
+ }
+
+ #[test]
+ fn pane_custom_command_keybinds_parse_from_toml() {
+ let toml = r#"
+[[keys.command]]
+key = "g"
+type = "pane"
+command = "lazygit"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let kb = config.keybinds();
+
+ assert_eq!(kb.custom_commands.len(), 1);
+ assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane);
+ }
+
+ #[test]
+ fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() {
+ let toml = r#"
+[keys]
+new_workspace = "g"
+
+[[keys.command]]
+key = "g"
+command = "echo hi"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics.iter().any(|d| {
+ d.contains("duplicate custom keybinding")
+ && d.contains("keys.command[0].key")
+ && d.contains("keys.new_workspace")
+ }));
+ assert!(kb.custom_commands.is_empty());
+ }
+
+ #[test]
+ fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() {
+ let toml = r#"
+[[keys.command]]
+key = "q"
+command = "echo hi"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics.iter().any(|d| {
+ d.contains("duplicate custom keybinding")
+ && d.contains("keys.command[0].key")
+ && d.contains("navigate.quit")
+ }));
+ assert!(kb.custom_commands.is_empty());
+ }
+
+ #[test]
+ fn live_keybinds_reject_invalid_keybinding() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+rename_workspace = "wat"
+"#,
+ )
+ .unwrap();
+
+ let diagnostics = config.live_keybinds().unwrap_err();
+ assert!(diagnostics
+ .iter()
+ .any(|d| d.contains("keys.rename_workspace")));
+ }
+
+ #[test]
+ fn live_keybinds_ignore_non_key_diagnostics() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+new_workspace = "g"
+
+[ui.sound]
+done_path = "sounds/missing.mp3"
+"#,
+ )
+ .unwrap();
+
+ let live = config.live_keybinds().unwrap();
+ assert_eq!(
+ live.keybinds.new_workspace,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ }
+}