diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 00bbf3cf..450afab8 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -49,7 +49,9 @@ pub(crate) use self::{ handle_global_menu_key, handle_keybind_help_key, handle_navigator_key, insert_navigator_search_text, insert_rename_input_text, }, - navigate::terminal_direct_navigation_action, + navigate::{ + terminal_direct_indexed_navigation_action, terminal_direct_non_indexed_navigation_action, + }, settings::open_settings_at, }; use self::{ diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 426864e6..a1597669 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -21,6 +21,7 @@ use crate::{ terminal::TerminalRuntimeRegistry, }; +#[cfg(test)] pub(crate) fn terminal_direct_navigation_action( state: &AppState, key: TerminalKey, @@ -28,6 +29,20 @@ pub(crate) fn terminal_direct_navigation_action( action_for_key(state, key, BindingDispatch::Direct) } +pub(crate) fn terminal_direct_non_indexed_navigation_action( + state: &AppState, + key: TerminalKey, +) -> Option { + non_indexed_action_for_key(state, key, BindingDispatch::Direct) +} + +pub(crate) fn terminal_direct_indexed_navigation_action( + state: &AppState, + key: TerminalKey, +) -> Option { + indexed_navigation_action(state, key, BindingDispatch::Direct) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ActionContext { Direct, @@ -61,25 +76,10 @@ impl App { return; } - if let Some(action) = action_for_key(&self.state, raw_key, BindingDispatch::Prefix) { - if action == NavigateAction::EditScrollback { - let previous_mode = self.state.mode; - self.cancel_copy_mode_if_active(); - self.launch_focused_scrollback_editor(); - finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); - } else if action == NavigateAction::CopyMode { - self.cancel_copy_mode_if_active(); - self.execute_tui_navigate_action(action, ActionContext::Prefix); - } else if copy_mode_survives_prefix_action(action) { - self.execute_tui_navigate_action(action, ActionContext::Prefix); - if self.state.copy_mode.is_some() { - self.state.sync_copy_mode_with_focus(); - } - } else { - self.cancel_copy_mode_if_active(); - self.execute_tui_navigate_action(action, ActionContext::Prefix); - } - self.selection_autoscroll_deadline = None; + if let Some(action) = + non_indexed_action_for_key(&self.state, raw_key, BindingDispatch::Prefix) + { + self.execute_prefix_key_action(action); return; } @@ -89,9 +89,37 @@ impl App { return; } + if let Some(action) = + indexed_navigation_action(&self.state, raw_key, BindingDispatch::Prefix) + { + self.execute_prefix_key_action(action); + return; + } + leave_command_mode(&mut self.state); } + fn execute_prefix_key_action(&mut self, action: NavigateAction) { + if action == NavigateAction::EditScrollback { + let previous_mode = self.state.mode; + self.cancel_copy_mode_if_active(); + self.launch_focused_scrollback_editor(); + finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); + } else if action == NavigateAction::CopyMode { + self.cancel_copy_mode_if_active(); + self.execute_tui_navigate_action(action, ActionContext::Prefix); + } else if copy_mode_survives_prefix_action(action) { + self.execute_tui_navigate_action(action, ActionContext::Prefix); + if self.state.copy_mode.is_some() { + self.state.sync_copy_mode_with_focus(); + } + } else { + self.cancel_copy_mode_if_active(); + self.execute_tui_navigate_action(action, ActionContext::Prefix); + } + self.selection_autoscroll_deadline = None; + } + pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) { let key = raw_key.as_key_event(); self.state.update_dismissed = true; @@ -127,7 +155,7 @@ impl App { return; } - if let Some(action) = navigate_mode_action_for_key(&self.state, raw_key) { + if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, raw_key) { if action == NavigateAction::EditScrollback { self.launch_focused_scrollback_editor(); } else { @@ -139,6 +167,12 @@ impl App { if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) { self.launch_custom_command(binding, ActionContext::Navigate); + return; + } + + if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, raw_key) { + self.execute_tui_navigate_action(action, ActionContext::Navigate); + self.selection_autoscroll_deadline = None; } } @@ -1360,15 +1394,21 @@ fn action_matches( } } +#[cfg(test)] fn action_for_key( state: &AppState, key: TerminalKey, dispatch: BindingDispatch, ) -> Option { - if let Some(action) = indexed_navigation_action(state, key, dispatch) { - return Some(action); - } + non_indexed_action_for_key(state, key, dispatch) + .or_else(|| indexed_navigation_action(state, key, dispatch)) +} +fn non_indexed_action_for_key( + state: &AppState, + key: TerminalKey, + dispatch: BindingDispatch, +) -> Option { let kb = &state.keybinds; for (bindings, action) in [ (&kb.help, NavigateAction::Help), @@ -1424,6 +1464,7 @@ fn action_for_key( None } +#[cfg(test)] fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option { let action = action_for_key(state, key, BindingDispatch::Prefix)?; if matches!( @@ -1438,6 +1479,30 @@ fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option Option { + let action = non_indexed_action_for_key(state, key, BindingDispatch::Prefix)?; + if matches!( + action, + NavigateAction::FocusPaneLeft + | NavigateAction::FocusPaneDown + | NavigateAction::FocusPaneUp + | NavigateAction::FocusPaneRight + ) { + return None; + } + Some(action) +} + +fn navigate_mode_indexed_action_for_key( + state: &AppState, + key: TerminalKey, +) -> Option { + indexed_navigation_action(state, key, BindingDispatch::Prefix) +} + #[cfg(test)] pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { let mut terminal_runtimes = TerminalRuntimeRegistry::new(); @@ -2383,6 +2448,113 @@ last_pane = "prefix+tab" assert_eq!(action, Some(NavigateAction::SwitchTab(2))); } + #[test] + fn prefix_shift_indexed_workspace_shortcut_maps_shifted_symbol_key() { + let mut state = state_with_workspaces(&["one", "two"]); + let config: Config = + toml::from_str("[keys]\nswitch_workspace = \"prefix+shift+1..9\"\n").unwrap(); + state.keybinds.switch_workspace = config.keybinds().switch_workspace; + + let action = action_for_key( + &state, + TerminalKey::new(KeyCode::Char('@'), KeyModifiers::empty()), + BindingDispatch::Prefix, + ); + + assert_eq!(action, Some(NavigateAction::SwitchWorkspace(1))); + } + + #[test] + fn literal_symbol_binding_takes_precedence_over_shifted_indexed_alias() { + let mut state = state_with_workspaces(&["one", "two"]); + let config: Config = toml::from_str( + r#" +[keys] +help = "prefix+!" +switch_workspace = "prefix+shift+1..9" +"#, + ) + .unwrap(); + state.keybinds = config.keybinds(); + + let action = action_for_key( + &state, + TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()), + BindingDispatch::Prefix, + ); + + assert_eq!(action, Some(NavigateAction::Help)); + } + + #[test] + fn literal_symbol_custom_command_is_visible_before_shifted_indexed_alias() { + let mut state = state_with_workspaces(&["one", "two"]); + let config: Config = toml::from_str( + r#" +[keys] +switch_workspace = "prefix+shift+1..9" + +[[keys.command]] +key = "prefix+!" +command = "echo literal" +"#, + ) + .unwrap(); + state.keybinds = config.keybinds(); + + let key = TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()); + assert!(command_for_key(&state, key, BindingDispatch::Prefix).is_some()); + assert_eq!( + indexed_navigation_action(&state, key, BindingDispatch::Prefix), + Some(NavigateAction::SwitchWorkspace(0)) + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn literal_symbol_custom_command_runs_before_shifted_indexed_alias() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; + app.state.active = Some(1); + app.state.selected = 1; + app.state.mode = Mode::Terminal; + + let output_path = unique_temp_path("literal-symbol-custom-command"); + let config: Config = toml::from_str(&format!( + r#" +[keys] +switch_workspace = "prefix+shift+1..9" + +[[keys.command]] +key = "prefix+!" +command = "printf literal > '{}'" +"#, + output_path.display() + )) + .unwrap(); + app.state.keybinds = config.keybinds(); + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty())) + .await; + + assert_eq!(wait_for_file(&output_path), "literal"); + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.mode, Mode::Terminal); + let _ = std::fs::remove_file(output_path); + } + #[tokio::test] async fn navigate_mode_runs_prefix_action_rhs_without_pressing_prefix_again() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index a8e364ba..0fc35bd3 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -34,7 +34,8 @@ impl App { let key_event = key.as_key_event(); - if let Some(action) = super::terminal_direct_navigation_action(&self.state, key) { + if let Some(action) = super::terminal_direct_non_indexed_navigation_action(&self.state, key) + { debug!( code = ?key_event.code, modifiers = ?key_event.modifiers, @@ -66,6 +67,18 @@ impl App { return None; } + if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, key) { + debug!( + code = ?key_event.code, + modifiers = ?key_event.modifiers, + kind = ?key_event.kind, + action = ?action, + "intercepted terminal direct indexed keybinding before forwarding to pane" + ); + self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct); + return None; + } + if self.state.is_prefix_key(key) { self.state.mode = Mode::Prefix; return None; diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 2a1cfc49..838481ed 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -255,11 +255,21 @@ pub struct IndexedKeybind { impl IndexedKeybind { pub fn matched_index(&self, key: TerminalKey) -> Option { - let KeyCode::Char(c @ '1'..='9') = key.code else { - return None; + let key_number = match key.code { + KeyCode::Char(c @ '1'..='9') => c, + KeyCode::Char(c) => { + let number = shifted_number_symbol(c)?; + if !indexed_shifted_number_matches(key, self.trigger.combo(), number) { + return None; + } + number + } + _ => return None, }; - if terminal_key_matches_combo(key, self.trigger.combo()) { - Some((c as usize) - ('1' as usize)) + let legacy_shifted_number = + matches!(key.code, KeyCode::Char(c) if shifted_number_symbol(c) == Some(key_number)); + if terminal_key_matches_combo(key, self.trigger.combo()) || legacy_shifted_number { + Some((key_number as usize) - ('1' as usize)) } else { None } @@ -1355,6 +1365,31 @@ fn legacy_shifted_ascii_letter_matches( && actual_modifiers | KeyModifiers::SHIFT == expected_modifiers } +const SHIFTED_NUMBER_SYMBOLS: [(char, char); 9] = [ + ('1', '!'), + ('2', '@'), + ('3', '#'), + ('4', '$'), + ('5', '%'), + ('6', '^'), + ('7', '&'), + ('8', '*'), + ('9', '('), +]; + +fn shifted_number_symbol(ch: char) -> Option { + SHIFTED_NUMBER_SYMBOLS + .iter() + .find_map(|(number, symbol)| (*symbol == ch).then_some(*number)) +} + +fn indexed_shifted_number_matches(key: TerminalKey, combo: KeyCombo, number: char) -> bool { + let (expected_code, expected_modifiers) = normalize_key_combo(combo); + matches!(expected_code, KeyCode::Char(expected) if expected == number) + && expected_modifiers.contains(KeyModifiers::SHIFT) + && key.modifiers == expected_modifiers.difference(KeyModifiers::SHIFT) +} + fn shifted_char_matches_expected( actual_code: KeyCode, shifted_codepoint: Option,