diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 5c9911b9d6..71a845f0cc 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -283,6 +283,18 @@ "default": "\"down\"", "description": "Move workspace selection down in navigate mode." }, + { + "key": "keys.navigate_navigator_up", + "type": "keybinding", + "default": "\"ctrl+p\"", + "description": "Move the session navigator selection up." + }, + { + "key": "keys.navigate_navigator_down", + "type": "keybinding", + "default": "\"ctrl+n\"", + "description": "Move the session navigator selection down." + }, { "key": "keys.navigate_pane_left", "type": "keybinding", diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 0a5c13e569..2606f8d6c2 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -164,6 +164,26 @@ pub(crate) fn handle_navigator_key( terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, key: KeyEvent, ) { + let terminal_key = TerminalKey::from(key); + if state + .keybinds + .navigator + .up + .matches_direct_key(&terminal_key) + { + state.move_navigator_selection_from(terminal_runtimes, -1); + return; + } + if state + .keybinds + .navigator + .down + .matches_direct_key(&terminal_key) + { + state.move_navigator_selection_from(terminal_runtimes, 1); + return; + } + if state.navigator.search_focused { match key.code { KeyCode::Esc => { @@ -179,12 +199,6 @@ pub(crate) fn handle_navigator_key( } KeyCode::Up => state.move_navigator_selection_from(terminal_runtimes, -1), KeyCode::Down => state.move_navigator_selection_from(terminal_runtimes, 1), - KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => { - state.move_navigator_selection_from(terminal_runtimes, 1) - } - KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => { - state.move_navigator_selection_from(terminal_runtimes, -1) - } KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => { state.navigator.query.clear(); state.navigator.state_filter = None; @@ -1880,6 +1894,86 @@ mod tests { assert!(state.navigator.query.is_empty()); } + #[test] + fn navigator_configured_movement_keys_work_in_normal_and_search_views() { + let mut state = state_with_workspaces(&["alpha", "beta"]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + let config: crate::config::Config = toml::from_str( + r#" +[keys] +navigate_navigator_up = "ctrl+alt+p" +navigate_navigator_down = "ctrl+alt+n" +"#, + ) + .unwrap(); + state.keybinds = config.keybinds(); + state.mode = Mode::Navigator; + + handle_navigator_key( + &mut state, + &terminal_runtimes, + KeyEvent::new( + KeyCode::Char('n'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ), + ); + assert_eq!(state.navigator.selected, 1); + + handle_navigator_key( + &mut state, + &terminal_runtimes, + KeyEvent::new( + KeyCode::Char('p'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ), + ); + assert_eq!(state.navigator.selected, 0); + + state.navigator.search_focused = true; + handle_navigator_key( + &mut state, + &terminal_runtimes, + KeyEvent::new( + KeyCode::Char('n'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ), + ); + assert_eq!(state.navigator.selected, 1); + + handle_navigator_key( + &mut state, + &terminal_runtimes, + KeyEvent::new( + KeyCode::Char('p'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ), + ); + assert_eq!(state.navigator.selected, 0); + } + + #[test] + fn navigator_builtin_vertical_movement_keys_remain_available() { + let mut state = state_with_workspaces(&["alpha", "beta", "gamma"]); + let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + state.mode = Mode::Navigator; + + for key in [ + KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), + ] { + handle_navigator_key(&mut state, &terminal_runtimes, key); + } + assert_eq!(state.navigator.selected, 2); + + for key in [ + KeyEvent::new(KeyCode::Up, KeyModifiers::empty()), + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::empty()), + ] { + handle_navigator_key(&mut state, &terminal_runtimes, key); + } + assert_eq!(state.navigator.selected, 0); + } + #[test] fn navigator_empty_search_escape_returns_to_commands() { let mut state = state_with_workspaces(&["alpha", "beta"]); diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 199076f03c..d12334139f 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -302,10 +302,17 @@ pub struct NavigateKeybinds { pub pane_right: ActionKeybinds, } +#[derive(Debug, Clone)] +pub struct NavigatorKeybinds { + pub up: ActionKeybinds, + pub down: ActionKeybinds, +} + /// Parsed keybinds for Herdr actions. #[derive(Debug, Clone)] pub struct Keybinds { pub navigate: NavigateKeybinds, + pub navigator: NavigatorKeybinds, pub help: ActionKeybinds, pub settings: ActionKeybinds, pub new_workspace: ActionKeybinds, @@ -452,6 +459,9 @@ impl Config { let mut navigate_registry = BindingRegistry::new(prefix, prefix_source); navigate_registry.reserve_direct(prefix, "keys.prefix", prefix_source); reserve_navigate_runtime_keys(&mut navigate_registry); + let mut navigator_registry = BindingRegistry::new(prefix, prefix_source); + navigator_registry.reserve_direct(prefix, "keys.prefix", prefix_source); + reserve_navigator_runtime_keys(&mut navigator_registry); macro_rules! empty_action { () => { @@ -468,6 +478,10 @@ impl Config { pane_up: empty_action!(), pane_right: empty_action!(), }, + navigator: NavigatorKeybinds { + up: empty_action!(), + down: empty_action!(), + }, help: empty_action!(), settings: empty_action!(), new_workspace: empty_action!(), @@ -590,6 +604,24 @@ impl Config { apply_navigate!(keybinds.navigate.pane_down, navigate_pane_down, source); apply_navigate!(keybinds.navigate.pane_up, navigate_pane_up, source); apply_navigate!(keybinds.navigate.pane_right, navigate_pane_right, source); + if field_source!(navigate_navigator_up) == source { + keybinds.navigator.up = parse_navigator_bindings( + "keys.navigate_navigator_up", + &self.keys.navigate_navigator_up, + &mut navigator_registry, + &mut diagnostics, + source, + ); + } + if field_source!(navigate_navigator_down) == source { + keybinds.navigator.down = parse_navigator_bindings( + "keys.navigate_navigator_down", + &self.keys.navigate_navigator_down, + &mut navigator_registry, + &mut diagnostics, + source, + ); + } apply_action!(keybinds.help, help, source); apply_action!(keybinds.settings, settings, source); apply_action!(keybinds.new_workspace, new_workspace, source); @@ -718,6 +750,30 @@ fn reserve_navigate_runtime_keys(registry: &mut BindingRegistry) { } } +fn reserve_navigator_runtime_keys(registry: &mut BindingRegistry) { + reserve_navigate_runtime_keys(registry); + for combo in [ + (KeyCode::Up, KeyModifiers::empty()), + (KeyCode::Down, KeyModifiers::empty()), + (KeyCode::Char('j'), KeyModifiers::empty()), + (KeyCode::Char('k'), KeyModifiers::empty()), + (KeyCode::Char('a'), KeyModifiers::empty()), + (KeyCode::Char('b'), KeyModifiers::empty()), + (KeyCode::Char('w'), KeyModifiers::empty()), + (KeyCode::Char('i'), KeyModifiers::empty()), + (KeyCode::Char('d'), KeyModifiers::empty()), + (KeyCode::Char('/'), KeyModifiers::empty()), + (KeyCode::Char('d'), KeyModifiers::CONTROL), + (KeyCode::Char('u'), KeyModifiers::CONTROL), + (KeyCode::Home, KeyModifiers::empty()), + (KeyCode::End, KeyModifiers::empty()), + (KeyCode::Char('G'), KeyModifiers::SHIFT), + (KeyCode::Char(' '), KeyModifiers::empty()), + ] { + registry.reserve_direct(combo, "navigator reserved keys", BindingSource::Default); + } +} + fn append_custom_command_bindings( config: &Config, keybinds: &mut Keybinds, @@ -849,6 +905,42 @@ fn parse_navigate_bindings( ActionKeybinds { bindings } } +fn parse_navigator_bindings( + field: &'static str, + config: &BindingConfig, + registry: &mut BindingRegistry, + diagnostics: &mut Vec, + source: BindingSource, +) -> 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_navigator_binding(field, &binding, registry, diagnostics, source) { + continue; + } + registry.register(&binding, field, source); + 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, @@ -996,6 +1088,33 @@ fn reject_navigate_binding( false } +fn reject_navigator_binding( + field: &str, + binding: &ResolvedBinding, + registry: &BindingRegistry, + diagnostics: &mut Vec, + source: BindingSource, +) -> bool { + if reject_navigate_binding(field, binding, registry, diagnostics, source) { + return true; + } + + let (code, modifiers) = normalize_key_combo(binding.trigger.combo()); + if matches!(code, KeyCode::Char(_)) + && (modifiers.is_empty() || modifiers == KeyModifiers::SHIFT) + { + let diag = format!( + "navigator keybinding must not consume printable search input: {field} = {:?}; disabling binding", + binding.label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } + + false +} + fn reject_binding( field: &str, binding: &ResolvedBinding, @@ -1817,6 +1936,49 @@ navigate_pane_down = "ctrl+j" })); } + #[test] + fn navigator_bindings_are_independent_from_workspace_picker_bindings() { + let config: Config = toml::from_str( + r#" +[keys] +navigate_workspace_up = "ctrl+p" +navigate_workspace_down = "ctrl+n" +navigate_navigator_up = "ctrl+p" +navigate_navigator_down = "ctrl+n" +"#, + ) + .unwrap(); + let keybinds = config.keybinds(); + + for bindings in [&keybinds.navigate.workspace_up, &keybinds.navigator.up] { + assert!(bindings + .matches_direct_key(&TerminalKey::new(KeyCode::Char('p'), KeyModifiers::CONTROL,))); + } + for bindings in [&keybinds.navigate.workspace_down, &keybinds.navigator.down] { + assert!(bindings + .matches_direct_key(&TerminalKey::new(KeyCode::Char('n'), KeyModifiers::CONTROL,))); + } + } + + #[test] + fn navigator_bindings_reject_printable_search_input() { + let config: Config = toml::from_str( + r#" +[keys] +navigate_navigator_up = "x" +"#, + ) + .unwrap(); + let keybinds = config.keybinds(); + let diagnostics = config.collect_diagnostics(); + + assert!(keybinds.navigator.up.bindings.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("must not consume printable search input") + && diag.contains("keys.navigate_navigator_up") + })); + } + #[test] fn navigate_bindings_reject_runtime_reserved_keys() { let config: Config = toml::from_str( diff --git a/src/config/model.rs b/src/config/model.rs index e56ac08722..f2cdcb95b2 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -335,6 +335,10 @@ pub struct KeysConfig { pub navigate_workspace_up: BindingConfig, /// Move workspace selection down in navigate mode. Default: "down". pub navigate_workspace_down: BindingConfig, + /// Move the session navigator selection up. Default: "ctrl+p". + pub navigate_navigator_up: BindingConfig, + /// Move the session navigator selection down. Default: "ctrl+n". + pub navigate_navigator_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". @@ -455,6 +459,10 @@ pub(crate) struct KeysConfigOverlay { #[serde(skip_serializing_if = "Option::is_none")] navigate_workspace_down: Option, #[serde(skip_serializing_if = "Option::is_none")] + navigate_navigator_up: Option, + #[serde(skip_serializing_if = "Option::is_none")] + navigate_navigator_down: Option, + #[serde(skip_serializing_if = "Option::is_none")] navigate_pane_left: Option, #[serde(skip_serializing_if = "Option::is_none")] navigate_pane_down: Option, @@ -570,6 +578,8 @@ impl<'de> Deserialize<'de> for KeysConfig { apply_field!(goto); apply_field!(navigate_workspace_up); apply_field!(navigate_workspace_down); + apply_field!(navigate_navigator_up); + apply_field!(navigate_navigator_down); apply_field!(navigate_pane_left); apply_field!(navigate_pane_down); apply_field!(navigate_pane_up); @@ -668,6 +678,8 @@ impl KeysConfig { copy_effective_action_field!(goto, keybinds.goto); copy_effective_action_field!(navigate_workspace_up, keybinds.navigate.workspace_up); copy_effective_action_field!(navigate_workspace_down, keybinds.navigate.workspace_down); + copy_effective_action_field!(navigate_navigator_up, keybinds.navigator.up); + copy_effective_action_field!(navigate_navigator_down, keybinds.navigator.down); copy_effective_action_field!(navigate_pane_left, keybinds.navigate.pane_left); copy_effective_action_field!(navigate_pane_down, keybinds.navigate.pane_down); copy_effective_action_field!(navigate_pane_up, keybinds.navigate.pane_up); @@ -949,6 +961,8 @@ impl Default for KeysConfig { goto: BindingConfig::one("prefix+g"), navigate_workspace_up: BindingConfig::one("up"), navigate_workspace_down: BindingConfig::one("down"), + navigate_navigator_up: BindingConfig::one("ctrl+p"), + navigate_navigator_down: BindingConfig::one("ctrl+n"), navigate_pane_left: BindingConfig::one("h"), navigate_pane_down: BindingConfig::one("j"), navigate_pane_up: BindingConfig::one("k"), diff --git a/src/main.rs b/src/main.rs index 27c1d92f0f..a4c8e61d74 100644 --- a/src/main.rs +++ b/src/main.rs @@ -213,10 +213,12 @@ 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. +# Navigation movement. Navigate-mode shortcuts win while the workspace picker is open. +# Navigator shortcuts move the Session Navigator selection. Do not include prefix+, esc, enter, tab, or 1..9 here. # navigate_workspace_up = "up" # navigate_workspace_down = "down" +# navigate_navigator_up = "ctrl+p" +# navigate_navigator_down = "ctrl+n" # navigate_pane_left = "h" # left arrow always focuses the pane to the left # navigate_pane_down = "j" # navigate_pane_up = "k" diff --git a/src/ui.rs b/src/ui.rs index 60143fe448..75ba7f5096 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1428,7 +1428,16 @@ mod tests { #[test] fn keybind_help_shows_unset_for_optional_actions() { - let app = crate::app::state::AppState::test_new(); + let mut app = crate::app::state::AppState::test_new(); + let config: crate::config::Config = toml::from_str( + r#" +[keys] +navigate_navigator_up = "ctrl+alt+p" +navigate_navigator_down = "ctrl+alt+n" +"#, + ) + .unwrap(); + app.keybinds = config.keybinds(); let groups = keybind_help_groups(&app); let workspace_tab = groups @@ -1437,6 +1446,12 @@ mod tests { .expect("workspace tab group") .1 .clone(); + let navigation = groups + .iter() + .find(|(name, _)| *name == "navigation") + .expect("navigation group") + .1 + .clone(); let panes = groups .iter() .find(|(name, _)| *name == "panes") @@ -1462,6 +1477,9 @@ mod tests { assert!(workspace_tab .iter() .any(|(key, label)| key == "unset" && label.as_ref() == "switch workspace 1-9")); + assert!(navigation.iter().any(|(key, label)| { + key == "ctrl+alt+p / ctrl+alt+n" && label.as_ref() == "session navigator selection" + })); assert!(panes .iter() .any(|(key, label)| key == "prefix+h" && label.as_ref() == "focus pane left")); diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index 25c999fa3d..605602ad5f 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -93,6 +93,14 @@ pub(super) fn keybind_help_groups(app: &AppState) -> Vec { ), "workspace list", ), + help_entry( + format!( + "{} / {}", + keybind_label(&kb.navigator.up), + keybind_label(&kb.navigator.down) + ), + "session navigator selection", + ), help_entry( format!( "{} / {} / {} / {} / left / right",