fix: match shifted indexed number keybinds

refs #1184
This commit is contained in:
Ogulcan Celik 2026-07-08 22:31:09 +03:00
parent 552aa8ca22
commit b708f85e7d
4 changed files with 251 additions and 29 deletions

View File

@ -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::{

View File

@ -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();

View File

@ -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;

View File

@ -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>,