aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 03:37:41 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 03:41:16 +0300
commit26205a2ac5e21053bd94a0925efcfca44c6f549b (patch)
tree530fdd445bedadb521759fa89792b98aaf0e3f39 /src/app/input
parentc5b3997f3b7d334bd2f35c7aee5216236b58193e (diff)
feat: make navigate movement keybinds configurable
refs #193
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/navigate.rs291
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);