From 836fd7e585af15cc1ccb0d4f04586ffec28d2a3c Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 20 May 2026 18:55:41 +0300 Subject: feat: add keybinding v2 refs #154 refs #201 refs #202 refs #219 --- src/app/input/mod.rs | 23 +- src/app/input/modal.rs | 50 +- src/app/input/navigate.rs | 725 ++++++++++------ src/app/input/terminal.rs | 174 +++- src/app/mod.rs | 39 +- src/app/state.rs | 86 +- src/cli.rs | 116 ++- src/config.rs | 12 +- src/config/io.rs | 94 +++ src/config/keybinds.rs | 2006 +++++++++++++++++++++------------------------ src/config/model.rs | 187 +++-- src/main.rs | 88 +- src/remote.rs | 163 ++++ src/ui.rs | 63 +- src/ui/keybind_help.rs | 143 ++-- src/ui/menus.rs | 65 +- src/ui/onboarding.rs | 2 +- src/ui/panes.rs | 5 +- src/ui/release_notes.rs | 10 +- 19 files changed, 2366 insertions(+), 1685 deletions(-) (limited to 'src') 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 { - 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 { 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 { - 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 { 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 { - 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 { + 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 { 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,17 +1016,202 @@ 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(); @@ -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; @@ -485,6 +509,138 @@ mod tests { assert_eq!(app.state.mode, Mode::Terminal); } + #[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(); diff --git a/src/app/mod.rs b/src/app/mod.rs index f33de78f..63bf690d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1048,6 +1048,9 @@ impl App { fn handle_non_terminal_key(&mut self, key: crate::input::TerminalKey) { let key_event = key.as_key_event(); match self.state.mode { + Mode::Prefix => { + self.handle_prefix_key(key); + } Mode::Navigate => { self.handle_navigate_key(key); } @@ -1055,7 +1058,7 @@ impl App { input::handle_rename_key(&mut self.state, key_event); } Mode::Resize => { - input::handle_resize_key(&mut self.state, key_event); + input::handle_resize_key(&mut self.state, key); } Mode::ConfirmClose => { input::handle_confirm_close_key(&mut self.state, key_event); @@ -1104,7 +1107,7 @@ mod tests { use crate::detect::{Agent, AgentState}; use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; - use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use std::sync::{Mutex, OnceLock}; fn raw_key( @@ -1330,7 +1333,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\n[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n", + "[terminal]\ndefault_shell = \"nu\"\n[keys]\nnew_workspace = \"prefix+g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -1341,10 +1344,11 @@ mod tests { assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); assert_eq!(app.state.prefix_code, KeyCode::Char('a')); assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL); - assert_eq!( - app.state.keybinds.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); + assert!(app + .state + .keybinds + .new_workspace + .matches_prefix(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()))); assert_eq!( app.state.toast_config.delivery, crate::config::ToastDelivery::Herdr @@ -1523,7 +1527,7 @@ mod tests { let mut app = test_app(); let original_prefix = (app.state.prefix_code, app.state.prefix_mods); - let original_keybinds = app.state.keybinds.new_workspace; + let original_keybinds = app.state.keybinds.new_workspace.clone(); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); @@ -1556,7 +1560,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[keys]\nnew_workspace = \"g\"\n[ui.toast]\ndelivery = \"desktop\"\n", + "[keys]\nnew_workspace = \"prefix+g\"\n[ui.toast]\ndelivery = \"desktop\"\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -1566,10 +1570,11 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); - assert_eq!( - app.state.keybinds.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); + assert!(app + .state + .keybinds + .new_workspace + .matches_prefix(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()))); assert_eq!( app.state.toast_config.delivery, crate::config::ToastDelivery::Herdr @@ -1650,7 +1655,7 @@ mod tests { let mut app = test_app(); let original_prefix = (app.state.prefix_code, app.state.prefix_mods); - let original_keybinds = app.state.keybinds.new_workspace; + let original_keybinds = app.state.keybinds.new_workspace.clone(); let original_toast_delivery = app.state.toast_config.delivery; let report = app.reload_config(); @@ -2547,8 +2552,8 @@ mod tests { assert_eq!( app.state.mode, - Mode::Navigate, - "prefix key should enter navigate mode" + Mode::Prefix, + "prefix key should enter prefix mode" ); assert!( !app.state.detach_requested, @@ -2584,7 +2589,7 @@ mod tests { app.state.prefix_mods = KeyModifiers::CONTROL; app.route_client_input(vec![0x0c]); - assert_eq!(app.state.mode, Mode::Navigate); + assert_eq!(app.state.mode, Mode::Prefix); app.route_client_input(vec![0x0c]); assert_eq!(app.state.mode, Mode::Terminal); diff --git a/src/app/state.rs b/src/app/state.rs index 0c38171d..2b078caf 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -590,6 +590,7 @@ pub enum Mode { ReleaseNotes, ProductAnnouncement, Navigate, + Prefix, Terminal, RenameWorkspace, RenameTab, @@ -1023,8 +1024,8 @@ impl AppState { self.mouse_capture || self.focused_pane_requests_mouse_capture() } - pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool { - key_matches(key, self.prefix_code, self.prefix_mods) + pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool { + crate::config::terminal_key_matches_combo(key, (self.prefix_code, self.prefix_mods)) } pub fn estimate_pane_size(&self) -> (u16, u16) { @@ -1110,23 +1111,16 @@ impl AppState { } } +#[cfg(test)] pub fn key_matches( key: &crossterm::event::KeyEvent, expected_code: KeyCode, expected_mods: KeyModifiers, ) -> bool { - if key.modifiers != expected_mods { - return false; - } - - match (key.code, expected_code) { - (KeyCode::Char(actual), KeyCode::Char(expected)) - if actual.is_ascii_alphabetic() && expected.is_ascii_alphabetic() => - { - actual.eq_ignore_ascii_case(&expected) - } - (actual, expected) => actual == expected, - } + crate::config::terminal_key_matches_combo( + crate::input::TerminalKey::from(*key), + (expected_code, expected_mods), + ) } // --------------------------------------------------------------------------- @@ -1221,69 +1215,7 @@ impl AppState { }, local_sound_playback: false, toast_config: ToastConfig::default(), - keybinds: Keybinds { - new_workspace: (KeyCode::Char('n'), KeyModifiers::empty()), - new_workspace_label: "n".into(), - rename_workspace: (KeyCode::Char('n'), KeyModifiers::SHIFT), - rename_workspace_label: "shift+n".into(), - close_workspace: (KeyCode::Char('d'), KeyModifiers::SHIFT), - close_workspace_label: "shift+d".into(), - detach: None, - detach_label: None, - reload_config: None, - reload_config_label: None, - open_notification_target: None, - open_notification_target_label: None, - previous_workspace: None, - previous_workspace_label: None, - next_workspace: None, - next_workspace_label: None, - previous_agent: None, - previous_agent_label: None, - next_agent: None, - next_agent_label: None, - indexed_tabs: None, - indexed_tabs_label: None, - indexed_workspaces: None, - indexed_workspaces_label: None, - indexed_agents: None, - indexed_agents_label: None, - new_tab: (KeyCode::Char('c'), KeyModifiers::empty()), - new_tab_label: "c".into(), - rename_tab: None, - rename_tab_label: None, - previous_tab: None, - previous_tab_label: None, - next_tab: None, - next_tab_label: None, - close_tab: None, - close_tab_label: None, - rename_pane: None, - rename_pane_label: None, - edit_scrollback: None, - edit_scrollback_label: None, - focus_pane_left: None, - focus_pane_left_label: None, - focus_pane_down: None, - focus_pane_down_label: None, - focus_pane_up: None, - focus_pane_up_label: None, - focus_pane_right: None, - focus_pane_right_label: None, - split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()), - split_vertical_label: "v".into(), - split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()), - split_horizontal_label: "-".into(), - close_pane: (KeyCode::Char('x'), KeyModifiers::empty()), - close_pane_label: "x".into(), - zoom: (KeyCode::Char('f'), KeyModifiers::empty()), - zoom_label: "f".into(), - resize_mode: (KeyCode::Char('r'), KeyModifiers::empty()), - resize_mode_label: "r".into(), - toggle_sidebar: (KeyCode::Char('b'), KeyModifiers::empty()), - toggle_sidebar_label: "b".into(), - custom_commands: Vec::new(), - }, + keybinds: Keybinds::default(), spinner_tick: 0, palette: Palette::catppuccin(), theme_name: "catppuccin".to_string(), diff --git a/src/cli.rs b/src/cli.rs index 3e07ab2c..e9fe6e81 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,6 @@ use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Serialize; @@ -33,6 +33,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result { exit_code } "status" => run_status_command(&args[2..])?, + "config" => run_config_command(&args[2..])?, "workspace" => run_workspace_command(&args[2..])?, "tab" => run_tab_command(&args[2..])?, "agent" => run_agent_command(&args[2..])?, @@ -95,6 +96,114 @@ fn run_status_command(args: &[String]) -> std::io::Result { } } +fn run_config_command(args: &[String]) -> std::io::Result { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_config_help(); + return Ok(2); + }; + + match subcommand { + "reset-keys" => config_reset_keys(&args[1..]), + "help" | "--help" | "-h" => { + print_config_help(); + Ok(0) + } + _ => { + print_config_help(); + Ok(2) + } + } +} + +fn config_reset_keys(args: &[String]) -> std::io::Result { + if !args.is_empty() { + eprintln!("usage: herdr config reset-keys"); + return Ok(2); + } + + let path = crate::config::config_path(); + if !path.exists() { + println!( + "No config file found at {}. Built-in v2 keybindings already apply.", + path.display() + ); + return Ok(0); + } + + let content = std::fs::read_to_string(&path)?; + let parsed = match content.parse::() { + Ok(value) => value, + Err(err) => { + eprintln!( + "config file at {} is invalid TOML: {err}. Fix it manually or move it aside to use defaults.", + path.display() + ); + return Ok(1); + } + }; + let Some(table) = parsed.as_table() else { + eprintln!( + "config file at {} is invalid TOML: top-level config must be a table.", + path.display() + ); + return Ok(1); + }; + + if !table.contains_key("keys") { + println!( + "No [keys] config found in {}. Built-in v2 keybindings already apply.", + path.display() + ); + return Ok(0); + } + + let (updated, removed) = crate::config::remove_keybinding_config_sections(&content); + if !removed { + eprintln!( + "could not safely remove keybinding config from {} without rewriting comments; edit the file manually or remove the top-level keys setting.", + path.display() + ); + return Ok(1); + } + if let Err(err) = updated.parse::() { + eprintln!( + "removing keybinding config would make {} invalid TOML: {err}; leaving config unchanged", + path.display() + ); + return Ok(1); + } + + let backup_path = key_config_backup_path(&path); + std::fs::copy(&path, &backup_path)?; + std::fs::write(&path, updated)?; + + println!("Created backup: {}", backup_path.display()); + println!( + "Removed [keys], [keys.indexed], and [[keys.command]] from {}.", + path.display() + ); + println!("Built-in v2 keybindings will apply after Herdr restarts or reloads config."); + println!("If a Herdr server is running, run `herdr server reload-config` to apply this now."); + println!( + "To restore: cp {} {}", + backup_path.display(), + path.display() + ); + Ok(0) +} + +fn key_config_backup_path(path: &std::path::Path) -> std::path::PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("config.toml"); + path.with_file_name(format!("{file_name}.bak-keybind-v2-{timestamp}")) +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ServerRuntimeStatus { Running { @@ -2071,6 +2180,11 @@ fn print_status_help() { eprintln!(" herdr status client show local client binary status"); } +fn print_config_help() { + eprintln!("herdr config commands:"); + eprintln!(" herdr config reset-keys back up config.toml and remove custom keybindings"); +} + fn print_workspace_help() { eprintln!("herdr workspace commands:"); eprintln!(" herdr workspace list"); diff --git a/src/config.rs b/src/config.rs index 37ba5bbf..48ebb8f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,12 +8,14 @@ mod theme; pub use self::{ io::{ - config_diagnostic_summary, config_dir, config_path, load_live_config, remove_section_key, - state_dir, upsert_section_bool, upsert_section_value, + config_diagnostic_summary, config_dir, config_path, load_live_config, + remove_keybinding_config_sections, remove_section_key, state_dir, upsert_section_bool, + upsert_section_value, }, keybinds::{ - format_key_combo, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, - Keybinds, LiveKeybindConfig, + format_key_combo, normalize_key_combo, terminal_key_matches_combo, ActionKeybinds, + BindingConfig, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, + IndexedKeybind, Keybinds, LiveKeybindConfig, }, model::{ validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport, @@ -42,7 +44,7 @@ impl Config { self.validated_keybinds().1 } - /// Parsed keybinds for navigate mode actions. + /// Parsed keybinds for Herdr actions. pub fn keybinds(&self) -> Keybinds { self.validated_keybinds().3 } diff --git a/src/config/io.rs b/src/config/io.rs index 07b63d37..12176815 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -293,6 +293,58 @@ pub fn remove_section_key(content: &str, section: &str, key: &str) -> String { result.join("\n") + "\n" } +pub fn remove_keybinding_config_sections(content: &str) -> (String, bool) { + let mut result = Vec::new(); + let mut removed = false; + let mut skipping_key_section = false; + let mut in_table = false; + + for line in content.lines() { + let trimmed = line.trim(); + + if let Some(table_name) = toml_table_header_name(trimmed) { + in_table = true; + skipping_key_section = is_keys_table_name(table_name); + if skipping_key_section { + removed = true; + continue; + } + } else if skipping_key_section || (!in_table && is_top_level_keys_assignment(trimmed)) { + removed = true; + continue; + } + + result.push(line.to_string()); + } + + let mut updated = result.join("\n"); + if content.ends_with('\n') || !updated.is_empty() { + updated.push('\n'); + } + (updated, removed) +} + +fn toml_table_header_name(trimmed: &str) -> Option<&str> { + if let Some(name) = trimmed + .strip_prefix("[[") + .and_then(|value| value.strip_suffix("]]")) + { + return Some(name.trim()); + } + trimmed + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .map(str::trim) +} + +fn is_keys_table_name(name: &str) -> bool { + name == "keys" || name.starts_with("keys.") +} + +fn is_top_level_keys_assignment(trimmed: &str) -> bool { + trimmed.starts_with("keys ") || trimmed.starts_with("keys=") || trimmed.starts_with("keys.") +} + fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String { let header = format!("[{section}]"); let assignment = format!("{key} = {value}"); @@ -381,4 +433,46 @@ mod tests { assert!(updated.contains("delivery = \"herdr\"")); assert!(updated.contains("[ui.sound]\nenabled = true")); } + + #[test] + fn remove_keybinding_config_sections_removes_keys_tables_only() { + let content = r#"onboarding = false + +[theme] +name = "catppuccin" + +[keys] +prefix = "ctrl+a" +new_tab = "c" + +[[keys.command]] +key = "g" +command = "lazygit" + +[keys.indexed] +tabs = "ctrl" + +[ui] +mouse_capture = false +"#; + + let (updated, removed) = remove_keybinding_config_sections(content); + + assert!(removed); + assert!(updated.contains("onboarding = false")); + assert!(updated.contains("[theme]\nname = \"catppuccin\"")); + assert!(updated.contains("[ui]\nmouse_capture = false")); + assert!(!updated.contains("[keys]")); + assert!(!updated.contains("[[keys.command]]")); + assert!(!updated.contains("[keys.indexed]")); + assert!(toml::from_str::(&updated).is_ok()); + } + + #[test] + fn remove_keybinding_config_sections_reports_noop_without_keys() { + let content = "[ui]\nmouse_capture = true\n"; + let (updated, removed) = remove_keybinding_config_sections(content); + assert!(!removed); + assert_eq!(updated, content); + } } diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 13d65ec3..58c26020 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -1,15 +1,50 @@ +#[cfg(test)] +use crossterm::event::KeyEvent; use crossterm::event::{KeyCode, KeyModifiers}; use serde::Deserialize; use tracing::warn; use super::Config; +use crate::input::TerminalKey; + +pub type KeyCombo = (KeyCode, KeyModifiers); #[derive(Debug)] pub struct LiveKeybindConfig { - pub prefix: (KeyCode, KeyModifiers), + pub prefix: KeyCombo, pub keybinds: Keybinds, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum BindingConfig { + One(String), + Many(Vec), +} + +impl Default for BindingConfig { + fn default() -> Self { + Self::One(String::new()) + } +} + +impl BindingConfig { + pub fn one(value: impl Into) -> Self { + Self::One(value.into()) + } + + pub fn empty() -> Self { + Self::One(String::new()) + } + + fn values(&self) -> Vec<&str> { + match self { + Self::One(value) => vec![value.as_str()], + Self::Many(values) => values.iter().map(String::as_str).collect(), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum CommandKeybindType { @@ -21,8 +56,8 @@ pub enum CommandKeybindType { #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct CommandKeybindConfig { - /// Navigate-mode key that runs a command after pressing the prefix key. - pub key: String, + /// Key that runs a command. Use `prefix+g` for prefix mode or a modified chord for direct mode. + pub key: BindingConfig, /// Command executed either in the background shell or inside a pane. pub command: String, /// Command execution mode. Default: "shell". @@ -33,7 +68,7 @@ pub struct CommandKeybindConfig { impl Default for CommandKeybindConfig { fn default() -> Self { Self { - key: String::new(), + key: BindingConfig::empty(), command: String::new(), action_type: CommandKeybindType::Shell, } @@ -46,253 +81,273 @@ pub enum CustomCommandAction { Pane, } -#[derive(Debug, Clone)] -pub struct CustomCommandKeybind { - pub key: (KeyCode, KeyModifiers), +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingTrigger { + Direct(KeyCombo), + Prefix(KeyCombo), +} + +impl BindingTrigger { + pub fn combo(self) -> KeyCombo { + match self { + Self::Direct(combo) | Self::Prefix(combo) => combo, + } + } + + pub fn is_direct(self) -> bool { + matches!(self, Self::Direct(_)) + } + + pub fn is_prefix(self) -> bool { + matches!(self, Self::Prefix(_)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedBinding { + pub trigger: BindingTrigger, pub label: String, - pub command: String, - pub action: CustomCommandAction, } -/// Parsed keybinds for navigate mode actions. -#[derive(Debug, Clone)] -pub struct Keybinds { - pub new_workspace: (KeyCode, KeyModifiers), - pub new_workspace_label: String, - pub rename_workspace: (KeyCode, KeyModifiers), - pub rename_workspace_label: String, - pub close_workspace: (KeyCode, KeyModifiers), - pub close_workspace_label: String, - pub detach: Option<(KeyCode, KeyModifiers)>, - pub detach_label: Option, - pub reload_config: Option<(KeyCode, KeyModifiers)>, - pub reload_config_label: Option, - pub open_notification_target: Option<(KeyCode, KeyModifiers)>, - pub open_notification_target_label: Option, - pub previous_workspace: Option<(KeyCode, KeyModifiers)>, - pub previous_workspace_label: Option, - pub next_workspace: Option<(KeyCode, KeyModifiers)>, - pub next_workspace_label: Option, - pub previous_agent: Option<(KeyCode, KeyModifiers)>, - pub previous_agent_label: Option, - pub next_agent: Option<(KeyCode, KeyModifiers)>, - pub next_agent_label: Option, - pub indexed_tabs: Option, - pub indexed_tabs_label: Option, - pub indexed_workspaces: Option, - pub indexed_workspaces_label: Option, - pub indexed_agents: Option, - pub indexed_agents_label: Option, - pub new_tab: (KeyCode, KeyModifiers), - pub new_tab_label: String, - pub rename_tab: Option<(KeyCode, KeyModifiers)>, - pub rename_tab_label: Option, - pub previous_tab: Option<(KeyCode, KeyModifiers)>, - pub previous_tab_label: Option, - pub next_tab: Option<(KeyCode, KeyModifiers)>, - pub next_tab_label: Option, - pub close_tab: Option<(KeyCode, KeyModifiers)>, - pub close_tab_label: Option, - pub rename_pane: Option<(KeyCode, KeyModifiers)>, - pub rename_pane_label: Option, - pub edit_scrollback: Option<(KeyCode, KeyModifiers)>, - pub edit_scrollback_label: Option, - pub focus_pane_left: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_left_label: Option, - pub focus_pane_down: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_down_label: Option, - pub focus_pane_up: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_up_label: Option, - pub focus_pane_right: Option<(KeyCode, KeyModifiers)>, - pub focus_pane_right_label: Option, - pub split_vertical: (KeyCode, KeyModifiers), - pub split_vertical_label: String, - pub split_horizontal: (KeyCode, KeyModifiers), - pub split_horizontal_label: String, - pub close_pane: (KeyCode, KeyModifiers), - pub close_pane_label: String, - pub zoom: (KeyCode, KeyModifiers), - pub zoom_label: String, - pub resize_mode: (KeyCode, KeyModifiers), - pub resize_mode_label: String, - pub toggle_sidebar: (KeyCode, KeyModifiers), - pub toggle_sidebar_label: String, - pub custom_commands: Vec, +impl ResolvedBinding { + #[cfg(test)] + fn matches_key_event(&self, key: &KeyEvent) -> bool { + key_event_matches_combo(key, self.trigger.combo()) + } + + fn matches_terminal_key(&self, key: TerminalKey) -> bool { + terminal_key_matches_combo(key, self.trigger.combo()) + } } -impl Config { - pub(super) fn validated_keybinds( - &self, - ) -> ( - Option, - (KeyCode, KeyModifiers), - Vec, - Keybinds, - ) { - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - enum BindingScope { - Navigate, - TerminalDirect, +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ActionKeybinds { + pub bindings: Vec, +} + +impl ActionKeybinds { + #[cfg(test)] + pub fn prefix(label: &str) -> Self { + let raw = if label.starts_with("prefix+") { + label.to_string() + } else { + format!("prefix+{label}") + }; + let trigger = parse_binding_string(&raw) + .and_then(|parsed| match parsed { + ParsedBinding::Single(binding) => Some(binding), + ParsedBinding::Range(_) => None, + }) + .expect("prefix binding should parse"); + Self { + bindings: vec![trigger], } + } - #[derive(Clone)] - struct RequiredBinding<'a> { - scope: BindingScope, - field: &'a str, - label: String, - default_label: &'a str, - value: (KeyCode, KeyModifiers), - default: (KeyCode, KeyModifiers), + #[cfg(test)] + pub fn direct(label: &str) -> Self { + let trigger = parse_binding_string(label) + .and_then(|parsed| match parsed { + ParsedBinding::Single(binding) => Some(binding), + ParsedBinding::Range(_) => None, + }) + .expect("direct binding should parse"); + Self { + bindings: vec![trigger], } + } - struct OptionalBinding { - scopes: Vec, - field: &'static str, - value: Option<(KeyCode, KeyModifiers)>, - label: Option, + #[cfg(test)] + pub fn matches_prefix(&self, key: &KeyEvent) -> bool { + self.bindings + .iter() + .any(|binding| binding.trigger.is_prefix() && binding.matches_key_event(key)) + } + + pub fn matches_prefix_key(&self, key: TerminalKey) -> bool { + self.bindings + .iter() + .any(|binding| binding.trigger.is_prefix() && binding.matches_terminal_key(key)) + } + + pub fn matches_direct_key(&self, key: TerminalKey) -> bool { + self.bindings + .iter() + .any(|binding| binding.trigger.is_direct() && binding.matches_terminal_key(key)) + } + + pub fn labels(&self) -> Vec { + self.bindings + .iter() + .map(|binding| binding.label.clone()) + .collect() + } + + pub fn label(&self) -> Option { + let labels = self.labels(); + if labels.is_empty() { + None + } else { + Some(labels.join(" / ")) } + } - struct IndexedBinding { - field: &'static str, - value: Option, - label: Option, + pub fn prefix_rhs_label(&self) -> Option { + let labels: Vec = self + .bindings + .iter() + .filter(|binding| binding.trigger.is_prefix()) + .map(|binding| { + binding + .label + .strip_prefix("prefix+") + .unwrap_or(&binding.label) + .to_string() + }) + .collect(); + if labels.is_empty() { + None + } else { + Some(labels.join(" / ")) } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexedKeybind { + pub trigger: BindingTrigger, + pub label: String, +} - #[derive(Default)] - struct BindingRegistry { - seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>, +impl IndexedKeybind { + pub fn matched_index(&self, key: TerminalKey) -> Option { + let KeyCode::Char(c @ '1'..='9') = key.code else { + return None; + }; + if terminal_key_matches_combo(key, self.trigger.combo()) { + Some((c as usize) - ('1' as usize)) + } else { + None } + } +} - impl BindingRegistry { - fn register( - &mut self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - field: &str, - ) -> Option { - self.seen - .insert((scope, binding.0, binding.1), field.to_string()) - } +#[derive(Debug, Clone)] +pub struct CustomCommandKeybind { + pub bindings: ActionKeybinds, + pub label: String, + pub command: String, + pub action: CustomCommandAction, +} - fn conflict( - &self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - ) -> Option<&str> { - self.seen - .get(&(scope, binding.0, binding.1)) - .map(String::as_str) - } +/// Parsed keybinds for Herdr actions. +#[derive(Debug, Clone)] +pub struct Keybinds { + pub help: ActionKeybinds, + pub settings: ActionKeybinds, + pub quit: ActionKeybinds, + pub new_workspace: ActionKeybinds, + pub rename_workspace: ActionKeybinds, + pub close_workspace: ActionKeybinds, + pub workspace_picker: ActionKeybinds, + pub detach: ActionKeybinds, + pub reload_config: ActionKeybinds, + pub open_notification_target: ActionKeybinds, + pub previous_workspace: ActionKeybinds, + pub next_workspace: ActionKeybinds, + pub previous_agent: ActionKeybinds, + pub next_agent: ActionKeybinds, + pub focus_agent: Vec, + pub new_tab: ActionKeybinds, + pub rename_tab: ActionKeybinds, + pub previous_tab: ActionKeybinds, + pub next_tab: ActionKeybinds, + pub switch_tab: Vec, + pub switch_workspace: Vec, + pub close_tab: ActionKeybinds, + pub rename_pane: ActionKeybinds, + pub edit_scrollback: ActionKeybinds, + pub focus_pane_left: ActionKeybinds, + pub focus_pane_down: ActionKeybinds, + pub focus_pane_up: ActionKeybinds, + pub focus_pane_right: ActionKeybinds, + pub cycle_pane_next: ActionKeybinds, + pub cycle_pane_previous: ActionKeybinds, + pub split_vertical: ActionKeybinds, + pub split_horizontal: ActionKeybinds, + pub close_pane: ActionKeybinds, + pub zoom: ActionKeybinds, + pub resize_mode: ActionKeybinds, + pub toggle_sidebar: ActionKeybinds, + pub custom_commands: Vec, +} - fn reserve_if_unbound( - &mut self, - scope: BindingScope, - binding: (KeyCode, KeyModifiers), - field: &str, - ) { - self.seen - .entry((scope, binding.0, binding.1)) - .or_insert_with(|| field.to_string()); - } - } +impl Default for Keybinds { + fn default() -> Self { + Config::default().keybinds() + } +} - fn required_binding<'a>( - scope: BindingScope, - field: &'a str, - configured_label: &'a str, - default_label: &'a str, - default: (KeyCode, KeyModifiers), - diagnostics: &mut Vec, - ) -> RequiredBinding<'a> { - let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default); - let label = if let Some(diag) = diag { - diagnostics.push(diag); - default_label.to_string() - } else { - configured_label.to_string() - }; - RequiredBinding { - scope, - field, - label, - default_label, - value, - default, - } +#[derive(Clone)] +enum ParsedBinding { + Single(ResolvedBinding), + Range(Vec), +} + +struct BindingRegistry { + prefix_combo: KeyCombo, + direct: std::collections::HashMap, + prefix: std::collections::HashMap, +} + +impl BindingRegistry { + fn new(prefix_combo: KeyCombo) -> Self { + Self { + prefix_combo: normalize_key_combo(prefix_combo), + direct: std::collections::HashMap::new(), + prefix: std::collections::HashMap::new(), } + } - fn optional_binding( - scopes: Vec, - field: &'static str, - configured_label: &str, - diagnostics: &mut Vec, - ) -> OptionalBinding { - if configured_label.trim().is_empty() { - return OptionalBinding { - scopes, - field, - value: None, - label: None, - }; - } - match parse_key_combo(configured_label) { - Some(value) => OptionalBinding { - scopes, - field, - value: Some(value), - label: Some(configured_label.to_string()), - }, - None => { - let diag = format!( - "invalid keybinding: {field} = {:?}; disabling binding", - configured_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - OptionalBinding { - scopes, - field, - value: None, - label: None, - } - } - } + fn reserve_direct(&mut self, combo: KeyCombo, field: &str) { + self.direct + .entry(normalize_key_combo(combo)) + .or_insert_with(|| field.to_string()); + } + + fn prefix_rhs_is_reserved(&self, combo: KeyCombo) -> bool { + normalize_key_combo(combo) == self.prefix_combo + } + + fn conflict(&self, binding: &ResolvedBinding) -> Option<&str> { + match binding.trigger { + BindingTrigger::Direct(combo) => self + .direct + .get(&normalize_key_combo(combo)) + .map(String::as_str), + BindingTrigger::Prefix(combo) => self + .prefix + .get(&normalize_key_combo(combo)) + .map(String::as_str), } + } - fn indexed_binding( - field: &'static str, - configured_label: &str, - diagnostics: &mut Vec, - ) -> IndexedBinding { - if configured_label.trim().is_empty() { - return IndexedBinding { - field, - value: None, - label: None, - }; + fn register(&mut self, binding: &ResolvedBinding, field: &str) { + match binding.trigger { + BindingTrigger::Direct(combo) => { + self.direct + .insert(normalize_key_combo(combo), field.to_string()); } - match parse_modifier_combo(configured_label) { - Some(value) => IndexedBinding { - field, - value: Some(value), - label: Some(format!("{}+1..9", configured_label.trim())), - }, - None => { - let diag = format!( - "invalid indexed keybinding: {field} = {:?}; disabling binding", - configured_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - IndexedBinding { - field, - value: None, - label: None, - } - } + BindingTrigger::Prefix(combo) => { + self.prefix + .insert(normalize_key_combo(combo), field.to_string()); } } + } +} +impl Config { + pub(super) fn validated_keybinds(&self) -> (Option, KeyCombo, Vec, Keybinds) { let mut diagnostics = Vec::new(); let (prefix, prefix_diag) = parse_key_combo_with_diagnostic( &self.keys.prefix, @@ -303,508 +358,340 @@ impl Config { warn!(message = %diag, "config diagnostic"); } - let mut bindings = vec![ - required_binding( - BindingScope::Navigate, - "keys.new_workspace", - &self.keys.new_workspace, - "n", - (KeyCode::Char('n'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.rename_workspace", - &self.keys.rename_workspace, - "shift+n", - (KeyCode::Char('n'), KeyModifiers::SHIFT), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.close_workspace", - &self.keys.close_workspace, - "shift+d", - (KeyCode::Char('d'), KeyModifiers::SHIFT), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.new_tab", - &self.keys.new_tab, - "c", - (KeyCode::Char('c'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.split_vertical", - &self.keys.split_vertical, - "v", - (KeyCode::Char('v'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.split_horizontal", - &self.keys.split_horizontal, - "-", - (KeyCode::Char('-'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.close_pane", - &self.keys.close_pane, - "x", - (KeyCode::Char('x'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.zoom", - &self.keys.zoom, - "f", - (KeyCode::Char('f'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.resize_mode", - &self.keys.resize_mode, - "r", - (KeyCode::Char('r'), KeyModifiers::empty()), - &mut diagnostics, - ), - required_binding( - BindingScope::Navigate, - "keys.toggle_sidebar", - &self.keys.toggle_sidebar, - "b", - (KeyCode::Char('b'), KeyModifiers::empty()), - &mut diagnostics, - ), - ]; - - let navigate_scope = || vec![BindingScope::Navigate]; - let terminal_direct_scope = || vec![BindingScope::TerminalDirect]; - let direct_navigation_scopes = - || vec![BindingScope::Navigate, BindingScope::TerminalDirect]; - - let mut optional_bindings = vec![ - optional_binding( - navigate_scope(), - "keys.detach", - &self.keys.detach, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.reload_config", - &self.keys.reload_config, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.open_notification_target", - &self.keys.open_notification_target, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.previous_workspace", - &self.keys.previous_workspace, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.next_workspace", - &self.keys.next_workspace, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.previous_agent", - &self.keys.previous_agent, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.next_agent", - &self.keys.next_agent, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.rename_tab", - &self.keys.rename_tab, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.previous_tab", - &self.keys.previous_tab, - &mut diagnostics, - ), - optional_binding( - direct_navigation_scopes(), - "keys.next_tab", - &self.keys.next_tab, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.close_tab", - &self.keys.close_tab, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.rename_pane", - &self.keys.rename_pane, - &mut diagnostics, - ), - optional_binding( - navigate_scope(), - "keys.edit_scrollback", - &self.keys.edit_scrollback, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_left", - &self.keys.focus_pane_left, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_down", - &self.keys.focus_pane_down, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_up", - &self.keys.focus_pane_up, - &mut diagnostics, - ), - optional_binding( - terminal_direct_scope(), - "keys.focus_pane_right", - &self.keys.focus_pane_right, - &mut diagnostics, - ), - ]; - - let mut indexed_bindings = vec![ - indexed_binding( - "keys.indexed.tabs", - &self.keys.indexed.tabs, - &mut diagnostics, - ), - indexed_binding( - "keys.indexed.workspaces", - &self.keys.indexed.workspaces, - &mut diagnostics, - ), - indexed_binding( - "keys.indexed.agents", - &self.keys.indexed.agents, - &mut diagnostics, - ), - ]; + let mut registry = BindingRegistry::new(prefix); + registry.reserve_direct(prefix, "keys.prefix"); - let mut registry = BindingRegistry::default(); - for binding in &mut bindings { - if let Some(first_field) = registry.conflict(binding.scope, binding.value) { - let diag = format!( - "duplicate keybinding: {} conflicts with {}; using default {}", - binding.field, first_field, binding.default_label - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - binding.value = binding.default; - binding.label = binding.default_label.to_string(); - } - registry.register(binding.scope, binding.value, binding.field); + macro_rules! action { + ($field:literal, $config:expr) => { + parse_action_bindings($field, $config, false, &mut registry, &mut diagnostics) + }; } - - registry.reserve_if_unbound(BindingScope::TerminalDirect, prefix, "keys.prefix"); - - for binding in &mut optional_bindings { - let Some(value) = binding.value else { - continue; + macro_rules! indexed { + ($field:literal, $config:expr) => { + parse_indexed_bindings($field, $config, &mut registry, &mut diagnostics) }; - - let conflict = binding - .scopes - .iter() - .find_map(|scope| registry.conflict(*scope, value)); - if let Some(first_field) = conflict { - let diag = format!( - "duplicate keybinding: {} conflicts with {}; disabling binding", - binding.field, first_field - ); - warn!(message = %diag, "config diagnostic"); - diagnostics.push(diag); - binding.value = None; - binding.label = None; - continue; - } - for scope in &binding.scopes { - registry.register(*scope, value, binding.field); - } } - registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix"); - for (field, binding) in [ - ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())), - ( - "navigate.open_workspace", - (KeyCode::Enter, KeyModifiers::empty()), - ), - ( - "navigate.settings", - (KeyCode::Char('s'), KeyModifiers::empty()), - ), - ( - "navigate.keybind_help", - (KeyCode::Char('?'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_up", - (KeyCode::Up, KeyModifiers::empty()), - ), - ( - "navigate.workspace_down", - (KeyCode::Down, KeyModifiers::empty()), - ), - ( - "navigate.focus_left", - (KeyCode::Char('h'), KeyModifiers::empty()), - ), - ( - "navigate.focus_down", - (KeyCode::Char('j'), KeyModifiers::empty()), - ), - ( - "navigate.focus_up", - (KeyCode::Char('k'), KeyModifiers::empty()), - ), - ( - "navigate.focus_right", - (KeyCode::Char('l'), KeyModifiers::empty()), - ), - ( - "navigate.arrow_left", - (KeyCode::Left, KeyModifiers::empty()), - ), - ( - "navigate.arrow_right", - (KeyCode::Right, KeyModifiers::empty()), - ), - ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())), - ( - "navigate.tab_prev", - (KeyCode::BackTab, KeyModifiers::empty()), - ), - ( - "navigate.workspace_1", - (KeyCode::Char('1'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_2", - (KeyCode::Char('2'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_3", - (KeyCode::Char('3'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_4", - (KeyCode::Char('4'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_5", - (KeyCode::Char('5'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_6", - (KeyCode::Char('6'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_7", - (KeyCode::Char('7'), KeyModifiers::empty()), - ), - ( - "navigate.workspace_8", - (KeyCode::Char('8'), KeyModifiers::empty()), + let mut keybinds = Keybinds { + help: action!("keys.help", &self.keys.help), + settings: action!("keys.settings", &self.keys.settings), + quit: action!("keys.quit", &self.keys.quit), + new_workspace: action!("keys.new_workspace", &self.keys.new_workspace), + rename_workspace: action!("keys.rename_workspace", &self.keys.rename_workspace), + close_workspace: action!("keys.close_workspace", &self.keys.close_workspace), + workspace_picker: action!("keys.workspace_picker", &self.keys.workspace_picker), + detach: action!("keys.detach", &self.keys.detach), + reload_config: action!("keys.reload_config", &self.keys.reload_config), + open_notification_target: action!( + "keys.open_notification_target", + &self.keys.open_notification_target ), - ( - "navigate.workspace_9", - (KeyCode::Char('9'), KeyModifiers::empty()), + previous_workspace: action!("keys.previous_workspace", &self.keys.previous_workspace), + next_workspace: action!("keys.next_workspace", &self.keys.next_workspace), + previous_agent: action!("keys.previous_agent", &self.keys.previous_agent), + next_agent: action!("keys.next_agent", &self.keys.next_agent), + focus_agent: indexed!("keys.focus_agent", &self.keys.focus_agent), + new_tab: action!("keys.new_tab", &self.keys.new_tab), + rename_tab: action!("keys.rename_tab", &self.keys.rename_tab), + previous_tab: action!("keys.previous_tab", &self.keys.previous_tab), + next_tab: action!("keys.next_tab", &self.keys.next_tab), + switch_tab: indexed!("keys.switch_tab", &self.keys.switch_tab), + switch_workspace: indexed!("keys.switch_workspace", &self.keys.switch_workspace), + close_tab: action!("keys.close_tab", &self.keys.close_tab), + rename_pane: action!("keys.rename_pane", &self.keys.rename_pane), + edit_scrollback: action!("keys.edit_scrollback", &self.keys.edit_scrollback), + focus_pane_left: action!("keys.focus_pane_left", &self.keys.focus_pane_left), + focus_pane_down: action!("keys.focus_pane_down", &self.keys.focus_pane_down), + focus_pane_up: action!("keys.focus_pane_up", &self.keys.focus_pane_up), + focus_pane_right: action!("keys.focus_pane_right", &self.keys.focus_pane_right), + cycle_pane_next: action!("keys.cycle_pane_next", &self.keys.cycle_pane_next), + cycle_pane_previous: action!( + "keys.cycle_pane_previous", + &self.keys.cycle_pane_previous ), - ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())), - ] { - registry.reserve_if_unbound(BindingScope::Navigate, binding, field); - } + split_vertical: action!("keys.split_vertical", &self.keys.split_vertical), + split_horizontal: action!("keys.split_horizontal", &self.keys.split_horizontal), + close_pane: action!("keys.close_pane", &self.keys.close_pane), + zoom: action!("keys.zoom", &self.keys.zoom), + resize_mode: action!("keys.resize_mode", &self.keys.resize_mode), + toggle_sidebar: action!("keys.toggle_sidebar", &self.keys.toggle_sidebar), + custom_commands: Vec::new(), + }; - for binding in &mut indexed_bindings { - let Some(modifiers) = binding.value else { - continue; - }; + append_legacy_indexed_bindings( + &mut keybinds.switch_tab, + "keys.indexed.tabs", + &self.keys.indexed.tabs, + &mut registry, + &mut diagnostics, + ); + append_legacy_indexed_bindings( + &mut keybinds.switch_workspace, + "keys.indexed.workspaces", + &self.keys.indexed.workspaces, + &mut registry, + &mut diagnostics, + ); + append_legacy_indexed_bindings( + &mut keybinds.focus_agent, + "keys.indexed.agents", + &self.keys.indexed.agents, + &mut registry, + &mut diagnostics, + ); - let mut conflict = None; - 'indexes: for idx in 1..=9 { - let key = ( - KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), - modifiers, - ); - for scope in [BindingScope::Navigate, BindingScope::TerminalDirect] { - if let Some(first_field) = registry.conflict(scope, key) { - conflict = Some(first_field.to_string()); - break 'indexes; - } - } - } + for (index, command) in self.keys.command.iter().enumerate() { + let key_field = format!("keys.command[{index}].key"); + let command_field = format!("keys.command[{index}].command"); - if let Some(first_field) = conflict { - let diag = format!( - "duplicate indexed keybinding: {} conflicts with {}; disabling binding", - binding.field, first_field - ); + if command.command.trim().is_empty() { + let diag = + format!("empty custom command: {command_field}; disabling custom command"); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); - binding.value = None; - binding.label = None; continue; } - for idx in 1..=9 { - let key = ( - KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), - modifiers, - ); - registry.register(BindingScope::Navigate, key, binding.field); - registry.register(BindingScope::TerminalDirect, key, binding.field); + let bindings = parse_action_bindings_owned( + &key_field, + &command.key, + false, + &mut registry, + &mut diagnostics, + ); + if bindings.bindings.is_empty() { + continue; } + + let action = match command.action_type { + CommandKeybindType::Shell => CustomCommandAction::Shell, + CommandKeybindType::Pane => CustomCommandAction::Pane, + }; + let label = bindings.label().unwrap_or_else(|| "unset".to_string()); + keybinds.custom_commands.push(CustomCommandKeybind { + bindings, + label, + command: command.command.clone(), + action, + }); } - let mut custom_commands = Vec::new(); - for (index, command) in self.keys.command.iter().enumerate() { - let key_field = format!("keys.command[{index}].key"); - let command_field = format!("keys.command[{index}].command"); + (prefix_diag, prefix, diagnostics, keybinds) + } +} - if command.command.trim().is_empty() { - let diag = - format!("empty custom command: {command_field}; disabling custom command"); +fn parse_action_bindings( + field: &'static str, + config: &BindingConfig, + allow_ranges: bool, + registry: &mut BindingRegistry, + diagnostics: &mut Vec, +) -> ActionKeybinds { + parse_action_bindings_owned(field, config, allow_ranges, registry, diagnostics) +} + +fn parse_action_bindings_owned( + field: &str, + config: &BindingConfig, + allow_ranges: bool, + registry: &mut BindingRegistry, + diagnostics: &mut Vec, +) -> ActionKeybinds { + let mut bindings = Vec::new(); + for raw in config.values() { + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + match parse_binding_string(raw) { + Some(ParsedBinding::Single(binding)) => { + if reject_binding(field, &binding, registry, diagnostics) { + continue; + } + registry.register(&binding, field); + bindings.push(binding); + } + Some(ParsedBinding::Range(_)) if !allow_ranges => { + let diag = format!("range keybinding is only valid for indexed actions: {field} = {raw:?}; disabling binding"); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); - continue; } - - let Some(binding) = parse_key_combo(&command.key) else { - let diag = format!( - "invalid keybinding: {} = {:?}; disabling custom command", - key_field, command.key - ); + Some(ParsedBinding::Range(range)) => { + for binding in range { + if reject_binding(field, &binding, registry, diagnostics) { + continue; + } + registry.register(&binding, field); + bindings.push(binding); + } + } + None => { + let diag = format!("invalid keybinding: {field} = {raw:?}; disabling binding"); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); - continue; - }; + } + } + } + ActionKeybinds { bindings } +} - if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) { +fn parse_indexed_bindings( + field: &'static str, + config: &BindingConfig, + registry: &mut BindingRegistry, + diagnostics: &mut Vec, +) -> Vec { + parse_action_bindings(field, config, true, registry, diagnostics) + .bindings + .into_iter() + .filter_map(|binding| { + if matches!(binding.trigger.combo().0, KeyCode::Char('1'..='9')) { + Some(IndexedKeybind { + trigger: binding.trigger, + label: binding.label, + }) + } else { let diag = format!( - "duplicate custom keybinding: {} conflicts with {}; disabling custom command", - key_field, first_field + "indexed keybinding must use 1..9: {field} = {:?}; disabling binding", + binding.label ); warn!(message = %diag, "config diagnostic"); diagnostics.push(diag); - continue; + None } + }) + .collect() +} + +fn append_legacy_indexed_bindings( + target: &mut Vec, + field: &'static str, + configured_label: &str, + registry: &mut BindingRegistry, + diagnostics: &mut Vec, +) { + if configured_label.trim().is_empty() { + return; + } + let Some(modifiers) = parse_modifier_combo(configured_label) else { + let diag = format!( + "invalid indexed keybinding: {field} = {configured_label:?}; disabling binding" + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return; + }; + + for idx in 1..=9 { + let combo = ( + KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), + modifiers, + ); + let binding = ResolvedBinding { + trigger: BindingTrigger::Direct(combo), + label: format!("{}+{idx}", configured_label.trim()), + }; + if reject_binding(field, &binding, registry, diagnostics) { + continue; + } + registry.register(&binding, field); + target.push(IndexedKeybind { + trigger: binding.trigger, + label: binding.label, + }); + } +} + +fn reject_binding( + field: &str, + binding: &ResolvedBinding, + registry: &BindingRegistry, + diagnostics: &mut Vec, +) -> bool { + if binding.trigger.is_prefix() && registry.prefix_rhs_is_reserved(binding.trigger.combo()) { + let diag = format!( + "reserved keybinding: {field} = {:?} uses keys.prefix as the prefix-mode key; pressing the prefix twice sends a literal prefix key, so this binding is disabled", + binding.label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } + + if let Some(first_field) = registry.conflict(binding) { + let diag = format!( + "duplicate keybinding: {field} = {:?} conflicts with {}; disabling binding", + binding.label, first_field + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } - registry.register(BindingScope::Navigate, binding, &key_field); - let action = match command.action_type { - CommandKeybindType::Shell => CustomCommandAction::Shell, - CommandKeybindType::Pane => CustomCommandAction::Pane, - }; - custom_commands.push(CustomCommandKeybind { - key: binding, - label: format_key_combo(binding), - command: command.command.clone(), - action, - }); - } + if binding.trigger.is_direct() && is_unmodified_printable(binding.trigger.combo()) { + let suggestion = format!("prefix+{}", binding.label); + let diag = format!( + "unsafe direct keybinding: {field} = {:?} would intercept typing; use {:?} to require the prefix; disabling binding", + binding.label, suggestion + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + return true; + } - let keybinds = Keybinds { - new_workspace: bindings[0].value, - new_workspace_label: bindings[0].label.clone(), - rename_workspace: bindings[1].value, - rename_workspace_label: bindings[1].label.clone(), - close_workspace: bindings[2].value, - close_workspace_label: bindings[2].label.clone(), - detach: optional_bindings[0].value, - detach_label: optional_bindings[0].label.clone(), - reload_config: optional_bindings[1].value, - reload_config_label: optional_bindings[1].label.clone(), - open_notification_target: optional_bindings[2].value, - open_notification_target_label: optional_bindings[2].label.clone(), - previous_workspace: optional_bindings[3].value, - previous_workspace_label: optional_bindings[3].label.clone(), - next_workspace: optional_bindings[4].value, - next_workspace_label: optional_bindings[4].label.clone(), - previous_agent: optional_bindings[5].value, - previous_agent_label: optional_bindings[5].label.clone(), - next_agent: optional_bindings[6].value, - next_agent_label: optional_bindings[6].label.clone(), - indexed_tabs: indexed_bindings[0].value, - indexed_tabs_label: indexed_bindings[0].label.clone(), - indexed_workspaces: indexed_bindings[1].value, - indexed_workspaces_label: indexed_bindings[1].label.clone(), - indexed_agents: indexed_bindings[2].value, - indexed_agents_label: indexed_bindings[2].label.clone(), - new_tab: bindings[3].value, - new_tab_label: bindings[3].label.clone(), - rename_tab: optional_bindings[7].value, - rename_tab_label: optional_bindings[7].label.clone(), - previous_tab: optional_bindings[8].value, - previous_tab_label: optional_bindings[8].label.clone(), - next_tab: optional_bindings[9].value, - next_tab_label: optional_bindings[9].label.clone(), - close_tab: optional_bindings[10].value, - close_tab_label: optional_bindings[10].label.clone(), - rename_pane: optional_bindings[11].value, - rename_pane_label: optional_bindings[11].label.clone(), - edit_scrollback: optional_bindings[12].value, - edit_scrollback_label: optional_bindings[12].label.clone(), - focus_pane_left: optional_bindings[13].value, - focus_pane_left_label: optional_bindings[13].label.clone(), - focus_pane_down: optional_bindings[14].value, - focus_pane_down_label: optional_bindings[14].label.clone(), - focus_pane_up: optional_bindings[15].value, - focus_pane_up_label: optional_bindings[15].label.clone(), - focus_pane_right: optional_bindings[16].value, - focus_pane_right_label: optional_bindings[16].label.clone(), - split_vertical: bindings[4].value, - split_vertical_label: bindings[4].label.clone(), - split_horizontal: bindings[5].value, - split_horizontal_label: bindings[5].label.clone(), - close_pane: bindings[6].value, - close_pane_label: bindings[6].label.clone(), - zoom: bindings[7].value, - zoom_label: bindings[7].label.clone(), - resize_mode: bindings[8].value, - resize_mode_label: bindings[8].label.clone(), - toggle_sidebar: bindings[9].value, - toggle_sidebar_label: bindings[9].label.clone(), - custom_commands, - }; + false +} - (prefix_diag, prefix, diagnostics, keybinds) +fn parse_binding_string(raw: &str) -> Option { + let trimmed = raw.trim(); + let (trigger_prefix, body) = if let Some(rest) = trimmed.strip_prefix("prefix+") { + (true, rest) + } else { + (false, trimmed) + }; + + if let Some(range_modifiers) = parse_range_modifiers(body) { + let bindings = (1..=9) + .map(|idx| { + let combo = ( + KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')), + range_modifiers, + ); + let key_label = format_key_combo(combo); + ResolvedBinding { + trigger: if trigger_prefix { + BindingTrigger::Prefix(combo) + } else { + BindingTrigger::Direct(combo) + }, + label: if trigger_prefix { + format!("prefix+{key_label}") + } else { + key_label + }, + } + }) + .collect(); + return Some(ParsedBinding::Range(bindings)); } + + let combo = parse_key_combo(body)?; + let label = if trigger_prefix { + format!("prefix+{}", format_key_combo(combo)) + } else { + format_key_combo(combo) + }; + Some(ParsedBinding::Single(ResolvedBinding { + trigger: if trigger_prefix { + BindingTrigger::Prefix(combo) + } else { + BindingTrigger::Direct(combo) + }, + label, + })) } -pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { +pub fn format_key_combo(binding: KeyCombo) -> String { let (code, modifiers) = binding; let mut parts = Vec::new(); if modifiers.contains(KeyModifiers::CONTROL) { @@ -813,7 +700,7 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { if modifiers.contains(KeyModifiers::ALT) { parts.push("alt".to_string()); } - if modifiers.contains(KeyModifiers::SHIFT) { + if modifiers.contains(KeyModifiers::SHIFT) && !matches!(code, KeyCode::BackTab) { parts.push("shift".to_string()); } if modifiers.contains(KeyModifiers::SUPER) { @@ -846,7 +733,7 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String { return if parts.is_empty() { key } else { - format!("{}+tab", parts.join("+")) + format!("{}+{key}", parts.join("+")) }; } @@ -873,6 +760,23 @@ fn parse_modifier_token(token: &str) -> Option { } } +fn parse_range_modifiers(s: &str) -> Option { + let mut modifiers = KeyModifiers::empty(); + let mut saw_range = false; + for part in s.split('+') { + let trimmed = part.trim(); + if trimmed == "1..9" { + if saw_range { + return None; + } + saw_range = true; + } else { + modifiers |= parse_modifier_token(trimmed)?; + } + } + saw_range.then_some(modifiers) +} + fn parse_modifier_combo(s: &str) -> Option { let mut modifiers = KeyModifiers::empty(); let parts: Vec<&str> = s.split('+').collect(); @@ -895,7 +799,7 @@ fn parse_modifier_combo(s: &str) -> Option { } } -pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { +pub(super) fn parse_key_combo(s: &str) -> Option { let parts: Vec<&str> = s.split('+').collect(); let mut modifiers = KeyModifiers::empty(); let mut key_str: Option<&str> = None; @@ -907,27 +811,42 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { } if let Some(modifier) = parse_modifier_token(trimmed) { modifiers |= modifier; + } else if key_str.is_some() { + return None; } else { - if key_str.is_some() { - return None; - } key_str = Some(trimmed); } } let key_str = key_str?; - let lower = key_str.to_lowercase(); let code = match lower.as_str() { "space" | " " => KeyCode::Char(' '), "enter" | "return" => KeyCode::Enter, "esc" | "escape" => KeyCode::Esc, + "tab" if modifiers.contains(KeyModifiers::SHIFT) => { + modifiers.remove(KeyModifiers::SHIFT); + KeyCode::BackTab + } "tab" => KeyCode::Tab, "backspace" | "bs" => KeyCode::Backspace, "left" => KeyCode::Left, "right" => KeyCode::Right, "up" => KeyCode::Up, "down" => KeyCode::Down, + "minus" => KeyCode::Char('-'), + "comma" => KeyCode::Char(','), + "period" => KeyCode::Char('.'), + "slash" => KeyCode::Char('/'), + "backslash" => KeyCode::Char('\\'), + "quote" => KeyCode::Char('\''), + "double_quote" | "double-quote" => KeyCode::Char('"'), + "semicolon" => KeyCode::Char(';'), + "colon" => KeyCode::Char(':'), + "percent" => KeyCode::Char('%'), + "ampersand" => KeyCode::Char('&'), + "backtick" => KeyCode::Char('`'), + "plus" => KeyCode::Char('+'), s if s.len() == 1 => { let ch = key_str.chars().next().unwrap(); if ch.is_ascii_uppercase() { @@ -941,14 +860,14 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { _ => return None, }; - Some((code, modifiers)) + Some(normalize_key_combo((code, modifiers))) } fn parse_key_combo_with_diagnostic( s: &str, field: &str, - fallback: (KeyCode, KeyModifiers), -) -> ((KeyCode, KeyModifiers), Option) { + fallback: KeyCombo, +) -> (KeyCombo, Option) { match parse_key_combo(s) { Some(binding) => (binding, None), None => { @@ -959,489 +878,426 @@ fn parse_key_combo_with_diagnostic( } } +pub fn normalize_key_combo((mut code, mut modifiers): KeyCombo) -> KeyCombo { + if matches!(code, KeyCode::Tab) && modifiers.contains(KeyModifiers::SHIFT) { + code = KeyCode::BackTab; + modifiers.remove(KeyModifiers::SHIFT); + } else if matches!(code, KeyCode::BackTab) { + modifiers.remove(KeyModifiers::SHIFT); + } + (code, modifiers) +} + #[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; +pub fn key_event_matches_combo(key: &KeyEvent, combo: KeyCombo) -> bool { + key_parts_match_combo(key.code, key.modifiers, None, combo) +} - #[test] - fn parse_simple_char() { - assert_eq!( - parse_key_combo("v"), - Some((KeyCode::Char('v'), KeyModifiers::empty())) - ); - } +pub fn terminal_key_matches_combo(key: TerminalKey, combo: KeyCombo) -> bool { + key_parts_match_combo(key.code, key.modifiers, key.shifted_codepoint, combo) +} - #[test] - fn parse_ctrl_combo() { - assert_eq!( - parse_key_combo("ctrl+b"), - Some((KeyCode::Char('b'), KeyModifiers::CONTROL)) - ); +fn key_parts_match_combo( + actual_code: KeyCode, + actual_modifiers: KeyModifiers, + shifted_codepoint: Option, + combo: KeyCombo, +) -> bool { + let (actual_code, actual_modifiers) = normalize_key_combo((actual_code, actual_modifiers)); + let (expected_code, expected_modifiers) = normalize_key_combo(combo); + + if actual_modifiers == expected_modifiers + && key_codes_match(actual_code, expected_code, shifted_codepoint) + { + return true; } - #[test] - fn parse_cmd_combo() { - assert_eq!( - parse_key_combo("cmd+1"), - Some((KeyCode::Char('1'), KeyModifiers::SUPER)) - ); + let actual_without_shift = actual_modifiers.difference(KeyModifiers::SHIFT); + actual_modifiers.contains(KeyModifiers::SHIFT) + && actual_without_shift == expected_modifiers + && shifted_char_matches_expected(actual_code, shifted_codepoint, expected_code) +} + +fn key_codes_match(actual: KeyCode, expected: KeyCode, shifted_codepoint: Option) -> bool { + match (actual, expected) { + (KeyCode::Char(actual), KeyCode::Char(expected)) + if actual.is_ascii_alphabetic() && expected.is_ascii_alphabetic() => + { + actual.eq_ignore_ascii_case(&expected) + } + (KeyCode::Char(actual), KeyCode::Char(expected)) => { + actual == expected + || shifted_char_matches_expected( + KeyCode::Char(actual), + shifted_codepoint, + KeyCode::Char(expected), + ) + } + (actual, expected) => actual == expected, } +} - #[test] - fn parse_modifier_combo_for_indexed_bindings() { - assert_eq!( - parse_modifier_combo("ctrl+shift"), - Some(KeyModifiers::CONTROL | KeyModifiers::SHIFT) - ); - assert_eq!(parse_modifier_combo("1"), None); +fn shifted_char_matches_expected( + actual_code: KeyCode, + shifted_codepoint: Option, + expected_code: KeyCode, +) -> bool { + let KeyCode::Char(expected) = expected_code else { + return false; + }; + if shifted_codepoint.and_then(char::from_u32) == Some(expected) { + return true; } + matches!(actual_code, KeyCode::Char(actual) if actual == expected && is_shifted_punctuation(expected)) +} - #[test] - fn parse_special_key() { - assert_eq!( - parse_key_combo("enter"), - Some((KeyCode::Enter, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("tab"), - Some((KeyCode::Tab, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("esc"), - Some((KeyCode::Esc, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("left"), - Some((KeyCode::Left, KeyModifiers::empty())) - ); - assert_eq!( - parse_key_combo("alt+right"), - Some((KeyCode::Right, KeyModifiers::ALT)) - ); +fn is_shifted_punctuation(ch: char) -> bool { + matches!( + ch, + '!' | '@' + | '#' + | '$' + | '%' + | '^' + | '&' + | '*' + | '(' + | ')' + | '_' + | '+' + | '{' + | '}' + | '|' + | ':' + | '"' + | '<' + | '>' + | '?' + | '~' + ) +} + +fn is_unmodified_printable(combo: KeyCombo) -> bool { + matches!(combo.0, KeyCode::Char(ch) if !ch.is_control()) + && combo.1.difference(KeyModifiers::SHIFT).is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{config::Config, input::TerminalKey}; + + fn binding_triggers(bindings: &ActionKeybinds) -> Vec { + bindings + .bindings + .iter() + .map(|binding| binding.trigger) + .collect() } #[test] - fn parse_ctrl_shift() { + fn parse_simple_char_combo() { assert_eq!( - parse_key_combo("ctrl+shift+a"), - Some(( - KeyCode::Char('a'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT - )) + parse_key_combo("v"), + Some((KeyCode::Char('v'), KeyModifiers::empty())) ); } #[test] - fn parse_f_key() { + fn parse_shift_tab_as_backtab() { assert_eq!( - parse_key_combo("f5"), - Some((KeyCode::F(5), KeyModifiers::empty())) + parse_key_combo("shift+tab"), + Some((KeyCode::BackTab, KeyModifiers::empty())) ); } #[test] - fn parse_punctuation_key() { + fn parse_named_punctuation() { assert_eq!( - parse_key_combo("ctrl+`"), - Some((KeyCode::Char('`'), KeyModifiers::CONTROL)) + parse_key_combo("minus"), + Some((KeyCode::Char('-'), KeyModifiers::empty())) ); - } - - #[test] - fn uppercase_char_implies_shift() { assert_eq!( - parse_key_combo("D"), - Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) + parse_key_combo("comma"), + Some((KeyCode::Char(','), KeyModifiers::empty())) ); - } - - #[test] - fn explicit_shift_and_uppercase_do_not_double_apply_shift() { assert_eq!( - parse_key_combo("shift+D"), - Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) + parse_key_combo("ampersand"), + Some((KeyCode::Char('&'), KeyModifiers::empty())) ); } #[test] - fn invalid_keybinding_is_rejected() { - assert_eq!(parse_key_combo("ctrl+foo+bar"), None); - assert_eq!(parse_key_combo("ctrl+"), None); - } - - #[test] - fn default_keybinds_parse() { - let config = Config::default(); + fn prefix_binding_is_not_direct_binding() { + let config: Config = toml::from_str( + r#" +[keys] +next_tab = "prefix+n" +"#, + ) + .unwrap(); let kb = config.keybinds(); - assert_eq!(kb.new_workspace.0, KeyCode::Char('n')); assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) + binding_triggers(&kb.next_tab), + vec![BindingTrigger::Prefix(( + KeyCode::Char('n'), + KeyModifiers::empty() + ))] ); - assert_eq!( - kb.close_workspace, - (KeyCode::Char('d'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.detach, None); - assert_eq!(kb.previous_agent, None); - assert_eq!(kb.next_agent, None); - assert_eq!(kb.indexed_tabs, None); - assert_eq!(kb.indexed_workspaces, None); - assert_eq!(kb.indexed_agents, None); - assert_eq!(kb.split_vertical.0, KeyCode::Char('v')); - assert_eq!(kb.split_horizontal.0, KeyCode::Char('-')); - assert_eq!(kb.close_pane.0, KeyCode::Char('x')); - assert_eq!(kb.edit_scrollback, None); - assert_eq!(kb.zoom.0, KeyCode::Char('f')); - assert_eq!(kb.resize_mode.0, KeyCode::Char('r')); - assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b')); - assert!(kb.custom_commands.is_empty()); } #[test] - fn custom_keybinds_from_toml() { - let toml = r#" + fn array_bindings_allow_prefix_and_modified_direct() { + let config: Config = toml::from_str( + r#" [keys] -prefix = "ctrl+a" -new_workspace = "c" -rename_workspace = "shift+r" -close_workspace = "ctrl+d" -split_vertical = "s" -split_horizontal = "shift+s" -close_pane = "ctrl+w" -zoom = "z" -resize_mode = "ctrl+r" -toggle_sidebar = "tab" -previous_agent = "alt+a" -next_agent = "alt+d" -edit_scrollback = "e" -focus_pane_left = "alt+h" -focus_pane_right = "alt+right" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let (code, mods) = config.prefix_key(); - assert_eq!(code, KeyCode::Char('a')); - assert_eq!(mods, KeyModifiers::CONTROL); - +next_tab = ["prefix+n", "ctrl+alt+]"] +"#, + ) + .unwrap(); let kb = config.keybinds(); assert_eq!( - kb.new_workspace, - (KeyCode::Char('c'), KeyModifiers::empty()) - ); - assert_eq!( - kb.rename_workspace, - (KeyCode::Char('r'), KeyModifiers::SHIFT) - ); - assert_eq!( - kb.close_workspace, - (KeyCode::Char('d'), KeyModifiers::CONTROL) - ); - assert_eq!(kb.split_vertical.0, KeyCode::Char('s')); - assert_eq!( - kb.split_horizontal, - (KeyCode::Char('s'), KeyModifiers::SHIFT) - ); - assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL)); - assert_eq!(kb.zoom.0, KeyCode::Char('z')); - assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL)); - assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty())); - assert_eq!( - kb.previous_agent, - Some((KeyCode::Char('a'), KeyModifiers::ALT)) - ); - assert_eq!(kb.next_agent, Some((KeyCode::Char('d'), KeyModifiers::ALT))); - assert_eq!( - kb.edit_scrollback, - Some((KeyCode::Char('e'), KeyModifiers::empty())) - ); - assert_eq!( - kb.focus_pane_left, - Some((KeyCode::Char('h'), KeyModifiers::ALT)) - ); - assert_eq!( - kb.focus_pane_right, - Some((KeyCode::Right, KeyModifiers::ALT)) + binding_triggers(&kb.next_tab), + vec![ + BindingTrigger::Prefix((KeyCode::Char('n'), KeyModifiers::empty())), + BindingTrigger::Direct(( + KeyCode::Char(']'), + KeyModifiers::CONTROL | KeyModifiers::ALT + )), + ] ); - assert_eq!(kb.focus_pane_down, None); - assert_eq!(kb.focus_pane_up, None); + assert_eq!(kb.next_tab.prefix_rhs_label().as_deref(), Some("n")); } #[test] - fn legacy_fullscreen_keybind_alias_maps_to_zoom() { - let toml = r#" + fn unsafe_direct_printable_binding_is_disabled_with_diagnostic() { + let config: Config = toml::from_str( + r#" [keys] -fullscreen = "z" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - assert_eq!(kb.zoom.0, KeyCode::Char('z')); +new_tab = "c" +close_tab = "X" +"#, + ) + .unwrap(); + let diagnostics = config.collect_diagnostics(); + let keybinds = config.keybinds(); + assert!(keybinds.new_tab.bindings.is_empty()); + assert!(keybinds.close_tab.bindings.is_empty()); + assert!( + diagnostics + .iter() + .any(|diag| diag.contains("unsafe direct keybinding") + && diag.contains("keys.new_tab")) + ); + assert!(diagnostics.iter().any( + |diag| diag.contains("unsafe direct keybinding") && diag.contains("keys.close_tab") + )); } #[test] - fn uppercase_keybind_from_toml_flows_into_shift_combo() { - let toml = r#" -[keys] -close_pane = "X" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); - assert_eq!(kb.close_pane, (KeyCode::Char('x'), KeyModifiers::SHIFT)); + fn shifted_letter_binding_matches_uppercase_key_event() { + let bindings = ActionKeybinds::prefix("shift+n"); + assert!(bindings.matches_prefix(&KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT))); } #[test] - fn invalid_keybinding_produces_diagnostic_and_falls_back() { - let toml = r#" -[keys] -rename_workspace = "wat" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("keys.rename_workspace"))); + fn shifted_tab_inputs_match_backtab_canonical_binding() { + let bindings = ActionKeybinds::prefix("shift+tab"); + assert!( + bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::empty())) + ); + assert!( + bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::SHIFT)) + ); + assert!(bindings.matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT))); + assert!(!ActionKeybinds::prefix("tab") + .matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT))); assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) + normalize_key_combo((KeyCode::Tab, KeyModifiers::CONTROL | KeyModifiers::SHIFT)), + (KeyCode::BackTab, KeyModifiers::CONTROL) ); - assert_eq!(kb.rename_workspace_label, "shift+n"); } #[test] - fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() { - let toml = r#" -[keys] -new_workspace = "g" -rename_workspace = "g" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("duplicate keybinding"))); + fn format_modified_backtab_keeps_shift_label() { assert_eq!( - kb.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) + format_key_combo((KeyCode::BackTab, KeyModifiers::CONTROL)), + "ctrl+shift+tab" ); assert_eq!( - kb.rename_workspace, - (KeyCode::Char('n'), KeyModifiers::SHIFT) + format_key_combo((KeyCode::BackTab, KeyModifiers::CONTROL | KeyModifiers::ALT)), + "ctrl+alt+shift+tab" ); - assert_eq!(kb.rename_workspace_label, "shift+n"); } #[test] - fn duplicate_optional_keybinding_is_disabled_with_diagnostic() { - let toml = r#" -[keys] -new_workspace = "g" -rename_tab = "g" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics - .iter() - .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding"))); - assert_eq!( - kb.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - assert_eq!(kb.rename_tab, None); + fn shifted_punctuation_matches_enhanced_input() { + let help = ActionKeybinds::prefix("?"); + assert!(help.matches_prefix_key(TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT))); + assert!(help.matches_prefix_key( + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_shifted_codepoint('?' as u32) + )); + + let bang = ActionKeybinds::prefix("!"); + assert!(bang.matches_prefix_key( + TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) + .with_shifted_codepoint('!' as u32) + )); } #[test] - fn custom_command_keybinds_parse_from_toml() { - let toml = r#" -[[keys.command]] -key = "g" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); + fn prefix_rhs_equal_to_configured_prefix_is_rejected() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +help = "prefix+ctrl+a" +"#, + ) + .unwrap(); + let diagnostics = config.collect_diagnostics(); + assert!(config.keybinds().help.bindings.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("reserved keybinding") + && diag.contains("keys.help") + && diag.contains("keys.prefix") + })); - assert_eq!(kb.custom_commands.len(), 1); - assert_eq!( - kb.custom_commands[0].key, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); - assert_eq!(kb.custom_commands[0].label, "g"); - assert_eq!(kb.custom_commands[0].command, "echo hi"); - assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell); + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +help = "prefix+ctrl+b" +"#, + ) + .unwrap(); + assert!(!config.keybinds().help.bindings.is_empty()); } #[test] - fn pane_custom_command_keybinds_parse_from_toml() { - let toml = r#" -[[keys.command]] -key = "g" -type = "pane" -command = "lazygit" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let kb = config.keybinds(); + fn custom_command_prefix_rhs_equal_to_configured_prefix_is_rejected() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+b" - assert_eq!(kb.custom_commands.len(), 1); - assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane); +[[keys.command]] +key = "prefix+ctrl+b" +command = "echo no" +"#, + ) + .unwrap(); + let diagnostics = config.collect_diagnostics(); + assert!(config.keybinds().custom_commands.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("reserved keybinding") && diag.contains("keys.command[0].key") + })); } #[test] - fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() { - let toml = r#" + fn direct_custom_printable_binding_is_rejected_as_unsafe() { + let config: Config = toml::from_str( + r#" [keys] -new_workspace = "g" [[keys.command]] key = "g" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); +command = "echo no" +"#, + ) + .unwrap(); let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate custom keybinding") - && d.contains("keys.command[0].key") - && d.contains("keys.new_workspace") + assert!(config.keybinds().custom_commands.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("unsafe direct keybinding") && diag.contains("keys.command[0].key") })); - assert!(kb.custom_commands.is_empty()); } #[test] - fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() { - let toml = r#" + fn direct_custom_binding_conflicting_with_builtin_is_disabled() { + let config: Config = toml::from_str( + r#" +[keys] +new_tab = "ctrl+alt+g" + [[keys.command]] -key = "q" -command = "echo hi" -"#; - let config: Config = toml::from_str(toml).unwrap(); +key = "ctrl+alt+g" +command = "echo no" +"#, + ) + .unwrap(); let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate custom keybinding") - && d.contains("keys.command[0].key") - && d.contains("navigate.quit") + let keybinds = config.keybinds(); + assert!(!keybinds.new_tab.bindings.is_empty()); + assert!(keybinds.custom_commands.is_empty()); + assert!(diagnostics.iter().any(|diag| { + diag.contains("duplicate keybinding") && diag.contains("keys.command[0].key") })); - assert!(kb.custom_commands.is_empty()); } #[test] - fn indexed_keybinds_parse_from_toml() { - let toml = r#" -[keys.indexed] -tabs = "ctrl" -workspaces = "ctrl+shift" -agents = "alt" -"#; - let config: Config = toml::from_str(toml).unwrap(); + fn prefixed_indexed_bindings_support_modifiers() { + let config: Config = toml::from_str( + r#" +[keys] +switch_workspace = "prefix+shift+1..9" +"#, + ) + .unwrap(); let kb = config.keybinds(); - - assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL)); + assert_eq!(kb.switch_workspace.len(), 9); assert_eq!( - kb.indexed_workspaces, - Some(KeyModifiers::CONTROL | KeyModifiers::SHIFT) + kb.switch_workspace[0].trigger, + BindingTrigger::Prefix((KeyCode::Char('1'), KeyModifiers::SHIFT)) ); - assert_eq!(kb.indexed_agents, Some(KeyModifiers::ALT)); - assert_eq!(kb.indexed_tabs_label.as_deref(), Some("ctrl+1..9")); - } - - #[test] - fn indexed_keybinding_conflict_disables_later_family() { - let toml = r#" -[keys.indexed] -tabs = "ctrl" -workspaces = "ctrl" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL)); - assert_eq!(kb.indexed_workspaces, None); - assert!(diagnostics.iter().any(|d| { - d.contains("duplicate indexed keybinding") - && d.contains("keys.indexed.workspaces") - && d.contains("keys.indexed.tabs") - })); + assert_eq!(kb.switch_workspace[0].label, "prefix+shift+1"); } #[test] - fn terminal_direct_keybinding_conflict_disables_later_binding() { - let toml = r#" -[keys] -previous_tab = "alt+h" -focus_pane_left = "alt+h" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - + fn default_keymap_is_prefix_first_and_tab_centered() { + let kb = Config::default().keybinds(); assert_eq!( - kb.previous_tab, - Some((KeyCode::Char('h'), KeyModifiers::ALT)) + binding_triggers(&kb.next_tab), + vec![BindingTrigger::Prefix(( + KeyCode::Char('n'), + KeyModifiers::empty() + ))] ); - assert_eq!(kb.focus_pane_left, None); - assert!(diagnostics + assert_eq!( + binding_triggers(&kb.previous_tab), + vec![BindingTrigger::Prefix(( + KeyCode::Char('p'), + KeyModifiers::empty() + ))] + ); + assert_eq!(kb.switch_tab.len(), 9); + assert!(kb + .switch_tab .iter() - .any(|d| { d.contains("keys.focus_pane_left") && d.contains("keys.previous_tab") })); - } - - #[test] - fn terminal_direct_keybinding_conflicting_with_prefix_is_disabled() { - let toml = r#" -[keys] -prefix = "ctrl+b" -previous_tab = "ctrl+b" -"#; - let config: Config = toml::from_str(toml).unwrap(); - let diagnostics = config.collect_diagnostics(); - let kb = config.keybinds(); - - assert_eq!(kb.previous_tab, None); - assert!(diagnostics + .all(|binding| binding.trigger.is_prefix())); + assert!(kb + .new_tab + .bindings .iter() - .any(|d| { d.contains("keys.previous_tab") && d.contains("keys.prefix") })); + .all(|binding| binding.trigger.is_prefix())); } #[test] - fn live_keybinds_reject_invalid_keybinding() { + fn duplicate_prefix_binding_disables_later_binding() { let config: Config = toml::from_str( r#" [keys] -rename_workspace = "wat" +next_tab = "prefix+n" +new_workspace = "prefix+n" "#, ) .unwrap(); - - let diagnostics = config.live_keybinds().unwrap_err(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + assert!(kb.next_tab.bindings.is_empty() || kb.new_workspace.bindings.is_empty()); assert!(diagnostics .iter() - .any(|d| d.contains("keys.rename_workspace"))); - } - - #[test] - fn live_keybinds_ignore_non_key_diagnostics() { - let config: Config = toml::from_str( - r#" -[keys] -new_workspace = "g" - -[ui.sound] -done_path = "sounds/missing.mp3" -"#, - ) - .unwrap(); - - let live = config.live_keybinds().unwrap(); - assert_eq!( - live.keybinds.new_workspace, - (KeyCode::Char('g'), KeyModifiers::empty()) - ); + .any(|diag| diag.contains("duplicate keybinding"))); } } diff --git a/src/config/model.rs b/src/config/model.rs index ff3a7b22..10e80417 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Deserializer, Serialize}; -use super::{CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES}; +use super::{ + BindingConfig, CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "lowercase")] @@ -90,63 +92,81 @@ pub struct LoadedConfig { #[derive(Debug, Deserialize)] #[serde(default)] pub struct KeysConfig { - /// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc"). + /// Prefix key to enter prefix mode (e.g. "ctrl+b", "f12", "esc"). pub prefix: String, - /// Create a new workspace. Default: "n" - pub new_workspace: String, - /// Rename the selected workspace. Default: "shift+n" - pub rename_workspace: String, - /// Close the selected workspace. Default: "shift+d" - pub close_workspace: String, - /// Optional explicit detach shortcut in server/client mode. Unset by default. - pub detach: String, - /// Reload config.toml in the running app/server. Unset by default. - pub reload_config: String, - /// Focus the currently visible notification target. Unset by default. - pub open_notification_target: String, + /// Open keybinding help. Default: "prefix+?" + pub help: BindingConfig, + /// Open settings. Default: "prefix+s" + pub settings: BindingConfig, + /// Quit or detach. Default: "prefix+q" + pub quit: BindingConfig, + /// Create a new workspace. Default: "prefix+shift+n" + pub new_workspace: BindingConfig, + /// Rename the selected workspace. Default: "prefix+shift+w" + pub rename_workspace: BindingConfig, + /// Close the selected workspace. Default: "prefix+shift+d" + pub close_workspace: BindingConfig, + /// Open the workspace navigation surface. Default: "prefix+w" + pub workspace_picker: BindingConfig, + /// Optional explicit detach shortcut in server/client mode. Default: "prefix+d". + pub detach: BindingConfig, + /// Reload config.toml in the running app/server. Default: "prefix+shift+r". + pub reload_config: BindingConfig, + /// Focus the currently visible notification target. Default: "prefix+o". + pub open_notification_target: BindingConfig, /// Select the previous workspace. Unset by default. - pub previous_workspace: String, + pub previous_workspace: BindingConfig, /// Select the next workspace. Unset by default. - pub next_workspace: String, + pub next_workspace: BindingConfig, /// Focus the previous agent shown in the agent panel. Unset by default. - pub previous_agent: String, + pub previous_agent: BindingConfig, /// Focus the next agent shown in the agent panel. Unset by default. - pub next_agent: String, - /// Create a new tab in the active workspace. Default: "c" - pub new_tab: String, - /// Rename the active tab. Unset by default. - pub rename_tab: String, - /// Select the previous tab. Unset by default. - pub previous_tab: String, - /// Select the next tab. Unset by default. - pub next_tab: String, - /// Close the active tab. Unset by default. - pub close_tab: String, - /// Rename the focused pane. Unset by default. - pub rename_pane: String, - /// Open the focused pane scrollback in $EDITOR. Unset by default. - pub edit_scrollback: String, - /// Focus the pane to the left in terminal mode. Unset by default. - pub focus_pane_left: String, - /// Focus the pane below in terminal mode. Unset by default. - pub focus_pane_down: String, - /// Focus the pane above in terminal mode. Unset by default. - pub focus_pane_up: String, - /// Focus the pane to the right in terminal mode. Unset by default. - pub focus_pane_right: String, - /// Split pane vertically (side by side). Default: "v" - pub split_vertical: String, - /// Split pane horizontally (stacked). Default: "-" - pub split_horizontal: String, - /// Close the focused pane. Default: "x" - pub close_pane: String, - /// Toggle zoom for the focused pane. Default: "f" + pub next_agent: BindingConfig, + /// Focus an agent by index 1-9. Unset by default. + pub focus_agent: BindingConfig, + /// Create a new tab in the active workspace. Default: "prefix+c" + pub new_tab: BindingConfig, + /// Rename the active tab. Default: "prefix+shift+t". + pub rename_tab: BindingConfig, + /// Select the previous tab. Default: "prefix+p". + pub previous_tab: BindingConfig, + /// Select the next tab. Default: "prefix+n". + pub next_tab: BindingConfig, + /// Switch to tab 1-9. Default: "prefix+1..9". + pub switch_tab: BindingConfig, + /// Switch to workspace 1-9 from prefix mode. Unset by default. + pub switch_workspace: BindingConfig, + /// Close the active tab. Default: "prefix+shift+x". + pub close_tab: BindingConfig, + /// Rename the focused pane. Default: "prefix+shift+p". + pub rename_pane: BindingConfig, + /// Open the focused pane scrollback in $EDITOR. Default: "prefix+e". + pub edit_scrollback: BindingConfig, + /// Focus the pane to the left. Default: "prefix+h". + pub focus_pane_left: BindingConfig, + /// Focus the pane below. Default: "prefix+j". + pub focus_pane_down: BindingConfig, + /// Focus the pane above. Default: "prefix+k". + pub focus_pane_up: BindingConfig, + /// Focus the pane to the right. Default: "prefix+l". + pub focus_pane_right: BindingConfig, + /// Cycle to the next pane. Default: "prefix+tab". + pub cycle_pane_next: BindingConfig, + /// Cycle to the previous pane. Default: "prefix+shift+tab". + pub cycle_pane_previous: BindingConfig, + /// Split pane vertically (side by side). Default: "prefix+v" + pub split_vertical: BindingConfig, + /// Split pane horizontally (stacked). Default: "prefix+minus" + pub split_horizontal: BindingConfig, + /// Close the focused pane. Default: "prefix+x" + pub close_pane: BindingConfig, + /// Toggle zoom for the focused pane. Default: "prefix+z" #[serde(alias = "fullscreen")] - pub zoom: String, - /// Enter resize mode. Default: "r" - pub resize_mode: String, - /// Toggle sidebar collapse. Default: "b" - pub toggle_sidebar: String, + pub zoom: BindingConfig, + /// Enter resize mode. Default: "prefix+r" + pub resize_mode: BindingConfig, + /// Toggle sidebar collapse. Default: "prefix+b" + pub toggle_sidebar: BindingConfig, /// Optional indexed shortcuts expanded over number keys 1-9. pub indexed: IndexedKeysConfig, /// Prefix-mode custom command bindings. @@ -212,33 +232,42 @@ impl Default for KeysConfig { fn default() -> Self { Self { prefix: "ctrl+b".into(), - new_workspace: "n".into(), - rename_workspace: "shift+n".into(), - close_workspace: "shift+d".into(), - detach: "".into(), - reload_config: "".into(), - open_notification_target: "".into(), - previous_workspace: "".into(), - next_workspace: "".into(), - previous_agent: "".into(), - next_agent: "".into(), - new_tab: "c".into(), - rename_tab: "".into(), - previous_tab: "".into(), - next_tab: "".into(), - close_tab: "".into(), - rename_pane: "".into(), - edit_scrollback: "".into(), - focus_pane_left: "".into(), - focus_pane_down: "".into(), - focus_pane_up: "".into(), - focus_pane_right: "".into(), - split_vertical: "v".into(), - split_horizontal: "-".into(), - close_pane: "x".into(), - zoom: "f".into(), - resize_mode: "r".into(), - toggle_sidebar: "b".into(), + help: BindingConfig::one("prefix+?"), + settings: BindingConfig::one("prefix+s"), + quit: BindingConfig::one("prefix+q"), + new_workspace: BindingConfig::one("prefix+shift+n"), + rename_workspace: BindingConfig::one("prefix+shift+w"), + close_workspace: BindingConfig::one("prefix+shift+d"), + workspace_picker: BindingConfig::one("prefix+w"), + detach: BindingConfig::one("prefix+d"), + reload_config: BindingConfig::one("prefix+shift+r"), + open_notification_target: BindingConfig::one("prefix+o"), + previous_workspace: BindingConfig::empty(), + next_workspace: BindingConfig::empty(), + previous_agent: BindingConfig::empty(), + next_agent: BindingConfig::empty(), + focus_agent: BindingConfig::empty(), + new_tab: BindingConfig::one("prefix+c"), + rename_tab: BindingConfig::one("prefix+shift+t"), + previous_tab: BindingConfig::one("prefix+p"), + next_tab: BindingConfig::one("prefix+n"), + switch_tab: BindingConfig::one("prefix+1..9"), + switch_workspace: BindingConfig::empty(), + close_tab: BindingConfig::one("prefix+shift+x"), + rename_pane: BindingConfig::one("prefix+shift+p"), + edit_scrollback: BindingConfig::one("prefix+e"), + focus_pane_left: BindingConfig::one("prefix+h"), + focus_pane_down: BindingConfig::one("prefix+j"), + focus_pane_up: BindingConfig::one("prefix+k"), + focus_pane_right: BindingConfig::one("prefix+l"), + cycle_pane_next: BindingConfig::one("prefix+tab"), + cycle_pane_previous: BindingConfig::one("prefix+shift+tab"), + split_vertical: BindingConfig::one("prefix+v"), + split_horizontal: BindingConfig::one("prefix+minus"), + close_pane: BindingConfig::one("prefix+x"), + zoom: BindingConfig::one("prefix+z"), + resize_mode: BindingConfig::one("prefix+r"), + toggle_sidebar: BindingConfig::one("prefix+b"), indexed: IndexedKeysConfig::default(), command: Vec::new(), } diff --git a/src/main.rs b/src/main.rs index 0814c09e..2120555e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,56 +81,68 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # default_shell = "" [keys] -# Prefix key to enter navigate mode (default: "ctrl+b") +# Prefix key to enter prefix mode (default: "ctrl+b") # Examples: "ctrl+b", "f12", "esc", "-" -# Accepted syntax: plain keys, ctrl/shift/alt/cmd/super modifiers, and special keys like enter/tab/esc/left/right/up/down -# Most reliable bindings are plain keys, ctrl+letter, esc/tab/enter, and function keys. +# Action bindings use explicit syntax: "prefix+n" requires the prefix; +# "ctrl+alt+n" is a direct terminal-mode shortcut. +# Accepted key syntax: plain keys, ctrl/shift/alt/cmd/super modifiers, and special keys like enter/tab/esc/left/right/up/down. +# Named punctuation such as minus, comma, ampersand, plus, and backtick is also accepted. +# Most reliable direct bindings are ctrl+letter, function keys, and explicit modified chords. # alt+..., cmd/super, and punctuation-with-modifiers may depend on your terminal/tmux setup. # prefix = "ctrl+b" -# Navigate-mode actions -# new_workspace = "n" -# rename_workspace = "shift+n" -# close_workspace = "shift+d" +# Prefix-mode actions +# help = "prefix+?" +# settings = "prefix+s" +# quit = "prefix+q" +# detach = "prefix+d" +# reload_config = "prefix+shift+r" +# open_notification_target = "prefix+o" +# workspace_picker = "prefix+w" +# new_workspace = "prefix+shift+n" +# rename_workspace = "prefix+shift+w" +# close_workspace = "prefix+shift+d" # previous_workspace = "" # optional, unset by default # next_workspace = "" # optional, unset by default # previous_agent = "" # optional, unset by default # next_agent = "" # optional, unset by default -# detach = "" # optional explicit detach shortcut in server/client mode -# reload_config = "" # optional shortcut to reload config.toml without restarting -# open_notification_target = "" # optional shortcut to jump to the visible notification target -# new_tab = "c" -# rename_tab = "" # optional, unset by default -# previous_tab = "" # optional, unset by default -# next_tab = "" # optional, unset by default -# close_tab = "" # optional, unset by default -# rename_pane = "" # optional, unset by default -# edit_scrollback = "" # optional, opens focused pane scrollback in $EDITOR -# focus_pane_left = "" # optional, unset by default -# focus_pane_down = "" # optional, unset by default -# focus_pane_up = "" # optional, unset by default -# focus_pane_right = "" # optional, unset by default -# split_vertical = "v" -# split_horizontal = "-" -# close_pane = "x" -# zoom = "f" # legacy alias: fullscreen -# resize_mode = "r" -# toggle_sidebar = "b" - -# Custom prefix-mode commands. Press prefix, then the configured key. +# focus_agent = "" # optional indexed binding, e.g. "prefix+alt+1..9" +# new_tab = "prefix+c" +# rename_tab = "prefix+shift+t" +# previous_tab = "prefix+p" +# next_tab = "prefix+n" +# switch_tab = "prefix+1..9" +# switch_workspace = "" # optional indexed binding, e.g. "prefix+shift+1..9" +# close_tab = "prefix+shift+x" +# rename_pane = "prefix+shift+p" +# edit_scrollback = "prefix+e" +# focus_pane_left = "prefix+h" +# focus_pane_down = "prefix+j" +# focus_pane_up = "prefix+k" +# focus_pane_right = "prefix+l" +# cycle_pane_next = "prefix+tab" +# cycle_pane_previous = "prefix+shift+tab" +# split_vertical = "prefix+v" +# split_horizontal = "prefix+minus" +# close_pane = "prefix+x" +# zoom = "prefix+z" # legacy alias: fullscreen +# resize_mode = "prefix+r" +# toggle_sidebar = "prefix+b" + +# Custom commands use the same binding syntax. # type = "shell" runs detached in the background. # type = "pane" opens a temporary pane and closes it when the command exits. # [[keys.command]] -# key = "g" +# key = "prefix+g" # type = "pane" # command = "lazygit" -# Optional modifier-only shortcuts expanded over number keys 1-9. -# Empty means disabled. Examples: "ctrl", "ctrl+shift", "alt". +# Legacy indexed shortcut config is still parsed for compatibility. +# Prefer switch_tab, switch_workspace, and focus_agent for new configs. # [keys.indexed] -# tabs = "" # e.g. "ctrl" makes ctrl+1..9 switch tabs -# workspaces = "" # e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces -# agents = "" # e.g. "alt" makes alt+1..9 focus agent rows +# tabs = "" # e.g. "ctrl" makes ctrl+1..9 switch tabs directly +# workspaces = "" # e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces directly +# agents = "" # e.g. "alt" makes alt+1..9 focus agent rows directly [ui] # Sidebar width (auto-scaled based on workspace names, this sets the default) @@ -304,6 +316,7 @@ fn main() -> io::Result<()> { println!(" herdr update"); println!(" herdr server stop"); println!(" herdr server reload-config"); + println!(" herdr config ..."); println!(" herdr workspace ..."); println!(" herdr tab ..."); println!(" herdr agent ..."); @@ -328,6 +341,10 @@ fn main() -> io::Result<()> { "herdr server reload-config", "Reload config.toml in the running server", ), + ( + "herdr config reset-keys", + "Back up config.toml and remove custom keybindings", + ), ( "herdr workspace ", "Workspace helpers over the socket API", @@ -409,6 +426,7 @@ fn main() -> io::Result<()> { "remote-client-bridge", "update", "status", + "config", "workspace", "pane", "wait", diff --git a/src/remote.rs b/src/remote.rs index 5ab7e6fd..089772b4 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -284,6 +284,7 @@ fn prepare_remote_herdr(target: &str) -> io::Result { ))); } warn_if_remote_bin_not_on_path(target)?; + maybe_copy_local_keybindings_to_remote(target, &remote_herdr)?; Ok(remote_herdr) } @@ -652,6 +653,131 @@ fn confirm_remote_install( Ok(()) } +fn maybe_copy_local_keybindings_to_remote( + target: &str, + remote_herdr: &RemoteHerdr, +) -> io::Result<()> { + let Some(config_toml) = local_keybindings_config_toml()? else { + return Ok(()); + }; + let remote_config_path = remote_config_path(target, remote_herdr)?; + if remote_path_exists(target, &remote_config_path)? { + return Ok(()); + } + if !confirm_remote_keybindings_copy(target)? { + return Ok(()); + } + upload_remote_config(target, &remote_config_path, config_toml.as_bytes()) +} + +fn local_keybindings_config_toml() -> io::Result> { + let path = crate::config::config_path(); + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path)?; + Ok(local_keybindings_config_toml_from_str(&content)) +} + +fn local_keybindings_config_toml_from_str(content: &str) -> Option { + let mut value = content.parse::().ok()?; + let root = value.as_table_mut()?; + let mut keys = root.remove("keys")?.as_table()?.clone(); + keys.remove("command"); + if keys.is_empty() { + return None; + } + + let mut out = toml::map::Map::new(); + out.insert("keys".to_string(), toml::Value::Table(keys)); + toml::to_string_pretty(&toml::Value::Table(out)).ok() +} + +fn remote_config_path(target: &str, remote_herdr: &RemoteHerdr) -> io::Result { + let command = format!("{} --help", remote_herdr.shell_path); + let output = ssh_output(target, &command)?; + if !output.status.success() { + return Err(command_failed("remote config path probe failed", &output)); + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .find_map(|line| line.trim().strip_prefix("Config: ").map(str::to_string)) + .ok_or_else(|| io::Error::other("remote config path probe did not print a Config line")) +} + +fn remote_path_exists(target: &str, path: &str) -> io::Result { + let command = format!("test -e {}", shell_quote(path)); + let output = ssh_output(target, &command)?; + Ok(output.status.success()) +} + +fn confirm_remote_keybindings_copy(target: &str) -> io::Result { + if !io::stdin().is_terminal() { + return Ok(false); + } + eprintln!("remote Herdr config is not present on {target}."); + eprintln!( + "Herdr can copy your local [keys] settings so the remote server uses the same keybindings." + ); + eprintln!("Custom command keybindings are not copied because they run on the remote host."); + eprint!("Copy local Herdr keybindings to {target}? [Y/n] "); + io::stderr().flush()?; + + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_ascii_lowercase(); + Ok(!(answer == "n" || answer == "no")) +} + +fn upload_remote_config(target: &str, path: &str, content: &[u8]) -> io::Result<()> { + let script = format!( + r#"dest={} +dir="${{dest%/*}}" +mkdir -p "$dir" +umask 077 +tmp="${{dest}}.tmp.$$" +cat > "$tmp" +mv "$tmp" "$dest" +"#, + shell_quote(path) + ); + + let mut child = Command::new("ssh") + .arg("-T") + .arg(target) + .arg(format!("sh -eu -c {}", shell_quote(&script))) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|err| { + io::Error::new( + err.kind(), + format!("failed to start ssh config upload: {err}"), + ) + })?; + + let copy_result = if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(content) + } else { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "ssh config upload stdin missing", + )) + }; + let status = child.wait()?; + copy_result?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!( + "remote config upload exited with {status}" + ))) + } +} + fn install_remote_herdr( target: &str, remote_herdr: &RemoteHerdr, @@ -1095,6 +1221,43 @@ mod tests { ); } + #[test] + fn local_keybindings_config_extracts_only_keys_without_commands() { + let toml = r#" +[theme] +name = "one-dark" + +[keys] +prefix = "ctrl+a" +new_tab = ["prefix+c", "ctrl+alt+n"] +next_tab = "prefix+n" + +[keys.indexed] +tabs = "ctrl" + +[[keys.command]] +key = "prefix+g" +type = "pane" +command = "lazygit" +"#; + + let copied = local_keybindings_config_toml_from_str(toml).expect("copied key config"); + assert!(copied.contains("[keys]")); + assert!(copied.contains("prefix = \"ctrl+a\"")); + assert!(copied.contains("prefix+c")); + assert!(copied.contains("ctrl+alt+n")); + assert!(copied.contains("[keys.indexed]")); + assert!(copied.contains("tabs = \"ctrl\"")); + assert!(!copied.contains("one-dark")); + assert!(!copied.contains("lazygit")); + assert!(!copied.contains("[[keys.command]]")); + } + + #[test] + fn local_keybindings_config_returns_none_without_keys() { + assert!(local_keybindings_config_toml_from_str("[theme]\nname = \"one-dark\"\n").is_none()); + } + #[test] fn remote_bridge_command_uses_installed_binary() { let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { diff --git a/src/ui.rs b/src/ui.rs index ed52660e..050cf194 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -23,7 +23,7 @@ use self::dialogs::{render_confirm_close_overlay, render_rename_overlay}; use self::keybind_help::render_keybind_help_overlay; use self::menus::{ render_context_menu, render_global_launcher_menu, render_navigate_overlay, - render_resize_overlay, + render_prefix_overlay, render_resize_overlay, }; use self::mobile::{ compute_mobile_header_hit_areas, is_mobile_width, mobile_switcher_max_scroll_for_height, @@ -311,6 +311,7 @@ pub fn render(app: &AppState, frame: &mut Frame) { render_mobile_panel(app, frame, frame.area()) } Mode::Navigate => render_navigate_overlay(app, frame, terminal_area), + Mode::Prefix => render_prefix_overlay(app, frame, terminal_area), Mode::Resize => render_resize_overlay(app, frame, terminal_area), Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area), Mode::ContextMenu => { @@ -850,6 +851,22 @@ mod tests { assert_eq!(lines[0].1.spans[1].style.bg, Some(palette.surface0)); } + #[test] + fn release_notes_config_inline_code_uses_nonbreaking_spaces() { + let palette = Palette::catppuccin(); + let lines = release_notes_lines("- After: `new_tab = \"prefix+c\"`", &palette); + + assert_eq!(lines.len(), 1); + assert_eq!( + lines[0].1.spans[2].content.as_ref(), + "new_tab\u{00a0}=\u{00a0}\"prefix+c\"" + ); + assert_eq!( + line_text(&lines[0].1).replace('\u{00a0}', " "), + " • After: new_tab = \"prefix+c\"" + ); + } + #[test] fn release_notes_preview_lines_show_update_steps() { let palette = Palette::catppuccin(); @@ -895,6 +912,28 @@ mod tests { assert_eq!(line_text(&lines[2].1), "▏ second"); } + #[test] + fn prefix_mode_renders_prefix_indicator() { + let mut app = crate::app::state::AppState::test_new(); + app.mode = Mode::Prefix; + app.view.terminal_area = ratatui::layout::Rect::new(0, 0, 60, 4); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 4)) + .expect("test terminal"); + + terminal + .draw(|frame| render_prefix_overlay(&app, frame, app.view.terminal_area)) + .expect("draw prefix overlay"); + + let rendered = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("PREFIX")); + } + #[test] fn keybind_help_shows_unset_for_optional_actions() { let app = crate::app::state::AppState::test_new(); @@ -917,13 +956,19 @@ mod tests { assert!(workspace_tab.contains(&("unset".to_string(), "next workspace"))); assert!(workspace_tab.contains(&("unset".to_string(), "previous agent"))); assert!(workspace_tab.contains(&("unset".to_string(), "next agent"))); - assert!(workspace_tab.contains(&("unset".to_string(), "rename tab"))); - assert!(workspace_tab.contains(&("unset".to_string(), "previous tab"))); - assert!(workspace_tab.contains(&("unset".to_string(), "next tab"))); - assert!(workspace_tab.contains(&("unset".to_string(), "close tab"))); - assert!(panes.contains(&("unset".to_string(), "focus pane left"))); - assert!(panes.contains(&("unset".to_string(), "focus pane down"))); - assert!(panes.contains(&("unset".to_string(), "focus pane up"))); - assert!(panes.contains(&("unset".to_string(), "focus pane right"))); + assert!(workspace_tab.contains(&("unset".to_string(), "focus agent 1-9"))); + assert!(workspace_tab.contains(&("unset".to_string(), "switch workspace 1-9"))); + assert!(panes + .iter() + .any(|(key, label)| key == "prefix+h" && *label == "focus pane left")); + assert!(panes + .iter() + .any(|(key, label)| key == "prefix+j" && *label == "focus pane down")); + assert!(panes + .iter() + .any(|(key, label)| key == "prefix+k" && *label == "focus pane up")); + assert!(panes + .iter() + .any(|(key, label)| key == "prefix+l" && *label == "focus pane right")); } } diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index e7a76155..fe3d7582 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -14,8 +14,31 @@ use super::widgets::{ }; use crate::app::AppState; -fn optional_keybind_label(label: &Option) -> String { - label.clone().unwrap_or_else(|| "unset".to_string()) +fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String { + bindings.label().unwrap_or_else(|| "unset".to_string()) +} + +fn indexed_label(bindings: &[crate::config::IndexedKeybind]) -> String { + if bindings.is_empty() { + "unset".to_string() + } else if bindings.len() == 9 { + let first = &bindings[0].label; + if first.ends_with('1') { + format!("{}1..9", first.trim_end_matches('1')) + } else { + bindings + .iter() + .map(|binding| binding.label.clone()) + .collect::>() + .join(" / ") + } + } else { + bindings + .iter() + .map(|binding| binding.label.clone()) + .collect::>() + .join(" / ") + } } pub(super) fn keybind_help_groups( @@ -29,12 +52,16 @@ pub(super) fn keybind_help_groups( vec![ ( crate::config::format_key_combo((app.prefix_code, app.prefix_mods)), - "navigate mode", + "prefix mode", ), - ("prefix + ?".to_string(), "keybinds"), + (keybind_label(&kb.help), "keybinds"), + (keybind_label(&kb.settings), "settings"), + (keybind_label(&kb.quit), "quit"), + (keybind_label(&kb.detach), "detach from server"), + (keybind_label(&kb.reload_config), "reload config"), ( - optional_keybind_label(&kb.reload_config_label), - "reload config", + keybind_label(&kb.open_notification_target), + "open notification target", ), ], )); @@ -47,85 +74,47 @@ pub(super) fn keybind_help_groups( ("h j k l / arrows".to_string(), "move focus"), ("tab / shift+tab".to_string(), "cycle pane"), ("enter".to_string(), "open workspace"), - ("s".to_string(), "settings"), - ("q".to_string(), "quit"), + ("1..9".to_string(), "switch workspace"), ], )); - let mut workspace_tab = vec![ - (kb.new_workspace_label.clone(), "new workspace"), - (kb.rename_workspace_label.clone(), "rename workspace"), - (kb.close_workspace_label.clone(), "close workspace"), - ( - optional_keybind_label(&kb.open_notification_target_label), - "open notification target", - ), - ( - optional_keybind_label(&kb.previous_workspace_label), - "previous workspace", - ), - ( - optional_keybind_label(&kb.next_workspace_label), - "next workspace", - ), - ( - optional_keybind_label(&kb.indexed_workspaces_label), - "switch workspace 1-9", - ), - ( - optional_keybind_label(&kb.previous_agent_label), - "previous agent", - ), - (optional_keybind_label(&kb.next_agent_label), "next agent"), - ( - optional_keybind_label(&kb.indexed_agents_label), - "focus agent 1-9", - ), - (kb.new_tab_label.clone(), "new tab"), - (optional_keybind_label(&kb.rename_tab_label), "rename tab"), - ( - optional_keybind_label(&kb.previous_tab_label), - "previous tab", - ), - (optional_keybind_label(&kb.next_tab_label), "next tab"), - ( - optional_keybind_label(&kb.indexed_tabs_label), - "switch tab 1-9", - ), - (optional_keybind_label(&kb.close_tab_label), "close tab"), + let workspace_tab = vec![ + (keybind_label(&kb.workspace_picker), "workspace navigation"), + (keybind_label(&kb.new_workspace), "new workspace"), + (keybind_label(&kb.rename_workspace), "rename workspace"), + (keybind_label(&kb.close_workspace), "close workspace"), + (keybind_label(&kb.previous_workspace), "previous workspace"), + (keybind_label(&kb.next_workspace), "next workspace"), + (indexed_label(&kb.switch_workspace), "switch workspace 1-9"), + (keybind_label(&kb.previous_agent), "previous agent"), + (keybind_label(&kb.next_agent), "next agent"), + (indexed_label(&kb.focus_agent), "focus agent 1-9"), + (keybind_label(&kb.new_tab), "new tab"), + (keybind_label(&kb.rename_tab), "rename tab"), + (keybind_label(&kb.previous_tab), "previous tab"), + (keybind_label(&kb.next_tab), "next tab"), + (indexed_label(&kb.switch_tab), "switch tab 1-9"), + (keybind_label(&kb.close_tab), "close tab"), ]; - if let Some(label) = &kb.detach_label { - workspace_tab.insert(3, (label.clone(), "detach from server")); - } groups.push(("workspaces / tabs", workspace_tab)); let panes = vec![ - (kb.split_vertical_label.clone(), "split vertical"), - (kb.split_horizontal_label.clone(), "split horizontal"), - (kb.close_pane_label.clone(), "close pane"), - (optional_keybind_label(&kb.rename_pane_label), "rename pane"), - ( - optional_keybind_label(&kb.edit_scrollback_label), - "edit scrollback", - ), - (kb.zoom_label.clone(), "zoom pane"), - (kb.resize_mode_label.clone(), "resize mode"), - (kb.toggle_sidebar_label.clone(), "toggle sidebar"), - ( - optional_keybind_label(&kb.focus_pane_left_label), - "focus pane left", - ), - ( - optional_keybind_label(&kb.focus_pane_down_label), - "focus pane down", - ), - ( - optional_keybind_label(&kb.focus_pane_up_label), - "focus pane up", - ), + (keybind_label(&kb.split_vertical), "split vertical"), + (keybind_label(&kb.split_horizontal), "split horizontal"), + (keybind_label(&kb.close_pane), "close pane"), + (keybind_label(&kb.rename_pane), "rename pane"), + (keybind_label(&kb.edit_scrollback), "edit scrollback"), + (keybind_label(&kb.zoom), "zoom pane"), + (keybind_label(&kb.resize_mode), "resize mode"), + (keybind_label(&kb.toggle_sidebar), "toggle sidebar"), + (keybind_label(&kb.focus_pane_left), "focus pane left"), + (keybind_label(&kb.focus_pane_down), "focus pane down"), + (keybind_label(&kb.focus_pane_up), "focus pane up"), + (keybind_label(&kb.focus_pane_right), "focus pane right"), + (keybind_label(&kb.cycle_pane_next), "cycle pane next"), ( - optional_keybind_label(&kb.focus_pane_right_label), - "focus pane right", + keybind_label(&kb.cycle_pane_previous), + "cycle pane previous", ), ]; groups.push(("panes", panes)); diff --git a/src/ui/menus.rs b/src/ui/menus.rs index 7a074c68..d17aa897 100644 --- a/src/ui/menus.rs +++ b/src/ui/menus.rs @@ -9,6 +9,12 @@ use ratatui::{ use super::widgets::{panel_contrast_fg, render_panel_shell}; use crate::app::AppState; +fn prefix_rhs_label(bindings: &crate::config::ActionKeybinds) -> String { + bindings + .prefix_rhs_label() + .unwrap_or_else(|| "unset".to_string()) +} + fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui::style::Color) { frame.render_widget(Clear, area); let buf = frame.buffer_mut(); @@ -18,6 +24,38 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui: frame.render_widget(Paragraph::new(line), area); } +pub(super) fn render_prefix_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let key = Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(app.palette.overlay0); + let mode_style = Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD); + + let workspace_picker = prefix_rhs_label(&app.keybinds.workspace_picker); + let help = prefix_rhs_label(&app.keybinds.help); + let prefix = crate::config::format_key_combo((app.prefix_code, app.prefix_mods)); + + let line = Line::from(vec![ + Span::styled(" PREFIX ", mode_style), + Span::raw(" "), + Span::styled("esc", key), + Span::styled(" cancel ", dim), + Span::styled(prefix, key), + Span::styled(" send prefix ", dim), + Span::styled(workspace_picker, key), + Span::styled(" workspace nav ", dim), + Span::styled(help, key), + Span::styled(" keybinds", dim), + ]); + + let overlay_y = area.y + area.height.saturating_sub(1); + let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); + render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); +} + pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let key = Style::default() .fg(app.palette.accent) @@ -30,6 +68,15 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R .add_modifier(Modifier::BOLD); let kb = &app.keybinds; + let new_tab = prefix_rhs_label(&kb.new_tab); + let split_vertical = prefix_rhs_label(&kb.split_vertical); + let split_horizontal = prefix_rhs_label(&kb.split_horizontal); + let close_pane = prefix_rhs_label(&kb.close_pane); + let zoom = prefix_rhs_label(&kb.zoom); + let resize = prefix_rhs_label(&kb.resize_mode); + let help = prefix_rhs_label(&kb.help); + let settings = prefix_rhs_label(&kb.settings); + let quit = prefix_rhs_label(&kb.quit); let line = Line::from(vec![ Span::styled(" NAVIGATE ", mode_style), Span::raw(" "), @@ -39,23 +86,23 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R Span::styled(" ws ", dim), Span::styled("⇥", key), Span::styled(" pane ", dim), - Span::styled(kb.new_tab_label.as_str(), key), + Span::styled(new_tab, key), Span::styled(" new tab ", dim), - Span::styled(kb.split_vertical_label.as_str(), key), + Span::styled(split_vertical, key), Span::styled(" split│ ", dim), - Span::styled(kb.split_horizontal_label.as_str(), key), + Span::styled(split_horizontal, key), Span::styled(" split─ ", dim), - Span::styled(kb.close_pane_label.as_str(), key), + Span::styled(close_pane, key), Span::styled(" close ", dim), - Span::styled(kb.zoom_label.as_str(), key), + Span::styled(zoom, key), Span::styled(" zoom ", dim), - Span::styled(kb.resize_mode_label.as_str(), key), + Span::styled(resize, key), Span::styled(" resize ", dim), - Span::styled("?", key), + Span::styled(help, key), Span::styled(" keybinds ", dim), - Span::styled("s", key), + Span::styled(settings, key), Span::styled(" settings ", dim), - Span::styled("q", key), + Span::styled(quit, key), Span::styled(" quit", dim), ]); diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs index 104f737f..8829acc7 100644 --- a/src/ui/onboarding.rs +++ b/src/ui/onboarding.rs @@ -78,7 +78,7 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { .add_modifier(Modifier::BOLD), ), Span::styled( - " enters navigate mode · ", + " enters prefix mode · ", Style::default().fg(app.palette.overlay1), ), Span::styled( diff --git a/src/ui/panes.rs b/src/ui/panes.rs index d9108575..550d34ce 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -355,7 +355,10 @@ fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) { Line::from(vec![ Span::styled(" Press ", Style::default().fg(p.overlay0)), Span::styled( - app.keybinds.new_workspace_label.to_string(), + app.keybinds + .new_workspace + .label() + .unwrap_or_else(|| "unset".to_string()), Style::default().fg(p.accent).add_modifier(Modifier::BOLD), ), Span::styled(" to create one", Style::default().fg(p.overlay0)), diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs index 9d069828..e6d20885 100644 --- a/src/ui/release_notes.rs +++ b/src/ui/release_notes.rs @@ -281,7 +281,15 @@ fn release_notes_inline_spans<'a>( let (code, after_end) = after_start.split_at(end); width += code.chars().count(); if !code.is_empty() { - spans.push(Span::styled(code.to_string(), code_style)); + // Keep short config examples together when Paragraph wraps. + // Snippets like `new_tab = "prefix+c"` read poorly when they + // split at the spaces around `=` in narrow announcement modals. + let display_code = if code.contains('=') { + code.replace(' ', "\u{00a0}") + } else { + code.to_string() + }; + spans.push(Span::styled(display_code, code_style)); } remaining = &after_end[1..]; } -- cgit v1.2.3