mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 07:16:23 -04:00
Add result/focus/zero/one events and action follow-up bindings
Extend the bindable-event set and generalise binding so that actions can themselves be bound. New finder events (fired from the post-draw `Event::Render` path, so a binding sees a stable, up-to-date list): - `result` — filtering for the current query completed - `focus` — the focused item changed (cursor move or result update) - `zero` — a completed search has no matches - `one` — a completed search has exactly one match `zero`/`one` read `MatcherControl::get_num_matched()` rather than the rendered list count, which can briefly lag the matcher. Actions as events: any action can be bound as if it were an event, so a follow-up chain runs after it (e.g. `reload:first`, `first:last`). This is parsed by `parse_action_binds` into `SkimOptions::action_binds` (keyed by `Action::name`) and applied in `handle_action`, which now wraps the per-variant `dispatch_action`. - Keys win: a name shared by a key and an action binds the key; use an `act-` prefix to target the action (`act-up:down`). - New `skip` action suppresses the triggering action's own behaviour, so `act-up:skip+down` remaps the up action to down and `up:skip` disables the up key. Also fixes `parse_key` so a non-numeric `f…` name (e.g. `focus`, `first`) falls through to name/event matching instead of erroring on the function- key branch. Adds unit and snapshot tests and documents everything in ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QKxusGbxLxqWdYyeqrb68
This commit is contained in:
parent
0279c0349a
commit
0ee3964b25
|
|
@ -965,15 +965,41 @@ and `parse_key` accepts the friendly names below:
|
|||
|
||||
| Bind name | `SkimEvent` | Reserved code | Fired when |
|
||||
| --- | --- | --- | --- |
|
||||
| `change` | `SkimEvent::Change` | `F(255)` | the query changes |
|
||||
| `start` | `SkimEvent::Start` | `F(254)` | skim has started and entered its event loop (once) |
|
||||
| `load` | `SkimEvent::Load` | `F(253)` | the reader finishes producing items (once per read; a `reload` fires it again) |
|
||||
| `change` | `SkimEvent::Change` | `F(255)` | the query changes |
|
||||
| `result` | `SkimEvent::Result` | `F(252)` | filtering for the current query completes and its results are ready |
|
||||
| `focus` | `SkimEvent::Focus` | `F(251)` | the focused item changes (cursor movement or a result update) |
|
||||
| `zero` | `SkimEvent::Zero` | `F(250)` | a completed search has no matches |
|
||||
| `one` | `SkimEvent::One` | `F(249)` | a completed search has exactly one match |
|
||||
|
||||
`change` is injected by `App::on_query_changed` (`src/tui/app.rs`); `start` and
|
||||
`load` are injected by `Skim::fire_start_event` / `Skim::check_reader`
|
||||
(`src/skim.rs`) via `Event::Key(SkimEvent::_.into())`. Each flows through
|
||||
`handle_key` and its keymap lookup like any other key, so an unbound event is a
|
||||
harmless no-op.
|
||||
`change` is injected by `App::on_query_changed`; `start` by
|
||||
`Skim::fire_start_event` and `load`/`reader_done` via `Skim::check_reader`
|
||||
(`src/skim.rs`). `result`, `zero`, `one` and `focus` are all injected from the
|
||||
`Event::Render` handler *after* the frame is drawn, so a binding sees a stable,
|
||||
up-to-date list; `zero`/`one` read `MatcherControl::get_num_matched()` (the
|
||||
matcher's authoritative count, which the rendered list may briefly lag). Each
|
||||
event flows through `handle_key` and its keymap lookup like any other key, so an
|
||||
unbound event is a harmless no-op.
|
||||
|
||||
### Actions as Events (follow-up bindings)
|
||||
|
||||
Any **action** can also be bound as if it were an event: after the action runs,
|
||||
a follow-up chain bound to its name is appended to `handle_action`'s result. For
|
||||
example `reload:first` runs `first` right after a `reload`, and `first:last`
|
||||
ends on the last item.
|
||||
|
||||
- **Keys win.** If a bind's "key" resolves to a real key it stays in the key
|
||||
map, so a name shared by a key and an action (e.g. `up`) always binds the key.
|
||||
Prefix with `act-` to target the action instead: `act-up:down`.
|
||||
- **`skip`.** Including [`Action::Skip`] in the follow-up chain suppresses the
|
||||
triggering action's own default behaviour, so `act-up:skip+down` remaps the
|
||||
`up` action to `down`, and `up:skip` disables the up key. On its own `skip` is
|
||||
a no-op.
|
||||
|
||||
Follow-up chains are parsed by `binds::parse_action_binds` into
|
||||
`SkimOptions::action_binds` (keyed by `Action::name`), and applied in
|
||||
`App::handle_action`, which wraps the per-variant `App::dispatch_action`.
|
||||
|
||||
### Action Dispatch
|
||||
|
||||
|
|
@ -1270,9 +1296,11 @@ The global allocator is `mimalloc` (v3), chosen for its low-latency multi-thread
|
|||
| `popup::check_env` | `src/popup/mod.rs:72` | Guard: multiplexer present and not already in popup |
|
||||
| `check_and_run_popup` | `src/bin/main.rs:131` | Check popup conditions, dispatch to popup::run_with |
|
||||
| `sk_main` | `src/bin/main.rs:144` | CLI orchestration + output printing |
|
||||
| `SkimEvent` | `src/binds.rs:25` | `start`/`load`/`change` synthetic events → reserved `KeyEvent` |
|
||||
| `parse_key` | `src/binds.rs:196` | `"ctrl-a"` → `KeyEvent` |
|
||||
| `parse_action_chain` | `src/binds.rs:270` | `"down+select"` → `Vec<Action>` |
|
||||
| `SkimEvent` | `src/binds.rs:25` | `change`/`start`/`load`/`result`/`focus`/`zero`/`one` synthetic events → reserved `KeyEvent` |
|
||||
| `parse_key` | `src/binds.rs:213` | `"ctrl-a"` → `KeyEvent` |
|
||||
| `parse_action_binds` | `src/binds.rs:299` | `"reload:first"`, `"act-up:skip+down"` → action follow-up map |
|
||||
| `parse_action_chain` | `src/binds.rs:329` | `"down+select"` → `Vec<Action>` |
|
||||
| `Action::name` | `src/tui/event.rs:316` | `Action` → canonical bind name (reverse of `parse_action`) |
|
||||
| `Matcher::create_engine_factory_with_builder` | `src/matcher.rs:189` | Build engine factory chain from options |
|
||||
| `ExactOrFuzzyEngineFactory::create_engine_with_case` | `src/engine/factory.rs:93` | Parse query prefixes, build engine |
|
||||
| `AndOrEngineFactory::parse_andor` | `src/engine/factory.rs:176` | Split query into AND/OR tree |
|
||||
|
|
|
|||
63
src/binds.rs
63
src/binds.rs
|
|
@ -30,6 +30,15 @@ pub enum SkimEvent {
|
|||
Load,
|
||||
/// Fired whenever the query changes.
|
||||
Change,
|
||||
/// Fired when filtering for the current query completes and the result
|
||||
/// list is ready.
|
||||
Result,
|
||||
/// Fired when the focused item changes (cursor movement or a result update).
|
||||
Focus,
|
||||
/// Fired when a completed search yields no matches.
|
||||
Zero,
|
||||
/// Fired when a completed search yields exactly one match.
|
||||
One,
|
||||
}
|
||||
|
||||
impl SkimEvent {
|
||||
|
|
@ -40,6 +49,10 @@ impl SkimEvent {
|
|||
SkimEvent::Change => KeyCode::F(255),
|
||||
SkimEvent::Start => KeyCode::F(254),
|
||||
SkimEvent::Load => KeyCode::F(253),
|
||||
SkimEvent::Result => KeyCode::F(252),
|
||||
SkimEvent::Focus => KeyCode::F(251),
|
||||
SkimEvent::Zero => KeyCode::F(250),
|
||||
SkimEvent::One => KeyCode::F(249),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +71,10 @@ impl SkimEvent {
|
|||
"start" => Some(SkimEvent::Start),
|
||||
"load" => Some(SkimEvent::Load),
|
||||
"change" => Some(SkimEvent::Change),
|
||||
"result" => Some(SkimEvent::Result),
|
||||
"focus" => Some(SkimEvent::Focus),
|
||||
"zero" => Some(SkimEvent::Zero),
|
||||
"one" => Some(SkimEvent::One),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -227,8 +244,11 @@ pub fn parse_key(key: &str) -> Result<KeyEvent> {
|
|||
} else {
|
||||
keycode = KeyCode::Char(char);
|
||||
}
|
||||
} else if let Some(f) = key.strip_prefix('f') {
|
||||
let f_index = f.parse::<u8>()?;
|
||||
} else if let Some(f) = key.strip_prefix('f')
|
||||
&& let Ok(f_index) = f.parse::<u8>()
|
||||
{
|
||||
// A function key like `f10`. If the suffix isn't numeric (e.g. `focus`,
|
||||
// `first`), fall through to the named-key / event matching below.
|
||||
keycode = KeyCode::F(f_index);
|
||||
} else {
|
||||
keycode = match key.as_str() {
|
||||
|
|
@ -288,6 +308,45 @@ fn split_top_level(value: &str, separator: char) -> Vec<&str> {
|
|||
parts
|
||||
}
|
||||
|
||||
/// Parses follow-up action bindings from raw `--bind` specs.
|
||||
///
|
||||
/// Any action can be bound as if it were an event: when the "key" of a bind is
|
||||
/// not a real key but is a known action name, the bound chain becomes a
|
||||
/// *follow-up* that runs right after that action. For example `reload:first`
|
||||
/// queues `first` immediately after a `reload`. The returned map is keyed by the
|
||||
/// action's canonical name (see [`Action::name`](crate::tui::event::Action::name)),
|
||||
/// so it can be looked up directly from the action that just ran.
|
||||
///
|
||||
/// Keys take precedence: if the "key" resolves to a real key it is left to the
|
||||
/// key map, so a name shared by a key and an action (e.g. `up`) always binds the
|
||||
/// key. To target the action in that case, prefix it with `act-` (`act-up`).
|
||||
#[must_use]
|
||||
pub fn parse_action_binds<'a, T>(maps: T) -> HashMap<String, Vec<Action>>
|
||||
where
|
||||
T: Iterator<Item = &'a str>,
|
||||
{
|
||||
let mut res = HashMap::new();
|
||||
for map in maps {
|
||||
let Some((key, chain)) = map.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
// Keys win: anything that parses as a real key is not an action trigger.
|
||||
if parse_key(key).is_ok() {
|
||||
continue;
|
||||
}
|
||||
// `act-<name>` explicitly targets the action `<name>`, even when `<name>`
|
||||
// is also a key. Without the prefix, a bare action name still works as
|
||||
// long as it isn't a key.
|
||||
let action_name = key.strip_prefix("act-").unwrap_or(key);
|
||||
if let Some(action) = event::parse_action(action_name)
|
||||
&& let Ok(actions) = parse_action_chain(chain)
|
||||
{
|
||||
res.insert(action.name().to_string(), actions);
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Parses an action chain, separated by '+'s into the corresponding actions
|
||||
///
|
||||
/// # Errors
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ fn skim_event_name_roundtrip() {
|
|||
("start", SkimEvent::Start),
|
||||
("load", SkimEvent::Load),
|
||||
("change", SkimEvent::Change),
|
||||
("result", SkimEvent::Result),
|
||||
("focus", SkimEvent::Focus),
|
||||
("zero", SkimEvent::Zero),
|
||||
("one", SkimEvent::One),
|
||||
] {
|
||||
assert_eq!(SkimEvent::from_name(name), Some(event));
|
||||
assert_eq!(parse_key(name).unwrap(), KeyEvent::from(event));
|
||||
|
|
@ -197,6 +201,46 @@ fn parse_keymaps_collects_iterator() {
|
|||
assert!(keymap.get(&parse_key("ctrl-x").unwrap()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_binds_key_wins_over_action() {
|
||||
// A bare action name that is not a key binds the action as a follow-up.
|
||||
let binds = parse_action_binds(["first:last"].into_iter());
|
||||
assert_eq!(binds.get("first"), Some(&vec![Last]));
|
||||
|
||||
// A name that is also a real key (`up`) is left to the key map, so it is
|
||||
// NOT registered as an action trigger.
|
||||
let binds = parse_action_binds(["up:down"].into_iter());
|
||||
assert!(!binds.contains_key("up"));
|
||||
|
||||
// `act-` forces the action interpretation even for a key-shaped name.
|
||||
let binds = parse_action_binds(["act-up:down"].into_iter());
|
||||
assert_eq!(binds.get("up"), Some(&vec![Down(1)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_binds_parse_skip_chain() {
|
||||
// `skip` is parsed like any other action and kept in the chain.
|
||||
let binds = parse_action_binds(["act-up:skip+down"].into_iter());
|
||||
assert_eq!(binds.get("up"), Some(&vec![Skip, Down(1)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_name_round_trips_through_parse_action() {
|
||||
// Every canonical name resolves back to an action with the same name.
|
||||
for name in [
|
||||
"abort",
|
||||
"first",
|
||||
"last",
|
||||
"reload",
|
||||
"skip",
|
||||
"backward-delete-char/eof",
|
||||
"up",
|
||||
] {
|
||||
let action = event::parse_action(name).unwrap_or_else(|| panic!("`{name}` should parse"));
|
||||
assert_eq!(action.name(), name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_action_chain_unknown_is_error() {
|
||||
assert!(parse_action_chain("not-a-real-action").is_err());
|
||||
|
|
|
|||
|
|
@ -1112,6 +1112,14 @@ pub struct SkimOptions {
|
|||
/// The internal (parsed) keymap
|
||||
#[cfg_attr(feature = "cli", clap(skip))]
|
||||
pub keymap: KeyMap,
|
||||
|
||||
/// Follow-up action bindings, keyed by the canonical action name.
|
||||
///
|
||||
/// Populated from `--bind` entries whose "key" is an action name rather than
|
||||
/// a real key (e.g. `reload:first`). After an action runs, the chain bound to
|
||||
/// its name is queued.
|
||||
#[cfg_attr(feature = "cli", clap(skip))]
|
||||
pub action_binds: std::collections::HashMap<String, Vec<Action>>,
|
||||
}
|
||||
|
||||
impl Default for SkimOptions {
|
||||
|
|
@ -1267,6 +1275,7 @@ impl Default for SkimOptions {
|
|||
selector: Default::default(),
|
||||
preview_fn: Default::default(),
|
||||
keymap: Default::default(),
|
||||
action_binds: Default::default(),
|
||||
#[cfg(feature = "cli")]
|
||||
shell: Default::default(),
|
||||
#[cfg(feature = "cli")]
|
||||
|
|
@ -1312,6 +1321,14 @@ impl SkimOptions {
|
|||
res
|
||||
});
|
||||
|
||||
// Bindings whose "key" is an action name (e.g. `reload:first`) become
|
||||
// follow-up actions that run right after that action.
|
||||
self.action_binds = self
|
||||
.bind
|
||||
.iter()
|
||||
.flat_map(|part| crate::binds::parse_action_binds(part.split(',')))
|
||||
.collect();
|
||||
|
||||
if self.reverse {
|
||||
self.layout = TuiLayout::Reverse;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ use super::event::Action;
|
|||
use super::header::Header;
|
||||
use super::item_list::ItemList;
|
||||
use super::{Event, Tui, input, preview};
|
||||
use crate::binds::SkimEvent;
|
||||
use crate::thread_pool::{self, ThreadPool};
|
||||
use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||
use eyre::{Result, bail};
|
||||
|
|
@ -133,6 +134,12 @@ pub struct App {
|
|||
/// Whether the `load` event has been fired for the current read. Reset on
|
||||
/// `reload` so a new read fires `load` again.
|
||||
pub load_event_fired: bool,
|
||||
/// Set whenever a matcher run is (re)started; drives the one-shot `result`
|
||||
/// (and `zero`/`one`) events once that run completes and is rendered.
|
||||
pub result_pending: bool,
|
||||
/// The item that currently has focus, tracked so the `focus` event fires
|
||||
/// only when it actually changes (cursor movement or a result update).
|
||||
last_focused: Option<Arc<dyn SkimItem>>,
|
||||
}
|
||||
|
||||
impl Widget for &mut App {
|
||||
|
|
@ -258,6 +265,8 @@ impl Default for App {
|
|||
currently_scrolling: false,
|
||||
reader_done: false,
|
||||
load_event_fired: false,
|
||||
result_pending: false,
|
||||
last_focused: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -326,6 +335,8 @@ impl App {
|
|||
currently_scrolling: false,
|
||||
reader_done: false,
|
||||
load_event_fired: false,
|
||||
result_pending: false,
|
||||
last_focused: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -561,15 +572,42 @@ impl App {
|
|||
f.render_widget(&mut *self, f.area());
|
||||
f.set_cursor_position(self.cursor_pos);
|
||||
})?;
|
||||
// The reader has finished and the freshly-read items have now
|
||||
// been merged into the item list by the render above, so the
|
||||
// list is stable: fire the one-shot `load` event. Routed through
|
||||
// the keymap like any key, so `--bind load:<action>` runs and can
|
||||
// safely inspect the fully-populated list.
|
||||
// The synthetic finder events below are fired *after* the draw,
|
||||
// once the item list reflects the latest reader/matcher output,
|
||||
// so a binding can safely inspect a stable, up-to-date list. Each
|
||||
// is routed through the keymap like any key press.
|
||||
|
||||
// `load`: the reader has finished and its items are now rendered.
|
||||
if self.reader_done && !self.load_event_fired {
|
||||
self.load_event_fired = true;
|
||||
tui.event_tx
|
||||
.try_send(Event::Key(crate::binds::SkimEvent::Load.into()))?;
|
||||
tui.event_tx.try_send(Event::Key(SkimEvent::Load.into()))?;
|
||||
}
|
||||
|
||||
// `result` (+ `zero`/`one`): the in-flight search has completed
|
||||
// and its results are on screen.
|
||||
if self.result_pending && self.matcher_control.stopped() {
|
||||
self.result_pending = false;
|
||||
tui.event_tx.try_send(Event::Key(SkimEvent::Result.into()))?;
|
||||
// Use the matcher's own count, which is authoritative as soon
|
||||
// as it stops; the rendered `item_list` may briefly lag it.
|
||||
match self.matcher_control.get_num_matched() {
|
||||
0 => tui.event_tx.try_send(Event::Key(SkimEvent::Zero.into()))?,
|
||||
1 => tui.event_tx.try_send(Event::Key(SkimEvent::One.into()))?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// `focus`: the focused item changed (cursor movement or a result
|
||||
// update that shifted the current row to a different item).
|
||||
let focused = self.item_list.selected().map(|m| m.item);
|
||||
let focus_changed = match (&self.last_focused, &focused) {
|
||||
(Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
if focus_changed {
|
||||
self.last_focused = focused;
|
||||
tui.event_tx.try_send(Event::Key(SkimEvent::Focus.into()))?;
|
||||
}
|
||||
}
|
||||
Event::Heartbeat | Event::Tick => {
|
||||
|
|
@ -722,8 +760,39 @@ impl App {
|
|||
vec![]
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
/// Runs an action, then appends any follow-up actions bound to it.
|
||||
///
|
||||
/// An action can be bound as if it were an event (e.g. `reload:first`): once
|
||||
/// the action has run, the chain the user bound to its name is queued after
|
||||
/// the action's own events. See [`Action::name`](crate::tui::event::Action::name).
|
||||
///
|
||||
/// If that follow-up chain contains [`Action::Skip`], the action's own
|
||||
/// default behaviour is suppressed and only the rest of the chain runs, so
|
||||
/// `act-up:skip+down` remaps the `up` action to `down`.
|
||||
fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
|
||||
let follow = self.options.action_binds.get(act.name()).cloned();
|
||||
let skip_default = follow
|
||||
.as_ref()
|
||||
.is_some_and(|chain| chain.iter().any(|a| matches!(a, Action::Skip)));
|
||||
|
||||
let mut events = if skip_default {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.dispatch_action(act)?
|
||||
};
|
||||
if let Some(chain) = follow {
|
||||
events.extend(
|
||||
chain
|
||||
.into_iter()
|
||||
.filter(|a| !matches!(a, Action::Skip))
|
||||
.map(Event::Action),
|
||||
);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn dispatch_action(&mut self, act: &Action) -> Result<Vec<Event>> {
|
||||
#[allow(clippy::enum_glob_use)]
|
||||
use Action::*;
|
||||
use ratatui::widgets::ListDirection::{BottomToTop, TopToBottom};
|
||||
|
|
@ -906,7 +975,10 @@ impl App {
|
|||
.collect());
|
||||
}
|
||||
}
|
||||
Ignore => (),
|
||||
// `ignore` is a no-op. `skip` is also a no-op on its own; its
|
||||
// suppression effect is applied in `handle_action` when it appears in
|
||||
// an action's follow-up chain.
|
||||
Ignore | Skip => (),
|
||||
KillLine => {
|
||||
let cursor = self.input.cursor_pos as usize;
|
||||
let deleted = self.input.split_off(cursor);
|
||||
|
|
@ -1295,6 +1367,9 @@ impl App {
|
|||
no_sort,
|
||||
self.needs_render.clone(),
|
||||
);
|
||||
// A new search is in flight; arm the `result`/`zero`/`one` events to
|
||||
// fire once it completes and its results are rendered.
|
||||
self.result_pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
105
src/tui/event.rs
105
src/tui/event.rs
|
|
@ -260,6 +260,12 @@ pub enum Action {
|
|||
SelectRow(usize),
|
||||
/// Select current item
|
||||
Select,
|
||||
/// Suppress the default behaviour of the action this is bound to.
|
||||
///
|
||||
/// Only meaningful as a follow-up bound to an action (e.g. `act-up:skip`):
|
||||
/// it cancels that action's own effect, so the remaining follow-up chain
|
||||
/// runs in its place. On its own it is a no-op.
|
||||
Skip,
|
||||
/// Set the header (or disable it on an empty value)
|
||||
SetHeader(Option<String>),
|
||||
/// Set the preview cmd and rerun preview
|
||||
|
|
@ -302,6 +308,104 @@ pub enum Action {
|
|||
Custom(ActionCallback),
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// Returns the canonical kebab-case name of this action — the same spelling
|
||||
/// [`parse_action`] accepts.
|
||||
///
|
||||
/// This lets an action be bound as if it were an event (e.g. `reload:first`):
|
||||
/// after the action runs, any follow-up chain keyed by this name is queued.
|
||||
/// The name ignores the action's arguments, so `down` matches `Down(1)` and
|
||||
/// `Down(5)` alike.
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &'static str {
|
||||
use Action::{
|
||||
Abort, Accept, AddChar, AppendAndSelect, BackwardChar, BackwardDeleteChar, BackwardDeleteCharEof,
|
||||
BackwardKillWord, BackwardWord, BeginningOfLine, Bind, Cancel, ClearScreen, Custom, DeleteChar, DeleteCharEof,
|
||||
DeselectAll, Down, EndOfLine, Execute, ExecuteSilent, First, ForwardChar, ForwardWord, HalfPageDown,
|
||||
HalfPageUp, IfNonMatched, IfQueryEmpty, IfQueryNotEmpty, Ignore, KillLine, KillWord, Last, NextHistory,
|
||||
PageDown, PageUp, PreviewDown, PreviewLeft, PreviewPageDown, PreviewPageUp, PreviewRight, PreviewUp,
|
||||
PreviousHistory, Redraw, RefreshCmd, RefreshPreview, Reload, RestartMatcher, RotateMode, ScrollLeft,
|
||||
ScrollRight, Select, SelectAll, SelectRow, SetHeader, SetPreviewCmd, SetQuery, Skip, Toggle, ToggleAll,
|
||||
ToggleIn, ToggleInteractive, ToggleOut, TogglePreview, TogglePreviewWrap, ToggleSort, Top, Unbind,
|
||||
UnixLineDiscard, UnixWordRubout, Up, Yank,
|
||||
};
|
||||
match self {
|
||||
Abort => "abort",
|
||||
Accept(_) => "accept",
|
||||
AddChar(_) => "add-char",
|
||||
AppendAndSelect => "append-and-select",
|
||||
BackwardChar => "backward-char",
|
||||
BackwardDeleteChar => "backward-delete-char",
|
||||
BackwardDeleteCharEof => "backward-delete-char/eof",
|
||||
BackwardKillWord => "backward-kill-word",
|
||||
BackwardWord => "backward-word",
|
||||
BeginningOfLine => "beginning-of-line",
|
||||
Bind(_) => "bind",
|
||||
Cancel => "cancel",
|
||||
ClearScreen => "clear-screen",
|
||||
DeleteChar => "delete-char",
|
||||
DeleteCharEof => "delete-char/eof",
|
||||
DeselectAll => "deselect-all",
|
||||
Down(_) => "down",
|
||||
EndOfLine => "end-of-line",
|
||||
Execute(_) => "execute",
|
||||
ExecuteSilent(_) => "execute-silent",
|
||||
First => "first",
|
||||
ForwardChar => "forward-char",
|
||||
ForwardWord => "forward-word",
|
||||
IfQueryEmpty(..) => "if-query-empty",
|
||||
IfQueryNotEmpty(..) => "if-query-not-empty",
|
||||
IfNonMatched(..) => "if-non-matched",
|
||||
Ignore => "ignore",
|
||||
KillLine => "kill-line",
|
||||
KillWord => "kill-word",
|
||||
Last => "last",
|
||||
NextHistory => "next-history",
|
||||
HalfPageDown(_) => "half-page-down",
|
||||
HalfPageUp(_) => "half-page-up",
|
||||
PageDown(_) => "page-down",
|
||||
PageUp(_) => "page-up",
|
||||
PreviewUp(_) => "preview-up",
|
||||
PreviewDown(_) => "preview-down",
|
||||
PreviewLeft(_) => "preview-left",
|
||||
PreviewRight(_) => "preview-right",
|
||||
PreviewPageUp(_) => "preview-page-up",
|
||||
PreviewPageDown(_) => "preview-page-down",
|
||||
PreviousHistory => "previous-history",
|
||||
Redraw => "redraw",
|
||||
RefreshCmd => "refresh-cmd",
|
||||
RefreshPreview => "refresh-preview",
|
||||
RestartMatcher => "restart-matcher",
|
||||
Reload(_) => "reload",
|
||||
RotateMode => "rotate-mode",
|
||||
ScrollLeft(_) => "scroll-left",
|
||||
ScrollRight(_) => "scroll-right",
|
||||
SelectAll => "select-all",
|
||||
SelectRow(_) => "select-row",
|
||||
Select => "select",
|
||||
SetHeader(_) => "set-header",
|
||||
SetPreviewCmd(_) => "set-preview-cmd",
|
||||
SetQuery(_) => "set-query",
|
||||
Skip => "skip",
|
||||
Toggle => "toggle",
|
||||
ToggleAll => "toggle-all",
|
||||
ToggleIn => "toggle-in",
|
||||
ToggleInteractive => "toggle-interactive",
|
||||
ToggleOut => "toggle-out",
|
||||
TogglePreview => "toggle-preview",
|
||||
TogglePreviewWrap => "toggle-preview-wrap",
|
||||
ToggleSort => "toggle-sort",
|
||||
Top => "top",
|
||||
Unbind(_) => "unbind",
|
||||
UnixLineDiscard => "unix-line-discard",
|
||||
UnixWordRubout => "unix-word-rubout",
|
||||
Up(_) => "up",
|
||||
Yank => "yank",
|
||||
Custom(_) => "custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an action string into an Action enum
|
||||
///
|
||||
/// Returns `None` if the action is unrecognized, or an `if-*` action is
|
||||
|
|
@ -407,6 +511,7 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
|
|||
"scroll-left" => Some(ScrollLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"scroll-right" => Some(ScrollRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
|
||||
"select" => Some(Select),
|
||||
"skip" => Some(Skip),
|
||||
"select-all" => Some(SelectAll),
|
||||
"select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
|
||||
"set-header" => Some(SetHeader(arg)),
|
||||
|
|
|
|||
|
|
@ -67,6 +67,60 @@ insta_test!(bind_load, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["-
|
|||
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "10");
|
||||
});
|
||||
|
||||
// Any action can be bound as if it were an event: `first:last` runs `last`
|
||||
// right after `first`, so pressing the key ends on the last item.
|
||||
insta_test!(bind_action_followup, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "ctrl-a:first", "--bind", "first:last"], {
|
||||
@ctrl 'a';
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "10");
|
||||
@snap;
|
||||
});
|
||||
|
||||
// `act-<name>` targets the *action* even when the name is also a key: `act-up`
|
||||
// binds the Up action (not the up key). Bound to `last`, running the Up action
|
||||
// appends a jump to the last item.
|
||||
insta_test!(bind_act_prefix, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "act-up:last"], {
|
||||
@action Up(1);
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "10");
|
||||
@snap;
|
||||
});
|
||||
|
||||
// `skip` suppresses the triggering action's own effect. Here the First action is
|
||||
// remapped: instead of jumping to the first item it only sets the header, so the
|
||||
// cursor stays put (on the last item) and the header is updated.
|
||||
insta_test!(bind_skip, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "act-first:skip+set-header(skipped)"], {
|
||||
@action Last;
|
||||
@action First;
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().header.header == "skipped");
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "10");
|
||||
@snap;
|
||||
});
|
||||
|
||||
// Test result event: fires when filtering completes and the list is ready.
|
||||
insta_test!(bind_result, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "result:last"], {
|
||||
@snap;
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().item_list.selected().unwrap().text() == "10");
|
||||
});
|
||||
|
||||
// Test focus event: fires when the focused item changes (here, initial focus).
|
||||
insta_test!(bind_focus, ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], &["--bind", "focus:set-header(focused)"], {
|
||||
@snap;
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().header.header == "focused");
|
||||
});
|
||||
|
||||
// Test zero event: fires when a completed search has no matches.
|
||||
insta_test!(bind_zero, ["a", "b", "c"], &["--bind", "zero:set-header(none)"], {
|
||||
@char 'z';
|
||||
@snap;
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().header.header == "none");
|
||||
});
|
||||
|
||||
// Test one event: fires when a completed search has exactly one match.
|
||||
insta_test!(bind_one, ["apple", "banana", "cherry"], &["--bind", "one:set-header(single)"], {
|
||||
@type "app";
|
||||
@snap;
|
||||
@assert(|h: &common::insta::TestHarness| h.skim.app().header.header == "single");
|
||||
});
|
||||
|
||||
insta_test!(bind_set_query_basic, ["a", "b", "c"], &["--bind", "ctrl-a:set-query(foo)"], {
|
||||
@snap;
|
||||
@ctrl 'a';
|
||||
|
|
|
|||
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_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_skip@001.snap
Normal file
29
tests/snapshots/binds__bind_skip@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-first:skip+set-header(skipped)\nafter:\n @action Last\n @action First"
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
"> 10 "
|
||||
" 9 "
|
||||
" 8 "
|
||||
" 7 "
|
||||
" 6 "
|
||||
" 5 "
|
||||
" 4 "
|
||||
" 3 "
|
||||
" 2 "
|
||||
" 1 "
|
||||
" skipped "
|
||||
" 10/10 9/0"
|
||||
"> "
|
||||
cursor: (24, 3)
|
||||
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