aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-28 04:29:37 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-28 04:29:37 +0300
commitf18f26d1c0fd8101baee946f71bdd77b9743df41 (patch)
tree0fd096fe8c6acd069209ec8ff340a0d0f0921966 /src
parent6a4315c4ee563b515c2b775df8d78d242d680ced (diff)
fix prefix key defaults and binding parsing
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs10
-rw-r--r--src/app/state.rs58
-rw-r--r--src/config.rs111
-rw-r--r--src/main.rs8
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"