mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
1100 lines
35 KiB
Rust
1100 lines
35 KiB
Rust
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 reload_config: Option<(KeyCode, KeyModifiers)>,
|
|
pub reload_config_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.reload_config",
|
|
&self.keys.reload_config,
|
|
&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(),
|
|
reload_config: optional_bindings[1].value,
|
|
reload_config_label: optional_bindings[1].label.clone(),
|
|
previous_workspace: optional_bindings[2].value,
|
|
previous_workspace_label: optional_bindings[2].label.clone(),
|
|
next_workspace: optional_bindings[3].value,
|
|
next_workspace_label: optional_bindings[3].label.clone(),
|
|
new_tab: bindings[3].value,
|
|
new_tab_label: bindings[3].label.clone(),
|
|
rename_tab: optional_bindings[4].value,
|
|
rename_tab_label: optional_bindings[4].label.clone(),
|
|
previous_tab: optional_bindings[5].value,
|
|
previous_tab_label: optional_bindings[5].label.clone(),
|
|
next_tab: optional_bindings[6].value,
|
|
next_tab_label: optional_bindings[6].label.clone(),
|
|
close_tab: optional_bindings[7].value,
|
|
close_tab_label: optional_bindings[7].label.clone(),
|
|
focus_pane_left: optional_bindings[8].value,
|
|
focus_pane_left_label: optional_bindings[8].label.clone(),
|
|
focus_pane_down: optional_bindings[9].value,
|
|
focus_pane_down_label: optional_bindings[9].label.clone(),
|
|
focus_pane_up: optional_bindings[10].value,
|
|
focus_pane_up_label: optional_bindings[10].label.clone(),
|
|
focus_pane_right: optional_bindings[11].value,
|
|
focus_pane_right_label: optional_bindings[11].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())
|
|
);
|
|
}
|
|
}
|