feat: add indexed keybind families
This commit is contained in:
parent
43d60fc662
commit
4cbe72ec2d
|
|
@ -77,14 +77,14 @@ keybindings live under `[keys]`.
|
|||
|
||||
supported syntax:
|
||||
- plain keys: `n`, `x`, `-`, `` ` ``
|
||||
- modifiers: `ctrl+b`, `shift+n`, `alt+x`
|
||||
- modifiers: `ctrl+b`, `shift+n`, `alt+x`, `cmd+x`, `super+x`
|
||||
- special keys: `enter`, `esc`, `tab`, `backspace`, `left`, `right`, `up`, `down`
|
||||
- function keys: `f1`, `f12`
|
||||
- uppercase letters also imply shift: `D` works like `shift+d`
|
||||
|
||||
notes:
|
||||
- most reliable bindings are plain keys, `ctrl+letter`, `esc`/`tab`/`enter`, and function keys
|
||||
- `alt+...` and punctuation-with-modifiers may vary depending on terminal/tmux setup
|
||||
- `alt+...`, `cmd`/`super`, and punctuation-with-modifiers may vary depending on terminal/tmux setup
|
||||
- bindings marked `unset` in the key reference are supported actions with no default key assigned
|
||||
- for navigate-mode actions, duplicate keybindings are treated as config errors; later conflicting bindings fall back to defaults
|
||||
|
||||
|
|
@ -116,6 +116,11 @@ focus_pane_left = "alt+h"
|
|||
focus_pane_down = "alt+j"
|
||||
focus_pane_up = "alt+k"
|
||||
focus_pane_right = "alt+l"
|
||||
|
||||
[keys.indexed]
|
||||
tabs = "" # optional; e.g. "ctrl" makes ctrl+1..9 switch tabs
|
||||
workspaces = "" # optional; e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces
|
||||
agents = "" # optional; follows visible agent panel order
|
||||
```
|
||||
|
||||
### key reference
|
||||
|
|
@ -150,6 +155,23 @@ focus_pane_right = "alt+l"
|
|||
| `resize_mode` | `r` | enter or leave resize mode |
|
||||
| `toggle_sidebar` | `b` | collapse or expand the sidebar |
|
||||
|
||||
### indexed keybindings
|
||||
|
||||
Use `[keys.indexed]` to bind number keys `1` through `9` as positional shortcuts. Each value is a modifier combo only. Empty values disable that shortcut family.
|
||||
|
||||
```toml
|
||||
[keys.indexed]
|
||||
tabs = ""
|
||||
workspaces = ""
|
||||
agents = ""
|
||||
```
|
||||
|
||||
| key | default | action |
|
||||
|-----|---------|--------|
|
||||
| `tabs` | unset | switch to tab 1-9 in the active workspace, left to right |
|
||||
| `workspaces` | unset | switch to workspace 1-9 in sidebar order, top to bottom |
|
||||
| `agents` | unset | focus agent row 1-9 in the visible agent panel order |
|
||||
|
||||
### custom command keybindings
|
||||
|
||||
Use `[[keys.command]]` to bind a prefix-mode key to a command. Press the prefix key, then the configured key.
|
||||
|
|
|
|||
|
|
@ -331,6 +331,32 @@ impl AppState {
|
|||
self.cycle_agent_entry(false);
|
||||
}
|
||||
|
||||
pub fn focus_agent_entry(&mut self, idx: usize) -> bool {
|
||||
let entries = crate::ui::agent_panel_entries(self);
|
||||
let Some(target) = entries.get(idx) else {
|
||||
return false;
|
||||
};
|
||||
let ws_idx = target.ws_idx;
|
||||
let tab_idx = target.tab_idx;
|
||||
let pane_id = target.pane_id;
|
||||
|
||||
self.switch_workspace(ws_idx);
|
||||
self.switch_tab(tab_idx);
|
||||
if let Some(tab) = self
|
||||
.workspaces
|
||||
.get_mut(ws_idx)
|
||||
.and_then(|ws| ws.tabs.get_mut(tab_idx))
|
||||
{
|
||||
if tab.panes.contains_key(&pane_id) {
|
||||
tab.layout.focus_pane(pane_id);
|
||||
self.mark_session_dirty();
|
||||
self.ensure_agent_panel_entry_visible(idx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn cycle_agent_entry(&mut self, forward: bool) {
|
||||
let entries = crate::ui::agent_panel_entries(self);
|
||||
if entries.is_empty() {
|
||||
|
|
@ -351,24 +377,7 @@ impl AppState {
|
|||
(None, false) => entries.len() - 1,
|
||||
};
|
||||
|
||||
let target = &entries[target_idx];
|
||||
let ws_idx = target.ws_idx;
|
||||
let tab_idx = target.tab_idx;
|
||||
let pane_id = target.pane_id;
|
||||
|
||||
self.switch_workspace(ws_idx);
|
||||
self.switch_tab(tab_idx);
|
||||
if let Some(tab) = self
|
||||
.workspaces
|
||||
.get_mut(ws_idx)
|
||||
.and_then(|ws| ws.tabs.get_mut(tab_idx))
|
||||
{
|
||||
if tab.panes.contains_key(&pane_id) {
|
||||
tab.layout.focus_pane(pane_id);
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
}
|
||||
self.ensure_agent_panel_entry_visible(target_idx);
|
||||
self.focus_agent_entry(target_idx);
|
||||
}
|
||||
|
||||
fn ensure_agent_panel_entry_visible(&mut self, idx: usize) {
|
||||
|
|
@ -986,6 +995,31 @@ mod tests {
|
|||
assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_agent_entry_uses_agent_panel_order() {
|
||||
let mut first = Workspace::test_new("one");
|
||||
let first_root = first.tabs[0].root_pane;
|
||||
let first_second = first.test_split(Direction::Horizontal);
|
||||
first.tabs[0].layout.focus_pane(first_root);
|
||||
let second = Workspace::test_new("two");
|
||||
let second_root = second.tabs[0].root_pane;
|
||||
|
||||
let mut state = AppState::test_new();
|
||||
state.workspaces = vec![first, second];
|
||||
state.active = Some(0);
|
||||
state.selected = 0;
|
||||
state.mode = Mode::Terminal;
|
||||
state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces;
|
||||
mark_agent(&mut state, 0, 0, first_root);
|
||||
mark_agent(&mut state, 0, 0, first_second);
|
||||
mark_agent(&mut state, 1, 0, second_root);
|
||||
|
||||
assert!(state.focus_agent_entry(2));
|
||||
|
||||
assert_eq!(state.active, Some(1));
|
||||
assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_agent_cycles_only_current_scope_entries() {
|
||||
let mut first = Workspace::test_new("one");
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ pub(crate) fn terminal_direct_navigation_action(
|
|||
state: &AppState,
|
||||
key: &KeyEvent,
|
||||
) -> Option<NavigateAction> {
|
||||
if let Some(action) = indexed_navigation_action(state, key) {
|
||||
return Some(action);
|
||||
}
|
||||
|
||||
let kb = &state.keybinds;
|
||||
if kb
|
||||
.previous_workspace
|
||||
|
|
@ -373,6 +377,9 @@ pub(crate) enum NavigateAction {
|
|||
NewWorkspace,
|
||||
RenameWorkspace,
|
||||
CloseWorkspace,
|
||||
SwitchWorkspace(usize),
|
||||
SwitchTab(usize),
|
||||
FocusAgent(usize),
|
||||
PreviousWorkspace,
|
||||
NextWorkspace,
|
||||
PreviousAgent,
|
||||
|
|
@ -398,7 +405,40 @@ pub(crate) enum NavigateAction {
|
|||
Detach,
|
||||
}
|
||||
|
||||
fn indexed_navigation_action(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
|
||||
let KeyCode::Char(c @ '1'..='9') = key.code else {
|
||||
return None;
|
||||
};
|
||||
let idx = (c as usize) - ('1' as usize);
|
||||
let kb = &state.keybinds;
|
||||
|
||||
if kb
|
||||
.indexed_tabs
|
||||
.is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods))
|
||||
{
|
||||
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));
|
||||
}
|
||||
if kb
|
||||
.indexed_agents
|
||||
.is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods))
|
||||
{
|
||||
return Some(NavigateAction::FocusAgent(idx));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
|
||||
if let Some(action) = indexed_navigation_action(state, key) {
|
||||
return Some(action);
|
||||
}
|
||||
|
||||
let kb = &state.keybinds;
|
||||
if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) {
|
||||
return Some(NavigateAction::NewWorkspace);
|
||||
|
|
@ -526,6 +566,27 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
|
|||
}
|
||||
}
|
||||
}
|
||||
NavigateAction::SwitchWorkspace(idx) => {
|
||||
if idx < state.workspaces.len() {
|
||||
state.switch_workspace(idx);
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
}
|
||||
NavigateAction::SwitchTab(idx) => {
|
||||
let tab_exists = state
|
||||
.active
|
||||
.and_then(|ws_idx| state.workspaces.get(ws_idx))
|
||||
.is_some_and(|ws| idx < ws.tabs.len());
|
||||
if tab_exists {
|
||||
state.switch_tab(idx);
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
}
|
||||
NavigateAction::FocusAgent(idx) => {
|
||||
if state.focus_agent_entry(idx) {
|
||||
leave_navigate_mode(state);
|
||||
}
|
||||
}
|
||||
NavigateAction::PreviousWorkspace => {
|
||||
state.previous_workspace();
|
||||
leave_navigate_mode(state);
|
||||
|
|
@ -791,6 +852,20 @@ mod tests {
|
|||
assert_eq!(action, Some(NavigateAction::FocusPaneLeft));
|
||||
}
|
||||
|
||||
#[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 action = terminal_direct_navigation_action(
|
||||
&state,
|
||||
&KeyEvent::new(KeyCode::Char('3'), KeyModifiers::CONTROL),
|
||||
);
|
||||
|
||||
assert_eq!(action, Some(NavigateAction::SwitchTab(2)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
|
|
|||
|
|
@ -1095,6 +1095,12 @@ impl AppState {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,12 @@ pub struct Keybinds {
|
|||
pub previous_agent_label: Option<String>,
|
||||
pub next_agent: Option<(KeyCode, KeyModifiers)>,
|
||||
pub next_agent_label: Option<String>,
|
||||
pub indexed_tabs: Option<KeyModifiers>,
|
||||
pub indexed_tabs_label: Option<String>,
|
||||
pub indexed_workspaces: Option<KeyModifiers>,
|
||||
pub indexed_workspaces_label: Option<String>,
|
||||
pub indexed_agents: Option<KeyModifiers>,
|
||||
pub indexed_agents_label: Option<String>,
|
||||
pub new_tab: (KeyCode, KeyModifiers),
|
||||
pub new_tab_label: String,
|
||||
pub rename_tab: Option<(KeyCode, KeyModifiers)>,
|
||||
|
|
@ -138,12 +144,18 @@ impl Config {
|
|||
}
|
||||
|
||||
struct OptionalBinding {
|
||||
scope: BindingScope,
|
||||
scopes: Vec<BindingScope>,
|
||||
field: &'static str,
|
||||
value: Option<(KeyCode, KeyModifiers)>,
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
struct IndexedBinding {
|
||||
field: &'static str,
|
||||
value: Option<KeyModifiers>,
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BindingRegistry {
|
||||
seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>,
|
||||
|
|
@ -208,14 +220,14 @@ impl Config {
|
|||
}
|
||||
|
||||
fn optional_binding(
|
||||
scope: BindingScope,
|
||||
scopes: Vec<BindingScope>,
|
||||
field: &'static str,
|
||||
configured_label: &str,
|
||||
diagnostics: &mut Vec<String>,
|
||||
) -> OptionalBinding {
|
||||
if configured_label.trim().is_empty() {
|
||||
return OptionalBinding {
|
||||
scope,
|
||||
scopes,
|
||||
field,
|
||||
value: None,
|
||||
label: None,
|
||||
|
|
@ -223,7 +235,7 @@ impl Config {
|
|||
}
|
||||
match parse_key_combo(configured_label) {
|
||||
Some(value) => OptionalBinding {
|
||||
scope,
|
||||
scopes,
|
||||
field,
|
||||
value: Some(value),
|
||||
label: Some(configured_label.to_string()),
|
||||
|
|
@ -236,7 +248,41 @@ impl Config {
|
|||
warn!(message = %diag, "config diagnostic");
|
||||
diagnostics.push(diag);
|
||||
OptionalBinding {
|
||||
scope,
|
||||
scopes,
|
||||
field,
|
||||
value: None,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed_binding(
|
||||
field: &'static str,
|
||||
configured_label: &str,
|
||||
diagnostics: &mut Vec<String>,
|
||||
) -> IndexedBinding {
|
||||
if configured_label.trim().is_empty() {
|
||||
return IndexedBinding {
|
||||
field,
|
||||
value: None,
|
||||
label: None,
|
||||
};
|
||||
}
|
||||
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,
|
||||
|
|
@ -338,105 +384,128 @@ impl Config {
|
|||
),
|
||||
];
|
||||
|
||||
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(
|
||||
BindingScope::Navigate,
|
||||
navigate_scope(),
|
||||
"keys.detach",
|
||||
&self.keys.detach,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
navigate_scope(),
|
||||
"keys.reload_config",
|
||||
&self.keys.reload_config,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
navigate_scope(),
|
||||
"keys.open_notification_target",
|
||||
&self.keys.open_notification_target,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
direct_navigation_scopes(),
|
||||
"keys.previous_workspace",
|
||||
&self.keys.previous_workspace,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
direct_navigation_scopes(),
|
||||
"keys.next_workspace",
|
||||
&self.keys.next_workspace,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
direct_navigation_scopes(),
|
||||
"keys.previous_agent",
|
||||
&self.keys.previous_agent,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
direct_navigation_scopes(),
|
||||
"keys.next_agent",
|
||||
&self.keys.next_agent,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
navigate_scope(),
|
||||
"keys.rename_tab",
|
||||
&self.keys.rename_tab,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
direct_navigation_scopes(),
|
||||
"keys.previous_tab",
|
||||
&self.keys.previous_tab,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
direct_navigation_scopes(),
|
||||
"keys.next_tab",
|
||||
&self.keys.next_tab,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
navigate_scope(),
|
||||
"keys.close_tab",
|
||||
&self.keys.close_tab,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
navigate_scope(),
|
||||
"keys.rename_pane",
|
||||
&self.keys.rename_pane,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::TerminalDirect,
|
||||
terminal_direct_scope(),
|
||||
"keys.focus_pane_left",
|
||||
&self.keys.focus_pane_left,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::TerminalDirect,
|
||||
terminal_direct_scope(),
|
||||
"keys.focus_pane_down",
|
||||
&self.keys.focus_pane_down,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::TerminalDirect,
|
||||
terminal_direct_scope(),
|
||||
"keys.focus_pane_up",
|
||||
&self.keys.focus_pane_up,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::TerminalDirect,
|
||||
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::default();
|
||||
for binding in &mut bindings {
|
||||
if let Some(first_field) = registry.conflict(binding.scope, binding.value) {
|
||||
|
|
@ -452,11 +521,18 @@ impl Config {
|
|||
registry.register(binding.scope, binding.value, binding.field);
|
||||
}
|
||||
|
||||
registry.reserve_if_unbound(BindingScope::TerminalDirect, prefix, "keys.prefix");
|
||||
|
||||
for binding in &mut optional_bindings {
|
||||
let Some(value) = binding.value else {
|
||||
continue;
|
||||
};
|
||||
if let Some(first_field) = registry.conflict(binding.scope, value) {
|
||||
|
||||
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
|
||||
|
|
@ -467,7 +543,9 @@ impl Config {
|
|||
binding.label = None;
|
||||
continue;
|
||||
}
|
||||
registry.register(binding.scope, value, binding.field);
|
||||
for scope in &binding.scopes {
|
||||
registry.register(*scope, value, binding.field);
|
||||
}
|
||||
}
|
||||
|
||||
registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix");
|
||||
|
|
@ -563,6 +641,47 @@ impl Config {
|
|||
registry.reserve_if_unbound(BindingScope::Navigate, binding, field);
|
||||
}
|
||||
|
||||
for binding in &mut indexed_bindings {
|
||||
let Some(modifiers) = binding.value else {
|
||||
continue;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(first_field) = conflict {
|
||||
let diag = format!(
|
||||
"duplicate indexed 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 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 mut custom_commands = Vec::new();
|
||||
for (index, command) in self.keys.command.iter().enumerate() {
|
||||
let key_field = format!("keys.command[{index}].key");
|
||||
|
|
@ -630,6 +749,12 @@ impl Config {
|
|||
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,
|
||||
|
|
@ -681,6 +806,15 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String {
|
|||
if modifiers.contains(KeyModifiers::SHIFT) {
|
||||
parts.push("shift".to_string());
|
||||
}
|
||||
if modifiers.contains(KeyModifiers::SUPER) {
|
||||
parts.push(super_modifier_label().to_string());
|
||||
}
|
||||
if modifiers.contains(KeyModifiers::HYPER) {
|
||||
parts.push("hyper".to_string());
|
||||
}
|
||||
if modifiers.contains(KeyModifiers::META) {
|
||||
parts.push("meta".to_string());
|
||||
}
|
||||
|
||||
let key = match code {
|
||||
KeyCode::Char(' ') => "space".to_string(),
|
||||
|
|
@ -710,6 +844,47 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String {
|
|||
parts.join("+")
|
||||
}
|
||||
|
||||
fn super_modifier_label() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"cmd"
|
||||
} else {
|
||||
"super"
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_modifier_token(token: &str) -> Option<KeyModifiers> {
|
||||
match token.to_lowercase().as_str() {
|
||||
"ctrl" | "control" => Some(KeyModifiers::CONTROL),
|
||||
"shift" => Some(KeyModifiers::SHIFT),
|
||||
"alt" | "option" | "meta" => Some(KeyModifiers::ALT),
|
||||
"cmd" | "command" | "super" => Some(KeyModifiers::SUPER),
|
||||
"hyper" => Some(KeyModifiers::HYPER),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_modifier_combo(s: &str) -> Option<KeyModifiers> {
|
||||
let mut modifiers = KeyModifiers::empty();
|
||||
let parts: Vec<&str> = s.split('+').collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
for part in &parts {
|
||||
let trimmed = part.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
modifiers |= parse_modifier_token(trimmed)?;
|
||||
}
|
||||
|
||||
if modifiers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
|
||||
let parts: Vec<&str> = s.split('+').collect();
|
||||
let mut modifiers = KeyModifiers::empty();
|
||||
|
|
@ -717,17 +892,16 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
|
|||
|
||||
for part in &parts {
|
||||
let trimmed = part.trim();
|
||||
match trimmed.to_lowercase().as_str() {
|
||||
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
|
||||
"shift" => modifiers |= KeyModifiers::SHIFT,
|
||||
"alt" | "meta" => modifiers |= KeyModifiers::ALT,
|
||||
_ if trimmed.is_empty() => return None,
|
||||
_ => {
|
||||
if key_str.is_some() {
|
||||
return None;
|
||||
}
|
||||
key_str = Some(trimmed);
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(modifier) = parse_modifier_token(trimmed) {
|
||||
modifiers |= modifier;
|
||||
} else {
|
||||
if key_str.is_some() {
|
||||
return None;
|
||||
}
|
||||
key_str = Some(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -796,6 +970,23 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cmd_combo() {
|
||||
assert_eq!(
|
||||
parse_key_combo("cmd+1"),
|
||||
Some((KeyCode::Char('1'), KeyModifiers::SUPER))
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_special_key() {
|
||||
assert_eq!(
|
||||
|
|
@ -885,6 +1076,9 @@ mod tests {
|
|||
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'));
|
||||
|
|
@ -1110,6 +1304,84 @@ command = "echo hi"
|
|||
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();
|
||||
let kb = config.keybinds();
|
||||
|
||||
assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL));
|
||||
assert_eq!(
|
||||
kb.indexed_workspaces,
|
||||
Some(KeyModifiers::CONTROL | 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")
|
||||
}));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
assert_eq!(
|
||||
kb.previous_tab,
|
||||
Some((KeyCode::Char('h'), KeyModifiers::ALT))
|
||||
);
|
||||
assert_eq!(kb.focus_pane_left, None);
|
||||
assert!(diagnostics
|
||||
.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
|
||||
.iter()
|
||||
.any(|d| { d.contains("keys.previous_tab") && d.contains("keys.prefix") }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_keybinds_reject_invalid_keybinding() {
|
||||
let config: Config = toml::from_str(
|
||||
|
|
|
|||
|
|
@ -123,10 +123,23 @@ pub struct KeysConfig {
|
|||
pub resize_mode: String,
|
||||
/// Toggle sidebar collapse. Default: "b"
|
||||
pub toggle_sidebar: String,
|
||||
/// Optional indexed shortcuts expanded over number keys 1-9.
|
||||
pub indexed: IndexedKeysConfig,
|
||||
/// Prefix-mode custom command bindings.
|
||||
pub command: Vec<CommandKeybindConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct IndexedKeysConfig {
|
||||
/// Modifier combo for tab shortcuts 1-9. Unset by default.
|
||||
pub tabs: String,
|
||||
/// Modifier combo for workspace shortcuts 1-9. Unset by default.
|
||||
pub workspaces: String,
|
||||
/// Modifier combo for agent shortcuts 1-9. Unset by default.
|
||||
pub agents: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct UiConfig {
|
||||
|
|
@ -195,6 +208,7 @@ impl Default for KeysConfig {
|
|||
fullscreen: "f".into(),
|
||||
resize_mode: "r".into(),
|
||||
toggle_sidebar: "b".into(),
|
||||
indexed: IndexedKeysConfig::default(),
|
||||
command: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
src/main.rs
11
src/main.rs
|
|
@ -75,9 +75,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
|
|||
[keys]
|
||||
# Prefix key to enter navigate mode (default: "ctrl+b")
|
||||
# Examples: "ctrl+b", "f12", "esc", "-"
|
||||
# Accepted syntax: plain keys, ctrl/shift/alt modifiers, and special keys like enter/tab/esc/left/right/up/down
|
||||
# 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.
|
||||
# alt+... and punctuation-with-modifiers may depend on your terminal/tmux setup.
|
||||
# alt+..., cmd/super, and punctuation-with-modifiers may depend on your terminal/tmux setup.
|
||||
# prefix = "ctrl+b"
|
||||
|
||||
# Navigate-mode actions
|
||||
|
|
@ -116,6 +116,13 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
|
|||
# type = "pane"
|
||||
# command = "lazygit"
|
||||
|
||||
# Optional modifier-only shortcuts expanded over number keys 1-9.
|
||||
# Empty means disabled. Examples: "ctrl", "ctrl+shift", "alt".
|
||||
# [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
|
||||
|
||||
[ui]
|
||||
# Sidebar width (auto-scaled based on workspace names, this sets the default)
|
||||
# sidebar_width = 26
|
||||
|
|
|
|||
|
|
@ -68,11 +68,19 @@ pub(super) fn keybind_help_groups(
|
|||
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"),
|
||||
(
|
||||
|
|
@ -80,6 +88,10 @@ pub(super) fn keybind_help_groups(
|
|||
"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"),
|
||||
];
|
||||
if let Some(label) = &kb.detach_label {
|
||||
|
|
|
|||
Loading…
Reference in New Issue