mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 07:16:23 -04:00
feat: allow binding actions & more events (#1125)
* Add `start` and `load` events alongside `change` Introduce `start` and `load` bindable events, mirroring the existing `change` event, so `--bind start:<action>` and `--bind load:<action>` work. To avoid scattering magic high-F-key literals (`F(255)` for `change`), add a `SkimEvent` enum in `binds.rs` with `Start`, `Load` and `Change` variants that transparently convert to the reserved `KeyEvent`s used to route them through the keymap. `parse_key` now accepts the friendly names `start`, `load` and `change` via `SkimEvent::from_name`. The keymap key type stays `KeyEvent`, so the public API is unchanged. Firing: - `change` is emitted by `on_query_changed` (now via the named variant). - `start` fires exactly once when skim enters its event loop (`Skim::fire_start_event`). - `load` fires once the reader has finished AND the freshly-read items have been rendered into the list, so a `load` binding acts on a fully-populated, stable list. It is re-armed on `reload`. Add unit coverage for the event-name round-trip and integration tests for `start` and `load` bindings, and document the events in ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QKxusGbxLxqWdYyeqrb68 * 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 * Rename skip action to suppress and document it in the manpage Rename the `skip` action to `suppress`, which more clearly conveys that it cancels the triggering action's default behaviour. Add it to the manpage actions list, noting that when bound to an action it suppresses that action's default (so the rest of the chain runs in its place), and when bound to a key it is equivalent to `ignore`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QKxusGbxLxqWdYyeqrb68 * Fire finder events from callbacks instead of the render path Move the synthetic finder events off the per-render check: - `focus` now rides along with `App::on_selection_changed` (via a small `take_focus_event` helper), firing only when the focused item actually changes on cursor movement. - `load`/`result`/`zero`/`one` track async reader/matcher completion, which has no synchronous callback, so `App::poll_completion_events` edge-triggers them from the `Heartbeat` handler rather than the render path. A `Render` is queued just before them so a list-inspecting binding (e.g. `load:first`) still sees the finished results. This removes the branching that previously ran on every render tick and keeps the event logic out of the unrelated `dispatch_action` arms. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QKxusGbxLxqWdYyeqrb68 * misc tweaks incl. noremap * fixes * docs: document new binds * coderabbit review * fix: address Copilot review comments on action binds - Split `--bind` specs with top-level comma splitting in `SkimOptions::build` so commas inside parenthesized action arguments (e.g. `act-up:execute(echo a,b)`) no longer garble follow-up bindings. Reuses the existing `split_top_level` helper (now `pub(crate)`), matching `KeyMap::add_keymaps_str`. - Correct the misleading `load` event comment in `check_reader`: the event is fired from `App::poll_completion_events` (the heartbeat handler), not the render path. - Add a unit test covering commas inside action arguments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pxems1gNKKezFcxtZfURJp * fix: harden action-trigger binds (logging, runtime bind/unbind, API surface) Address three review findings on the action-trigger feature: - Log dropped `--bind` specs: an unknown trigger name or an invalid follow-up chain in parse_action_binds is now reported via debug! instead of vanishing silently, matching the keymap path's behavior. - Make the runtime `bind`/`unbind` actions manage action triggers as well as keys: `bind(act-up:last)` merges into action_binds and `unbind(act-up)` removes the trigger, with the same keys-win precedence as `--bind`. Trigger-name resolution is shared through a new binds::action_trigger_name helper. - Narrow the new App fields (reader_done, load_event_fired, result_pending) to pub(crate): they are a Skim<->App coordination protocol, not public API. Update the manpage (regenerated sk.1) and ARCHITECTURE.md accordingly, and cover the new behavior with unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TgSqqsXn1GfQRZhQWAWi7t * fix: don't abort the event loop on an invalid if-* branch chain `if-*` branch chains are stored unparsed by `parse_action`, so an invalid action name only surfaces when the binding fires. `dispatch_conditional` propagated that parse error out of `App::handle_event`, killing the whole finder mid-session on a bind typo. Log and skip the chain instead, matching the invalid-chain handling of `parse_action_binds`. Also refresh the stale line numbers in the ARCHITECTURE.md cross-reference table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TgSqqsXn1GfQRZhQWAWi7t * fix: make sure to render before start in sync mode * chore: push snaps * fix: misc * fix: address review comments on start event, if-* logging and docs - skim.rs: retry the one-shot `start` event from `tick()` so a momentarily full bounded `event_tx` at the `start()`/`enter()` call sites can no longer drop it permanently. Idempotent via the `start_fired` guard. - app.rs: log an invalid `if-*` conditional action chain at `warn!` instead of `debug!` so a misconfigured binding is discoverable by default. - ARCHITECTURE.md: clarify that `Skim::check_reader` only records `reader_done`; `App::poll_completion_events` owns and emits `load`/`result`/`zero`/`one`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QKxusGbxLxqWdYyeqrb68 * chore: minor formatting --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
76a27ed80c
commit
776d708ede
128
ARCHITECTURE.md
128
ARCHITECTURE.md
|
|
@ -958,6 +958,78 @@ Notable defaults:
|
|||
|
||||
User bindings from `--bind key:action[+action]` are parsed at startup and merged via `KeyMap::add_keymaps()`.
|
||||
|
||||
### Synthetic Events (`SkimEvent`)
|
||||
|
||||
Besides real key presses, skim fires a few *synthetic* events that can be bound
|
||||
to actions just like keys. Because the keymap is keyed by
|
||||
`crossterm::event::KeyEvent`, these events are represented *transparently* as
|
||||
reserved function-key codes in the high-`F` range that no real terminal emits.
|
||||
The [`SkimEvent`](src/binds.rs) enum gives them named variants so the reserved
|
||||
codes live in one place rather than being scattered as magic `F(255)` literals,
|
||||
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) |
|
||||
| `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 on cursor movement or a result update |
|
||||
| `zero` | `SkimEvent::Zero` | `F(250)` | the reader is done and the final search has no matches |
|
||||
| `one` | `SkimEvent::One` | `F(249)` | the reader is done and the final search has exactly one match |
|
||||
|
||||
Events are injected from the nearest state-change site:
|
||||
|
||||
- **`change`** — `App::on_query_changed`.
|
||||
- **`focus`** — `App::on_selection_changed` handles cursor movement;
|
||||
`Event::Render` checks again after matcher output is merged into the list so
|
||||
result-driven focus changes are also observed. `take_focus_event` de-duplicates
|
||||
both paths.
|
||||
- **`start`** — `Skim::fire_start_event` (`src/skim.rs`). `Skim::check_reader`
|
||||
only records the `reader_done` state; it does not itself emit `load`.
|
||||
- **`load`/`result`/`zero`/`one`** — these track *async* reader/matcher
|
||||
completion, which has no synchronous callback, so `App::poll_completion_events`
|
||||
owns and edge-triggers them from the `Heartbeat` handler (not the render path). A
|
||||
`Render` is queued just before them so a binding that inspects the list (e.g.
|
||||
`load:first`) sees the finished results. `result` may fire for intermediate
|
||||
matcher passes while input is streaming; `zero`/`one` wait for `reader_done`
|
||||
before reading `MatcherControl::get_num_matched()`, so a transient empty or
|
||||
one-item pass cannot terminate the finder before later input arrives.
|
||||
|
||||
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 dispatched directly. 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`.
|
||||
- **Non-recursive.** Follow-up actions use `noremap` semantics: actions in the
|
||||
right-hand chain do not trigger their own follow-up bindings.
|
||||
- **`suppress`.** Including [`Action::Suppress`] in the follow-up chain cancels
|
||||
only the triggering action's default behaviour. Thus
|
||||
`act-up:suppress+down+up` executes `down` then `up` once. On its own,
|
||||
`suppress` is a no-op (equivalent to `ignore`).
|
||||
|
||||
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 dispatches each chain member through the private
|
||||
per-variant `App::dispatch_action` without re-entering `handle_action`.
|
||||
Conditional actions likewise dispatch their selected subaction chain immediately
|
||||
through `dispatch_action`, preserving the same non-recursive semantics. When a
|
||||
directly dispatched subaction accepts or aborts, `App::final_action` records it
|
||||
and `Skim::tick` copies it to `Skim::final_event`, so output and exit status
|
||||
reflect the actual terminating action rather than its outer trigger.
|
||||
The runtime `bind`/`unbind` actions manage action triggers as well as keys:
|
||||
`bind(act-up:last)` merges into `action_binds` and `unbind(act-up)` removes the
|
||||
trigger (resolved via `binds::action_trigger_name`), with the same keys-win
|
||||
precedence as `--bind`.
|
||||
|
||||
### Action Dispatch
|
||||
|
||||
```
|
||||
|
|
@ -970,7 +1042,9 @@ Event::Key(k) → handle_key(k)
|
|||
Event::Action(a) → handle_action(a) → Vec<Event>
|
||||
```
|
||||
|
||||
`handle_action` is a large match statement covering all ~70+ `Action` variants. Key action categories:
|
||||
`handle_action` is a large match statement covering all ~70+ `Action` variants. The local
|
||||
`define_action_catalog!` macro is the single source for each variant's canonical bind name and parser arm, so
|
||||
`Action::name` and `parse_action` cannot drift. Key action categories:
|
||||
|
||||
| Category | Actions |
|
||||
| --- | --- |
|
||||
|
|
@ -984,7 +1058,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 |
|
||||
| Bindings | `Bind(spec)` — add `trigger:action[+action]` bindings (keys or action triggers) at runtime; `Unbind(triggers)` — remove bindings for a comma-separated list of keys or action triggers |
|
||||
| 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.
|
||||
|
|
@ -1026,6 +1100,9 @@ pub enum ItemPreview {
|
|||
`Skim::output()` is called after the event loop exits:
|
||||
|
||||
```
|
||||
Skim::tick()
|
||||
└─ app.final_action.take() → final_event ← includes nested follow-up/conditional actions
|
||||
|
||||
Skim::output()
|
||||
├─ reader_control.kill() ← stop reader threads
|
||||
├─ is_abort = !matches!(final_event, Action::Accept)
|
||||
|
|
@ -1219,25 +1296,27 @@ The global allocator is `mimalloc` (v3), chosen for its low-latency multi-thread
|
|||
|
||||
| Call site | File | What it does |
|
||||
| --- | --- | --- |
|
||||
| `Skim::run_with` | `src/skim.rs:58` | Top-level library entry point |
|
||||
| `Skim::run_items` | `src/skim.rs:100` | Convenience wrapper for iterator inputs |
|
||||
| `Skim::init_tui` | `src/skim.rs:124` | Initialize default crossterm TUI backend |
|
||||
| `Skim::init` | `src/skim.rs:143` | Constructs all subsystems from options |
|
||||
| `Skim::start` | `src/skim.rs:185` | Starts reader + initial matcher pass |
|
||||
| `Skim::handle_reload` | `src/skim.rs:195` | Kills reader, clears pool, restarts |
|
||||
| `Skim::init_tui_with` | `src/skim.rs:258` | Install a caller-provided TUI backend |
|
||||
| `Skim::enter` | `src/skim.rs:345` | Enter terminal, resolve image picker, start listener/event pump |
|
||||
| `Skim::should_enter` | `src/skim.rs:385` | Filter/select-1/exit-0/sync gate |
|
||||
| `Skim::output` | `src/skim.rs:488` | Collect & return SkimOutput |
|
||||
| `Skim::tick` | `src/skim.rs:569` | Single async event loop iteration |
|
||||
| `App::from_options` | `src/tui/app.rs:332` | Build all widgets from options |
|
||||
| `App::run_preview` | `src/tui/app.rs:484` | Expand cmd, debounce, call Preview::spawn |
|
||||
| `App::handle_event` | `src/tui/app.rs:609` | Dispatch all Event variants |
|
||||
| `App::handle_action` | `src/tui/app.rs:793` | Dispatch all Action variants |
|
||||
| `run_foreground` | `src/tui/app.rs:70` | Suspend reader, run `execute` child with its own tty stdin, restart reader |
|
||||
| `App::restart_matcher` | `src/tui/app.rs:1282` | Kill old match pass, start new one |
|
||||
| `App::expand_cmd` | `src/tui/app.rs:1355` | Substitute `{}`, `{q}`, `{n}` etc. |
|
||||
| `Widget::render (App)` | `src/tui/app.rs:200` | Root render; calls all sub-widgets |
|
||||
| `Skim::run_with` | `src/skim.rs:70` | Top-level library entry point |
|
||||
| `Skim::run_items` | `src/skim.rs:112` | Convenience wrapper for iterator inputs |
|
||||
| `Skim::init_tui` | `src/skim.rs:136` | Initialize default crossterm TUI backend |
|
||||
| `Skim::init` | `src/skim.rs:155` | Constructs all subsystems from options |
|
||||
| `Skim::start` | `src/skim.rs:199` | Starts reader + initial matcher pass |
|
||||
| `Skim::handle_reload` | `src/skim.rs:231` | Kills reader, clears pool, restarts |
|
||||
| `Skim::init_tui_with` | `src/skim.rs:303` | Install a caller-provided TUI backend |
|
||||
| `Skim::enter` | `src/skim.rs:390` | Enter terminal, resolve image picker, start listener/event pump |
|
||||
| `Skim::should_enter` | `src/skim.rs:434` | Filter/select-1/exit-0/sync gate |
|
||||
| `Skim::output` | `src/skim.rs:539` | Collect & return SkimOutput |
|
||||
| `Skim::tick` | `src/skim.rs:620` | Single async event loop iteration |
|
||||
| `App::from_options` | `src/tui/app.rs:285` | Build all widgets from options |
|
||||
| `App::run_preview` | `src/tui/app.rs:498` | Expand cmd, debounce, call Preview::spawn |
|
||||
| `App::handle_event` | `src/tui/app.rs:623` | Dispatch all Event variants |
|
||||
| `App::handle_action` | `src/tui/app.rs:828` | Apply action follow-up bindings |
|
||||
| `App::dispatch_conditional` | `src/tui/app.rs:847` | Dispatch the selected conditional subaction chain without follow-up bindings |
|
||||
| `App::dispatch_action` | `src/tui/app.rs:870` | Dispatch one Action variant without follow-up bindings |
|
||||
| `Tui::run_execute` | `src/tui/backend.rs:353` | Suspend reader, run `execute` child with its own tty stdin, restart reader |
|
||||
| `App::restart_matcher` | `src/tui/app.rs:1355` | Kill old match pass, start new one |
|
||||
| `App::expand_cmd` | `src/tui/app.rs:1431` | Substitute `{}`, `{q}`, `{n}` etc. |
|
||||
| `Widget::render (App)` | `src/tui/app.rs:149` | Root render; calls all sub-widgets |
|
||||
| `Matcher::run` | `src/matcher.rs:~260` | Parallel match dispatch |
|
||||
| `merge_worker_results` | `src/matcher.rs:28` | Merge k sorted runs → ProcessedItems |
|
||||
| `ItemPool::append` | `src/item.rs:469` | Add items, notify matcher |
|
||||
|
|
@ -1256,8 +1335,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 |
|
||||
| `parse_key` | `src/binds.rs:139` | `"ctrl-a"` → `KeyEvent` |
|
||||
| `parse_action_chain` | `src/binds.rs:211` | `"down+select"` → `Vec<Action>` |
|
||||
| `SkimEvent` | `src/binds.rs:26` | `change`/`start`/`load`/`result`/`focus`/`zero`/`one` synthetic events → reserved `KeyEvent` |
|
||||
| `parse_key` | `src/binds.rs:223` | `"ctrl-a"` → `KeyEvent` |
|
||||
| `parse_action_binds` | `src/binds.rs:329` | `"reload:first"`, `"act-up:suppress+down"` → action follow-up map |
|
||||
| `parse_action_chain` | `src/binds.rs:377` | `"down+select"` → `Vec<Action>` |
|
||||
| `Action::name` | `src/tui/event.rs:331` | `Action` → canonical bind name (shared catalog with `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 |
|
||||
|
|
|
|||
|
|
@ -197,13 +197,14 @@ history: History scheme: will force index as the first tiebreak
|
|||
.SH INTERFACE
|
||||
.TP
|
||||
\fB\-b\fR, \fB\-\-bind\fR [\fI<BIND>...\fR] [default: ]
|
||||
Comma separated list of bindings
|
||||
Comma\-separated key, event, and action bindings
|
||||
|
||||
You can customize key bindings of sk with `\-\-bind` option which takes a comma\-separated list of
|
||||
key binding expressions. Each key binding expression follows the following format: `<key>:<action>`
|
||||
See the [KEYBINDS] section for details
|
||||
`\-\-bind` takes comma\-separated `<trigger>:<action>` expressions. A trigger can be a key, a finder
|
||||
event (`change`, `start`, `load`, `result`, `focus`, `zero`, or `one`), or an action name. Use the
|
||||
`act\-` prefix for action triggers; it is recommended to avoid ambiguity and required when the action
|
||||
name is also a key, for example `act\-up:last`. See the [KEYBINDS] section for details.
|
||||
|
||||
**Example**: `sk \-\-bind=ctrl\-j:accept,ctrl\-k:kill\-line`
|
||||
**Example**: `sk \-\-bind=ctrl\-j:accept,load:last,act\-up:down`
|
||||
|
||||
## Multiple actions can be chained using + separator.
|
||||
|
||||
|
|
@ -752,9 +753,9 @@ If a term is prefixed by `!`, sk will exclude the items that match this term.
|
|||
.SH KEYBINDS
|
||||
|
||||
.br
|
||||
Keybinds can be set by the `\-\-bind` option, which takes a comma\-separated list of [key]:[action[+action2].
|
||||
Bindings can be set by the `\-\-bind` option, which takes a comma\-separated list of `<trigger>:<action>[+action2]` expressions. A trigger can be a key, a finder event, or an action name.
|
||||
.br
|
||||
Actions can take arguments, specified either between parentheses `reload(ls)` or after a colon `reload:ls`
|
||||
Actions can take arguments, specified either between parentheses `reload(ls)` or after a colon `reload:ls`.
|
||||
.br
|
||||
|
||||
.SS "Available keys (aliases in parentheses)"
|
||||
|
|
@ -837,6 +838,34 @@ Actions can take arguments, specified either between parentheses `reload(ls)` or
|
|||
* any single character
|
||||
.br
|
||||
|
||||
.SS "Bindable finder events"
|
||||
|
||||
.br
|
||||
* change: the query changes
|
||||
.br
|
||||
* start: skim enters its event loop; fired once
|
||||
.br
|
||||
* load: the reader and matcher finish consuming the current input; fired once per read, including reloads
|
||||
.br
|
||||
* result: filtering for the current query completes
|
||||
.br
|
||||
* focus: the focused item changes because of cursor movement or a result update
|
||||
.br
|
||||
* zero: the input stream is complete and the final search has no matches
|
||||
.br
|
||||
* one: the input stream is complete and the final search has exactly one match
|
||||
.br
|
||||
|
||||
.SS "Actions as binding triggers"
|
||||
|
||||
.br
|
||||
Actions can also be used as binding triggers. A follow\-up chain bound to an action name runs immediately after that action. Use the `act\-` prefix for action triggers; it is recommended to avoid ambiguity and required when the action name is also a key, for example `act\-up:last`.
|
||||
.br
|
||||
|
||||
.br
|
||||
Follow\-up chains use non\-recursive (`noremap`) semantics: their actions do not trigger further action bindings. Add `suppress` to skip the triggering action\*(Aqs default behavior, for example `act\-up:suppress+down`.
|
||||
.br
|
||||
|
||||
.SS "Actions[:default keys][*notes]"
|
||||
|
||||
.br
|
||||
|
|
@ -858,7 +887,7 @@ 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)
|
||||
* bind(...): *arg is a comma\-separated list of `trigger:action[+action]` bindings to add (same syntax as \-\-bind, including action triggers such as `act\-up:last`)
|
||||
.br
|
||||
* clear\-screen: ctrl\-l
|
||||
.br
|
||||
|
|
@ -932,6 +961,8 @@ Actions can take arguments, specified either between parentheses `reload(ls)` or
|
|||
.br
|
||||
* set\-query(...): *arg will be a expanded expression, see COMMAND EXPANSION for details
|
||||
.br
|
||||
* suppress: *if bound to an action (e.g. `act\-up:suppress`), suppresses that action\*(Aqs default behavior so the rest of the non\-recursive chain runs once in its place; if bound to a key, equivalent to `ignore`
|
||||
.br
|
||||
* toggle
|
||||
.br
|
||||
* toggle\-all
|
||||
|
|
@ -954,7 +985,7 @@ 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
|
||||
* unbind(...): *arg is a comma\-separated list of keys or action triggers (e.g. `act\-up`) to unbind
|
||||
.br
|
||||
* unix\-line\-discard: ctrl\-u
|
||||
.br
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ complete -c sk -l split-match -d 'Enable split matching and set delimiter' -r
|
|||
complete -c sk -l scheme -r -f -a "default\t'Default scheme, no modifications to the options'
|
||||
path\t'Path scheme: will find the furthest match in the item and set pathname as the main tiebreak'
|
||||
history\t'History scheme: will force index as the first tiebreak'"
|
||||
complete -c sk -s b -l bind -d 'Comma separated list of bindings' -r
|
||||
complete -c sk -s b -l bind -d 'Comma-separated key, event, and action bindings' -r
|
||||
complete -c sk -s c -l cmd -d 'Command to invoke dynamically in interactive mode' -r
|
||||
complete -c sk -s I -d 'Replace replstr with the selected item in commands' -r
|
||||
complete -c sk -l color -d 'Set color theme' -r
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ module completions {
|
|||
--split-match: string # Enable split matching and set delimiter
|
||||
--last-match # Highlight the last match found, not the first one This makes tiebreak more pertinent on path items where we want to prioritize a match on the last parts
|
||||
--scheme: string@"nu-complete sk scheme"
|
||||
--bind(-b): string # Comma separated list of bindings
|
||||
--bind(-b): string # Comma-separated key, event, and action bindings
|
||||
--multi(-m) # Enable multiple selection
|
||||
--no-multi # Disable multiple selection
|
||||
--no-mouse # Disable mouse
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ smart\:"Smart case\: case-insensitive unless query contains uppercase"))' \
|
|||
'--scheme=[]:SCHEME:((default\:"Default scheme, no modifications to the options"
|
||||
path\:"Path scheme\: will find the furthest match in the item and set pathname as the main tiebreak"
|
||||
history\:"History scheme\: will force index as the first tiebreak"))' \
|
||||
'*-b+[Comma separated list of bindings]::BIND:_default' \
|
||||
'*--bind=[Comma separated list of bindings]::BIND:_default' \
|
||||
'*-b+[Comma-separated key, event, and action bindings]::BIND:_default' \
|
||||
'*--bind=[Comma-separated key, event, and action bindings]::BIND:_default' \
|
||||
'-c+[Command to invoke dynamically in interactive mode]:CMD:_default' \
|
||||
'--cmd=[Command to invoke dynamically in interactive mode]:CMD:_default' \
|
||||
'-I+[Replace replstr with the selected item in commands]:REPLSTR:_default' \
|
||||
|
|
|
|||
154
src/binds.rs
154
src/binds.rs
|
|
@ -11,6 +11,82 @@ use eyre::{Result, eyre};
|
|||
|
||||
use crate::tui::event::{self, Action};
|
||||
|
||||
/// Synthetic events that skim fires internally and that can be bound to actions
|
||||
/// via the keymap, exactly like a real key press.
|
||||
///
|
||||
/// The keymap is keyed by crossterm's [`KeyEvent`], which cannot express
|
||||
/// "the query changed" or "reading finished" directly. Each variant is
|
||||
/// therefore represented *transparently* as a reserved function-key code in the
|
||||
/// high-`F` range (`F(249)`–`F(255)`) that no real terminal ever emits. The
|
||||
/// seven variants are `change`, `start`, `load`, `result`, `focus`, `zero`, and
|
||||
/// `one`. Giving these reserved codes named variants keeps them in one place
|
||||
/// instead of scattering magic function-key literals across the codebase, and
|
||||
/// lets [`parse_key`] accept every friendly event name.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum SkimEvent {
|
||||
/// Fired once, when skim has started up and entered its event loop.
|
||||
Start,
|
||||
/// Fired when the reader finishes producing items (once per read; a
|
||||
/// `reload` starts a new read and fires it again).
|
||||
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 {
|
||||
/// The reserved [`KeyCode`] used to route this event through the keymap.
|
||||
#[must_use]
|
||||
pub const fn key_code(self) -> KeyCode {
|
||||
match self {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reserved [`KeyEvent`] used to route this event through the keymap.
|
||||
#[must_use]
|
||||
pub const fn key_event(self) -> KeyEvent {
|
||||
KeyEvent::new(self.key_code(), KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
/// Parses an event name (`start`, `load`, `change`) into a [`SkimEvent`].
|
||||
///
|
||||
/// Returns `None` if the name is not a recognised event.
|
||||
#[must_use]
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SkimEvent> for KeyEvent {
|
||||
fn from(event: SkimEvent) -> Self {
|
||||
event.key_event()
|
||||
}
|
||||
}
|
||||
|
||||
/// A map of key events to their associated actions
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KeyMap(pub HashMap<KeyEvent, Vec<Action>>);
|
||||
|
|
@ -135,7 +211,11 @@ pub fn get_default_key_map() -> KeyMap {
|
|||
KeyMap(ret)
|
||||
}
|
||||
|
||||
/// Parses a key str into a crossterm `KeyEvent`
|
||||
/// Parses a key str into a crossterm `KeyEvent`.
|
||||
///
|
||||
/// In addition to keyboard names, accepts all seven names recognized by
|
||||
/// [`SkimEvent::from_name`]: `change`, `start`, `load`, `result`, `focus`,
|
||||
/// `zero`, and `one`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the key string is empty, contains an unknown modifier,
|
||||
|
|
@ -169,8 +249,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() {
|
||||
|
|
@ -188,8 +271,10 @@ pub fn parse_key(key: &str) -> Result<KeyEvent> {
|
|||
"end" => KeyCode::End,
|
||||
"pgup" => KeyCode::PageUp,
|
||||
"pgdown" => KeyCode::PageDown,
|
||||
"change" => KeyCode::F(255),
|
||||
s => return Err(eyre!("Unknown key {}", s)),
|
||||
s => match SkimEvent::from_name(s) {
|
||||
Some(event) => event.key_code(),
|
||||
None => return Err(eyre!("Unknown key {}", s)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -208,7 +293,7 @@ where
|
|||
res
|
||||
}
|
||||
|
||||
fn split_top_level(value: &str, separator: char) -> Vec<&str> {
|
||||
pub(crate) fn split_top_level(value: &str, separator: char) -> Vec<&str> {
|
||||
let mut depth = 0_u32;
|
||||
let mut start = 0;
|
||||
let mut parts = Vec::new();
|
||||
|
|
@ -228,6 +313,63 @@ 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;
|
||||
}
|
||||
let Some(name) = action_trigger_name(key) else {
|
||||
debug!("Ignoring bind `{map}`: `{key}` is neither a key nor an action");
|
||||
continue;
|
||||
};
|
||||
match parse_action_chain(chain) {
|
||||
Ok(actions) => {
|
||||
res.insert(name.to_string(), actions);
|
||||
}
|
||||
Err(err) => debug!("Ignoring bind `{map}`: invalid action chain `{chain}`: {err}"),
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Resolves a bind trigger to the canonical name of the action it targets
|
||||
/// (see [`Action::name`](crate::tui::event::Action::name)). `act-<name>`
|
||||
/// explicitly targets the action `<name>`, even when `<name>` is also a key;
|
||||
/// without the prefix, a bare action name works too. Returns `None` if the
|
||||
/// name is not a known action.
|
||||
///
|
||||
/// This performs pure name resolution: callers that want "keys win" semantics
|
||||
/// (e.g. [`parse_action_binds`]) must check [`parse_key`] first.
|
||||
pub(crate) fn action_trigger_name(trigger: &str) -> Option<&'static str> {
|
||||
let action_name = trigger.strip_prefix("act-").unwrap_or(trigger);
|
||||
// Some actions require an argument when executed, but their canonical
|
||||
// name is still valid as a trigger. `()` supplies the parser's empty
|
||||
// placeholder solely for name validation.
|
||||
let action = event::parse_action(action_name).or_else(|| event::parse_action(&format!("{action_name}()")))?;
|
||||
Some(action.name())
|
||||
}
|
||||
|
||||
/// Parses an action chain, separated by '+'s into the corresponding actions
|
||||
///
|
||||
/// # Errors
|
||||
|
|
|
|||
|
|
@ -129,6 +129,30 @@ fn test_parse_key() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skim_event_name_roundtrip() {
|
||||
// Named events resolve to distinct reserved key events and back.
|
||||
for (name, event) in [
|
||||
("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));
|
||||
}
|
||||
// Unknown names are not events.
|
||||
assert_eq!(SkimEvent::from_name("nope"), None);
|
||||
// A binding referencing an event name resolves to an action chain.
|
||||
let keymap = KeyMap::from("start:first,load:last,change:first");
|
||||
assert!(keymap.get(&SkimEvent::Start.key_event()).is_some());
|
||||
assert!(keymap.get(&SkimEvent::Load.key_event()).is_some());
|
||||
assert!(keymap.get(&SkimEvent::Change.key_event()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_key_error_cases() {
|
||||
// Empty input.
|
||||
|
|
@ -177,6 +201,76 @@ 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)]));
|
||||
|
||||
// Actions that require arguments when executed are still valid triggers.
|
||||
let binds = parse_action_binds(
|
||||
[
|
||||
"act-add-char:last",
|
||||
"act-execute:last",
|
||||
"act-execute-silent:last",
|
||||
"act-set-preview-cmd:last",
|
||||
"act-set-query:last",
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
for name in ["add-char", "execute", "execute-silent", "set-preview-cmd", "set-query"] {
|
||||
assert_eq!(binds.get(name), Some(&vec![Last]), "missing trigger `{name}`");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_binds_parse_suppress_chain() {
|
||||
// `suppress` is parsed like any other action and kept in the chain.
|
||||
let binds = parse_action_binds(["act-up:suppress+down"].into_iter());
|
||||
assert_eq!(binds.get("up"), Some(&vec![Suppress, Down(1)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_trigger_name_resolves_actions() {
|
||||
// `act-` targets the action explicitly; a bare action name works too.
|
||||
assert_eq!(action_trigger_name("act-up"), Some("up"));
|
||||
assert_eq!(action_trigger_name("first"), Some("first"));
|
||||
// Argument-taking actions resolve by name alone.
|
||||
assert_eq!(action_trigger_name("act-execute"), Some("execute"));
|
||||
// Unknown names are not triggers.
|
||||
assert_eq!(action_trigger_name("nope"), None);
|
||||
assert_eq!(action_trigger_name("act-nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_binds_invalid_specs_are_skipped() {
|
||||
// An unknown trigger and an invalid chain are both dropped (with a debug
|
||||
// log) without affecting valid binds in the same list.
|
||||
let binds = parse_action_binds(["nokey:last", "act-up:not-an-action", "first:last"].into_iter());
|
||||
assert_eq!(binds.len(), 1);
|
||||
assert_eq!(binds.get("first"), Some(&vec![Last]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_binds_split_top_level_preserves_commas_in_args() {
|
||||
// A single `--bind` spec containing a comma inside `(...)` must not be split
|
||||
// there: `options.rs` uses `split_top_level(part, ',')` so the comma stays
|
||||
// part of the action argument instead of garbling the follow-up binding.
|
||||
let spec = "act-up:execute(echo a,b),first:last";
|
||||
let binds = parse_action_binds(split_top_level(spec, ',').into_iter());
|
||||
assert_eq!(binds.get("up"), Some(&vec![Execute(String::from("echo a,b"))]));
|
||||
assert_eq!(binds.get("first"), Some(&vec![Last]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_action_chain_unknown_is_error() {
|
||||
assert!(parse_action_chain("not-a-real-action").is_err());
|
||||
|
|
|
|||
|
|
@ -121,8 +121,30 @@ const KEYS_SS: &str = "
|
|||
* alt-shift-right
|
||||
* any single character
|
||||
";
|
||||
const ACTIONS_SS: &str = "
|
||||
* abort: ctrl-c ctrl-q esc
|
||||
const BINDABLE_EVENTS_SS: &str = concat!(
|
||||
"\n",
|
||||
"* change: the query changes\n",
|
||||
"* start: skim enters its event loop; fired once\n",
|
||||
"* load: the reader and matcher finish consuming the current input; ",
|
||||
"fired once per read, including reloads\n",
|
||||
"* result: filtering for the current query completes\n",
|
||||
"* focus: the focused item changes because of cursor movement or a result update\n",
|
||||
"* zero: the input stream is complete and the final search has no matches\n",
|
||||
"* one: the input stream is complete and the final search has exactly one match\n",
|
||||
);
|
||||
|
||||
const ACTION_BINDINGS_SS: &str = concat!(
|
||||
"\n",
|
||||
"Actions can also be used as binding triggers. A follow-up chain bound to an action name runs immediately ",
|
||||
"after that action. Use the `act-` prefix for action triggers; it is recommended to avoid ambiguity and ",
|
||||
"required when the action name is also a key, for example `act-up:last`.\n\n",
|
||||
"Follow-up chains use non-recursive (`noremap`) semantics: their actions do not trigger further action ",
|
||||
"bindings. Add `suppress` to skip the triggering action's default behavior, for example ",
|
||||
"`act-up:suppress+down`.\n",
|
||||
);
|
||||
|
||||
const ACTIONS_SS: &str = concat!(
|
||||
"\n* abort: ctrl-c ctrl-q esc
|
||||
* accept(...): enter *the argument will be printed when the binding is triggered*
|
||||
* append-and-select
|
||||
* backward-char: ctrl-b left
|
||||
|
|
@ -131,7 +153,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)
|
||||
* bind(...): *arg is a comma-separated list of `trigger:action[+action]` bindings to add (same syntax as --bind, including action triggers such as `act-up:last`)
|
||||
* clear-screen: ctrl-l
|
||||
* delete-char: del
|
||||
* delete-char/eof: ctrl-d
|
||||
|
|
@ -168,7 +190,10 @@ const ACTIONS_SS: &str = "
|
|||
* select-row
|
||||
* set-preview-cmd(...): *arg will be a expanded expression, see COMMAND EXPANSION for details
|
||||
* set-query(...): *arg will be a expanded expression, see COMMAND EXPANSION for details
|
||||
* toggle
|
||||
",
|
||||
"* suppress: *if bound to an action (e.g. `act-up:suppress`), suppresses that action's default behavior ",
|
||||
"so the rest of the non-recursive chain runs once in its place; if bound to a key, equivalent to `ignore`\n",
|
||||
"* toggle
|
||||
* toggle-all
|
||||
* toggle+down: ctrl-i tab
|
||||
* toggle-in: (--layout=reverse ? toggle+up: toggle+down)
|
||||
|
|
@ -179,12 +204,13 @@ const ACTIONS_SS: &str = "
|
|||
* toggle-sort
|
||||
* toggle+up: btab shift-tab
|
||||
* top
|
||||
* unbind(...): *arg is a comma-separated list of keys to unbind
|
||||
* unbind(...): *arg is a comma-separated list of keys or action triggers (e.g. `act-up`) to unbind
|
||||
* unix-line-discard: ctrl-u
|
||||
* unix-word-rubout: ctrl-w
|
||||
* up: ctrl-k ctrl-p up
|
||||
* yank: ctrl-y
|
||||
";
|
||||
",
|
||||
);
|
||||
|
||||
#[cfg(feature = "listen")]
|
||||
const REMOTE_SECTION: &str = "
|
||||
|
|
@ -284,12 +310,17 @@ Exact search can be enabled by default by the `--exact` command-line flag. In ex
|
|||
section(
|
||||
&mut custom,
|
||||
"KEYBINDS",
|
||||
"
|
||||
Keybinds can be set by the `--bind` option, which takes a comma-separated list of [key]:[action[+action2].
|
||||
Actions can take arguments, specified either between parentheses `reload(ls)` or after a colon `reload:ls`
|
||||
",
|
||||
concat!(
|
||||
"\nBindings can be set by the `--bind` option, which takes a comma-separated list of ",
|
||||
"`<trigger>:<action>[+action2]` expressions. A trigger can be a key, a finder event, or an action ",
|
||||
"name.\n",
|
||||
"Actions can take arguments, specified either between parentheses `reload(ls)` or after a colon ",
|
||||
"`reload:ls`.\n",
|
||||
),
|
||||
);
|
||||
subsection(&mut custom, "Available keys (aliases in parentheses)", KEYS_SS);
|
||||
subsection(&mut custom, "Bindable finder events", BINDABLE_EVENTS_SS);
|
||||
subsection(&mut custom, "Actions as binding triggers", ACTION_BINDINGS_SS);
|
||||
subsection(&mut custom, "Actions[:default keys][*notes]", ACTIONS_SS);
|
||||
|
||||
section(
|
||||
|
|
@ -407,4 +438,16 @@ mod tests {
|
|||
assert!(out.contains(section), "manpage should contain section '{section}'");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manpage_documents_bindable_events_and_actions() {
|
||||
let out = manpage_str();
|
||||
for event in ["change", "start", "load", "result", "focus", "zero", "one"] {
|
||||
assert!(out.contains(event), "manpage should document the '{event}' event");
|
||||
}
|
||||
assert!(out.contains("Actions as binding triggers"));
|
||||
assert!(out.contains("act\\-"));
|
||||
assert!(out.contains("noremap"));
|
||||
assert!(out.contains("suppress"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,13 +319,14 @@ pub struct SkimOptions {
|
|||
scheme: Option<MatchScheme>,
|
||||
|
||||
// --- Interface ---
|
||||
/// Comma separated list of bindings
|
||||
/// Comma-separated key, event, and action bindings
|
||||
///
|
||||
/// You can customize key bindings of sk with `--bind` option which takes a comma-separated list of
|
||||
/// key binding expressions. Each key binding expression follows the following format: `<key>:<action>`
|
||||
/// See the [KEYBINDS] section for details
|
||||
/// `--bind` takes comma-separated `<trigger>:<action>` expressions. A trigger can be a key, a finder
|
||||
/// event (`change`, `start`, `load`, `result`, `focus`, `zero`, or `one`), or an action name. Use the
|
||||
/// `act-` prefix for action triggers; it is recommended to avoid ambiguity and required when the action
|
||||
/// name is also a key, for example `act-up:last`. See the [KEYBINDS] section for details.
|
||||
///
|
||||
/// **Example**: `sk --bind=ctrl-j:accept,ctrl-k:kill-line`
|
||||
/// **Example**: `sk --bind=ctrl-j:accept,load:last,act-up:down`
|
||||
///
|
||||
/// ## Multiple actions can be chained using + separator.
|
||||
///
|
||||
|
|
@ -1112,6 +1113,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 +1276,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 +1322,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(crate::binds::split_top_level(part, ',').into_iter()))
|
||||
.collect();
|
||||
|
||||
if self.reverse {
|
||||
self.layout = TuiLayout::Reverse;
|
||||
}
|
||||
|
|
|
|||
47
src/skim.rs
47
src/skim.rs
|
|
@ -11,6 +11,7 @@ use tokio::runtime::Handle;
|
|||
use tokio::select;
|
||||
use tokio::task::block_in_place;
|
||||
|
||||
use crate::binds::SkimEvent;
|
||||
use crate::reader::{Reader, ReaderControl};
|
||||
use crate::tui::event::Action;
|
||||
use crate::tui::{App, Event, Size, TICK_RATE, Tui};
|
||||
|
|
@ -41,6 +42,8 @@ where
|
|||
listener: Option<interprocess::local_socket::tokio::Listener>,
|
||||
final_event: Event,
|
||||
final_key: KeyEvent,
|
||||
/// Whether the `start` event has already been fired (fired exactly once).
|
||||
start_fired: bool,
|
||||
}
|
||||
|
||||
impl Skim {
|
||||
|
|
@ -188,6 +191,7 @@ where
|
|||
listener: None,
|
||||
final_event: Event::Quit,
|
||||
final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
|
||||
start_fired: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -196,6 +200,28 @@ where
|
|||
debug!("Starting reader with initial_cmd: {:?}", self.initial_cmd);
|
||||
self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), &self.initial_cmd));
|
||||
self.app.restart_matcher(true);
|
||||
// If the TUI is already available (e.g. test harnesses that build the
|
||||
// TUI before starting), fire the `start` event now. In the normal
|
||||
// binary flow the TUI is created after `start()`, so `enter()` fires it.
|
||||
self.fire_start_event();
|
||||
}
|
||||
|
||||
/// Fire the `start` event exactly once, as soon as the TUI event channel is
|
||||
/// available. The event is routed through the keymap like any other key, so
|
||||
/// a `--bind start:<action>` binding runs when skim comes up. In sync mode,
|
||||
/// render the completed matcher output first so the action sees every item.
|
||||
fn fire_start_event(&mut self) {
|
||||
if self.start_fired {
|
||||
return;
|
||||
}
|
||||
if let Some(tui) = self.tui.as_ref() {
|
||||
if self.app.options.sync && tui.event_tx.try_send(Event::Render).is_err() {
|
||||
return;
|
||||
}
|
||||
if tui.event_tx.try_send(Event::Key(SkimEvent::Start.into())).is_ok() {
|
||||
self.start_fired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a reload event by killing the current reader, clearing items, and starting a new reader.
|
||||
|
|
@ -218,6 +244,10 @@ where
|
|||
// Start a new reader with the new command
|
||||
self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), new_cmd));
|
||||
self.reader_done = false;
|
||||
// A new read is in flight: arm the `load` event to fire again once the
|
||||
// new item set has been read and rendered.
|
||||
self.app.reader_done = false;
|
||||
self.app.load_event_fired = false;
|
||||
}
|
||||
|
||||
/// Check if the reader has finished and restart the matcher if needed.
|
||||
|
|
@ -234,6 +264,11 @@ where
|
|||
&& !self.reader_done
|
||||
{
|
||||
self.reader_done = true;
|
||||
// Signal that reading is complete. The `load` event is fired later
|
||||
// from `App::poll_completion_events` (the heartbeat handler) once the
|
||||
// reader is done, the matcher has stopped, and every item has been
|
||||
// consumed, so a `load` binding sees a fully-populated, stable list.
|
||||
self.app.reader_done = true;
|
||||
self.app.restart_matcher(false);
|
||||
// If the matcher already consumed everything, stop the periodic
|
||||
// interval immediately rather than waiting for the next tick.
|
||||
|
|
@ -385,6 +420,9 @@ where
|
|||
.as_mut()
|
||||
.expect("TUI needs to be initialized using Skim::init_tui before starting")
|
||||
.start();
|
||||
// In the normal binary flow the TUI is created after `start()`, so this
|
||||
// is the first point at which the `start` event can be queued.
|
||||
self.fire_start_event();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -580,6 +618,11 @@ where
|
|||
/// }
|
||||
/// ```
|
||||
pub async fn tick(&mut self) -> Result<bool> {
|
||||
// Retry the one-shot `start` event until the (bounded) event channel
|
||||
// accepts it. `start()`/`enter()` fire it eagerly, but if the channel was
|
||||
// momentarily full there, this guarantees it is not lost. Idempotent: the
|
||||
// `start_fired` guard makes every call after the first a no-op.
|
||||
self.fire_start_event();
|
||||
let matcher_interval = &mut self.matcher_interval;
|
||||
let items_available = self.app.item_pool.items_available.clone();
|
||||
select! {
|
||||
|
|
@ -600,6 +643,10 @@ where
|
|||
self.app.handle_event(self.tui.as_mut().expect("TUI should be initialized before handling events"), &evt)?;
|
||||
}
|
||||
|
||||
if let Some(action) = self.app.final_action.take() {
|
||||
self.final_event = Event::Action(action);
|
||||
}
|
||||
|
||||
// Check reader status and update
|
||||
self.check_reader();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,20 @@ fn output_collects_results_and_marks_abort() {
|
|||
assert_eq!(output.cmd, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_accept_actions_are_reported_as_accepts() {
|
||||
for binding in ["start:first,first:accept", "start:if-query-empty(accept)"] {
|
||||
let mut options = SkimOptions::default();
|
||||
options.bind = vec![binding.to_string()];
|
||||
let mut skim = started_skim_with(options.build(), &["a"]);
|
||||
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(skim.run()).unwrap();
|
||||
|
||||
assert!(matches!(skim.final_event(), Event::Action(Action::Accept(None))));
|
||||
assert!(!skim.output().is_abort, "binding `{binding}` was reported as an abort");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_uses_input_as_cmd_in_interactive_mode() {
|
||||
let mut options = SkimOptions::default();
|
||||
|
|
|
|||
260
src/tui/app.rs
260
src/tui/app.rs
|
|
@ -22,8 +22,9 @@ 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::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||
use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||
use eyre::{Result, bail};
|
||||
use input::Input;
|
||||
use preview::Preview;
|
||||
|
|
@ -54,6 +55,8 @@ pub struct App {
|
|||
pub reader_pool: Arc<ThreadPool>,
|
||||
/// Whether the application should quit
|
||||
pub should_quit: bool,
|
||||
/// The terminating action, including one dispatched inside a follow-up or conditional chain.
|
||||
pub(crate) final_action: Option<Action>,
|
||||
|
||||
/// Current cursor position (x, y)
|
||||
pub cursor_pos: (u16, u16),
|
||||
|
|
@ -127,6 +130,19 @@ pub struct App {
|
|||
items_just_updated: bool,
|
||||
/// Records if we are scrolling (mouse down on the scrollbar and no mouse up yet)
|
||||
currently_scrolling: bool,
|
||||
/// Set by [`Skim::check_reader`] once the reader has finished producing
|
||||
/// items. Reset on `reload`. Drives the one-shot `load` event.
|
||||
pub(crate) reader_done: bool,
|
||||
/// Whether the `load` event has been fired for the current read. Reset on
|
||||
/// `reload` so a new read fires `load` again.
|
||||
pub(crate) load_event_fired: bool,
|
||||
/// Set whenever a matcher run is (re)started; edge-triggers the one-shot
|
||||
/// `result` (and `zero`/`one`) events once that run completes, polled from
|
||||
/// the heartbeat.
|
||||
pub(crate) result_pending: bool,
|
||||
/// The last item that had focus, tracked so the `focus` event fires only
|
||||
/// when the focused item actually changes on cursor movement.
|
||||
last_focused: Option<Arc<dyn SkimItem>>,
|
||||
}
|
||||
|
||||
impl Widget for &mut App {
|
||||
|
|
@ -217,6 +233,7 @@ impl Default for App {
|
|||
item_pool: Arc::default(),
|
||||
theme,
|
||||
should_quit: false,
|
||||
final_action: None,
|
||||
cursor_pos: (0, 0),
|
||||
matcher: Matcher::builder(Rc::new(ExactOrFuzzyEngineFactory::builder().build()))
|
||||
.case(crate::CaseMatching::default())
|
||||
|
|
@ -250,6 +267,10 @@ impl Default for App {
|
|||
reader_timer: std::time::Instant::now(),
|
||||
items_just_updated: false,
|
||||
currently_scrolling: false,
|
||||
reader_done: false,
|
||||
load_event_fired: false,
|
||||
result_pending: false,
|
||||
last_focused: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -283,6 +304,7 @@ impl App {
|
|||
item_list: ItemList::from_options(&options, theme.clone()),
|
||||
theme,
|
||||
should_quit: false,
|
||||
final_action: None,
|
||||
cursor_pos: (0, 0),
|
||||
matcher: Matcher::from_options(&options),
|
||||
yank_register: String::new(),
|
||||
|
|
@ -316,6 +338,10 @@ impl App {
|
|||
.unwrap(),
|
||||
pending_preview_run: false,
|
||||
currently_scrolling: false,
|
||||
reader_done: false,
|
||||
load_event_fired: false,
|
||||
result_pending: false,
|
||||
last_focused: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -373,9 +399,65 @@ impl App {
|
|||
self.items_just_updated = true;
|
||||
}
|
||||
|
||||
/// Call after selection changes (e.g., selection actions, `Event::Key`)
|
||||
fn on_selection_changed() -> Vec<Event> {
|
||||
vec![Event::RunPreview]
|
||||
/// Call after selection changes (e.g., selection actions, `Event::Key`).
|
||||
///
|
||||
/// Emits the `focus` event when the focused item actually changed, so a
|
||||
/// `focus:<action>` binding runs on cursor movement.
|
||||
fn on_selection_changed(&mut self) -> Vec<Event> {
|
||||
let mut events = vec![Event::RunPreview];
|
||||
events.extend(self.take_focus_event());
|
||||
events
|
||||
}
|
||||
|
||||
/// Returns a `focus` event if the focused item changed since the last call,
|
||||
/// updating the tracked item. Used by [`Self::on_selection_changed`].
|
||||
fn take_focus_event(&mut self) -> Option<Event> {
|
||||
let focused = self.item_list.selected().map(|m| m.item);
|
||||
let changed = match (&self.last_focused, &focused) {
|
||||
(Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
if changed {
|
||||
self.last_focused = focused;
|
||||
Some(Event::Key(SkimEvent::Focus.into()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the async reader/matcher state and returns any newly-due
|
||||
/// completion events (`load`, `result`, `zero`, `one`).
|
||||
///
|
||||
/// Each is edge-triggered by a flag so it fires once per read / search:
|
||||
/// `load` when the reader finishes, `result` (plus `zero`/`one` from the
|
||||
/// matcher's authoritative count) when a search completes. Called from the
|
||||
/// heartbeat.
|
||||
fn poll_completion_events(&mut self) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
if self.reader_done
|
||||
&& !self.load_event_fired
|
||||
&& self.matcher_control.stopped()
|
||||
&& self.item_pool.num_not_taken() == 0
|
||||
{
|
||||
self.load_event_fired = true;
|
||||
events.push(Event::Key(SkimEvent::Load.into()));
|
||||
}
|
||||
|
||||
if self.result_pending && self.matcher_control.stopped() {
|
||||
self.result_pending = false;
|
||||
events.push(Event::Key(SkimEvent::Result.into()));
|
||||
if self.reader_done {
|
||||
match self.matcher_control.get_num_matched() {
|
||||
0 => events.push(Event::Key(SkimEvent::Zero.into())),
|
||||
1 => events.push(Event::Key(SkimEvent::One.into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
/// Call when query changes (e.g., `AddChar`, `BackwardDeleteChar`, etc.)
|
||||
|
|
@ -387,7 +469,7 @@ impl App {
|
|||
}
|
||||
self.restart_matcher_debounced();
|
||||
vec![
|
||||
Event::Key(KeyEvent::new(KeyCode::F(255), KeyModifiers::NONE)), // Send F255 which is the change bind
|
||||
Event::Key(crate::binds::SkimEvent::Change.into()), // fire the `change` event binding
|
||||
Event::RunPreview,
|
||||
]
|
||||
}
|
||||
|
|
@ -551,6 +633,11 @@ impl App {
|
|||
f.render_widget(&mut *self, f.area());
|
||||
f.set_cursor_position(self.cursor_pos);
|
||||
})?;
|
||||
// Matcher output is merged into the item list during rendering,
|
||||
// so this is where result-driven focus changes become observable.
|
||||
if let Some(event) = self.take_focus_event() {
|
||||
tui.event_tx.try_send(event)?;
|
||||
}
|
||||
}
|
||||
Event::Heartbeat | Event::Tick => {
|
||||
// Heartbeat is used for periodic UI updates
|
||||
|
|
@ -571,6 +658,19 @@ impl App {
|
|||
tui.event_tx.try_send(Event::Render)?;
|
||||
}
|
||||
|
||||
// Fire the reader/matcher-completion events (`load`, `result`,
|
||||
// `zero`, `one`). These track async state that has no synchronous
|
||||
// callback, so they are polled here on the heartbeat rather than
|
||||
// in the render path. A `Render` is queued first so a binding
|
||||
// that inspects the list (e.g. `load:first`) sees the final one.
|
||||
let completion_events = self.poll_completion_events();
|
||||
if !completion_events.is_empty() {
|
||||
tui.event_tx.try_send(Event::Render)?;
|
||||
for evt in completion_events {
|
||||
tui.event_tx.try_send(evt)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a debounced preview run needs to be executed
|
||||
if self.pending_preview_run
|
||||
&& let Err(e) = self.run_preview(tui)
|
||||
|
|
@ -720,14 +820,61 @@ impl App {
|
|||
vec![]
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
/// Runs an action, then directly dispatches any follow-up actions bound to it.
|
||||
///
|
||||
/// Follow-ups use non-recursive (`noremap`) semantics: an action in the
|
||||
/// follow-up chain does not trigger its own follow-up binding. If the chain
|
||||
/// contains [`Action::Suppress`], the triggering action is skipped.
|
||||
fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
|
||||
let follow = self.options.action_binds.get(act.name()).cloned();
|
||||
let suppress_default = follow
|
||||
.as_ref()
|
||||
.is_some_and(|chain| chain.iter().any(|a| matches!(a, Action::Suppress)));
|
||||
|
||||
let mut events = if suppress_default {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.dispatch_action(act)?
|
||||
};
|
||||
if let Some(chain) = follow {
|
||||
for action in chain.iter().filter(|a| !matches!(a, Action::Suppress)) {
|
||||
events.extend(self.dispatch_action(action)?);
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn dispatch_conditional(&mut self, condition: bool, then: &str, otherwise: Option<&str>) -> Result<Vec<Event>> {
|
||||
let Some(chain) = condition.then_some(then).or(otherwise) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
// `if-*` branch chains are stored unparsed (see `parse_action`), so an
|
||||
// invalid action name only surfaces here, at dispatch time. Log and
|
||||
// skip the chain instead of erroring out of the event loop, matching
|
||||
// the invalid-chain handling of `parse_action_binds`.
|
||||
let actions = match crate::binds::parse_action_chain(chain) {
|
||||
Ok(actions) => actions,
|
||||
Err(err) => {
|
||||
warn!("Ignoring conditional action chain `{chain}`: {err}");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
for action in actions {
|
||||
events.extend(self.dispatch_action(&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};
|
||||
match act {
|
||||
Abort | Accept(_) => {
|
||||
self.should_quit = true;
|
||||
self.final_action = Some(act.clone());
|
||||
}
|
||||
AddChar(c) => {
|
||||
self.input.insert(*c);
|
||||
|
|
@ -749,7 +896,7 @@ impl App {
|
|||
)]);
|
||||
self.item_list.select_row(self.item_list.items.len() - 1);
|
||||
self.restart_matcher_debounced();
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
BackwardChar => {
|
||||
self.input.move_cursor(-1);
|
||||
|
|
@ -781,10 +928,15 @@ impl App {
|
|||
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.
|
||||
// Bind one or more `trigger:action[+action]` pairs, reusing the
|
||||
// same parsing/merging logic as the `--bind` CLI option: key
|
||||
// triggers merge into the keymap, action triggers into the
|
||||
// follow-up action bindings. Existing bindings for the same
|
||||
// triggers are replaced.
|
||||
self.options.keymap.add_keymaps_str(spec);
|
||||
self.options.action_binds.extend(crate::binds::parse_action_binds(
|
||||
crate::binds::split_top_level(spec, ',').into_iter(),
|
||||
));
|
||||
}
|
||||
Cancel => {
|
||||
self.matcher_control.kill();
|
||||
|
|
@ -809,7 +961,7 @@ impl App {
|
|||
DeselectAll => {
|
||||
if !self.item_list.selection.is_empty() {
|
||||
self.item_list.selection = Default::default();
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
}
|
||||
Down(n) => {
|
||||
|
|
@ -817,7 +969,7 @@ impl App {
|
|||
TopToBottom => self.item_list.scroll_by(i32::from(*n)),
|
||||
BottomToTop => self.item_list.scroll_by(-i32::from(*n)),
|
||||
}
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
EndOfLine => {
|
||||
self.input.move_to_end();
|
||||
|
|
@ -841,7 +993,7 @@ impl App {
|
|||
First | Top => {
|
||||
// Jump to first item (considering reserved items)
|
||||
self.item_list.jump_to_first();
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
ForwardChar => {
|
||||
self.input.move_cursor(1);
|
||||
|
|
@ -850,39 +1002,18 @@ impl App {
|
|||
self.input.move_cursor_forward_word();
|
||||
}
|
||||
IfQueryEmpty(then, otherwise) => {
|
||||
let inner = crate::binds::parse_action_chain(then)?;
|
||||
if self.input.is_empty() {
|
||||
return Ok(inner.iter().map(|e| Event::Action(e.to_owned())).collect());
|
||||
} else if let Some(o) = otherwise {
|
||||
return Ok(crate::binds::parse_action_chain(o)?
|
||||
.iter()
|
||||
.map(|e| Event::Action(e.to_owned()))
|
||||
.collect());
|
||||
}
|
||||
return self.dispatch_conditional(self.input.is_empty(), then, otherwise.as_deref());
|
||||
}
|
||||
IfQueryNotEmpty(then, otherwise) => {
|
||||
let inner = crate::binds::parse_action_chain(then)?;
|
||||
if !self.input.is_empty() {
|
||||
return Ok(inner.iter().map(|e| Event::Action(e.to_owned())).collect());
|
||||
} else if let Some(o) = otherwise {
|
||||
return Ok(crate::binds::parse_action_chain(o)?
|
||||
.iter()
|
||||
.map(|e| Event::Action(e.to_owned()))
|
||||
.collect());
|
||||
}
|
||||
return self.dispatch_conditional(!self.input.is_empty(), then, otherwise.as_deref());
|
||||
}
|
||||
IfNonMatched(then, otherwise) => {
|
||||
let inner = crate::binds::parse_action_chain(then)?;
|
||||
if self.item_list.items.is_empty() {
|
||||
return Ok(inner.iter().map(|e| Event::Action(e.to_owned())).collect());
|
||||
} else if let Some(o) = otherwise {
|
||||
return Ok(crate::binds::parse_action_chain(o)?
|
||||
.iter()
|
||||
.map(|e| Event::Action(e.to_owned()))
|
||||
.collect());
|
||||
}
|
||||
return self.dispatch_conditional(self.item_list.items.is_empty(), then, otherwise.as_deref());
|
||||
}
|
||||
Ignore => (),
|
||||
// `ignore` is a no-op. `suppress` 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 | Suppress => (),
|
||||
KillLine => {
|
||||
let cursor = self.input.cursor_pos as usize;
|
||||
let deleted = self.input.split_off(cursor);
|
||||
|
|
@ -897,7 +1028,7 @@ impl App {
|
|||
Last => {
|
||||
// Jump to last item
|
||||
self.item_list.jump_to_last();
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
NextHistory => {
|
||||
// Use cmd_history in interactive mode, query_history otherwise
|
||||
|
|
@ -944,7 +1075,7 @@ impl App {
|
|||
} else {
|
||||
self.item_list.scroll_by_rows(offset * n);
|
||||
}
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
HalfPageUp(n) => {
|
||||
let offset = i32::from(self.item_list.height) / 2;
|
||||
|
|
@ -953,7 +1084,7 @@ impl App {
|
|||
} else {
|
||||
self.item_list.scroll_by_rows(-offset * n);
|
||||
}
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
PageDown(n) => {
|
||||
let offset = i32::from(self.item_list.height);
|
||||
|
|
@ -962,7 +1093,7 @@ impl App {
|
|||
} else {
|
||||
self.item_list.scroll_by_rows(offset * n);
|
||||
}
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
PageUp(n) => {
|
||||
let offset = i32::from(self.item_list.height);
|
||||
|
|
@ -971,7 +1102,7 @@ impl App {
|
|||
} else {
|
||||
self.item_list.scroll_by_rows(-offset * n);
|
||||
}
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
PreviewUp(n) => {
|
||||
self.preview.scroll_up(u16::try_from(*n).unwrap_or(u16::MAX));
|
||||
|
|
@ -1083,15 +1214,15 @@ impl App {
|
|||
}
|
||||
SelectAll => {
|
||||
self.item_list.select_all();
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
SelectRow(row) => {
|
||||
self.item_list.select_row(*row);
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
Select => {
|
||||
self.item_list.select();
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
SetHeader(opt_header) => {
|
||||
opt_header.clone_into(&mut self.options.header);
|
||||
|
|
@ -1111,11 +1242,11 @@ impl App {
|
|||
}
|
||||
Toggle => {
|
||||
self.item_list.toggle();
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
ToggleAll => {
|
||||
self.item_list.toggle_all();
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
ToggleIn => {
|
||||
self.item_list.toggle();
|
||||
|
|
@ -1123,7 +1254,7 @@ impl App {
|
|||
TopToBottom => self.item_list.select_next(),
|
||||
BottomToTop => self.item_list.select_previous(),
|
||||
}
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
ToggleInteractive => {
|
||||
self.options.interactive = !self.options.interactive;
|
||||
|
|
@ -1136,7 +1267,7 @@ impl App {
|
|||
TopToBottom => self.item_list.select_previous(),
|
||||
BottomToTop => self.item_list.select_next(),
|
||||
}
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
TogglePreview => {
|
||||
self.options.preview_window.hidden = !self.options.preview_window.hidden;
|
||||
|
|
@ -1152,13 +1283,21 @@ impl App {
|
|||
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) {
|
||||
// Remove the bindings for one or more keys or action triggers.
|
||||
// Keys win, mirroring `bind`: a name that parses as a real key
|
||||
// unbinds the key; otherwise `act-up`/`first` style triggers are
|
||||
// removed from the follow-up action bindings.
|
||||
for trigger in crate::binds::split_top_level(spec, ',') {
|
||||
match crate::binds::parse_key(trigger) {
|
||||
Ok(parsed) => {
|
||||
self.options.keymap.remove(&parsed);
|
||||
}
|
||||
Err(err) => debug!("Failed to unbind key {key}: {err}"),
|
||||
Err(err) => match crate::binds::action_trigger_name(trigger) {
|
||||
Some(name) => {
|
||||
self.options.action_binds.remove(name);
|
||||
}
|
||||
None => debug!("Failed to unbind {trigger}: {err}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1177,7 +1316,7 @@ impl App {
|
|||
TopToBottom => self.item_list.scroll_by(-i32::from(*n)),
|
||||
BottomToTop => self.item_list.scroll_by(i32::from(*n)),
|
||||
}
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
Yank => {
|
||||
// Insert from yank register at cursor position
|
||||
|
|
@ -1271,6 +1410,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1421,7 +1563,7 @@ impl App {
|
|||
self.needs_render();
|
||||
|
||||
if self.item_list.current != old_current {
|
||||
return Ok(Self::on_selection_changed());
|
||||
return Ok(self.on_selection_changed());
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,12 +57,59 @@ fn act(app: &mut App, action: Action) -> Vec<Event> {
|
|||
app.handle_action(&action).expect("handle_action failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_waits_until_all_items_are_consumed() {
|
||||
let mut app = App::default();
|
||||
app.reader_done = true;
|
||||
app.item_pool.append(vec![Arc::new("item".to_string())]);
|
||||
|
||||
assert!(app.poll_completion_events().is_empty());
|
||||
|
||||
assert_eq!(app.item_pool.take().len(), 1);
|
||||
assert!(
|
||||
app.poll_completion_events()
|
||||
.iter()
|
||||
.any(|event| matches!(event, Event::Key(key) if *key == crate::binds::SkimEvent::Load.key_event()))
|
||||
);
|
||||
assert!(app.poll_completion_events().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinality_events_wait_for_reader_completion() {
|
||||
let mut app = App::default();
|
||||
app.result_pending = true;
|
||||
|
||||
let events = app.poll_completion_events();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, Event::Key(key) if *key == crate::binds::SkimEvent::Result.key_event()))
|
||||
);
|
||||
assert!(events.iter().all(|event| {
|
||||
!matches!(event, Event::Key(key) if *key == crate::binds::SkimEvent::Zero.key_event()
|
||||
|| *key == crate::binds::SkimEvent::One.key_event())
|
||||
}));
|
||||
|
||||
app.reader_done = true;
|
||||
app.result_pending = true;
|
||||
assert!(
|
||||
app.poll_completion_events()
|
||||
.iter()
|
||||
.any(|event| matches!(event, Event::Key(key) if *key == crate::binds::SkimEvent::Zero.key_event()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_char_updates_query_and_emits_events() {
|
||||
let mut app = App::default();
|
||||
let events = act(&mut app, Action::AddChar('x'));
|
||||
assert_eq!(app.input.value, "x");
|
||||
// on_query_changed emits a F255 change-key event and a RunPreview
|
||||
// on_query_changed emits a `change` event key (SkimEvent::Change) and a RunPreview
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, Event::Key(key) if *key == crate::binds::SkimEvent::Change.key_event()))
|
||||
);
|
||||
assert!(events.iter().any(|e| matches!(e, Event::RunPreview)));
|
||||
}
|
||||
|
||||
|
|
@ -450,32 +497,89 @@ fn toggle_in_out_in_bottom_to_top_layout() {
|
|||
#[test]
|
||||
fn if_query_empty_branches() {
|
||||
let mut app = App::default();
|
||||
// Query empty -> "then" branch (ignore action).
|
||||
let events = act(&mut app, Action::IfQueryEmpty("ignore".to_string(), None));
|
||||
assert!(events.iter().all(|e| matches!(e, Event::Action(Action::Ignore))));
|
||||
assert!(act(&mut app, Action::IfQueryEmpty("abort".to_string(), None)).is_empty());
|
||||
assert!(app.should_quit);
|
||||
|
||||
// Query non-empty -> "otherwise" branch.
|
||||
let mut app = App::default();
|
||||
app.input.value = "x".to_string();
|
||||
let events = act(
|
||||
&mut app,
|
||||
Action::IfQueryEmpty("ignore".to_string(), Some("abort".to_string())),
|
||||
assert!(
|
||||
act(
|
||||
&mut app,
|
||||
Action::IfQueryEmpty("ignore".to_string(), Some("abort".to_string())),
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Action(Action::Abort))));
|
||||
assert!(app.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_query_not_empty_branches() {
|
||||
let mut app = App::default();
|
||||
app.input.value = "x".to_string();
|
||||
let events = act(&mut app, Action::IfQueryNotEmpty("abort".to_string(), None));
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Action(Action::Abort))));
|
||||
assert!(act(&mut app, Action::IfQueryNotEmpty("abort".to_string(), None)).is_empty());
|
||||
assert!(app.should_quit);
|
||||
|
||||
let mut app = App::default();
|
||||
let events = act(
|
||||
&mut app,
|
||||
Action::IfQueryNotEmpty("abort".to_string(), Some("ignore".to_string())),
|
||||
assert!(
|
||||
act(
|
||||
&mut app,
|
||||
Action::IfQueryNotEmpty("ignore".to_string(), Some("abort".to_string())),
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(events.iter().all(|e| matches!(e, Event::Action(Action::Ignore))));
|
||||
assert!(app.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_bind_and_unbind_manage_action_triggers() {
|
||||
let mut app = app_with_items(&["a", "b", "c"]);
|
||||
|
||||
// `bind(act-up:suppress+last)` registers an action trigger at runtime.
|
||||
act(&mut app, Action::Bind("act-up:suppress+last".to_string()));
|
||||
assert_eq!(
|
||||
app.options.action_binds.get("up"),
|
||||
Some(&vec![Action::Suppress, Action::Last])
|
||||
);
|
||||
act(&mut app, Action::Up(1));
|
||||
assert_eq!(app.item_list.selected().unwrap().text(), "c");
|
||||
|
||||
// `unbind(up)` targets the *key*, leaving the action trigger in place.
|
||||
act(&mut app, Action::Unbind("up".to_string()));
|
||||
assert!(
|
||||
app.options
|
||||
.keymap
|
||||
.get(&crate::binds::parse_key("up").unwrap())
|
||||
.is_none()
|
||||
);
|
||||
assert!(app.options.action_binds.contains_key("up"));
|
||||
|
||||
// `unbind(act-up)` removes the action trigger; `up` acts normally again.
|
||||
act(&mut app, Action::Unbind("act-up".to_string()));
|
||||
assert!(!app.options.action_binds.contains_key("up"));
|
||||
act(&mut app, Action::First);
|
||||
act(&mut app, Action::Up(1));
|
||||
assert_eq!(app.item_list.selected().unwrap().text(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_invalid_chain_is_ignored() {
|
||||
// Branch chains are unvalidated at parse time; a bad action name must be
|
||||
// logged and skipped at dispatch time, not abort the event loop.
|
||||
let mut app = App::default();
|
||||
let events = act(&mut app, Action::IfQueryEmpty("not-a-real-action".to_string(), None));
|
||||
assert!(events.is_empty());
|
||||
assert!(!app.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_subactions_are_dispatched_without_remapping() {
|
||||
let mut app = app_with_items(&["a", "b", "c"]);
|
||||
app.options.action_binds.insert("up".to_string(), vec![Action::Last]);
|
||||
|
||||
let events = act(&mut app, Action::IfQueryEmpty("up".to_string(), None));
|
||||
|
||||
assert_eq!(app.item_list.selected().unwrap().text(), "b");
|
||||
assert!(events.iter().all(|event| !matches!(event, Event::Action(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -551,18 +655,19 @@ fn run_execute_event_runs_command_and_restarts_reader() {
|
|||
|
||||
#[test]
|
||||
fn if_non_matched_branches() {
|
||||
// Empty item list -> "then".
|
||||
let mut app = App::default();
|
||||
let events = act(&mut app, Action::IfNonMatched("abort".to_string(), None));
|
||||
assert!(events.iter().any(|e| matches!(e, Event::Action(Action::Abort))));
|
||||
assert!(act(&mut app, Action::IfNonMatched("abort".to_string(), None)).is_empty());
|
||||
assert!(app.should_quit);
|
||||
|
||||
// Non-empty list -> "otherwise".
|
||||
let mut app = app_with_items(&["a"]);
|
||||
let events = act(
|
||||
&mut app,
|
||||
Action::IfNonMatched("abort".to_string(), Some("ignore".to_string())),
|
||||
assert!(
|
||||
act(
|
||||
&mut app,
|
||||
Action::IfNonMatched("ignore".to_string(), Some("abort".to_string())),
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(events.iter().all(|e| matches!(e, Event::Action(Action::Ignore))));
|
||||
assert!(app.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
231
src/tui/event.rs
231
src/tui/event.rs
|
|
@ -2,7 +2,6 @@ use std::future::Future;
|
|||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::exhaustive_match;
|
||||
use crossterm::event::{KeyEvent, MouseEvent};
|
||||
use derive_more::{Debug, Eq, PartialEq};
|
||||
|
||||
|
|
@ -267,6 +266,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:suppress`):
|
||||
/// 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 (equivalent to `ignore`).
|
||||
Suppress,
|
||||
/// Set the header (or disable it on an empty value)
|
||||
SetHeader(Option<String>),
|
||||
/// Set the preview cmd and rerun preview
|
||||
|
|
@ -309,6 +314,123 @@ pub enum Action {
|
|||
Custom(ActionCallback),
|
||||
}
|
||||
|
||||
fn parse_conditional(arg: Option<String>, constructor: fn(String, Option<String>) -> Action) -> Option<Action> {
|
||||
let arg = arg?;
|
||||
let (then, otherwise) = match arg.split_once('+') {
|
||||
Some((then, "")) => (then, None),
|
||||
Some((then, otherwise)) => (then, Some(otherwise.to_string())),
|
||||
None => (arg.as_str(), None),
|
||||
};
|
||||
Some(constructor(then.to_string(), otherwise))
|
||||
}
|
||||
|
||||
macro_rules! define_action_catalog {
|
||||
($arg:ident; $($pattern:pat => $name:literal => $parsed:expr),+ $(,)?) => {
|
||||
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 {
|
||||
#[allow(clippy::enum_glob_use)]
|
||||
use Action::*;
|
||||
match self {
|
||||
$($pattern => $name),+
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_named_action(action: &str, $arg: Option<String>) -> Option<Action> {
|
||||
#[allow(clippy::enum_glob_use)]
|
||||
use Action::*;
|
||||
match action {
|
||||
$($name => $parsed),+,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_action_catalog! {
|
||||
arg;
|
||||
Abort => "abort" => Some(Abort),
|
||||
Accept(_) => "accept" => Some(Accept(arg)),
|
||||
AddChar(_) => "add-char" => Some(AddChar(arg.unwrap_or_default().chars().next().unwrap_or_default())),
|
||||
AppendAndSelect => "append-and-select" => Some(AppendAndSelect),
|
||||
BackwardChar => "backward-char" => Some(BackwardChar),
|
||||
BackwardDeleteChar => "backward-delete-char" => Some(BackwardDeleteChar),
|
||||
BackwardDeleteCharEof => "backward-delete-char/eof" => Some(BackwardDeleteCharEof),
|
||||
BackwardKillWord => "backward-kill-word" => Some(BackwardKillWord),
|
||||
BackwardWord => "backward-word" => Some(BackwardWord),
|
||||
BeginningOfLine => "beginning-of-line" => Some(BeginningOfLine),
|
||||
Bind(_) => "bind" => Some(Bind(arg.unwrap_or_default())),
|
||||
Cancel => "cancel" => Some(Cancel),
|
||||
ClearScreen => "clear-screen" => Some(ClearScreen),
|
||||
DeleteChar => "delete-char" => Some(DeleteChar),
|
||||
DeleteCharEof => "delete-char/eof" => Some(DeleteCharEof),
|
||||
DeselectAll => "deselect-all" => Some(DeselectAll),
|
||||
Down(_) => "down" => Some(Down(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
EndOfLine => "end-of-line" => Some(EndOfLine),
|
||||
Execute(_) => "execute" => Some(Execute(arg.unwrap_or_default())),
|
||||
ExecuteSilent(_) => "execute-silent" => Some(ExecuteSilent(arg.unwrap_or_default())),
|
||||
First => "first" => Some(First),
|
||||
ForwardChar => "forward-char" => Some(ForwardChar),
|
||||
ForwardWord => "forward-word" => Some(ForwardWord),
|
||||
IfQueryEmpty(..) => "if-query-empty" => parse_conditional(arg, IfQueryEmpty),
|
||||
IfQueryNotEmpty(..) => "if-query-not-empty" => parse_conditional(arg, IfQueryNotEmpty),
|
||||
IfNonMatched(..) => "if-non-matched" => parse_conditional(arg, IfNonMatched),
|
||||
Ignore => "ignore" => Some(Ignore),
|
||||
KillLine => "kill-line" => Some(KillLine),
|
||||
KillWord => "kill-word" => Some(KillWord),
|
||||
Last => "last" => Some(Last),
|
||||
NextHistory => "next-history" => Some(NextHistory),
|
||||
HalfPageDown(_) => "half-page-down" => Some(HalfPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
HalfPageUp(_) => "half-page-up" => Some(HalfPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
PageDown(_) => "page-down" => Some(PageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
PageUp(_) => "page-up" => Some(PageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
PreviewUp(_) => "preview-up" => Some(PreviewUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
PreviewDown(_) => "preview-down" => Some(PreviewDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
PreviewLeft(_) => "preview-left" => Some(PreviewLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
PreviewRight(_) => "preview-right" => Some(PreviewRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
PreviewPageUp(_) => "preview-page-up" => Some(PreviewPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
PreviewPageDown(_) => "preview-page-down" => Some(PreviewPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
PreviousHistory => "previous-history" => Some(PreviousHistory),
|
||||
Redraw => "redraw" => Some(Redraw),
|
||||
RefreshCmd => "refresh-cmd" => Some(RefreshCmd),
|
||||
RefreshPreview => "refresh-preview" => Some(RefreshPreview),
|
||||
RestartMatcher => "restart-matcher" => Some(RestartMatcher),
|
||||
Reload(_) => "reload" => Some(Reload(arg)),
|
||||
RotateMode => "rotate-mode" => Some(RotateMode),
|
||||
ScrollLeft(_) => "scroll-left" => Some(ScrollLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
ScrollRight(_) => "scroll-right" => Some(ScrollRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
SelectAll => "select-all" => Some(SelectAll),
|
||||
SelectRow(_) => "select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
|
||||
Select => "select" => Some(Select),
|
||||
SetHeader(_) => "set-header" => Some(SetHeader(arg)),
|
||||
SetPreviewCmd(_) => "set-preview-cmd" => Some(SetPreviewCmd(arg.unwrap_or_default())),
|
||||
SetQuery(_) => "set-query" => Some(SetQuery(arg.unwrap_or_default())),
|
||||
Suppress => "suppress" => Some(Suppress),
|
||||
Toggle => "toggle" => Some(Toggle),
|
||||
ToggleAll => "toggle-all" => Some(ToggleAll),
|
||||
ToggleIn => "toggle-in" => Some(ToggleIn),
|
||||
ToggleInteractive => "toggle-interactive" => Some(ToggleInteractive),
|
||||
ToggleOut => "toggle-out" => Some(ToggleOut),
|
||||
TogglePreview => "toggle-preview" => Some(TogglePreview),
|
||||
TogglePreviewWrap => "toggle-preview-wrap" => Some(TogglePreviewWrap),
|
||||
ToggleSort => "toggle-sort" => Some(ToggleSort),
|
||||
Top => "top" => Some(Top),
|
||||
Unbind(_) => "unbind" => Some(Unbind(arg.unwrap_or_default())),
|
||||
UnixLineDiscard => "unix-line-discard" => Some(UnixLineDiscard),
|
||||
UnixWordRubout => "unix-word-rubout" => Some(UnixWordRubout),
|
||||
Up(_) => "up" => Some(Up(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
Yank => "yank" => Some(Yank),
|
||||
Custom(_) => "custom" => None,
|
||||
}
|
||||
|
||||
/// Parses an action string into an Action enum
|
||||
///
|
||||
/// Returns `None` if the action is unrecognized, or an `if-*` action is
|
||||
|
|
@ -329,117 +451,14 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
|
|||
}
|
||||
debug!("parse_action: action={action}, arg={arg:?}");
|
||||
|
||||
// Parse `if` chains
|
||||
if action.starts_with("if-") {
|
||||
let then_arg;
|
||||
let mut otherwise_arg = None;
|
||||
|
||||
let if_arg = arg?;
|
||||
if if_arg.contains('+') {
|
||||
let split = if_arg.split_once('+');
|
||||
match split {
|
||||
Some((a, "")) => {
|
||||
then_arg = a.to_string();
|
||||
}
|
||||
Some((a, b)) => {
|
||||
then_arg = a.to_string();
|
||||
otherwise_arg = Some(b.to_string());
|
||||
}
|
||||
None => unreachable!(),
|
||||
}
|
||||
} else {
|
||||
then_arg = if_arg.clone();
|
||||
}
|
||||
match action {
|
||||
"if-non-matched" => Some(Action::IfNonMatched(then_arg, otherwise_arg)),
|
||||
"if-query-empty" => Some(Action::IfQueryEmpty(then_arg, otherwise_arg)),
|
||||
"if-query-not-empty" => Some(Action::IfQueryNotEmpty(then_arg, otherwise_arg)),
|
||||
_ => None,
|
||||
}
|
||||
} else if matches!(
|
||||
if matches!(
|
||||
action,
|
||||
"add-char" | "bind" | "execute" | "execute-silent" | "set-preview-cmd" | "set-query" | "unbind"
|
||||
) && arg.is_none()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
exhaustive_match! {
|
||||
action => Option<Action>;
|
||||
{
|
||||
"abort" => Some(Abort),
|
||||
"accept" => Some(Accept(arg)),
|
||||
"add-char" => Some(AddChar(arg.unwrap_or_default().chars().next().unwrap_or_default())),
|
||||
"append-and-select" => Some(AppendAndSelect),
|
||||
"backward-char" => Some(BackwardChar),
|
||||
"backward-delete-char" => Some(BackwardDeleteChar),
|
||||
"backward-delete-char/eof" => Some(BackwardDeleteCharEof),
|
||||
"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),
|
||||
"delete-char/eof" => Some(DeleteCharEof),
|
||||
"deselect-all" => Some(DeselectAll),
|
||||
"down" => Some(Down(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"end-of-line" => Some(EndOfLine),
|
||||
"execute" => Some(Execute(arg.unwrap_or_default())),
|
||||
"execute-silent" => Some(ExecuteSilent(arg.unwrap_or_default())),
|
||||
"first" => Some(First),
|
||||
"forward-char" => Some(ForwardChar),
|
||||
"forward-word" => Some(ForwardWord),
|
||||
"ignore" => Some(Ignore),
|
||||
"kill-line" => Some(KillLine),
|
||||
"kill-word" => Some(KillWord),
|
||||
"last" => Some(Last),
|
||||
"next-history" => Some(NextHistory),
|
||||
"half-page-down" => Some(HalfPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"half-page-up" => Some(HalfPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"page-down" => Some(PageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"page-up" => Some(PageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"preview-up" => Some(PreviewUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"preview-down" => Some(PreviewDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"preview-left" => Some(PreviewLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"preview-right" => Some(PreviewRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"preview-page-up" => Some(PreviewPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"preview-page-down" => Some(PreviewPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"previous-history" => Some(PreviousHistory),
|
||||
"redraw" => Some(Redraw),
|
||||
"refresh-cmd" => Some(RefreshCmd),
|
||||
"refresh-preview" => Some(RefreshPreview),
|
||||
"restart-matcher" => Some(RestartMatcher),
|
||||
"reload" => Some(Reload(arg.clone())),
|
||||
"rotate-mode" => Some(RotateMode),
|
||||
"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),
|
||||
"select-all" => Some(SelectAll),
|
||||
"select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
|
||||
"set-header" => Some(SetHeader(arg)),
|
||||
"set-preview-cmd" => Some(SetPreviewCmd(arg.unwrap_or_default())),
|
||||
"set-query" => Some(SetQuery(arg.unwrap_or_default())),
|
||||
"toggle" => Some(Toggle),
|
||||
"toggle-all" => Some(ToggleAll),
|
||||
"toggle-in" => Some(ToggleIn),
|
||||
"toggle-interactive" => Some(ToggleInteractive),
|
||||
"toggle-out" => Some(ToggleOut),
|
||||
"toggle-preview" => Some(TogglePreview),
|
||||
"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))),
|
||||
"yank" => Some(Yank),
|
||||
"unreachable-if-non-matched" => Some(IfNonMatched(Default::default(), None)),
|
||||
"unreachable-if-query-empty" => Some(IfQueryEmpty(Default::default(), None)),
|
||||
"unreachable-if-query-not-empty" => Some(IfQueryNotEmpty(Default::default(), None)),
|
||||
"custom-do-not-use-from-cli" => Some(Custom(ActionCallback::new_sync(|_: &mut crate::tui::App| { Ok(Vec::new()) }))),
|
||||
}
|
||||
default _ => None
|
||||
}
|
||||
parse_named_action(action, arg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const NO_ARG_ACTIONS: &[&str] = &[
|
|||
"rotate-mode",
|
||||
"select",
|
||||
"select-all",
|
||||
"suppress",
|
||||
"toggle",
|
||||
"toggle-all",
|
||||
"toggle-in",
|
||||
|
|
@ -48,17 +49,32 @@ const NO_ARG_ACTIONS: &[&str] = &[
|
|||
#[test]
|
||||
fn parse_all_no_arg_actions() {
|
||||
for name in NO_ARG_ACTIONS {
|
||||
assert!(parse_action(name).is_some(), "expected `{name}` to parse");
|
||||
let action = parse_action(name).unwrap_or_else(|| panic!("expected `{name}` to parse"));
|
||||
assert_eq!(action.name(), *name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_numeric_actions_default_to_one() {
|
||||
assert_eq!(parse_action("down"), Some(Action::Down(1)));
|
||||
assert_eq!(parse_action("up"), Some(Action::Up(1)));
|
||||
assert_eq!(parse_action("page-down"), Some(Action::PageDown(1)));
|
||||
assert_eq!(parse_action("scroll-left"), Some(Action::ScrollLeft(1)));
|
||||
assert_eq!(parse_action("select-row"), Some(Action::SelectRow(0)));
|
||||
for (name, expected) in [
|
||||
("down", Action::Down(1)),
|
||||
("up", Action::Up(1)),
|
||||
("half-page-down", Action::HalfPageDown(1)),
|
||||
("half-page-up", Action::HalfPageUp(1)),
|
||||
("page-down", Action::PageDown(1)),
|
||||
("page-up", Action::PageUp(1)),
|
||||
("preview-up", Action::PreviewUp(1)),
|
||||
("preview-down", Action::PreviewDown(1)),
|
||||
("preview-left", Action::PreviewLeft(1)),
|
||||
("preview-right", Action::PreviewRight(1)),
|
||||
("preview-page-up", Action::PreviewPageUp(1)),
|
||||
("preview-page-down", Action::PreviewPageDown(1)),
|
||||
("scroll-left", Action::ScrollLeft(1)),
|
||||
("scroll-right", Action::ScrollRight(1)),
|
||||
("select-row", Action::SelectRow(0)),
|
||||
] {
|
||||
assert_eq!(parse_action(name), Some(expected), "unexpected default for `{name}`");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -78,6 +94,16 @@ fn parse_numeric_actions_with_paren_arg() {
|
|||
|
||||
#[test]
|
||||
fn parse_string_arg_actions() {
|
||||
for (spec, name) in [
|
||||
("execute:ls -la", "execute"),
|
||||
("execute-silent:touch x", "execute-silent"),
|
||||
("set-query:hello", "set-query"),
|
||||
("set-preview-cmd:cat {}", "set-preview-cmd"),
|
||||
("add-char:z", "add-char"),
|
||||
] {
|
||||
assert_eq!(parse_action(spec).map(|action| action.name()), Some(name));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
parse_action("execute:ls -la"),
|
||||
Some(Action::Execute("ls -la".to_string()))
|
||||
|
|
@ -99,6 +125,10 @@ fn parse_string_arg_actions() {
|
|||
|
||||
#[test]
|
||||
fn parse_optional_arg_actions() {
|
||||
for name in ["accept", "set-header", "reload"] {
|
||||
assert_eq!(parse_action(name).map(|action| action.name()), Some(name));
|
||||
}
|
||||
|
||||
assert_eq!(parse_action("accept"), Some(Action::Accept(None)));
|
||||
assert_eq!(
|
||||
parse_action("accept:enter"),
|
||||
|
|
@ -147,6 +177,11 @@ fn parse_bind_and_unbind_require_argument() {
|
|||
|
||||
#[test]
|
||||
fn parse_if_chains_then_only() {
|
||||
for name in ["if-query-empty", "if-query-not-empty", "if-non-matched"] {
|
||||
let spec = format!("{name}:abort");
|
||||
assert_eq!(parse_action(&spec).map(|action| action.name()), Some(name));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
parse_action("if-query-empty:abort"),
|
||||
Some(Action::IfQueryEmpty("abort".to_string(), None))
|
||||
|
|
|
|||
|
|
@ -53,6 +53,82 @@ insta_test!(bind_change, ["1", "12", "13", "14", "15", "16", "17", "18", "19", "
|
|||
@snap;
|
||||
});
|
||||
|
||||
// `start` fires exactly once and before `load`: appending one character from
|
||||
// each event must produce `sl`, not `ssl` or `ls`.
|
||||
insta_test!(bind_start, ["sl"], &["--bind", "start:add-char(s),load:add-char(l)"], {
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().input.value == "sl");
|
||||
@snap;
|
||||
});
|
||||
insta_test!(bind_start_select_all_no_sync, ["a", "b", "c"], &["--multi", "--bind", "start:select-all"], {
|
||||
@snap;
|
||||
});
|
||||
insta_test!(bind_start_select_all_sync, ["a", "b", "c"], &["--multi", "--sync", "--bind", "start:select-all"], {
|
||||
@snap;
|
||||
});
|
||||
|
||||
// Test load event: fires once the reader has finished AND the read items have
|
||||
// been rendered into the list, so a `load` binding can safely act on the
|
||||
// fully-populated list (here it jumps to the last item).
|
||||
insta_test!(bind_load, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "load:last"], {
|
||||
@snap;
|
||||
@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;
|
||||
});
|
||||
|
||||
// `suppress` cancels only the triggering action. Follow-up actions use
|
||||
// non-recursive (`noremap`) semantics, so the final `up` runs once without
|
||||
// re-entering this binding: down then up returns to the first item.
|
||||
insta_test!(bind_suppress, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "act-up:suppress+down+up"], {
|
||||
@action Last;
|
||||
@action Down(5);
|
||||
@action Up(1);
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "5");
|
||||
@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");
|
||||
});
|
||||
|
||||
// `focus` fires when the initial matcher result establishes focus, without a
|
||||
// cursor action. This covers result-driven focus changes from the render path.
|
||||
insta_test!(bind_focus, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "focus:set-header(focused)"], {
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().header.header == "focused");
|
||||
@snap;
|
||||
});
|
||||
|
||||
// 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';
|
||||
|
|
|
|||
29
tests/snapshots/binds__bind_act_prefix@001.snap
Normal file
29
tests/snapshots/binds__bind_act_prefix@001.snap
Normal 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)
|
||||
29
tests/snapshots/binds__bind_action_followup@001.snap
Normal file
29
tests/snapshots/binds__bind_action_followup@001.snap
Normal 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)
|
||||
29
tests/snapshots/binds__bind_focus@001.snap
Normal file
29
tests/snapshots/binds__bind_focus@001.snap
Normal 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)
|
||||
29
tests/snapshots/binds__bind_load@001.snap
Normal file
29
tests/snapshots/binds__bind_load@001.snap
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
source: tests/binds.rs
|
||||
description: "input: items [\"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\"]\noptions: --bind load:last"
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
"> 10 "
|
||||
" 9 "
|
||||
" 8 "
|
||||
" 7 "
|
||||
" 6 "
|
||||
" 5 "
|
||||
" 4 "
|
||||
" 3 "
|
||||
" 2 "
|
||||
" 1 "
|
||||
" 10/10 9/0"
|
||||
"> "
|
||||
cursor: (24, 3)
|
||||
29
tests/snapshots/binds__bind_one@001.snap
Normal file
29
tests/snapshots/binds__bind_one@001.snap
Normal 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)
|
||||
29
tests/snapshots/binds__bind_result@001.snap
Normal file
29
tests/snapshots/binds__bind_result@001.snap
Normal 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)
|
||||
29
tests/snapshots/binds__bind_start@001.snap
Normal file
29
tests/snapshots/binds__bind_start@001.snap
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
source: tests/binds.rs
|
||||
description: "input: items [\"sl\"]\noptions: --bind start:add-char(s),load:add-char(l)"
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
"> sl "
|
||||
" 1/1 0/0"
|
||||
"> sl "
|
||||
cursor: (24, 5)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
source: tests/binds.rs
|
||||
description: "input: items [\"a\", \"b\", \"c\"]\noptions: --multi --bind start:select-all"
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" c "
|
||||
" b "
|
||||
"> a "
|
||||
" 3/3 0/0"
|
||||
"> "
|
||||
cursor: (24, 3)
|
||||
29
tests/snapshots/binds__bind_start_select_all_sync@001.snap
Normal file
29
tests/snapshots/binds__bind_start_select_all_sync@001.snap
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
source: tests/binds.rs
|
||||
description: "input: items [\"a\", \"b\", \"c\"]\noptions: --multi --sync --bind start:select-all"
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" >c "
|
||||
" >b "
|
||||
">>a "
|
||||
" 3/3 [3] 0/0"
|
||||
"> "
|
||||
cursor: (24, 3)
|
||||
29
tests/snapshots/binds__bind_suppress@001.snap
Normal file
29
tests/snapshots/binds__bind_suppress@001.snap
Normal 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:suppress+down+up\nafter:\n @action Last\n @action Down(5)\n @action Up(1)"
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" 10 "
|
||||
" 9 "
|
||||
" 8 "
|
||||
" 7 "
|
||||
" 6 "
|
||||
"> 5 "
|
||||
" 4 "
|
||||
" 3 "
|
||||
" 2 "
|
||||
" 1 "
|
||||
" 10/10 4/0"
|
||||
"> "
|
||||
cursor: (24, 3)
|
||||
29
tests/snapshots/binds__bind_zero@001.snap
Normal file
29
tests/snapshots/binds__bind_zero@001.snap
Normal 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)
|
||||
Loading…
Reference in a new issue