mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 07:16:23 -04:00
feat: add bind and unbind actions (#1121)
* feat: add `bind` and `unbind` actions Add two new actions that allow programmatic (re)binding of keys at runtime, both through `--bind` keybindings and the IPC/listen socket: - `bind(key:action[+action][,key:action…])` adds one or more bindings, reusing the same parsing/merging logic as the `--bind` CLI option. Existing bindings for the same key are replaced. - `unbind(key[,key…])` removes the bindings for a comma-separated list of keys, mirroring fzf's `unbind(...)` semantics. Because the `Action` enum derives serde when the `listen` feature is enabled, both actions are drivable over the IPC socket for free. Covered by unit tests for parsing (`event_tests.rs`) and dispatch (`app_tests.rs`), plus IPC integration tests (`listen.rs`). Manpage and ARCHITECTURE.md updated with the new actions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JYqqomFjYqqXd5NvkVxfbQ * feat: add `bind` and `unbind` actions * chore: generate files * fixes * fixes --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ff98133bbd
commit
ad97bfa7de
|
|
@ -979,6 +979,7 @@ Event::Action(a) → handle_action(a) → Vec<Event>
|
|||
| 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
29
src/binds.rs
29
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<Vec<Action>> {
|
||||
let mut actions: Vec<Action> = 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('{'))
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -704,17 +704,8 @@ impl App {
|
|||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Action> {
|
|||
}
|
||||
} 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<Action> {
|
|||
"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<Action> {
|
|||
"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))),
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue