Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/next/website/src/data/config-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,18 @@
"default": "\"down\"",
"description": "Move workspace selection down in navigate mode."
},
{
"key": "keys.navigate_navigator_up",
"type": "keybinding",
"default": "\"ctrl+p\"",
"description": "Move the session navigator selection up."
},
{
"key": "keys.navigate_navigator_down",
"type": "keybinding",
"default": "\"ctrl+n\"",
"description": "Move the session navigator selection down."
},
{
"key": "keys.navigate_pane_left",
"type": "keybinding",
Expand Down
106 changes: 100 additions & 6 deletions src/app/input/modal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,26 @@ pub(crate) fn handle_navigator_key(
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
key: KeyEvent,
) {
let terminal_key = TerminalKey::from(key);
if state
.keybinds
.navigator
.up
.matches_direct_key(&terminal_key)
{
state.move_navigator_selection_from(terminal_runtimes, -1);
return;
}
if state
.keybinds
.navigator
.down
.matches_direct_key(&terminal_key)
{
state.move_navigator_selection_from(terminal_runtimes, 1);
return;
}

if state.navigator.search_focused {
match key.code {
KeyCode::Esc => {
Expand All @@ -179,12 +199,6 @@ pub(crate) fn handle_navigator_key(
}
KeyCode::Up => state.move_navigator_selection_from(terminal_runtimes, -1),
KeyCode::Down => state.move_navigator_selection_from(terminal_runtimes, 1),
KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => {
state.move_navigator_selection_from(terminal_runtimes, 1)
}
KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => {
state.move_navigator_selection_from(terminal_runtimes, -1)
}
KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => {
state.navigator.query.clear();
state.navigator.state_filter = None;
Expand Down Expand Up @@ -1880,6 +1894,86 @@ mod tests {
assert!(state.navigator.query.is_empty());
}

#[test]
fn navigator_configured_movement_keys_work_in_normal_and_search_views() {
let mut state = state_with_workspaces(&["alpha", "beta"]);
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
let config: crate::config::Config = toml::from_str(
r#"
[keys]
navigate_navigator_up = "ctrl+alt+p"
navigate_navigator_down = "ctrl+alt+n"
"#,
)
.unwrap();
state.keybinds = config.keybinds();
state.mode = Mode::Navigator;

handle_navigator_key(
&mut state,
&terminal_runtimes,
KeyEvent::new(
KeyCode::Char('n'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
),
);
assert_eq!(state.navigator.selected, 1);

handle_navigator_key(
&mut state,
&terminal_runtimes,
KeyEvent::new(
KeyCode::Char('p'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
),
);
assert_eq!(state.navigator.selected, 0);

state.navigator.search_focused = true;
handle_navigator_key(
&mut state,
&terminal_runtimes,
KeyEvent::new(
KeyCode::Char('n'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
),
);
assert_eq!(state.navigator.selected, 1);

handle_navigator_key(
&mut state,
&terminal_runtimes,
KeyEvent::new(
KeyCode::Char('p'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
),
);
assert_eq!(state.navigator.selected, 0);
}

#[test]
fn navigator_builtin_vertical_movement_keys_remain_available() {
let mut state = state_with_workspaces(&["alpha", "beta", "gamma"]);
let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
state.mode = Mode::Navigator;

for key in [
KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()),
] {
handle_navigator_key(&mut state, &terminal_runtimes, key);
}
assert_eq!(state.navigator.selected, 2);

for key in [
KeyEvent::new(KeyCode::Up, KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::empty()),
] {
handle_navigator_key(&mut state, &terminal_runtimes, key);
}
assert_eq!(state.navigator.selected, 0);
}

#[test]
fn navigator_empty_search_escape_returns_to_commands() {
let mut state = state_with_workspaces(&["alpha", "beta"]);
Expand Down
162 changes: 162 additions & 0 deletions src/config/keybinds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,17 @@ pub struct NavigateKeybinds {
pub pane_right: ActionKeybinds,
}

#[derive(Debug, Clone)]
pub struct NavigatorKeybinds {
pub up: ActionKeybinds,
pub down: ActionKeybinds,
}

/// Parsed keybinds for Herdr actions.
#[derive(Debug, Clone)]
pub struct Keybinds {
pub navigate: NavigateKeybinds,
pub navigator: NavigatorKeybinds,
pub help: ActionKeybinds,
pub settings: ActionKeybinds,
pub new_workspace: ActionKeybinds,
Expand Down Expand Up @@ -452,6 +459,9 @@ impl Config {
let mut navigate_registry = BindingRegistry::new(prefix, prefix_source);
navigate_registry.reserve_direct(prefix, "keys.prefix", prefix_source);
reserve_navigate_runtime_keys(&mut navigate_registry);
let mut navigator_registry = BindingRegistry::new(prefix, prefix_source);
navigator_registry.reserve_direct(prefix, "keys.prefix", prefix_source);
reserve_navigator_runtime_keys(&mut navigator_registry);

macro_rules! empty_action {
() => {
Expand All @@ -468,6 +478,10 @@ impl Config {
pane_up: empty_action!(),
pane_right: empty_action!(),
},
navigator: NavigatorKeybinds {
up: empty_action!(),
down: empty_action!(),
},
help: empty_action!(),
settings: empty_action!(),
new_workspace: empty_action!(),
Expand Down Expand Up @@ -590,6 +604,24 @@ impl Config {
apply_navigate!(keybinds.navigate.pane_down, navigate_pane_down, source);
apply_navigate!(keybinds.navigate.pane_up, navigate_pane_up, source);
apply_navigate!(keybinds.navigate.pane_right, navigate_pane_right, source);
if field_source!(navigate_navigator_up) == source {
keybinds.navigator.up = parse_navigator_bindings(
"keys.navigate_navigator_up",
&self.keys.navigate_navigator_up,
&mut navigator_registry,
&mut diagnostics,
source,
);
}
if field_source!(navigate_navigator_down) == source {
keybinds.navigator.down = parse_navigator_bindings(
"keys.navigate_navigator_down",
&self.keys.navigate_navigator_down,
&mut navigator_registry,
&mut diagnostics,
source,
);
}
apply_action!(keybinds.help, help, source);
apply_action!(keybinds.settings, settings, source);
apply_action!(keybinds.new_workspace, new_workspace, source);
Expand Down Expand Up @@ -718,6 +750,30 @@ fn reserve_navigate_runtime_keys(registry: &mut BindingRegistry) {
}
}

fn reserve_navigator_runtime_keys(registry: &mut BindingRegistry) {
reserve_navigate_runtime_keys(registry);
for combo in [
(KeyCode::Up, KeyModifiers::empty()),
(KeyCode::Down, KeyModifiers::empty()),
(KeyCode::Char('j'), KeyModifiers::empty()),
(KeyCode::Char('k'), KeyModifiers::empty()),
(KeyCode::Char('a'), KeyModifiers::empty()),
(KeyCode::Char('b'), KeyModifiers::empty()),
(KeyCode::Char('w'), KeyModifiers::empty()),
(KeyCode::Char('i'), KeyModifiers::empty()),
(KeyCode::Char('d'), KeyModifiers::empty()),
(KeyCode::Char('/'), KeyModifiers::empty()),
(KeyCode::Char('d'), KeyModifiers::CONTROL),
(KeyCode::Char('u'), KeyModifiers::CONTROL),
(KeyCode::Home, KeyModifiers::empty()),
(KeyCode::End, KeyModifiers::empty()),
(KeyCode::Char('G'), KeyModifiers::SHIFT),
(KeyCode::Char(' '), KeyModifiers::empty()),
] {
registry.reserve_direct(combo, "navigator reserved keys", BindingSource::Default);
}
}

fn append_custom_command_bindings(
config: &Config,
keybinds: &mut Keybinds,
Expand Down Expand Up @@ -849,6 +905,42 @@ fn parse_navigate_bindings(
ActionKeybinds { bindings }
}

fn parse_navigator_bindings(
field: &'static str,
config: &BindingConfig,
registry: &mut BindingRegistry,
diagnostics: &mut Vec<String>,
source: BindingSource,
) -> 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_navigator_binding(field, &binding, registry, diagnostics, source) {
continue;
}
registry.register(&binding, field, source);
bindings.push(binding);
}
Some(ParsedBinding::Range(_)) => {
let diag = format!("range keybinding is only valid for indexed actions: {field} = {raw:?}; disabling binding");
warn!(message = %diag, "config diagnostic");
diagnostics.push(diag);
}
None => {
let diag = format!("invalid keybinding: {field} = {raw:?}; disabling binding");
warn!(message = %diag, "config diagnostic");
diagnostics.push(diag);
}
}
}
ActionKeybinds { bindings }
}

fn parse_indexed_bindings(
field: &'static str,
config: &BindingConfig,
Expand Down Expand Up @@ -996,6 +1088,33 @@ fn reject_navigate_binding(
false
}

fn reject_navigator_binding(
field: &str,
binding: &ResolvedBinding,
registry: &BindingRegistry,
diagnostics: &mut Vec<String>,
source: BindingSource,
) -> bool {
if reject_navigate_binding(field, binding, registry, diagnostics, source) {
return true;
}

let (code, modifiers) = normalize_key_combo(binding.trigger.combo());
if matches!(code, KeyCode::Char(_))
&& (modifiers.is_empty() || modifiers == KeyModifiers::SHIFT)
{
let diag = format!(
"navigator keybinding must not consume printable search input: {field} = {:?}; disabling binding",
binding.label
);
warn!(message = %diag, "config diagnostic");
diagnostics.push(diag);
return true;
}

false
}

fn reject_binding(
field: &str,
binding: &ResolvedBinding,
Expand Down Expand Up @@ -1817,6 +1936,49 @@ navigate_pane_down = "ctrl+j"
}));
}

#[test]
fn navigator_bindings_are_independent_from_workspace_picker_bindings() {
let config: Config = toml::from_str(
r#"
[keys]
navigate_workspace_up = "ctrl+p"
navigate_workspace_down = "ctrl+n"
navigate_navigator_up = "ctrl+p"
navigate_navigator_down = "ctrl+n"
"#,
)
.unwrap();
let keybinds = config.keybinds();

for bindings in [&keybinds.navigate.workspace_up, &keybinds.navigator.up] {
assert!(bindings
.matches_direct_key(&TerminalKey::new(KeyCode::Char('p'), KeyModifiers::CONTROL,)));
}
for bindings in [&keybinds.navigate.workspace_down, &keybinds.navigator.down] {
assert!(bindings
.matches_direct_key(&TerminalKey::new(KeyCode::Char('n'), KeyModifiers::CONTROL,)));
}
}

#[test]
fn navigator_bindings_reject_printable_search_input() {
let config: Config = toml::from_str(
r#"
[keys]
navigate_navigator_up = "x"
"#,
)
.unwrap();
let keybinds = config.keybinds();
let diagnostics = config.collect_diagnostics();

assert!(keybinds.navigator.up.bindings.is_empty());
assert!(diagnostics.iter().any(|diag| {
diag.contains("must not consume printable search input")
&& diag.contains("keys.navigate_navigator_up")
}));
}

#[test]
fn navigate_bindings_reject_runtime_reserved_keys() {
let config: Config = toml::from_str(
Expand Down
Loading
Loading