diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 10 | ||||
| -rw-r--r-- | src/app/state.rs | 58 | ||||
| -rw-r--r-- | src/config.rs | 111 | ||||
| -rw-r--r-- | src/main.rs | 8 |
4 files changed, 149 insertions, 38 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index c91722f7..254b7916 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -8,7 +8,7 @@ use tracing::warn; use crate::layout::{NavDirection, PaneInfo, SplitBorder}; use crate::selection::Selection; -use super::state::{AppState, ContextMenuState, DragState, Mode, CONTEXT_MENU_ITEMS}; +use super::state::{key_matches, AppState, ContextMenuState, DragState, Mode, CONTEXT_MENU_ITEMS}; use super::App; // --------------------------------------------------------------------------- @@ -86,19 +86,19 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { // Configurable keybinds (checked before fixed binds) let kb = &state.keybinds; - if key.code == kb.split_vertical.0 && key.modifiers.contains(kb.split_vertical.1) { + if key_matches(&key, kb.split_vertical.0, kb.split_vertical.1) { state.split_pane(Direction::Horizontal); return; } - if key.code == kb.split_horizontal.0 && key.modifiers.contains(kb.split_horizontal.1) { + if key_matches(&key, kb.split_horizontal.0, kb.split_horizontal.1) { state.split_pane(Direction::Vertical); return; } - if key.code == kb.close_pane.0 && key.modifiers.contains(kb.close_pane.1) { + if key_matches(&key, kb.close_pane.0, kb.close_pane.1) { state.close_pane(); return; } - if key.code == kb.fullscreen.0 && key.modifiers.contains(kb.fullscreen.1) { + if key_matches(&key, kb.fullscreen.0, kb.fullscreen.1) { state.toggle_fullscreen(); return; } diff --git a/src/app/state.rs b/src/app/state.rs index 92d729e8..7a577bb8 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -75,7 +75,7 @@ pub struct AppState { impl AppState { pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool { - key.code == self.prefix_code && key.modifiers.contains(self.prefix_mods) + key_matches(key, self.prefix_code, self.prefix_mods) } pub fn estimate_pane_size(&self) -> (u16, u16) { @@ -87,6 +87,25 @@ impl AppState { } } +pub fn key_matches( + key: &crossterm::event::KeyEvent, + expected_code: KeyCode, + expected_mods: KeyModifiers, +) -> bool { + if key.modifiers != expected_mods { + return false; + } + + match (key.code, expected_code) { + (KeyCode::Char(actual), KeyCode::Char(expected)) + if actual.is_ascii_alphabetic() && expected.is_ascii_alphabetic() => + { + actual.eq_ignore_ascii_case(&expected) + } + (actual, expected) => actual == expected, + } +} + // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- @@ -113,9 +132,9 @@ impl AppState { context_menu: None, update_available: None, update_dismissed: false, - prefix_code: KeyCode::Char('s'), + prefix_code: KeyCode::Char('b'), prefix_mods: KeyModifiers::CONTROL, - prefix_label: "ctrl+s".into(), + prefix_label: "ctrl+b".into(), sidebar_width: 26, sidebar_collapsed: false, confirm_close: true, @@ -133,3 +152,36 @@ impl AppState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEvent; + + #[test] + fn key_matches_requires_exact_modifiers() { + assert!(key_matches( + &KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), + KeyCode::Char('b'), + KeyModifiers::CONTROL, + )); + + assert!(!key_matches( + &KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + KeyCode::Char('b'), + KeyModifiers::CONTROL, + )); + } + + #[test] + fn key_matches_letters_case_insensitively() { + assert!(key_matches( + &KeyEvent::new(KeyCode::Char('B'), KeyModifiers::SHIFT), + KeyCode::Char('b'), + KeyModifiers::SHIFT, + )); + } +} diff --git a/src/config.rs b/src/config.rs index fe14ca8e..0bd280ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,7 +16,7 @@ pub struct Config { #[derive(Debug, Deserialize)] #[serde(default)] pub struct KeysConfig { - /// Prefix key to toggle navigate mode (e.g. "ctrl+s", "ctrl+a", "ctrl+b"). + /// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc"). pub prefix: String, /// Split pane vertically (side by side). Default: "v" pub split_vertical: String, @@ -105,7 +105,7 @@ impl AgentSoundOverrides { impl Default for KeysConfig { fn default() -> Self { Self { - prefix: "ctrl+s".into(), + prefix: "ctrl+b".into(), split_vertical: "v".into(), split_horizontal: "-".into(), close_pane: "x".into(), @@ -168,21 +168,45 @@ impl Config { } pub fn prefix_key(&self) -> (KeyCode, KeyModifiers) { - parse_key_combo(&self.keys.prefix) + parse_key_combo_or_warn( + &self.keys.prefix, + "keys.prefix", + (KeyCode::Char('b'), KeyModifiers::CONTROL), + ) } /// Human-readable label for the prefix key (shown in status bar). pub fn prefix_label(&self) -> String { - self.keys.prefix.clone() + if parse_key_combo(&self.keys.prefix).is_some() { + self.keys.prefix.clone() + } else { + "ctrl+b".into() + } } /// Parsed keybinds for navigate mode actions. pub fn keybinds(&self) -> Keybinds { Keybinds { - split_vertical: parse_key_combo(&self.keys.split_vertical), - split_horizontal: parse_key_combo(&self.keys.split_horizontal), - close_pane: parse_key_combo(&self.keys.close_pane), - fullscreen: parse_key_combo(&self.keys.fullscreen), + split_vertical: parse_key_combo_or_warn( + &self.keys.split_vertical, + "keys.split_vertical", + (KeyCode::Char('v'), KeyModifiers::empty()), + ), + split_horizontal: parse_key_combo_or_warn( + &self.keys.split_horizontal, + "keys.split_horizontal", + (KeyCode::Char('-'), KeyModifiers::empty()), + ), + close_pane: parse_key_combo_or_warn( + &self.keys.close_pane, + "keys.close_pane", + (KeyCode::Char('x'), KeyModifiers::empty()), + ), + fullscreen: parse_key_combo_or_warn( + &self.keys.fullscreen, + "keys.fullscreen", + (KeyCode::Char('f'), KeyModifiers::empty()), + ), } } } @@ -272,35 +296,52 @@ fn config_path() -> PathBuf { } } -fn parse_key_combo(s: &str) -> (KeyCode, KeyModifiers) { +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 = ""; + let mut key_str: Option<&str> = None; for part in &parts { - match part.trim().to_lowercase().as_str() { + let trimmed = part.trim(); + match trimmed.to_lowercase().as_str() { "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, "shift" => modifiers |= KeyModifiers::SHIFT, "alt" | "meta" => modifiers |= KeyModifiers::ALT, - _ => key_str = part.trim(), + _ if trimmed.is_empty() => return None, + _ => { + if key_str.is_some() { + return None; + } + key_str = Some(trimmed); + } } } + let key_str = key_str?; + let code = match key_str.to_lowercase().as_str() { "space" | " " => KeyCode::Char(' '), "enter" | "return" => KeyCode::Enter, "esc" | "escape" => KeyCode::Esc, "tab" => KeyCode::Tab, "backspace" | "bs" => KeyCode::Backspace, - s if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap()), - s if s.starts_with('f') => s[1..] - .parse::<u8>() - .map(KeyCode::F) - .unwrap_or(KeyCode::Char('s')), - _ => KeyCode::Char('s'), + s if s.len() == 1 => KeyCode::Char(key_str.chars().next().unwrap()), + s if s.starts_with('f') => s[1..].parse::<u8>().ok().map(KeyCode::F)?, + _ => return None, }; - (code, modifiers) + Some((code, modifiers)) +} + +fn parse_key_combo_or_warn( + s: &str, + field: &str, + fallback: (KeyCode, KeyModifiers), +) -> (KeyCode, KeyModifiers) { + parse_key_combo(s).unwrap_or_else(|| { + warn!(field, value = s, "invalid keybinding, using fallback"); + fallback + }) } #[cfg(test)] @@ -312,15 +353,15 @@ mod tests { fn parse_simple_char() { assert_eq!( parse_key_combo("v"), - (KeyCode::Char('v'), KeyModifiers::empty()) + Some((KeyCode::Char('v'), KeyModifiers::empty())) ); } #[test] fn parse_ctrl_combo() { assert_eq!( - parse_key_combo("ctrl+s"), - (KeyCode::Char('s'), KeyModifiers::CONTROL) + parse_key_combo("ctrl+b"), + Some((KeyCode::Char('b'), KeyModifiers::CONTROL)) ); } @@ -328,15 +369,15 @@ mod tests { fn parse_special_key() { assert_eq!( parse_key_combo("enter"), - (KeyCode::Enter, KeyModifiers::empty()) + Some((KeyCode::Enter, KeyModifiers::empty())) ); assert_eq!( parse_key_combo("tab"), - (KeyCode::Tab, KeyModifiers::empty()) + Some((KeyCode::Tab, KeyModifiers::empty())) ); assert_eq!( parse_key_combo("esc"), - (KeyCode::Esc, KeyModifiers::empty()) + Some((KeyCode::Esc, KeyModifiers::empty())) ); } @@ -344,10 +385,10 @@ mod tests { fn parse_ctrl_shift() { assert_eq!( parse_key_combo("ctrl+shift+a"), - ( + Some(( KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT - ) + )) ); } @@ -355,11 +396,25 @@ mod tests { fn parse_f_key() { assert_eq!( parse_key_combo("f5"), - (KeyCode::F(5), KeyModifiers::empty()) + Some((KeyCode::F(5), KeyModifiers::empty())) ); } #[test] + fn parse_punctuation_key() { + assert_eq!( + parse_key_combo("ctrl+`"), + Some((KeyCode::Char('`'), KeyModifiers::CONTROL)) + ); + } + + #[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(); diff --git a/src/main.rs b/src/main.rs index 5659a386..8f23287d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,8 +64,12 @@ const DEFAULT_CONFIG: &str = r#"# herdr configuration # Place this file at ~/.config/herdr/config.toml [keys] -# Prefix key to enter navigate mode (e.g. "ctrl+s", "ctrl+a", "ctrl+b") -# prefix = "ctrl+s" +# Prefix key to enter navigate mode (default: "ctrl+b") +# Examples: "ctrl+b", "f12", "esc", "-" +# Accepted syntax: plain keys, ctrl/shift/alt modifiers, and special keys like enter/tab/esc +# Most reliable bindings are plain keys, ctrl+letter, esc/tab/enter, and function keys. +# alt+... and punctuation-with-modifiers may depend on your terminal/tmux setup. +# prefix = "ctrl+b" # Pane controls (in navigate mode) # split_vertical = "v" |
