diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3b2c3184..5c9460f3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -979,6 +979,7 @@ Event::Action(a) → handle_action(a) → Vec | Conditional | `IfQueryEmpty(then, else?)`, `IfQueryNotEmpty(then, else?)`, `IfNonMatched(then, else?)` | | Lifecycle | `Accept(key?)`, `Abort`, `Cancel` | | UI | `ClearScreen`, `Redraw`, `SetHeader(text?)`, `SelectRow(n)` | +| Bindings | `Bind(spec)` — add `key:action[+action]` bindings at runtime; `Unbind(keys)` — remove bindings for a comma-separated key list | | Custom | `Custom(ActionCallback)` — async or sync closure receiving `&mut App` | `Action::Custom(ActionCallback)` is the library extension point: callers can inject arbitrary async logic into the action pipeline without forking skim. diff --git a/man/man1/sk.1 b/man/man1/sk.1 index 011a1957..2bf76634 100644 --- a/man/man1/sk.1 +++ b/man/man1/sk.1 @@ -858,6 +858,8 @@ Actions can take arguments, specified either between parentheses `reload(ls)` or .br * beginning\-of\-line: ctrl\-a home .br +* bind(...): *arg is a comma\-separated list of `key:action[+action]` bindings to add (same syntax as \-\-bind) +.br * clear\-screen: ctrl\-l .br * delete\-char: del @@ -952,6 +954,8 @@ Actions can take arguments, specified either between parentheses `reload(ls)` or .br * top .br +* unbind(...): *arg is a comma\-separated list of keys to unbind +.br * unix\-line\-discard: ctrl\-u .br * unix\-word\-rubout: ctrl\-w diff --git a/src/binds.rs b/src/binds.rs index effa8c4d..c140ae1a 100644 --- a/src/binds.rs +++ b/src/binds.rs @@ -30,7 +30,7 @@ impl DerefMut for KeyMap { impl From<&str> for KeyMap { fn from(value: &str) -> Self { - parse_keymaps(value.split(',')) + parse_keymaps(split_top_level(value, ',').into_iter()) } } @@ -41,6 +41,11 @@ impl Default for KeyMap { } impl KeyMap { + /// Adds keymaps from a comma-separated string. + pub(crate) fn add_keymaps_str(&mut self, source: &str) { + self.add_keymaps(split_top_level(source, ',').into_iter()); + } + /// Adds keymaps from the source, parsing them using `parse_keymap` pub fn add_keymaps<'a, T>(&mut self, source: T) where @@ -203,13 +208,33 @@ where res } +fn split_top_level(value: &str, separator: char) -> Vec<&str> { + let mut depth = 0_u32; + let mut start = 0; + let mut parts = Vec::new(); + + for (index, ch) in value.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + _ if ch == separator && depth == 0 => { + parts.push(&value[start..index]); + start = index + ch.len_utf8(); + } + _ => {} + } + } + parts.push(&value[start..]); + parts +} + /// Parses an action chain, separated by '+'s into the corresponding actions /// /// # Errors /// Returns an error if the action chain is empty or contains only unknown actions. pub fn parse_action_chain(action_chain: &str) -> Result> { let mut actions: Vec = vec![]; - let mut split = action_chain.split('+'); + let mut split = split_top_level(action_chain, '+').into_iter(); while let Some(mut s) = split.next().map(String::from) { if (s.starts_with("if-") || s.ends_with('{')) diff --git a/src/binds_tests.rs b/src/binds_tests.rs index 875dbc31..c43d8926 100644 --- a/src/binds_tests.rs +++ b/src/binds_tests.rs @@ -149,6 +149,28 @@ fn keymap_from_str_parses_bindings() { assert!(keymap.get(&parse_key("enter").unwrap()).is_some()); } +#[test] +fn keymap_from_str_preserves_nested_bind_separators() { + let keymap = KeyMap::from("ctrl-z:bind(ctrl-x:abort+up),ctrl-w:unbind(ctrl-x,ctrl-y)"); + + assert_eq!( + keymap.get(&parse_key("ctrl-z").unwrap()), + Some(&vec![Bind("ctrl-x:abort+up".into())]) + ); + assert_eq!( + keymap.get(&parse_key("ctrl-w").unwrap()), + Some(&vec![Unbind("ctrl-x,ctrl-y".into())]) + ); +} + +#[test] +fn parse_action_chain_preserves_nested_bind_chain() { + assert_eq!( + parse_action_chain("bind(ctrl-x:abort+up)").unwrap(), + vec![Bind("ctrl-x:abort+up".into())] + ); +} + #[test] fn parse_keymaps_collects_iterator() { let keymap = parse_keymaps(["ctrl-x:abort", "up:up"].into_iter()); diff --git a/src/manpage.rs b/src/manpage.rs index bafa9c24..743a1d1f 100644 --- a/src/manpage.rs +++ b/src/manpage.rs @@ -131,6 +131,7 @@ const ACTIONS_SS: &str = " * backward-kill-word: alt-bs * backward-word: alt-b shift-left * beginning-of-line: ctrl-a home +* bind(...): *arg is a comma-separated list of `key:action[+action]` bindings to add (same syntax as --bind) * clear-screen: ctrl-l * delete-char: del * delete-char/eof: ctrl-d @@ -178,6 +179,7 @@ const ACTIONS_SS: &str = " * toggle-sort * toggle+up: btab shift-tab * top +* unbind(...): *arg is a comma-separated list of keys to unbind * unix-line-discard: ctrl-u * unix-word-rubout: ctrl-w * up: ctrl-k ctrl-p up diff --git a/src/options.rs b/src/options.rs index 51eb4359..b448593d 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1308,7 +1308,7 @@ impl SkimOptions { } self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| { - res.add_keymaps(part.split(',')); + res.add_keymaps_str(part); res }); diff --git a/src/tui/app.rs b/src/tui/app.rs index 01d97a6c..94e141a0 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -704,17 +704,8 @@ impl App { #[allow(clippy::too_many_lines)] fn handle_action(&mut self, act: &Action) -> Result> { - use Action::{ - Abort, Accept, AddChar, AppendAndSelect, BackwardChar, BackwardDeleteChar, BackwardDeleteCharEof, - BackwardKillWord, BackwardWord, BeginningOfLine, Cancel, ClearScreen, Custom, DeleteChar, DeleteCharEof, - DeselectAll, Down, EndOfLine, Execute, ExecuteSilent, First, ForwardChar, ForwardWord, HalfPageDown, - HalfPageUp, IfNonMatched, IfQueryEmpty, IfQueryNotEmpty, Ignore, KillLine, KillWord, Last, NextHistory, - PageDown, PageUp, PreviewDown, PreviewLeft, PreviewPageDown, PreviewPageUp, PreviewRight, PreviewUp, - PreviousHistory, Redraw, RefreshCmd, RefreshPreview, Reload, RestartMatcher, RotateMode, ScrollLeft, - ScrollRight, Select, SelectAll, SelectRow, SetHeader, SetPreviewCmd, SetQuery, Toggle, ToggleAll, ToggleIn, - ToggleInteractive, ToggleOut, TogglePreview, TogglePreviewWrap, ToggleSort, Top, UnixLineDiscard, - UnixWordRubout, Up, Yank, - }; + #[allow(clippy::enum_glob_use)] + use Action::*; use ratatui::widgets::ListDirection::{BottomToTop, TopToBottom}; match act { Abort | Accept(_) => { @@ -771,6 +762,12 @@ impl App { BeginningOfLine => { self.input.move_cursor_to(0); } + Bind(spec) => { + // Bind one or more `key:action[+action]` pairs, reusing the same + // parsing/merging logic as the `--bind` CLI option. Existing + // bindings for the same keys are replaced. + self.options.keymap.add_keymaps_str(spec); + } Cancel => { self.matcher_control.kill(); self.preview.kill(); @@ -1158,6 +1155,17 @@ impl App { self.options.no_sort = !self.options.no_sort; self.restart_matcher(true); } + Unbind(spec) => { + // Remove the bindings for one or more keys. + for key in spec.split(',') { + match crate::binds::parse_key(key) { + Ok(parsed) => { + self.options.keymap.remove(&parsed); + } + Err(err) => debug!("Failed to unbind key {key}: {err}"), + } + } + } UnixLineDiscard => { if !self.input.delete_to_beginning().is_empty() { return Ok(self.on_query_changed()); diff --git a/src/tui/app_tests.rs b/src/tui/app_tests.rs index 277016c1..b9e9843f 100644 --- a/src/tui/app_tests.rs +++ b/src/tui/app_tests.rs @@ -643,6 +643,76 @@ fn custom_action_runs_async_callback() { assert!(events.iter().any(|e| matches!(e, Event::Action(Action::Abort)))); } +#[test] +fn bind_action_adds_action_chain() { + let mut app = App::default(); + let key = crate::binds::parse_key("ctrl-x").unwrap(); + // Not bound by default. + app.options.keymap.remove(&key); + assert!(app.options.keymap.get(&key).is_none()); + + act(&mut app, Action::Bind("ctrl-x:abort+up".to_string())); + + assert_eq!(app.options.keymap.get(&key), Some(&vec![Action::Abort, Action::Up(1)])); +} + +#[test] +fn bind_action_binds_multiple_comma_separated_keys() { + let mut app = App::default(); + act(&mut app, Action::Bind("ctrl-x:abort,ctrl-y:select-all".to_string())); + + let key_x = crate::binds::parse_key("ctrl-x").unwrap(); + let key_y = crate::binds::parse_key("ctrl-y").unwrap(); + assert_eq!(app.options.keymap.get(&key_x), Some(&vec![Action::Abort])); + assert_eq!(app.options.keymap.get(&key_y), Some(&vec![Action::SelectAll])); +} + +#[test] +fn bind_action_replaces_existing_binding() { + let mut app = App::default(); + // Enter is bound to Accept(None) by default. + let key = crate::binds::parse_key("enter").unwrap(); + assert_eq!(app.options.keymap.get(&key), Some(&vec![Action::Accept(None)])); + + act(&mut app, Action::Bind("enter:abort".to_string())); + + assert_eq!(app.options.keymap.get(&key), Some(&vec![Action::Abort])); +} + +#[test] +fn unbind_action_removes_keymap_entry() { + let mut app = App::default(); + let key = crate::binds::parse_key("enter").unwrap(); + assert!(app.options.keymap.get(&key).is_some()); + + act(&mut app, Action::Unbind("enter".to_string())); + + assert!(app.options.keymap.get(&key).is_none()); +} + +#[test] +fn unbind_action_removes_multiple_comma_separated_keys() { + let mut app = App::default(); + let key_up = crate::binds::parse_key("up").unwrap(); + let key_down = crate::binds::parse_key("down").unwrap(); + assert!(app.options.keymap.get(&key_up).is_some()); + assert!(app.options.keymap.get(&key_down).is_some()); + + act(&mut app, Action::Unbind("up,down".to_string())); + + assert!(app.options.keymap.get(&key_up).is_none()); + assert!(app.options.keymap.get(&key_down).is_none()); +} + +#[test] +fn unbind_action_ignores_unparseable_keys() { + let mut app = App::default(); + // A bogus key name is skipped without panicking; valid keys are still removed. + let key = crate::binds::parse_key("enter").unwrap(); + act(&mut app, Action::Unbind("not-a-key,enter".to_string())); + assert!(app.options.keymap.get(&key).is_none()); +} + #[test] fn handle_key_maps_plain_char_to_add_char() { let mut app = App::default(); diff --git a/src/tui/event.rs b/src/tui/event.rs index a84dc7d6..7462c7a0 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -174,6 +174,8 @@ pub enum Action { BackwardWord, /// Move cursor to beginning of line BeginningOfLine, + /// Bind one or more keys to action chains (`key:action[+action][,key:action…]`) + Bind(String), /// Cancel current operation Cancel, /// Clear the screen @@ -282,6 +284,8 @@ pub enum Action { ToggleSort, /// Jump to first item in list (alias for First) Top, + /// Unbind one or more keys (`key[,key…]`) + Unbind(String), /// Discard line (unix-style) UnixLineDiscard, /// Delete word backward (unix-style) @@ -347,7 +351,7 @@ pub fn parse_action(raw_action: &str) -> Option { } } else if matches!( action, - "add-char" | "execute" | "execute-silent" | "set-preview-cmd" | "set-query" + "add-char" | "bind" | "execute" | "execute-silent" | "set-preview-cmd" | "set-query" | "unbind" ) && arg.is_none() { None @@ -365,6 +369,7 @@ pub fn parse_action(raw_action: &str) -> Option { "backward-kill-word" => Some(BackwardKillWord), "backward-word" => Some(BackwardWord), "beginning-of-line" => Some(BeginningOfLine), + "bind" => Some(Bind(arg.unwrap_or_default())), "cancel" => Some(Cancel), "clear-screen" => Some(ClearScreen), "delete-char" => Some(DeleteChar), @@ -416,6 +421,7 @@ pub fn parse_action(raw_action: &str) -> Option { "toggle-preview-wrap" => Some(TogglePreviewWrap), "toggle-sort" => Some(ToggleSort), "top" => Some(Top), + "unbind" => Some(Unbind(arg.unwrap_or_default())), "unix-line-discard" => Some(UnixLineDiscard), "unix-word-rubout" => Some(UnixWordRubout), "up" => Some(Up(arg.and_then(|s| s.parse().ok()).unwrap_or(1))), diff --git a/src/tui/event_tests.rs b/src/tui/event_tests.rs index 6ba97e75..aa854e04 100644 --- a/src/tui/event_tests.rs +++ b/src/tui/event_tests.rs @@ -112,6 +112,39 @@ fn parse_optional_arg_actions() { ); } +#[test] +fn parse_bind_and_unbind_actions() { + // `bind` captures the whole `key:action` spec as its string argument, using + // either the paren or colon form. + assert_eq!( + parse_action("bind(ctrl-a:accept)"), + Some(Action::Bind("ctrl-a:accept".to_string())) + ); + assert_eq!( + parse_action("bind:ctrl-a:accept"), + Some(Action::Bind("ctrl-a:accept".to_string())) + ); + // `unbind` captures a comma-separated list of keys, like fzf's `unbind(...)`. + assert_eq!( + parse_action("unbind(ctrl-a)"), + Some(Action::Unbind("ctrl-a".to_string())) + ); + assert_eq!( + parse_action("unbind(ctrl-a,ctrl-b)"), + Some(Action::Unbind("ctrl-a,ctrl-b".to_string())) + ); +} + +#[test] +fn parse_bind_and_unbind_require_argument() { + // Without an argument both actions are rejected rather than silently + // producing an empty binding. + assert_eq!(parse_action("bind"), None); + assert_eq!(parse_action("bind:"), None); + assert_eq!(parse_action("unbind"), None); + assert_eq!(parse_action("unbind:"), None); +} + #[test] fn parse_if_chains_then_only() { assert_eq!( diff --git a/tests/listen.rs b/tests/listen.rs index e9ca97d4..0dced7f0 100644 --- a/tests/listen.rs +++ b/tests/listen.rs @@ -420,3 +420,36 @@ fn listen_yank() -> std::io::Result<()> { ); Ok(()) } + +// Bind a previously-unbound key over IPC, then trigger it from the keyboard. +#[test] +fn listen_bind() -> std::io::Result<()> { + let (tmux, mut stream) = setup("bind", &[])?; + sk_test!(@expand tmux; + @capture[2]starts_with("> a"); + // The header acknowledges that the preceding bind has been processed + // before terminal key events are sent through a separate channel. + send(&mut stream, "bind(ctrl-x:up)+set-header(ready)")?; + @capture[*]trim().eq("ready"); + @keys Ctrl(&Key('x')), Ctrl(&Key('x')); + @capture[*]starts_with("> c"); + ); + Ok(()) +} + +// Unbind a key over IPC so its keypress becomes a no-op. The trailing `x` +// distinguishes the two cases: if ctrl-u were still bound to unix-line-discard +// the query would read `> x`, but with it unbound the query is preserved. +#[test] +fn listen_unbind() -> std::io::Result<()> { + let (tmux, mut stream) = setup("unbind", &[])?; + sk_test!(@expand tmux; + @keys Str("hello"); + @capture[0]trim().eq("> hello"); + send(&mut stream, "unbind(ctrl-u)+set-header(ready)")?; + @capture[*]trim().eq("ready"); + @keys Ctrl(&Key('u')), Key('x'); + @capture[0]trim().eq("> hellox"); + ); + Ok(()) +}