aboutsummaryrefslogtreecommitdiff
path: root/src/config/keybinds.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-21 16:30:04 +0300
committerGitHub <noreply@github.com>2026-04-21 16:30:04 +0300
commit3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch)
treec457a7cf0af271dea1a412474fad5a33c1b60be8 /src/config/keybinds.rs
parent2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff)
feat: add persistent server-client sessions
Make herdr persistent by default. Launching herdr now starts or reattaches to a background session server. Clients can detach and reattach while panes and agent processes keep running. Session mode now supports multi-client attach, auto-detect startup, and a thin-client/headless-server split. This also refactors the large app, ui, input, pane, config, workspace, and persistence modules into smaller focused submodules, while preserving behavior and colocating tests with the code they exercise. Upgrade notes: - persistence mode is now the default - in-app quit detaches the current client instead of stopping the server - use `herdr server stop` to stop the background session - use `--no-session` for the old single-process behavior - default socket paths now live under the config directory
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())
+ );
+ }
+}