aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/app/input/mod.rs4
-rw-r--r--src/app/input/navigate.rs218
-rw-r--r--src/app/input/terminal.rs15
-rw-r--r--src/config/keybinds.rs43
4 files changed, 251 insertions, 29 deletions
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<NavigateAction> {
+ non_indexed_action_for_key(state, key, BindingDispatch::Direct)
+}
+
+pub(crate) fn terminal_direct_indexed_navigation_action(
+ state: &AppState,
+ key: TerminalKey,
+) -> Option<NavigateAction> {
+ 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<NavigateAction> {
- 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<NavigateAction> {
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<NavigateAction> {
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<Na
Some(action)
}
+fn navigate_mode_non_indexed_action_for_key(
+ state: &AppState,
+ key: TerminalKey,
+) -> Option<NavigateAction> {
+ 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<NavigateAction> {
+ 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<usize> {
- 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<char> {
+ 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<u32>,