diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-24 03:37:41 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-24 03:41:16 +0300 |
| commit | 26205a2ac5e21053bd94a0925efcfca44c6f549b (patch) | |
| tree | 530fdd445bedadb521759fa89792b98aaf0e3f39 | |
| parent | c5b3997f3b7d334bd2f35c7aee5216236b58193e (diff) | |
feat: make navigate movement keybinds configurable
refs #193
| -rw-r--r-- | docs/next/website/src/content/docs/configuration.mdx | 10 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 291 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 273 | ||||
| -rw-r--r-- | src/config/model.rs | 18 | ||||
| -rw-r--r-- | src/main.rs | 9 | ||||
| -rw-r--r-- | src/ui/keybind_help.rs | 20 | ||||
| -rw-r--r-- | src/ui/menus.rs | 11 |
7 files changed, 573 insertions, 59 deletions
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 23debd45..735befb2 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -96,6 +96,8 @@ new_tab = "prefix+c" next_tab = "prefix+n" previous_tab = "prefix+p" focus_pane_left = "prefix+h" +navigate_workspace_down = "j" +navigate_pane_down = "ctrl+j" split_horizontal = "prefix+minus" ``` @@ -110,6 +112,12 @@ new_workspace = "prefix+shift+n" new_worktree = "prefix+shift+g" rename_workspace = "prefix+shift+w" close_workspace = "prefix+shift+d" +navigate_workspace_up = "up" +navigate_workspace_down = "down" +navigate_pane_left = "h" +navigate_pane_down = "j" +navigate_pane_up = "k" +navigate_pane_right = "l" new_tab = "prefix+c" previous_tab = "prefix+p" next_tab = "prefix+n" @@ -139,7 +147,7 @@ remove_worktree = "prefix+alt+d" next_tab = ["prefix+n", "ctrl+alt+]"] ``` -Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Named punctuation such as `minus`, `comma`, `ampersand`, `plus`, and `backtick` is also accepted. Plain direct printable keys such as `n` are unsafe because they intercept typing; use `prefix+n` unless you intentionally want a direct binding. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings. +Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Named punctuation such as `minus`, `comma`, `ampersand`, `plus`, and `backtick` is also accepted. Plain direct printable keys such as `n` are unsafe because they intercept typing; use `prefix+n` unless you intentionally want a direct binding. The `navigate_workspace_*` and `navigate_pane_*` fields are navigate-mode-only and may use plain keys such as `j` or `k`; they must not use `prefix+`, `esc`, `enter`, `tab`, `shift+tab`, `left`, `right`, or unmodified `1` through `9`. Left and right arrows are permanent aliases for pane-left and pane-right navigation. These navigate-mode shortcuts are independent from general action bindings such as `focus_pane_down = "prefix+j"`; when both use the same key, the navigate-mode shortcut wins while navigate mode is open. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings. If you have old custom keybindings and want the new defaults, run `herdr config reset-keys`. Herdr backs up `config.toml`, removes `[keys]` and `[[keys.command]]`, and uses built-in v2 defaults after restart or `herdr server reload-config`. diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 4e8a50b3..108fed8b 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -84,11 +84,11 @@ impl App { return; } - if handle_navigate_reserved_key(&mut self.state, key) { + if handle_navigate_reserved_key(&mut self.state, raw_key) { return; } - if let Some(action) = action_for_key(&self.state, raw_key, BindingDispatch::Prefix) { + if let Some(action) = navigate_mode_action_for_key(&self.state, raw_key) { if action == NavigateAction::EditScrollback { self.launch_focused_scrollback_editor(); } else { @@ -367,62 +367,76 @@ pub(crate) fn command_for_key( .cloned() } -pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool { +pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKey) -> bool { let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); - if !modifiers.is_empty() { - return false; - } - - match code { - KeyCode::Enter => { - if !state.workspaces.is_empty() { - state.switch_workspace(state.selected); - leave_navigate_mode(state); + if modifiers.is_empty() { + match code { + KeyCode::Enter => { + if !state.workspaces.is_empty() { + state.switch_workspace(state.selected); + leave_navigate_mode(state); + } + return true; } - true - } - KeyCode::Char(c @ '1'..='9') => { - let idx = (c as usize) - ('1' as usize); - if let Some(ws_idx) = state.workspace_at_visible_position(idx) { - state.switch_workspace(ws_idx); - leave_navigate_mode(state); + KeyCode::Char(c @ '1'..='9') => { + let idx = (c as usize) - ('1' as usize); + if let Some(ws_idx) = state.workspace_at_visible_position(idx) { + state.switch_workspace(ws_idx); + leave_navigate_mode(state); + } + return true; } - true - } - KeyCode::Up => { - state.move_selected_workspace_by_visible_delta(-1); - true - } - KeyCode::Down => { - state.move_selected_workspace_by_visible_delta(1); - true - } - KeyCode::Char('h') | KeyCode::Left => { - state.navigate_pane(NavDirection::Left); - true - } - KeyCode::Char('j') => { - state.navigate_pane(NavDirection::Down); - true - } - KeyCode::Char('k') => { - state.navigate_pane(NavDirection::Up); - true - } - KeyCode::Char('l') | KeyCode::Right => { - state.navigate_pane(NavDirection::Right); - true - } - KeyCode::Tab => { - state.cycle_pane(false); - true - } - KeyCode::BackTab => { - state.cycle_pane(true); - true + KeyCode::Tab => { + state.cycle_pane(false); + return true; + } + KeyCode::BackTab => { + state.cycle_pane(true); + return true; + } + KeyCode::Left => { + state.navigate_pane(NavDirection::Left); + return true; + } + KeyCode::Right => { + state.navigate_pane(NavDirection::Right); + return true; + } + _ => {} } - _ => false, } + + if state.keybinds.navigate.workspace_up.matches_direct_key(key) { + state.move_selected_workspace_by_visible_delta(-1); + return true; + } + if state + .keybinds + .navigate + .workspace_down + .matches_direct_key(key) + { + state.move_selected_workspace_by_visible_delta(1); + return true; + } + if state.keybinds.navigate.pane_left.matches_direct_key(key) { + state.navigate_pane(NavDirection::Left); + return true; + } + if state.keybinds.navigate.pane_down.matches_direct_key(key) { + state.navigate_pane(NavDirection::Down); + return true; + } + if state.keybinds.navigate.pane_up.matches_direct_key(key) { + state.navigate_pane(NavDirection::Up); + return true; + } + if state.keybinds.navigate.pane_right.matches_direct_key(key) { + state.navigate_pane(NavDirection::Right); + return true; + } + + false } #[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key @@ -436,11 +450,11 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { return; } - if handle_navigate_reserved_key(state, key) { + if handle_navigate_reserved_key(state, terminal_key) { return; } - if let Some(action) = action_for_key(state, terminal_key, BindingDispatch::Prefix) { + if let Some(action) = navigate_mode_action_for_key(state, terminal_key) { execute_navigate_action_in_context( state, &mut terminal_runtimes, @@ -598,6 +612,20 @@ fn action_for_key( None } +fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option<NavigateAction> { + let action = action_for_key(state, key, BindingDispatch::Prefix)?; + if matches!( + action, + NavigateAction::FocusPaneLeft + | NavigateAction::FocusPaneDown + | NavigateAction::FocusPaneUp + | NavigateAction::FocusPaneRight + ) { + return None; + } + Some(action) +} + #[cfg(test)] pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { let mut terminal_runtimes = TerminalRuntimeRegistry::new(); @@ -1229,6 +1257,159 @@ mod tests { } #[test] + fn navigate_workspace_keys_are_configurable() { + let mut state = state_with_workspaces(&["a", "b"]); + let config: Config = toml::from_str( + r#" +[keys] +navigate_workspace_down = "j" +navigate_pane_down = "ctrl+j" +"#, + ) + .unwrap(); + state.keybinds = config.keybinds(); + state.selected = 0; + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), + ); + + assert_eq!(state.selected, 1); + assert_eq!(state.mode, Mode::Navigate); + } + + #[test] + fn navigate_pane_keys_are_configurable() { + let mut state = state_with_workspaces(&["test"]); + let root = state.workspaces[0].tabs[0].root_pane; + let below = state.workspaces[0].test_split(Direction::Vertical); + state.workspaces[0].layout.focus_pane(root); + state.view.pane_infos = state.workspaces[0] + .active_tab() + .unwrap() + .layout + .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); + let config: Config = toml::from_str( + r#" +[keys] +navigate_workspace_down = "j" +navigate_pane_down = "ctrl+j" +"#, + ) + .unwrap(); + state.keybinds = config.keybinds(); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), + ); + + assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); + assert_eq!(state.mode, Mode::Navigate); + } + + #[test] + fn focus_pane_prefix_rhs_does_not_create_navigate_mode_pane_shortcut() { + let mut state = state_with_workspaces(&["test"]); + let root = state.workspaces[0].tabs[0].root_pane; + let below = state.workspaces[0].test_split(Direction::Vertical); + state.workspaces[0].layout.focus_pane(root); + state.view.pane_infos = state.workspaces[0] + .active_tab() + .unwrap() + .layout + .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); + let config: Config = toml::from_str( + r#" +[keys] +focus_pane_down = "prefix+f" +"#, + ) + .unwrap(); + state.keybinds = config.keybinds(); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), + ); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), + ); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); + assert_eq!(state.mode, Mode::Navigate); + } + + #[test] + fn customized_navigate_pane_key_disables_matching_prefix_rhs_fallback() { + let mut state = state_with_workspaces(&["test"]); + let root = state.workspaces[0].tabs[0].root_pane; + let below = state.workspaces[0].test_split(Direction::Vertical); + state.workspaces[0].layout.focus_pane(root); + state.view.pane_infos = state.workspaces[0] + .active_tab() + .unwrap() + .layout + .panes(ratatui::layout::Rect::new(0, 0, 80, 24)); + let config: Config = toml::from_str( + r#" +[keys] +navigate_pane_down = "ctrl+j" +"#, + ) + .unwrap(); + state.keybinds = config.keybinds(); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), + ); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), + ); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(below)); + assert_eq!(state.mode, Mode::Navigate); + } + + #[test] + fn left_and_right_arrows_remain_permanent_navigate_pane_aliases() { + let mut state = state_with_workspaces(&["test"]); + let root = state.workspaces[0].tabs[0].root_pane; + let right = state.workspaces[0].test_split(Direction::Horizontal); + state.workspaces[0].layout.focus_pane(right); + crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24)); + let config: Config = toml::from_str( + r#" +[keys] +navigate_pane_left = "ctrl+h" +navigate_pane_right = "ctrl+l" +"#, + ) + .unwrap(); + state.keybinds = config.keybinds(); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Left, KeyModifiers::empty()), + ); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); + crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24)); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), + ); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); + assert_eq!(state.mode, Mode::Navigate); + } + + #[test] fn mobile_workspace_keyboard_navigation_keeps_selected_row_visible() { let mut state = state_with_workspaces(&["a", "b", "c", "d"]); state.active = Some(0); diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index e6109e30..9235cf05 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -242,7 +242,19 @@ pub struct CustomCommandKeybind { /// Parsed keybinds for Herdr actions. #[derive(Debug, Clone)] +pub struct NavigateKeybinds { + pub workspace_up: ActionKeybinds, + pub workspace_down: ActionKeybinds, + pub pane_left: ActionKeybinds, + pub pane_down: ActionKeybinds, + pub pane_up: ActionKeybinds, + pub pane_right: ActionKeybinds, +} + +/// Parsed keybinds for Herdr actions. +#[derive(Debug, Clone)] pub struct Keybinds { + pub navigate: NavigateKeybinds, pub help: ActionKeybinds, pub settings: ActionKeybinds, pub new_workspace: ActionKeybinds, @@ -363,6 +375,9 @@ impl Config { let mut registry = BindingRegistry::new(prefix); registry.reserve_direct(prefix, "keys.prefix"); + let mut navigate_registry = BindingRegistry::new(prefix); + navigate_registry.reserve_direct(prefix, "keys.prefix"); + reserve_navigate_runtime_keys(&mut navigate_registry); macro_rules! action { ($field:literal, $config:expr) => { @@ -376,6 +391,44 @@ impl Config { } let mut keybinds = Keybinds { + navigate: NavigateKeybinds { + workspace_up: parse_navigate_bindings( + "keys.navigate_workspace_up", + &self.keys.navigate_workspace_up, + &mut navigate_registry, + &mut diagnostics, + ), + workspace_down: parse_navigate_bindings( + "keys.navigate_workspace_down", + &self.keys.navigate_workspace_down, + &mut navigate_registry, + &mut diagnostics, + ), + pane_left: parse_navigate_bindings( + "keys.navigate_pane_left", + &self.keys.navigate_pane_left, + &mut navigate_registry, + &mut diagnostics, + ), + pane_down: parse_navigate_bindings( + "keys.navigate_pane_down", + &self.keys.navigate_pane_down, + &mut navigate_registry, + &mut diagnostics, + ), + pane_up: parse_navigate_bindings( + "keys.navigate_pane_up", + &self.keys.navigate_pane_up, + &mut navigate_registry, + &mut diagnostics, + ), + pane_right: parse_navigate_bindings( + "keys.navigate_pane_right", + &self.keys.navigate_pane_right, + &mut navigate_registry, + &mut diagnostics, + ), + }, help: action!("keys.help", &self.keys.help), settings: action!("keys.settings", &self.keys.settings), new_workspace: action!("keys.new_workspace", &self.keys.new_workspace), @@ -486,6 +539,27 @@ impl Config { } } +fn reserve_navigate_runtime_keys(registry: &mut BindingRegistry) { + for combo in [ + (KeyCode::Esc, KeyModifiers::empty()), + (KeyCode::Enter, KeyModifiers::empty()), + (KeyCode::Tab, KeyModifiers::empty()), + (KeyCode::BackTab, KeyModifiers::empty()), + (KeyCode::Tab, KeyModifiers::SHIFT), + (KeyCode::Left, KeyModifiers::empty()), + (KeyCode::Right, KeyModifiers::empty()), + ] { + registry.reserve_direct(combo, "navigate reserved keys"); + } + + for idx in '1'..='9' { + registry.reserve_direct( + (KeyCode::Char(idx), KeyModifiers::empty()), + "navigate reserved keys", + ); + } +} + fn parse_action_bindings( field: &'static str, config: &BindingConfig, @@ -541,6 +615,41 @@ fn parse_action_bindings_owned( ActionKeybinds { bindings } } +fn parse_navigate_bindings( + field: &'static str, + config: &BindingConfig, + registry: &mut BindingRegistry, + diagnostics: &mut Vec<String>, +) -> ActionKeybinds { + let mut bindings = Vec::new(); + for raw in config.values() { + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + match parse_binding_string(raw) { + Some(ParsedBinding::Single(binding)) => { + if reject_navigate_binding(field, &binding, registry, diagnostics) { + continue; + } + registry.register(&binding, field); + bindings.push(binding); + } + Some(ParsedBinding::Range(_)) => { + let diag = format!("range keybinding is only valid for indexed actions: {field} = {raw:?}; disabling binding"); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + } + None => { + let diag = format!("invalid keybinding: {field} = {raw:?}; disabling binding"); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + } + } + } + ActionKeybinds { bindings } +} + fn parse_indexed_bindings( field: &'static str, config: &BindingConfig, @@ -608,6 +717,42 @@ fn append_legacy_indexed_bindings( } } +fn reject_navigate_binding( + field: &str, + binding: &ResolvedBinding, + registry: &BindingRegistry, + diagnostics: &mut Vec<String>, +) -> bool { + if binding.trigger.is_prefix() { + let diag = format!( + "navigate keybinding must not include prefix: {field} = {:?}; disabling binding", + binding.label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } + + if matches!(normalize_key_combo(binding.trigger.combo()).0, KeyCode::Esc) { + let diag = format!( + "navigate keybinding cannot use esc: {field} = {:?}; disabling binding", + binding.label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } + + if let Some(first_field) = registry.conflict(binding) { + let diag = format!("{}: kept {first_field}, disabled {field}", binding.label); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } + + false +} + fn reject_binding( field: &str, binding: &ResolvedBinding, @@ -1249,6 +1394,134 @@ help = "prefix+ctrl+b" } #[test] + fn navigate_bindings_allow_plain_keys_and_reject_local_conflicts() { + let config: Config = toml::from_str( + r#" +[keys] +navigate_workspace_up = "j" +navigate_workspace_down = "j" +navigate_pane_down = "ctrl+j" +"#, + ) + .unwrap(); + let keybinds = config.keybinds(); + let diagnostics = config.collect_diagnostics(); + + assert!(keybinds + .navigate + .workspace_up + .matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()))); + assert!(keybinds.navigate.workspace_down.bindings.is_empty()); + assert!(keybinds + .navigate + .pane_down + .matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::CONTROL))); + assert!(diagnostics.iter().any(|diag| { + diag.contains("kept keys.navigate_workspace_up") + && diag.contains("disabled keys.navigate_workspace_down") + })); + } + + #[test] + fn navigate_bindings_reject_runtime_reserved_keys() { + let config: Config = toml::from_str( + r#" +[keys] +navigate_workspace_up = ["esc", "alt+esc", "enter", "1", "tab", "shift+tab", "left", "right"] +"#, + ) + .unwrap(); + let keybinds = config.keybinds(); + let diagnostics = config.collect_diagnostics(); + + assert!(keybinds.navigate.workspace_up.bindings.is_empty()); + assert_eq!( + diagnostics + .iter() + .filter(|diag| { + (diag.contains("navigate reserved keys") + || diag.contains("navigate keybinding cannot use esc")) + && diag.contains("keys.navigate_workspace_up") + }) + .count(), + 8 + ); + } + + #[test] + fn navigate_bindings_can_reuse_navigate_mode_prefix_rhs_keys() { + let config: Config = toml::from_str( + r#" +[keys] +navigate_workspace_down = ["n", "f"] + +[[keys.command]] +key = "prefix+f" +command = "echo hi" +"#, + ) + .unwrap(); + let keybinds = config.keybinds(); + let diagnostics = config.collect_diagnostics(); + + assert!(keybinds + .navigate + .workspace_down + .matches_direct_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty()))); + assert!(keybinds + .navigate + .workspace_down + .matches_direct_key(TerminalKey::new(KeyCode::Char('f'), KeyModifiers::empty()))); + assert!(!keybinds.custom_commands.is_empty()); + assert!(!diagnostics.iter().any(|diag| { + diag.contains("disabled keys.navigate_workspace_down") + && (diag.contains("keys.next_tab") || diag.contains("keys.command")) + })); + } + + #[test] + fn navigate_bindings_do_not_conflict_with_general_focus_pane_bindings() { + let config: Config = toml::from_str( + r#" +[keys] +navigate_pane_down = "j" +"#, + ) + .unwrap(); + let keybinds = config.keybinds(); + + assert!(keybinds + .navigate + .pane_down + .matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()))); + } + + #[test] + fn navigate_bindings_reject_prefix_syntax_and_prefix_key() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +navigate_workspace_up = "prefix+j" +navigate_workspace_down = "ctrl+a" +"#, + ) + .unwrap(); + let keybinds = config.keybinds(); + let diagnostics = config.collect_diagnostics(); + + assert!(keybinds.navigate.workspace_up.bindings.is_empty()); + assert!(keybinds.navigate.workspace_down.bindings.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("navigate keybinding must not include prefix") + && diag.contains("keys.navigate_workspace_up") + })); + assert!(diagnostics.iter().any(|diag| { + diag.contains("kept keys.prefix") && diag.contains("keys.navigate_workspace_down") + })); + } + + #[test] fn custom_command_prefix_rhs_equal_to_configured_prefix_is_rejected() { let config: Config = toml::from_str( r#" diff --git a/src/config/model.rs b/src/config/model.rs index b6b7a2fb..7db8c2f4 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -153,6 +153,18 @@ pub struct KeysConfig { pub workspace_picker: BindingConfig, /// Open the session navigator. Default: "prefix+g" pub goto: BindingConfig, + /// Move workspace selection up in navigate mode. Default: "up". + pub navigate_workspace_up: BindingConfig, + /// Move workspace selection down in navigate mode. Default: "down". + pub navigate_workspace_down: BindingConfig, + /// Focus the pane to the left in navigate mode. Default: "h". Left arrow is always an alias. + pub navigate_pane_left: BindingConfig, + /// Focus the pane below in navigate mode. Default: "j". + pub navigate_pane_down: BindingConfig, + /// Focus the pane above in navigate mode. Default: "k". + pub navigate_pane_up: BindingConfig, + /// Focus the pane to the right in navigate mode. Default: "l". Right arrow is always an alias. + pub navigate_pane_right: BindingConfig, /// Detach from server/client mode, or exit --no-session mode. Default: "prefix+q". pub detach: BindingConfig, /// Reload config.toml in the running app/server. Default: "prefix+shift+r". @@ -346,6 +358,12 @@ impl Default for KeysConfig { close_workspace: BindingConfig::one("prefix+shift+d"), workspace_picker: BindingConfig::one("prefix+w"), goto: BindingConfig::one("prefix+g"), + navigate_workspace_up: BindingConfig::one("up"), + navigate_workspace_down: BindingConfig::one("down"), + navigate_pane_left: BindingConfig::one("h"), + navigate_pane_down: BindingConfig::one("j"), + navigate_pane_up: BindingConfig::one("k"), + navigate_pane_right: BindingConfig::one("l"), detach: BindingConfig::one("prefix+q"), reload_config: BindingConfig::one("prefix+shift+r"), open_notification_target: BindingConfig::one("prefix+o"), diff --git a/src/main.rs b/src/main.rs index a49b76df..4c7063ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -140,6 +140,15 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # resize_mode = "prefix+r" # toggle_sidebar = "prefix+b" +# Navigate-mode movement. These local shortcuts win while navigate mode is open. +# They are independent from focus_pane_*. Do not include prefix+, esc, enter, tab, or 1..9 here. +# navigate_workspace_up = "up" +# navigate_workspace_down = "down" +# navigate_pane_left = "h" # left arrow always focuses the pane to the left +# navigate_pane_down = "j" +# navigate_pane_up = "k" +# navigate_pane_right = "l" # right arrow always focuses the pane to the right + # Custom commands use the same binding syntax. # type = "shell" runs detached in the background. # type = "pane" opens a temporary pane and closes it when the command exits. diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index c1f0fa74..21f4baed 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -69,8 +69,24 @@ pub(super) fn keybind_help_groups( "navigation", vec