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 /src/app | |
| parent | c5b3997f3b7d334bd2f35c7aee5216236b58193e (diff) | |
feat: make navigate movement keybinds configurable
refs #193
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/input/navigate.rs | 291 |
1 files changed, 236 insertions, 55 deletions
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); |
