Add result/focus/zero/one events and action follow-up bindings

Extend the bindable-event set and generalise binding so that actions can
themselves be bound.

New finder events (fired from the post-draw `Event::Render` path, so a
binding sees a stable, up-to-date list):
- `result` — filtering for the current query completed
- `focus`  — the focused item changed (cursor move or result update)
- `zero`   — a completed search has no matches
- `one`    — a completed search has exactly one match

`zero`/`one` read `MatcherControl::get_num_matched()` rather than the
rendered list count, which can briefly lag the matcher.

Actions as events: any action can be bound as if it were an event, so a
follow-up chain runs after it (e.g. `reload:first`, `first:last`). This is
parsed by `parse_action_binds` into `SkimOptions::action_binds` (keyed by
`Action::name`) and applied in `handle_action`, which now wraps the
per-variant `dispatch_action`.

- Keys win: a name shared by a key and an action binds the key; use an
  `act-` prefix to target the action (`act-up:down`).
- New `skip` action suppresses the triggering action's own behaviour, so
  `act-up:skip+down` remaps the up action to down and `up:skip` disables
  the up key.

Also fixes `parse_key` so a non-numeric `f…` name (e.g. `focus`, `first`)
falls through to name/event matching instead of erroring on the function-
key branch.

Adds unit and snapshot tests and documents everything in ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QKxusGbxLxqWdYyeqrb68
This commit is contained in:
Claude 2026-07-18 07:20:07 +00:00 committed by Loric ANDRE
parent 0279c0349a
commit 0ee3964b25
14 changed files with 605 additions and 20 deletions

View file

@ -965,15 +965,41 @@ and `parse_key` accepts the friendly names below:
| Bind name | `SkimEvent` | Reserved code | Fired when |
| --- | --- | --- | --- |
| `change` | `SkimEvent::Change` | `F(255)` | the query changes |
| `start` | `SkimEvent::Start` | `F(254)` | skim has started and entered its event loop (once) |
| `load` | `SkimEvent::Load` | `F(253)` | the reader finishes producing items (once per read; a `reload` fires it again) |
| `change` | `SkimEvent::Change` | `F(255)` | the query changes |
| `result` | `SkimEvent::Result` | `F(252)` | filtering for the current query completes and its results are ready |
| `focus` | `SkimEvent::Focus` | `F(251)` | the focused item changes (cursor movement or a result update) |
| `zero` | `SkimEvent::Zero` | `F(250)` | a completed search has no matches |
| `one` | `SkimEvent::One` | `F(249)` | a completed search has exactly one match |
`change` is injected by `App::on_query_changed` (`src/tui/app.rs`); `start` and
`load` are injected by `Skim::fire_start_event` / `Skim::check_reader`
(`src/skim.rs`) via `Event::Key(SkimEvent::_.into())`. Each flows through
`handle_key` and its keymap lookup like any other key, so an unbound event is a
harmless no-op.
`change` is injected by `App::on_query_changed`; `start` by
`Skim::fire_start_event` and `load`/`reader_done` via `Skim::check_reader`
(`src/skim.rs`). `result`, `zero`, `one` and `focus` are all injected from the
`Event::Render` handler *after* the frame is drawn, so a binding sees a stable,
up-to-date list; `zero`/`one` read `MatcherControl::get_num_matched()` (the
matcher's authoritative count, which the rendered list may briefly lag). Each
event flows through `handle_key` and its keymap lookup like any other key, so an
unbound event is a harmless no-op.
### Actions as Events (follow-up bindings)
Any **action** can also be bound as if it were an event: after the action runs,
a follow-up chain bound to its name is appended to `handle_action`'s result. For
example `reload:first` runs `first` right after a `reload`, and `first:last`
ends on the last item.
- **Keys win.** If a bind's "key" resolves to a real key it stays in the key
map, so a name shared by a key and an action (e.g. `up`) always binds the key.
Prefix with `act-` to target the action instead: `act-up:down`.
- **`skip`.** Including [`Action::Skip`] in the follow-up chain suppresses the
triggering action's own default behaviour, so `act-up:skip+down` remaps the
`up` action to `down`, and `up:skip` disables the up key. On its own `skip` is
a no-op.
Follow-up chains are parsed by `binds::parse_action_binds` into
`SkimOptions::action_binds` (keyed by `Action::name`), and applied in
`App::handle_action`, which wraps the per-variant `App::dispatch_action`.
### Action Dispatch
@ -1270,9 +1296,11 @@ The global allocator is `mimalloc` (v3), chosen for its low-latency multi-thread
| `popup::check_env` | `src/popup/mod.rs:72` | Guard: multiplexer present and not already in popup |
| `check_and_run_popup` | `src/bin/main.rs:131` | Check popup conditions, dispatch to popup::run_with |
| `sk_main` | `src/bin/main.rs:144` | CLI orchestration + output printing |
| `SkimEvent` | `src/binds.rs:25` | `start`/`load`/`change` synthetic events → reserved `KeyEvent` |
| `parse_key` | `src/binds.rs:196` | `"ctrl-a"``KeyEvent` |
| `parse_action_chain` | `src/binds.rs:270` | `"down+select"``Vec<Action>` |
| `SkimEvent` | `src/binds.rs:25` | `change`/`start`/`load`/`result`/`focus`/`zero`/`one` synthetic events → reserved `KeyEvent` |
| `parse_key` | `src/binds.rs:213` | `"ctrl-a"``KeyEvent` |
| `parse_action_binds` | `src/binds.rs:299` | `"reload:first"`, `"act-up:skip+down"` → action follow-up map |
| `parse_action_chain` | `src/binds.rs:329` | `"down+select"``Vec<Action>` |
| `Action::name` | `src/tui/event.rs:316` | `Action` → canonical bind name (reverse of `parse_action`) |
| `Matcher::create_engine_factory_with_builder` | `src/matcher.rs:189` | Build engine factory chain from options |
| `ExactOrFuzzyEngineFactory::create_engine_with_case` | `src/engine/factory.rs:93` | Parse query prefixes, build engine |
| `AndOrEngineFactory::parse_andor` | `src/engine/factory.rs:176` | Split query into AND/OR tree |

View file

@ -30,6 +30,15 @@ pub enum SkimEvent {
Load,
/// Fired whenever the query changes.
Change,
/// Fired when filtering for the current query completes and the result
/// list is ready.
Result,
/// Fired when the focused item changes (cursor movement or a result update).
Focus,
/// Fired when a completed search yields no matches.
Zero,
/// Fired when a completed search yields exactly one match.
One,
}
impl SkimEvent {
@ -40,6 +49,10 @@ impl SkimEvent {
SkimEvent::Change => KeyCode::F(255),
SkimEvent::Start => KeyCode::F(254),
SkimEvent::Load => KeyCode::F(253),
SkimEvent::Result => KeyCode::F(252),
SkimEvent::Focus => KeyCode::F(251),
SkimEvent::Zero => KeyCode::F(250),
SkimEvent::One => KeyCode::F(249),
}
}
@ -58,6 +71,10 @@ impl SkimEvent {
"start" => Some(SkimEvent::Start),
"load" => Some(SkimEvent::Load),
"change" => Some(SkimEvent::Change),
"result" => Some(SkimEvent::Result),
"focus" => Some(SkimEvent::Focus),
"zero" => Some(SkimEvent::Zero),
"one" => Some(SkimEvent::One),
_ => None,
}
}
@ -227,8 +244,11 @@ pub fn parse_key(key: &str) -> Result<KeyEvent> {
} else {
keycode = KeyCode::Char(char);
}
} else if let Some(f) = key.strip_prefix('f') {
let f_index = f.parse::<u8>()?;
} else if let Some(f) = key.strip_prefix('f')
&& let Ok(f_index) = f.parse::<u8>()
{
// A function key like `f10`. If the suffix isn't numeric (e.g. `focus`,
// `first`), fall through to the named-key / event matching below.
keycode = KeyCode::F(f_index);
} else {
keycode = match key.as_str() {
@ -288,6 +308,45 @@ fn split_top_level(value: &str, separator: char) -> Vec<&str> {
parts
}
/// Parses follow-up action bindings from raw `--bind` specs.
///
/// Any action can be bound as if it were an event: when the "key" of a bind is
/// not a real key but is a known action name, the bound chain becomes a
/// *follow-up* that runs right after that action. For example `reload:first`
/// queues `first` immediately after a `reload`. The returned map is keyed by the
/// action's canonical name (see [`Action::name`](crate::tui::event::Action::name)),
/// so it can be looked up directly from the action that just ran.
///
/// Keys take precedence: if the "key" resolves to a real key it is left to the
/// key map, so a name shared by a key and an action (e.g. `up`) always binds the
/// key. To target the action in that case, prefix it with `act-` (`act-up`).
#[must_use]
pub fn parse_action_binds<'a, T>(maps: T) -> HashMap<String, Vec<Action>>
where
T: Iterator<Item = &'a str>,
{
let mut res = HashMap::new();
for map in maps {
let Some((key, chain)) = map.split_once(':') else {
continue;
};
// Keys win: anything that parses as a real key is not an action trigger.
if parse_key(key).is_ok() {
continue;
}
// `act-<name>` explicitly targets the action `<name>`, even when `<name>`
// is also a key. Without the prefix, a bare action name still works as
// long as it isn't a key.
let action_name = key.strip_prefix("act-").unwrap_or(key);
if let Some(action) = event::parse_action(action_name)
&& let Ok(actions) = parse_action_chain(chain)
{
res.insert(action.name().to_string(), actions);
}
}
res
}
/// Parses an action chain, separated by '+'s into the corresponding actions
///
/// # Errors

View file

@ -136,6 +136,10 @@ fn skim_event_name_roundtrip() {
("start", SkimEvent::Start),
("load", SkimEvent::Load),
("change", SkimEvent::Change),
("result", SkimEvent::Result),
("focus", SkimEvent::Focus),
("zero", SkimEvent::Zero),
("one", SkimEvent::One),
] {
assert_eq!(SkimEvent::from_name(name), Some(event));
assert_eq!(parse_key(name).unwrap(), KeyEvent::from(event));
@ -197,6 +201,46 @@ fn parse_keymaps_collects_iterator() {
assert!(keymap.get(&parse_key("ctrl-x").unwrap()).is_some());
}
#[test]
fn action_binds_key_wins_over_action() {
// A bare action name that is not a key binds the action as a follow-up.
let binds = parse_action_binds(["first:last"].into_iter());
assert_eq!(binds.get("first"), Some(&vec![Last]));
// A name that is also a real key (`up`) is left to the key map, so it is
// NOT registered as an action trigger.
let binds = parse_action_binds(["up:down"].into_iter());
assert!(!binds.contains_key("up"));
// `act-` forces the action interpretation even for a key-shaped name.
let binds = parse_action_binds(["act-up:down"].into_iter());
assert_eq!(binds.get("up"), Some(&vec![Down(1)]));
}
#[test]
fn action_binds_parse_skip_chain() {
// `skip` is parsed like any other action and kept in the chain.
let binds = parse_action_binds(["act-up:skip+down"].into_iter());
assert_eq!(binds.get("up"), Some(&vec![Skip, Down(1)]));
}
#[test]
fn action_name_round_trips_through_parse_action() {
// Every canonical name resolves back to an action with the same name.
for name in [
"abort",
"first",
"last",
"reload",
"skip",
"backward-delete-char/eof",
"up",
] {
let action = event::parse_action(name).unwrap_or_else(|| panic!("`{name}` should parse"));
assert_eq!(action.name(), name);
}
}
#[test]
fn parse_action_chain_unknown_is_error() {
assert!(parse_action_chain("not-a-real-action").is_err());

View file

@ -1112,6 +1112,14 @@ pub struct SkimOptions {
/// The internal (parsed) keymap
#[cfg_attr(feature = "cli", clap(skip))]
pub keymap: KeyMap,
/// Follow-up action bindings, keyed by the canonical action name.
///
/// Populated from `--bind` entries whose "key" is an action name rather than
/// a real key (e.g. `reload:first`). After an action runs, the chain bound to
/// its name is queued.
#[cfg_attr(feature = "cli", clap(skip))]
pub action_binds: std::collections::HashMap<String, Vec<Action>>,
}
impl Default for SkimOptions {
@ -1267,6 +1275,7 @@ impl Default for SkimOptions {
selector: Default::default(),
preview_fn: Default::default(),
keymap: Default::default(),
action_binds: Default::default(),
#[cfg(feature = "cli")]
shell: Default::default(),
#[cfg(feature = "cli")]
@ -1312,6 +1321,14 @@ impl SkimOptions {
res
});
// Bindings whose "key" is an action name (e.g. `reload:first`) become
// follow-up actions that run right after that action.
self.action_binds = self
.bind
.iter()
.flat_map(|part| crate::binds::parse_action_binds(part.split(',')))
.collect();
if self.reverse {
self.layout = TuiLayout::Reverse;
}

View file

@ -22,6 +22,7 @@ use super::event::Action;
use super::header::Header;
use super::item_list::ItemList;
use super::{Event, Tui, input, preview};
use crate::binds::SkimEvent;
use crate::thread_pool::{self, ThreadPool};
use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use eyre::{Result, bail};
@ -133,6 +134,12 @@ pub struct App {
/// Whether the `load` event has been fired for the current read. Reset on
/// `reload` so a new read fires `load` again.
pub load_event_fired: bool,
/// Set whenever a matcher run is (re)started; drives the one-shot `result`
/// (and `zero`/`one`) events once that run completes and is rendered.
pub result_pending: bool,
/// The item that currently has focus, tracked so the `focus` event fires
/// only when it actually changes (cursor movement or a result update).
last_focused: Option<Arc<dyn SkimItem>>,
}
impl Widget for &mut App {
@ -258,6 +265,8 @@ impl Default for App {
currently_scrolling: false,
reader_done: false,
load_event_fired: false,
result_pending: false,
last_focused: None,
}
}
}
@ -326,6 +335,8 @@ impl App {
currently_scrolling: false,
reader_done: false,
load_event_fired: false,
result_pending: false,
last_focused: None,
}
}
@ -561,15 +572,42 @@ impl App {
f.render_widget(&mut *self, f.area());
f.set_cursor_position(self.cursor_pos);
})?;
// The reader has finished and the freshly-read items have now
// been merged into the item list by the render above, so the
// list is stable: fire the one-shot `load` event. Routed through
// the keymap like any key, so `--bind load:<action>` runs and can
// safely inspect the fully-populated list.
// The synthetic finder events below are fired *after* the draw,
// once the item list reflects the latest reader/matcher output,
// so a binding can safely inspect a stable, up-to-date list. Each
// is routed through the keymap like any key press.
// `load`: the reader has finished and its items are now rendered.
if self.reader_done && !self.load_event_fired {
self.load_event_fired = true;
tui.event_tx
.try_send(Event::Key(crate::binds::SkimEvent::Load.into()))?;
tui.event_tx.try_send(Event::Key(SkimEvent::Load.into()))?;
}
// `result` (+ `zero`/`one`): the in-flight search has completed
// and its results are on screen.
if self.result_pending && self.matcher_control.stopped() {
self.result_pending = false;
tui.event_tx.try_send(Event::Key(SkimEvent::Result.into()))?;
// Use the matcher's own count, which is authoritative as soon
// as it stops; the rendered `item_list` may briefly lag it.
match self.matcher_control.get_num_matched() {
0 => tui.event_tx.try_send(Event::Key(SkimEvent::Zero.into()))?,
1 => tui.event_tx.try_send(Event::Key(SkimEvent::One.into()))?,
_ => {}
}
}
// `focus`: the focused item changed (cursor movement or a result
// update that shifted the current row to a different item).
let focused = self.item_list.selected().map(|m| m.item);
let focus_changed = match (&self.last_focused, &focused) {
(Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
(None, None) => false,
_ => true,
};
if focus_changed {
self.last_focused = focused;
tui.event_tx.try_send(Event::Key(SkimEvent::Focus.into()))?;
}
}
Event::Heartbeat | Event::Tick => {
@ -722,8 +760,39 @@ impl App {
vec![]
}
#[allow(clippy::too_many_lines)]
/// Runs an action, then appends any follow-up actions bound to it.
///
/// An action can be bound as if it were an event (e.g. `reload:first`): once
/// the action has run, the chain the user bound to its name is queued after
/// the action's own events. See [`Action::name`](crate::tui::event::Action::name).
///
/// If that follow-up chain contains [`Action::Skip`], the action's own
/// default behaviour is suppressed and only the rest of the chain runs, so
/// `act-up:skip+down` remaps the `up` action to `down`.
fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
let follow = self.options.action_binds.get(act.name()).cloned();
let skip_default = follow
.as_ref()
.is_some_and(|chain| chain.iter().any(|a| matches!(a, Action::Skip)));
let mut events = if skip_default {
Vec::new()
} else {
self.dispatch_action(act)?
};
if let Some(chain) = follow {
events.extend(
chain
.into_iter()
.filter(|a| !matches!(a, Action::Skip))
.map(Event::Action),
);
}
Ok(events)
}
#[allow(clippy::too_many_lines)]
fn dispatch_action(&mut self, act: &Action) -> Result<Vec<Event>> {
#[allow(clippy::enum_glob_use)]
use Action::*;
use ratatui::widgets::ListDirection::{BottomToTop, TopToBottom};
@ -906,7 +975,10 @@ impl App {
.collect());
}
}
Ignore => (),
// `ignore` is a no-op. `skip` is also a no-op on its own; its
// suppression effect is applied in `handle_action` when it appears in
// an action's follow-up chain.
Ignore | Skip => (),
KillLine => {
let cursor = self.input.cursor_pos as usize;
let deleted = self.input.split_off(cursor);
@ -1295,6 +1367,9 @@ impl App {
no_sort,
self.needs_render.clone(),
);
// A new search is in flight; arm the `result`/`zero`/`one` events to
// fire once it completes and its results are rendered.
self.result_pending = true;
}
}

View file

@ -260,6 +260,12 @@ pub enum Action {
SelectRow(usize),
/// Select current item
Select,
/// Suppress the default behaviour of the action this is bound to.
///
/// Only meaningful as a follow-up bound to an action (e.g. `act-up:skip`):
/// it cancels that action's own effect, so the remaining follow-up chain
/// runs in its place. On its own it is a no-op.
Skip,
/// Set the header (or disable it on an empty value)
SetHeader(Option<String>),
/// Set the preview cmd and rerun preview
@ -302,6 +308,104 @@ pub enum Action {
Custom(ActionCallback),
}
impl Action {
/// Returns the canonical kebab-case name of this action — the same spelling
/// [`parse_action`] accepts.
///
/// This lets an action be bound as if it were an event (e.g. `reload:first`):
/// after the action runs, any follow-up chain keyed by this name is queued.
/// The name ignores the action's arguments, so `down` matches `Down(1)` and
/// `Down(5)` alike.
#[must_use]
pub fn name(&self) -> &'static str {
use Action::{
Abort, Accept, AddChar, AppendAndSelect, BackwardChar, BackwardDeleteChar, BackwardDeleteCharEof,
BackwardKillWord, BackwardWord, BeginningOfLine, Bind, 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, Skip, Toggle, ToggleAll,
ToggleIn, ToggleInteractive, ToggleOut, TogglePreview, TogglePreviewWrap, ToggleSort, Top, Unbind,
UnixLineDiscard, UnixWordRubout, Up, Yank,
};
match self {
Abort => "abort",
Accept(_) => "accept",
AddChar(_) => "add-char",
AppendAndSelect => "append-and-select",
BackwardChar => "backward-char",
BackwardDeleteChar => "backward-delete-char",
BackwardDeleteCharEof => "backward-delete-char/eof",
BackwardKillWord => "backward-kill-word",
BackwardWord => "backward-word",
BeginningOfLine => "beginning-of-line",
Bind(_) => "bind",
Cancel => "cancel",
ClearScreen => "clear-screen",
DeleteChar => "delete-char",
DeleteCharEof => "delete-char/eof",
DeselectAll => "deselect-all",
Down(_) => "down",
EndOfLine => "end-of-line",
Execute(_) => "execute",
ExecuteSilent(_) => "execute-silent",
First => "first",
ForwardChar => "forward-char",
ForwardWord => "forward-word",
IfQueryEmpty(..) => "if-query-empty",
IfQueryNotEmpty(..) => "if-query-not-empty",
IfNonMatched(..) => "if-non-matched",
Ignore => "ignore",
KillLine => "kill-line",
KillWord => "kill-word",
Last => "last",
NextHistory => "next-history",
HalfPageDown(_) => "half-page-down",
HalfPageUp(_) => "half-page-up",
PageDown(_) => "page-down",
PageUp(_) => "page-up",
PreviewUp(_) => "preview-up",
PreviewDown(_) => "preview-down",
PreviewLeft(_) => "preview-left",
PreviewRight(_) => "preview-right",
PreviewPageUp(_) => "preview-page-up",
PreviewPageDown(_) => "preview-page-down",
PreviousHistory => "previous-history",
Redraw => "redraw",
RefreshCmd => "refresh-cmd",
RefreshPreview => "refresh-preview",
RestartMatcher => "restart-matcher",
Reload(_) => "reload",
RotateMode => "rotate-mode",
ScrollLeft(_) => "scroll-left",
ScrollRight(_) => "scroll-right",
SelectAll => "select-all",
SelectRow(_) => "select-row",
Select => "select",
SetHeader(_) => "set-header",
SetPreviewCmd(_) => "set-preview-cmd",
SetQuery(_) => "set-query",
Skip => "skip",
Toggle => "toggle",
ToggleAll => "toggle-all",
ToggleIn => "toggle-in",
ToggleInteractive => "toggle-interactive",
ToggleOut => "toggle-out",
TogglePreview => "toggle-preview",
TogglePreviewWrap => "toggle-preview-wrap",
ToggleSort => "toggle-sort",
Top => "top",
Unbind(_) => "unbind",
UnixLineDiscard => "unix-line-discard",
UnixWordRubout => "unix-word-rubout",
Up(_) => "up",
Yank => "yank",
Custom(_) => "custom",
}
}
}
/// Parses an action string into an Action enum
///
/// Returns `None` if the action is unrecognized, or an `if-*` action is
@ -407,6 +511,7 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
"scroll-left" => Some(ScrollLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
"scroll-right" => Some(ScrollRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
"select" => Some(Select),
"skip" => Some(Skip),
"select-all" => Some(SelectAll),
"select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
"set-header" => Some(SetHeader(arg)),

View file

@ -67,6 +67,60 @@ insta_test!(bind_load, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["-
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "10");
});
// Any action can be bound as if it were an event: `first:last` runs `last`
// right after `first`, so pressing the key ends on the last item.
insta_test!(bind_action_followup, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "ctrl-a:first", "--bind", "first:last"], {
@ctrl 'a';
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "10");
@snap;
});
// `act-<name>` targets the *action* even when the name is also a key: `act-up`
// binds the Up action (not the up key). Bound to `last`, running the Up action
// appends a jump to the last item.
insta_test!(bind_act_prefix, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "act-up:last"], {
@action Up(1);
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "10");
@snap;
});
// `skip` suppresses the triggering action's own effect. Here the First action is
// remapped: instead of jumping to the first item it only sets the header, so the
// cursor stays put (on the last item) and the header is updated.
insta_test!(bind_skip, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "act-first:skip+set-header(skipped)"], {
@action Last;
@action First;
@assert(|h: &common::insta::TestHarness| h.skim.app().header.header == "skipped");
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "10");
@snap;
});
// Test result event: fires when filtering completes and the list is ready.
insta_test!(bind_result, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "result:last"], {
@snap;
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "10");
});
// Test focus event: fires when the focused item changes (here, initial focus).
insta_test!(bind_focus, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "focus:set-header(focused)"], {
@snap;
@assert(|h: &common::insta::TestHarness| h.skim.app().header.header == "focused");
});
// Test zero event: fires when a completed search has no matches.
insta_test!(bind_zero, ["a", "b", "c"], &["--bind", "zero:set-header(none)"], {
@char 'z';
@snap;
@assert(|h: &common::insta::TestHarness| h.skim.app().header.header == "none");
});
// Test one event: fires when a completed search has exactly one match.
insta_test!(bind_one, ["apple", "banana", "cherry"], &["--bind", "one:set-header(single)"], {
@type "app";
@snap;
@assert(|h: &common::insta::TestHarness| h.skim.app().header.header == "single");
});
insta_test!(bind_set_query_basic, ["a", "b", "c"], &["--bind", "ctrl-a:set-query(foo)"], {
@snap;
@ctrl 'a';

View file

@ -0,0 +1,29 @@
---
source: tests/binds.rs
description: "input: items [\"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\"]\noptions: --bind act-up:last\nafter:\n @action Up(1)"
---
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
"> 10 "
" 9 "
" 8 "
" 7 "
" 6 "
" 5 "
" 4 "
" 3 "
" 2 "
" 1 "
" 10/10 9/0"
"> "
cursor: (24, 3)

View file

@ -0,0 +1,29 @@
---
source: tests/binds.rs
description: "input: items [\"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\"]\noptions: --bind ctrl-a:first --bind first:last\nafter:\n @ctrl 'a'"
---
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
"> 10 "
" 9 "
" 8 "
" 7 "
" 6 "
" 5 "
" 4 "
" 3 "
" 2 "
" 1 "
" 10/10 9/0"
"> "
cursor: (24, 3)

View file

@ -0,0 +1,29 @@
---
source: tests/binds.rs
description: "input: items [\"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\"]\noptions: --bind focus:set-header(focused)"
---
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" 10 "
" 9 "
" 8 "
" 7 "
" 6 "
" 5 "
" 4 "
" 3 "
" 2 "
"> 1 "
" focused "
" 10/10 0/0"
"> "
cursor: (24, 3)

View file

@ -0,0 +1,29 @@
---
source: tests/binds.rs
description: "input: items [\"apple\", \"banana\", \"cherry\"]\noptions: --bind one:set-header(single)\nafter:\n @type \"app\""
---
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
"> apple "
" single "
" 1/3 0/0"
"> app "
cursor: (24, 6)

View file

@ -0,0 +1,29 @@
---
source: tests/binds.rs
description: "input: items [\"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\"]\noptions: --bind result:last"
---
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
"> 10 "
" 9 "
" 8 "
" 7 "
" 6 "
" 5 "
" 4 "
" 3 "
" 2 "
" 1 "
" 10/10 9/0"
"> "
cursor: (24, 3)

View file

@ -0,0 +1,29 @@
---
source: tests/binds.rs
description: "input: items [\"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\"]\noptions: --bind act-first:skip+set-header(skipped)\nafter:\n @action Last\n @action First"
---
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
"> 10 "
" 9 "
" 8 "
" 7 "
" 6 "
" 5 "
" 4 "
" 3 "
" 2 "
" 1 "
" skipped "
" 10/10 9/0"
"> "
cursor: (24, 3)

View file

@ -0,0 +1,29 @@
---
source: tests/binds.rs
description: "input: items [\"a\", \"b\", \"c\"]\noptions: --bind zero:set-header(none)\nafter:\n @char 'z'"
---
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" none "
" 0/3 0/0"
"> z "
cursor: (24, 4)