aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs23
-rw-r--r--src/app/input/modal.rs50
-rw-r--r--src/app/input/navigate.rs725
-rw-r--r--src/app/input/terminal.rs174
4 files changed, 674 insertions, 298 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 4cafc926..245f935b 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -56,23 +56,24 @@ impl App {
pub(super) async fn handle_key(&mut self, key: TerminalKey) {
match self.state.mode {
Mode::Terminal => self.handle_terminal_key(key).await,
+ Mode::Prefix => self.handle_prefix_key(key),
Mode::Navigate => self.handle_navigate_key(key),
_ => {
- let key = key.as_key_event();
+ let key_event = key.as_key_event();
match self.state.mode {
- Mode::Onboarding => self.handle_onboarding_key(key),
- Mode::ReleaseNotes => self.handle_release_notes_key(key),
- Mode::ProductAnnouncement => self.handle_product_announcement_key(key),
- Mode::Navigate => unreachable!(),
+ Mode::Onboarding => self.handle_onboarding_key(key_event),
+ Mode::ReleaseNotes => self.handle_release_notes_key(key_event),
+ Mode::ProductAnnouncement => self.handle_product_announcement_key(key_event),
+ Mode::Prefix | Mode::Navigate => unreachable!(),
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
- handle_rename_key(&mut self.state, key)
+ handle_rename_key(&mut self.state, key_event)
}
Mode::Resize => handle_resize_key(&mut self.state, key),
- Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key),
- Mode::ContextMenu => handle_context_menu_key(&mut self.state, key),
- Mode::Settings => self.handle_settings_key(key),
- Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key),
- Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key),
+ Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event),
+ Mode::ContextMenu => handle_context_menu_key(&mut self.state, key_event),
+ Mode::Settings => self.handle_settings_key(key_event),
+ Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event),
+ Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event),
Mode::Terminal => unreachable!(),
}
}
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index ff5e8582..5b585221 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -2,7 +2,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Direction, Rect};
use crate::{
- app::state::{key_matches, AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ app::state::{AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ input::TerminalKey,
layout::NavDirection,
};
@@ -448,14 +449,12 @@ pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
}
}
-pub(crate) fn handle_resize_key(state: &mut AppState, key: KeyEvent) {
+pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) {
+ let key = raw_key.as_key_event();
if key.code == KeyCode::Esc
|| key.code == KeyCode::Enter
- || key_matches(
- &key,
- state.keybinds.resize_mode.0,
- state.keybinds.resize_mode.1,
- )
+ || state.keybinds.resize_mode.matches_prefix_key(raw_key)
+ || state.keybinds.resize_mode.matches_direct_key(raw_key)
{
if state.active.is_some() {
state.mode = Mode::Terminal;
@@ -648,12 +647,43 @@ mod tests {
fn custom_resize_key_exits_resize_mode() {
let mut state = state_with_workspaces(&["test"]);
state.mode = Mode::Resize;
- state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.resize_mode_label = "g".into();
+ state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g");
handle_resize_key(
&mut state,
- KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn direct_resize_key_exits_resize_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::Resize;
+ state.keybinds.resize_mode = crate::config::ActionKeybinds::direct("ctrl+alt+r");
+
+ handle_resize_key(
+ &mut state,
+ TerminalKey::new(
+ KeyCode::Char('r'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn resize_key_exit_matches_enhanced_shifted_punctuation() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::Resize;
+ state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("?");
+
+ handle_resize_key(
+ &mut state,
+ TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('?' as u32),
);
assert_eq!(state.mode, Mode::Terminal);
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index a2967ca3..8bf10832 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -11,7 +11,7 @@ use ratatui::layout::Direction;
use crate::{
app::{
- state::{key_matches, AppState, Mode},
+ state::{AppState, Mode},
App,
},
input::TerminalKey,
@@ -20,109 +20,84 @@ use crate::{
pub(crate) fn terminal_direct_navigation_action(
state: &AppState,
- key: &KeyEvent,
+ key: TerminalKey,
) -> Option<NavigateAction> {
- if let Some(action) = indexed_navigation_action(state, key) {
- return Some(action);
- }
+ action_for_key(state, key, BindingDispatch::Direct)
+}
- let kb = &state.keybinds;
- if kb
- .previous_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousWorkspace);
- }
- if kb
- .next_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextWorkspace);
- }
- if kb
- .previous_agent
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousAgent);
- }
- if kb
- .next_agent
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextAgent);
- }
- if kb
- .previous_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousTab);
- }
- if kb
- .next_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextTab);
- }
- if kb
- .focus_pane_left
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::FocusPaneLeft);
- }
- if kb
- .focus_pane_down
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::FocusPaneDown);
- }
- if kb
- .focus_pane_up
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::FocusPaneUp);
- }
- if kb
- .focus_pane_right
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::FocusPaneRight);
- }
- None
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ActionContext {
+ Direct,
+ Prefix,
+ Navigate,
}
impl App {
- pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) {
+ pub(crate) fn handle_prefix_key(&mut self, raw_key: TerminalKey) {
let key = raw_key.as_key_event();
self.state.update_dismissed = true;
- if self.state.is_prefix(&key) {
+ if self.state.is_prefix_key(raw_key) {
if !self.pass_through_key_to_focused_pane(raw_key) {
- leave_navigate_mode(&mut self.state);
+ leave_command_mode(&mut self.state);
}
return;
}
if key.code == KeyCode::Esc {
- leave_navigate_mode(&mut self.state);
+ leave_command_mode(&mut self.state);
return;
}
- if let Some(action) = navigate_action_for_key(&self.state, &key) {
+ if let Some(action) = action_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
if action == NavigateAction::EditScrollback {
+ let previous_mode = self.state.mode;
self.launch_focused_scrollback_editor();
+ finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode);
} else {
- execute_navigate_action(&mut self.state, action);
+ execute_navigate_action_in_context(&mut self.state, action, ActionContext::Prefix);
}
self.selection_autoscroll_deadline = None;
return;
}
+ if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
+ self.launch_custom_command(binding, ActionContext::Prefix);
+ return;
+ }
+
+ leave_command_mode(&mut self.state);
+ }
+
+ pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) {
+ let key = raw_key.as_key_event();
+ self.state.update_dismissed = true;
+
+ if key.code == KeyCode::Esc || self.state.is_prefix_key(raw_key) {
+ leave_navigate_mode(&mut self.state);
+ return;
+ }
+
if handle_navigate_reserved_key(&mut self.state, key) {
return;
}
- if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) {
- self.launch_custom_command(binding);
+ if let Some(action) = action_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
+ if action == NavigateAction::EditScrollback {
+ self.launch_focused_scrollback_editor();
+ } else {
+ execute_navigate_action_in_context(
+ &mut self.state,
+ action,
+ ActionContext::Navigate,
+ );
+ }
+ self.selection_autoscroll_deadline = None;
+ return;
+ }
+
+ if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
+ self.launch_custom_command(binding, ActionContext::Navigate);
}
}
@@ -143,7 +118,12 @@ impl App {
true
}
- fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) {
+ pub(super) fn launch_custom_command(
+ &mut self,
+ binding: crate::config::CustomCommandKeybind,
+ context: ActionContext,
+ ) {
+ let previous_mode = self.state.mode;
let previous_toast = self.state.toast.clone();
let result = match binding.action {
crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding),
@@ -152,7 +132,7 @@ impl App {
}
};
match result {
- Ok(()) => leave_navigate_mode(&mut self.state),
+ Ok(()) => finish_custom_command_context(&mut self.state, context, previous_mode),
Err(err) => {
self.state.toast = Some(crate::app::state::ToastNotification {
kind: crate::app::state::ToastKind::NeedsAttention,
@@ -161,6 +141,7 @@ impl App {
target: None,
});
self.sync_toast_deadline(previous_toast);
+ finish_custom_command_context(&mut self.state, context, previous_mode);
}
}
}
@@ -233,7 +214,7 @@ impl App {
Ok(())
}
- fn launch_focused_scrollback_editor(&mut self) {
+ pub(super) fn launch_focused_scrollback_editor(&mut self) {
let previous_toast = self.state.toast.clone();
match self.open_focused_scrollback_in_editor() {
Ok(()) => self.sync_toast_deadline(previous_toast),
@@ -359,25 +340,35 @@ impl App {
}
}
-fn navigate_custom_command_for_key(
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum BindingDispatch {
+ Direct,
+ Prefix,
+}
+
+pub(crate) fn command_for_key(
state: &AppState,
- key: &KeyEvent,
+ key: TerminalKey,
+ dispatch: BindingDispatch,
) -> Option<crate::config::CustomCommandKeybind> {
state
.keybinds
.custom_commands
.iter()
- .find(|binding| key_matches(key, binding.key.0, binding.key.1))
+ .find(|binding| match dispatch {
+ BindingDispatch::Direct => binding.bindings.matches_direct_key(key),
+ BindingDispatch::Prefix => binding.bindings.matches_prefix_key(key),
+ })
.cloned()
}
pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool {
- match key.code {
- KeyCode::Char('q') => {
- super::modal::request_quit_or_detach(state);
- leave_navigate_mode(state);
- true
- }
+ 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);
@@ -393,14 +384,6 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent)
}
true
}
- KeyCode::Char('s') => {
- super::settings::open_settings(state);
- true
- }
- KeyCode::Char('?') => {
- super::modal::open_keybind_help(state);
- true
- }
KeyCode::Up => {
if state.selected > 0 {
state.selected -= 1;
@@ -446,18 +429,20 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent)
#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key
pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
state.update_dismissed = true;
+ let terminal_key = TerminalKey::from(key);
- if state.is_prefix(&key) || key.code == KeyCode::Esc {
+ if state.is_prefix_key(terminal_key) || key.code == KeyCode::Esc {
leave_navigate_mode(state);
return;
}
- if let Some(action) = navigate_action_for_key(state, &key) {
- execute_navigate_action(state, action);
+ if handle_navigate_reserved_key(state, key) {
return;
}
- let _ = handle_navigate_reserved_key(state, key);
+ if let Some(action) = action_for_key(state, terminal_key, BindingDispatch::Prefix) {
+ execute_navigate_action_in_context(state, action, ActionContext::Navigate);
+ }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -468,6 +453,7 @@ pub(crate) enum NavigateAction {
SwitchWorkspace(usize),
SwitchTab(usize),
FocusAgent(usize),
+ WorkspacePicker,
PreviousWorkspace,
NextWorkspace,
PreviousAgent,
@@ -489,158 +475,129 @@ pub(crate) enum NavigateAction {
Zoom,
EnterResizeMode,
ToggleSidebar,
+ CyclePaneNext,
+ CyclePanePrevious,
+ Help,
+ Settings,
+ Quit,
ReloadConfig,
OpenNotificationTarget,
Detach,
}
-fn indexed_navigation_action(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
- let KeyCode::Char(c @ '1'..='9') = key.code else {
- return None;
- };
- let idx = (c as usize) - ('1' as usize);
+fn indexed_navigation_action(
+ state: &AppState,
+ key: TerminalKey,
+ dispatch: BindingDispatch,
+) -> Option<NavigateAction> {
let kb = &state.keybinds;
+ let trigger_matches = |binding: &crate::config::IndexedKeybind| match dispatch {
+ BindingDispatch::Direct => binding.trigger.is_direct(),
+ BindingDispatch::Prefix => binding.trigger.is_prefix(),
+ };
- if kb
- .indexed_tabs
- .is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods))
- {
- return Some(NavigateAction::SwitchTab(idx));
+ for binding in &kb.switch_tab {
+ if trigger_matches(binding) {
+ if let Some(idx) = binding.matched_index(key) {
+ return Some(NavigateAction::SwitchTab(idx));
+ }
+ }
}
- if kb
- .indexed_workspaces
- .is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods))
- {
- return Some(NavigateAction::SwitchWorkspace(idx));
+ for binding in &kb.switch_workspace {
+ if trigger_matches(binding) {
+ if let Some(idx) = binding.matched_index(key) {
+ return Some(NavigateAction::SwitchWorkspace(idx));
+ }
+ }
}
- if kb
- .indexed_agents
- .is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods))
- {
- return Some(NavigateAction::FocusAgent(idx));
+ for binding in &kb.focus_agent {
+ if trigger_matches(binding) {
+ if let Some(idx) = binding.matched_index(key) {
+ return Some(NavigateAction::FocusAgent(idx));
+ }
+ }
}
None
}
-fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
- if let Some(action) = indexed_navigation_action(state, key) {
+fn action_matches(
+ bindings: &crate::config::ActionKeybinds,
+ key: TerminalKey,
+ dispatch: BindingDispatch,
+) -> bool {
+ match dispatch {
+ BindingDispatch::Direct => bindings.matches_direct_key(key),
+ BindingDispatch::Prefix => bindings.matches_prefix_key(key),
+ }
+}
+
+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);
}
let kb = &state.keybinds;
- if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) {
- return Some(NavigateAction::NewWorkspace);
- }
- if key_matches(key, kb.rename_workspace.0, kb.rename_workspace.1) {
- return Some(NavigateAction::RenameWorkspace);
- }
- if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) {
- return Some(NavigateAction::CloseWorkspace);
- }
- if kb
- .previous_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousWorkspace);
- }
- if kb
- .next_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextWorkspace);
- }
- if kb
- .previous_agent
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousAgent);
- }
- if kb
- .next_agent
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextAgent);
- }
- if key_matches(key, kb.new_tab.0, kb.new_tab.1) {
- return Some(NavigateAction::NewTab);
- }
- if kb
- .rename_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::RenameTab);
- }
- if kb
- .previous_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousTab);
- }
- if kb
- .next_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextTab);
- }
- if kb
- .close_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::CloseTab);
- }
- if kb
- .rename_pane
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::RenamePane);
- }
- if kb
- .edit_scrollback
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::EditScrollback);
- }
- if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) {
- return Some(NavigateAction::SplitVertical);
- }
- if key_matches(key, kb.split_horizontal.0, kb.split_horizontal.1) {
- return Some(NavigateAction::SplitHorizontal);
- }
- if key_matches(key, kb.close_pane.0, kb.close_pane.1) {
- return Some(NavigateAction::ClosePane);
- }
- if key_matches(key, kb.zoom.0, kb.zoom.1) {
- return Some(NavigateAction::Zoom);
- }
- if key_matches(key, kb.resize_mode.0, kb.resize_mode.1) {
- return Some(NavigateAction::EnterResizeMode);
- }
- if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) {
- return Some(NavigateAction::ToggleSidebar);
- }
- if kb
- .reload_config
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::ReloadConfig);
- }
- if kb
- .open_notification_target
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::OpenNotificationTarget);
- }
- if kb
- .detach
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::Detach);
+ for (bindings, action) in [
+ (&kb.help, NavigateAction::Help),
+ (&kb.settings, NavigateAction::Settings),
+ (&kb.quit, NavigateAction::Quit),
+ (&kb.workspace_picker, NavigateAction::WorkspacePicker),
+ (&kb.new_workspace, NavigateAction::NewWorkspace),
+ (&kb.rename_workspace, NavigateAction::RenameWorkspace),
+ (&kb.close_workspace, NavigateAction::CloseWorkspace),
+ (&kb.previous_workspace, NavigateAction::PreviousWorkspace),
+ (&kb.next_workspace, NavigateAction::NextWorkspace),
+ (&kb.previous_agent, NavigateAction::PreviousAgent),
+ (&kb.next_agent, NavigateAction::NextAgent),
+ (&kb.new_tab, NavigateAction::NewTab),
+ (&kb.rename_tab, NavigateAction::RenameTab),
+ (&kb.previous_tab, NavigateAction::PreviousTab),
+ (&kb.next_tab, NavigateAction::NextTab),
+ (&kb.close_tab, NavigateAction::CloseTab),
+ (&kb.rename_pane, NavigateAction::RenamePane),
+ (&kb.edit_scrollback, NavigateAction::EditScrollback),
+ (&kb.focus_pane_left, NavigateAction::FocusPaneLeft),
+ (&kb.focus_pane_down, NavigateAction::FocusPaneDown),
+ (&kb.focus_pane_up, NavigateAction::FocusPaneUp),
+ (&kb.focus_pane_right, NavigateAction::FocusPaneRight),
+ (&kb.cycle_pane_next, NavigateAction::CyclePaneNext),
+ (&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious),
+ (&kb.split_vertical, NavigateAction::SplitVertical),
+ (&kb.split_horizontal, NavigateAction::SplitHorizontal),
+ (&kb.close_pane, NavigateAction::ClosePane),
+ (&kb.zoom, NavigateAction::Zoom),
+ (&kb.resize_mode, NavigateAction::EnterResizeMode),
+ (&kb.toggle_sidebar, NavigateAction::ToggleSidebar),
+ (&kb.reload_config, NavigateAction::ReloadConfig),
+ (
+ &kb.open_notification_target,
+ NavigateAction::OpenNotificationTarget,
+ ),
+ (&kb.detach, NavigateAction::Detach),
+ ] {
+ if action_matches(bindings, key, dispatch) {
+ return Some(action);
+ }
}
None
}
+#[cfg(test)]
pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
+ execute_navigate_action_in_context(state, action, ActionContext::Navigate);
+}
+
+pub(super) fn execute_navigate_action_in_context(
+ state: &mut AppState,
+ action: NavigateAction,
+ context: ActionContext,
+) {
+ let previous_mode = state.mode;
match action {
NavigateAction::NewWorkspace => {
state.request_new_workspace = true;
@@ -682,6 +639,10 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
leave_navigate_mode(state);
}
}
+ NavigateAction::WorkspacePicker => {
+ state.mobile_switcher_scroll = 0;
+ state.mode = Mode::Navigate;
+ }
NavigateAction::PreviousWorkspace => {
state.previous_workspace();
leave_navigate_mode(state);
@@ -699,11 +660,13 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
leave_navigate_mode(state);
}
NavigateAction::NewTab => {
- if state.prompt_new_tab_name {
- super::modal::open_new_tab_dialog(state);
- } else {
- state.request_new_tab = true;
- leave_navigate_mode(state);
+ if state.active.is_some() {
+ if state.prompt_new_tab_name {
+ super::modal::open_new_tab_dialog(state);
+ } else {
+ state.request_new_tab = true;
+ leave_navigate_mode(state);
+ }
}
}
NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false),
@@ -754,6 +717,20 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
state.sidebar_collapsed = !state.sidebar_collapsed;
leave_navigate_mode(state);
}
+ NavigateAction::CyclePaneNext => {
+ state.cycle_pane(false);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::CyclePanePrevious => {
+ state.cycle_pane(true);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::Help => super::modal::open_keybind_help(state),
+ NavigateAction::Settings => super::settings::open_settings(state),
+ NavigateAction::Quit => {
+ super::modal::request_quit_or_detach(state);
+ leave_navigate_mode(state);
+ }
NavigateAction::ReloadConfig => {
state.request_reload_config = true;
leave_navigate_mode(state);
@@ -769,6 +746,8 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
leave_navigate_mode(state);
}
}
+
+ finish_action_context(state, context, previous_mode);
}
fn leave_navigate_mode(state: &mut AppState) {
@@ -777,6 +756,34 @@ fn leave_navigate_mode(state: &mut AppState) {
}
}
+fn finish_action_context(state: &mut AppState, context: ActionContext, previous_mode: Mode) {
+ if matches!(context, ActionContext::Direct | ActionContext::Prefix)
+ && state.mode == previous_mode
+ {
+ leave_command_mode(state);
+ }
+}
+
+fn finish_custom_command_context(
+ state: &mut AppState,
+ context: ActionContext,
+ previous_mode: Mode,
+) {
+ if context == ActionContext::Navigate {
+ leave_navigate_mode(state);
+ } else {
+ finish_action_context(state, context, previous_mode);
+ }
+}
+
+fn leave_command_mode(state: &mut AppState) {
+ state.mode = if state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+}
+
fn write_scrollback_temp_file(content: &str) -> io::Result<std::path::PathBuf> {
let mut last_collision = None;
for attempt in 0..16 {
@@ -850,8 +857,7 @@ mod tests {
#[test]
fn custom_rename_key_enters_rename_mode() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.rename_workspace_label = "g".into();
+ state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g");
handle_navigate_key(
&mut state,
@@ -865,8 +871,7 @@ mod tests {
#[test]
fn custom_new_workspace_key_requests_and_exits_navigate() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.new_workspace_label = "g".into();
+ state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g");
handle_navigate_key(
&mut state,
@@ -880,8 +885,7 @@ mod tests {
#[test]
fn custom_sidebar_toggle_key_toggles_and_exits_navigate() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.toggle_sidebar_label = "g".into();
+ state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("g");
assert!(!state.sidebar_collapsed);
handle_navigate_key(
@@ -896,8 +900,7 @@ mod tests {
#[test]
fn custom_resize_key_enters_resize_mode() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.resize_mode_label = "g".into();
+ state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g");
handle_navigate_key(
&mut state,
@@ -910,8 +913,7 @@ mod tests {
#[test]
fn custom_reload_config_key_requests_reload_and_exits_navigate() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.reload_config = Some((KeyCode::Char('g'), KeyModifiers::empty()));
- state.keybinds.reload_config_label = Some("g".into());
+ state.keybinds.reload_config = crate::config::ActionKeybinds::prefix("g");
handle_navigate_key(
&mut state,
@@ -928,8 +930,7 @@ mod tests {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Navigate;
- state.keybinds.open_notification_target = Some((KeyCode::Char('g'), KeyModifiers::empty()));
- state.keybinds.open_notification_target_label = Some("g".into());
+ state.keybinds.open_notification_target = crate::config::ActionKeybinds::prefix("g");
let target_workspace_id = state.workspaces[1].id.clone();
let target_pane = state.workspaces[1].tabs[0].root_pane;
state.toast = Some(crate::app::state::ToastNotification {
@@ -989,12 +990,11 @@ mod tests {
#[test]
fn terminal_direct_agent_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.next_agent = Some((KeyCode::Char('a'), KeyModifiers::ALT));
- state.keybinds.next_agent_label = Some("alt+a".into());
+ state.keybinds.next_agent = crate::config::ActionKeybinds::direct("alt+a");
let action = terminal_direct_navigation_action(
&state,
- &KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT),
+ TerminalKey::new(KeyCode::Char('a'), KeyModifiers::ALT),
);
assert_eq!(action, Some(NavigateAction::NextAgent));
@@ -1003,12 +1003,11 @@ mod tests {
#[test]
fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT));
- state.keybinds.focus_pane_left_label = Some("alt+left".into());
+ state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+left");
let action = terminal_direct_navigation_action(
&state,
- &KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
+ TerminalKey::new(KeyCode::Left, KeyModifiers::ALT),
);
assert_eq!(action, Some(NavigateAction::FocusPaneLeft));
@@ -1017,18 +1016,203 @@ mod tests {
#[test]
fn terminal_direct_indexed_tab_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.indexed_tabs = Some(KeyModifiers::CONTROL);
- state.keybinds.indexed_tabs_label = Some("ctrl+1..9".into());
+ let config: Config = toml::from_str("[keys]\nswitch_tab = \"ctrl+3\"\n").unwrap();
+ state.keybinds.switch_tab = config.keybinds().switch_tab;
let action = terminal_direct_navigation_action(
&state,
- &KeyEvent::new(KeyCode::Char('3'), KeyModifiers::CONTROL),
+ TerminalKey::new(KeyCode::Char('3'), KeyModifiers::CONTROL),
);
assert_eq!(action, Some(NavigateAction::SwitchTab(2)));
}
#[tokio::test]
+ async fn navigate_mode_runs_prefix_action_rhs_without_pressing_prefix_again() {
+ 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("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Navigate;
+
+ app.handle_navigate_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::SHIFT));
+
+ assert!(app.state.request_new_workspace);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn prefix_focus_pane_is_one_shot_and_returns_to_terminal() {
+ 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("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(Direction::Horizontal);
+ app.state.workspaces[0].layout.focus_pane(right);
+ app.state.view.pane_infos = app.state.workspaces[0]
+ .active_tab()
+ .unwrap()
+ .layout
+ .panes(ratatui::layout::Rect::new(0, 0, 80, 24));
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty()))
+ .await;
+
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn no_op_prefix_action_exits_prefix_mode() {
+ 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("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::Char('o'), KeyModifiers::empty()))
+ .await;
+
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn unmatched_prefix_rhs_exits_prefix_mode() {
+ 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("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::F(12), KeyModifiers::empty()))
+ .await;
+
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn prefix_help_matches_enhanced_shifted_question_mark() {
+ 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("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(
+ TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('?' as u32),
+ )
+ .await;
+
+ assert_eq!(app.state.mode, Mode::KeybindHelp);
+ }
+
+ #[test]
+ fn navigate_mode_help_is_binding_driven() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.help = crate::config::ActionKeybinds::prefix("f");
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT),
+ );
+ assert_eq!(state.mode, Mode::Navigate);
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()),
+ );
+ assert_eq!(state.mode, Mode::KeybindHelp);
+ }
+
+ #[test]
+ fn modified_navigate_local_key_can_be_bound_as_prefix_rhs() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+h");
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT),
+ );
+
+ assert!(state.sidebar_collapsed);
+ }
+
+ #[test]
+ fn empty_state_new_tab_is_no_op() {
+ let mut state = crate::app::state::AppState::test_new();
+ state.mode = Mode::Prefix;
+
+ execute_navigate_action_in_context(
+ &mut state,
+ NavigateAction::NewTab,
+ ActionContext::Prefix,
+ );
+
+ assert_eq!(state.mode, Mode::Navigate);
+ assert!(!state.creating_new_tab);
+ assert!(!state.request_new_tab);
+ assert!(state.workspaces.is_empty());
+ }
+
+ #[tokio::test]
async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
@@ -1049,8 +1233,8 @@ mod tests {
output_path.display()
);
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- key: (KeyCode::Char('g'), KeyModifiers::empty()),
- label: "g".into(),
+ bindings: crate::config::ActionKeybinds::prefix("g"),
+ label: "prefix+g".into(),
command,
action: crate::config::CustomCommandAction::Shell,
}];
@@ -1060,7 +1244,7 @@ mod tests {
app.state.prefix_mods,
))
.await;
- assert_eq!(app.state.mode, Mode::Navigate);
+ assert_eq!(app.state.mode, Mode::Prefix);
app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
.await;
@@ -1110,8 +1294,8 @@ mod tests {
let output_path = unique_temp_path("custom-pane-command");
let command = format!("printf done > '{}'", output_path.display());
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- key: (KeyCode::Char('g'), KeyModifiers::empty()),
- label: "g".into(),
+ bindings: crate::config::ActionKeybinds::prefix("g"),
+ label: "prefix+g".into(),
command,
action: crate::config::CustomCommandAction::Pane,
}];
@@ -1164,7 +1348,7 @@ mod tests {
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
- app.state.mode = Mode::Navigate;
+ app.state.mode = Mode::Terminal;
let output_path = unique_temp_path("edit-scrollback");
let previous_editor = std::env::var_os("EDITOR");
@@ -1172,10 +1356,15 @@ mod tests {
"EDITOR",
format!("sh -c 'cp \"$1\" {}' sh", output_path.display()),
);
- app.state.keybinds.edit_scrollback = Some((KeyCode::Char('g'), KeyModifiers::empty()));
- app.state.keybinds.edit_scrollback_label = Some("g".into());
+ app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::prefix("g");
- app.handle_navigate_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()));
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
+ .await;
match previous_editor {
Some(value) => std::env::set_var("EDITOR", value),
@@ -1194,8 +1383,7 @@ mod tests {
fn zoom_action_exits_navigate_mode() {
let mut state = state_with_workspaces(&["test"]);
state.workspaces[0].test_split(Direction::Horizontal);
- state.keybinds.zoom = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.zoom_label = "g".into();
+ state.keybinds.zoom = crate::config::ActionKeybinds::prefix("g");
handle_navigate_key(
&mut state,
@@ -1265,10 +1453,11 @@ mod tests {
let mut state = crate::app::state::AppState::test_new();
state.quit_detaches = true;
- assert!(handle_navigate_reserved_key(
+ handle_navigate_key(
&mut state,
- KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())
- ));
+ KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()),
+ );
+
assert!(state.detach_requested);
assert!(!state.should_quit);
}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 12fb67b2..41a62e82 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -34,21 +34,44 @@ impl App {
let key_event = key.as_key_event();
- if let Some(action) = super::terminal_direct_navigation_action(&self.state, &key_event) {
+ if let Some(action) = super::terminal_direct_navigation_action(&self.state, key) {
debug!(
code = ?key_event.code,
modifiers = ?key_event.modifiers,
kind = ?key_event.kind,
action = ?action,
- "intercepted terminal direct navigation key before forwarding to pane"
+ "intercepted terminal direct keybinding before forwarding to pane"
);
- super::navigate::execute_navigate_action(&mut self.state, action);
+ if action == super::navigate::NavigateAction::EditScrollback {
+ self.launch_focused_scrollback_editor();
+ } else {
+ super::navigate::execute_navigate_action_in_context(
+ &mut self.state,
+ action,
+ super::navigate::ActionContext::Direct,
+ );
+ }
+ return None;
+ }
+
+ if let Some(binding) = super::navigate::command_for_key(
+ &self.state,
+ key,
+ super::navigate::BindingDispatch::Direct,
+ ) {
+ debug!(
+ code = ?key_event.code,
+ modifiers = ?key_event.modifiers,
+ kind = ?key_event.kind,
+ command = %binding.label,
+ "intercepted terminal direct custom command before forwarding to pane"
+ );
+ self.launch_custom_command(binding, super::navigate::ActionContext::Direct);
return None;
}
- if self.state.is_prefix(&key_event) {
- self.state.mobile_switcher_scroll = 0;
- self.state.mode = Mode::Navigate;
+ if self.state.is_prefix_key(key) {
+ self.state.mode = Mode::Prefix;
return None;
}
@@ -169,7 +192,9 @@ mod tests {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::layout::Rect;
- use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
+ use super::super::{
+ app_for_mouse_test, mouse, numbered_lines_bytes, unique_temp_path, wait_for_file,
+ };
use super::*;
use crate::{config::Config, workspace::Workspace};
@@ -475,8 +500,7 @@ mod tests {
.layout
.panes(Rect::new(0, 0, 80, 24));
let focused_before = app.state.workspaces[0].layout.focused();
- app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT));
- app.state.keybinds.focus_pane_left_label = Some("alt+h".into());
+ app.state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+h");
app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT))
.await;
@@ -486,6 +510,138 @@ mod tests {
}
#[tokio::test]
+ async fn terminal_direct_edit_scrollback_opens_editor_pane() {
+ 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(),
+ );
+ let mut workspace = Workspace::test_new("test");
+ let root_pane = workspace.tabs[0].root_pane;
+ workspace.tabs[0].runtimes.insert(
+ root_pane,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"),
+ );
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let output_path = unique_temp_path("direct-edit-scrollback");
+ let previous_editor = std::env::var_os("EDITOR");
+ std::env::set_var(
+ "EDITOR",
+ format!("sh -c 'cp \"$1\" {}' sh", output_path.display()),
+ );
+ app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::direct("ctrl+alt+e");
+
+ app.handle_terminal_key(TerminalKey::new(
+ KeyCode::Char('e'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ))
+ .await;
+
+ match previous_editor {
+ Some(value) => std::env::set_var("EDITOR", value),
+ None => std::env::remove_var("EDITOR"),
+ }
+
+ let content = wait_for_file(&output_path);
+ assert!(content.contains("alpha"));
+ assert!(content.contains("beta"));
+ assert_eq!(app.state.mode, Mode::Terminal);
+
+ let _ = std::fs::remove_file(output_path);
+ }
+
+ #[tokio::test]
+ async fn direct_custom_command_runs_before_forwarding_to_pane() {
+ 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("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let output_path = unique_temp_path("direct-custom-command");
+ let command = format!("printf direct > '{}'", output_path.display());
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
+ label: "ctrl+alt+g".into(),
+ command,
+ action: crate::config::CustomCommandAction::Shell,
+ }];
+
+ app.handle_terminal_key(TerminalKey::new(
+ KeyCode::Char('g'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ))
+ .await;
+
+ assert_eq!(wait_for_file(&output_path), "direct");
+ assert_eq!(app.state.mode, Mode::Terminal);
+ let _ = std::fs::remove_file(output_path);
+ }
+
+ #[tokio::test]
+ async fn direct_custom_pane_command_opens_overlay_pane() {
+ 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(),
+ );
+ let (workspace, terminal, runtime) = Workspace::new(
+ std::env::current_dir().unwrap_or_else(|_| "/".into()),
+ 24,
+ 80,
+ app.state.pane_scrollback_limit_bytes,
+ app.state.host_terminal_theme,
+ &app.state.default_shell,
+ app.event_tx.clone(),
+ app.render_notify.clone(),
+ app.render_dirty.clone(),
+ )
+ .expect("workspace should spawn");
+ app.state.workspaces = vec![workspace];
+ app.state
+ .terminal_runtimes
+ .insert(terminal.id.clone(), runtime);
+ app.state.terminals.insert(terminal.id.clone(), terminal);
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
+ label: "ctrl+alt+g".into(),
+ command: "printf direct-pane".into(),
+ action: crate::config::CustomCommandAction::Pane,
+ }];
+
+ app.handle_terminal_key(TerminalKey::new(
+ KeyCode::Char('g'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ))
+ .await;
+
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
+ assert!(app.state.workspaces[0].tabs[0].zoomed);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
async fn alt_backspace_is_forwarded_to_focused_pane() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");