Compare commits

...

82 commits

Author SHA1 Message Date
Loric ANDRE dd3b09a687 release: v5.7.0
Some checks failed
Pull request update / autoupdate (push) Has been cancelled
Release PR / prepare (push) Has been cancelled
Release PR / tag (push) Has been cancelled
Release PR / update-pr (push) Has been cancelled
2026-09-08 14:52:59 +02:00
LoricAndre 672639fd80
fix: stream preview command instead of waiting for completion (#1176)
closes #1174
2026-09-08 11:57:31 +00:00
dependabot[bot] a177153700
chore(deps): bump ratatui-image in the cargo-prod group (#1175)
Bumps the cargo-prod group with 1 update: [ratatui-image](https://github.com/ratatui/ratatui-image).


Updates `ratatui-image` from 11.0.7 to 11.0.8
- [Release notes](https://github.com/ratatui/ratatui-image/releases)
- [Changelog](https://github.com/ratatui/ratatui-image/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ratatui/ratatui-image/compare/v11.0.7...v11.0.8)

---
updated-dependencies:
- dependency-name: ratatui-image
  dependency-version: 11.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 13:13:29 +02:00
Loric ANDRE 801d1ab036 docs: update benchmarks
Some checks failed
Pull request update / autoupdate (push) Has been cancelled
Release PR / prepare (push) Has been cancelled
Release PR / tag (push) Has been cancelled
Release PR / update-pr (push) Has been cancelled
2026-09-06 22:47:38 +02:00
LoricAndre 17c517fd02
chore: nitpicks after multithreading review (#1172)
Some checks are pending
Pull request update / autoupdate (push) Waiting to run
Release PR / prepare (push) Waiting to run
Release PR / update-pr (push) Blocked by required conditions
Release PR / tag (push) Waiting to run
* chore: nitpicks after multithreading review

* chore: remove call sites from ARCHITECTURE.md

* chore: minor reliability corrections
2026-09-05 20:35:27 +02:00
LoricAndre 0392eeb70d
ci: lock everything to make sure dependencies match (#1170)
Some checks failed
Pull request update / autoupdate (push) Has been cancelled
Release PR / prepare (push) Has been cancelled
Release PR / tag (push) Has been cancelled
Release PR / update-pr (push) Has been cancelled
* ci: lock everything to make sure dependencies match

* fix: build locked for msrv too
2026-09-04 11:56:33 +02:00
skim-rs-bot[bot] 085f86b127
release: v5.6.7 (#1169)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-04 09:31:02 +00:00
Loric ANDRE 16c6f0b386 chore: update dependencies after tinyvec breakage 2026-09-04 11:04:21 +02:00
LoricAndre d7b799b9e6
fix: make min-height work again (#1168)
Some checks are pending
Pull request update / autoupdate (push) Waiting to run
Release PR / prepare (push) Waiting to run
Release PR / update-pr (push) Blocked by required conditions
Release PR / tag (push) Waiting to run
* fix: make min-height work again

* fix: revert to String and add integration tests

* chore: misc warnings

* fix: windows tests

* fix: ci public api fails because of incompatible deps version between HEAD and release
2026-09-04 00:04:26 +02:00
dependabot[bot] a9b0fd4393
chore(deps): bump taiki-e/install-action in the gha-prod group (#1166)
Some checks failed
Pull request update / autoupdate (push) Has been cancelled
Release PR / prepare (push) Has been cancelled
Release PR / tag (push) Has been cancelled
Release PR / update-pr (push) Has been cancelled
Bumps the gha-prod group with 1 update: [taiki-e/install-action](https://github.com/taiki-e/install-action).


Updates `taiki-e/install-action` from 2.85.4 to 2.87.1
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Commits](https://github.com/taiki-e/install-action/compare/v2.85.4...v2.87.1)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.87.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gha-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 09:06:02 +02:00
dependabot[bot] a53ada910a
chore(deps): bump the cargo-prod group with 2 updates (#1165)
Some checks are pending
Pull request update / autoupdate (push) Waiting to run
Release PR / prepare (push) Waiting to run
Release PR / update-pr (push) Blocked by required conditions
Release PR / tag (push) Waiting to run
Bumps the cargo-prod group with 2 updates: [indexmap](https://github.com/indexmap-rs/indexmap) and [which](https://github.com/harryfei/which-rs).


Updates `indexmap` from 2.14.0 to 2.14.1
- [Changelog](https://github.com/indexmap-rs/indexmap/blob/main/RELEASES.md)
- [Commits](https://github.com/indexmap-rs/indexmap/compare/2.14.0...2.14.1)

Updates `which` from 8.0.5 to 8.0.6
- [Release notes](https://github.com/harryfei/which-rs/releases)
- [Changelog](https://github.com/harryfei/which-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harryfei/which-rs/compare/8.0.5...8.0.6)

---
updated-dependencies:
- dependency-name: indexmap
  dependency-version: 2.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
- dependency-name: which
  dependency-version: 8.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-01 11:04:49 +02:00
skim-rs-bot[bot] 4b962af9ed
release: v5.6.6 (#1163)
Some checks failed
Pull request update / autoupdate (push) Has been cancelled
Release PR / prepare (push) Has been cancelled
Release PR / tag (push) Has been cancelled
Release PR / update-pr (push) Has been cancelled
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-22 17:21:53 +02:00
VXNCXNX e8f5b3ae65
fix: path_name_offset returns bytes while Rank::begin is a char index (#1160)
The PathName tiebreak computes path_name_offset - begin, so mixing the
two units inflates the score for any path with a non-ASCII directory
component and ranks a filename match below a directory match.

Co-authored-by: VXNCXNX <VXNCXNX@users.noreply.github.com>
2026-08-22 13:23:57 +00:00
VXNCXNX d699733053
fix(filter): --filter hangs when the query is below --min-query-length (#1158)
Co-authored-by: VXNCXNX <vxncxnx@users.noreply.github.com>
2026-08-22 09:19:38 +00:00
LoricAndre 756b084841
ci: fix auto update branch 2026-08-21 23:07:31 +02:00
dependabot[bot] 52275d3a3e
chore(deps): bump frizbee from 0.12.0 to 0.13.0 in the cargo-prod group (#1162)
Bumps the cargo-prod group with 1 update: [frizbee](https://github.com/saghen/frizbee).


Updates `frizbee` from 0.12.0 to 0.13.0
- [Release notes](https://github.com/saghen/frizbee/releases)
- [Changelog](https://github.com/saghen/frizbee/blob/main/CHANGELOG.md)
- [Commits](https://github.com/saghen/frizbee/compare/v0.12.0...v0.13.0)

---
updated-dependencies:
- dependency-name: frizbee
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 21:04:59 +00:00
Loric ANDRE 2ec03e630c ci: automatically update PRs on pushes to master 2026-08-21 22:32:29 +02:00
Loric ANDRE c0786aa9d3 chore: clippy & fmt after cargo update 2026-08-21 22:18:54 +02:00
LoricAndre bb80ac138e
fix: do not feed items through a fifo in zsh completions (#1164)
* fix(zsh): don't relay completion items through a named pipe

`_skim_complete` handed its candidates to skim through a fifo at the fixed
path `$TMPDIR/skim-complete-fifo-$$`, created by `_skim_feed_fifo` and fed by
a backgrounded `cat`. Opening a fifo for reading blocks until a writer opens
the other end, so if the feeder ever fails to get there -- it dies, or a stale
fifo left behind by an interrupted completion makes `mkfifo` fail, or the
leftover in the world-writable temp dir belongs to someone else -- the widget
blocks forever with nothing drawn and no way to interrupt it. Interrupted
completions also leaked the fifos that set this up.

That rendezvous is the only step of the completion path that can block
indefinitely, and `kill` is the only completion that reaches it without the
`**` trigger, so `kill<TAB>` is where it surfaces.

The fifo is not needed in the first place: the `_skim_complete_*` helpers pass
their candidates on `_skim_complete`'s stdin, and the command substitution that
runs skim inherits fd 0, so skim can read them directly. (fzf relays through a
fifo because its completion functions cannot pass their own stdin along; this
file is zsh-only and has no such constraint.)

Verified in a pty, in tmux and under kitty that `kill`, `ssh`, `export`,
`unset`, `unalias` and the path/dir completions all still populate skim,
that multi-select still runs through the `_post` filters (`kill<TAB>` still
inserts bare PIDs), and that no fifos are left in the temp dir.

Refs: https://github.com/skim-rs/skim/issues/1161

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFgaE1MjSaUGAGtR4aqVzT

* cleanup comms

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-21 22:14:19 +02:00
VXNCXNX 9de4825e51
fix(matcher): an inverse query only checks the first --nth field (#1159)
Co-authored-by: VXNCXNX <vxncxnx@users.noreply.github.com>
2026-08-21 22:06:58 +02:00
skim-rs-bot[bot] 1b80cff3f7
release: v5.6.5 (#1157)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-16 09:35:40 +00:00
VXNCXNX 94022921a3
fix(field): an out-of-range field index silently matches field 1 (#1155)
* fix(field): saturate out-of-range field indices to i32 bounds

Replace unwrap_or(1)/unwrap_or(-1) with saturation to i32 bounds so out-of-range indices don't silently become field 1. Affects --nth, --with-nth, --hide-nth and {N} in --output-format.

* test(field): assert exit status and stderr for the output-format cases

Per review feedback: an empty stdout alone could pass if the placeholder
errored out instead of rendering empty.

---------

Co-authored-by: VXNCXNX <VXNCXNX@users.noreply.github.com>
2026-08-16 11:07:20 +02:00
VXNCXNX 59aecd4f2b
fix: add allow_hyphen_values to --with-nth (#1156)
Matches --nth and --hide-nth behavior to support documented negative indices syntax.

Co-authored-by: VXNCXNX <VXNCXNX@users.noreply.github.com>
2026-08-16 08:11:27 +00:00
dependabot[bot] fe45ed36b7
chore(deps): bump shell-quote from 0.7.2 to 0.8.0 in the cargo-prod group (#1154)
* chore(deps): bump shell-quote in the cargo-prod group

Bumps the cargo-prod group with 1 update: [shell-quote](https://github.com/allenap/shell-quote).


Updates `shell-quote` from 0.7.2 to 0.8.0
- [Commits](https://github.com/allenap/shell-quote/compare/v0.7.2...v0.8.0)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>

* test: fix literal failing test

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Loric ANDRE <loric.andre@pm.me>
2026-08-11 13:21:43 +00:00
skim-rs-bot[bot] 01be03f0d5
release: v5.6.4 (#1153)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-10 10:39:35 +00:00
Loric ANDRE 733aaeb148 fix: avoid truncation by casting up instead of down 2026-08-10 12:12:00 +02:00
Loric ANDRE f6a302390c fix: different suseconds and time per platform 2026-08-10 12:10:25 +02:00
Loric ANDRE 5ff1bda145 fix: platform-dependant timevals 2026-08-10 12:05:32 +02:00
Loric ANDRE 070d0849b2 fix(image): detect picker from tty to support protocol detection with piped input 2026-08-10 11:58:44 +02:00
skim-rs-bot[bot] 5fc3b0c1a0
release: v5.6.3 (#1152)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-07 14:07:35 +02:00
Vimsucks a5bd0fefa6
fix: advance the input cursor by byte length in insert_str (#1151)
`cursor_pos` is a byte offset, as `move_cursor_to` shows by validating it with
`is_char_boundary` and clamping to `value.len()`. `insert_str` advanced it by
`chars().count()` instead, so any multibyte input left the cursor inside the
text that was just inserted.

The single-char `insert` already uses `c.len_utf8()` and was unaffected.

Pasting (or committing from an IME) "中文" left the cursor at byte 3 instead of
6, so the next chunk landed between the two characters: "中文" + "测试" came out
as "中测试文". Pure ASCII input never hit this because there char count equals
byte length.

Co-authored-by: zhuyang <zhuyang@qunhemail.com>
Co-authored-by: LoricAndre <57358788+LoricAndre@users.noreply.github.com>
2026-08-07 11:27:15 +00:00
skim-rs-bot[bot] f3c03000b3
release: v5.6.2 (#1145)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-07 10:43:53 +00:00
Loric ANDRE 1dead2b3c6 fix: match double-width roman characters (closes #1149) 2026-08-07 12:11:23 +02:00
LoricAndre b17e93890a
fix: reorder batches in --tac mode (#1150)
closes #1148
2026-08-07 10:10:17 +00:00
dependabot[bot] d99d6cecc7
chore(deps): bump the cargo-prod group with 2 updates (#1147)
Bumps the cargo-prod group with 2 updates: [clap](https://github.com/clap-rs/clap) and [frizbee](https://github.com/saghen/frizbee).


Updates `clap` from 4.6.4 to 4.6.5
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.4...clap_complete-v4.6.5)

Updates `frizbee` from 0.11.0 to 0.12.0
- [Commits](https://github.com/saghen/frizbee/compare/v0.11.0...v0.12.0)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
- dependency-name: frizbee
  dependency-version: 0.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 11:23:58 +02:00
Loric ANDRE 7429ca3ec2 fix: assymetry in char_equal causing panic with some unicode characters 2026-08-02 14:39:28 +02:00
dependabot[bot] bc379b5f47
chore(deps): bump the gha-prod group with 3 updates (#1146)
Bumps the gha-prod group with 3 updates: [taiki-e/install-action](https://github.com/taiki-e/install-action), [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `taiki-e/install-action` from 2 to 2.85.4
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Commits](https://github.com/taiki-e/install-action/compare/v2...v2.85.4)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/download-artifact` from 7.0.0 to 8.0.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](37930b1c2a...3e5f45b2cf)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.85.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gha-prod
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: gha-prod
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: gha-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 10:25:59 +02:00
Loric ANDRE d2ae57625f docs: preview command runs with sh/cmd, not SHELL 2026-07-28 13:37:43 +02:00
skim-rs-bot[bot] c398374a85
release: v5.6.1 (#1143)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-27 17:18:04 +00:00
Loric ANDRE 2303a15b0c ci: do not fail release on public API breakage 2026-07-27 18:47:58 +02:00
skim-rs-bot[bot] 794ddddb71
release: v5.6.0 (#1140)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-27 17:49:35 +02:00
Loric ANDRE 4745613342 ci: make coverage non-blocking to avoid issues with nightly rust 2026-07-26 16:23:32 +02:00
Loric ANDRE 29370cc004 docs: readd default keybindings to the manpage as an auto-generated separate subsection 2026-07-26 14:45:29 +02:00
LoricAndre cbfb7fac0f
feat: add the set-cmd action to change interactive mode command from bindings (#1142)
closes #1141
2026-07-25 17:43:04 +02:00
Loric ANDRE e929c512e3 fix(examples): gate malloc_trim to gcc in the multiple_runs example 2026-07-25 16:19:31 +02:00
LoricAndre 88ce5b97ac
test: replace tmux e2e harness with cross-platform Zellij harness (#1139)
* test: replace tmux e2e harness with cross-platform Zellij harness

Rewrite the end-to-end test harness to drive `sk` through Zellij instead of
tmux, keeping the same capabilities and public surface (ZellijController,
Keys, wait, sk, the sk_test! DSL and the line!/keys!/out! helpers) so the
existing tests port over with only import/type renames.

Zellij has no detached-server model like tmux, so the harness spawns a Zellij
client attached to an in-process pseudo-terminal via portable-pty (openpty on
Unix, ConPTY on Windows). Because Zellij 0.44+ and portable-pty are both
cross-platform, the harness — and the tests that only rely on it — are now
available on Windows too: the interactive tests (formerly unix.rs) are
un-gated. execute.rs, popup.rs and listen.rs stay unix-only for reasons
unrelated to the multiplexer (PermissionsExt, a mock sh/tmux binary, unix
sockets).

Key harness details:
- Session per test via `zellij attach --create` on a fixed 80x24 PTY.
- Keys injected as raw terminal bytes with `zellij action write`; screen read
  back with `zellij action dump-screen [--ansi]`, reversed to match the old
  bottom-anchored indexing.
- A generated config disables startup tips, pane frames, mouse mode and — the
  crucial bit — the kitty keyboard protocol, so injected legacy escape
  sequences (arrows, etc.) reach sk.
- All zellij CLI calls are run under a timeout and wait() has a wall-clock
  budget, so a wedged server surfaces as a fast retryable error instead of
  hanging a test.

popup.rs unsets $ZELLIJ and sets $TMUX so skim selects its tmux popup backend
(the mock) rather than the zellij one while running inside a Zellij pane.

Because each test spins up a full Zellij session, the e2e binaries are put in
a serialized nextest test-group; CI installs Zellij (all three OSes) in place
of tmux, and the obsolete tmux setup-scripts are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* ci: fix rustfmt and stop Windows from cancelling the other nextest legs

- Run `cargo +nightly fmt` on the new Zellij harness (rustfmt CI was red).
- Set `fail-fast: false` on the nextest matrix so a failing OS leg no longer
  cancels the others, giving a clear pass/fail signal per platform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* ci: install zellij via winget on Windows

taiki-e/install-action has no prebuilt Zellij binary for Windows and falls
back to `cargo install zellij`, which fails building openssl-sys from source
on the runner. Install via winget on Windows instead (taiki-e still handles
Linux/macOS), and expose winget's shim dir on PATH for the test step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test/ci: address review feedback on the Zellij harness

- Pin the Windows winget Zellij install to 0.44.3 to match the Linux/macOS
  runners (reproducible CI).
- Drop the unused `&locale` YAML anchor (actionlint flagged it).
- `wait` now surfaces the last predicate error on timeout instead of a
  generic one, so a persistent failure keeps its diagnostic cause.
- `output_with_timeout` tears down the child and reader threads on a
  `try_wait` error instead of leaking them.
- Add rustdoc to the public harness surface (`sk`, `wait`, `Keys`,
  `ZellijController` and its methods).

Deliberately not changed: a non-zero `zellij` exit is still not treated as an
error (some `zellij action` calls exit non-zero in transient states — e.g.
inline `sk` viewport teardown — while returning usable output; propagating it
broke `inline_clear_on_exit`), and `to_lines` keeps trimming to preserve the
tmux-parity bottom-anchored indexing the ported tests rely on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* ci: put the real zellij.exe dir on PATH for the Windows test step

The winget install succeeds, but its Links shim wasn't reliably visible to the
`cargo nextest` step's processes, so `which("zellij")` failed and every
interactive test panicked at setup. Locate the installed zellij.exe under the
WinGet Packages dir and add its directory to GITHUB_PATH instead, failing the
step loudly if it isn't found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* ci: reload PATH from registry after the MSI zellij install on Windows

The winget Zellij package is an MSI installer that installs to Program Files
and updates the machine PATH in the registry, not a portable under
WinGet\Packages — so the previous "search Packages" lookup threw. Reload PATH
from the machine/user registry values (with a Program Files fallback), then
export zellij's directory via GITHUB_PATH for the test step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test/ci: gate interactive e2e tests off Windows

Enabling the interactive tests on the Windows runner surfaced a real gap: the
PATH/install issues are fixed (winget install works), but under the Windows
runner's ConPTY the Zellij session never renders — dump-screen stays empty and
wait_ready times out with "pane not rendered yet" for every interactive test.
That's a harness-runtime gap on Windows (and sk's escape-code disambiguation on
Windows would be a further blocker), so gate interactive.rs `#![cfg(not(windows))]`
with a TODO, keeping the harness code cross-platform.

Since no Windows test now uses the harness, drop the winget Zellij install from
the Windows leg; Linux/macOS still install it via taiki-e. Adjust the docs that
claimed the e2e tests run on Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test(e2e): gate Zellij harness tests to Linux only

The Zellij-backed e2e harness renders reliably under the Linux CI runner,
but on the macOS and Windows runners the pane never comes up under their
PTY (`wait_ready` times out with "pane not rendered yet"). Restrict all
four e2e test files (interactive, execute, popup, listen) to
`#![cfg(target_os = "linux")]`, install Zellij only on the Linux runner,
and update the harness/agent/architecture docs to match.

The harness code stays cross-platform so macOS/Windows e2e can be
re-enabled once their runners render the session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test(e2e): make the Zellij harness render on macOS and Windows

The Zellij e2e harness previously only came up reliably on the Linux CI
runner; on macOS and Windows the pane never rendered and every e2e test
timed out with "pane not rendered yet". Root cause (surfaced by capturing
the Zellij client's PTY output): Zellij's client/server startup handshake
is racy — the client occasionally dies with "Received empty unknown from
server" and the session never renders. It's rare on Linux (flaky) but
frequent on the cold macOS/Windows runners.

Harden the harness so it renders everywhere instead of gating tests to
Linux:

- Detect a dead session fast (drain thread flags client PTY EOF) and
  respawn a fresh session, up to SESSION_SPAWN_ATTEMPTS times, instead of
  waiting out the whole render budget and failing.
- Resolve the pane's shell to an absolute `bash` path via `which`; the
  Zellij server's own environment may not have `bash` on PATH on the
  macOS/Windows runners, which would leave the pane with no shell to render.
- Nudge the client's terminal size until the server gives the pane a
  non-zero geometry to render into (the initial size can be dropped under
  ConPTY / a cold runner).
- Give the first render its own longer budget and, on timeout, surface a
  tail of the Zellij client output for diagnosing runners we can't
  reproduce locally.

Un-gate the tests accordingly: interactive.rs (pure harness) now runs on
Linux, macOS and Windows; execute.rs/popup.rs/listen.rs go back to
#![cfg(unix)] (Linux + macOS) — their Windows-incompatibility is POSIX
mock binaries / a unix socket, unrelated to the multiplexer. CI installs
Zellij on all three OSes (taiki-e on Linux/macOS, winget on Windows) and
the nextest job gets a 45-minute cap so a harness regression fails fast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test(e2e): fix macOS session-name rejection via short ZELLIJ_SOCKET_DIR

The macOS runner failed every Zellij e2e test at CLI-parse time:

  error: Invalid value "skim_e2e_..." for '--session <SESSION>':
         session name must be less than 0 characters

This is not the render race the previous commit addressed. Zellij places
each session's unix socket at `$ZELLIJ_SOCKET_DIR/<protocol>/<session>`,
and a unix socket path is length-capped by the OS (~104 bytes on macOS).
Zellij's default base is `$TMPDIR/zellij-<uid>`; on the macOS runners
`$TMPDIR` is a long `/var/folders/…` path that leaves ~0 bytes for the
session name, so Zellij rejects every name and the client exits before it
attaches (zellij-org/zellij#4211). Linux's short `/run`|`/tmp` base never
hits this, which is why it only failed on macOS.

- Export ZELLIJ_SOCKET_DIR=/tmp/skim-zj (a short base) on every zellij
  invocation — the attached client, `action`, and `run` — so they share a
  short socket path well under the cap on Linux and macOS alike.
- Shorten session names (`sk_<=10 chars_<6 rand>`): several were derived
  from long test names (e.g. execute_interactive_child_keeps_receiving_
  keys_fullscreen) and exceeded Zellij's ~36-char limit and ate socket
  budget; the random suffix still keeps them unique.

Also fix a stale doc command in AGENTS.md (`cargo nextest --tests` ->
`cargo nextest run --tests`), per PR review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test(e2e): answer the DSR cursor-position probe so Windows renders

The Windows nextest leg hung on every interactive.rs e2e test:

  Error: pane not rendered within 60s. zellij client output tail:
  \u{1b}[6n

The captured client output was a single `ESC[6n` — a Device Status
Report requesting the cursor position. Under the Windows ConPTY the
Zellij client probes the terminal size by asking for the cursor position
and blocks until the terminal replies; on Unix the size comes from the
PTY ioctl, so the client never waits (which is why only Windows hung).

The harness owns the master PTY — it *is* the terminal — so the drain
thread now watches for `ESC[6n` and writes back a Cursor Position Report
(`ESC[24;80R`, reporting the 24x80 pane). This unblocks the client so the
pane renders. The reply is harmless on Linux/macOS (all 45 e2e tests
still pass there), keeping interactive.rs on all three platforms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test(e2e): address review nits in the Zellij harness

Follow-ups from PR review, none affecting the cross-platform fixes:

- zellij_socket_dir() now returns io::Result and propagates a
  create_dir_all failure through run()/action()/spawn_once() instead of
  swallowing it, so a socket-dir problem surfaces directly rather than as
  a confusing downstream Zellij error.
- Fix a latent typo in the (currently unused) assert_line!/line! macro:
  std::io::std::io::Error{,Kind} -> std::io::Error / std::io::ErrorKind,
  so the macro compiles if a test ever uses it.
- tempfile() returns an InvalidData error instead of panicking on a
  non-UTF-8 temp path.

Skipped the reviewer's suggestion to stop trimming captured output: the
trim is load-bearing. It drops Zellij's blank padding rows so capture()[0]
is the bottom content line that every test indexes against; stripping only
CR/LF would reintroduce ~20 empty rows and shift every index. No test
exercises intentionally-spaced items, so there is no real defect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test(e2e): silence unused_assignments warning in wait()

`last_err` was initialised to `None` and always overwritten before it
could be read, so the initial assignment was dead (unused_assignments
warning at the top of every test build). Return the current predicate
error directly on timeout instead of stashing it — same behaviour (the
most recent error is surfaced), no dead variable, no warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test(e2e): wrap assert_line! timeout error to 120 columns

Pure formatting: split the Err/Error::new/format! construction in the
(rustfmt-skipped) assert_line! macro body across lines to satisfy the
repo's 120-column limit. No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test(e2e): guard against [-0] in the negative-index DSL macro

@method_neg_dispatch used `lines.len() >= $idx`, which is always true for
$idx == 0, so `@capture[-0]` would index `lines[lines.len()]` and panic.
Require `$idx > 0` in both the predicate and diagnostic paths so a `[-0]`
index falls through to the graceful "not enough lines" / "<no line>"
handling instead. No current test uses negative indices; this only closes
the latent edge case. Per PR review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test(e2e): use forward slashes for Windows paths in the bash command

With the DSR fix the Windows pane now renders and runs the command, which
surfaced the next issue: the harness drives a `bash` shell but embedded
native Windows paths (backslashes) into the command string. bash treats
`\` as an escape, so `.\target\release\sk.exe` collapsed to
`.targetreleasesk.exe` ("command not found") and the `C:\Users\...`
redirect/mv targets would mangle the same way.

Convert `\` to `/` for the `sk` binary and the outfile when building the
bash command in sk(); bash on Windows accepts `./target/release/sk.exe`
and `C:/Users/...`. On Unix the paths have no backslashes so it is a
no-op, and the stored outfile the test reads back keeps native separators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

* test(e2e): reap the Zellij client child in Drop

ZellijController::drop killed the client with child.kill() but never
waited on it, so on Unix each dropped controller left a zombie until the
test binary exited — and many controllers are created per binary. Pair
the kill with child.wait() (matching output_with_timeout) so the process
is reaped immediately. Per PR review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wTqRsi31RYJXEM3ZZQhQU

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-23 16:32:29 +00:00
skim-rs-bot[bot] 9016a712a2
release: v5.5.0 (#1135)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-23 12:16:07 +00:00
Loric ANDRE 0415ea7bcd fix: do not panic on push/pop keyboard enhancement flags failure 2026-07-23 13:49:15 +02:00
LoricAndre 3644242897
feat: add double-click as a bindable trigger (#1134)
* feat: add double-click as a bindable trigger

closes #313

* typo
2026-07-21 14:24:47 +00:00
skim-rs-bot[bot] b2a732efa0
release: v5.4.0 (#1133)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-21 12:43:12 +00:00
LoricAndre 4f4cd15a42
docs: update coverage url 2026-07-21 14:14:34 +02:00
Loric ANDRE df683e34a7 ci: actually push apt 2026-07-21 13:56:24 +02:00
Loric ANDRE d4c878893b ci: publish apt repo 2026-07-21 13:52:54 +02:00
LoricAndre 776d708ede
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>
2026-07-21 11:42:47 +00:00
dependabot[bot] 76a27ed80c
chore(deps): bump serde_json in the cargo-prod group (#1131)
Bumps the cargo-prod group with 1 update: [serde_json](https://github.com/serde-rs/json).


Updates `serde_json` from 1.0.150 to 1.0.151
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.150...v1.0.151)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.151
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 11:02:20 +02:00
LoricAndre d7e294eb7d
fix: allow execute actions to run interactive commands (#1132)
* fix(tui): stop input reader and give execute() children their own tty

Interactive/ncurses programs run via an `execute()` action (e.g. `ncdu`)
would freeze after a few keystrokes. Two independent problems caused it:

1. skim's background input reader (the `EventStream` task started in
   `Tui::start`) kept reading the terminal while the child ran, so skim and
   the child raced for keystrokes on the same tty — roughly half the keys
   were stolen from the child.

2. The child inherited skim's stdin (fd 0), which is a pipe whenever items
   are piped in (`find | sk`). An interactive child then had no keyboard
   source at all.

Fix both:

- Add `Tui::stop_and_join`, which cancels the event-pump task and blocks
  until it has dropped its `EventStream`, guaranteeing skim has released the
  terminal before the child starts. `run_foreground` calls it before running
  the child and `Tui::start` after.

- Give the child its own stdin opened from the controlling terminal
  (`/dev/tty`, or `CONIN$` on Windows), falling back to inheriting skim's
  stdin if that fails.

Because running a foreground process needs the `Tui` (which `handle_action`
does not have), `Execute` now only expands the command and returns a new
`Event::RunExecute`, which `handle_event` runs via `run_foreground`. This
mirrors the existing `RunPreview` pattern. `execute-silent` is unchanged.

Update ARCHITECTURE.md (event dispatch table, terminal lifecycle, and
cross-reference line numbers) and add tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBQ1chjHyN3JNTWqMMED44

* test(execute): add tmux e2e test; fix reader restart and post-execute repaint

Add a tmux-based integration test (tests/execute.rs, unix-gated so it runs
on the Linux and macOS CI legs) that drives an interactive child through an
`execute` action and asserts it keeps receiving keystrokes, then that skim
is interactive again once the child exits. It covers both the fullscreen and
inline (`--height`) layouts.

Writing the test surfaced two bugs in the execute reader-suspend work that
unit tests could not catch:

1. Reader never resumed. `Tui::stop_and_join` cancels the shared
   `CancellationToken`, but `Tui::start` reused that same token — and a
   cancelled token stays cancelled — so the respawned reader observed the
   cancellation immediately and exited without reading input. `start` now
   installs a fresh token on every call (also fixing the latent
   restart-while-running path).

2. Post-execute repaint hung when stdout was redirected. The repaint went
   through `Event::Redraw` → `tui.clear()`, and ratatui's `Terminal::clear`
   queries the cursor position, which crossterm writes to stdout via
   `ESC [ 6 n`. skim renders to stderr and its stdout is routinely redirected
   (`sk > file`), so the query reached no terminal, got no reply, and stalled
   the UI for seconds before erroring out. Replace it with
   `Tui::force_full_redraw`, which resets ratatui's diff buffers for a full
   repaint with no cursor query and works for both fullscreen and inline
   viewports.

Update ARCHITECTURE.md and the cross-reference table accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBQ1chjHyN3JNTWqMMED44

* ci: fix matrix

* fix: pop kitty keyboard flag before entering execute

* chore: remove duplication in backend.rs

* chore: refactor

* fix: kitty maintains a different set of flags in alt screen

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-21 09:01:53 +00:00
skim-rs-bot[bot] 5c1c3379fb
release: v5.3.2 (#1130)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-20 22:37:12 +00:00
LoricAndre 24a24619ea
ci: publish .deb, .rpm and winget packages on release (#1129)
* ci: add .deb and .rpm packages to releases via dist

Configure cargo-deb and cargo-generate-rpm to build Linux packages
containing the sk executable, the man pages (sk.1, sk-tmux.1) and the
bash/zsh/fish shell completions.

A new reusable workflow (package.yml) builds both packages and uploads
them under an artifacts-* name. It is wired into the release pipeline as
a dist global-artifacts-job in dist-workspace.toml, and release.yml is
regenerated with `dist generate` (not hand-edited) so dist's host job
attaches the packages to the GitHub Release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmNevtS7EV3vXyYRTVu7Aw

* ci: drop sk-tmux man page from .deb and .rpm packages

Package only the sk.1 man page; the sk-tmux.1 page is no longer shipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmNevtS7EV3vXyYRTVu7Aw

* ci: temporarily build release artifacts on PRs

Set dist pr-run-mode = "upload" so the .deb and .rpm (and the other
release artifacts) are built and uploaded on pull requests, allowing the
packages to be downloaded and verified before merging.

This is temporary and should be reverted before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmNevtS7EV3vXyYRTVu7Aw

* ci: install packaging tools via taiki-e/install-action

Address review feedback on package.yml:
- Install cargo-deb and cargo-generate-rpm with taiki-e/install-action
  (prebuilt binaries) plus a Swatinem/rust-cache step, matching the
  patterns used in test.yml, instead of compiling them with cargo install.
- Drop the `--output target/debian` flag from `cargo deb`; the default
  target/debian/ directory is what the collect step expects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmNevtS7EV3vXyYRTVu7Aw

* ci: build arm64 .deb and .rpm packages too

Turn the package job into a matrix that builds natively for both amd64
(ubuntu-22.04) and arm64 (ubuntu-22.04-arm), producing a .deb and .rpm
per architecture. Artifacts are uploaded under per-arch names so dist's
host job attaches all of them to the release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmNevtS7EV3vXyYRTVu7Aw

* ci: submit releases to winget via winget-releaser

Add a dist publish job that, after the GitHub Release is created, submits
the new version to the Windows Package Manager Community Repository using
vedantmgoyal9/winget-releaser and the Windows .zip artifact dist already
attaches to the release.

Uses the package identifier and installer regex from skim-rs/skim#769:
  identifier: skim-rs.skim
  installers-regex: '-pc-windows-msvc\.zip$'

Wired in through publish-jobs in dist-workspace.toml; release.yml is
regenerated with `dist generate` (not hand-edited). Prereleases are never
submitted. Requires a WINGET_TOKEN secret (a public_repo-scoped PAT that
owns a microsoft/winget-pkgs fork under skim-rs).

Refs: skim-rs/skim#769

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmNevtS7EV3vXyYRTVu7Aw

* ci: validate package version against the release plan

Consume the `plan` input dist passes to the package job: assert the
crate version equals the version dist planned for this release, so the
source-built .deb/.rpm can't silently drift from the release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmNevtS7EV3vXyYRTVu7Aw

* Update .github/workflows/winget.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update .github/workflows/package.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* docs: document .deb, .rpm, winget and scoop installation

Add the new install methods to the README: winget and Scoop rows plus a
Debian/RPM section with install commands, covering amd64 and arm64.

Also clarify in winget.yml that WINGET_TOKEN must be a classic PAT
(fine-grained tokens can't open the winget-pkgs PR).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmNevtS7EV3vXyYRTVu7Aw

* fix: install cargo-deb manually to avoid libc version mismatch

* ci: use blacksmith runners for long job

* fix: version spec for cargo install

* chore: revert pr action upload

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-20 22:11:51 +00:00
skim-rs-bot[bot] 6dbe37a7fa
release: v5.3.1 (#1128)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-19 21:42:17 +00:00
LoricAndre 6f94d80973
fix: ignore KeyEventState (#1127)
* fix: ignore KeyEventState

* test: add numlock ignore test

* Update src/tui/app_tests.rs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-19 21:19:59 +00:00
skim-rs-bot[bot] 4d093a42e1
release: v5.3.0 (#1123)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-19 15:25:03 +02:00
Loric ANDRE 3c3f26ebc9 fix: kitty keyboard protocol
closes #1126
2026-07-19 14:53:51 +02:00
LoricAndre ad97bfa7de
feat: add bind and unbind actions (#1121)
* feat: add `bind` and `unbind` actions

Add two new actions that allow programmatic (re)binding of keys at
runtime, both through `--bind` keybindings and the IPC/listen socket:

- `bind(key:action[+action][,key:action…])` adds one or more bindings,
  reusing the same parsing/merging logic as the `--bind` CLI option.
  Existing bindings for the same key are replaced.
- `unbind(key[,key…])` removes the bindings for a comma-separated list
  of keys, mirroring fzf's `unbind(...)` semantics.

Because the `Action` enum derives serde when the `listen` feature is
enabled, both actions are drivable over the IPC socket for free.

Covered by unit tests for parsing (`event_tests.rs`) and dispatch
(`app_tests.rs`), plus IPC integration tests (`listen.rs`). Manpage and
ARCHITECTURE.md updated with the new actions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYqqomFjYqqXd5NvkVxfbQ

* feat: add `bind` and `unbind` actions

* chore: generate files

* fixes

* fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-18 14:22:41 +00:00
LoricAndre ff98133bbd
ci: add public-api check (#1124)
* ci: add public-api check

* test: try pushing an API breaking change

* fix: install nightly

* test: revert breaking
2026-07-18 15:57:01 +02:00
LoricAndre dce26d622a
feat: add --hide-nth to hide fields from display but keep them searchable (#1122)
* Add --hide-nth flag to hide fields while keeping them searchable

Introduce a `--hide-nth <fieldspec>` option that takes the same
comma-separated field index expressions as `--nth`/`--with-nth`. The
listed fields are removed from the displayed line but remain part of the
text used for matching, so a query can still match them. Characters in
the hidden fields are ignored for match highlighting and horizontal
scrolling.

Implementation:
- Resolve the fieldspec to byte ranges in the same coordinate space as
  the matching/display text and store them as `hidden_ranges` in
  DefaultSkimItem metadata, exposed via a new `SkimItem::hidden_ranges()`
  trait method. text()/output() keep the full text so hidden fields stay
  searchable and are preserved on output.
- DefaultSkimItem::display() removes hidden characters and remaps match
  highlight positions into visible coordinates (project_visible_text /
  project_match_indices); this path takes precedence over ANSI styling.
- ItemRenderer::render_item applies the same projection to derive the
  visible sub-line text and hscroll match range, so hidden characters are
  ignored for horizontal scrolling.

Add unit tests for range normalization/projection and item behavior,
plus insta snapshot tests covering display removal, searchability, and
hscroll. Update ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCunXeAFMYAFduTc8SNjSM

* Preserve ANSI colors for surviving text under --hide-nth

Previously the hidden-field rendering path was applied ahead of the ANSI
display branch and rebuilt the line from the ANSI-stripped text, so
combining --hide-nth with --ansi dropped the colors of the visible
fields.

Integrate hidden-field removal into the ANSI branch instead: after
parsing the styled spans, drop the hidden characters while preserving
each span's style (retain_visible_spans) and remap the match positions
into the resulting visible coordinate space, then run the normal
highlighting. The plain (non-ANSI) branch keeps its project-and-to_line
handling. Surviving characters now keep their ANSI colors while hidden
fields stay searchable.

Add unit tests for ANSI color preservation and remapped highlighting,
plus ANSI color-snapshot integration tests. Update ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCunXeAFMYAFduTc8SNjSM

* Set hidden fields via builder instead of DefaultSkimItem::new param

Remove the `hidden_fields` parameter from `DefaultSkimItem::new` and set
the hidden fields through a `hidden_fields(&[FieldRange], &Regex)`
builder method instead. The builder resolves the fields against the
item's own `text()` (the same coordinate space `new` would have used),
so the result is identical while keeping `new`'s signature unchanged for
its many existing call sites.

The reader chains `.hidden_fields(&opt.hidden_fields, &opt.delimiter)`
onto construction. Revert the extra `&[]` argument at the other call
sites (selector, fuzz target, tests) and update the hide-nth tests to
use the builder. Update ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCunXeAFMYAFduTc8SNjSM

* chore: generate files

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-18 13:13:43 +00:00
LoricAndre 55aa7dacd5
feat: add 'left' and right info display modes (#1120) 2026-07-18 12:34:21 +00:00
skim-rs-bot[bot] 57a7487323
release: v5.2.0 (#1119)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-17 17:58:07 +00:00
LoricAndre bb6c03f377
feat: reduce binary size by removing uncommon image formats and color_eyre (#1118)
* Shrink binary: trim image decoders and swap color-eyre for eyre

Two dependency changes that cut the default `sk` binary from 13.6 MiB to
8.55 MiB (-5.06 MiB, -37%) with no loss of core functionality:

- image: build the `image` crate with only the common decoders (png,
  jpeg, gif, webp) instead of its full default format set, and drop
  ratatui-image's `image-defaults`. This removes AVIF encoding (ravif,
  avif-serialize), OpenEXR (exr), TIFF, QOI and other decoders that are
  irrelevant to terminal image previews. Previewing those formats now
  falls back to the normal command preview.

- error handling: replace color-eyre with plain eyre. color-eyre only
  provided colored panic/error backtraces; skim used none of its
  Section/Help extension APIs. This drops the backtrace/gimli/addr2line/
  color-spantrace stack. `color_eyre::install()` is no longer needed.

Tests, benches and examples are migrated from color_eyre to eyre so the
crate is fully removed from the dependency graph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BQtxeCS4gM7dumghqmNgST

* chore: fmt

* docs: ARCHITECTURE.md

* chore(flake): add cargo-bloat

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-17 17:29:43 +00:00
LoricAndre 21f7ef8795
feat: collapsed borders by default and --border-no-collapse flag (#1117)
* feat: collapsed borders by default and `--border-no-collapse` flag

* chore: generate files
2026-07-17 16:59:14 +00:00
skim-rs-bot[bot] f87ce2075d
release: v5.1.4 (#1116)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-17 12:45:54 +00:00
juneboku 29ca6f22bd
fix: preserve input order with --no-sort (#1115)
* fix: preserve input order with --no-sort

Workers grab 4096-item chunks from a shared queue, so the order in
which worker results are concatenated is nondeterministic. With
--no-sort the merged list was left in that arrival order, which
scrambles the display order once the item count exceeds
num_workers * chunk_size (~24k items on a 10-core machine) and makes
--filter output nondeterministic for large inputs.

Sort matched items by rank.index (the original input position) when
no_sort is set: each worker sorts its accumulator in prepare, and the
final merge exploits the k sorted runs, mirroring the sorted path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* avoid extra sorts by exploiting the thread_pool stability

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Loric ANDRE <loric.andre@pm.me>
2026-07-17 12:16:46 +00:00
skim-rs-bot[bot] b41a724a2a
release: v5.1.3 (#1112)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-16 21:58:51 +00:00
LoricAndre 04c0a5b6c8
ci: fix fixed viewport resize test in non-interactive env (#1114) 2026-07-16 21:30:34 +00:00
Loric ANDRE 9be7392911 fix: manually resize the Tui when not in fullscreen mode
closes #113
2026-07-16 22:14:17 +02:00
Loric ANDRE b1aa46917b chore: fix CHANGELOG duplication 2026-07-16 14:29:18 +02:00
skim-rs-bot[bot] 4047a5e3fd
release: v5.1.2 (#1111)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-16 12:36:16 +02:00
Loric ANDRE 95c360996a ci: add changelog as job output 2026-07-16 12:06:48 +02:00
Loric ANDRE 09479547eb ci: update app-id to client-id 2026-07-16 11:51:24 +02:00
Loric ANDRE 44b9cf2011 ci: add Release PR workflow 2026-07-16 11:43:44 +02:00
Loric ANDRE 8ea0afb63e release: v5.1.1 2026-07-16 10:55:28 +02:00
Lei Zhang 974bd267cc
fix: handle deprecated --expect flag in sk 4.x vim plugin (#1057)
* fix: handle deprecated --expect flag in sk 4.x vim plugin

sk 4.x deprecated --expect and no longer outputs the pressed key name
as the first line of results. This caused s:common_sink to silently
return without opening files, since it expected at least 2 lines
(key + filename). Now handles both old and new output formats.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(vim): replace deprecated expect with accept bindings

* fix(vim): shell-escape accept bindings

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Loric ANDRE <loric.andre@pm.me>
2026-07-16 10:19:04 +02:00
dependabot[bot] 815a9b4f94
chore(deps): bump the cargo-prod group with 3 updates (#1109)
* chore(deps): bump the cargo-prod group with 3 updates

Bumps the cargo-prod group with 3 updates: [frizbee](https://github.com/saghen/frizbee), [gungraun](https://github.com/gungraun/gungraun) and [thread_local](https://github.com/Amanieu/thread_local-rs).


Updates `frizbee` from 0.10.0 to 0.11.0
- [Commits](https://github.com/saghen/frizbee/compare/v0.10.0...v0.11.0)

Updates `gungraun` from 0.19.3 to 0.19.4
- [Release notes](https://github.com/gungraun/gungraun/releases)
- [Changelog](https://github.com/gungraun/gungraun/blob/main/CHANGELOG.md)
- [Commits](https://github.com/gungraun/gungraun/compare/v0.19.3...v0.19.4)

Updates `thread_local` from 1.1.9 to 1.1.10
- [Release notes](https://github.com/Amanieu/thread_local-rs/releases)
- [Changelog](https://github.com/Amanieu/thread_local-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Amanieu/thread_local-rs/compare/v1.1.9...v1.1.10)

---
updated-dependencies:
- dependency-name: frizbee
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-prod
- dependency-name: gungraun
  dependency-version: 0.19.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
- dependency-name: thread_local
  dependency-version: 1.1.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-prod
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: frizbee

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Loric ANDRE <loric.andre@pm.me>
2026-07-16 10:01:35 +02:00
151 changed files with 8862 additions and 3348 deletions

5
.codegraph/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore

View file

@ -1,9 +1,8 @@
experimental = ["setup-scripts", "wrapper-scripts"]
experimental = ["wrapper-scripts"]
[scripts.setup.stop-tmux]
command = "sh -c 'tmux kill-session -t skim_e2e || true'"
[scripts.setup.start-tmux]
command = "tmux new-session -d -s skim_e2e -n skim_e2e"
# The end-to-end tests drive `sk` inside a Zellij session that each test creates
# and tears down itself (see tests/common/zellij.rs), so — unlike the previous
# tmux-based harness — no shared multiplexer session needs to be set up here.
# Valgrind wrapper for memory leak detection
[scripts.wrapper.valgrind]
@ -16,17 +15,28 @@ command = [
"--suppressions=.config/valgrind.supp"
]
# The end-to-end tests each spin up a full Zellij session (server + client +
# PTY). Running many concurrently overwhelms the machine and makes the
# timing-sensitive tests flaky, so serialize everything in this group while the
# rest of the suite keeps running in parallel. (Under raw `cargo test`, pass
# `--test-threads=1` for the e2e test binaries instead.)
[test-groups]
e2e = { max-threads = 1 }
[profile.default]
fail-fast = false
retries = 2
[[profile.default.scripts]]
platform = "cfg(unix)"
setup = ["stop-tmux", "start-tmux"]
[[profile.default.overrides]]
filter = 'binary(interactive) | binary(listen) | binary(popup) | binary(execute)'
test-group = 'e2e'
[profile.default.junit]
path = "junit.xml"
[profile.ci]
retries = 9
[[profile.ci.overrides]]
filter = 'binary(interactive) | binary(listen) | binary(popup) | binary(execute)'
test-group = 'e2e'
# Valgrind profile for memory leak detection
# Usage: cargo nextest run --profile valgrind
@ -39,7 +49,6 @@ retries = 2
test-threads = 1 # Run tests serially to avoid interleaved valgrind output
[[profile.valgrind.scripts]]
platform = "cfg(unix)"
setup = ["stop-tmux", "start-tmux"]
run-wrapper = "valgrind"
# ThreadSanitizer profile for detecting data races and thread issues
@ -68,6 +77,3 @@ run-wrapper = "valgrind"
fail-fast = false
retries = 3
test-threads = 1 # TSan requires running tests serially
[[profile.tsan.scripts]]
platform = "cfg(unix)"
setup = ["stop-tmux", "start-tmux"]

75
.github/workflows/apt.yml vendored Normal file
View file

@ -0,0 +1,75 @@
name: Deploy APT repo
# Publishes the release's .deb packages as a (flat) APT repository under
# /apt on the gh-pages branch, alongside the coverage report at /coverage.
# keep_files preserves the coverage content (and vice versa).
#
# Requires APT_GPG_PRIVATE_KEY to be set :
#
# ```sh
# export GNUPGHOME=$(mktemp -d)
# cat > "$GNUPGHOME/skim-key" <<'EOF'
# %no-protection
# Key-Type: eddsa
# Key-Curve: ed25519
# Subkey-Type: ecdh
# Subkey-Curve: cv25519
# Name-Real: skim apt repo
# Name-Email: apt@skim-rs.github.io
# Expire-Date: 0
# %commit
# EOF
# gpg --batch --gen-key "$GNUPGHOME/skim-key" 2>&1 | tail -3
# KEYID=$(gpg --list-secret-keys --with-colons apt@skim-rs.github.io | awk -F: '/^sec:/{print $5; exit}')
# # Copy the output of the line below
# gpg --export-secret-keys --armor "$KEYID"
# ```
on:
workflow_call:
inputs:
plan:
required: true
type: string
jobs:
apt:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download release .deb packages
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ fromJson(inputs.plan).announcement_tag }}
run: |
mkdir -p apt
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern '*.deb' --dir apt
ls -l apt
- name: Build APT index
run: |
sudo apt-get update
sudo apt-get install -y dpkg-dev apt-utils
cd apt
dpkg-scanpackages --multiversion . > Packages
gzip -k -f Packages
apt-ftparchive release . > Release
- name: Sign the repo
# Skipped when the secret is absent; the repo then stays unsigned
# ([trusted=yes]). Add APT_GPG_PRIVATE_KEY (an ASCII-armored private
# key) to enable signed-by verification.
env:
GPG_KEY: ${{ secrets.APT_GPG_PRIVATE_KEY }}
run: |
echo "$GPG_KEY" | gpg --batch --import
KEYID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/{print $5; exit}')
cd apt
gpg --batch --yes --default-key "$KEYID" --clearsign -o InRelease Release
gpg --batch --yes --default-key "$KEYID" -abs -o Release.gpg Release
gpg --export --armor "$KEYID" > skim-archive-keyring.asc
- name: Deploy to gh-pages under /apt
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: apt
destination_dir: apt
keep_files: true

80
.github/workflows/package.yml vendored Normal file
View file

@ -0,0 +1,80 @@
name: Build Linux packages
# Custom dist job (wired in through `global-artifacts-jobs` in dist-workspace.toml).
# Builds a `.deb` and a `.rpm` for amd64 and arm64, each containing the `sk`
# executable, the man page and the shell completions, then uploads them under an
# `artifacts-*` name so that dist's `host` job attaches them to the GitHub
# Release. Both architectures build natively (no cross-compilation) on their
# respective GitHub-hosted runners.
on:
workflow_call:
inputs:
plan:
required: true
type: string
jobs:
linux-packages:
name: Build .deb and .rpm (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-22.04
- arch: arm64
runner: ubuntu-22.04-arm
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Install rust toolchain
run: rustup toolchain install
- name: Check the crate version matches the release plan
# These packages are built from the checked-out source (the same tree
# dist releases from), so the crate version must match the version dist
# planned for this release. Consume the plan to guard against drift.
env:
ANNOUNCEMENT_TAG: ${{ fromJson(inputs.plan).announcement_tag }}
run: |
plan_version="${ANNOUNCEMENT_TAG#v}"
crate_version="$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')"
echo "release plan: '$plan_version' | crate: '$crate_version'"
if [ -n "$plan_version" ] && [ "$plan_version" != "$crate_version" ]; then
echo "::error::Cargo.toml version ($crate_version) does not match the release plan ($plan_version)"
exit 1
fi
- name: Install cargo-generate-rpm
uses: taiki-e/install-action@v2.87.1
with:
tool: cargo-generate-rpm@0.21
- name: Setup cargo cache
uses: Swatinem/rust-cache@v2
# cargo-deb's prebuilt binary links GLIBC_2.39, which ubuntu-22.04
# (glibc 2.35) lacks. Build it from source so it links the runner's
# glibc. We stay on 22.04 on purpose: the sk binary it packages must
# keep a low glibc floor for the .deb to run on older distros.
- name: Install cargo-deb
run: cargo install 'cargo-deb@^3' --locked
- name: Build release binary
run: cargo build --release --bin sk
- name: Build .deb package
run: cargo deb --no-build
- name: Build .rpm package
run: cargo generate-rpm
- name: Collect packages
run: |
mkdir -p target/linux-packages
cp target/debian/*.deb target/linux-packages/
cp target/generate-rpm/*.rpm target/linux-packages/
ls -l target/linux-packages/
- name: Upload packages
uses: actions/upload-artifact@v7
with:
name: artifacts-linux-packages-${{ matrix.arch }}
path: target/linux-packages/*
if-no-files-found: error

13
.github/workflows/pr-auto-update.yml vendored Normal file
View file

@ -0,0 +1,13 @@
name: Pull request update
on:
push:
branches: [master]
jobs:
autoupdate:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: allonsy-studio/actions-pr-auto-update@5fbf9661cbf5c1f092ec33e5a1cfbe70dc5bd81c

View file

@ -88,11 +88,11 @@ jobs:
cache-all-crates: "true"
- name: Generate files
run: |
cargo run -- --man > ./man/man1/sk.1
cargo run -- --shell bash > ./shell/completion.bash
cargo run -- --shell zsh > ./shell/completion.zsh
cargo run -- --shell fish > ./shell/completion.fish
cargo run -- --shell nushell > ./shell/completion.nu
cargo run --locked -- --man > ./man/man1/sk.1
cargo run --locked -- --shell bash > ./shell/completion.bash
cargo run --locked -- --shell zsh > ./shell/completion.zsh
cargo run --locked -- --shell fish > ./shell/completion.fish
cargo run --locked -- --shell nushell > ./shell/completion.nu
- name: Check diff
run: |
if git diff --exit-code; then

175
.github/workflows/release-pr.yml vendored Normal file
View file

@ -0,0 +1,175 @@
name: Release PR
on:
push:
branches: [master]
pull_request:
branches: [master]
types: [closed]
permissions:
contents: read
concurrency:
group: release-pr-${{ github.event_name }}
cancel-in-progress: true
jobs:
prepare:
if: "${{ github.event_name == 'push' && !startsWith(github.event.head_commit.message, 'release: v') }}"
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
changelog: ${{ steps.changelog.outputs.content }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
persist-credentials: false
- uses: taiki-e/install-action@742a3317eac7bd62f91cd888b4eead5e784ba833 # v2
with:
tool: git-cliff
- id: version
name: Compute next version
shell: bash
run: |
version="$(git cliff --bumped-version)"
version="${version#v}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]
if git rev-parse --verify --quiet "refs/tags/v$version"; then
echo "No releasable changes since v$version"
exit 0
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Prepare release files
if: steps.version.outputs.version != ''
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
sed -i 's/^version = ".*"/version = "'"$VERSION"'"/' Cargo.toml
SKIM_DEFAULT_OPTIONS='' cargo run -- --man > man/man1/sk.1
SKIM_DEFAULT_OPTIONS='' cargo run -- --shell bash > shell/completion.bash
SKIM_DEFAULT_OPTIONS='' cargo run -- --shell zsh > shell/completion.zsh
SKIM_DEFAULT_OPTIONS='' cargo run -- --shell fish > shell/completion.fish
SKIM_DEFAULT_OPTIONS='' cargo run -- --shell nushell > shell/completion.nu
git cliff -p CHANGELOG.md -t "v$VERSION" -u
cargo generate-lockfile
echo "$VERSION" > shell/version.txt
git diff --binary -- CHANGELOG.md Cargo.lock Cargo.toml man shell > "$RUNNER_TEMP/release.patch"
test -s "$RUNNER_TEMP/release.patch"
- id: changelog
name: Render release changelog
if: steps.version.outputs.version != ''
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
delimiter="changelog-$(cat /proc/sys/kernel/random/uuid)"
{
echo "content<<$delimiter"
git cliff -u -t "v$VERSION" --strip all
echo "$delimiter"
} >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: steps.version.outputs.version != ''
with:
name: release-${{ github.sha }}
path: ${{ runner.temp }}/release.patch
if-no-files-found: error
update-pr:
needs: prepare
if: needs.prepare.outputs.version != ''
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-${{ github.sha }}
path: ${{ runner.temp }}/release
- name: Create the release commit
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
git fetch origin master
test "$(git rev-parse origin/master)" = "$GITHUB_SHA"
git apply "$RUNNER_TEMP/release/release.patch"
unexpected="$(git diff --name-only | grep -Ev '^(CHANGELOG.md|Cargo.lock|Cargo.toml|man/|shell/)' || true)"
test -z "$unexpected"
git switch -c release-pr
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git add CHANGELOG.md Cargo.lock Cargo.toml man shell
git commit -m "release: v$VERSION"
# Mint the write token only on this fresh runner, after repository code has finished executing.
- id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ vars.SKIM_RS_BOT_CLIENT_ID }}
private-key: ${{ secrets.SKIM_RS_BOT_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Push branch and create or update PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
VERSION: ${{ needs.prepare.outputs.version }}
CHANGELOG: ${{ needs.prepare.outputs.changelog }}
shell: bash
run: |
auth="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0)"
git -c http.extraheader="AUTHORIZATION: basic $auth" push --force origin HEAD:release-pr
cat > "$RUNNER_TEMP/pr-body.md" <<EOF
Automated release preparation for v$VERSION.
This branch is rebuilt from master as one commit on every push. Merge it with the repository's required squash merge; the resulting commit will be tagged v$VERSION and picked up by the release workflow.
EOF
printf '%s\n' "$CHANGELOG" >> "$RUNNER_TEMP/pr-body.md"
pr="$(gh pr list --base master --head release-pr --state open --json number --jq '.[0].number')"
if [[ -n "$pr" ]]; then
gh pr edit "$pr" --title "release: v$VERSION" --body-file "$RUNNER_TEMP/pr-body.md"
else
gh pr create --base master --head release-pr --title "release: v$VERSION" --body-file "$RUNNER_TEMP/pr-body.md"
fi
tag:
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref == 'release-pr' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
persist-credentials: false
sparse-checkout: Cargo.toml
- id: release
name: Validate release merge
env:
PR_TITLE: ${{ github.event.pull_request.title }}
shell: bash
run: |
version="$(awk -F '"' '/^version = "/ { print $2; exit }' Cargo.toml)"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]
test "$PR_TITLE" = "release: v$version"
echo "tag=v$version" >> "$GITHUB_OUTPUT"
# No checked-out code runs after this repository-scoped, contents-only token is minted.
- id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ vars.SKIM_RS_BOT_CLIENT_ID }}
private-key: ${{ secrets.SKIM_RS_BOT_PRIVATE_KEY }}
permission-contents: write
- name: Tag the squash commit
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
SHA: ${{ github.event.pull_request.merge_commit_sha }}
TAG: ${{ steps.release.outputs.tag }}
run: gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$TAG" -f sha="$SHA"

View file

@ -64,9 +64,9 @@ jobs:
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.4/cargo-dist-installer.sh | sh"
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
@ -82,7 +82,7 @@ jobs:
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
@ -91,10 +91,8 @@ jobs:
uses: ./.github/workflows/test.yml
secrets: inherit
permissions:
"code-quality": "write"
"contents": "read"
"contents": "write"
"id-token": "write"
"pages": "write"
# Build and packages all the platform-specific things
build-local-artifacts:
@ -141,7 +139,7 @@ jobs:
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
@ -168,7 +166,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
@ -190,14 +188,14 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
@ -215,20 +213,30 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
custom-package:
needs:
- plan
- build-local-artifacts
uses: ./.github/workflows/package.yml
with:
plan: ${{ needs.plan.outputs.val }}
secrets: inherit
# Determines if we should publish/announce
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
- custom-package
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.custom-package.result == 'skipped' || needs.custom-package.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
@ -240,14 +248,14 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
@ -260,14 +268,14 @@ jobs:
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: artifacts
@ -302,15 +310,45 @@ jobs:
"id-token": "write"
"packages": "write"
custom-winget:
needs:
- plan
- host
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
uses: ./.github/workflows/winget.yml
with:
plan: ${{ needs.plan.outputs.val }}
secrets: inherit
# publish jobs get escalated permissions
permissions:
"id-token": "write"
"packages": "write"
custom-apt:
needs:
- plan
- host
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
uses: ./.github/workflows/apt.yml
with:
plan: ${{ needs.plan.outputs.val }}
secrets: inherit
# publish jobs get escalated permissions
permissions:
"contents": "write"
"id-token": "write"
announce:
needs:
- plan
- host
- custom-publish
- custom-winget
- custom-apt
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish.result == 'skipped' || needs.custom-publish.result == 'success') }}
if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish.result == 'skipped' || needs.custom-publish.result == 'success') && (needs.custom-winget.result == 'skipped' || needs.custom-winget.result == 'success') && (needs.custom-apt.result == 'skipped' || needs.custom-apt.result == 'success') }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -18,38 +18,60 @@ concurrency:
jobs:
nextest:
runs-on: ${{matrix.os}}
runs-on: ${{matrix.runner}}
# Cap the wall-clock so a harness regression that makes the Zellij pane never
# render (each `wait_ready` then burning its full budget) fails the leg in
# minutes instead of letting a serialized e2e run drag on for hours.
timeout-minutes: 45
strategy:
# Report every OS independently: a failure on one (e.g. Windows) must not
# cancel the others, so we still get a clear signal from Linux and macOS.
fail-fast: false
matrix: &matrix
build: [linux, macos, windows]
include:
- build: linux
os: ubuntu-latest
target: x86_64-unknown-linux-musl
runner: ubuntu-latest
- build: macos
os: macos-latest
target: x86_64-apple-darwin
runner: macos-latest
- build: windows
os: windows-latest
target: x86_64-pc-windows-msvc
runner: windows-latest
permissions:
contents: read
steps:
- &linux-deps
name: "[linux] Install dependencies"
- &zellij-install
# The e2e tests drive `sk` inside a Zellij session (Zellij 0.44+ runs on
# Linux, macOS and Windows). taiki-e/install-action fetches a prebuilt
# binary on Linux and macOS; it has no Windows binary and `cargo install
# zellij` fails building openssl from source on the runner, so Windows
# installs via winget below.
name: Install zellij (Linux/macOS)
if: runner.os != 'Windows'
uses: taiki-e/install-action@v2.87.1
with:
tool: zellij@0.44.3
- name: Install zellij (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
sudo apt-get install tmux
tmux -V
locale
if: runner.os == 'Linux'
- name: "[macos] Install dependencies"
run: |
brew install tmux
tmux -V
locale
if: runner.os == 'macOS'
env:
HOMEBREW_NO_AUTO_UPDATE: 1
$ErrorActionPreference = 'Stop'
winget install --exact --id Zellij.Zellij --version 0.44.3 --source winget --accept-source-agreements --accept-package-agreements --disable-interactivity
# The winget package is an MSI that installs zellij and adds its dir to
# the machine PATH in the registry. Neither the current process nor
# GITHUB_PATH sees that until reloaded, so refresh PATH from the
# registry (with a Program Files fallback), then expose zellij's dir to
# later steps.
$env:PATH = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') + ';' + $env:PATH
$exe = (Get-Command zellij.exe -ErrorAction SilentlyContinue).Source
if (-not $exe) { $exe = (Get-ChildItem 'C:\Program Files' -Recurse -Filter zellij.exe -ErrorAction SilentlyContinue | Select-Object -First 1).FullName }
if (-not $exe) { throw "zellij.exe not found after winget install" }
$dir = Split-Path -Parent $exe
Write-Host "zellij installed at: $dir"
Add-Content -Path $env:GITHUB_PATH -Value $dir
& $exe --version
- name: Show locale
run: locale
if: runner.os != 'Windows'
- &checkout
name: Checkout repository
@ -61,7 +83,7 @@ jobs:
run: rustup toolchain install
- &nextest-install
name: Install nextest
uses: taiki-e/install-action@v2
uses: taiki-e/install-action@v2.87.1
with:
tool: nextest@0.9
- &cache
@ -71,7 +93,7 @@ jobs:
run: cargo test --doc
- name: "Run tests"
# Do not use `--all-targets` to avoid running benches
run: cargo nextest run --release --profile ci --bins --lib --examples --tests
run: cargo nextest run --release --locked --profile ci --bins --lib --examples --tests
env:
LC_ALL: en_US.UTF-8
TERM: xterm-256color
@ -93,36 +115,28 @@ jobs:
coverage:
runs-on: ubuntu-latest
continue-on-error: true
permissions:
code-quality: write
contents: write
steps:
- *linux-deps
- *zellij-install
- *checkout
- *toolchain
- *nextest-install
- uses: taiki-e/install-action@v2
- uses: taiki-e/install-action@v2.87.1
with:
tool: cargo-llvm-cov@0.8
- *cache
- name: "Run tests with coverage"
# Do not use `--all-targets` to avoid running benches
run: |
cargo +nightly llvm-cov nextest --release --profile ci --branch --bins --lib --examples --tests --no-report
cargo +nightly llvm-cov nextest --locked --release --profile ci --branch --bins --lib --examples --tests --no-report
cargo +nightly llvm-cov report --release --cobertura --output-path coverage.xml
cargo +nightly llvm-cov report --release --html
echo "COVERAGE_PERCENT=$(cargo +nightly llvm-cov report --release | tail -n1 | awk '{ print $13 }')" | tee --append $GITHUB_ENV
env:
LC_ALL: en_US.UTF-8
TERM: xterm-256color
- name: "Upload coverage report"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/upload-code-coverage@v1
with:
file: coverage.xml
language: Rust
label: ${{ runner.os }}
- name: "Generate coverage badge"
if: &if-master github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
uses: emibcn/badge-action@v2.0.4
@ -131,29 +145,17 @@ jobs:
status: ${{ env.COVERAGE_PERCENT }}
color: 'blue'
path: 'target/llvm-cov/html/coverage.svg'
- name: "Upload default branch results to gh pages"
- name: "Deploy coverage to gh-pages under /coverage"
if: *if-master
uses: actions/upload-pages-artifact@v5
uses: peaceiris/actions-gh-pages@v4
with:
path: target/llvm-cov/html
deploy-coverage-page:
if: *if-master
needs: coverage
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: "Deploy gh pages"
id: deployment
uses: actions/deploy-pages@v5
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: target/llvm-cov/html
destination_dir: coverage
keep_files: true
clippy:
runs-on: ${{matrix.os}}
runs-on: ${{matrix.runner}}
strategy:
matrix: *matrix
steps:
@ -164,7 +166,7 @@ jobs:
run: cargo clippy
rustfmt:
runs-on: ${{matrix.os}}
runs-on: ${{matrix.runner}}
strategy:
matrix: *matrix
steps:
@ -175,7 +177,7 @@ jobs:
cargo fmt --all -- --check
clippy-no-default-features:
runs-on: ${{matrix.os}}
runs-on: ${{matrix.runner}}
strategy:
matrix: *matrix
steps:
@ -184,11 +186,11 @@ jobs:
- *cache
- name: Run clippy with specific features
run: |
cargo clippy --no-default-features -- -Dwarnings
cargo clippy --no-default-features --features cli -- -Dwarnings
cargo clippy --no-default-features --features image -- -Dwarnings
cargo clippy --no-default-features --features listen -- -Dwarnings
cargo clippy --no-default-features --features frizbee -- -Dwarnings
cargo clippy --locked --no-default-features -- -Dwarnings
cargo clippy --locked --no-default-features --features cli -- -Dwarnings
cargo clippy --locked --no-default-features --features image -- -Dwarnings
cargo clippy --locked --no-default-features --features listen -- -Dwarnings
cargo clippy --locked --no-default-features --features frizbee -- -Dwarnings
msrv:
@ -196,16 +198,13 @@ jobs:
steps:
- *checkout
- *toolchain
- uses: taiki-e/install-action@v2
with:
tool: cargo-msrv@0.19
- name: MSRV Verify
run: cargo msrv verify
run: cargo +1.91.0 build --release --locked --all-targets
fuzz:
permissions:
contents: read
runs-on: ${{matrix.os}}
runs-on: ${{matrix.runner}}
strategy:
matrix: *matrix
steps:
@ -224,7 +223,7 @@ jobs:
# https://rust-fuzz.github.io/book/cargo-fuzz/windows/setup.html).
if: runner.os == 'Windows'
uses: ilammy/msvc-dev-cmd@v1
- uses: taiki-e/install-action@v2
- uses: taiki-e/install-action@v2.87.1
with:
tool: cargo-fuzz@0.13
- name: Run fuzz targets
@ -247,21 +246,47 @@ jobs:
done
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: fuzz-crashes-${{ matrix.build }}
name: fuzz-crashes-${{ runner.os }}
path: fuzz/artifacts/
if-no-files-found: ignore
# Single aggregate status check for branch protection / repository rulesets,
# so they don't need to enumerate every matrix leg by name individually.
public-api:
name: Check for public API breaking changes
permissions:
contents: read
continue-on-error: true
runs-on: ubuntu-latest
steps:
- *checkout
- *toolchain
- *cache
- uses: taiki-e/install-action@v2.87.1
with:
tool: cargo-public-api@0.52
- run: rustup toolchain install nightly-x86_64-unknown-linux-gnu
name: Install nightly toolchain
# Compare two repository revisions so cargo-public-api uses their committed
# lockfiles. A registry comparison resolves the released crate again and can
# fail when a new, broken transitive dependency is published.
- run: git fetch --force --tags
name: Fetch release tags
- run: |
latest_tag="$(git tag --list 'v*' --sort=-version:refname | head -n1)"
cargo public-api diff "${latest_tag}..HEAD" --deny removed --deny changed
name: Run cargo-public-api
ci-success:
name: CI Success
if: always()
needs:
- nextest
- coverage
- deploy-coverage-page
- clippy
- rustfmt
- clippy-no-default-features
@ -276,4 +301,4 @@ jobs:
echo "One or more required jobs failed or were cancelled."
exit 1
fi
echo "All required jobs passed (deploy-coverage-page is expected to skip off master)."
echo "All required jobs passed."

38
.github/workflows/winget.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Publish to winget
# Custom dist publish job (wired in through `publish-jobs` in dist-workspace.toml).
# After dist creates the GitHub Release, this submits the new version to the
# Windows Package Manager Community Repository (microsoft/winget-pkgs) using the
# Windows .zip artifact that dist already builds and attaches to the release.
#
# Requires a `WINGET_TOKEN` repository secret: a *classic* GitHub PAT with the
# `public_repo` scope that owns a fork of microsoft/winget-pkgs under the
# `fork-user` account (see below). Fine-grained tokens are not supported -- they
# can commit but cannot open the cross-fork PR to winget-pkgs. Without it the job
# fails at submission time.
on:
workflow_call:
inputs:
plan:
required: true
type: string
secrets:
WINGET_TOKEN:
required: true
description: "GitHub PAT with public_repo scope for winget-pkgs PR"
jobs:
winget:
name: Publish winget package
# winget-pkgs only accepts stable versions; never submit prereleases.
if: ${{ !fromJson(inputs.plan).announcement_is_prerelease }}
runs-on: ubuntu-latest
steps:
- name: Submit skim to the Windows Package Manager Community Repository
uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: skim-rs.skim
installers-regex: '-pc-windows-msvc\.zip$'
release-tag: ${{ fromJson(inputs.plan).announcement_tag }}
fork-user: skim-rs
token: ${{ secrets.WINGET_TOKEN }}

View file

@ -5,7 +5,7 @@
- Run: `cargo run [--release]`
- Test (all): `cargo nextest run`
- Test (single): `cargo nextest test_name`
- Integration/E2E tests: `cargo nextest --tests` (will need tmux under the hood)
- Integration/E2E tests: `cargo nextest run --tests` (drives `sk` through Zellij under the hood; needs `zellij` >= 0.44 and `bash` on `$PATH`)
- Memory leak detection: `cargo nextest run --profile valgrind`
- Thread leak/race detection:
1. Build: `RUSTFLAGS="-Zsanitizer=thread" cargo +nightly build --tests -Zbuild-std --target x86_64-unknown-linux-gnu`
@ -34,16 +34,34 @@
- Changing the threading model or synchronization primitives
- Adding or removing public API surface (`SkimItem`, `SkimOptions`, `SkimOutput`, etc.)
- Changing the event/action system or key binding infrastructure
- Keep call-site line numbers in the cross-reference table up to date when the referenced functions move.
## Testing
This application can be tested by :
- creating a new `tmux` session in the background (`tmux new-session -s <session name> -d`). Make sure to clear the `SKIM_DEFAULT_OPTIONS` env var.
- creating a new named tmux window in that session : `tmux new-window -d -P -F '#I' -n <window name> -t <session name>` and configuring the pane naming using `tmux set-window-option -t <window name> pane-base-index 0`
- sending the command to run and input using `tmux send-keys -t <window name> <keys>`
- when ready, capturing the window using `tmux capture-pane -b <window name> -t <window name>.0` and then saving the capture to a file using `tmux save-buffer -b <window name> <output file>`
The end-to-end tests drive a real `sk` process through a terminal, using the
Zellij-backed harness in `tests/common/zellij.rs` (`ZellijController` + the
`sk_test!` DSL). It requires `zellij` (>= 0.44) and `bash` on `$PATH`. The
harness is cross-platform (Linux, macOS and Windows). The pure-harness tests in
`interactive.rs` run on all three platforms; `execute.rs`, `popup.rs` and
`listen.rs` stay `#![cfg(unix)]` for reasons unrelated to the multiplexer (they
install POSIX mock binaries / bind a unix socket), so they run on Linux and
macOS. A few harness details make the non-Linux runners work: the pane's shell
is resolved to an absolute `bash` path (the Zellij server's environment may lack
`bash` on `PATH`); `ZELLIJ_SOCKET_DIR` is forced to a short path so the session's
unix socket path stays under the OS cap (macOS's default `$TMPDIR` is too long);
the drain thread answers the client's cursor-position report (`ESC[6n`), which
the Windows ConPTY client blocks on to learn the terminal size; and `wait_ready`
nudges the client's terminal size until the server gives the pane a non-zero
geometry to render into. The harness drives Zellij with:
- `zellij attach --create <session>` (spawned on an in-process PTY via
`portable-pty`) to start a detached session; `SKIM_DEFAULT_OPTIONS` and friends
are cleared on the spawned process.
- `zellij --session <session> action write <bytes...>` to inject keystrokes.
- `zellij --session <session> action dump-screen [--ansi]` to capture the pane.
When exploring manually you can reproduce the same flow with those commands; the
config the harness writes disables startup tips, pane frames and the kitty
keyboard protocol (so injected legacy escape sequences reach `sk`).
## Insta Snapshot Tests

View file

@ -102,7 +102,7 @@ skim/ ← workspace root
│ ├── binds.rs ← KeyMap, parse_key, parse_action_chain
│ ├── theme.rs ← ColorTheme, named palettes
│ ├── thread_pool.rs ← ThreadPool + parallel_work_queue
│ ├── field.rs ← field range parsing (--nth / --with-nth)
│ ├── field.rs ← field range parsing (--nth / --with-nth / --hide-nth)
│ ├── spinlock.rs ← lightweight SpinLock<T>
│ ├── util.rs ← printf helper, misc utilities
│ ├── popup/ ← tmux & zellij popup integration
@ -110,7 +110,7 @@ skim/ ← workspace root
│ │ ├── tmux.rs ← TmuxPopup (builds/runs tmux display-popup)
│ │ └── zellij.rs ← ZellijPopup (builds/runs zellij action new-floating-pane)
│ ├── prelude.rs ← convenience re-exports
│ ├── manpage.rs ← man-page generation (cli feature)
│ ├── manpage.rs ← man-page generation (cli feature); action list generated from ACTION_CATALOG
│ ├── shell.rs ← shell completion generation (cli feature)
│ ├── engine/ ← match engine implementations
│ │ ├── mod.rs
@ -140,7 +140,8 @@ skim/ ← workspace root
│ ├── mod.rs ← Size (fixed/percent/negative), Direction, BorderType, re-exports
│ ├── app.rs ← App struct + render + event dispatch (central state machine)
│ ├── backend.rs ← Tui<B> (ratatui terminal wrapper + crossterm event pump)
│ ├── event.rs ← Event enum, Action enum, ActionCallback, parse_action
│ ├── event.rs ← Event enum (re-exports Action, ActionCallback, parse_action)
│ ├── actions.rs ← Action enum + name + parse_action + ACTION_CATALOG, all generated by define_action_catalog!
│ ├── widget.rs ← SkimWidget trait + SkimRender result type
│ ├── input.rs ← Input widget (query box + cursor + status info)
│ ├── item_list.rs ← ItemList widget (scrollable match result list)
@ -153,7 +154,8 @@ skim/ ← workspace root
│ └── util.rs ← cursor helpers, style merging
├── tests/ ← integration & snapshot tests
│ ├── common/
│ │ └── insta.rs ← snap! / insta_test! macros for TUI snapshot testing
│ │ ├── insta.rs ← snap! / insta_test! macros for TUI snapshot testing
│ │ └── zellij.rs ← ZellijController + sk_test! DSL: cross-platform e2e harness driving sk in a Zellij pane
│ ├── snapshots/ ← committed .snap files
│ ├── ansi.rs ← ANSI rendering tests
│ ├── options.rs ← option coverage tests
@ -164,6 +166,7 @@ skim/ ← workspace root
```
The single crate exports:
- A **library** (`lib`): all types under `skim::*`, suitable for embedding.
- A **binary** (`sk`, requires feature `cli`): the `clap`-based CLI.
@ -174,6 +177,9 @@ The `image` feature (enabled by default) gates image preview support, including
`SkimOptions::image` / `SkimOptions::image_picker` fields, and the
`PreviewContent::Image` rendering path. With the feature off, the `--image` flag and
its supporting code are compiled out entirely and neither image crate is pulled in.
The `image` crate is built with only the common decoders enabled (`png`, `jpeg`,
`gif`, `webp`) rather than its full default set, keeping the binary small; previewing
other formats (TIFF, OpenEXR, QOI, BMP, …) will fail.
The `listen` feature (enabled by default) gates the IPC socket that lets other processes
drive skim via `--listen` / `--remote`, including the `interprocess`, `ron`, and `serde`
@ -214,7 +220,7 @@ main()
Two public entry points exist on `Skim`:
| Method | Use case |
|---|---|
| --- | --- |
| `Skim::run_with(options, source)` | Takes a `SkimItemReceiver` channel (or `None` to use the configured command collector). The canonical entry point. |
| `Skim::run_items(options, items)` | Convenience wrapper: accepts any `IntoIterator<Item: SkimItem>`, batches them through a bounded channel, and calls `run_with`. |
@ -265,7 +271,7 @@ Skim::run_with(options, source)
Each call to `tick()` runs a `tokio::select!` on four concurrent futures:
| Branch | Source | Action |
|---|---|---|
| --- | --- | --- |
| `tui.next()` | crossterm keyboard/mouse/resize/paste events | Dispatch to `app.handle_event()` |
| `matcher_interval.tick()` | 10 ms periodic timer (adaptive: disabled once reader finishes and all items are matched) | `app.restart_matcher(false)` |
| `items_available.notified()` | `Notify` set by `ItemPool::append` | `app.restart_matcher(false)` |
@ -286,6 +292,7 @@ The default mode. The TUI is shown in full. Items arrive from stdin or a command
When `--filter <query>` is set, skim never opens the TUI.
`Skim::should_enter()` enters a busy-wait loop:
```
loop {
if matcher.stopped() && reader.is_done() && pool.num_not_taken() == 0 {
@ -295,6 +302,7 @@ loop {
app.restart_matcher(false);
}
```
Then `app.item_list.items` is populated from `processed_items` and `output()` is called immediately. The matched items are printed to stdout by the binary, one per line (or null-delimited with `--print0`).
In filter mode the `FuzzyEngine` is built with `filter_mode = true`, which uses `fuzzy_match_range` instead of `fuzzy_indices` to skip the per-character index computation and run faster.
@ -306,6 +314,7 @@ In filter mode the `FuzzyEngine` is built with `filter_mode = true`, which uses
When `--interactive` is set together with `--cmd <template>`, the query box controls a shell command rather than a fuzzy filter. Every change to the input re-expands the template and issues a `Reload` event.
Template placeholders:
- `{}` — the current query
- `{q}` — alias for `{}`
- `{n}` — ordinal of the current item
@ -313,6 +322,7 @@ Template placeholders:
`App::expand_cmd()` handles placeholder expansion. On `Action::ToggleInteractive`, the mode flips between the query controlling the fuzzy filter and the query driving the command.
In interactive mode, the initial command is expanded against the initial query:
```rust
// src/skim.rs Skim::init()
let initial_cmd = if app.options.interactive && app.options.cmd.is_some() {
@ -321,6 +331,7 @@ let initial_cmd = if app.options.interactive && app.options.cmd.is_some() {
```
A `Reload(new_cmd)` event is handled at the `Skim::tick()` level (not `App::handle_event()`), because it must kill the reader and restart cleanly:
```rust
// src/skim.rs tick()
if let Event::Reload(new_cmd) = &evt {
@ -329,6 +340,7 @@ if let Event::Reload(new_cmd) = &evt {
```
`handle_reload()`:
1. Kills `reader_control` (waits for all reader threads to stop)
2. Clears `ItemPool`
3. Clears `ItemList` (unless `no_clear_if_empty`)
@ -342,7 +354,7 @@ if let Event::Reload(new_cmd) = &evt {
All three are handled in `Skim::should_enter()` before opening the TUI:
| Option | Meaning | Behaviour |
|---|---|---|
| --- | --- | --- |
| `--select-1` | Auto-accept if exactly one match | Waits until ≥ 2 matches or reader/matcher done; returns without TUI if exactly 1 match |
| `--exit-0` | Exit immediately if no matches | Waits until ≥ 1 match or done; returns without TUI if 0 matches |
| `--sync` | Block until all items processed | Waits until `num_matched == usize::MAX` (effectively waits for full scan) |
@ -369,10 +381,12 @@ ANSI input uses the same parallel pipeline as plain input — there is no separa
When `--popup [direction[,size[,size]]]` (alias `--tmux`) is set, the binary calls `check_and_run_popup()`, which checks `popup::check_env()` and, if true, delegates to `popup::run_with()` instead of `Skim::run_with()`.
**`check_env()`** returns `true` only when:
- `$_SKIM_POPUP` is **not** set in the environment (prevents the child process from recursing back into popup mode), and
- at least one supported multiplexer is detected: tmux (`$TMUX` set) or Zellij (`$ZELLIJ` set).
The popup flow:
1. Creates a temp directory for IPC (`/tmp/sk-popup-XXXXXXXX/`).
2. If stdin is piped, creates a named FIFO (`tmp_stdin`) and spawns a thread to relay stdin into it incrementally so the child can stream-read.
3. Reconstructs the `sk` command line from `std::env::args()`, shell-quotes every retained argument, strips `--popup`/`--tmux`, `--output-format`, and `--print-cmd`, then appends `--print-query --print-header --print-current --print-score`.
@ -414,9 +428,11 @@ Source (stdin bytes or child process stdout)
└── parallel_bufread() (all inputs)
├─ Thread 1: I/O reader — reads 256 KB chunks, splits at line boundaries,
│ assigns monotonic sequence numbers, sends to MPMC channel
├─ Thread N: workers — receive chunks, validate UTF-8,
├─ Thread 1: bounded dispatcher — uses in-flight tokens to limit queued/running jobs
├─ Pool workers: receive chunk jobs, validate UTF-8,
│ create DefaultSkimItem::new(line, ansi, trans_fields, matching_fields, delimiter)
│ (handles ANSI stripping, --nth / --with-nth transforms inline),
│ .hidden_fields(hidden_fields, delimiter)
│ (handles ANSI stripping, --nth / --with-nth / --hide-nth inline),
│ send (seq, items) pairs
├─ Thread 1: reorder — collects (seq, items), emits in order through SkimItemReceiver;
│ drops tx_pipeline_done on exit (signals killer thread)
@ -438,14 +454,32 @@ SkimItemReceiver channel
### `DefaultSkimItem` construction matrix
| `with_nth` | `ansi` | `text` field | `orig_text` | `stripped_text` |
|---|---|---|---|---|
| --- | --- | --- | --- | --- |
| false | false | original line | None | None |
| false | true | original line | None | stripped (+ `ansi_info`) |
| true | false | transformed | original | None |
| true | true | transformed | original | stripped (+ `ansi_info`) |
| false | true | original line | None | stripped (+ `ansi_info`) |
| true | false | transformed | original | None |
| true | true | transformed | original | stripped (+ `ansi_info`) |
Fields `/0` bytes are stripped from `text` (used for display/matching) but preserved in `orig_text` (used for output).
**`--hide-nth`** is orthogonal to the matrix above and applied through the builder method
`DefaultSkimItem::hidden_fields(hidden_fields, delimiter)` after construction (rather than a `new`
parameter). The requested fields are resolved to byte ranges (in the same coordinate space as
`text()` — the stripped text under `--ansi`, otherwise the `text` field) and stored as
`hidden_ranges` in the item metadata, exposed via the `SkimItem::hidden_ranges()` trait method. The
hidden fields **remain part of `text()`**, so they stay searchable and still participate in matching.
They only affect rendering:
- `DefaultSkimItem::display()` removes the hidden characters and remaps the match highlight
positions into the visible coordinate space (`project_visible_text` / `project_match_indices` in
`src/helper/item.rs`). This is integrated into **both** display branches: the plain branch projects
the text through `to_line`, and the ANSI branch drops the hidden characters from the already-parsed
styled spans (`retain_visible_spans`) so surviving characters **keep their ANSI colors**, then runs
the normal highlighting on the remapped visible-coordinate matches.
- `ItemRenderer::render_item` applies the same projection to derive the visible sub-line text and the
match range used for horizontal scrolling, so hidden characters are ignored for hscroll and never
highlighted.
---
## The Matching Subsystem
@ -458,7 +492,7 @@ Engines are composable through the factory pattern. Starting from `Matcher::crea
options
├── if regex mode:
│ RegexEngineFactory
│ RegexEngineFactory (configured with the same RankBuilder / --tiebreak criteria)
│ └─ if normalize: NormalizedEngineFactory(RegexEngineFactory)
└── else (fuzzy/exact mode):
@ -488,7 +522,7 @@ query: "'abc def | ghi ^xyz"
Query prefix semantics handled by `ExactOrFuzzyEngineFactory::create_engine_with_case()`:
| Prefix/suffix | Engine type |
|---|---|
| --- | --- |
| `'abc` | force ExactEngine (toggle from default) |
| `!abc` | ExactEngine with `inverse = true` |
| `^abc` | ExactEngine with `prefix = true` |
@ -501,11 +535,12 @@ Query prefix semantics handled by `ExactOrFuzzyEngineFactory::create_engine_with
### Fuzzy Algorithms
All algorithms implement the `FuzzyMatcher` trait with two methods:
- `fuzzy_indices(choice, pattern) → Option<(score, Vec<usize>)>` — full match with per-character highlights
- `fuzzy_match_range(choice, pattern) → Option<(score, begin, end)>` — fast path without highlight indices (used in filter mode)
| Algorithm | Flag | Notes |
|---|---|---|
| --- | --- | --- |
| `Arinae` | `--algorithm arinae` (default) | Smith-Waterman with affine gaps; typo-resistant; picks last occurrence on ties when `--last-match` |
| `SkimV2` | `--algorithm skim_v2` | Skim's classic dynamic-programming scorer |
| `Clangd` | `--algorithm clangd` | Clangd-style subsequence scoring |
@ -513,6 +548,7 @@ All algorithms implement the `FuzzyMatcher` trait with two methods:
| `Frizbee` | `--algorithm frizbee` | Edit-distance based; explicitly typo-tolerant |
Typo tolerance is configured via `Typos`:
- `Typos::Disabled` — no tolerance (default)
- `Typos::Smart` — adaptive: `query.len() / 4` typos allowed
- `Typos::Fixed(n)` — exactly n typos
@ -546,20 +582,21 @@ Matcher::run(query, item_pool, thread_pool, …)
│ └─ merge_worker_results(worker_results, no_sort, …)
│ ├─ concatenate k sorted runs
│ ├─ sort() (stable; driftsort detects k runs → O(n log k))
│ └─ write into SpinLock<Option<ProcessedItems>>
│ └─ validate query generation and write into Mutex<Option<ProcessedItems>>
└─ stopped.store(true)
```
Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before processing. `MatcherControl::kill()` sets `interrupt = true`; `MatcherControl::drop()` also calls `kill()`.
Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before processing. `MatcherControl::kill()` sets `interrupt = true`; `MatcherControl::drop()` also calls `kill()`. Each forced restart increments a shared query generation. Published `ProcessedItems` carry that generation; both publication and consumption reject stale generations, so a cancelled matcher cannot replace newer results.
### Ranking & Sorting
`MatchedItem` implements `Ord` through a lazy sort key computed by `Rank::sort_key(criteria)`. Items can also be disabled: `SkimItem::disabled()` returns `false` by default, and `--disable-pattern <regex>` marks matching items as disabled in the default item type. Disabled items stay visible but are dimmed by `ItemRenderer` and cannot be selected.
`Rank` fields:
| Field | Description |
|---|---|
| --- | --- |
| `score` | Raw match score (higher = better) |
| `begin` | First matched character index |
| `end` | Last matched character index |
@ -572,10 +609,13 @@ Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before
`MergeStrategy` (in `item_list.rs`):
| Strategy | When used |
|---|---|
| --- | --- |
| `Replace` | Fresh match pass (query changed, full re-sort) |
| `SortedMerge` | New items arrived during a running match (merge-insert) |
| `Append` | `--no-sort` mode |
| `Append` | Incremental `--no-sort` mode |
| `Prepend` | Incremental `--tac --no-sort` mode, preserving global reverse-input order |
`Rank::index` always records the item's stable ordinal in the original input stream. `RankBuilder` makes both configured index criteria and the implicit final index tiebreak descending under `--tac`, so normal `SortedMerge` remains valid across independently reversed batches.
---
@ -584,22 +624,26 @@ Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before
### Backend & Terminal Setup
`Tui<B>` (in `src/tui/backend.rs`) wraps `ratatui::Terminal<B>` and owns:
- A `tokio::sync::mpsc` channel (`event_tx` / `event_rx`) of capacity 1 M for events.
- A `JoinHandle` for a background Tokio task that reads `crossterm::event::EventStream` and sends `Event` values.
- A `CancellationToken` to stop the background task.
- A `is_fullscreen` flag that determines the `ratatui::Viewport`.
- An `is_fullscreen` flag that determines the `ratatui::Viewport`.
- The fixed viewport `Rect` for inline mode, which can move when the terminal scrolls.
**Viewport selection** (`Tui::new_with_height_and_backend()`):
- `Size::Percent(100)``Viewport::Fullscreen` (enters alternate screen).
- `Size::Fixed(lines)``Viewport::Fixed(Rect)` with that many rows.
- `Size::Percent(p)` → fixed viewport with `terminal_height * p / 100` rows.
- `Size::Neg(lines)` → fixed viewport with `terminal_height - lines` rows, saturating at zero.
Any fixed viewport is anchored at the current cursor position; the terminal is scrolled if needed to make room.
Any fixed viewport is anchored at the current cursor position; the terminal is scrolled if needed to make room. After construction, `Tui::min_height()` can increase an inline viewport to `--min-height`. It limits the height to the terminal height and scrolls the terminal before it moves and resizes the viewport when there are too few rows below it.
The default backend is `CrosstermBackend<BufWriter<Stderr>>`. Skim always draws to **stderr** so stdout remains clean for piped output.
**Terminal lifecycle:**
```
Tui::enter()
├─ enable_raw_mode()
@ -617,6 +661,10 @@ Tui::exit()
A panic hook is installed once (`PANIC_HOOK_SET: Once`) to ensure `cleanup_terminal()` runs even on panics.
**Foreground `execute` actions:** the `execute(cmd)` action must hand the terminal to a child process (e.g. an editor or an interactive TUI like `ncdu`). `handle_action` only expands the command and returns `Event::RunExecute(cmd)`; the actual run happens in `App::handle_event` (which owns the `Tui`) via the `run_foreground(tui, cmd)` helper in `src/tui/app.rs`. `run_foreground` calls `Tui::stop_and_join()` — which cancels the event-pump task **and blocks until it has dropped its `EventStream`** — so skim's reader stops consuming terminal input before the child starts; otherwise the two race for keystrokes and interactive children appear to freeze. It then leaves the alternate screen / raw mode, spawns the child with its **own** stdin opened from the controlling terminal (`/dev/tty`, or `CONIN$` on Windows; falls back to inheriting skim's stdin), waits for it, restores terminal modes, and calls `Tui::start()` to respawn the reader. Giving the child its own tty stdin is what lets `execute` work when skim's own stdin is a pipe (`find | sk`). `execute-silent(cmd)` needs no terminal and is still spawned directly inside `handle_action` with stdout/stderr sent to `/dev/null`.
Two subtleties make the resume correct. First, `Tui::start` installs a **fresh** `CancellationToken` on every call: a token stays cancelled once cancelled, so reusing the one `stop_and_join` cancelled would make the respawned reader observe the cancellation immediately and exit without reading input. Second, the post-execute repaint uses `Tui::force_full_redraw` (which resets both of ratatui's diff buffers) followed by `Event::Render`, rather than `Event::Redraw`/`tui.clear()`: ratatui's `Terminal::clear` first queries the cursor position, and crossterm writes that query (`ESC [ 6 n`) to **stdout**. Since skim renders to stderr and its stdout is routinely redirected (`sk > file`, `find | sk | …`), that query would reach no terminal, get no reply, and stall the UI for seconds before erroring out. `force_full_redraw` performs no cursor query and works for both fullscreen and inline viewports.
Image preview protocol detection is owned by `Skim::enter()`, not `Tui::enter_terminal()`: `--image=detect` temporarily ensures an alternate screen is active, queries `ratatui_image::picker::Picker::from_query_stdio()`, falls back to `Picker::halfblocks()` on failure, then stores the picker in both `SkimOptions.image_picker` and the `Preview` widget. `--image=halfblocks` skips detection and installs `Picker::halfblocks()` directly.
### Event Loop
@ -647,7 +695,7 @@ Frame rate is capped at 120 fps (`FRAME_TIME_MS = 1000/120`). `App::handle_event
`App` (in `src/tui/app.rs`) is the single mutable application state. It contains:
| Field | Type | Role |
|---|---|---|
| --- | --- | --- |
| `item_pool` | `Arc<ItemPool>` | Shared with reader; accumulates raw items |
| `matcher` | `Matcher` | Engine factory + case + rank config |
| `matcher_control` | `MatcherControl` | Handle to stop/query current match pass |
@ -662,6 +710,7 @@ Frame rate is capped at 120 fps (`FRAME_TIME_MS = 1000/120`). `App::handle_event
| `needs_render` | `Arc<AtomicBool>` | Signal from matcher → event loop |
| `yank_register` | `String` | Cut/yank buffer |
| `query_history` / `cmd_history` | `Vec<String>` | History for ↑/↓ navigation |
| `last_left_click` | `Option<Instant>` | Detect two left clicks within the 500 ms `double-click` window |
**`App::handle_event()`** dispatches on `Event`:
@ -669,11 +718,12 @@ Frame rate is capped at 120 fps (`FRAME_TIME_MS = 1000/120`). `App::handle_event
Event::Render → tui.draw(|f| f.render_widget(&mut self, f.area()))
Event::Heartbeat → update_spinner(); check pending_matcher_restart; throttled render
Event::RunPreview → run_preview(tui)
Event::RunExecute(cmd) → run_foreground(tui, cmd); force_full_redraw(); send Render
Event::Key(k) → handle_key(k) → [Action…] → tui.event_tx.send(Event::Action)
Event::Action(a) → handle_action(a) → [Event…] → tui.event_tx.send(…)
Event::Paste(t) → input.insert_str(cleaned); on_query_changed()
Event::Resize(…) → app.resize(); run_preview()
Event::Mouse(…) → handle_mouse()
Event::Mouse(…) → handle_mouse() → normal handling + optional `double-click` key event
Event::PreviewReady → apply preview offset; needs_render()
Event::AppendItems → item_pool.append(); restart_matcher(false)
Event::ClearItems → item_pool.clear(); restart_matcher(true)
@ -682,6 +732,7 @@ Event::Reload(_) → (handled by Skim::tick, not here)
```
**`App::restart_matcher(force)`:**
```
restart_matcher(force)
├─ if !force && matcher not stopped → skip (debounce)
@ -689,6 +740,7 @@ restart_matcher(force)
├─ kill existing matcher_control
├─ determine MergeStrategy
│ ├─ Replace → if query changed / force
│ ├─ Prepend → incremental --tac --no-sort
│ └─ SortedMerge / Append otherwise
└─ matcher.run(query, pool, thread_pool, processed_items, strategy, no_sort, needs_render)
→ returns new MatcherControl
@ -716,9 +768,13 @@ pub struct SkimRender {
### Layout Engine
`LayoutTemplate` pre-computes area splits from `SkimOptions` once and stores constraint trees. `apply(area: Rect) → AppLayout` is then a cheap, allocation-free split.
`LayoutTemplate` pre-computes area splits from `SkimOptions` once and stores constraint trees.
`apply(area: Rect) → AppLayout` is then a cheap, allocation-free split. Bordered
widgets share adjacent border rows and columns by default;
`SkimOptions::border_no_collapse` keeps their areas separate.
`AppLayout` has four optional areas:
```rust
pub struct AppLayout {
pub list_area: Rect,
@ -733,12 +789,13 @@ Layout is rebuilt on `Event::Resize`, when the header height changes (multiline
**Layout orientations** (`TuiLayout`):
| Mode | Description |
|---|---|
| --- | --- |
| `Default` | Input at bottom, list above, header above list (bottom-to-top reading) |
| `Reverse` | Input at top, list below (top-to-bottom reading) |
| `ReverseList` | List at top, input at bottom |
**Preview placement** is parsed from `--preview-window`:
- Direction: `left` / `right` / `up` / `down`
- Size: `50%` (default), fixed cells, or negative cells (`-N`, meaning the non-preview side keeps `N` cells)
- Modifiers: `hidden`, `wrap`, `pty`, `+offset`
@ -750,11 +807,13 @@ Layout is rebuilt on `Event::Resize`, when the header height changes (multiline
### Input Widget
`Input` (`src/tui/input.rs`) maintains:
- `value: String` — the query text (primary mode)
- `alternate_value: String` — the command text (interactive mode)
- `cursor_pos: usize` — character-level cursor position
Text operations (used by `handle_action`):
- `insert(char)` / `insert_str(&str)` — insert at cursor
- `delete(n)` — delete n characters forward
- `delete_backward_word()` / `delete_to_beginning()` / `delete_forward_word()`
@ -768,19 +827,24 @@ The `StatusInfo` struct rendered inside the input line shows:
### ItemList Widget
`ItemList` (`src/tui/item_list.rs`) maintains:
- `items: Vec<MatchedItem>` — the currently displayed matched items
- `processed_items: Arc<SpinLock<Option<ProcessedItems>>>` — shared with matcher
- `processed_items: Arc<Mutex<Option<ProcessedItems>>>` — shared with matcher
- `matcher_generation: Arc<AtomicUsize>` — identifies the active query generation
- `selection: Vec<usize>` — indices of multi-selected items
- `current: usize` — focused item index (0 = bottom in default layout)
- `offset: usize` — scroll offset (number of items scrolled)
- `manual_hscroll: i16` — user-driven horizontal scroll
On each render, `ItemList::render()` checks `processed_items` and swaps them in atomically via the `SpinLock`. Depending on `MergeStrategy`:
On each render, `ItemList::render()` checks `processed_items`, rejects results from an old query generation, and swaps current results in through the mutex. Depending on `MergeStrategy`:
- `Replace`: replaces `items` entirely.
- `SortedMerge`: performs an O(n+m) merge preserving order.
- `Append`: extends `items`.
- `Prepend`: places a reversed incremental `--tac` batch before existing items; the cursor follows the head unless the user moved away from it.
**Selection state management:**
- `toggle_at(idx)` / `toggle()` / `toggle_all()` / `select_all()` / `clear_selection()`
- `scroll_by_rows(n)` — scroll by terminal rows (accounting for multiline items)
- `scroll_by(n)` — scroll by item count
@ -806,13 +870,14 @@ Pre-selection is applied when items first appear: `DefaultSkimSelector::should_s
`Preview` (`src/tui/preview.rs`) renders a side/top/bottom pane showing expanded information about the focused item. Its stored content is one of three variants:
**Plain text mode** (no `pty`): spawns `sh -c <cmd>` on Unix or `cmd /c <cmd>` on Windows. On Windows, `Command::raw_arg` is used so `cmd.exe` receives shell metacharacters exactly as written. The child captures stdout (capped at `PREVIEW_MAX_BYTES`), parses it with `ansi_to_tui::IntoText`, stores as `PreviewContent::Text`, and sends `Event::PreviewReady`.
**Plain text mode** (no `pty`): spawns `sh -c <cmd>` on Unix or `cmd /c <cmd>` on Windows. On Windows, `Command::raw_arg` is used so `cmd.exe` receives shell metacharacters exactly as written. The worker drains stdout and stderr concurrently, but retains at most `PREVIEW_MAX_BYTES` from each stream. Retained stdout is parsed with `ansi_to_tui::IntoText` and published while the command runs. Cancellation terminates the child process group (the process tree on Windows) and invalidates its output writer, so an old reader cannot replace content from a newer preview. At exit, successful stdout or failed stderr is stored as `PreviewContent::Text` and followed by `Event::PreviewReady`.
**PTY mode** (`--preview-window pty`): creates a real pseudo-terminal pair via `portable_pty`. The child process sees a properly sized terminal (via `ROWS`/`COLUMNS` env and PTY dimensions). Output is parsed by a `vt100::Parser` with a scrollback buffer, stored as `PreviewContent::Terminal(Arc<RwLock<vt100::Parser>>)`. This enables interactive preview programs (e.g. `bat`, `delta`).
**Image mode** (`--image[=detect|halfblocks]`, requires the default `image` feature): treats the expanded preview command as an image path instead of executing it. A worker thread decodes the image with the `image` crate and stores `PreviewContent::Image { source, protocol, size }`. Rendering uses `ratatui_image`; `detect` builds an image protocol picker after entering the alternate screen, while `halfblocks` skips terminal capability detection and uses the portable half-block renderer. The protocol is rebuilt when the preview area changes so the image keeps its aspect ratio within the pane.
`Preview::spawn()`:
```
kill() ← kill any running preview
reset scroll_y / scroll_x
@ -830,16 +895,19 @@ else if pty mode:
→ Event::PreviewReady when EOF
else:
sh -c <cmd>
thread: wait for output → content.write() = PreviewContent::Text(…)
start shell in a dedicated process group with piped stdout + stderr
thread: drain both streams with bounded retention; stream active stdout; poll child status
→ cancellation invalidates the writer and kills the process group
→ content.write() = PreviewContent::Text(…)
→ Event::PreviewReady
```
Scroll state: `scroll_y`, `scroll_x` (in lines/columns). `page_up/down`, `scroll_up/down/left/right` modify these. `PreviewPosition` supports fixed, percentage, and negative offsets. When `PreviewReady` fires, an optional offset expression (from `--preview-window +expr`) is evaluated to auto-scroll to the matched line.
Scroll state: `scroll_y`, `scroll_x` (in lines/columns) and `total_lines` use `usize`; conversion to ratatui's `u16` coordinates saturates at render time. `page_up/down`, `scroll_up/down/left/right` modify these. `PreviewPosition` supports fixed, percentage, and negative offsets. When `PreviewReady` fires, an optional offset expression (from `--preview-window +expr`) is evaluated to auto-scroll to the matched line.
### Header Widget
`Header` (`src/tui/header.rs`) renders two kinds of content:
- **Static** (`--header <text>`): shown at the top or bottom depending on layout; expanded for tab characters once at init.
- **Dynamic** (`--header-lines N`): first N items from `ItemPool::reserved()` are treated as header lines instead of selectable items.
@ -855,8 +923,11 @@ Inline sep: " < " (when inline_info)
Right side: multi-select count when multi mode
```
`InfoDisplay` has four modes:
`InfoDisplay` has six modes:
- `Default` — separate line above the prompt
- `Left` — separate line above the prompt, with all info left-aligned
- `Right` — separate line above the prompt, with all info right-aligned
- `Inline` — inside the prompt line (after the query text)
- `InlineRight` — inside the prompt line, right-aligned
- `Hidden` — not shown
@ -869,7 +940,7 @@ Right side: multi-select count when multi mode
`parse_key(key_str)` (in `src/binds.rs`) converts strings like `"ctrl-a"`, `"alt-shift-f"`, `"f10"`, `"enter"` into `crossterm::event::KeyEvent { code, modifiers }`.
`parse_action(raw)` (in `src/tui/event.rs`) converts strings like `"down:2"`, `"execute(ls {})"`, `"if-query-empty:reload+up"` into `Action` variants.
`parse_action(raw)` (in `src/tui/actions.rs`, re-exported from `src/tui/event.rs`) converts strings like `"down:2"`, `"execute(ls {})"`, `"if-query-empty:reload+up"` into `Action` variants.
`parse_action_chain(chain)` splits on `+` (respecting `if-*{…+…}` syntax) into `Vec<Action>`.
@ -880,7 +951,7 @@ Right side: multi-select count when multi mode
Notable defaults:
| Key | Action |
|---|---|
| --- | --- |
| `Enter` | `Accept(None)` |
| `Esc` | `Abort` |
| `Ctrl-C` / `Ctrl-D` / `Ctrl-G` | `Abort` |
@ -900,6 +971,87 @@ Notable defaults:
User bindings from `--bind key:action[+action]` are parsed at startup and merged via `KeyMap::add_keymaps()`.
### Mouse Bindings
| Bind | Default | Fired when |
| --- | --- | --- |
| `double-click` | `Accept(None)` | two left-button presses occur within 500 ms; the first press still performs normal item selection |
`App::handle_mouse` recognizes the gesture after normal click handling and routes
it through the keymap using the reserved `SkimEvent::DoubleClick` key code.
### 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
```
@ -912,10 +1064,13 @@ 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 (in `src/tui/actions.rs`) is the single source for the `Action` enum itself, each
variant's canonical bind name, its parser arm, and the `ACTION_CATALOG` documentation list the manpage's action
section is generated from — so the enum, `Action::name`, `parse_action` and the manpage cannot drift. Key action categories:
| Category | Actions |
|---|---|
| --- | --- |
| Navigation | `Up/Down(n)`, `HalfPageUp/Down`, `PageUp/Down`, `First/Last/Top` |
| Text editing | `AddChar`, `BackwardChar/DeleteChar/Word`, `ForwardChar/Word`, `KillLine`, `Yank`, `UnixLineDiscard/WordRubout` |
| Selection | `Toggle`, `ToggleAll`, `ToggleIn/Out`, `Select`, `SelectAll`, `DeselectAll`, `AppendAndSelect` |
@ -926,6 +1081,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 `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.
@ -937,7 +1093,7 @@ Event::Action(a) → handle_action(a) → Vec<Event>
The preview command string supports placeholder substitution via `App::expand_cmd()`:
| Placeholder | Expands to |
|---|---|
| --- | --- |
| `{}` | text of the focused item |
| `{q}` | current query string |
| `{n}` | index of the focused item |
@ -967,6 +1123,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)
@ -979,6 +1138,7 @@ Skim::output()
```
`SkimOutput` fields returned to caller:
```rust
pub struct SkimOutput {
pub final_event: Event, // Action::Accept or Action::Abort
@ -993,6 +1153,7 @@ pub struct SkimOutput {
```
The output phase is `SkimOutput::write_output(&mut out, &BinOptions)` (`src/output.rs`), called by the CLI binary with a buffered stdout. `BinOptions` (also in `src/output.rs`, built via `BinOptions::from_opts`) captures the output-related flags. Keeping the serialization independent of stdout lets it be unit-tested by passing a `Vec<u8>`. It writes, in order:
1. `query` if `--print-query`
2. `cmd` if `--print-cmd`
3. `header` if `--print-header`
@ -1045,7 +1206,7 @@ This enables scripted control of a running skim session.
`ColorTheme` (`src/theme.rs`) holds 13 named `ratatui::style::Style` values:
| Field | Covers |
|---|---|
| --- | --- |
| `normal` | Default item text |
| `matched` | Highlighted match characters |
| `current` | Focused item background |
@ -1065,6 +1226,7 @@ Built-in palettes: `none`, `bw`, `default16`, `dark256`, `molokai256`, `light256
Selected via `--color base_theme[,component:color[:modifier]]`. Individual component overrides use CSS-style RGB hex (`#RRGGBB`), ANSI 256-color indices, or named modifiers (`bold`, `italic`, `underline`, `dim`, `reverse`).
`BorderType` mirrors Ratatui's border styles but adds two internal no-border states:
- `None` is the default `--border=none`; widgets do not draw boxes, but preview separators may still be drawn between panes.
- `ForceOff` is set by `--no-border`; it disables all borders, including tmux/zellij popup borders.
@ -1075,6 +1237,7 @@ Passing `--border` without a value means `plain`. Passing a value accepts Ratatu
## History
Query and command histories are managed in `SkimOptions`:
- Loaded at startup via `SkimOptions::init_histories()` from files specified by `--history-file` / `--cmd-history-file`.
- Stored in `App::query_history` / `App::cmd_history`.
- Navigation with `Action::NextHistory` / `Action::PreviousHistory` uses `history_index: Option<usize>` and `saved_input: String` to restore the original input when returning to the live query.
@ -1093,6 +1256,7 @@ pub trait Selector {
```
Three modes (combinable):
- `first_n(N)` — selects the first N items by index
- `preset(iter)` — selects items whose `text()` is in a `HashSet`
- `regex(pattern)` — selects items matching a regex
@ -1115,21 +1279,23 @@ Main thread (Tokio runtime)
└─ Tokio task: Tui event pump (crossterm EventStream + tick timer)
ThreadPool (N = num_cpus OS threads, persistent)
├─ Matcher coordinator (1 slot per match run)
└─ Worker threads (N-1 slots per match run)
Matcher ThreadPool (persistent)
└─ Worker threads process atomic match chunks; one separate coordinator thread waits for completion
Reader threads (OS threads, per-invocation):
├─ collect_items thread: blocks on SkimItemReceiver (recv_timeout 5ms), calls ItemPool::append
Reader ThreadPool (persistent)
└─ Short chunk jobs parse items; an in-flight token limit bounds the work queue
Reader threads (OS threads, per invocation):
├─ collect_items thread: blocks on SkimItemReceiver (recv_timeout 1ms), calls ItemPool::append
├─ I/O reader thread: reads large byte chunks, splits lines, assigns sequence numbers
├─ Worker threads (N): parse lines, create DefaultSkimItem (ANSI strip + field transforms inline)
├─ Bounded dispatcher thread: submits chunk jobs only while an in-flight token is available
├─ Reorder thread: sequence-ordered output; drops tx_pipeline_done on EOF
└─ Killer thread (command inputs only): waits for rx_interrupt or rx_pipeline_done;
kills child process when either fires
└─ Killer thread: waits for rx_interrupt or rx_pipeline_done; kills a command child if present
Preview thread (OS thread, per preview spawn):
└─ reads PTY/child stdout or decodes image path → PreviewContent Arc<RwLock>
→ sends Event::PreviewReady
Preview threads (OS threads, per preview spawn):
├─ PTY reader, image decoder, or plain-child monitor
└─ Plain mode also has bounded stdout and stderr drain threads
→ writes PreviewContent Arc<RwLock> and sends Event::PreviewReady
IPC handler task (Tokio, per connection):
└─ reads RON actions → sends Event::Action to TUI channel
@ -1139,9 +1305,10 @@ Popup stdin relay thread (OS thread, only in --popup/--tmux mode):
```
**Synchronization primitives used:**
- `Arc<SpinLock<Option<ProcessedItems>>>` — matcher-to-ItemList result handoff
- `Arc<Mutex<Option<ProcessedItems>>>` — matcher-to-ItemList result handoff without CPU-spinning under merge contention
- `Arc<AtomicBool>``needs_render` (matcher → event loop), `stopped` / `interrupt` (MatcherControl)
- `Arc<AtomicUsize>``processed` / `matched` counters, reader `components_to_stop`
- `Arc<AtomicUsize>``processed` / `matched` counters, matcher query generation, reader `components_to_stop`
- `Arc<tokio::sync::Notify>``items_available` (ItemPool → Skim::tick wakeup)
- `Arc<std::sync::RwLock<PreviewContent>>` — preview thread → Preview widget
- `kanal::Sender/Receiver<Vec<Arc<dyn SkimItem>>>` — item batches through pipeline
@ -1150,55 +1317,6 @@ The global allocator is `mimalloc` (v3), chosen for its low-latency multi-thread
---
## Important Call Sites (Cross-Reference)
| 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:260` | Build all widgets from options |
| `App::run_preview` | `src/tui/app.rs:414` | Expand cmd, debounce, call Preview::spawn |
| `App::handle_event` | `src/tui/app.rs:536` | Dispatch all Event variants |
| `App::handle_action` | `src/tui/app.rs:687` | Dispatch all Action variants |
| `App::restart_matcher` | `src/tui/app.rs:1183` | Kill old match pass, start new one |
| `App::expand_cmd` | `src/tui/app.rs:1256` | Substitute `{}`, `{q}`, `{n}` etc. |
| `Widget::render (App)` | `src/tui/app.rs:128` | 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 |
| `ItemPool::take` | `src/item.rs:502` | Take un-matched items for matcher |
| `DefaultSkimItem::new` | `src/helper/item.rs:58` | ANSI strip, field transform, ranges, disable pattern |
| `SkimItemReader::parallel_bufread` | `src/helper/item_reader.rs:263` | Unified parallel pipeline (all inputs) |
| `spawn_io_reader` | `src/helper/item_reader.rs:354` | I/O reader thread: chunk reads + line splitting |
| `spawn_reorder_thread` | `src/helper/item_reader.rs:458` | Reorder thread: ordered output + pipeline-done signal |
| `Preview::spawn` | `src/tui/preview.rs:319` | Start image, PTY, or plain preview worker |
| `Tui::new_with_height_and_backend` | `src/tui/backend.rs:77` | Terminal init + viewport sizing |
| `Tui::enter` | `src/tui/backend.rs:126` | Enable raw mode + terminal setup |
| `Tui::start` | `src/tui/backend.rs:192` | Spawn crossterm EventStream task |
| `popup::run_with` | `src/popup/mod.rs:86` | Delegate to multiplexer popup + parse output |
| `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>` |
| `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 |
| `FuzzyEngine::match_item` | `src/engine/fuzzy.rs:175` | Fuzzy match a single item |
| `LayoutTemplate::from_options` | `src/tui/layout.rs:76` | Compute widget constraint tree |
| `LayoutTemplate::apply` | `src/tui/layout.rs:165` | Split Rect into AppLayout |
| `ItemRenderer::render_item` | `src/tui/item_renderer.rs:84` | Full per-item render pipeline |
| `ColorTheme::init_from_options` | `src/theme.rs:56` | Parse `--color` spec |
---
## Public Library API

View file

@ -5,6 +5,224 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [5.7.0] - 2026-09-08
### 🐛 Bug Fixes
- Stream preview command instead of waiting for completion (#1176)
### 📚 Documentation
- Update benchmarks
### 🤖 CI
- Lock everything to make sure dependencies match (#1170)
### ⚙️ Miscellaneous Tasks
- Nitpicks after multithreading review (#1172)
## [5.6.7] - 2026-09-04
### 🐛 Bug Fixes
- Make min-height work again (#1168)
### ⚙️ Miscellaneous Tasks
- Update dependencies after tinyvec breakage
## [5.6.6] - 2026-08-22
### 🐛 Bug Fixes
- *(matcher)* An inverse query only checks the first --nth field (#1159)
- Do not feed items through a fifo in zsh completions (#1164)
- *(filter)* --filter hangs when the query is below --min-query-length (#1158)
- Path_name_offset returns bytes while Rank::begin is a char index (#1160)
### 🤖 CI
- Automatically update PRs on pushes to master
- Fix auto update branch
### ⚙️ Miscellaneous Tasks
- Clippy & fmt after cargo update
## [5.6.5] - 2026-08-16
### 🐛 Bug Fixes
- Add allow_hyphen_values to --with-nth (#1156)
- *(field)* An out-of-range field index silently matches field 1 (#1155)
### New Contributors
* @VXNCXNX made their first contribution in [#1155](https://github.com/skim-rs/skim/pull/1155)
## [5.6.4] - 2026-08-10
### 🐛 Bug Fixes
- *(image)* Detect picker from tty to support protocol detection with piped input
- Platform-dependant timevals
- Different suseconds and time per platform
- Avoid truncation by casting up instead of down
## [5.6.3] - 2026-08-07
### 🐛 Bug Fixes
- Advance the input cursor by byte length in insert_str (#1151)
### New Contributors
* @vimsucks made their first contribution in [#1151](https://github.com/skim-rs/skim/pull/1151)
## [5.6.2] - 2026-08-07
### 🐛 Bug Fixes
- Assymetry in char_equal causing panic with some unicode characters
- Reorder batches in `--tac` mode (#1150)
- Match double-width roman characters (closes #1149)
### 📚 Documentation
- Preview command runs with sh/cmd, not SHELL
## [5.6.1] - 2026-07-27
### 🤖 CI
- Do not fail release on public API breakage
## [5.6.0] - 2026-07-26
### 🚀 Features
- Add the `set-cmd` action to change interactive mode command from bindings (#1142)
### 🐛 Bug Fixes
- *(examples)* Gate malloc_trim to gcc in the multiple_runs example
### 📚 Documentation
- Readd default keybindings to the manpage as an auto-generated separate subsection
### 🧪 Testing
- Replace tmux e2e harness with cross-platform Zellij harness (#1139)
### 🤖 CI
- Make coverage non-blocking to avoid issues with nightly rust
## [5.5.0] - 2026-07-23
### 🚀 Features
- Add double-click as a bindable trigger (#1134)
### 🐛 Bug Fixes
- Do not panic on push/pop keyboard enhancement flags failure
## [5.4.0] - 2026-07-21
### 🚀 Features
- Allow binding actions & more events (#1125)
### 🐛 Bug Fixes
- Allow execute actions to run interactive commands (#1132)
### 📚 Documentation
- Update coverage url
### 🤖 CI
- Publish apt repo
- Actually push apt
## [5.3.2] - 2026-07-20
### 🤖 CI
- Publish .deb, .rpm and winget packages on release (#1129)
## [5.3.1] - 2026-07-19
### 🐛 Bug Fixes
- Ignore KeyEventState (#1127)
## [5.3.0] - 2026-07-19
### 🚀 Features
- Add 'left' and `right` info display modes (#1120)
- Add `--hide-nth` to hide fields from display but keep them searchable (#1122)
- Add bind and unbind actions (#1121)
### 🐛 Bug Fixes
- Kitty keyboard protocol
### 🤖 CI
- Add public-api check (#1124)
## [5.2.0] - 2026-07-17
### 🚀 Features
- Collapsed borders by default and `--border-no-collapse` flag (#1117)
- Reduce binary size by removing uncommon image formats and color_eyre (#1118)
## [5.1.4] - 2026-07-17
### 🐛 Bug Fixes
- Preserve input order with --no-sort (#1115)
### New Contributors
* @juneboku made their first contribution in [#1115](https://github.com/skim-rs/skim/pull/1115)
## [5.1.3] - 2026-07-16
### 🐛 Bug Fixes
- Manually resize the Tui when not in fullscreen mode
### 🤖 CI
- Fix fixed viewport resize test in non-interactive env (#1114)
### ⚙️ Miscellaneous Tasks
- Fix CHANGELOG duplication
## [5.1.2] - 2026-07-16
### 🤖 CI
- Add Release PR workflow
- Update app-id to client-id
- Add changelog as job output
## [5.1.1] - 2026-07-16
### 🐛 Bug Fixes
- Handle deprecated --expect flag in sk 4.x vim plugin (#1057)
### New Contributors
* @antiagainst made their first contribution in [#1057](https://github.com/skim-rs/skim/pull/1057)
## [5.1.0] - 2026-07-09
### 🚀 Features

1254
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,10 @@
[package]
name = "skim"
version = "5.1.0"
version = "5.7.0"
authors = ["Loric ANDRE", "Zhang Jinzhou <lotabout@gmail.com>"]
description = "Fuzzy Finder in rust!"
documentation = "https://docs.rs/skim"
homepage = "https://github.com/skim-rs/skim"
repository = "https://github.com/skim-rs/skim"
readme = "README.md"
keywords = ["fuzzy", "menu", "util"]
license = "MIT"
edition = "2024"
@ -51,6 +49,13 @@ inherits = "release"
debug = true
strip = false
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "unpacked"
[profile.dev.package."*"]
debug = false
[lints.rust]
missing_docs = "warn"
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage, coverage_nightly)'] }
@ -67,31 +72,31 @@ clap = { version = "4.6.1" , optional = true, features = ["cargo", "derive", "un
clap_complete = { version = "4.6.5", optional = true }
clap_complete_nushell = { version = "4.6.0", optional = true }
clap_mangen = { version = "0.3.0", optional = true }
color-eyre = "0.6.5"
eyre = "0.6.12"
# Crossterm's version is selected by ratatui
crossterm = { version = ">=0.0.0", features = ["event-stream", "use-dev-tty", "libc"] }
derive_builder = "0.20.2"
derive_more = { version = "2.1.1", features = ["debug", "eq"] }
env_logger = { version = "0.11.10", optional = true, features = ["humantime"] }
frizbee = { version = "=0.10.0", optional = true }
frizbee = { version = "=0.13.0", optional = true }
futures = "0.3.32"
gungraun = { version = "0.19.1", optional = true }
image = { version = "0.25.10", optional = true }
image = { version = "0.25.10", optional = true, default-features = false, features = ["png", "jpeg", "gif", "webp"] }
indexmap = "2.13.1"
interprocess = { version = "2.4.2", features = ["tokio"], optional = true }
kanal = "0.1.1"
log = "0.4.31"
memchr = "2.8.1"
mimalloc = { version = "0.1.48", features = ["v3"] }
nix = { version = "0.31.3", features = ["fs", "poll"] }
nix = { version = "0.31.3", features = ["fs", "poll", "signal"] }
portable-pty = "0.9.0"
ratatui = "0.30.0"
ratatui-image = { version = "11.0.4", features = ["image-defaults", "crossterm"], default-features = false, optional = true }
ratatui-image = { version = "11.0.4", features = ["crossterm"], default-features = false, optional = true }
regex = "1.12.3"
roff = "1.1.1"
ron = { version = "0.12.1", optional = true }
serde = { version = "1.0.228", features = ["derive"], optional = true }
shell-quote = "0.7.2"
shell-quote = "0.8.0"
shlex = { version = "2.0.1", optional = true }
tempfile = "3.27.0"
thiserror = "2.0.18"
@ -108,7 +113,7 @@ criterion = { version = "0.8.2", features = ["async_tokio"] }
gnuplot = "0.0.46"
insta = "1.47"
rand = "0.10.0"
serde_json = { version = "=1.0.150" }
serde_json = { version = "=1.0.151" }
serial_test = "=3.5.0"
[[bench]]
@ -142,3 +147,33 @@ upgrade-guid = "6DDAED06-EBE2-41C4-94F5-CB03F2A4B92E"
path-guid = "05D1A327-19E0-4C82-B5EC-6B28E40C691B"
license = false
eula = false
# Debian package (.deb) built via `cargo deb`.
# Ships the `sk` executable, the man pages and the shell completions.
[package.metadata.deb]
maintainer = "Loric ANDRE, Zhang Jinzhou <lotabout@gmail.com>"
copyright = "The skim developers"
section = "utils"
priority = "optional"
extended-description = "skim is a general purpose fuzzy finder written in Rust, usable as a command line tool and as a library."
assets = [
["target/release/sk", "usr/bin/", "755"],
["man/man1/sk.1", "usr/share/man/man1/", "644"],
["shell/completion.bash", "usr/share/bash-completion/completions/sk", "644"],
["shell/completion.zsh", "usr/share/zsh/vendor-completions/_sk", "644"],
["shell/completion.fish", "usr/share/fish/vendor_completions.d/sk.fish", "644"],
["README.md", "usr/share/doc/skim/README.md", "644"],
]
# RPM package (.rpm) built via `cargo generate-rpm`.
# Ships the `sk` executable, the man pages and the shell completions.
[package.metadata.generate-rpm]
assets = [
{ source = "target/release/sk", dest = "/usr/bin/sk", mode = "755" },
{ source = "man/man1/sk.1", dest = "/usr/share/man/man1/sk.1", mode = "644", doc = true },
{ source = "shell/completion.bash", dest = "/usr/share/bash-completion/completions/sk", mode = "644" },
{ source = "shell/completion.zsh", dest = "/usr/share/zsh/site-functions/_sk", mode = "644" },
{ source = "shell/completion.fish", dest = "/usr/share/fish/vendor_completions.d/sk.fish", mode = "644" },
{ source = "README.md", dest = "/usr/share/doc/skim/README.md", mode = "644", doc = true },
{ source = "LICENSE", dest = "/usr/share/doc/skim/LICENSE", mode = "644", doc = true },
]

View file

@ -5,8 +5,8 @@
<a href="https://github.com/skim-rs/skim/actions?query=workflow%3A%22Build+%26+Test%22+event%3Apush">
<img src="https://github.com/skim-rs/skim/actions/workflows/test.yml/badge.svg?event=push" alt="Build & Test" />
</a>
<a href="https://skim-rs.github.io/skim" >
<img src="https://skim-rs.github.io/skim/coverage.svg" alt="coverage badge" />
<a href="https://skim-rs.github.io/skim/coverage" >
<img src="https://skim-rs.github.io/skim/coverage/coverage.svg" alt="coverage badge" />
</a>
<a href="https://repology.org/project/skim-fuzzy-finder/versions">
<img src="https://repology.org/badge/tiny-repos/skim-fuzzy-finder.svg" alt="Packaging status" />
@ -81,28 +81,72 @@ The skim project contains several components:
## Package Managers
| OS | Package Manager | Command |
| -------------- | ----------------- | ---------------------------- |
| macOS | Homebrew | `brew install sk` |
| macOS | MacPorts | `sudo port install skim` |
| Alpine | apk | `apk add skim` |
| Arch | pacman | `pacman -S skim` |
| Fedora | COPR | see below |
| Gentoo | Portage | `emerge --ask app-misc/skim` |
| Guix | guix | `guix install skim` |
| Void | XBPS | `xbps-install -S skim` |
| OS | Package Manager | Command |
| -------------- | --------------- | ---------------------------- |
| macOS | Homebrew | `brew install sk` |
| macOS | MacPorts | `sudo port install skim` |
| Alpine | apk | `apk add skim` |
| Arch | pacman | `pacman -S skim` |
| Fedora | COPR | see below |
| Gentoo | Portage | `emerge --ask app-misc/skim` |
| Guix | guix | `guix install skim` |
| Void | XBPS | `xbps-install -S skim` |
| Windows | winget | `winget install skim` |
| Windows | Scoop | `scoop install skim` |
| Debian/Ubuntu | apt | see below |
| Fedora/RHEL | dnf | see below |
<a href="https://repology.org/project/skim-fuzzy-finder/versions">
<img src="https://repology.org/badge/vertical-allrepos/skim-fuzzy-finder.svg?columns=4" alt="Packaging status">
</a>
### Fedora
Up to date Fedora packages are provided via an unofficial community-maintained COPR repository.
### Debian/Ubuntu
A custom APT repository is available and updated automatically during each release:
1. Import the signing key
With wget:
```sh
sudo mkdir -p /etc/apt/keyrings
sudo wget -O /etc/apt/keyrings/skim.asc https://skim-rs.github.io/skim/apt/skim-archive-keyring.asc
```
Or with cURL:
```sh
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://skim-rs.github.io/skim/apt/skim-archive-keyring.asc | sudo tee /etc/apt/keyrings/skim.asc > /dev/null
```
2. Add the repository
```sh
echo 'deb [signed-by=/etc/apt/keyrings/skim.asc] https://skim-rs.github.io/skim/apt ./' | sudo tee /etc/apt/sources.list.d/skim.list
sudo apt-get update
```
3. Install
```sh
sudo apt-get install skim
```
Alternatively, `.deb` packages are attached directly to every [release](https://github.com/skim-rs/skim/releases/latest).
Download the one matching your architecture and run `sudo dpkg -i skim_*_amd64.deb`
### Fedora/RHEL
Up-to-date Fedora/RHEL packages are provided via an unofficial community-maintained COPR repository.
```bash
sudo dnf copr enable sisyphus1813/skim
sudo dnf install skim
```
Alternatively, `.rpm` packages are attached directly to every [release](https://github.com/skim-rs/skim/releases/latest).
Download it and run `sudo rpm -i skim-*.x86_64.rpm`
## Manually

BIN
bench.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 181 KiB

View file

@ -1462,8 +1462,8 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
let y_hi = mx * 2.0;
let axes = fg.axes2d();
axes.set_title("Total Time", lbl)
.set_x_label("Items", lbl)
.set_y_label("Time (s)", lbl)
.set_x_label("Items, log", lbl)
.set_y_label("Time (s, log)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
.set_y_log(Some(10.0))
@ -1487,7 +1487,7 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
let y_hi = (mx * 1.25).max(100.0);
let axes = fg.axes2d();
axes.set_title("Peak CPU", lbl)
.set_x_label("Items", lbl)
.set_x_label("Items, log", lbl)
.set_y_label("CPU (%)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
@ -1506,16 +1506,18 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
// ── Panel 2: Peak Memory — log x, linear y ───────────────────────────────
{
let (_, mx) = y_extent(&mem_bands);
let (mn, mx) = y_extent(&mem_bands);
let y_lo = (mn * 0.5).max(1e-9);
let y_hi = (mx * 1.25).max(1.0);
let axes = fg.axes2d();
axes.set_title("Peak Memory", lbl)
.set_x_label("Items", lbl)
.set_y_label("Memory (MB)", lbl)
.set_x_label("Items, log", lbl)
.set_y_label("Memory (MB, log)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
.set_y_log(Some(10.0))
.set_x_range(Fix(x_lo), Fix(x_hi))
.set_y_range(Fix(0.0), Fix(y_hi))
.set_y_range(Fix(y_lo), Fix(y_hi))
.set_x_grid(true)
.set_y_grid(true)
.set_legend(
@ -1533,7 +1535,7 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
let y_hi = (mx * 1.25).max(0.01);
let axes = fg.axes2d();
axes.set_title("Startup Time", lbl)
.set_x_label("Items", lbl)
.set_x_label("Items, log", lbl)
.set_y_label("Time (s)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))

View file

@ -1,7 +1,7 @@
#![allow(missing_docs, clippy::pedantic)]
use color_eyre::eyre::{Ok, Result};
use criterion::{Criterion, criterion_group, criterion_main};
use eyre::{Ok, Result};
use skim::prelude::*;

View file

@ -91,15 +91,16 @@ commit_parsers = [
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^ci", group = "<!-- 7 -->🤖 CI" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
{ message = "^chore", group = "<!-- 8 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 9 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 10 -->◀️ Revert" },
{ message = "^release", skip = true },
{ message = ".*", group = "<!-- 10 -->💼 Other" },
{ message = ".*", group = "<!-- 11 -->💼 Other" },
]
# Exclude commits that are not matched by any commit parser.
filter_commits = false

View file

@ -4,7 +4,7 @@ members = ["cargo:."]
# Config for 'dist'
[dist]
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
cargo-dist-version = "0.30.4"
cargo-dist-version = "0.32.0"
# CI backends to support
ci = "github"
# The installers to generate for each app
@ -19,13 +19,17 @@ install-updater = false
include = ["./man/", "./shell/"]
# Plan jobs to run in CI
plan-jobs = ["./test"]
# Global artifacts jobs to run in CI
global-artifacts-jobs = ["./package"]
# Publish jobs to run in CI
publish-jobs = ["./publish"]
publish-jobs = ["./publish", "./winget", "./apt"]
# Whether to publish prereleases to package managers
publish-prereleases = true
[dist.github-custom-job-permissions.test]
code-quality = "write"
contents = "read"
pages = "write"
contents = "write"
id-token = "write"
[dist.github-custom-job-permissions.apt]
contents = "write"
id-token = "write"

View file

@ -3,7 +3,7 @@
use skim::prelude::*;
use skim::tui::statusline::InfoDisplay;
fn main() -> color_eyre::Result<()> {
fn main() -> eyre::Result<()> {
let opts = SkimOptionsBuilder::default()
.multi(true)
.reverse(true)

View file

@ -1,7 +1,7 @@
//! Demonstrates fine-grained control over skim lifecycle events.
extern crate skim;
use color_eyre::Result;
use eyre::Result;
use skim::prelude::*;
#[tokio::main(flavor = "current_thread")]

View file

@ -6,7 +6,7 @@
use skim::options::ImageProtocol;
use skim::prelude::*;
fn main() -> color_eyre::Result<()> {
fn main() -> eyre::Result<()> {
env_logger::init();
let options = SkimOptionsBuilder::default()

View file

@ -13,7 +13,7 @@ fn main() {
.build()
.unwrap();
let res = Skim::run_with(opts, None).unwrap();
#[cfg(target_os = "linux")]
#[cfg(all(target_os = "linux", target_env = "gnu"))]
unsafe {
nix::libc::malloc_trim(0);
}

View file

@ -3,7 +3,7 @@
use skim::prelude::*;
#[tokio::main]
async fn main() -> color_eyre::eyre::Result<()> {
async fn main() -> eyre::Result<()> {
let opts = SkimOptionsBuilder::default().cmd("cat bench_data.txt").build()?;
println!("START");
@ -19,5 +19,5 @@ async fn main() -> color_eyre::eyre::Result<()> {
}
}
println!("DONE: {:?}", skim.output());
color_eyre::eyre::Ok(())
eyre::Ok(())
}

View file

@ -45,6 +45,8 @@
cargo-xwin
gnuplot
llvm
cargo-bloat
cargo-public-api
];
gungraun = with pkgs; [
valgrind

153
fuzz/Cargo.lock generated
View file

@ -2,21 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -96,21 +81,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link",
]
[[package]]
name = "base64"
version = "0.22.1"
@ -227,33 +197,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "color-eyre"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
dependencies = [
"backtrace",
"color-spantrace",
"eyre",
"indenter",
"once_cell",
"owo-colors",
"tracing-error",
]
[[package]]
name = "color-spantrace"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
dependencies = [
"once_cell",
"owo-colors",
"tracing-core",
"tracing-error",
]
[[package]]
name = "compact_str"
version = "0.9.1"
@ -738,12 +681,6 @@ dependencies = [
"r-efi 6.0.0",
]
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "hashbrown"
version = "0.16.1"
@ -1014,15 +951,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
]
[[package]]
name = "mio"
version = "1.2.1"
@ -1126,15 +1054,6 @@ dependencies = [
"libc",
]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@ -1150,12 +1069,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "owo-colors"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "palette"
version = "0.7.6"
@ -1526,12 +1439,6 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@ -1630,15 +1537,6 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shared_library"
version = "0.1.9"
@ -1715,14 +1613,14 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "skim"
version = "5.0.0"
version = "5.6.3"
dependencies = [
"ansi-to-tui",
"assert_enum_variants",
"color-eyre",
"crossterm",
"derive_builder",
"derive_more",
"eyre",
"futures",
"indexmap",
"kanal",
@ -2045,47 +1943,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-error"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db"
dependencies = [
"tracing",
"tracing-subscriber",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"sharded-slab",
"thread_local",
"tracing-core",
]
[[package]]
name = "tui-term"
version = "0.3.4"
@ -2180,12 +2037,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"

View file

@ -29,7 +29,8 @@ auto-release:
test target="":
cargo test --doc
cargo nextest run {{ target }}
tmux kill-session -t skim_e2e
# Each e2e test creates and tears down its own Zellij session, so there is
# no shared multiplexer session to clean up here.
bench-plot bins="./target/release/sk sk fzf":
#!/usr/bin/env bash

View file

@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH sk 1 "sk 5.1.0"
.TH sk 1 "sk 5.7.0"
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH NAME
@ -8,7 +8,7 @@ sk \- Fuzzy Finder in rust!
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH SYNOPSIS
\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-\-typos\fR] [\fB\-\-no\-typos\fR] [\fB\-\-normalize\fR] [\fB\-\-split\-match\fR] [\fB\-\-last\-match\fR] [\fB\-\-scheme\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-highlight\-line\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-cycle\fR] [\fB\-\-disabled\fR] [\fB\-\-disable\-pattern\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-selector\fR] [\fB\-\-multi\-selector\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-no\-border\fR] [\fB\-\-wrap\fR] [\fB\-\-multiline\fR] [\fB\-\-scrollbar\fR] [\fB\-\-no\-scrollbar\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-\-image\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-\-print\-header\fR] [\fB\-\-print\-current\fR] [\fB\-\-output\-format\fR] [\fB\-\-no\-strip\-ansi\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-shell\-bindings\fR] [\fB\-\-man\fR] [\fB\-\-listen\fR] [\fB\-\-remote\fR] [\fB\-\-popup\fR] [\fB\-\-log\-level\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR]
\fBsk\fR [\fB\-\-tac\fR] [\fB\-\-min\-query\-length\fR] [\fB\-\-no\-sort\fR] [\fB\-t\fR|\fB\-\-tiebreak\fR] [\fB\-n\fR|\fB\-\-nth\fR] [\fB\-\-with\-nth\fR] [\fB\-\-hide\-nth\fR] [\fB\-d\fR|\fB\-\-delimiter\fR] [\fB\-e\fR|\fB\-\-exact\fR] [\fB\-\-regex\fR] [\fB\-\-algo\fR] [\fB\-\-case\fR] [\fB\-\-typos\fR] [\fB\-\-no\-typos\fR] [\fB\-\-normalize\fR] [\fB\-\-split\-match\fR] [\fB\-\-last\-match\fR] [\fB\-\-scheme\fR] [\fB\-b\fR|\fB\-\-bind\fR] [\fB\-m\fR|\fB\-\-multi\fR] [\fB\-\-no\-multi\fR] [\fB\-\-no\-mouse\fR] [\fB\-c\fR|\fB\-\-cmd\fR] [\fB\-i\fR|\fB\-\-interactive\fR] [\fB\-I \fR] [\fB\-\-color\fR] [\fB\-\-highlight\-line\fR] [\fB\-\-no\-hscroll\fR] [\fB\-\-keep\-right\fR] [\fB\-\-skip\-to\-pattern\fR] [\fB\-\-no\-clear\-if\-empty\fR] [\fB\-\-no\-clear\-start\fR] [\fB\-\-no\-clear\fR] [\fB\-\-show\-cmd\-error\fR] [\fB\-\-cycle\fR] [\fB\-\-disabled\fR] [\fB\-\-disable\-pattern\fR] [\fB\-\-layout\fR] [\fB\-\-reverse\fR] [\fB\-\-height\fR] [\fB\-\-no\-height\fR] [\fB\-\-min\-height\fR] [\fB\-\-margin\fR] [\fB\-p\fR|\fB\-\-prompt\fR] [\fB\-\-cmd\-prompt\fR] [\fB\-\-selector\fR] [\fB\-\-multi\-selector\fR] [\fB\-\-ansi\fR] [\fB\-\-tabstop\fR] [\fB\-\-info\fR] [\fB\-\-no\-info\fR] [\fB\-\-inline\-info\fR] [\fB\-\-header\fR] [\fB\-\-header\-lines\fR] [\fB\-\-border\fR] [\fB\-\-border\-no\-collapse\fR] [\fB\-\-no\-border\fR] [\fB\-\-wrap\fR] [\fB\-\-multiline\fR] [\fB\-\-scrollbar\fR] [\fB\-\-no\-scrollbar\fR] [\fB\-\-history\fR] [\fB\-\-history\-size\fR] [\fB\-\-cmd\-history\fR] [\fB\-\-cmd\-history\-size\fR] [\fB\-\-preview\fR] [\fB\-\-preview\-window\fR] [\fB\-\-image\fR] [\fB\-q\fR|\fB\-\-query\fR] [\fB\-\-cmd\-query\fR] [\fB\-\-read0\fR] [\fB\-\-print0\fR] [\fB\-\-print\-query\fR] [\fB\-\-print\-cmd\fR] [\fB\-\-print\-score\fR] [\fB\-\-print\-header\fR] [\fB\-\-print\-current\fR] [\fB\-\-output\-format\fR] [\fB\-\-no\-strip\-ansi\fR] [\fB\-1\fR|\fB\-\-select\-1\fR] [\fB\-0\fR|\fB\-\-exit\-0\fR] [\fB\-\-sync\fR] [\fB\-\-pre\-select\-n\fR] [\fB\-\-pre\-select\-pat\fR] [\fB\-\-pre\-select\-items\fR] [\fB\-\-pre\-select\-file\fR] [\fB\-f\fR|\fB\-\-filter\fR] [\fB\-\-shell\fR] [\fB\-\-shell\-bindings\fR] [\fB\-\-man\fR] [\fB\-\-listen\fR] [\fB\-\-remote\fR] [\fB\-\-popup\fR] [\fB\-\-log\-level\fR] [\fB\-\-log\-file\fR] [\fB\-\-expect\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH OPTIONS
@ -94,6 +94,16 @@ Fields to be transformed
See nth for the details
.TP
\fB\-\-hide\-nth\fR \fI<HIDE_NTH>\fR [default: ]
Fields to hide from display while keeping them searchable
Takes the same comma\-separated field index expressions as **nth**. The listed
fields are removed from the displayed line but remain part of the text used for
matching, so a query can still match them. Characters in the hidden fields are
ignored for match highlighting and horizontal scrolling.
See **nth** for the field index expression syntax.
.TP
\fB\-d\fR, \fB\-\-delimiter\fR \fI<DELIMITER>\fR [default: [\\t\\n ]+]
Delimiter between fields
@ -187,13 +197,16 @@ 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, the
`double\-click` mouse binding, 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 and its
[Default key bindings] subsection for the defaults.
**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.
@ -333,10 +346,11 @@ Can either be a row count or a percentage A negative row count will use term hei
Disable height (force full screen)
.TP
\fB\-\-min\-height\fR \fI<MIN_HEIGHT>\fR [default: 10]
Minimum height of skim\*(Aqs window
Minimum height of skim\*(Aqs window as a non\-negative row count
Useful when the height is set as a percentage
Ignored when \-\-height is not specified
Must be a non\-negative row count, not a percentage.
Useful when the height is set as a percentage.
Ignored when \-\-height is not specified.
.TP
\fB\-\-margin\fR \fI<MARGIN>\fR [default: 0]
Screen margin
@ -389,6 +403,8 @@ Set matching result count display position
\- inline[:SEP] display info in the same row as the input with an optional non\-default
separator
\- default display info in a dedicated row above the input
\- left display all info left\-aligned in a dedicated row above the input
\- right display all info right\-aligned in a dedicated row above the input
\- inline\-right[:SEP] display info right\-aligned in the same row as the input with an optional
non\-default separator
.TP
@ -445,6 +461,9 @@ quadrant\-inside
quadrant\-outside
.RE
.TP
\fB\-\-border\-no\-collapse\fR
Do not collapse adjacent borders into a shared row or column
.TP
\fB\-\-no\-border\fR
Disables all borders, including in tmux/zellij popups
.TP
@ -496,9 +515,9 @@ Maximum number of query history entries to keep
\fB\-\-preview\fR \fI<PREVIEW>\fR
Preview command
Execute the given command for the current line and display the result on the preview window. {} in the command
is the placeholder that is replaced to the single\-quoted string of the current line. To transform the
replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
Execute the given command with `sh \-c` on linux and `cmd /c` on windows for the current line and display the result on the preview window.
`{}` in the command is the placeholder that is replaced to the single\-quoted string of the current line.
To transform the replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
**Examples**:
@ -737,9 +756,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)"
@ -819,131 +838,285 @@ Actions can take arguments, specified either between parentheses `reload(ls)` or
.br
* alt\-shift\-right
.br
* double\-click
.br
* any single character
.br
.SS "Actions[:default keys][*notes]"
.SS "Bindable finder events"
.br
* abort: ctrl\-c ctrl\-q esc
* change: the query changes
.br
* accept(...): enter *the argument will be printed when the binding is triggered*
* start: skim enters its event loop; fired once
.br
* append\-and\-select
* load: the reader and matcher finish consuming the current input; fired once per read, including reloads
.br
* backward\-char: ctrl\-b left
* result: filtering for the current query completes
.br
* backward\-delete\-char: ctrl\-h bspace
* focus: the focused item changes because of cursor movement or a result update
.br
* backward\-delete\-char/eof
* zero: the input stream is complete and the final search has no matches
.br
* backward\-kill\-word: alt\-bs
* one: the input stream is complete and the final search has exactly one match
.br
* backward\-word: alt\-b shift\-left
.SS "Actions as binding triggers"
.br
* beginning\-of\-line: ctrl\-a home
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
* clear\-screen: ctrl\-l
.br
* delete\-char: del
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
* delete\-char/eof: ctrl\-d
.SS Actions
.br
* deselect\-all
* abort: Abort and exit with error
.br
* down: ctrl\-j ctrl\-n down
* accept(...): Accept selection and exit with optional key. The argument is printed when the binding is triggered.
.br
* end\-of\-line: ctrl\-e end
* add\-char(...): Add a character to the query
.br
* execute(...): *arg will be a command, see COMMAND EXPANSION for details
* append\-and\-select: Append to selection and select
.br
* execute\-silent(...): *arg will be a command, see COMMAND EXPANSION for details
* backward\-char: Move cursor backward one character
.br
* forward\-char: ctrl\-f right
* backward\-delete\-char: Delete character before cursor
.br
* forward\-word: alt\-f shift\-right
* backward\-delete\-char/eof: Delete character before cursor or exit if the query is empty
.br
* if\-non\-matched
* backward\-kill\-word: Delete word before cursor
.br
* if\-query\-empty
* backward\-word: Move cursor backward one word
.br
* if\-query\-not\-empty
* beginning\-of\-line: Move cursor to beginning of line
.br
* ignore
* bind(...): Bind one or more keys to action chains. The argument is a comma\-separated list of `trigger:action[+action]` bindings to add, using the same syntax as `\-\-bind`, including action triggers such as `act\-up:last`.
.br
* kill\-line
* cancel: Cancel current operation
.br
* kill\-word: alt\-d
* clear\-screen: Clear the screen
.br
* next\-history: ctrl\-n with `\-\-history` or `\-\-cmd\-history`
* delete\-char: Delete character under cursor
.br
* page\-down: pgdn
* delete\-char/eof: Delete character or exit if empty
.br
* page\-up: pgup
* deselect\-all: Deselect all items
.br
* half\-page\-down
* down(...): Move selection down by N items
.br
* half\-page\-up
* end\-of\-line: Move cursor to end of line
.br
* preview\-up: shift\-up
* execute(...): Execute a command. The argument is a command, see COMMAND EXPANSION for details.
.br
* preview\-down: shift\-down
* execute\-silent(...): Execute a command silently. The argument is a command, see COMMAND EXPANSION for details.
.br
* preview\-left
* first: Jump to first item in list
.br
* preview\-right
* forward\-char: Move cursor forward one character
.br
* preview\-page\-down
* forward\-word: Move cursor forward one word
.br
* preview\-page\-up
* if\-query\-empty(...): Execute action if query is empty
.br
* previous\-history: ctrl\-p with `\-\-history` or `\-\-cmd\-history`
* if\-query\-not\-empty(...): Execute action if query is not empty
.br
* redraw
* if\-non\-matched(...): Execute action if no items match
.br
* refresh\-cmd
* ignore: Ignore the action
.br
* refresh\-preview
* kill\-line: Delete from cursor to end of line
.br
* reload(...)
* kill\-word: Delete word after cursor
.br
* select\-all
* last: Jump to last item in list
.br
* select\-row
* next\-history: Move to next history entry (requires `\-\-history` or `\-\-cmd\-history`)
.br
* set\-preview\-cmd(...): *arg will be a expanded expression, see COMMAND EXPANSION for details
* half\-page\-down(...): Scroll down by half a page
.br
* set\-query(...): *arg will be a expanded expression, see COMMAND EXPANSION for details
* half\-page\-up(...): Scroll up by half a page
.br
* toggle
* page\-down(...): Scroll down by a page
.br
* toggle\-all
* page\-up(...): Scroll up by a page
.br
* toggle+down: ctrl\-i tab
* preview\-up(...): Scroll preview up
.br
* toggle\-in: (\-\-layout=reverse ? toggle+up: toggle+down)
* preview\-down(...): Scroll preview down
.br
* toggle\-interactive
* preview\-left(...): Scroll preview left
.br
* toggle\-out: (\-\-layout=reverse ? toggle+down: toggle+up)
* preview\-right(...): Scroll preview right
.br
* toggle\-preview
* preview\-page\-up(...): Scroll preview up by a page
.br
* toggle\-preview\-wrap
* preview\-page\-down(...): Scroll preview down by a page
.br
* toggle\-sort
* previous\-history: Move to previous history entry (requires `\-\-history` or `\-\-cmd\-history`)
.br
* toggle+up: btab shift\-tab
* redraw: Redraw the screen
.br
* top
* refresh\-cmd: Refresh the command
.br
* unix\-line\-discard: ctrl\-u
* refresh\-preview: Refresh the preview
.br
* unix\-word\-rubout: ctrl\-w
* restart\-matcher: Restart the matcher
.br
* up: ctrl\-k ctrl\-p up
* reload(...): Reload with optional new command
.br
* yank: ctrl\-y
* rotate\-mode: Rotate through matching modes
.br
* scroll\-left(...): Scroll item list left
.br
* scroll\-right(...): Scroll item list right
.br
* select\-all: Select all items
.br
* select\-row(...): Select a specific row
.br
* select: Select current item
.br
* suppress: 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\*(Aqs own effect, so the remaining follow\-up chain runs in its place. On its own it is a no\-op (equivalent to `ignore`).
.br
* set\-cmd(...): Set the interactive\-mode command and rerun it. The argument is an expanded expression, see COMMAND EXPANSION for details.
.br
* set\-header(...): Set the header (or disable it on an empty value)
.br
* set\-preview\-cmd(...): Set the preview cmd and rerun preview. The argument is an expanded expression, see COMMAND EXPANSION for details.
.br
* set\-query(...): Set the query to the expanded value. The argument is an expanded expression, see COMMAND EXPANSION for details.
.br
* toggle: Toggle selection of current item
.br
* toggle\-all: Toggle selection of all items
.br
* toggle\-in: Toggle and move in
.br
* toggle\-interactive: Toggle interactive mode
.br
* toggle\-out: Toggle and move out
.br
* toggle\-preview: Toggle preview visibility
.br
* toggle\-preview\-wrap: Toggle preview line wrapping
.br
* toggle\-sort: Toggle sorting
.br
* top: Jump to first item in list (alias for First)
.br
* unbind(...): Unbind one or more keys. The argument is a comma\-separated list of keys or action triggers (e.g. `act\-up`) to unbind.
.br
* unix\-line\-discard: Discard line (unix\-style)
.br
* unix\-word\-rubout: Delete word backward (unix\-style)
.br
* up(...): Move selection up by N items
.br
* yank: Yank (paste)
.br
.SS "Default key bindings"
.br
* alt\-b: backward\-word
.br
* alt\-bspace: backward\-kill\-word
.br
* alt\-d: kill\-word
.br
* alt\-f: forward\-word
.br
* alt\-h: scroll\-left
.br
* alt\-l: scroll\-right
.br
* bspace: backward\-delete\-char
.br
* btab: toggle+up
.br
* ctrl\-a: beginning\-of\-line
.br
* ctrl\-b: backward\-char
.br
* ctrl\-c: abort
.br
* ctrl\-d: abort
.br
* ctrl\-e: end\-of\-line
.br
* ctrl\-f: forward\-char
.br
* ctrl\-g: abort
.br
* ctrl\-h: backward\-delete\-char
.br
* ctrl\-j: down
.br
* ctrl\-k: up
.br
* ctrl\-l: clear\-screen
.br
* ctrl\-left: backward\-word
.br
* ctrl\-n: down
.br
* ctrl\-p: up
.br
* ctrl\-q: toggle\-interactive
.br
* ctrl\-r: rotate\-mode
.br
* ctrl\-right: forward\-word
.br
* ctrl\-u: unix\-line\-discard
.br
* ctrl\-w: unix\-word\-rubout
.br
* ctrl\-y: yank
.br
* del: delete\-char
.br
* double\-click: accept
.br
* down: down
.br
* end: end\-of\-line
.br
* enter: accept
.br
* esc: abort
.br
* home: beginning\-of\-line
.br
* left: backward\-char
.br
* pgdn: page\-down
.br
* pgup: page\-up
.br
* right: forward\-char
.br
* shift\-btab: toggle+up
.br
* shift\-down: preview\-down
.br
* shift\-home: beginning\-of\-line
.br
* shift\-left: backward\-word
.br
* shift\-right: forward\-word
.br
* shift\-tab: toggle+up
.br
* shift\-up: preview\-up
.br
* tab: toggle+down
.br
* up: up
.br
.SH "COMMAND EXPANSION"
@ -1163,4 +1336,4 @@ When using `sk \-\-remote`, pipe in action chains (see the KEYBINDS section), fo
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH VERSION
v5.1.0
v5.7.0

View file

@ -338,7 +338,7 @@ function! skim#wrap(...)
" Action: g:skim_action
if !s:has_any(opts, ['sink', 'sink*'])
let opts._action = get(g:, 'skim_action', s:default_action)
let opts.options .= ' --expect='.join(keys(opts._action), ',')
let opts.options .= ' '.skim#shellescape('--bind='.join(map(keys(opts._action), 'v:val.":accept(".v:val.")"'), ','))
function! opts.sink(lines) abort
return s:common_sink(self._action, a:lines)
endfunction

View file

@ -23,7 +23,7 @@ _sk() {
case "${cmd}" in
sk)
opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --delimiter --exact --regex --algo --case --typos --no-typos --normalize --split-match --last-match --scheme --bind --multi --no-multi --no-mouse --cmd --interactive --color --highlight-line --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --cycle --disabled --disable-pattern --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --selector --multi-selector --ansi --tabstop --ellipsis --info --no-info --inline-info --header --header-lines --border --no-border --wrap --multiline --scrollbar --no-scrollbar --history --history-size --cmd-history --cmd-history-size --preview --preview-window --image --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --print-header --print-current --output-format --no-strip-ansi --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --shell-bindings --man --listen --remote --popup --log-level --log-file --flags --extended --literal --hscroll-off --filepath-word --jump-labels --no-bold --phony --tail --style --no-color --padding --border-label --border-label-pos --wrap-sign --no-multi-line --raw --track --gap --gap-line --freeze-left --freeze-right --scroll-off --gutter --gutter-raw --marker-multi-line --list-border --list-label --list-label-pos --no-input --info-command --separator --no-separator --ghost --input-border --input-label --input-label-pos --preview-label --preview-label-pos --header-first --header-border --header-lines-border --footer --footer-border --footer-label --footer-label-pos --with-shell --expect --help --version"
opts="-t -n -d -e -b -m -c -i -I -p -q -1 -0 -f -x -h -V --tac --min-query-length --no-sort --tiebreak --nth --with-nth --hide-nth --delimiter --exact --regex --algo --case --typos --no-typos --normalize --split-match --last-match --scheme --bind --multi --no-multi --no-mouse --cmd --interactive --color --highlight-line --no-hscroll --keep-right --skip-to-pattern --no-clear-if-empty --no-clear-start --no-clear --show-cmd-error --cycle --disabled --disable-pattern --layout --reverse --height --no-height --min-height --margin --prompt --cmd-prompt --selector --multi-selector --ansi --tabstop --ellipsis --info --no-info --inline-info --header --header-lines --border --border-no-collapse --no-border --wrap --multiline --scrollbar --no-scrollbar --history --history-size --cmd-history --cmd-history-size --preview --preview-window --image --query --cmd-query --read0 --print0 --print-query --print-cmd --print-score --print-header --print-current --output-format --no-strip-ansi --select-1 --exit-0 --sync --pre-select-n --pre-select-pat --pre-select-items --pre-select-file --filter --shell --shell-bindings --man --listen --remote --popup --log-level --log-file --flags --extended --literal --hscroll-off --filepath-word --jump-labels --no-bold --phony --tail --style --no-color --padding --border-label --border-label-pos --wrap-sign --no-multi-line --raw --track --gap --gap-line --freeze-left --freeze-right --scroll-off --gutter --gutter-raw --marker-multi-line --list-border --list-label --list-label-pos --no-input --info-command --separator --no-separator --ghost --input-border --input-label --input-label-pos --preview-label --preview-label-pos --header-first --header-border --header-lines-border --footer --footer-border --footer-label --footer-label-pos --with-shell --expect --help --version"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -53,6 +53,10 @@ _sk() {
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--hide-nth)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--delimiter)
COMPREPLY=($(compgen -f "${cur}"))
return 0

View file

@ -13,6 +13,7 @@ pathname\t''
-pathname\t''"
complete -c sk -s n -l nth -d 'Fields to be matched' -r
complete -c sk -l with-nth -d 'Fields to be transformed' -r
complete -c sk -l hide-nth -d 'Fields to hide from display while keeping them searchable' -r
complete -c sk -s d -l delimiter -d 'Delimiter between fields' -r
complete -c sk -l algo -d 'Fuzzy matching algorithm' -r -f -a "arinae\t'Arinae: typo-resistant & natural algorithm, default'
clangd\t'Clangd fuzzy matching algorithm'
@ -27,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
@ -37,7 +38,7 @@ complete -c sk -l layout -d 'Set layout' -r -f -a "default\t'Display from the bo
reverse\t'Display from the top of the screen'
reverse-list\t'Display from the top of the screen, prompt at the bottom'"
complete -c sk -l height -d 'Height of skim\'s window' -r
complete -c sk -l min-height -d 'Minimum height of skim\'s window' -r
complete -c sk -l min-height -d 'Minimum height of skim\'s window as a non-negative row count' -r
complete -c sk -l margin -d 'Screen margin' -r
complete -c sk -s p -l prompt -d 'Set prompt' -r
complete -c sk -l cmd-prompt -d 'Set prompt in command mode' -r
@ -156,6 +157,7 @@ complete -c sk -l no-height -d 'Disable height (force full screen)'
complete -c sk -l ansi -d 'Parse ANSI color codes in input strings'
complete -c sk -l no-info -d 'Alias for --info=hidden'
complete -c sk -l inline-info -d 'Alias for --info=inline'
complete -c sk -l border-no-collapse -d 'Do not collapse adjacent borders into a shared row or column'
complete -c sk -l no-border -d 'Disables all borders, including in tmux/zellij popups'
complete -c sk -l wrap -d 'Wrap items in the item list'
complete -c sk -l no-scrollbar -d 'Disable the scrollbar in the item list'

View file

@ -44,6 +44,7 @@ module completions {
--tiebreak(-t): string@"nu-complete sk tiebreak" # Comma-separated list of sort criteria to apply when the scores are tied.
--nth(-n): string # Fields to be matched
--with-nth: string # Fields to be transformed
--hide-nth: string # Fields to hide from display while keeping them searchable
--delimiter(-d): string # Delimiter between fields
--exact(-e) # Run in exact mode
--regex # Start in regex mode instead of fuzzy-match
@ -55,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
@ -78,7 +79,7 @@ module completions {
--reverse # Shorthand for reverse layout
--height: string # Height of skim's window
--no-height # Disable height (force full screen)
--min-height: string # Minimum height of skim's window
--min-height: string # Minimum height of skim's window as a non-negative row count
--margin: string # Screen margin
--prompt(-p): string # Set prompt
--cmd-prompt: string # Set prompt in command mode
@ -93,6 +94,7 @@ module completions {
--header: string # Set header, displayed next to the info
--header-lines: string # Number of lines of the input treated as header
--border: string@"nu-complete sk border" # Draw borders around the UI components
--border-no-collapse # Do not collapse adjacent borders into a shared row or column
--no-border # Disables all borders, including in tmux/zellij popups
--wrap # Wrap items in the item list
--multiline: string # Split item text into multiple display lines at the given separator character defaults to \n if read0 is set, and \\n if not (matching literal \n in text)

View file

@ -21,6 +21,7 @@ _sk() {
'*-n+[Fields to be matched]:NTH:_default' \
'*--nth=[Fields to be matched]:NTH:_default' \
'*--with-nth=[Fields to be transformed]:WITH_NTH:_default' \
'*--hide-nth=[Fields to hide from display while keeping them searchable]:HIDE_NTH:_default' \
'-d+[Delimiter between fields]:DELIMITER:_default' \
'--delimiter=[Delimiter between fields]:DELIMITER:_default' \
'--algo=[Fuzzy matching algorithm]:ALGORITHM:((arinae\:"Arinae\: typo-resistant & natural algorithm, default"
@ -36,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' \
@ -48,7 +49,7 @@ history\:"History scheme\: will force index as the first tiebreak"))' \
reverse\:"Display from the top of the screen"
reverse-list\:"Display from the top of the screen, prompt at the bottom"))' \
'--height=[Height of skim'\''s window]:HEIGHT:_default' \
'--min-height=[Minimum height of skim'\''s window]:MIN_HEIGHT:_default' \
'--min-height=[Minimum height of skim'\''s window as a non-negative row count]:MIN_HEIGHT:_default' \
'--margin=[Screen margin]:MARGIN:_default' \
'-p+[Set prompt]:PROMPT:_default' \
'--prompt=[Set prompt]:PROMPT:_default' \
@ -173,6 +174,7 @@ single-matcher\:"Limit the matcher thread pool to a single thread"))' \
'--ansi[Parse ANSI color codes in input strings]' \
'--no-info[Alias for --info=hidden]' \
'--inline-info[Alias for --info=inline]' \
'--border-no-collapse[Do not collapse adjacent borders into a shared row or column]' \
'--no-border[Disables all borders, including in tmux/zellij popups]' \
'--wrap[Wrap items in the item list]' \
'--no-scrollbar[Disable the scrollbar in the item list]' \

View file

@ -267,12 +267,6 @@ _skim_dir_completion() {
"" "/" ""
}
_skim_feed_fifo() (
command rm -f "$1"
mkfifo "$1"
cat <&0 > "$1" &
)
_skim_complete() {
setopt localoptions ksh_arrays
# Split arguments around --
@ -296,20 +290,17 @@ _skim_complete() {
rest=("$@")
fi
local fifo lbuf cmd matches post
fifo="${TMPDIR:-/tmp}/skim-complete-fifo-$$"
local lbuf cmd matches post
lbuf=${rest[0]}
cmd=$(__skim_extract_command "$lbuf")
post="${funcstack[1]}_post"
type $post > /dev/null 2>&1 || post=cat
_skim_feed_fifo "$fifo"
matches=$(SKIM_DEFAULT_OPTIONS="--reverse $SKIM_DEFAULT_OPTIONS $SKIM_COMPLETION_OPTS $str_arg" __skim_comprun "$cmd" "${args[@]}" -q "${(Q)prefix}" < "$fifo" | $post | tr '\n' ' ')
matches=$(SKIM_DEFAULT_OPTIONS="--reverse $SKIM_DEFAULT_OPTIONS $SKIM_COMPLETION_OPTS $str_arg" __skim_comprun "$cmd" "${args[@]}" -q "${(Q)prefix}" | $post | tr '\n' ' ')
if [ -n "$matches" ]; then
LBUFFER="$lbuf$matches"
fi
zle reset-prompt
command rm -f "$fifo"
}
_skim_complete_telnet() {

View file

@ -1 +1 @@
5.1.0
5.7.0

View file

@ -9,8 +9,7 @@ extern crate log;
extern crate shlex;
extern crate skim;
use color_eyre::Result;
use color_eyre::eyre::eyre;
use eyre::{Result, eyre};
#[cfg(feature = "listen")]
use interprocess::bound_util::RefWrite;
#[cfg(feature = "listen")]
@ -72,7 +71,6 @@ fn main() -> Result<()> {
let mut opts = SkimOptions::from_env().unwrap_or_else(|e| {
e.exit();
});
color_eyre::install()?;
init_logger(&opts);
// Build the options after setting the log target

View file

@ -6,11 +6,89 @@
use std::collections::HashMap;
use std::ops::{Deref, DerefMut};
use color_eyre::Result;
use color_eyre::eyre::eyre;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use eyre::{Result, eyre};
use crate::tui::event::{self, Action};
use crate::tui::actions::{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(248)``F(255)`) that no real terminal ever emits.
/// 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,
/// Fired after two left mouse-button presses no more than 500 ms apart.
DoubleClick,
}
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),
SkimEvent::DoubleClick => KeyCode::F(248),
}
}
/// 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),
"double-click" => Some(SkimEvent::DoubleClick),
_ => 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)]
@ -31,7 +109,7 @@ impl DerefMut for KeyMap {
impl From<&str> for KeyMap {
fn from(value: &str) -> Self {
parse_keymaps(value.split(','))
parse_keymaps(split_top_level(value, ',').into_iter())
}
}
@ -42,6 +120,11 @@ impl Default for KeyMap {
}
impl KeyMap {
/// Adds keymaps from a comma-separated string.
pub(crate) fn add_keymaps_str(&mut self, source: &str) {
self.add_keymaps(split_top_level(source, ',').into_iter());
}
/// Adds keymaps from the source, parsing them using `parse_keymap`
pub fn add_keymaps<'a, T>(&mut self, source: T)
where
@ -86,6 +169,7 @@ pub fn get_default_key_map() -> KeyMap {
ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), vec![Action::BackwardChar]);
ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), vec![Action::ForwardChar]);
ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), vec![Action::BackwardDeleteChar]);
ret.insert(SkimEvent::DoubleClick.key_event(), vec![Action::Accept(None)]);
ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), vec![Action::BackwardWord]);
@ -131,7 +215,10 @@ 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 names recognized by
/// [`SkimEvent::from_name`], including `change`, `start`, and `double-click`.
///
/// # Errors
/// Returns an error if the key string is empty, contains an unknown modifier,
@ -140,6 +227,9 @@ pub fn parse_key(key: &str) -> Result<KeyEvent> {
if key.is_empty() {
return Err(eyre!("Cannot parse empty key"));
}
if let Some(event) = SkimEvent::from_name(key) {
return Ok(event.key_event());
}
let parts = key.split('-').collect::<Vec<&str>>();
let mut mods = KeyModifiers::NONE;
@ -165,8 +255,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() {
@ -184,8 +277,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)),
},
}
}
@ -204,13 +299,90 @@ where
res
}
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();
for (index, ch) in value.char_indices() {
match ch {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ if ch == separator && depth == 0 => {
parts.push(&value[start..index]);
start = index + ch.len_utf8();
}
_ => {}
}
}
parts.push(&value[start..]);
parts
}
/// Parses 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::actions::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::actions::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 = actions::parse_action(action_name).or_else(|| actions::parse_action(&format!("{action_name}()")))?;
Some(action.name())
}
/// Parses an action chain, separated by '+'s into the corresponding actions
///
/// # Errors
/// Returns an error if the action chain is empty or contains only unknown actions.
pub fn parse_action_chain(action_chain: &str) -> Result<Vec<Action>> {
let mut actions: Vec<Action> = vec![];
let mut split = action_chain.split('+');
let mut split = split_top_level(action_chain, '+').into_iter();
while let Some(mut s) = split.next().map(String::from) {
if (s.starts_with("if-") || s.ends_with('{'))
@ -218,7 +390,7 @@ pub fn parse_action_chain(action_chain: &str) -> Result<Vec<Action>> {
{
s += &(String::from("+") + otherwise);
}
if let Some(act) = event::parse_action(&s) {
if let Some(act) = actions::parse_action(&s) {
actions.push(act);
}
}

View file

@ -1,5 +1,5 @@
use super::*;
use event::Action::*;
use actions::Action::*;
#[test]
fn test_parse_action_chain() {
let parsed = parse_action_chain(
@ -129,6 +129,44 @@ 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),
("double-click", SkimEvent::DoubleClick),
] {
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,double-click:accept");
for event in [
SkimEvent::Start,
SkimEvent::Load,
SkimEvent::Change,
SkimEvent::DoubleClick,
] {
assert!(keymap.get(&event.key_event()).is_some());
}
}
#[test]
fn double_click_accepts_by_default() {
assert_eq!(
get_default_key_map().get(&SkimEvent::DoubleClick.key_event()),
Some(&vec![Accept(None)])
);
}
#[test]
fn parse_key_error_cases() {
// Empty input.
@ -149,12 +187,104 @@ fn keymap_from_str_parses_bindings() {
assert!(keymap.get(&parse_key("enter").unwrap()).is_some());
}
#[test]
fn keymap_from_str_preserves_nested_bind_separators() {
let keymap = KeyMap::from("ctrl-z:bind(ctrl-x:abort+up),ctrl-w:unbind(ctrl-x,ctrl-y)");
assert_eq!(
keymap.get(&parse_key("ctrl-z").unwrap()),
Some(&vec![Bind("ctrl-x:abort+up".into())])
);
assert_eq!(
keymap.get(&parse_key("ctrl-w").unwrap()),
Some(&vec![Unbind("ctrl-x,ctrl-y".into())])
);
}
#[test]
fn parse_action_chain_preserves_nested_bind_chain() {
assert_eq!(
parse_action_chain("bind(ctrl-x:abort+up)").unwrap(),
vec![Bind("ctrl-x:abort+up".into())]
);
}
#[test]
fn parse_keymaps_collects_iterator() {
let keymap = parse_keymaps(["ctrl-x:abort", "up:up"].into_iter());
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());

View file

@ -79,24 +79,33 @@ impl MatchEngine for ExactEngine {
let mut matched_result = None;
let item_text = item.text();
let default_range = [(0, item_text.len())];
for &(start, end) in item.get_matching_ranges().unwrap_or(&default_range) {
let start = min(start, item_text.len());
let end = min(end, item_text.len());
if self.query_regex.is_none() {
matched_result = Some((0, 0));
break;
let ranges = item.get_matching_ranges().unwrap_or(&default_range);
if ranges.is_empty() {
// Nothing to match against (e.g. every `--nth` index is out of range): the item
// stays unmatched, inverse or not.
} else if self.query_regex.is_none() {
matched_result = Some((0, 0));
} else {
for &(start, end) in ranges {
let start = min(start, item_text.len());
let end = min(end, item_text.len());
matched_result =
regex_match(&item_text[start..end], self.query_regex.as_ref()).map(|(s, e)| (s + start, e + start));
if matched_result.is_some() {
break;
}
}
matched_result =
regex_match(&item_text[start..end], self.query_regex.as_ref()).map(|(s, e)| (s + start, e + start));
// An inverse query has to be evaluated over *all* the matching ranges: the item
// only matches when none of them contains the query. Inverting inside the loop
// would let the first non-matching field short-circuit the scan and wrongly
// accept an item whose later fields do contain the query.
if self.inverse {
matched_result = matched_result.xor(Some((0, 0)));
}
if matched_result.is_some() {
break;
}
}
let (begin, end) = matched_result?;

View file

@ -83,6 +83,83 @@ fn inverse_match_excludes_query() {
assert!(e.match_item(&"foo".to_string()).is_none());
}
/// An item exposing explicit matching ranges, as `--nth` produces.
struct RangedItem {
text: String,
ranges: Vec<(usize, usize)>,
}
impl SkimItem for RangedItem {
fn text(&self) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(&self.text)
}
fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
Some(&self.ranges)
}
}
#[test]
fn inverse_match_checks_every_matching_range() {
// `--nth 1,2` over "foo bar" yields two ranges: "foo" and "bar". An inverse
// query `!foo` must reject the item because one of the ranges contains "foo",
// even though the *first* range scanned may not.
let e = engine(
"foo",
ExactMatchingParam {
inverse: true,
case: CaseMatching::Ignore,
..Default::default()
},
);
let foo_in_first_range = RangedItem {
text: "foo bar".to_string(),
ranges: vec![(0, 3), (4, 7)],
};
assert!(
e.match_item(&foo_in_first_range).is_none(),
"item whose first field contains the query must not match an inverse query"
);
let foo_in_second_range = RangedItem {
text: "bar foo".to_string(),
ranges: vec![(0, 3), (4, 7)],
};
assert!(
e.match_item(&foo_in_second_range).is_none(),
"item whose second field contains the query must not match an inverse query"
);
let no_foo = RangedItem {
text: "bar baz".to_string(),
ranges: vec![(0, 3), (4, 7)],
};
assert!(
e.match_item(&no_foo).is_some(),
"item where no field contains the query must match an inverse query"
);
}
#[test]
fn inverse_match_with_no_matching_range_does_not_match() {
// Every `--nth` index out of range leaves the item with no range at all;
// there is nothing to match against, so the item stays unmatched.
let e = engine(
"foo",
ExactMatchingParam {
inverse: true,
case: CaseMatching::Ignore,
..Default::default()
},
);
let item = RangedItem {
text: "bar baz".to_string(),
ranges: vec![],
};
assert!(e.match_item(&item).is_none());
}
#[test]
fn empty_query_matches_everything() {
let e = engine("", ExactMatchingParam::default());

View file

@ -23,6 +23,14 @@ pub enum FieldRange {
Both(i32, i32),
}
/// Parses one side of a field range. The regex only ever hands us `-?\d+`, so the
/// single failure mode is overflowing `i32`; saturate instead of silently falling
/// back to a different field.
fn parse_index(s: &str) -> i32 {
s.parse()
.unwrap_or(if s.starts_with('-') { i32::MIN } else { i32::MAX })
}
impl FieldRange {
/// Parses a field range from a string (e.g., "1", "1..", "..10", "1..10")
#[allow(clippy::should_implement_trait)]
@ -32,8 +40,8 @@ impl FieldRange {
// "1", "1..", "..10", "1..10", etc.
let opt_caps = FIELD_RANGE.captures(range);
if let Some(caps) = opt_caps {
let opt_left = caps.name("left").map(|s| s.as_str().parse().unwrap_or(1));
let opt_right = caps.name("right").map(|s| s.as_str().parse().unwrap_or(-1));
let opt_left = caps.name("left").map(|s| parse_index(s.as_str()));
let opt_right = caps.name("right").map(|s| parse_index(s.as_str()));
let opt_sep = caps.name("sep").map(|s| s.as_str().to_string());
match (opt_left, opt_right) {

View file

@ -19,6 +19,24 @@ fn test_parse_range() {
assert_eq!(FieldRange::from_str("a..b"), None);
}
#[test]
fn test_parse_range_out_of_i32_range() {
// Indices past i32 saturate instead of silently falling back to field 1 / -1.
assert_eq!(FieldRange::from_str("2147483648"), Some(Single(i32::MAX)));
assert_eq!(FieldRange::from_str("-2147483649"), Some(Single(i32::MIN)));
assert_eq!(FieldRange::from_str("99999999999.."), Some(RightInf(i32::MAX)));
assert_eq!(FieldRange::from_str("..99999999999"), Some(LeftInf(i32::MAX)));
assert_eq!(FieldRange::from_str("2..99999999999"), Some(Both(2, i32::MAX)));
// ...and a saturated index still resolves to nothing on a short line.
assert_eq!(
FieldRange::from_str("2147483648").unwrap().to_index_pair(3),
Single(i32::MAX).to_index_pair(3)
);
assert_eq!(FieldRange::from_str("2147483648").unwrap().to_index_pair(3), None);
assert_eq!(FieldRange::from_str("-2147483649").unwrap().to_index_pair(3), None);
}
use regex::Regex;
#[test]

View file

@ -1,6 +1,7 @@
//! Byte/Char helpers
use super::Score;
use super::constants::SEPARATOR_TABLE;
use crate::fuzzy_matcher::util::char_equal;
use memchr::{memchr, memrchr};
pub(super) trait Atom: PartialEq + Into<char> + Copy {
@ -112,9 +113,15 @@ impl Atom for u8 {
}
impl Atom for char {
#[inline(always)]
fn eq_ignore_case(self, b: Self) -> bool {
self.to_lowercase().eq(b.to_lowercase())
fn eq(self, other: Self, respect_case: bool) -> bool {
char_equal(self, other, respect_case)
}
#[inline(always)]
fn eq_ignore_case(self, b: Self) -> bool {
char_equal(self, b, false)
}
#[inline(always)]
fn is_lowercase(self) -> bool {
self.is_lowercase()
@ -179,6 +186,8 @@ mod tests {
fn char_atom_eq_and_case() {
assert!('a'.eq('A', false));
assert!(!'a'.eq('A', true));
assert!(''.eq('a', true));
assert!(''.eq('a', false));
assert!('a'.is_lowercase());
assert!(!'A'.is_lowercase());
// Default (non-SIMD) find impls for char.

View file

@ -28,18 +28,14 @@ pub(super) fn compute_banding<const ALLOW_TYPOS: bool, C: Atom>(
) -> Option<BandingInfo> {
let n = pat.len();
let m = cho.len();
let row_bounds;
let j_first;
if ALLOW_TYPOS {
j_first = find_first_char(pat, cho, respect_case)?;
row_bounds = None;
let (j_first, row_bounds) = if ALLOW_TYPOS {
(find_first_char(pat, cho, respect_case)?, None)
} else {
let fm = compute_first_match_cols(pat, cho, respect_case)?;
let lm = compute_last_match_cols(pat, cho, respect_case)?;
j_first = fm[0];
row_bounds = Some(compute_row_col_bounds(n, m, &fm, &lm));
}
(fm[0], Some(compute_row_col_bounds(n, m, &fm, &lm)))
};
let bandwidth = if ALLOW_TYPOS { n + TYPO_BAND_SLACK } else { 0 };
let min_true_matches = if ALLOW_TYPOS { n.div_ceil(2) } else { 0 };

View file

@ -140,13 +140,14 @@ impl FuzzyMatcher for ClangdMatcher {
let mut row = num_pattern_chars;
let mut col = num_choice_chars;
while row > 0 || col > 0 {
if last_action == Action::Match {
indices_reverse.push((col - 1) as IndexType);
}
while col > 0 {
let cell = &dp[row][col];
if last_action == Action::Match {
if row == 0 {
debug_assert!(false, "clangd backtracking hit a match with no pattern left");
break;
}
indices_reverse.push((col - 1) as IndexType);
last_action = cell.last_action_match;
row -= 1;
col -= 1;

View file

@ -49,12 +49,9 @@ impl FrizbeeMatcher {
LOCAL_MATCHER.with(|cell| {
let mut slot = cell.borrow_mut();
if slot.as_ref().is_none() {
*slot = Some(Matcher::new("", &self.config));
}
let matcher = slot.as_mut().unwrap();
let matcher = slot.get_or_insert_with(|| Matcher::new("", &self.config));
matcher.set_config(self.config.clone());
matcher.set_needle(pattern);
matcher.set_pattern(pattern);
f(matcher)
})
}
@ -65,7 +62,10 @@ impl FuzzyMatcher for FrizbeeMatcher {
self.with_matcher(pattern, |m| {
m.match_one_indices(choice, 0).map(|mut hit| {
hit.indices.reverse();
(hit.score.into(), hit.indices)
(
hit.score.into(),
hit.indices.into_iter().map(|index| index as usize).collect(),
)
})
})
}

View file

@ -85,4 +85,53 @@ mod tests {
// Empty pattern yields an empty index list, so begin/end fall back to 0.
assert_eq!(StubMatcher.fuzzy_match_range("hello", ""), Some((0, 0, 0)));
}
/// Regression test for a fuzzer-found panic (fuzz target `fuzzy_match`).
///
/// 'İ' (U+0130) lowercases to two chars, and `char_equal` used to be
/// asymmetric for such characters. `cheap_matches` compares
/// `(choice, pattern)` while the matchers' `allow_match` helpers compare
/// `(pattern, choice)`, so the cheap pre-filter accepted a candidate the DP
/// then refused to match. The clangd matcher's backtracking loop walked off
/// the start of its matrix, panicking with "attempt to subtract with
/// overflow" in debug and an out-of-bounds index in release.
#[test]
fn multichar_lowercase_does_not_panic() {
use crate::fuzzy_matcher::clangd::ClangdMatcher;
use crate::fuzzy_matcher::fzy::FzyMatcher;
use crate::fuzzy_matcher::skim::SkimMatcherV2;
let skim = SkimMatcherV2::default();
let fzy = FzyMatcher::default();
let clangd = ClangdMatcher::default();
let matchers: [(&str, &dyn FuzzyMatcher); 3] = [("skim", &skim), ("fzy", &fzy), ("clangd", &clangd)];
// The exact crashing input from the fuzz artifact, plus related shapes.
let cases = [
("Jİ:I", "İ:İ"),
("I", "İ"),
("İ", "I"),
("i", "İ"),
("İ", "i"),
("Jİ:Iİ", "İİ"),
("straße", "STRASSE"),
("ffly", "ffl"),
];
for (choice, pattern) in cases {
let num_chars = choice.chars().count();
for (name, matcher) in matchers {
// Must not panic, and any returned index must be a valid char
// index into `choice` (the invariant asserted by the fuzzer).
if let Some((_score, indices)) = matcher.fuzzy_indices(choice, pattern) {
for idx in indices {
assert!(
idx < num_chars,
"{name}: match index {idx} out of bounds for {choice:?} ({num_chars} chars)"
);
}
}
}
}
}
}

View file

@ -27,6 +27,15 @@ fn test_match_or_not() {
);
}
#[test]
fn fullwidth_ascii_matches_ascii_query() {
let matcher = SkimMatcherV2::default();
let (_, indices) = matcher
.fuzzy_indices("", "abc")
.expect("fullwidth ASCII should match");
assert_eq!(indices, vec![0, 1, 2]);
}
#[test]
fn test_match_quality() {
let matcher = SkimMatcherV2::default().ignore_case();

View file

@ -22,26 +22,43 @@ pub fn cheap_matches(choice: &[char], pattern: &[char], case_sensitive: bool) ->
}
}
/// Given 2 character, check if they are equal (considering ascii case)
/// Convert the Unicode fullwidth form of an ASCII character to ASCII.
#[inline]
fn narrow_ascii_width(ch: char) -> char {
match ch {
'\u{3000}' => ' ',
'\u{FF01}'..='\u{FF5E}' => char::from_u32(ch as u32 - 0xFEE0).unwrap_or(ch),
_ => ch,
}
}
/// Given two characters, check if they are equal after folding ASCII width and,
/// when requested, case.
/// e.g. ('a', 'A', true) => false
/// e.g. ('a', 'A', false) => true
/// e.g. ('', 'a', true) => true
#[inline]
pub fn char_equal(a: char, b: char, case_sensitive: bool) -> bool {
if case_sensitive {
a == b
} else {
let a_lower = a.to_lowercase();
let mut b_lower = b.to_lowercase();
for a_n in a_lower {
let Some(b_n) = b_lower.next() else {
return false;
};
if a_n != b_n {
return false;
}
}
true
if a == b {
return true;
}
let a = narrow_ascii_width(a);
let b = narrow_ascii_width(b);
if a == b {
return true;
}
if case_sensitive {
return false;
}
if a.is_ascii() && b.is_ascii() {
return a.eq_ignore_ascii_case(&b);
}
a.to_lowercase().eq(b.to_lowercase())
}
#[derive(Debug, PartialEq)]
@ -158,11 +175,56 @@ mod tests {
assert!(!char_equal('a', 'b', false));
}
#[test]
fn char_equal_folds_fullwidth_ascii() {
assert!(char_equal('', 'a', true));
assert!(char_equal('', 'A', true));
assert!(!char_equal('', 'a', true));
assert!(char_equal('', 'a', false));
assert!(char_equal('', '1', true));
assert!(char_equal(' ', ' ', true));
}
#[test]
fn char_equal_multichar_lowercase_mismatch() {
// 'İ' (U+0130) lowercases to two chars ("i" + combining dot), so it is
// not equal to the single char 'i' — exercising the length-mismatch path.
assert!(!char_equal('İ', 'i', false));
// ...and the comparison must be symmetric. This direction used to
// return `true` because the shorter sequence was exhausted first, which
// made `cheap_matches` and `allow_match` disagree and drove the clangd
// matcher's backtracking past the start of the DP matrix.
assert!(!char_equal('i', 'İ', false));
assert!(!char_equal('I', 'İ', false));
assert!(!char_equal('İ', 'I', false));
}
#[test]
fn char_equal_is_symmetric() {
// Exhaustively check symmetry against chars with multi-char or
// otherwise unusual lowercase mappings.
let probes = ['i', 'I', 'İ', 'ı', 'ß', 'ẞ', 'ffl', 'ς', 'Σ', 'K', 'İ', 'Dž'];
for u in 0..=0x2FFFu32 {
let Some(ch) = char::from_u32(u) else { continue };
for p in probes {
for case_sensitive in [true, false] {
assert_eq!(
char_equal(ch, p, case_sensitive),
char_equal(p, ch, case_sensitive),
"char_equal is asymmetric for ({ch:?}, {p:?}, {case_sensitive})"
);
}
}
}
}
#[test]
fn char_equal_reflexive() {
for u in 0..=0x2FFFu32 {
let Some(ch) = char::from_u32(u) else { continue };
assert!(char_equal(ch, ch, true));
assert!(char_equal(ch, ch, false));
}
}
#[test]

View file

@ -2,7 +2,7 @@
//! Including the `DefaultSkimItem`
use crate::field::{FieldRange, parse_matching_fields, parse_transform_fields};
use crate::tui::util::merge_styles;
use crate::{DisplayContext, SkimItem};
use crate::{DisplayContext, Matches, SkimItem};
use ansi_to_tui::IntoText;
use ratatui::text::{Line, Span};
use regex::Regex;
@ -48,6 +48,12 @@ pub struct DefaultSkimItemMetadata {
/// The ranges on which to perform matching
matching_ranges: Option<Vec<(usize, usize)>>,
/// Byte ranges (in the display/matching text) of fields hidden via `--hide-nth`.
/// Characters inside these ranges are removed from the rendered line and ignored
/// for match highlighting and horizontal scrolling, but remain part of the text
/// used for matching so they stay searchable.
hidden_ranges: Option<Vec<(usize, usize)>>,
/// Whether the item should be disabled or not
disabled: bool,
}
@ -162,6 +168,7 @@ impl DefaultSkimItem {
stripped_text: stripped_text.map(std::string::String::into_boxed_str),
ansi_info,
matching_ranges,
hidden_ranges: None,
disabled: false,
}))
} else {
@ -174,6 +181,32 @@ impl DefaultSkimItem {
}
}
/// Builder-style setter for the fields hidden from display (via `--hide-nth`).
///
/// The fields are resolved against the item's display/matching text — which is
/// exactly what [`text()`](Self::text) returns (the ANSI-stripped text under
/// `--ansi`, otherwise the raw text) — so this must be called after construction.
/// The requested fields stay part of `text()` (and therefore searchable); they are
/// only removed from the rendered line and ignored for highlighting and hscroll.
///
/// A no-op when `hidden_fields` is empty or resolves to no ranges.
#[must_use]
pub fn hidden_fields(mut self, hidden_fields: &[FieldRange], delimiter: &Regex) -> Self {
if hidden_fields.is_empty() {
return self;
}
// Resolve the ranges before touching `self.metadata`; the `text()` borrow must
// end before the mutable borrow below.
let ranges = {
let text = self.text();
normalize_ranges(&parse_matching_fields(delimiter, text.as_ref(), hidden_fields))
};
if !ranges.is_empty() {
self.metadata.get_or_insert_default().hidden_ranges = Some(ranges);
}
self
}
fn contains_ansi_escape(s: &str) -> bool {
memchr::memchr(b'\x1b', s.as_bytes()).is_some()
}
@ -228,6 +261,16 @@ impl DefaultSkimItem {
None
}
}
/// Getter for `hidden_ranges` stored in metadata
#[must_use]
pub fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
if let Some(meta) = &self.metadata {
meta.hidden_ranges.as_ref().map(|v| v.as_ref() as &[(usize, usize)])
} else {
None
}
}
}
impl DefaultSkimItem {
@ -273,6 +316,10 @@ impl SkimItem for DefaultSkimItem {
self.matching_ranges()
}
fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
self.hidden_ranges()
}
// The display function handles ANSI stripping, field highlighting, and match
// rendering in a single pass; splitting it would require duplicating context handling.
#[allow(clippy::too_many_lines)]
@ -289,9 +336,23 @@ impl SkimItem for DefaultSkimItem {
// Extract all spans from the parsed text (should be a single line)
let all_spans: Vec<Span> = parsed_text.lines.into_iter().flat_map(|line| line.spans).collect();
// When fields are hidden (--hide-nth), drop the hidden characters from the
// parsed spans while preserving their ANSI styling, and remap the match
// positions into the resulting visible coordinate space. The remaining
// highlighting logic then runs unchanged on visible-coordinate CharIndices.
let (all_spans, matches) = if let Some(hidden) = self.hidden_ranges() {
let stripped = self.text();
let (_, map) = project_visible_text(stripped.as_ref(), hidden);
let visible_spans = retain_visible_spans(all_spans, &map);
let indices = project_match_indices(stripped.as_ref(), &context.matches, &map);
(visible_spans, Matches::CharIndices(indices))
} else {
(all_spans, context.matches.clone())
};
// Now apply highlighting based on matched positions
// We need to map match positions from stripped text to original text
match context.matches {
match matches {
crate::Matches::CharIndices(ref indices) => {
// Indices are already in stripped text coordinates (same as parsed ANSI text)
// No need to remap since both matching and ANSI parsing strip the codes
@ -446,6 +507,19 @@ impl SkimItem for DefaultSkimItem {
}
crate::Matches::None => Line::from(all_spans),
}
} else if let Some(hidden) = self.hidden_ranges() {
// Non-ANSI hidden path: remove the hidden characters and remap the match
// highlight positions into the visible coordinate space.
let (visible, map) = project_visible_text(&self.text, hidden);
let indices = project_match_indices(&self.text, &context.matches, &map);
DisplayContext {
score: context.score,
matches: Matches::CharIndices(indices),
container_width: context.container_width,
base_style: context.base_style,
matched_style: context.matched_style,
}
.to_line(Cow::Owned(visible))
} else {
// No ANSI mapping needed, use text as-is
context.to_line(Cow::Borrowed(&self.text))
@ -551,6 +625,110 @@ fn escape_ansi(raw: &str) -> String {
unsafe { String::from_utf8_unchecked(raw.bytes().map(|b| if b == 27 { b'?' } else { b }).collect()) }
}
/// Sort and merge a list of byte ranges into a canonical, non-overlapping form.
///
/// Empty ranges are dropped. Overlapping or touching ranges are merged so callers
/// can iterate the result assuming disjoint, ascending ranges.
#[must_use]
pub(crate) fn normalize_ranges(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
let mut sorted: Vec<(usize, usize)> = ranges.iter().copied().filter(|(s, e)| e > s).collect();
sorted.sort_unstable();
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(sorted.len());
for (start, end) in sorted {
if let Some(last) = merged.last_mut()
&& start <= last.1
{
last.1 = last.1.max(end);
} else {
merged.push((start, end));
}
}
merged
}
/// Remove the hidden byte ranges from `text`, returning the visible string and a
/// map from each original char index to its `Some(visible char index)`, or `None`
/// when that char falls inside a hidden range.
///
/// `hidden` must be normalized (see [`normalize_ranges`]): sorted, disjoint byte ranges.
#[must_use]
pub(crate) fn project_visible_text(text: &str, hidden: &[(usize, usize)]) -> (String, Vec<Option<usize>>) {
let mut visible = String::with_capacity(text.len());
let mut map = Vec::new();
let mut vis_idx = 0usize;
let mut hi = 0usize;
for (byte_pos, ch) in text.char_indices() {
while hi < hidden.len() && byte_pos >= hidden[hi].1 {
hi += 1;
}
let is_hidden = hi < hidden.len() && byte_pos >= hidden[hi].0 && byte_pos < hidden[hi].1;
if is_hidden {
map.push(None);
} else {
map.push(Some(vis_idx));
visible.push(ch);
vis_idx += 1;
}
}
(visible, map)
}
/// Drop hidden characters from already-parsed styled spans while preserving each
/// span's style, keeping the ANSI colors of the surviving characters intact.
///
/// `map` is the per-char index map produced by [`project_visible_text`]; the spans
/// are iterated in the same (stripped-text) char order the map is indexed by. Spans
/// that become empty after filtering are dropped.
#[must_use]
pub(crate) fn retain_visible_spans(spans: Vec<Span<'_>>, map: &[Option<usize>]) -> Vec<Span<'static>> {
let mut out = Vec::with_capacity(spans.len());
let mut char_idx = 0usize;
for span in spans {
let mut content = String::new();
for ch in span.content.chars() {
if map.get(char_idx).copied().flatten().is_some() {
content.push(ch);
}
char_idx += 1;
}
if !content.is_empty() {
out.push(Span::styled(content, span.style));
}
}
out
}
/// Convert the matched character positions of `matches` (in full-text coordinates)
/// into visible-text char indices, dropping any that fall inside hidden ranges.
///
/// `map` is the per-char index map produced by [`project_visible_text`]. The result
/// is sorted ascending and deduplicated, ready to feed a `Matches::CharIndices`.
#[must_use]
pub(crate) fn project_match_indices(text: &str, matches: &Matches, map: &[Option<usize>]) -> Vec<usize> {
let full_indices: Vec<usize> = match matches {
Matches::CharIndices(indices) => indices.clone(),
Matches::CharRange(start, end) => (*start..*end).collect(),
Matches::ByteRange(start, end) => text
.char_indices()
.enumerate()
.filter(|(_, (byte_pos, _))| *byte_pos >= *start && *byte_pos < *end)
.map(|(char_idx, _)| char_idx)
.collect(),
Matches::None => Vec::new(),
};
let mut visible: Vec<usize> = full_indices
.into_iter()
.filter_map(|ci| map.get(ci).copied().flatten())
.collect();
visible.sort_unstable();
visible.dedup();
visible
}
#[cfg(test)]
#[path = "item_tests.rs"]
mod test;

View file

@ -30,6 +30,7 @@ pub struct SkimItemReaderOption {
use_ansi_color: bool,
transform_fields: Vec<FieldRange>,
matching_fields: Vec<FieldRange>,
hidden_fields: Vec<FieldRange>,
delimiter: Regex,
line_ending: u8,
show_error: bool,
@ -44,6 +45,7 @@ impl Default for SkimItemReaderOption {
use_ansi_color: false,
transform_fields: Vec::new(),
matching_fields: Vec::new(),
hidden_fields: Vec::new(),
delimiter: Regex::new(DELIMITER_STR).unwrap(),
show_error: false,
disable_pattern: None,
@ -69,6 +71,11 @@ impl SkimItemReaderOption {
.iter()
.filter_map(|f| if f.is_empty() { None } else { FieldRange::from_str(f) })
.collect(),
hidden_fields: options
.hide_nth
.iter()
.filter_map(|f| if f.is_empty() { None } else { FieldRange::from_str(f) })
.collect(),
delimiter: options.delimiter.clone(),
show_error: options.show_cmd_error,
disable_pattern: options.disable_pattern.clone(),
@ -137,6 +144,23 @@ impl SkimItemReaderOption {
self
}
/// Sets the fields to hide from display (while keeping them searchable)
#[must_use]
pub fn hide_nth<'a, T>(mut self, hide_nth: T) -> Self
where
T: Iterator<Item = &'a str>,
{
self.hidden_fields = hide_nth.filter_map(FieldRange::from_str).collect();
self
}
/// Sets the hidden fields directly
#[must_use]
pub fn hidden_fields(mut self, hidden_fields: Vec<FieldRange>) -> Self {
self.hidden_fields = hidden_fields;
self
}
/// Enables reading null-terminated lines instead of newline-terminated
#[must_use]
pub fn read0(mut self, enable: bool) -> Self {
@ -241,16 +265,13 @@ impl SkimItemReader {
/// 1. **I/O thread** (dedicated) — reads large byte chunks (~256 KB) from
/// `source`, splitting on line boundaries, and sends them tagged with
/// monotonic sequence numbers into a bounded channel.
/// 2. **Dispatcher thread** (dedicated, lightweight) — drains that channel
/// and submits one pool job per chunk. The bounded channel provides
/// natural back-pressure on the I/O thread when the pool is busy.
/// 2. **Bounded dispatcher** — submits chunk jobs to the pool while a token
/// limit bounds queued and running work. It stops draining the input
/// channel when that limit is reached, which applies back-pressure to I/O.
/// 3. **Pool jobs** — parse lines, validate UTF-8, apply ANSI stripping and
/// field transforms, and create `DefaultSkimItem` + `Arc` per line.
/// Because these jobs share the same pool as the matcher, reader and
/// matcher compete for the same thread budget rather than over-subscribing
/// available CPU cores.
/// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from pool
/// jobs and emits them in sequence order so downstream index assignment
/// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from workers
/// and emits them in sequence order so downstream index assignment
/// and `--tac` behaviour are correct.
///
/// When `child` is `Some`, a **killer thread** is also spawned. It waits
@ -279,18 +300,27 @@ impl SkimItemReader {
// Stage 1: I/O thread.
Self::spawn_io_reader(source, tx_chunks, line_ending);
// Stage 2: dispatcher thread — bridges the bounded channel to the pool.
// Stage 2: dispatch at most a fixed number of queued or running jobs.
// A worker returns its token only after it sends the parsed result.
let max_in_flight = num_threads * 4;
let (tx_permits, rx_permits) = std::sync::mpsc::sync_channel(max_in_flight);
for _ in 0..max_in_flight {
tx_permits.send(()).expect("permit receiver is alive");
}
thread::spawn(move || {
while let Ok((seq, chunk)) = rx_chunks.recv() {
if rx_permits.recv().is_err() {
break;
}
let tx = tx_results.clone();
let return_permit = tx_permits.clone();
let opt = option.clone();
pool.spawn(move || {
let result = Self::process_chunk(seq, &chunk, &opt);
let _ = tx.send(result);
let _ = return_permit.send(());
});
}
// rx_chunks closed → all chunks dispatched; tx_results dropped here
// so the reorder thread exits once the last pool job finishes.
});
// A zero-capacity channel used as a completion signal: the reorder
@ -441,7 +471,8 @@ impl SkimItemReader {
&opt.transform_fields,
&opt.matching_fields,
&opt.delimiter,
);
)
.hidden_fields(&opt.hidden_fields, &opt.delimiter);
if opt.disable_pattern.as_ref().is_some_and(|re| re.is_match(line)) {
item.disable();
}

View file

@ -487,3 +487,171 @@ fn test_display_ansi_item_with_no_matches() {
assert!(text.contains("red"));
assert!(text.contains("text"));
}
#[test]
fn test_normalize_ranges_sorts_and_merges() {
// Overlapping and touching ranges are merged; empty ranges dropped; result sorted.
assert_eq!(normalize_ranges(&[(5, 8), (0, 3)]), vec![(0, 3), (5, 8)]);
assert_eq!(normalize_ranges(&[(0, 4), (2, 6)]), vec![(0, 6)]);
assert_eq!(normalize_ranges(&[(0, 3), (3, 6)]), vec![(0, 6)]);
assert_eq!(normalize_ranges(&[(2, 2), (0, 1)]), vec![(0, 1)]);
assert!(normalize_ranges(&[]).is_empty());
}
#[test]
fn test_project_visible_text_removes_hidden_ranges() {
// Hide bytes 6..10 ("RED ") from "apple RED 001".
let (visible, map) = project_visible_text("apple RED 001", &[(6, 10)]);
assert_eq!(visible, "apple 001");
// Chars 0..6 ("apple ") map to themselves, 6..10 ("RED ") are hidden,
// and the trailing "001" is shifted left by 4 positions.
assert_eq!(map[0], Some(0)); // 'a'
assert_eq!(map[5], Some(5)); // ' '
assert_eq!(map[6], None); // 'R'
assert_eq!(map[9], None); // ' '
assert_eq!(map[10], Some(6)); // '0'
assert_eq!(map[12], Some(8)); // '1'
}
#[test]
fn test_project_match_indices_drops_hidden_and_remaps() {
use crate::Matches;
let (_visible, map) = project_visible_text("apple RED 001", &[(6, 10)]);
// A match spanning both a visible char ('e' at 4) and hidden chars (7,8) keeps
// only the visible one, remapped into visible coordinates (unchanged here).
let indices = project_match_indices("apple RED 001", &Matches::CharIndices(vec![4, 7, 8]), &map);
assert_eq!(indices, vec![4]);
// A byte range covering "001" (bytes 10..13) maps to visible chars 6,7,8.
let indices = project_match_indices("apple RED 001", &Matches::ByteRange(10, 13), &map);
assert_eq!(indices, vec![6, 7, 8]);
// A match entirely inside the hidden field yields nothing.
let indices = project_match_indices("apple RED 001", &Matches::CharRange(6, 9), &map);
assert!(indices.is_empty());
}
#[test]
fn test_hidden_ranges_keep_text_searchable() {
use crate::field::FieldRange;
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Hide field 2 ("RED") but keep it searchable.
let item = DefaultSkimItem::new("apple RED 001", false, &[], &[], &delimiter)
.hidden_fields(&[FieldRange::Single(2)], &delimiter);
// text() (used for matching) retains the hidden field, so it stays searchable.
assert_eq!(item.text(), "apple RED 001");
// hidden_ranges exposes the field's byte range (including its trailing delimiter).
assert_eq!(item.hidden_ranges(), Some(&[(6, 10)][..]));
}
#[test]
fn test_hidden_field_removed_from_display() {
use crate::field::FieldRange;
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::Style;
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
let item = DefaultSkimItem::new("apple RED 001", false, &[], &[], &delimiter)
.hidden_fields(&[FieldRange::Single(2)], &delimiter);
let context = DisplayContext {
score: 0,
matches: Matches::None,
container_width: 80,
base_style: Style::default(),
matched_style: Style::default(),
};
let line = item.display(context);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
// The hidden field is gone from what is displayed, but the rest remains.
assert_eq!(rendered, "apple 001");
assert!(!rendered.contains("RED"));
}
#[test]
fn test_hidden_field_match_not_highlighted() {
use crate::field::FieldRange;
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
let item = DefaultSkimItem::new("apple RED 001", false, &[], &[], &delimiter)
.hidden_fields(&[FieldRange::Single(2)], &delimiter);
// Simulate a match on the hidden "RED" (chars 6,7,8 in the full text).
let context = DisplayContext {
score: 0,
matches: Matches::CharIndices(vec![6, 7, 8]),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().bg(Color::Yellow),
};
let line = item.display(context);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(rendered, "apple 001");
// No span carries the highlight background, since the matched chars are hidden.
assert!(line.spans.iter().all(|span| span.style.bg != Some(Color::Yellow)));
}
#[test]
fn test_hidden_field_preserves_ansi_colors() {
use crate::field::FieldRange;
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
// Two colored, space-separated fields: green "one" and red "two".
let item = DefaultSkimItem::new(
"\x1b[32mone\x1b[0m \x1b[31mtwo\x1b[0m",
true, // ansi_enabled
&[],
&[],
&delimiter,
)
.hidden_fields(&[FieldRange::Single(1)], &delimiter); // hide the first (green) field
// The hidden field is still part of the matchable text.
assert_eq!(item.text(), "one two");
let context = DisplayContext {
score: 0,
matches: Matches::None,
container_width: 80,
base_style: Style::default(),
matched_style: Style::default(),
};
let line = item.display(context);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
// "one " (field 1 plus its trailing delimiter) is gone; "two" remains.
assert_eq!(rendered, "two");
// The surviving field keeps its ANSI red foreground; the hidden green is gone.
assert!(
line.spans.iter().any(|span| span.style.fg == Some(Color::Red)),
"surviving field should keep its ANSI red foreground"
);
assert!(
line.spans.iter().all(|span| span.style.fg != Some(Color::Green)),
"hidden field's ANSI green foreground should not appear"
);
}
#[test]
fn test_hidden_field_ansi_highlight_remapped() {
use crate::field::FieldRange;
use crate::{DisplayContext, Matches, SkimItem};
use ratatui::style::{Color, Style};
use regex::Regex;
let delimiter = Regex::new(r"\s+").unwrap();
let item = DefaultSkimItem::new("\x1b[32mone\x1b[0m \x1b[31mtwo\x1b[0m", true, &[], &[], &delimiter)
.hidden_fields(&[FieldRange::Single(1)], &delimiter); // hide green "one"
// Match "two" — chars 4,5,6 in the full stripped text "one two".
let context = DisplayContext {
score: 0,
matches: Matches::CharIndices(vec![4, 5, 6]),
container_width: 80,
base_style: Style::default(),
matched_style: Style::default().bg(Color::Yellow),
};
let line = item.display(context);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(rendered, "two");
// The match on the visible field is highlighted (remapped to visible coords 0..3)
// while its ANSI red foreground is preserved alongside the highlight background.
assert!(line.spans.iter().any(|span| span.style.bg == Some(Color::Yellow)));
assert!(line.spans.iter().any(|span| span.style.fg == Some(Color::Red)));
}

View file

@ -24,12 +24,14 @@ use tokio::sync::Notify;
#[derive(Debug)]
pub struct RankBuilder {
criterion: Vec<RankCriteria>,
tac: bool,
}
impl Default for RankBuilder {
fn default() -> Self {
Self {
criterion: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
tac: false,
}
}
}
@ -43,7 +45,13 @@ impl RankBuilder {
}
criterion.dedup();
Self { criterion }
Self { criterion, tac: false }
}
#[must_use]
pub(crate) fn tac(mut self, tac: bool) -> Self {
self.tac = tac;
self
}
/// Returns the tiebreak criteria slice.
@ -52,11 +60,40 @@ impl RankBuilder {
&self.criterion
}
/// Computes the byte offset of the first character after the last path separator
/// (`/` or `\`) in `text`. Returns `0` when no separator is present.
fn sort_key(&self, rank: &Rank) -> [i32; 6] {
let configured = rank.sort_key(&self.criterion);
let mut key = [0; 6];
key[..5].copy_from_slice(&configured);
if self.tac {
for (priority, criterion) in self.criterion.iter().take(5).enumerate() {
key[priority] = match criterion {
RankCriteria::Index => rank.index.saturating_neg(),
RankCriteria::NegIndex => rank.index,
_ => key[priority],
};
}
}
key[5] = if self.tac {
rank.index.saturating_neg()
} else {
rank.index
};
key
}
/// Computes the **character** index of the first character after the last path
/// separator (`/` or `\`) in `text`. Returns `0` when no separator is present.
///
/// This must be a char index, not a byte offset: the `PathName` tiebreak
/// subtracts it from [`Rank::begin`], which is a char index, so counting bytes
/// here would mix units and mis-rank any path with a non-ASCII component.
fn path_name_offset(text: &str) -> i32 {
text.rfind(['/', '\\'])
.map_or(0, |pos| i32::try_from(pos).unwrap_or(i32::MAX).saturating_add(1))
text.rfind(['/', '\\']).map_or(0, |pos| {
i32::try_from(text[..pos].chars().count())
.unwrap_or(i32::MAX)
.saturating_add(1)
})
}
/// Builds a `Rank` from raw match measurements.
@ -121,8 +158,9 @@ pub struct MatchedItem {
/// Range of characters that matched the pattern
pub matched_range: Option<MatchRange>,
/// Sort key precomputed at construction time from `rank` and the tiebreak
/// criteria. Caching avoids recomputing it on every comparison during sort.
sort_key: [i32; 5],
/// criteria. The sixth slot is the tac-aware implicit index tiebreak.
/// Caching avoids recomputing it on every comparison during sort.
sort_key: [i32; 6],
}
impl std::fmt::Debug for MatchedItem {
@ -162,7 +200,7 @@ impl MatchedItem {
item,
rank,
matched_range,
sort_key: rank.sort_key(rank_builder.criteria()),
sort_key: rank_builder.sort_key(&rank),
}
}
/// Merge two sorted `Vec<MatchedItem>` lists into one, preserving sort order by rank.
@ -359,9 +397,7 @@ impl PartialOrd for MatchedItem {
impl Ord for MatchedItem {
fn cmp(&self, other: &Self) -> CmpOrd {
self.sort_key
.cmp(&other.sort_key)
.then_with(|| self.rank.index.cmp(&other.rank.index))
self.sort_key.cmp(&other.sort_key)
}
}

View file

@ -37,6 +37,33 @@ fn build_rank_records_offsets_and_pathname() {
assert_eq!(rank.path_name_offset, i32::try_from("src/lib/".len()).unwrap());
}
#[test]
fn path_name_offset_counts_chars_not_bytes() {
// `path_name_offset` is subtracted from `Rank::begin`, which is a char index,
// so a multi-byte directory component must not inflate it.
let rb = RankBuilder::default();
// "ééé/" is 4 chars but 7 bytes; the filename starts at char index 4.
let rank = rb.build_rank(0, 4, 5, "ééé/a");
assert_eq!(rank.path_name_offset, 4);
// With the match sitting on the filename, PathName must score it as 0 (best).
// A byte-based offset would give 7 - 4 = 3 and rank it below a plain match.
assert_eq!(rank.sort_key(&[RankCriteria::PathName])[0], 0);
}
#[test]
fn pathname_tiebreak_prefers_filename_match_with_non_ascii_dir() {
// "ééééé/a" matches in the filename (best), "a/xxxxx" matches in the dir part.
let rb = RankBuilder::new(vec![RankCriteria::PathName, RankCriteria::Index]);
let in_filename = MatchedItem::new(item("ééééé/a"), rb.build_rank(0, 6, 7, "ééééé/a"), None, &rb);
let in_dir = MatchedItem::new(item("a/xxxxx"), rb.build_rank(0, 0, 1, "a/xxxxx"), None, &rb);
assert!(
in_filename < in_dir,
"a filename match must outrank a directory match even when the directory is non-ASCII"
);
}
#[test]
fn sort_key_flips_score_sign() {
let rank = Rank {
@ -59,6 +86,57 @@ fn matched_item_ordering_prefers_higher_score() {
assert!(high < low);
}
#[test]
fn matched_item_ordering_reverses_stable_input_index_for_tac() {
let old_rank = Rank {
index: 0,
..Default::default()
};
let new_rank = Rank {
index: 1,
..Default::default()
};
let normal_builder = RankBuilder::new(vec![RankCriteria::Index]);
let old = MatchedItem::new(item("old"), old_rank, None, &normal_builder);
let new = MatchedItem::new(item("new"), new_rank, None, &normal_builder);
assert!(old < new);
let tac_builder = RankBuilder::new(vec![RankCriteria::Index]).tac(true);
let old = MatchedItem::new(item("old"), old_rank, None, &tac_builder);
let new = MatchedItem::new(item("new"), new_rank, None, &tac_builder);
assert!(new < old);
assert_eq!(new.rank.index, 1);
assert_eq!(MatchedItem::sorted_merge(vec![old], vec![new])[0].text(), "new");
let tac_builder = RankBuilder::default().tac(true);
let old = MatchedItem::new(item("old"), old_rank, None, &tac_builder);
let new = MatchedItem::new(item("new"), new_rank, None, &tac_builder);
assert!(new < old);
}
#[test]
fn sorted_merge_tac_places_newer_incremental_batch_first() {
let rank_builder = RankBuilder::default().tac(true);
let make = |text: &str, index| {
MatchedItem::new(
item(text),
Rank {
index,
..Default::default()
},
None,
&rank_builder,
)
};
let existing = vec![make("c", 2), make("b", 1), make("a", 0)];
let incoming = vec![make("e", 4), make("d", 3)];
let merged = MatchedItem::sorted_merge(existing, incoming);
let indexes: Vec<_> = merged.iter().map(|item| item.rank.index).collect();
assert_eq!(indexes, [4, 3, 2, 1, 0]);
}
#[test]
fn sorted_merge_handles_empty_inputs() {
let a = vec![matched("a", 0, 10)];

View file

@ -1,12 +1,16 @@
//! Provides what's needed to generate skim's man page
use std::fmt::Write as _;
use std::io::Write;
use clap::CommandFactory;
use clap_mangen::Man;
use color_eyre::eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use eyre::Result;
use roff::{Inline, Roff};
use crate::SkimOptions;
use crate::binds::{SkimEvent, get_default_key_map};
use crate::tui::actions::{ACTION_CATALOG, Action};
const THEME_SECTION: &str = "
Available themes:
@ -119,70 +123,30 @@ const KEYS_SS: &str = "
* alt-shift-down
* alt-shift-left
* alt-shift-right
* double-click
* any single character
";
const ACTIONS_SS: &str = "
* 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
* backward-delete-char: ctrl-h bspace
* backward-delete-char/eof
* backward-kill-word: alt-bs
* backward-word: alt-b shift-left
* beginning-of-line: ctrl-a home
* clear-screen: ctrl-l
* delete-char: del
* delete-char/eof: ctrl-d
* deselect-all
* down: ctrl-j ctrl-n down
* end-of-line: ctrl-e end
* execute(...): *arg will be a command, see COMMAND EXPANSION for details
* execute-silent(...): *arg will be a command, see COMMAND EXPANSION for details
* forward-char: ctrl-f right
* forward-word: alt-f shift-right
* if-non-matched
* if-query-empty
* if-query-not-empty
* ignore
* kill-line
* kill-word: alt-d
* next-history: ctrl-n with `--history` or `--cmd-history`
* page-down: pgdn
* page-up: pgup
* half-page-down
* half-page-up
* preview-up: shift-up
* preview-down: shift-down
* preview-left
* preview-right
* preview-page-down
* preview-page-up
* previous-history: ctrl-p with `--history` or `--cmd-history`
* redraw
* refresh-cmd
* refresh-preview
* reload(...)
* select-all
* 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
* toggle-all
* toggle+down: ctrl-i tab
* toggle-in: (--layout=reverse ? toggle+up: toggle+down)
* toggle-interactive
* toggle-out: (--layout=reverse ? toggle+down: toggle+up)
* toggle-preview
* toggle-preview-wrap
* toggle-sort
* toggle+up: btab shift-tab
* top
* unix-line-discard: ctrl-u
* unix-word-rubout: ctrl-w
* up: ctrl-k ctrl-p up
* yank: ctrl-y
";
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",
);
#[cfg(feature = "listen")]
const REMOTE_SECTION: &str = "
@ -196,6 +160,87 @@ such as `socat` on linux: `echo 'ToggleIn' | socat -u STDIN ABSTRACT-CONNECT:opt
When using `sk --remote`, pipe in action chains (see the KEYBINDS section), for instance `echo 'up+select-row' | sk --remote optional_address`
";
/// Renders the list of bindable actions from the action catalog.
///
/// The list is generated from `define_action_catalog!` in `src/tui/actions.rs`,
/// so a new action shows up here (with its doc comment) automatically.
fn actions_ss() -> String {
let mut res = String::from("\n");
for action in ACTION_CATALOG.iter().filter(|action| action.is_bindable()) {
let _ = writeln!(res, "* {}: {}", action.display_name(), action.summary());
}
res
}
/// Renders the runtime default keymap, keeping the manpage in sync with
/// [`get_default_key_map`].
fn default_keys_ss() -> String {
let mut bindings = get_default_key_map()
.iter()
.map(|(key, actions)| {
let actions = actions.iter().map(Action::name).collect::<Vec<_>>().join("+");
(key_name(key), actions)
})
.collect::<Vec<_>>();
bindings.sort_unstable_by(|left, right| left.0.cmp(&right.0));
let mut res = String::from("\n");
for (key, actions) in bindings {
let _ = writeln!(res, "* {key}: {actions}");
}
res
}
fn key_name(key: &KeyEvent) -> String {
if *key == SkimEvent::DoubleClick.key_event() {
return "double-click".to_string();
}
// Crossterm can report back-tab with every modifier set. Keep the familiar
// binding spelling instead of exposing that terminal representation.
if key.code == KeyCode::BackTab && key.modifiers == KeyModifiers::all() {
return "btab".to_string();
}
let mut parts = Vec::new();
for (modifier, name) in [
(KeyModifiers::CONTROL, "ctrl"),
(KeyModifiers::ALT, "alt"),
(KeyModifiers::SHIFT, "shift"),
(KeyModifiers::SUPER, "super"),
(KeyModifiers::HYPER, "hyper"),
(KeyModifiers::META, "meta"),
] {
if key.modifiers.contains(modifier) {
parts.push(name.to_string());
}
}
parts.push(match key.code {
KeyCode::Backspace => "bspace".to_string(),
KeyCode::Enter => "enter".to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
KeyCode::Up => "up".to_string(),
KeyCode::Down => "down".to_string(),
KeyCode::Home => "home".to_string(),
KeyCode::End => "end".to_string(),
KeyCode::PageUp => "pgup".to_string(),
KeyCode::PageDown => "pgdn".to_string(),
KeyCode::Tab => "tab".to_string(),
KeyCode::BackTab => "btab".to_string(),
KeyCode::Delete => "del".to_string(),
KeyCode::Insert => "insert".to_string(),
KeyCode::F(number) => format!("f{number}"),
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(character) => character.to_string(),
KeyCode::Null => "null".to_string(),
KeyCode::Esc => "esc".to_string(),
_ => format!("{:?}", key.code).to_lowercase(),
});
parts.join("-")
}
fn parse_str(src: &str) -> Vec<Inline> {
let mut res = Vec::new();
for line in src.lines() {
@ -282,13 +327,19 @@ 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, "Actions[:default keys][*notes]", ACTIONS_SS);
subsection(&mut custom, "Bindable finder events", BINDABLE_EVENTS_SS);
subsection(&mut custom, "Actions as binding triggers", ACTION_BINDINGS_SS);
subsection(&mut custom, "Actions", &actions_ss());
subsection(&mut custom, "Default key bindings", &default_keys_ss());
section(
&mut custom,
@ -405,4 +456,54 @@ mod tests {
assert!(out.contains(section), "manpage should contain section '{section}'");
}
}
#[test]
fn manpage_documents_default_key_bindings() {
let out = manpage_str();
assert!(out.contains("Default key bindings"));
for binding in [
"* ctrl\\-a: beginning\\-of\\-line",
"* enter: accept",
"* tab: toggle+down",
"* double\\-click: accept",
] {
assert!(
out.contains(binding),
"manpage should contain default binding '{binding}'"
);
}
}
#[test]
fn manpage_documents_every_bindable_action() {
let out = manpage_str();
for action in ACTION_CATALOG.iter().filter(|action| action.is_bindable()) {
// roff escapes dashes in the rendered output
let rendered = format!("* {}", action.display_name().replace('-', "\\-"));
assert!(
out.contains(&rendered),
"manpage should document the '{}' action",
action.name
);
assert!(
!action.summary().is_empty(),
"action '{}' needs a doc comment to document it",
action.name
);
}
// `custom` only exists for library users, it cannot be bound by name.
assert!(!out.contains("* custom"), "the custom action should not be listed");
}
#[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"));
}
}

View file

@ -2,14 +2,13 @@
use crate::thread_pool::{self, ThreadPool};
use crate::tui::item_list::{MergeStrategy, ProcessedItems};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use crate::engine::normalized::NormalizedEngineFactory;
use crate::engine::split::SplitMatchEngineFactory;
use crate::item::{ItemPool, MatchedItem, RankBuilder};
use crate::prelude::{AndOrEngineFactory, ExactOrFuzzyEngineFactory, RegexEngineFactory};
use crate::spinlock::SpinLock;
use crate::{CaseMatching, MatchEngineFactory, SkimItem, SkimOptions};
/// Merges per-worker match results and writes them into `processed_items`.
@ -21,48 +20,80 @@ use crate::{CaseMatching, MatchEngineFactory, SkimItem, SkimOptions};
/// this consistently outperforms tree-based or fold-based merge strategies
/// due to driftsort's cache-friendly single-buffer merge passes.
///
/// When `no_sort` is true, the worker results are simply flattened.
/// When `no_sort` is true, worker results arrive in chunk-index order and are
/// flattened without sorting.
///
/// Signals `needs_render` after writing so the UI picks up the new data.
fn input_index(tac: bool, start: usize, batch_len: usize, batch_index: usize) -> usize {
debug_assert!(batch_index < batch_len);
if tac {
start + batch_len - 1 - batch_index
} else {
start + batch_index
}
}
fn merge_worker_results(
worker_results: Vec<Vec<MatchedItem>>,
no_sort: bool,
processed_items: &SpinLock<Option<ProcessedItems>>,
processed_items: &Mutex<Option<ProcessedItems>>,
merge_strategy: MergeStrategy,
generation: usize,
current_generation: &AtomicUsize,
needs_render: &AtomicBool,
) {
if current_generation.load(Ordering::Acquire) != generation {
return;
}
let total_len: usize = worker_results.iter().map(Vec::len).sum();
let mut items = Vec::with_capacity(total_len);
for chunk in worker_results {
items.extend(chunk);
}
// Each worker's sub-list is already sorted by `prepare`, so the
// concatenated Vec consists of k sorted runs. Rust's stable sort
// (driftsort since 1.81, a TimSort variant before that) detects
// pre-existing runs and merges them in O(n log k) for k workers,
// all on contiguous memory with a single auxiliary buffer.
if !no_sort {
// Each worker's sub-list is already sorted by `prepare`, so stable
// sort detects the pre-existing runs and merges them efficiently.
items.sort();
}
trace!("matcher stop, total matched: {}", items.len());
// Single lock, single write into processed_items.
let mut guard = processed_items.lock();
// Validate while holding the result lock so an old matcher cannot overwrite
// results that belong to a newer query generation.
let mut guard = processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if current_generation.load(Ordering::Acquire) != generation {
return;
}
if matches!(merge_strategy, MergeStrategy::Replace) {
*guard = Some(ProcessedItems {
items,
merge: MergeStrategy::Replace,
generation,
});
drop(guard);
needs_render.store(true, Ordering::Relaxed);
return;
}
match &mut *guard {
Some(existing) if existing.generation != generation => {
*guard = Some(ProcessedItems {
items,
merge: MergeStrategy::Replace,
generation,
});
}
Some(existing) => {
if no_sort {
existing.items.extend(items);
if matches!(merge_strategy, MergeStrategy::Prepend) {
items.append(&mut existing.items);
existing.items = items;
} else {
existing.items.extend(items);
}
} else {
// Both sides are fully sorted — one O(n+m) merge.
MatchedItem::merge_into_sorted(&mut existing.items, items);
@ -72,6 +103,7 @@ fn merge_worker_results(
*guard = Some(ProcessedItems {
items,
merge: merge_strategy,
generation,
});
}
}
@ -188,15 +220,16 @@ impl Matcher {
#[must_use]
pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
if options.regex {
let regex_factory = RegexEngineFactory::builder();
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
let regex_factory = RegexEngineFactory::builder().rank_builder(rank_builder.clone());
let factory: Rc<dyn MatchEngineFactory> = if options.normalize {
Rc::new(NormalizedEngineFactory::new(regex_factory))
} else {
Rc::new(regex_factory)
};
(factory, Arc::new(RankBuilder::default()))
(factory, rank_builder)
} else {
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()));
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
log::debug!("Creating matcher for algo {:?}", options.algorithm);
let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
.fuzzy_algorithm(options.algorithm)
@ -263,9 +296,12 @@ impl Matcher {
query: &str,
item_pool: &Arc<ItemPool>,
thread_pool: &Arc<ThreadPool>,
processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
processed_items: Arc<Mutex<Option<ProcessedItems>>>,
merge_strategy: MergeStrategy,
no_sort: bool,
tac: bool,
generation: usize,
current_generation: Arc<AtomicUsize>,
needs_render: Arc<AtomicBool>,
) -> MatcherControl {
let matcher_engine = self.engine_factory.create_engine_with_case(query, self.case_matching);
@ -321,6 +357,7 @@ impl Matcher {
num_workers,
&shared_items,
CHUNK_SIZE,
no_sort,
// identity seed value for each worker's local accumulator
Vec::<MatchedItem>::new,
// process_chunk called for each chunk; returns a Vec of matches
@ -337,7 +374,10 @@ impl Matcher {
if let Some(match_result) = matcher_engine.match_item(item.as_ref()) {
chunk_matched += 1;
let mut rank = match_result.rank;
let index = chunk_start + i + start;
let batch_index = chunk_start + i;
// `take()` reverses each tac batch, so recover the
// item's stable ordinal in the original input stream.
let index = input_index(tac, start, total, batch_index);
rank.index = i32::try_from(index).unwrap_or(i32::MAX);
local_matches.push(MatchedItem::new(
Arc::clone(item),
@ -375,11 +415,7 @@ impl Matcher {
// chunk order), so driftsort's run-detection overhead is pure
// cost. The final merge uses sort() so that driftsort can
// exploit the k sorted runs produced by the workers.
move |acc: &mut Vec<MatchedItem>| {
if !no_sort {
acc.sort_unstable();
}
},
|acc: &mut Vec<MatchedItem>| acc.sort_unstable(),
// merge concat pre-sorted worker results and sort().
// Rust's stable sort detects the k sorted runs and merges
// them in O(n log k), then writes into processed_items.
@ -388,7 +424,15 @@ impl Matcher {
return;
}
merge_worker_results(worker_results, no_sort, &processed_items, merge_strategy, &needs_render);
merge_worker_results(
worker_results,
no_sort,
&processed_items,
merge_strategy,
generation,
&current_generation,
&needs_render,
);
},
);
stopped.store(true, Ordering::Relaxed);
@ -456,26 +500,135 @@ mod tests {
assert!(engine.match_item(&"foobar".to_string()).is_some());
}
#[test]
fn regex_factory_uses_configured_tiebreak() {
let options = SkimOptionsBuilder::default()
.regex(true)
.tiebreak(vec![crate::RankCriteria::Length])
.build()
.unwrap();
let (factory, rank_builder) = Matcher::create_engine_factory_with_builder(&options);
let engine = factory.create_engine("a");
let mut matches: Vec<_> = ["aaaa", "a", "aaa"]
.into_iter()
.enumerate()
.map(|(index, text)| {
let item: Arc<dyn SkimItem> = Arc::new(text.to_string());
let mut result = engine.match_item(item.as_ref()).unwrap();
result.rank.index = i32::try_from(index).unwrap();
MatchedItem::new(item, result.rank, Some(result.matched_range), &rank_builder)
})
.collect();
matches.sort();
let output: Vec<_> = matches.iter().map(|item| item.text().into_owned()).collect();
assert_eq!(output, ["a", "aaa", "aaaa"]);
}
fn merge_test_results(
worker_results: Vec<Vec<MatchedItem>>,
no_sort: bool,
processed_items: &Mutex<Option<ProcessedItems>>,
merge_strategy: MergeStrategy,
needs_render: &AtomicBool,
) {
let generation = AtomicUsize::new(0);
merge_worker_results(
worker_results,
no_sort,
processed_items,
merge_strategy,
0,
&generation,
needs_render,
);
}
#[test]
fn merge_worker_results_replace_sorts() {
let processed = SpinLock::new(None);
let processed = Mutex::new(None);
let needs_render = AtomicBool::new(false);
let workers = vec![vec![matched("b", 1)], vec![matched("a", 0)]];
merge_worker_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
merge_test_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
assert!(needs_render.load(Ordering::Relaxed));
let guard = processed.lock();
let guard = processed.lock().unwrap();
let items = &guard.as_ref().unwrap().items;
assert_eq!(items.len(), 2);
}
#[test]
fn stale_generation_cannot_publish_results() {
let processed = Mutex::new(None);
let needs_render = AtomicBool::new(false);
let generation = AtomicUsize::new(2);
merge_worker_results(
vec![vec![matched("stale", 0)]],
false,
&processed,
MergeStrategy::Replace,
1,
&generation,
&needs_render,
);
assert!(processed.lock().unwrap().is_none());
assert!(!needs_render.load(Ordering::Relaxed));
}
#[test]
fn current_results_replace_pending_stale_generation() {
let processed = Mutex::new(Some(ProcessedItems {
items: vec![matched("stale", 0)],
merge: MergeStrategy::SortedMerge,
generation: 0,
}));
let needs_render = AtomicBool::new(false);
let generation = AtomicUsize::new(1);
merge_worker_results(
vec![vec![matched("current", 1)]],
false,
&processed,
MergeStrategy::SortedMerge,
1,
&generation,
&needs_render,
);
let guard = processed.lock().unwrap();
let result = guard.as_ref().unwrap();
assert_eq!(result.generation, 1);
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].text(), "current");
assert!(matches!(result.merge, MergeStrategy::Replace));
}
#[test]
fn merge_worker_results_no_sort_preserves_chunk_order() {
let processed = Mutex::new(None);
let needs_render = AtomicBool::new(false);
let workers = vec![
vec![matched("a", 0), matched("b", 1)],
vec![matched("c", 2), matched("d", 3)],
vec![matched("e", 4), matched("f", 5)],
];
merge_test_results(workers, true, &processed, MergeStrategy::Replace, &needs_render);
let guard = processed.lock().unwrap();
let items = &guard.as_ref().unwrap().items;
let indexes: Vec<i32> = items.iter().map(|item| item.rank.index).collect();
assert_eq!(indexes, vec![0, 1, 2, 3, 4, 5]);
}
#[test]
fn merge_worker_results_append_no_sort_extends_existing() {
let processed = SpinLock::new(None);
let processed = Mutex::new(None);
let needs_render = AtomicBool::new(false);
// First append establishes the existing list.
merge_worker_results(
merge_test_results(
vec![vec![matched("a", 0)]],
true,
&processed,
@ -483,7 +636,7 @@ mod tests {
&needs_render,
);
// Second append with no_sort extends the existing list in place.
merge_worker_results(
merge_test_results(
vec![vec![matched("b", 1)]],
true,
&processed,
@ -491,7 +644,49 @@ mod tests {
&needs_render,
);
let guard = processed.lock();
let guard = processed.lock().unwrap();
assert_eq!(guard.as_ref().unwrap().items.len(), 2);
}
#[test]
fn tac_input_index_spans_incremental_batches() {
let first_batch: Vec<_> = (0..3).map(|index| input_index(true, 0, 3, index)).collect();
let second_batch: Vec<_> = (0..2).map(|index| input_index(true, 3, 2, index)).collect();
assert_eq!(first_batch, [2, 1, 0]);
assert_eq!(second_batch, [4, 3]);
assert_eq!(input_index(false, 3, 2, 0), 3);
assert_eq!(input_index(false, 3, 2, 1), 4);
}
#[test]
fn merge_worker_results_prepend_no_sort_places_new_batch_first() {
let processed = Mutex::new(None);
let needs_render = AtomicBool::new(false);
merge_test_results(
vec![vec![matched("c", 2), matched("b", 1), matched("a", 0)]],
true,
&processed,
MergeStrategy::Prepend,
&needs_render,
);
merge_test_results(
vec![vec![matched("e", 4), matched("d", 3)]],
true,
&processed,
MergeStrategy::Prepend,
&needs_render,
);
let guard = processed.lock().unwrap();
let indexes: Vec<i32> = guard
.as_ref()
.unwrap()
.items
.iter()
.map(|item| item.rank.index)
.collect();
assert_eq!(indexes, [4, 3, 2, 1, 0]);
}
}

View file

@ -16,13 +16,24 @@ use crate::binds::KeyMap;
use crate::item::RankCriteria;
use crate::prelude::SkimItemReader;
use crate::reader::CommandCollector;
use crate::tui::event::Action;
use crate::tui::actions::Action;
use crate::tui::options::{PreviewLayout, TuiLayout};
use crate::tui::statusline::{Info, InfoDisplay};
use crate::tui::{BorderType, PreviewCallback};
use crate::util::read_file_lines;
use crate::{CaseMatching, FuzzyAlgorithm, Selector, Typos};
const MIN_HEIGHT_PARSE_ERROR: &str = "min-height needs to be a non-negative integer";
pub(crate) fn parse_min_height(s: &str) -> Result<u16, String> {
s.parse().map_err(|_| MIN_HEIGHT_PARSE_ERROR.to_string())
}
#[cfg(feature = "cli")]
fn parse_min_height_value(s: &str) -> Result<String, String> {
parse_min_height(s).map(|_| s.to_string())
}
#[cfg(feature = "cli")]
/// Custom value parser for delimiter that handles escape sequences
fn parse_delimiter_value(s: &str) -> Result<Regex, String> {
@ -196,10 +207,37 @@ pub struct SkimOptions {
/// See **nth** for the details
#[cfg_attr(
feature = "cli",
arg(long, default_value = "", help_heading = "Search", value_delimiter = ',')
arg(
long,
default_value = "",
help_heading = "Search",
value_delimiter = ',',
allow_hyphen_values = true,
)
)]
pub with_nth: Vec<String>,
/// Fields to hide from display while keeping them searchable
///
/// Takes the same comma-separated field index expressions as **nth**. The listed
/// fields are removed from the displayed line but remain part of the text used for
/// matching, so a query can still match them. Characters in the hidden fields are
/// ignored for match highlighting and horizontal scrolling.
///
/// See **nth** for the field index expression syntax.
#[cfg_attr(
feature = "cli",
arg(
long,
default_value = "",
help_heading = "Search",
verbatim_doc_comment,
value_delimiter = ',',
allow_hyphen_values = true,
)
)]
pub hide_nth: Vec<String>,
/// Delimiter between fields
///
/// In regex format, defaults to AWK-style. Escape sequences like \x00, \t, \n are supported.
@ -298,13 +336,16 @@ 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, the
/// `double-click` mouse binding, 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 and its
/// [Default key bindings] subsection for the defaults.
///
/// **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.
///
@ -472,13 +513,21 @@ pub struct SkimOptions {
#[cfg_attr(feature = "cli", arg(long, help_heading = "Layout"))]
pub no_height: bool,
/// Minimum height of skim's window
/// Minimum height of skim's window as a non-negative row count
///
/// Useful when the height is set as a percentage
/// Ignored when --height is not specified
/// Must be a non-negative row count, not a percentage.
/// Useful when the height is set as a percentage.
/// Ignored when --height is not specified.
#[cfg_attr(
feature = "cli",
arg(long, default_value = "10", help_heading = "Layout", verbatim_doc_comment)
arg(
long,
default_value = "10",
help_heading = "Layout",
allow_hyphen_values = true,
value_parser = parse_min_height_value,
verbatim_doc_comment
)
)]
pub min_height: String,
@ -561,6 +610,8 @@ pub struct SkimOptions {
/// - inline[:SEP] display info in the same row as the input with an optional non-default
/// separator
/// - default display info in a dedicated row above the input
/// - left display all info left-aligned in a dedicated row above the input
/// - right display all info right-aligned in a dedicated row above the input
/// - inline-right[:SEP] display info right-aligned in the same row as the input with an optional
/// non-default separator
#[cfg_attr(
@ -600,6 +651,10 @@ pub struct SkimOptions {
)]
pub border: BorderType,
/// Do not collapse adjacent borders into a shared row or column
#[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
pub border_no_collapse: bool,
/// Disables all borders, including in tmux/zellij popups
#[cfg_attr(feature = "cli", arg(long, help_heading = "Display", overrides_with = "border"))]
pub no_border: bool,
@ -672,9 +727,9 @@ pub struct SkimOptions {
// --- Preview ---
/// Preview command
///
/// Execute the given command for the current line and display the result on the preview window. {} in the command
/// is the placeholder that is replaced to the single-quoted string of the current line. To transform the
/// replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
/// Execute the given command with `sh -c` on linux and `cmd /c` on windows for the current line and display the result on the preview window.
/// `{}` in the command is the placeholder that is replaced to the single-quoted string of the current line.
/// To transform the replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
///
/// **Examples**:
///
@ -1085,6 +1140,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 {
@ -1109,6 +1172,7 @@ impl Default for SkimOptions {
tiebreak: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
nth: Default::default(),
with_nth: Default::default(),
hide_nth: Default::default(),
delimiter: Regex::new(r"[\t\n ]+").unwrap(),
exact: Default::default(),
regex: Default::default(),
@ -1185,6 +1249,7 @@ impl Default for SkimOptions {
filepath_word: Default::default(),
jump_labels: String::from("abcdefghijklmnopqrstuvwxyz"),
border: Default::default(),
border_no_collapse: Default::default(),
no_bold: Default::default(),
phony: Default::default(),
scheme: Default::default(),
@ -1238,6 +1303,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")]
@ -1279,10 +1345,18 @@ impl SkimOptions {
}
self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| {
res.add_keymaps(part.split(','));
res.add_keymaps_str(part);
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;
}

View file

@ -2,6 +2,7 @@
//! and history initialization, which apply defaults and cross-option rules.
use super::*;
use crate::field::FieldRange;
use crate::item::RankCriteria;
use crate::tui::statusline::InfoDisplay;
@ -21,6 +22,50 @@ fn merge(
.expect("options should parse")
}
#[test]
fn min_height_accepts_a_non_negative_row_count() {
assert_eq!(super::parse_min_height("60"), Ok(60));
assert_eq!(
SkimOptionsBuilder::default()
.min_height("60")
.build()
.unwrap()
.min_height,
"60"
);
}
#[test]
fn min_height_rejects_non_integers() {
for value in ["-1", "30%", "many"] {
assert_eq!(
super::parse_min_height(value),
Err("min-height needs to be a non-negative integer".to_string())
);
}
}
#[cfg(feature = "cli")]
#[test]
fn cli_min_height_uses_custom_error() {
for value in ["-1", "30%"] {
let error = SkimOptions::merge_args_and_parse(
"sk".to_string(),
None,
None,
["--min-height".to_string(), value.to_string()],
None,
)
.expect_err("invalid min-height must fail");
assert!(
error
.to_string()
.contains("min-height needs to be a non-negative integer")
);
}
}
#[test]
fn merge_uses_skim_default_command_when_no_cmd_flag() {
// SKIM_DEFAULT_COMMAND fills `cmd` when neither --cmd nor a pipe is given.
@ -287,3 +332,38 @@ fn build_history_file_adds_history_keybindings() {
let _ = std::fs::remove_file(&qpath);
}
/// Helper: parse real CLI args with no env influence.
fn parse_args(args: &[&str]) -> Result<SkimOptions, clap::Error> {
SkimOptions::merge_args_and_parse(
"sk".to_string(),
None,
None,
args.iter().map(|s| (*s).to_string()),
None,
)
}
#[test]
fn negative_field_indices_parse_for_every_nth_flag() {
// All three flags document the same `nth` syntax, which includes `-1` for the
// last field; a space-separated negative value must not be read as a flag.
for flag in ["--nth", "--with-nth", "--hide-nth"] {
let opts = parse_args(&[flag, "-1"]).unwrap_or_else(|e| panic!("{flag} -1 failed to parse: {e}"));
let got = match flag {
"--nth" => &opts.nth,
"--with-nth" => &opts.with_nth,
_ => &opts.hide_nth,
};
assert_eq!(got, &vec!["-1".to_string()], "{flag}");
assert_eq!(
FieldRange::from_str(&got[0]),
Some(FieldRange::Single(-1)),
"{flag} should resolve to the last field"
);
}
// ...and a negative index inside a comma-separated list.
let opts = parse_args(&["--with-nth", "2,-1"]).expect("--with-nth 2,-1 should parse");
assert_eq!(opts.with_nth, vec!["2".to_string(), "-1".to_string()]);
}

View file

@ -5,7 +5,7 @@ use derive_builder::Builder;
use crate::item::MatchedItem;
use crate::options::SkimOptions;
use crate::tui::Event;
use crate::tui::event::Action;
use crate::tui::actions::Action;
/// Output from running skim, containing the final selection and state
#[derive(Debug)]

View file

@ -20,7 +20,7 @@ use nix::unistd::mkfifo;
use crate::item::{MatchedItem, RankBuilder};
use crate::tui::Event;
use crate::tui::event::Action;
use crate::tui::actions::Action;
use crate::{Rank, SkimItem, SkimOptions, SkimOutput};
use tmux::TmuxPopup;

View file

@ -11,7 +11,7 @@ pub use crate::helper::selector::DefaultSkimSelector;
pub use crate::options::{SkimOptions, SkimOptionsBuilder};
pub use crate::output::SkimOutput;
pub use crate::reader::CommandCollector;
pub use crate::tui::event::Action;
pub use crate::tui::actions::Action;
pub use crate::tui::{Event, PreviewCallback};
pub use crate::*;
pub use kanal::{Receiver, Sender, bounded, unbounded};

View file

@ -10,7 +10,9 @@ use crate::{SkimItem, SkimItemReceiver};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread::JoinHandle;
use std::time::Duration;
/// Trait for collecting items from command output
pub trait CommandCollector {
@ -39,6 +41,7 @@ pub struct ReaderControl {
tx_interrupt: Sender<i32>,
tx_interrupt_cmd: Option<Sender<i32>>,
components_to_stop: Arc<AtomicUsize>,
collector_handle: Option<JoinHandle<()>>,
items: Arc<SpinLock<Vec<Arc<dyn SkimItem>>>>,
}
@ -52,7 +55,14 @@ impl ReaderControl {
let _ = self.tx_interrupt_cmd.clone().map(|tx| tx.send(1));
let _ = self.tx_interrupt.send(1);
while self.components_to_stop.load(Ordering::SeqCst) != 0 {}
if let Some(handle) = self.collector_handle.take() {
let _ = handle.join();
}
// Command collectors can own additional components outside the reader's
// join handle. Wait without consuming a CPU while they process the signal.
while self.components_to_stop.load(Ordering::Acquire) != 0 {
std::thread::sleep(Duration::from_millis(1));
}
}
/// Takes all items collected so far
@ -123,12 +133,14 @@ impl Reader {
);
let components_to_stop_clone = components_to_stop.clone();
let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
let (tx_interrupt, collector_handle) =
collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
ReaderControl {
tx_interrupt,
tx_interrupt_cmd,
components_to_stop,
collector_handle: Some(collector_handle),
items,
}
}
@ -149,7 +161,7 @@ impl Reader {
);
let components_to_stop_clone = components_to_stop.clone();
let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| {
let (tx_interrupt, collector_handle) = collect_items(components_to_stop_clone, rx_item, move |items| {
item_pool.append(items);
});
debug!("collect: started ({components_to_stop:?} components)");
@ -158,6 +170,7 @@ impl Reader {
tx_interrupt,
tx_interrupt_cmd,
components_to_stop,
collector_handle: Some(collector_handle),
items,
}
}
@ -172,18 +185,19 @@ impl Default for Reader {
}
}
fn collect_items<F>(components_to_stop: Arc<AtomicUsize>, rx_item: SkimItemReceiver, callback: F) -> Sender<i32>
fn collect_items<F>(
components_to_stop: Arc<AtomicUsize>,
rx_item: SkimItemReceiver,
callback: F,
) -> (Sender<i32>, JoinHandle<()>)
where
F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
{
let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
let started = Arc::new(AtomicBool::new(false));
let started_clone = started.clone();
std::thread::spawn(move || {
components_to_stop.fetch_add(1, Ordering::AcqRel);
let handle = std::thread::spawn(move || {
debug!("collect_item start");
components_to_stop.fetch_add(1, Ordering::SeqCst);
started_clone.store(true, Ordering::SeqCst); // notify parent that it is started
loop {
if let Ok(Some(msg)) = rx_interrupt.try_recv() {
@ -205,15 +219,11 @@ where
}
}
components_to_stop.fetch_sub(1, Ordering::SeqCst);
components_to_stop.fetch_sub(1, Ordering::AcqRel);
debug!("collect_item stop");
});
while !started.load(Ordering::SeqCst) {
// busy waiting for the thread to start. (components_to_stop is added)
}
tx_interrupt
(tx_interrupt, handle)
}
#[cfg(test)]

View file

@ -3,16 +3,19 @@ use std::io::{BufWriter, Stderr};
use std::sync::Arc;
use std::time::Duration;
use color_eyre::eyre::{self, OptionExt, Result};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use eyre::{self, OptionExt, Result};
#[cfg(feature = "image")]
use ratatui_image::picker::Picker;
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::actions::Action;
#[cfg(feature = "image")]
use crate::tui::util::detect_image_picker;
use crate::tui::{App, Event, Size, TICK_RATE, Tui};
use crate::{SkimItem, SkimItemReceiver, SkimOptions, SkimOutput};
@ -41,6 +44,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 {
@ -135,6 +140,8 @@ impl Skim {
if self.app.options.no_mouse {
tui.disable_mouse();
}
let min_height = crate::options::parse_min_height(&self.app.options.min_height).map_err(eyre::Report::msg)?;
tui.min_height(min_height)?;
self.tui = Some(tui);
Ok(())
}
@ -188,6 +195,7 @@ where
listener: None,
final_event: Event::Quit,
final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
start_fired: false,
})
}
@ -196,6 +204,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 +248,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 +268,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.
@ -365,7 +404,7 @@ where
if !tui.is_fullscreen {
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;
}
let picker = Picker::from_query_stdio().unwrap_or_else(|err| {
let picker = detect_image_picker().unwrap_or_else(|err| {
warn!("failed to query terminal image protocol: {err:?}");
Picker::halfblocks()
});
@ -385,6 +424,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(())
}
@ -404,6 +446,18 @@ where
if app.options.filter.is_some() {
trace!("filter mode: waiting for all items to be processed");
loop {
// `--min-query-length` short-circuits `restart_matcher`, so the pool would
// never be drained and this loop would spin forever. There is nothing to
// match in that case: stop as soon as the reader is done.
if app.query_below_min_length() {
if reader_control.is_done() {
debug!("filter mode: query shorter than --min-query-length, no results");
app.item_list.items.clear();
return false;
}
std::thread::sleep(Duration::from_millis(1));
continue;
}
let matcher_stopped = app.matcher_control.stopped();
let reader_done = reader_control.is_done();
if matcher_stopped && reader_done && app.item_pool.num_not_taken() == 0 {
@ -416,6 +470,7 @@ where
.item_list
.processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
.unwrap_or_default()
.items
@ -467,7 +522,14 @@ where
app.matcher_control.get_num_matched()
);
if app.matcher_control.get_num_matched() == min_items_before_enter - 1 {
app.item_list.items = app.item_list.processed_items.lock().take().unwrap_or_default().items;
app.item_list.items = app
.item_list
.processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
.unwrap_or_default()
.items;
debug!("early exit, result: {:?}", app.results());
return false;
}
@ -580,6 +642,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 +667,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();
}

View file

@ -74,6 +74,17 @@ pub trait SkimItem: AsAny + Send + Sync + 'static {
None
}
/// Byte ranges of `text()` that are hidden from display (via `--hide-nth`).
///
/// Characters inside these ranges are removed from the rendered line and ignored
/// for match highlighting and horizontal scrolling, but stay part of `text()` so
/// they remain searchable. Ranges are expressed as (`start_byte`, `end_byte`) and
/// are expected to be sorted and non-overlapping. Returns `None` when nothing is
/// hidden.
fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
None
}
/// Returns true if the item should be disabled
/// Disabled items cannot be selected
fn disabled(&self) -> bool {

View file

@ -66,6 +66,42 @@ fn should_enter_is_false_in_filter_mode() {
assert_eq!(skim.app().item_list.items.len(), 3);
}
#[test]
fn filter_mode_terminates_when_query_is_below_min_query_length() {
// `--min-query-length` makes `restart_matcher` a no-op, so the filter loop must
// not wait for the item pool to drain — it used to spin forever here. Run it on a
// worker thread so the regression surfaces as a failed assertion, not a hung test.
let (done_tx, done_rx) = crate::prelude::unbounded::<(bool, usize)>();
let worker = std::thread::spawn(move || {
let mut options = SkimOptions::default();
options.filter = Some("a".to_string());
options.min_query_length = Some(3);
let options = options.build();
let mut skim = started_skim_with(options, &["a", "b", "c"]);
let entered = skim.should_enter();
let _ = done_tx.send((entered, skim.app().item_list.items.len()));
});
let (entered, matched) = done_rx
.recv_timeout(Duration::from_secs(10))
.expect("filter mode did not terminate with a query below --min-query-length");
worker.join().unwrap();
assert!(!entered);
// The query is too short, so nothing is reported as matched.
assert_eq!(matched, 0);
}
#[test]
fn filter_mode_matches_when_query_meets_min_query_length() {
let mut options = SkimOptions::default();
options.filter = Some("abc".to_string());
options.min_query_length = Some(3);
let options = options.build();
let mut skim = started_skim_with(options, &["abc", "xyz"]);
assert!(!skim.should_enter());
assert_eq!(skim.app().item_list.items.len(), 1);
}
#[test]
fn should_enter_is_false_for_select_1_single_match() {
let mut options = SkimOptions::default();
@ -108,6 +144,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();

View file

@ -26,9 +26,9 @@ pub struct SpinLockGuard<'a, T: ?Sized + 'a> {
}
impl<'a, T: ?Sized + 'a> SpinLockGuard<'a, T> {
/// Creates a new guard for the given lock
pub fn new(pool: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
Self { __lock: pool }
/// Creates a guard after its lock has been acquired.
fn new(lock: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
Self { __lock: lock }
}
}

View file

@ -277,7 +277,7 @@ impl<R> Slot<R> {
// ---------------------------------------------------------------------------
/// Processes `items` in parallel across `num_workers` threads from the given
/// pool, then hands the per-worker results to `merge`.
/// pool, then hands the results to `merge`.
///
/// The work is split into chunks of `chunk_size`. Each worker thread
/// repeatedly grabs the next available chunk (via an atomic counter), runs
@ -285,8 +285,10 @@ impl<R> Slot<R> {
/// accumulator using `reduce`. When all chunks are consumed, each worker
/// calls `prepare` on its local accumulator (e.g. to sort it) — this step
/// runs **in parallel** across all workers — and then writes the prepared
/// result into its slot. The coordinator collects every worker's result and
/// passes them all to `merge` in a single call.
/// result into its slot. The coordinator collects every worker's result and
/// passes them all to `merge` in a single call. When `preserve_chunk_order` is
/// set, reduction and preparation are skipped and each chunk result is stored
/// directly in its chunk-indexed slot.
///
/// Because each worker picks up the *next* chunk as soon as it finishes the
/// previous one, faster threads naturally do more work without any explicit
@ -298,6 +300,7 @@ impl<R> Slot<R> {
/// * `num_workers` how many workers to dispatch (capped to pool size internally by caller).
/// * `items` the data to process; shared read-only across workers via `Arc`.
/// * `chunk_size` number of items per chunk.
/// * `preserve_chunk_order` store results by chunk index and skip reduction/preparation.
/// * `identity` the identity/seed value for per-worker local accumulators (called once per worker).
/// * `process_chunk` `(chunk_start_index, &[T]) -> R` processes one chunk.
/// * `reduce` folds a per-chunk result into a worker-local accumulator (`&mut acc, partial`).
@ -309,6 +312,7 @@ pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
num_workers: usize,
items: &Arc<S>,
chunk_size: usize,
preserve_chunk_order: bool,
identity: I,
process_chunk: P,
reduce: M,
@ -336,10 +340,10 @@ pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
// Shared atomic counter workers fetch-add to grab the next chunk index.
let next_chunk = Arc::new(AtomicUsize::new(0));
// Contiguous, cache-line-aligned per-worker result slots. Each worker
// writes only to its own slot (lock-free via UnsafeCell); the
// coordinator reads after the AtomicCounter barrier.
let slots: Arc<Vec<Slot<R>>> = Arc::new((0..num_workers).map(|_| Slot::new()).collect());
// Ordered mode uses one slot per chunk; otherwise each worker writes its
// reduced result to its own slot. The coordinator reads only after the barrier.
let num_slots = if preserve_chunk_order { num_chunks } else { num_workers };
let slots: Arc<Vec<Slot<R>>> = Arc::new((0..num_slots).map(|_| Slot::new()).collect());
// Barrier: we wait until all workers have finished.
let remaining = Arc::new(AtomicCounter::new(num_workers));
@ -368,9 +372,25 @@ pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
// Scope all Arc-holding work so clones are dropped before we
// signal completion. This lets the coordinator safely unwrap
// the outer Arcs.
let local_acc = {
let mut local_acc = w_identity();
let local_acc = if preserve_chunk_order {
loop {
let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
if chunk_idx >= num_chunks {
break;
}
let start = chunk_idx * chunk_size;
let end = total.min(start + chunk_size);
let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
let partial = w_process_chunk(start, &slice[start..end]);
// SAFETY: every chunk index is handed out once, so each
// slot has one writer. The coordinator reads after the barrier.
unsafe { *w_slots[chunk_idx].value.get() = Some(partial) };
}
None
} else {
let mut local_acc = w_identity();
loop {
let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
if chunk_idx >= num_chunks {
@ -387,16 +407,14 @@ pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
// Run prepare (e.g. sort) while still on the worker thread
// so that this work happens in parallel across workers.
w_prepare(&mut local_acc);
// w_items, w_next_chunk, w_process_chunk, w_reduce,
// w_prepare, w_identity are dropped when this block ends.
local_acc
Some(local_acc)
};
// Write into our own slot lock-free, no contention.
// SAFETY: each worker_id is unique; no other thread writes to
// this slot, and the coordinator reads only after the barrier.
unsafe { *w_slots[worker_id].value.get() = Some(local_acc) };
if let Some(local_acc) = local_acc {
// SAFETY: each worker_id is unique; no other thread writes to
// this slot, and the coordinator reads only after the barrier.
unsafe { *w_slots[worker_id].value.get() = Some(local_acc) };
}
// Drop the slots Arc *before* signalling completion.
// The coordinator calls Arc::into_inner(slots) after wait_for_zero
@ -418,7 +436,7 @@ pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
// Block until all workers are done.
remaining.wait_for_zero();
// Collect per-worker results and hand them to `merge` in one call.
// Collect worker or chunk results in slot order and hand them to `merge`.
// Workers dropped their `w_slots` Arc clone explicitly before signalling
// completion, so we are the sole owner here.
if let Some(slots) = Arc::into_inner(slots) {

View file

@ -72,6 +72,7 @@ fn parallel_work_queue_sums() {
4,
&items,
64,
false,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,
@ -85,6 +86,29 @@ fn parallel_work_queue_sums() {
assert_eq!(result, 500_500);
}
#[test]
fn parallel_work_queue_preserves_chunk_order() {
let pool = ThreadPool::new(4);
let items: Arc<[usize]> = (0..40).collect::<Vec<_>>().into();
let mut starts = Vec::new();
parallel_work_queue(
&pool,
4,
&items,
10,
true,
|| -> Vec<usize> { panic!("ordered mode must not create accumulators") },
|start, _chunk| {
std::thread::sleep(std::time::Duration::from_millis((30 - start) as u64));
vec![start]
},
|_acc, _partial| panic!("ordered mode must not reduce"),
|_acc| panic!("ordered mode must not prepare"),
|chunk_results| starts.extend(chunk_results.into_iter().flatten()),
);
assert_eq!(starts, vec![0, 10, 20, 30]);
}
#[test]
fn parallel_work_queue_empty() {
let pool = ThreadPool::new(2);
@ -95,6 +119,7 @@ fn parallel_work_queue_empty() {
2,
&items,
64,
false,
Vec::<u64>::new,
|_start, chunk| chunk.to_vec(),
|acc, mut partial| acc.append(&mut partial),
@ -118,6 +143,7 @@ fn parallel_work_queue_single_thread() {
1,
&items,
10,
false,
|| 0i32,
|_start, chunk| chunk.iter().sum::<i32>(),
|acc, partial| *acc += partial,
@ -156,6 +182,7 @@ fn parallel_work_queue_many_workers_few_chunks() {
8,
&items,
5,
false,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,
@ -186,6 +213,7 @@ fn parallel_work_queue_single_thread_pool_no_deadlock() {
1,
&items,
10,
false,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,

474
src/tui/actions.rs Normal file
View file

@ -0,0 +1,474 @@
//! Action definitions, the action catalog, and action parsing.
//!
//! The [`Action`] enum, its canonical names and its parser are all generated
//! from a single source of truth: the [`define_action_catalog`] invocation at
//! the bottom of this module. Each entry declares the variant (with its doc
//! comment and payload types), the kebab-case name accepted by `--bind`, and
//! the expression that builds the variant from an optional argument.
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use derive_more::{Debug, Eq, PartialEq};
use super::event::Event;
type BoxError = Box<dyn std::error::Error + Sync + Send>;
type BoxFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<Event>, BoxError>> + Send + 'a>>;
/// Trait object stored inside [`ActionCallback`].
///
/// Having an explicit trait (rather than a bare `dyn Fn` type alias) allows
/// Rust to correctly resolve the higher-ranked lifetime in the return type.
trait AsyncCallbackFn: Send {
fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a>;
}
/// Adapter that stores a concrete async closure and implements [`AsyncCallbackFn`].
struct AsyncFnWrapper<F>(F);
impl<F, Fut> AsyncCallbackFn for AsyncFnWrapper<F>
where
F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send,
Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
{
fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
Box::pin((self.0)(app))
}
}
/// Adapter that stores a plain synchronous closure and implements [`AsyncCallbackFn`].
struct SyncFnWrapper<F>(F);
impl<F> AsyncCallbackFn for SyncFnWrapper<F>
where
F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send,
{
fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
Box::pin(std::future::ready((self.0)(app)))
}
}
/// A custom action callback that receives a mutable reference to the App.
///
/// The closure will be called with a mutable reference to App and should return
/// a vec of events that will be processed after the callback completes.
///
/// Both sync and async closures are supported:
/// - Use [`ActionCallback::new`] to wrap an **async** closure or block.
/// - Use [`ActionCallback::new_sync`] to wrap a plain synchronous closure.
#[derive(Clone)]
pub struct ActionCallback(Arc<Mutex<dyn AsyncCallbackFn>>);
impl std::fmt::Debug for ActionCallback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ActionCallback").finish()
}
}
impl ActionCallback {
/// Create a new action callback from an **async** closure or block.
///
/// ```rust,ignore
/// ActionCallback::new(|app| async move {
/// // async work here …
/// Ok(vec![])
/// });
/// ```
pub fn new<F, Fut>(f: F) -> Self
where
F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send + 'static,
Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
{
Self(Arc::new(Mutex::new(AsyncFnWrapper(f))))
}
/// Create a new action callback from a plain **synchronous** closure.
///
/// This is a convenience wrapper; the closure is lifted into an immediately-
/// resolving future so it integrates with the same async call site.
///
/// ```rust,ignore
/// ActionCallback::new_sync(|app| {
/// Ok(vec![Event::Action(Action::SelectAll)])
/// });
/// ```
pub fn new_sync<F>(f: F) -> Self
where
F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send + 'static,
{
Self(Arc::new(Mutex::new(SyncFnWrapper(f))))
}
/// Call the callback with an App reference, driving the returned future to completion.
///
/// Must be called from within a Tokio multi-thread runtime context.
pub(crate) fn call(&self, app: &mut crate::tui::App) -> Result<Vec<Event>, BoxError> {
let callback = self.0.lock().unwrap();
let fut = callback.call(app);
// We are inside a synchronous call stack that originates from an async
// tokio context. `block_in_place` moves the current thread out of the
// async worker pool temporarily so we can block on the future without
// starving the runtime.
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
}
}
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))
}
/// Documentation for a single entry of the action catalog.
///
/// Produced by `define_action_catalog!` and exposed through [`ACTION_CATALOG`];
/// used to generate the actions list of the manpage.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActionDoc {
/// Canonical kebab-case name, as accepted by [`parse_action`].
pub name: &'static str,
/// Whether the action carries an argument (`name(...)` / `name:...`).
pub takes_arg: bool,
/// The action's rustdoc, one line per doc comment line.
pub doc: &'static str,
}
impl ActionDoc {
/// The action as it is spelled in a binding, with `(...)` for actions taking an argument.
#[must_use]
pub fn display_name(&self) -> String {
if self.takes_arg {
format!("{}(...)", self.name)
} else {
self.name.to_string()
}
}
/// The action's documentation collapsed into a single line.
#[must_use]
pub fn summary(&self) -> String {
self.doc
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
/// Whether this action can be named in a binding.
///
/// [`Action::Custom`] exists only for library users building actions in
/// Rust, so it has no spelling [`parse_action`] accepts.
#[must_use]
pub fn is_bindable(&self) -> bool {
// Actions that require an argument only parse with one, so retry with
// the parser's empty placeholder before giving up.
parse_action(self.name)
.or_else(|| parse_action(&format!("{}()", self.name)))
.is_some()
}
}
/// Expands to the argument marker used in the manpage, ignoring the payload types it is handed.
macro_rules! action_arg_marker {
($($payload:tt)*) => {
true
};
}
/// Declares the whole action catalog in one place.
///
/// Every entry has the shape
///
/// ```text
/// /// doc comment
/// Variant(payload types…) => "canonical-name" => constructor expression
/// ```
///
/// Doc comments are captured (so they can be re-emitted on the variant *and*
/// rendered into the manpage), which means any other attribute has to be passed
/// through the optional `@attrs[…]` group instead — a bare `#[…]` would be
/// ambiguous with the doc comments:
///
/// ```text
/// /// doc comment
/// @attrs[debug("custom")]
/// Variant(payload) => "canonical-name" => constructor expression
/// ```
///
/// and the macro generates, from that single list:
/// - the [`Action`] enum (with the doc comments and payloads as written),
/// - [`Action::name`], mapping each variant to its canonical name,
/// - `parse_named_action`, mapping a name plus optional argument back to a variant,
/// - [`ACTION_CATALOG`], the name/argument/documentation list the manpage is generated from.
///
/// The identifier before the `;` is the name bound to the optional argument
/// (`Option<String>`) inside the constructor expressions.
macro_rules! define_action_catalog {
(
$arg:ident;
$(
$(#[doc = $doc:literal])*
$(@attrs[$($attr:meta),+ $(,)?])?
$variant:ident $(($($payload:ty),+ $(,)?))? => $name:literal => $parsed:expr
),+ $(,)?
) => {
/// Actions that can be performed in skim
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "listen", derive(serde::Serialize, serde::Deserialize))]
pub enum Action {
$(
$(#[doc = $doc])*
$($(#[$attr])+)?
$variant $(($($payload),+))?,
)+
}
/// Every action, in declaration order, with its name, argument marker and documentation.
///
/// This is the source the manpage's action list is generated from, so a
/// new entry in the catalog is documented automatically.
pub const ACTION_CATALOG: &[ActionDoc] = &[
$(ActionDoc {
name: $name,
takes_arg: false $(|| action_arg_marker!($($payload)+))?,
doc: concat!($($doc, "\n"),*),
}),+
];
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 {
match self {
$(Self::$variant { .. } => $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 and exit with error
Abort => "abort" => Some(Abort),
/// Accept selection and exit with optional key.
///
/// The argument is printed when the binding is triggered.
Accept(Option<String>) => "accept" => Some(Accept(arg)),
/// Add a character to the query
AddChar(char) => "add-char" => arg.map(|s| AddChar(s.chars().next().unwrap_or_default())),
/// Append to selection and select
AppendAndSelect => "append-and-select" => Some(AppendAndSelect),
/// Move cursor backward one character
BackwardChar => "backward-char" => Some(BackwardChar),
/// Delete character before cursor
BackwardDeleteChar => "backward-delete-char" => Some(BackwardDeleteChar),
/// Delete character before cursor or exit if the query is empty
BackwardDeleteCharEof => "backward-delete-char/eof" => Some(BackwardDeleteCharEof),
/// Delete word before cursor
BackwardKillWord => "backward-kill-word" => Some(BackwardKillWord),
/// Move cursor backward one word
BackwardWord => "backward-word" => Some(BackwardWord),
/// Move cursor to beginning of line
BeginningOfLine => "beginning-of-line" => Some(BeginningOfLine),
/// Bind one or more keys to action chains.
///
/// The argument is a comma-separated list of `trigger:action[+action]` bindings to add,
/// using the same syntax as `--bind`, including action triggers such as `act-up:last`.
Bind(String) => "bind" => arg.map(Bind),
/// Cancel current operation
Cancel => "cancel" => Some(Cancel),
/// Clear the screen
ClearScreen => "clear-screen" => Some(ClearScreen),
/// Delete character under cursor
DeleteChar => "delete-char" => Some(DeleteChar),
/// Delete character or exit if empty
DeleteCharEof => "delete-char/eof" => Some(DeleteCharEof),
/// Deselect all items
DeselectAll => "deselect-all" => Some(DeselectAll),
/// Move selection down by N items
Down(u16) => "down" => Some(Down(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Move cursor to end of line
EndOfLine => "end-of-line" => Some(EndOfLine),
/// Execute a command.
///
/// The argument is a command, see COMMAND EXPANSION for details.
Execute(String) => "execute" => arg.map(Execute),
/// Execute a command silently.
///
/// The argument is a command, see COMMAND EXPANSION for details.
ExecuteSilent(String) => "execute-silent" => arg.map(ExecuteSilent),
/// Jump to first item in list
First => "first" => Some(First),
/// Move cursor forward one character
ForwardChar => "forward-char" => Some(ForwardChar),
/// Move cursor forward one word
ForwardWord => "forward-word" => Some(ForwardWord),
/// Execute action if query is empty
IfQueryEmpty(String, Option<String>) => "if-query-empty" => parse_conditional(arg, IfQueryEmpty),
/// Execute action if query is not empty
IfQueryNotEmpty(String, Option<String>) => "if-query-not-empty" => parse_conditional(arg, IfQueryNotEmpty),
/// Execute action if no items match
IfNonMatched(String, Option<String>) => "if-non-matched" => parse_conditional(arg, IfNonMatched),
/// Ignore the action
Ignore => "ignore" => Some(Ignore),
/// Delete from cursor to end of line
KillLine => "kill-line" => Some(KillLine),
/// Delete word after cursor
KillWord => "kill-word" => Some(KillWord),
/// Jump to last item in list
Last => "last" => Some(Last),
/// Move to next history entry (requires `--history` or `--cmd-history`)
NextHistory => "next-history" => Some(NextHistory),
/// Scroll down by half a page
HalfPageDown(i32) => "half-page-down" => Some(HalfPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Scroll up by half a page
HalfPageUp(i32) => "half-page-up" => Some(HalfPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Scroll down by a page
PageDown(i32) => "page-down" => Some(PageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Scroll up by a page
PageUp(i32) => "page-up" => Some(PageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Scroll preview up
PreviewUp(i32) => "preview-up" => Some(PreviewUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Scroll preview down
PreviewDown(i32) => "preview-down" => Some(PreviewDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Scroll preview left
PreviewLeft(i32) => "preview-left" => Some(PreviewLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Scroll preview right
PreviewRight(i32) => "preview-right" => Some(PreviewRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Scroll preview up by a page
PreviewPageUp(i32) => "preview-page-up" => Some(PreviewPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Scroll preview down by a page
PreviewPageDown(i32) => "preview-page-down" => Some(PreviewPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Move to previous history entry (requires `--history` or `--cmd-history`)
PreviousHistory => "previous-history" => Some(PreviousHistory),
/// Redraw the screen
Redraw => "redraw" => Some(Redraw),
/// Refresh the command
RefreshCmd => "refresh-cmd" => Some(RefreshCmd),
/// Refresh the preview
RefreshPreview => "refresh-preview" => Some(RefreshPreview),
/// Restart the matcher
RestartMatcher => "restart-matcher" => Some(RestartMatcher),
/// Reload with optional new command
Reload(Option<String>) => "reload" => Some(Reload(arg)),
/// Rotate through matching modes
RotateMode => "rotate-mode" => Some(RotateMode),
/// Scroll item list left
ScrollLeft(i32) => "scroll-left" => Some(ScrollLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Scroll item list right
ScrollRight(i32) => "scroll-right" => Some(ScrollRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Select all items
SelectAll => "select-all" => Some(SelectAll),
/// Select a specific row
SelectRow(usize) => "select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
/// Select current item
Select => "select" => Some(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 => "suppress" => Some(Suppress),
/// Set the interactive-mode command and rerun it.
///
/// The argument is an expanded expression, see COMMAND EXPANSION for details.
SetCmd(String) => "set-cmd" => arg.map(SetCmd),
/// Set the header (or disable it on an empty value)
SetHeader(Option<String>) => "set-header" => Some(SetHeader(arg)),
/// Set the preview cmd and rerun preview.
///
/// The argument is an expanded expression, see COMMAND EXPANSION for details.
SetPreviewCmd(String) => "set-preview-cmd" => arg.map(SetPreviewCmd),
/// Set the query to the expanded value.
///
/// The argument is an expanded expression, see COMMAND EXPANSION for details.
SetQuery(String) => "set-query" => arg.map(SetQuery),
/// Toggle selection of current item
Toggle => "toggle" => Some(Toggle),
/// Toggle selection of all items
ToggleAll => "toggle-all" => Some(ToggleAll),
/// Toggle and move in
ToggleIn => "toggle-in" => Some(ToggleIn),
/// Toggle interactive mode
ToggleInteractive => "toggle-interactive" => Some(ToggleInteractive),
/// Toggle and move out
ToggleOut => "toggle-out" => Some(ToggleOut),
/// Toggle preview visibility
TogglePreview => "toggle-preview" => Some(TogglePreview),
/// Toggle preview line wrapping
TogglePreviewWrap => "toggle-preview-wrap" => Some(TogglePreviewWrap),
/// Toggle sorting
ToggleSort => "toggle-sort" => Some(ToggleSort),
/// Jump to first item in list (alias for First)
Top => "top" => Some(Top),
/// Unbind one or more keys.
///
/// The argument is a comma-separated list of keys or action triggers (e.g. `act-up`) to unbind.
Unbind(String) => "unbind" => arg.map(Unbind),
/// Discard line (unix-style)
UnixLineDiscard => "unix-line-discard" => Some(UnixLineDiscard),
/// Delete word backward (unix-style)
UnixWordRubout => "unix-word-rubout" => Some(UnixWordRubout),
/// Move selection up by N items
Up(u16) => "up" => Some(Up(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
/// Yank (paste)
Yank => "yank" => Some(Yank),
/// Custom action from lib
@attrs[
debug("custom"),
eq(skip),
partial_eq(skip),
cfg_attr(feature = "listen", serde(skip)),
]
Custom(ActionCallback) => "custom" => None,
}
/// Parses an action string into an Action enum
///
/// Returns `None` if the action is unrecognized, or if it is specified without
/// an argument it requires (the `if-*` actions, `execute`, `set-query`, … — see
/// the `arg.map(…)` arms of the catalog).
#[must_use]
pub fn parse_action(raw_action: &str) -> Option<Action> {
let parts = raw_action.split_once([':', '(', ')']);
let action;
let mut arg = None;
match parts {
None => action = raw_action,
Some((act, "")) => action = act,
Some((act, a)) => {
action = act;
arg = Some(a.trim_end_matches(')').to_string());
}
}
debug!("parse_action: action={action}, arg={arg:?}");
parse_named_action(action, arg)
}
#[cfg(test)]
#[path = "actions_tests.rs"]
mod tests;

342
src/tui/actions_tests.rs Normal file
View file

@ -0,0 +1,342 @@
use super::*;
const NO_ARG_ACTIONS: &[&str] = &[
"abort",
"append-and-select",
"backward-char",
"backward-delete-char",
"backward-delete-char/eof",
"backward-kill-word",
"backward-word",
"beginning-of-line",
"cancel",
"clear-screen",
"delete-char",
"delete-char/eof",
"deselect-all",
"end-of-line",
"first",
"forward-char",
"forward-word",
"ignore",
"kill-line",
"kill-word",
"last",
"next-history",
"previous-history",
"redraw",
"refresh-cmd",
"refresh-preview",
"restart-matcher",
"rotate-mode",
"select",
"select-all",
"suppress",
"toggle",
"toggle-all",
"toggle-in",
"toggle-interactive",
"toggle-out",
"toggle-preview",
"toggle-preview-wrap",
"toggle-sort",
"top",
"unix-line-discard",
"unix-word-rubout",
"yank",
];
#[test]
fn parse_all_no_arg_actions() {
for name in NO_ARG_ACTIONS {
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() {
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]
fn parse_numeric_actions_with_colon_arg() {
assert_eq!(parse_action("down:3"), Some(Action::Down(3)));
assert_eq!(parse_action("up:5"), Some(Action::Up(5)));
assert_eq!(parse_action("half-page-down:2"), Some(Action::HalfPageDown(2)));
assert_eq!(parse_action("preview-up:4"), Some(Action::PreviewUp(4)));
assert_eq!(parse_action("select-row:7"), Some(Action::SelectRow(7)));
}
#[test]
fn parse_numeric_actions_with_paren_arg() {
assert_eq!(parse_action("down(3)"), Some(Action::Down(3)));
assert_eq!(parse_action("scroll-right(2)"), Some(Action::ScrollRight(2)));
}
#[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()))
);
assert_eq!(
parse_action("execute-silent:touch x"),
Some(Action::ExecuteSilent("touch x".to_string()))
);
assert_eq!(
parse_action("set-query:hello"),
Some(Action::SetQuery("hello".to_string()))
);
assert_eq!(
parse_action("set-preview-cmd:cat {}"),
Some(Action::SetPreviewCmd("cat {}".to_string()))
);
assert_eq!(parse_action("add-char:z"), Some(Action::AddChar('z')));
}
#[test]
fn parse_set_cmd_action() {
assert_eq!(
parse_action("set-cmd:find ."),
Some(Action::SetCmd("find .".to_string()))
);
assert_eq!(
parse_action("set-cmd(grep {q})"),
Some(Action::SetCmd("grep {q}".to_string()))
);
// Like the other `set-*-cmd` actions, an argument is required.
assert_eq!(parse_action("set-cmd"), None);
assert_eq!(parse_action("set-cmd:"), None);
}
#[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"),
Some(Action::Accept(Some("enter".to_string())))
);
assert_eq!(parse_action("set-header"), Some(Action::SetHeader(None)));
assert_eq!(parse_action("reload"), Some(Action::Reload(None)));
assert_eq!(
parse_action("reload:find ."),
Some(Action::Reload(Some("find .".to_string())))
);
}
#[test]
fn parse_bind_and_unbind_actions() {
// `bind` captures the whole `key:action` spec as its string argument, using
// either the paren or colon form.
assert_eq!(
parse_action("bind(ctrl-a:accept)"),
Some(Action::Bind("ctrl-a:accept".to_string()))
);
assert_eq!(
parse_action("bind:ctrl-a:accept"),
Some(Action::Bind("ctrl-a:accept".to_string()))
);
// `unbind` captures a comma-separated list of keys, like fzf's `unbind(...)`.
assert_eq!(
parse_action("unbind(ctrl-a)"),
Some(Action::Unbind("ctrl-a".to_string()))
);
assert_eq!(
parse_action("unbind(ctrl-a,ctrl-b)"),
Some(Action::Unbind("ctrl-a,ctrl-b".to_string()))
);
}
#[test]
fn arg_required_actions_reject_a_missing_argument() {
// These actions are meaningless without an argument, so they must not fall
// back to an empty one. The requirement is expressed by their `arg.map(…)`
// catalog arms, so keep every one of them covered here.
for name in [
"add-char",
"bind",
"execute",
"execute-silent",
"set-cmd",
"set-preview-cmd",
"set-query",
"unbind",
] {
assert_eq!(parse_action(name), None, "`{name}` should require an argument");
assert_eq!(
parse_action(&format!("{name}:")),
None,
"`{name}:` should require an argument"
);
assert!(parse_action(&format!("{name}:x")).is_some(), "`{name}:x` should parse");
}
}
#[test]
fn parse_bind_and_unbind_require_argument() {
// Without an argument both actions are rejected rather than silently
// producing an empty binding.
assert_eq!(parse_action("bind"), None);
assert_eq!(parse_action("bind:"), None);
assert_eq!(parse_action("unbind"), None);
assert_eq!(parse_action("unbind:"), None);
}
#[test]
fn parse_if_chains_then_only() {
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))
);
assert_eq!(
parse_action("if-non-matched:ignore"),
Some(Action::IfNonMatched("ignore".to_string(), None))
);
}
#[test]
fn parse_if_chains_then_and_else() {
assert_eq!(
parse_action("if-query-not-empty:abort+ignore"),
Some(Action::IfQueryNotEmpty("abort".to_string(), Some("ignore".to_string())))
);
}
#[test]
fn parse_numeric_action_with_invalid_arg_falls_back_to_default() {
// A non-numeric argument is ignored and the default count is used.
assert_eq!(parse_action("down:abc"), Some(Action::Down(1)));
assert_eq!(parse_action("page-up:xyz"), Some(Action::PageUp(1)));
// SelectRow defaults to 0 rather than 1.
assert_eq!(parse_action("select-row:nope"), Some(Action::SelectRow(0)));
}
#[test]
fn parse_unknown_action_returns_none() {
assert_eq!(parse_action("not-a-real-action"), None);
}
#[test]
fn parse_action_trailing_separator_yields_no_arg() {
// A separator with nothing after it (`act:`) is treated as if no argument
// was supplied, so optional-arg actions fall back to their `None` form
// rather than being handed an empty string.
assert_eq!(parse_action("accept:"), Some(Action::Accept(None)));
assert_eq!(parse_action("reload:"), Some(Action::Reload(None)));
assert_eq!(parse_action("set-header:"), Some(Action::SetHeader(None)));
// Numeric actions fall back to their default count for the same reason.
assert_eq!(parse_action("down:"), Some(Action::Down(1)));
assert_eq!(parse_action("select-row:"), Some(Action::SelectRow(0)));
}
#[test]
fn parse_if_chain_with_trailing_plus_has_empty_else() {
// A trailing `+` yields a then-branch with no otherwise-branch.
assert_eq!(
parse_action("if-query-empty:abort+"),
Some(Action::IfQueryEmpty("abort".to_string(), None))
);
}
#[test]
fn parse_if_chain_unknown_kind_returns_none() {
// An `if-` prefixed action that is not one of the known kinds is rejected.
assert_eq!(parse_action("if-bogus:abort"), None);
}
#[test]
fn action_callback_debug_is_opaque() {
let cb = ActionCallback::new_sync(|_app| Ok(vec![]));
assert_eq!(format!("{cb:?}"), "ActionCallback");
}
#[test]
fn action_callback_async_constructor_builds() {
// The async constructor wraps the closure without invoking it.
let cb = ActionCallback::new(|_app| async move { Ok(vec![Event::Render]) });
// Cloning shares the same inner callback.
let _clone = cb.clone();
assert_eq!(format!("{cb:?}"), "ActionCallback");
}
#[test]
fn catalog_covers_every_action_and_round_trips() {
for entry in ACTION_CATALOG {
assert!(!entry.summary().is_empty(), "`{}` needs a doc comment", entry.name);
if !entry.is_bindable() {
continue;
}
let action = parse_action(entry.name)
.or_else(|| parse_action(&format!("{}()", entry.name)))
.unwrap_or_else(|| panic!("expected `{}` to parse", entry.name));
assert_eq!(action.name(), entry.name);
assert_eq!(
entry.display_name(),
if entry.takes_arg {
format!("{}(...)", entry.name)
} else {
entry.name.to_string()
}
);
}
}
#[test]
fn catalog_marks_arguments_and_bindability() {
let entry = |name: &str| {
ACTION_CATALOG
.iter()
.find(|entry| entry.name == name)
.unwrap_or_else(|| panic!("`{name}` should be in the catalog"))
};
assert!(!entry("abort").takes_arg);
assert!(entry("accept").takes_arg);
assert_eq!(entry("accept").display_name(), "accept(...)");
// The custom action has no spelling the parser accepts.
assert!(!entry("custom").is_bindable());
// Multi-line docs collapse into a single line.
assert!(
entry("suppress")
.summary()
.starts_with("Suppress the default behaviour")
);
assert!(!entry("suppress").summary().contains('\n'));
}

View file

@ -18,13 +18,14 @@ use crate::{ItemPreview, PreviewContext, Rank, SkimItem, SkimOptions, util};
#[path = "app_tests.rs"]
mod tests;
use super::event::Action;
use super::actions::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 color_eyre::eyre::{Result, bail};
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;
use ratatui::buffer::Buffer;
@ -42,6 +43,7 @@ static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
const MATCHER_DEBOUNCE_MS: u128 = 200;
const HIDE_GRACE_MS: u128 = 500;
const DOUBLE_CLICK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
/// Application state for skim's TUI
#[allow(clippy::struct_excessive_bools)]
@ -54,6 +56,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 +131,21 @@ 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,
/// Time of the previous left click, used to recognize `double-click` bindings.
last_left_click: std::time::Instant,
/// 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 +236,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 +270,13 @@ impl Default for App {
reader_timer: std::time::Instant::now(),
items_just_updated: false,
currently_scrolling: false,
last_left_click: std::time::Instant::now()
.checked_sub(DOUBLE_CLICK_INTERVAL * 2)
.unwrap(),
reader_done: false,
load_event_fired: false,
result_pending: false,
last_focused: None,
}
}
}
@ -283,6 +310,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 +344,13 @@ impl App {
.unwrap(),
pending_preview_run: false,
currently_scrolling: false,
last_left_click: std::time::Instant::now()
.checked_sub(DOUBLE_CLICK_INTERVAL * 2)
.unwrap(),
reader_done: false,
load_event_fired: false,
result_pending: false,
last_focused: None,
}
}
@ -329,9 +364,7 @@ impl App {
self.layout_template = LayoutTemplate::from_options(&self.options, self.header.height());
self.layout = self.layout_template.apply(Rect::new(0, 0, cols, rows));
}
}
impl App {
/// Calculate preview offset from offset expression (e.g., "+123", "+{2}", "+{2}-2")
fn calculate_preview_offset(&self, offset_expr: &str) -> u16 {
// Remove the leading '+'
@ -375,9 +408,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.)
@ -389,7 +478,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,
]
}
@ -493,7 +582,7 @@ impl App {
u16::try_from(u32::from(self.preview.rows) * u32::from(p) / 100).unwrap_or(u16::MAX)
}
};
self.preview.scroll_y = v_scroll;
self.preview.scroll_y = usize::from(v_scroll);
self.preview.scroll_down(v_offset);
let h_scroll = match preview_position.h_scroll {
@ -510,7 +599,7 @@ impl App {
u16::try_from(u32::from(self.preview.cols) * u32::from(p) / 100).unwrap_or(u16::MAX)
}
};
self.preview.scroll_x = h_scroll.saturating_add(h_offset);
self.preview.scroll_x = usize::from(h_scroll).saturating_add(usize::from(h_offset));
}
ItemPreview::TextWithPos(t, preview_position) | ItemPreview::AnsiWithPos(t, preview_position) => self
.preview
@ -544,6 +633,7 @@ impl App {
where
B::Error: Send + Sync + 'static,
{
trace!("handling event {event:?}");
match event {
Event::Render => {
// Always render to avoid freezing, but the render function itself can optimize
@ -552,6 +642,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
@ -572,6 +667,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)
@ -584,9 +692,26 @@ impl App {
warn!("RunPreview: error {e:?}");
}
}
Event::Clear | Event::Redraw => {
Event::RunExecute(cmd) => {
tui.run_execute(cmd)?;
self.handle_event(tui, &Event::Redraw)?;
}
Event::Clear => {
tui.clear()?;
}
Event::Redraw => {
// Avoid `Event::Redraw` (which calls `tui.clear()`): ratatui's
// `Terminal::clear` first queries the cursor position, and
// crossterm writes that query (`ESC [ 6 n`) to *stdout*. skim
// renders to stderr and its stdout is frequently redirected
// (`sk > file`, `find | sk | …`); there the query reaches no
// terminal, no reply ever comes, and the UI stalls for seconds
// before erroring out. Resetting both of ratatui's diff buffers
// instead makes the next draw repaint every cell — no cursor
// query, and it works for both fullscreen and inline viewports.
tui.force_full_redraw();
self.handle_event(tui, &Event::Render)?;
}
Event::Quit | Event::Close => {
tui.exit()?;
self.should_quit = true;
@ -630,6 +755,16 @@ impl App {
}
}
Event::Resize(cols, rows) => {
// We need to manually resize Fixed viewports
if !tui.is_fullscreen {
let curr = tui.terminal.get_frame().area();
let _ = tui.terminal.resize(Rect {
x: curr.x,
y: curr.y,
width: *cols,
height: *rows,
});
}
self.resize(*cols, *rows);
if let Err(e) = self.run_preview(tui) {
warn!("error while rerunnig preview after resize: {e}");
@ -666,9 +801,10 @@ impl App {
self.on_items_updated();
}
fn handle_key(&mut self, key: &KeyEvent) -> Vec<Event> {
debug!("key event: {key:?}");
let normalized_key = KeyEvent::new(key.code, key.modifiers);
debug!("key event: {key:?}, normalized: {normalized_key:?}");
if let Some(act) = &self.options.keymap.get(key) {
if let Some(act) = &self.options.keymap.get(&normalized_key) {
debug!("{act:?}");
return act.iter().map(|a| Event::Action(a.clone())).collect();
}
@ -693,23 +829,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>> {
use Action::{
Abort, Accept, AddChar, AppendAndSelect, BackwardChar, BackwardDeleteChar, BackwardDeleteCharEof,
BackwardKillWord, BackwardWord, BeginningOfLine, Cancel, ClearScreen, Custom, DeleteChar, DeleteCharEof,
DeselectAll, Down, EndOfLine, Execute, ExecuteSilent, First, ForwardChar, ForwardWord, HalfPageDown,
HalfPageUp, IfNonMatched, IfQueryEmpty, IfQueryNotEmpty, Ignore, KillLine, KillWord, Last, NextHistory,
PageDown, PageUp, PreviewDown, PreviewLeft, PreviewPageDown, PreviewPageUp, PreviewRight, PreviewUp,
PreviousHistory, Redraw, RefreshCmd, RefreshPreview, Reload, RestartMatcher, RotateMode, ScrollLeft,
ScrollRight, Select, SelectAll, SelectRow, SetHeader, SetPreviewCmd, SetQuery, Toggle, ToggleAll, ToggleIn,
ToggleInteractive, ToggleOut, TogglePreview, TogglePreviewWrap, ToggleSort, Top, UnixLineDiscard,
UnixWordRubout, Up, Yank,
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);
@ -731,7 +905,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);
@ -762,6 +936,15 @@ impl App {
BeginningOfLine => {
self.input.move_cursor_to(0);
}
Bind(spec) => {
// Bind one or more `trigger:action[+action]` pairs, reusing the
// same parsing/merging logic as the `--bind` CLI option.
// 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();
self.preview.kill();
@ -785,7 +968,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) => {
@ -793,41 +976,19 @@ 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();
}
Execute(cmd) => {
use std::io::IsTerminal as _;
// Running a foreground process needs the `Tui` (to suspend
// skim's input reader and toggle terminal modes), which this
// method does not have. Expand the command here and hand it to
// the event loop, which runs it via `Event::RunExecute`.
let expanded_cmd = self.expand_cmd(cmd, true);
debug!("execute: {expanded_cmd}");
let mut command = crate::shell_cmd(&expanded_cmd);
let has_tty = std::io::stderr().is_terminal();
let in_raw_mode = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
if has_tty {
if in_raw_mode {
crossterm::terminal::disable_raw_mode()?;
}
crossterm::execute!(
std::io::stderr(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableMouseCapture
)?;
}
let _ = command.spawn().and_then(|mut c| c.wait());
if has_tty {
if in_raw_mode {
crossterm::terminal::enable_raw_mode()?;
}
crossterm::execute!(
std::io::stderr(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture
)?;
}
return Ok(vec![Event::Redraw]);
return Ok(vec![Event::RunExecute(expanded_cmd)]);
}
ExecuteSilent(cmd) => {
let expanded_cmd = self.expand_cmd(cmd, true);
@ -839,7 +1000,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);
@ -848,39 +1009,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);
@ -895,7 +1035,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
@ -942,7 +1082,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;
@ -951,7 +1091,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);
@ -960,7 +1100,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);
@ -969,7 +1109,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));
@ -1081,15 +1221,25 @@ 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());
}
SetCmd(cmd) => {
// Command counterpart of `SetPreviewCmd`: swap the command
// template (used by interactive mode and `refresh-cmd`) and
// immediately re-run it.
self.cmd.clone_from(cmd);
self.options.cmd = Some(cmd.clone());
self.item_list.clear_selection();
let expanded_cmd = self.expand_cmd(cmd, true);
return Ok(vec![Event::Reload(expanded_cmd)]);
}
SetHeader(opt_header) => {
opt_header.clone_into(&mut self.options.header);
@ -1109,11 +1259,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();
@ -1121,7 +1271,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;
@ -1134,7 +1284,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;
@ -1149,6 +1299,22 @@ impl App {
self.options.no_sort = !self.options.no_sort;
self.restart_matcher(true);
}
Unbind(spec) => {
// Remove the bindings for one or more keys or action triggers.
for trigger in crate::binds::split_top_level(spec, ',') {
match crate::binds::parse_key(trigger) {
Ok(parsed) => {
self.options.keymap.remove(&parsed);
}
Err(err) => match crate::binds::action_trigger_name(trigger) {
Some(name) => {
self.options.action_binds.remove(name);
}
None => debug!("Failed to unbind {trigger}: {err}"),
},
}
}
}
UnixLineDiscard => {
if !self.input.delete_to_beginning().is_empty() {
return Ok(self.on_query_changed());
@ -1164,7 +1330,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
@ -1172,7 +1338,7 @@ impl App {
return Ok(self.on_query_changed());
}
Custom(cb) => {
return cb.call(self).map_err(|e| color_eyre::eyre::eyre!("{}", e));
return cb.call(self).map_err(|e| eyre::eyre!("{}", e));
}
}
Ok(Vec::default())
@ -1182,7 +1348,7 @@ impl App {
pub fn results(&mut self) -> Vec<MatchedItem> {
if self.options.filter.is_some() {
// In filter mode, drain items to avoid cloning
self.item_list.items.drain(..).collect()
std::mem::take(&mut self.item_list.items)
} else if self.options.multi && !self.item_list.selection.is_empty() {
self.item_list.selection.clone().into_iter().collect()
} else if let Some(sel) = self.item_list.selected() {
@ -1192,6 +1358,18 @@ impl App {
}
}
/// Whether the current query is shorter than `--min-query-length`, meaning no
/// results should be produced yet.
///
/// Always false when `--min-query-length` is unset, or under `--disabled`, where
/// the input is not used as a query.
#[must_use]
pub fn query_below_min_length(&self) -> bool {
self.options
.min_query_length
.is_some_and(|min| !self.options.disabled && self.input.value.chars().count() < min)
}
/// Restart the matcher to process items in the item pool.
///
/// If `force` is true, the matcher will be restarted even if it's currently running.
@ -1200,19 +1378,19 @@ impl App {
pub fn restart_matcher(&mut self, force: bool) {
use crate::tui::item_list::MergeStrategy;
// Check if query meets minimum length requirement
if let Some(min_length) = self.options.min_query_length
&& !self.options.disabled
{
let query_to_check = &self.input.value;
if query_to_check.chars().count() < min_length {
// Query is too short, clear items and don't run matcher
self.matcher_control.kill();
self.item_list.items.clear();
self.item_list.current = 0;
self.item_list.offset = 0;
return;
}
if self.query_below_min_length() {
// Query is too short, clear items and don't run matcher
self.matcher_control.kill();
self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel);
self.item_list
.processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
self.item_list.items.clear();
self.item_list.current = 0;
self.item_list.offset = 0;
return;
}
let matcher_stopped = self.matcher_control.stopped();
@ -1241,8 +1419,16 @@ impl App {
self.item_pool.reset();
}
let generation = if force {
self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel) + 1
} else {
self.item_list.matcher_generation.load(Ordering::Acquire)
};
let merge_strategy = if force {
MergeStrategy::Replace
} else if no_sort && self.options.tac {
MergeStrategy::Prepend
} else if no_sort {
MergeStrategy::Append
} else {
@ -1256,8 +1442,14 @@ impl App {
self.item_list.processed_items.clone(),
merge_strategy,
no_sort,
self.options.tac,
generation,
self.item_list.matcher_generation.clone(),
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;
}
}
@ -1346,6 +1538,7 @@ impl App {
trace!("Got mouse event {mouse_event:?}");
let old_current = self.item_list.current;
let mut double_click = false;
match mouse_event.kind {
MouseEventKind::ScrollUp => {
@ -1371,6 +1564,10 @@ impl App {
return self.handle_action(&Action::Down(1));
}
MouseEventKind::Down(MouseButton::Left) => {
let now = std::time::Instant::now();
double_click = now.duration_since(self.last_left_click) <= DOUBLE_CLICK_INTERVAL;
self.last_left_click = now;
if let Some((inner, scrollbar_col)) = self.scrollbar_column()
&& mouse_pos.x == scrollbar_col
&& inner.contains(mouse_pos)
@ -1407,10 +1604,15 @@ impl App {
self.needs_render();
if self.item_list.current != old_current {
return Ok(Self::on_selection_changed());
let mut events = if self.item_list.current == old_current {
Vec::new()
} else {
self.on_selection_changed()
};
if double_click {
events.push(Event::Key(SkimEvent::DoubleClick.into()));
}
Ok(vec![])
Ok(events)
}
fn toggle_spinner(&mut self) {
self.show_spinner = !self.show_spinner;

View file

@ -14,7 +14,8 @@ use std::sync::Arc;
use super::*;
use crate::item::{MatchedItem, RankBuilder};
use crate::tui::event::{Action, Event};
use crate::tui::actions::Action;
use crate::tui::event::Event;
use crate::tui::layout::LayoutTemplate;
use crate::tui::statusline::InfoDisplay;
use crate::{Rank, SkimItem};
@ -57,12 +58,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)));
}
@ -347,6 +395,59 @@ fn refresh_cmd_reloads_in_interactive_mode() {
assert!(act(&mut app, Action::RefreshCmd).is_empty());
}
#[test]
fn set_cmd_replaces_command_and_reloads() {
let mut app = app_with_items(&["a", "b"]);
app.options.multi = true;
app.cmd = "ls".to_string();
act(&mut app, Action::SelectAll);
assert!(!app.item_list.selection.is_empty());
let events = act(&mut app, Action::SetCmd("find .".to_string()));
// The command template is swapped, both on the app and in the options, so
// `refresh-cmd` and interactive mode pick it up too.
assert_eq!(app.cmd, "find .");
assert_eq!(app.options.cmd.as_deref(), Some("find ."));
// The previous results are gone, so their selection must not survive.
assert!(app.item_list.selection.is_empty());
assert!(
matches!(events.as_slice(), [Event::Reload(cmd)] if cmd == "find ."),
"expected a single reload of the new command, got {events:?}"
);
}
#[test]
fn set_cmd_expands_placeholders_in_the_reloaded_command() {
let mut app = app_with_items(&["a"]);
app.input.value = "myquery".to_string();
let events = act(&mut app, Action::SetCmd("grep {q}".to_string()));
// The stored template keeps the placeholder, only the emitted command is expanded.
assert_eq!(app.cmd, "grep {q}");
let [Event::Reload(cmd)] = events.as_slice() else {
panic!("expected a reload event, got {events:?}");
};
assert!(cmd.contains("myquery"), "expected the query in `{cmd}`");
assert!(!cmd.contains("{q}"), "expected `{{q}}` to be expanded in `{cmd}`");
}
#[test]
fn set_cmd_is_picked_up_by_refresh_cmd() {
let mut app = App::default();
app.options.interactive = true;
app.cmd = "ls".to_string();
act(&mut app, Action::SetCmd("find .".to_string()));
let events = act(&mut app, Action::RefreshCmd);
assert!(
matches!(events.as_slice(), [Event::Reload(cmd)] if cmd == "find ."),
"refresh-cmd should re-run the command set by set-cmd, got {events:?}"
);
}
#[test]
fn reload_actions_emit_reload() {
let mut app = app_with_items(&["a"]);
@ -450,32 +551,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]
@ -506,30 +664,64 @@ fn next_history_at_most_recent_is_noop() {
}
#[test]
fn execute_action_runs_command() {
// Execute spawns a foreground command (toggling raw mode / alt screen) and
// returns a Redraw event. Use a no-op that exists in each platform's shell:
// `true` for `sh`, `rem` (a comment builtin) for `cmd`.
let noop = if cfg!(windows) { "rem" } else { "true" };
fn execute_action_expands_and_defers_to_event_loop() {
// `Execute` expands the command and hands it to the event loop via
// `Event::RunExecute`, rather than spawning it here (spawning needs the
// `Tui` to suspend skim's input reader). `{}` expands to the query since no
// item is selected.
let mut app = App::default();
let events = act(&mut app, Action::Execute(noop.to_string()));
assert!(events.iter().any(|e| matches!(e, Event::Redraw)));
app.input.value = "hello".to_string();
let events = act(&mut app, Action::Execute("echo {q}".to_string()));
assert!(
matches!(events.as_slice(), [Event::RunExecute(cmd)] if cmd.contains("hello")),
"expected RunExecute with expanded query, got {events:?}"
);
}
#[test]
fn run_execute_event_runs_command_and_restarts_reader() {
// Driving `Event::RunExecute` through `handle_event` should run the command
// to completion, restart skim's input reader, and queue a repaint. Under
// the test harness stderr is not a tty, so the raw-mode / alt-screen
// toggles are skipped and only the reader-suspend + spawn path is exercised.
use crate::tui::{Size, Tui};
use ratatui::backend::TestBackend;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let _guard = rt.enter();
let mut app = App::default();
let mut tui = Tui::new_with_height_and_backend(TestBackend::new(80, 24), Size::Percent(100))
.expect("failed to build test TUI");
let noop = if cfg!(windows) { "rem" } else { "true" };
rt.block_on(async {
app.handle_event(&mut tui, &Event::RunExecute(noop.to_string()))
.expect("handle_event failed");
});
// The reader task is (re)started after the child exits.
assert!(tui.task.is_some(), "reader task should be running after execute");
}
#[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]
@ -616,7 +808,7 @@ fn deselect_all_clears_existing_selection() {
#[test]
fn custom_action_runs_callback() {
use crate::tui::event::ActionCallback;
use crate::tui::actions::ActionCallback;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
@ -630,7 +822,7 @@ fn custom_action_runs_callback() {
#[test]
fn custom_action_runs_async_callback() {
use crate::tui::event::ActionCallback;
use crate::tui::actions::ActionCallback;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
@ -643,6 +835,76 @@ fn custom_action_runs_async_callback() {
assert!(events.iter().any(|e| matches!(e, Event::Action(Action::Abort))));
}
#[test]
fn bind_action_adds_action_chain() {
let mut app = App::default();
let key = crate::binds::parse_key("ctrl-x").unwrap();
// Not bound by default.
app.options.keymap.remove(&key);
assert!(app.options.keymap.get(&key).is_none());
act(&mut app, Action::Bind("ctrl-x:abort+up".to_string()));
assert_eq!(app.options.keymap.get(&key), Some(&vec![Action::Abort, Action::Up(1)]));
}
#[test]
fn bind_action_binds_multiple_comma_separated_keys() {
let mut app = App::default();
act(&mut app, Action::Bind("ctrl-x:abort,ctrl-y:select-all".to_string()));
let key_x = crate::binds::parse_key("ctrl-x").unwrap();
let key_y = crate::binds::parse_key("ctrl-y").unwrap();
assert_eq!(app.options.keymap.get(&key_x), Some(&vec![Action::Abort]));
assert_eq!(app.options.keymap.get(&key_y), Some(&vec![Action::SelectAll]));
}
#[test]
fn bind_action_replaces_existing_binding() {
let mut app = App::default();
// Enter is bound to Accept(None) by default.
let key = crate::binds::parse_key("enter").unwrap();
assert_eq!(app.options.keymap.get(&key), Some(&vec![Action::Accept(None)]));
act(&mut app, Action::Bind("enter:abort".to_string()));
assert_eq!(app.options.keymap.get(&key), Some(&vec![Action::Abort]));
}
#[test]
fn unbind_action_removes_keymap_entry() {
let mut app = App::default();
let key = crate::binds::parse_key("enter").unwrap();
assert!(app.options.keymap.get(&key).is_some());
act(&mut app, Action::Unbind("enter".to_string()));
assert!(app.options.keymap.get(&key).is_none());
}
#[test]
fn unbind_action_removes_multiple_comma_separated_keys() {
let mut app = App::default();
let key_up = crate::binds::parse_key("up").unwrap();
let key_down = crate::binds::parse_key("down").unwrap();
assert!(app.options.keymap.get(&key_up).is_some());
assert!(app.options.keymap.get(&key_down).is_some());
act(&mut app, Action::Unbind("up,down".to_string()));
assert!(app.options.keymap.get(&key_up).is_none());
assert!(app.options.keymap.get(&key_down).is_none());
}
#[test]
fn unbind_action_ignores_unparseable_keys() {
let mut app = App::default();
// A bogus key name is skipped without panicking; valid keys are still removed.
let key = crate::binds::parse_key("enter").unwrap();
act(&mut app, Action::Unbind("not-a-key,enter".to_string()));
assert!(app.options.keymap.get(&key).is_none());
}
#[test]
fn handle_key_maps_plain_char_to_add_char() {
let mut app = App::default();
@ -712,6 +974,23 @@ fn handle_key_ctrl_non_char_falls_through_to_empty() {
assert!(app.handle_key(&key).is_empty());
}
#[test]
fn handle_key_ignore_numlock() {
let mut app = App::default();
app.options.keymap.add_keymaps_str("ctrl-a:add-char(a)");
// NUM_LOCK must not prevent the Ctrl+a binding from matching.
let key = KeyEvent::new_with_kind_and_state(
KeyCode::Char('a'),
KeyModifiers::CONTROL,
crossterm::event::KeyEventKind::Press,
crossterm::event::KeyEventState::NUM_LOCK,
);
assert!(matches!(
app.handle_key(&key).as_slice(),
&[Event::Action(Action::AddChar('a'))]
));
}
#[test]
fn expand_cmd_substitutes_query() {
let mut app = App::default();
@ -762,6 +1041,13 @@ fn resize_updates_layout() {
assert_eq!(app.layout.list_area.width, 120);
}
#[test]
fn resize_fixed_updates_layout() {
let mut app = App::default();
app.resize(120, 40);
assert_eq!(app.layout.list_area.width, 120);
}
#[test]
fn restart_matcher_short_query_clears_items() {
let mut app = app_with_items(&["a", "b"]);
@ -1107,6 +1393,33 @@ fn handle_event_resize_reflows_and_reruns_preview() {
assert_eq!(app.layout.list_area.width, 60);
}
#[test]
fn handle_event_resize_fixed_resizes_terminal() {
let mut app = app_with_items(&["a", "b"]);
let _ = render(&mut app, 40, 10);
assert_eq!(app.layout.list_area.width, 40);
assert_eq!(app.layout.list_area.height, 8);
let mut tui = test_tui();
let curr_rect = tui.terminal.get_frame().area();
tui.terminal = ratatui::Terminal::with_options(
tui.backend().clone(),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(Rect {
height: 20,
..curr_rect
}),
},
)
.unwrap();
tui.is_fullscreen = false;
assert_eq!(tui.terminal.get_frame().area().height, 20);
app.handle_event(&mut tui, &Event::Resize(40, 10)).unwrap();
assert_eq!(tui.terminal.get_frame().area().height, 10);
}
// ---------------------------------------------------------------------------
// Mouse handling via handle_event(Event::Mouse(..)).
// ---------------------------------------------------------------------------
@ -1223,6 +1536,39 @@ fn mouse_selection_change_requests_preview() -> Result<()> {
Ok(())
}
#[test]
fn double_click_keeps_first_click_and_emits_binding_event() -> Result<()> {
let mut app = app_with_items(&["a", "b", "c"]);
let _ = render(&mut app, 40, 6);
let inner = app.list_inner_area();
let item_one_row = inner.y + inner.height - 2;
let first = app.handle_mouse(mouse_down(inner.x, item_one_row))?;
assert_eq!(app.item_list.current, 1);
assert!(first.iter().any(|event| matches!(event, Event::RunPreview)));
assert!(
!first
.iter()
.any(|event| matches!(event, Event::Key(key) if *key == SkimEvent::DoubleClick.key_event()))
);
let second = app.handle_mouse(mouse_down(inner.x, item_one_row))?;
assert!(
second
.iter()
.any(|event| matches!(event, Event::Key(key) if *key == SkimEvent::DoubleClick.key_event()))
);
app.last_left_click = past_instant(std::time::Duration::from_millis(501));
let late = app.handle_mouse(mouse_down(inner.x, item_one_row))?;
assert!(
!late
.iter()
.any(|event| matches!(event, Event::Key(key) if *key == SkimEvent::DoubleClick.key_event()))
);
Ok(())
}
#[test]
fn mouse_selection_same_item_only_requests_render() -> Result<()> {
let mut app = app_with_items(&["a", "b", "c", "d", "e", "f"]);

View file

@ -1,13 +1,15 @@
use std::io::BufWriter;
use std::io::{BufWriter, stderr};
use std::ops::{Deref, DerefMut};
use std::process::Stdio;
use std::sync::Once;
use color_eyre::eyre::Result;
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, KeyEventKind,
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{self, cursor};
use eyre::Result;
use futures::{FutureExt as _, StreamExt as _};
use ratatui::layout::Rect;
use ratatui::prelude::{Backend, CrosstermBackend};
@ -40,6 +42,8 @@ where
pub cancellation_token: CancellationToken,
/// Whether running in fullscreen mode
pub is_fullscreen: bool,
/// The terminal's rect (drawing) area, set if the layout is inline
rect: Option<Rect>,
enable_mouse: bool,
}
@ -50,7 +54,7 @@ impl Tui {
///
/// Returns an error if the TUI backend cannot be initialized.
pub fn new_with_height(height: Size) -> Result<Self> {
let backend = CrosstermBackend::new(std::io::BufWriter::new(std::io::stderr()));
let backend = CrosstermBackend::new(std::io::BufWriter::new(stderr()));
Self::new_with_height_and_backend(backend, height)
}
/// Disable mouse handling.
@ -85,6 +89,7 @@ where
Size::Neg(lines) => Some(term_height.saturating_sub(lines)),
};
let rect: Option<Rect>;
let viewport = if let Some(mut height) = lines {
// Until https://github.com/crossterm-rs/crossterm/issues/919 is fixed, we need to do it ourselves
let cursor_pos = cursor_pos_from_tty()?;
@ -92,16 +97,18 @@ where
height = height.min(term_height);
if term_height - cursor_pos.1 < height {
let to_scroll = height - (term_height - cursor_pos.1) - 1;
crossterm::execute!(std::io::stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
y = y.saturating_sub(to_scroll);
}
Viewport::Fixed(Rect::new(
rect = Some(Rect::new(
0,
y,
backend.size().expect("Failed to get terminal width").width - 1,
height,
))
));
Viewport::Fixed(rect.unwrap())
} else {
rect = None;
Viewport::Fullscreen
};
@ -109,6 +116,7 @@ where
Ok(Self {
terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })?,
task: None,
rect,
event_rx: event_channel.1,
event_tx: event_channel.0,
tick_rate: f64::from(TICK_RATE),
@ -144,13 +152,7 @@ where
#[cfg(windows)]
super::windows::install_ctrl_c_handler()?;
crossterm::execute!(std::io::stderr(), EnableBracketedPaste)?;
if self.enable_mouse {
crossterm::execute!(std::io::stderr(), EnableMouseCapture)?;
}
if self.is_fullscreen {
crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
}
self.execute_enter()?;
Ok(())
}
@ -171,7 +173,7 @@ where
let area = self.get_frame().area();
let orig = ratatui::layout::Position { x: area.x, y: area.y };
crossterm::execute!(
std::io::stderr(),
stderr(),
cursor::MoveTo(orig.x, orig.y),
Clear(ClearType::FromCursorDown)
)?;
@ -184,6 +186,47 @@ where
pub fn stop(&self) {
self.cancel();
}
/// Forces the next [`draw`](ratatui::Terminal::draw) to repaint every cell.
///
/// ratatui only writes cells that differ from the previously drawn buffer.
/// After the display has been disturbed out from under it — e.g. an
/// `execute` action that ran a child program and re-entered the alternate
/// screen — that cached buffer is stale and a normal draw would leave the
/// screen partially blank. Resetting *both* double buffers makes the next
/// draw diff against an empty buffer and thus repaint everything.
///
/// Unlike [`ratatui::Terminal::clear`], this performs no cursor-position
/// query (which crossterm writes to stdout and which stalls when stdout is
/// redirected), and it is viewport-agnostic (works for fullscreen and
/// inline layouts alike).
pub fn force_full_redraw(&mut self) {
self.terminal.swap_buffers();
self.terminal.swap_buffers();
}
/// Stops the input reader and waits for it to release the terminal.
///
/// Unlike [`stop`](Self::stop), this blocks until the background task has
/// observed the cancellation and dropped its `EventStream`, so crossterm's
/// internal reader thread has stopped reading the terminal before this
/// returns. Call this before handing the terminal to a foreground child
/// process (e.g. an `execute` action): otherwise skim's reader competes
/// with the child for keystrokes and interactive TUIs appear to freeze.
///
/// Restart the reader afterwards with [`start`](Self::start).
///
/// # Panics
///
/// Panics if called from outside a multi-threaded Tokio runtime, since it
/// uses `block_in_place` to await the reader task from synchronous code.
pub fn stop_and_join(&mut self) {
self.cancel();
if let Some(task) = self.task.take() {
// We are on a synchronous call stack nested inside the async event
// loop. `block_in_place` moves this worker off the async pool so we
// can block on the task's completion without starving the runtime.
let _ = tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(task));
}
}
/// Cancels all background tasks
pub fn cancel(&self) {
self.cancellation_token.cancel();
@ -192,10 +235,17 @@ where
pub fn start(&mut self) {
let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
let event_tx_clone = self.event_tx.clone();
let cancellation_token_clone = self.cancellation_token.clone();
// Cancel any previously running reader before spawning a new one.
if self.task.is_some() {
self.cancel();
}
// Install a fresh cancellation token: a `CancellationToken` stays
// cancelled once cancelled, so reusing the old one (after `stop`,
// `stop_and_join`, or a prior `start`) would make the new task observe
// the cancellation immediately and exit without reading any input.
// This is what lets the reader resume after an `execute` action.
self.cancellation_token = CancellationToken::new();
let cancellation_token_clone = self.cancellation_token.clone();
self.task = Some(tokio::spawn(async move {
let mut reader = crossterm::event::EventStream::new();
let mut tick_interval = tokio::time::interval(tick_delay);
@ -241,6 +291,142 @@ where
pub async fn next(&mut self) -> Option<Event> {
self.event_rx.recv().await
}
fn execute_enter(&self) -> Result<()> {
crossterm::execute!(stderr(), EnableBracketedPaste)?;
if self.enable_mouse {
crossterm::execute!(stderr(), EnableMouseCapture)?;
}
if self.is_fullscreen {
crossterm::execute!(stderr(), EnterAlternateScreen, cursor::Hide)?;
}
if let Err(e) = crossterm::execute!(
stderr(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
) {
warn!("Failed to enable keyboard enhancement flags: {e}");
}
Ok(())
}
fn execute_leave(&self) -> Result<()> {
crossterm::execute!(stderr(), DisableBracketedPaste)?;
if self.enable_mouse {
crossterm::execute!(stderr(), DisableMouseCapture)?;
}
if let Err(e) = crossterm::execute!(stderr(), PopKeyboardEnhancementFlags) {
warn!("Failed to remove keyboard enhancement flags: {e}");
}
if self.is_fullscreen {
crossterm::execute!(stderr(), LeaveAlternateScreen, cursor::Show)?;
}
Ok(())
}
/// Pauses the TUI by disabling raw mode, exiting alternate screen etc.
/// Returns true if we were in raw mode, false otherwise. Used to restore to the same state later.
///
/// # Errors
///
/// This propagates the errors of the `disable_raw_mode` and `crossterm::execute` calls.
pub fn pause(&mut self) -> Result<bool> {
let in_raw_mode = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
if in_raw_mode {
crossterm::terminal::disable_raw_mode()?;
}
self.execute_leave()?;
Ok(in_raw_mode)
}
/// Resumes the TUI after a `pause()`
/// Takes `in_raw_mode`, the boolean returned by `pause()`
///
/// # Errors
/// This propagates the errors of the `disable_raw_mode` and `crossterm::execute` calls.
pub fn resume(&mut self, in_raw_mode: bool) -> Result<()> {
if in_raw_mode {
crossterm::terminal::enable_raw_mode()?;
}
self.execute_enter()?;
Ok(())
}
/// Run a command in the foreground, temporarily handing it the terminal.
///
/// This suspends skim's own input reader (via [`Tui::stop_and_join`]) so it
/// does not compete with the child for terminal input — the cause of
/// interactive TUIs freezing after a few keystrokes — leaves the alternate
/// screen and raw mode, runs the command to completion, then restores skim's
/// terminal state and restarts the reader. The child is given its own handle
/// to the controlling terminal as stdin (see [`execute_child_stdin`]).
pub(crate) fn run_execute(&mut self, cmd: &str) -> Result<()> {
use std::io::IsTerminal as _;
let has_tty = std::io::stderr().is_terminal();
let mut in_raw_mode = false;
// Stop skim's input reader and wait for it to release the terminal, so the
// child is the only reader of keystrokes while it runs.
self.stop_and_join();
if has_tty {
in_raw_mode = self.pause()?;
}
let mut command = crate::shell_cmd(cmd);
command.stdin(execute_child_stdin());
let _ = command.spawn().and_then(|mut c| c.wait());
let mut restore_result = Ok(());
if has_tty {
restore_result = self.resume(in_raw_mode);
}
// Resume skim's input reader now that the terminal is ours again.
self.start();
restore_result
}
/// Set the minimum height of an inline viewport.
///
/// Scrolls the terminal when there are not enough rows below the viewport's
/// current origin.
///
/// # Errors
///
/// Returns an error if the terminal size cannot be read, the terminal cannot
/// be scrolled, or the viewport cannot be resized.
pub fn min_height(&mut self, min_height: u16) -> Result<()> {
if self.is_fullscreen {
return Ok(());
}
let Some(current_rect) = self.rect else {
return Ok(());
};
let terminal_height = self.backend().size()?.height;
let (rect, to_scroll) = rect_with_min_height(current_rect, min_height, terminal_height);
if rect == current_rect {
return Ok(());
}
if to_scroll > 0 {
crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
}
debug!("min_height: resizing TUI to {rect:?}");
self.resize(rect)?;
self.rect = Some(rect);
Ok(())
}
}
fn rect_with_min_height(mut rect: Rect, min_height: u16, terminal_height: u16) -> (Rect, u16) {
rect.height = rect.height.max(min_height).min(terminal_height);
let lowest_origin = terminal_height.saturating_sub(rect.height);
let to_scroll = rect.y.saturating_sub(lowest_origin);
rect.y = rect.y.saturating_sub(to_scroll);
(rect, to_scroll)
}
impl<B: Backend> Deref for Tui<B>
@ -294,8 +480,11 @@ fn set_panic_hook() {
/// - Escape sequences are written atomically to stderr
/// - `SetConsoleMode` (used by `disable_raw_mode`) is thread-safe on Windows
pub(crate) fn cleanup_terminal() -> std::io::Result<()> {
if let Err(e) = crossterm::execute!(stderr(), PopKeyboardEnhancementFlags) {
warn!("Failed to remove keyboard enhancement flags: {e}");
}
crossterm::execute!(
std::io::stderr(),
stderr(),
DisableMouseCapture,
DisableBracketedPaste,
LeaveAlternateScreen,
@ -305,6 +494,22 @@ pub(crate) fn cleanup_terminal() -> std::io::Result<()> {
Ok(())
}
/// Build the stdin handle for an `execute` child process.
///
/// skim's own stdin (fd 0) is frequently a pipe carrying the item list
/// (e.g. `find | sk`), which is useless as a keyboard source for an
/// interactive child. Hand the child a fresh handle to the controlling
/// terminal instead, so programs like `ncdu` or other ncurses TUIs can read
/// the keyboard even when skim's stdin is a pipe. Falls back to inheriting
/// skim's stdin if the terminal cannot be opened.
fn execute_child_stdin() -> std::process::Stdio {
#[cfg(unix)]
let tty = std::fs::File::open("/dev/tty");
#[cfg(windows)]
let tty = std::fs::OpenOptions::new().read(true).write(true).open("CONIN$");
tty.map_or_else(|_| Stdio::inherit(), Stdio::from)
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
@ -317,6 +522,14 @@ mod tests {
.expect("failed to build test TUI")
}
fn inline_tui(rect: Rect) -> Tui<TestBackend> {
let mut tui = fullscreen_tui();
tui.is_fullscreen = false;
tui.rect = Some(rect);
tui.resize(rect).expect("failed to set initial viewport");
tui
}
#[test]
fn new_with_full_height_is_fullscreen() {
let tui = fullscreen_tui();
@ -348,4 +561,50 @@ mod tests {
assert_eq!(area.width, 80);
assert_eq!(area.height, 24);
}
#[test]
fn min_height_resizes_and_scrolls_inline_terminal() {
let mut tui = inline_tui(Rect::new(0, 20, 79, 4));
tui.min_height(10).expect("failed to apply minimum height");
assert_eq!(tui.get_frame().area(), Rect::new(0, 14, 79, 10));
assert_eq!(tui.rect, Some(Rect::new(0, 14, 79, 10)));
}
#[test]
fn min_height_keeps_origin_when_rows_are_available() {
let rect = Rect::new(0, 5, 79, 4);
let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
assert_eq!(rect, Rect::new(0, 5, 79, 10));
assert_eq!(to_scroll, 0);
}
#[test]
fn min_height_scrolls_to_make_room() {
let rect = Rect::new(0, 20, 79, 4);
let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
assert_eq!(rect, Rect::new(0, 14, 79, 10));
assert_eq!(to_scroll, 6);
}
#[test]
fn min_height_is_limited_to_terminal_height() {
let rect = Rect::new(0, 20, 79, 4);
let (rect, to_scroll) = rect_with_min_height(rect, 30, 24);
assert_eq!(rect, Rect::new(0, 0, 79, 24));
assert_eq!(to_scroll, 20);
}
#[test]
fn existing_height_is_limited_to_terminal_height() {
let rect = Rect::new(0, 0, 79, 30);
let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
assert_eq!(rect, Rect::new(0, 0, 79, 24));
assert_eq!(to_scroll, 0);
}
}

View file

@ -1,111 +1,8 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use crate::exhaustive_match;
use crossterm::event::{KeyEvent, MouseEvent};
use derive_more::{Debug, Eq, PartialEq};
type BoxError = Box<dyn std::error::Error + Sync + Send>;
type BoxFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<Event>, BoxError>> + Send + 'a>>;
/// Trait object stored inside [`ActionCallback`].
///
/// Having an explicit trait (rather than a bare `dyn Fn` type alias) allows
/// Rust to correctly resolve the higher-ranked lifetime in the return type.
trait AsyncCallbackFn: Send {
fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a>;
}
/// Adapter that stores a concrete async closure and implements [`AsyncCallbackFn`].
struct AsyncFnWrapper<F>(F);
impl<F, Fut> AsyncCallbackFn for AsyncFnWrapper<F>
where
F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send,
Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
{
fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
Box::pin((self.0)(app))
}
}
/// Adapter that stores a plain synchronous closure and implements [`AsyncCallbackFn`].
struct SyncFnWrapper<F>(F);
impl<F> AsyncCallbackFn for SyncFnWrapper<F>
where
F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send,
{
fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
Box::pin(std::future::ready((self.0)(app)))
}
}
/// A custom action callback that receives a mutable reference to the App.
///
/// The closure will be called with a mutable reference to App and should return
/// a vec of events that will be processed after the callback completes.
///
/// Both sync and async closures are supported:
/// - Use [`ActionCallback::new`] to wrap an **async** closure or block.
/// - Use [`ActionCallback::new_sync`] to wrap a plain synchronous closure.
#[derive(Clone)]
pub struct ActionCallback(Arc<Mutex<dyn AsyncCallbackFn>>);
impl std::fmt::Debug for ActionCallback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ActionCallback").finish()
}
}
impl ActionCallback {
/// Create a new action callback from an **async** closure or block.
///
/// ```rust,ignore
/// ActionCallback::new(|app| async move {
/// // async work here …
/// Ok(vec![])
/// });
/// ```
pub fn new<F, Fut>(f: F) -> Self
where
F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send + 'static,
Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
{
Self(Arc::new(Mutex::new(AsyncFnWrapper(f))))
}
/// Create a new action callback from a plain **synchronous** closure.
///
/// This is a convenience wrapper; the closure is lifted into an immediately-
/// resolving future so it integrates with the same async call site.
///
/// ```rust,ignore
/// ActionCallback::new_sync(|app| {
/// Ok(vec![Event::Action(Action::SelectAll)])
/// });
/// ```
pub fn new_sync<F>(f: F) -> Self
where
F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send + 'static,
{
Self(Arc::new(Mutex::new(SyncFnWrapper(f))))
}
/// Call the callback with an App reference, driving the returned future to completion.
///
/// Must be called from within a Tokio multi-thread runtime context.
pub(crate) fn call(&self, app: &mut crate::tui::App) -> Result<Vec<Event>, BoxError> {
let callback = self.0.lock().unwrap();
let fut = callback.call(app);
// We are inside a synchronous call stack that originates from an async
// tokio context. `block_in_place` moves the current thread out of the
// async worker pool temporarily so we can block on the future without
// starving the runtime.
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
}
}
pub use super::actions::{Action, ActionCallback, parse_action};
/// Events that can occur during skim's execution
#[derive(Clone, Debug)]
@ -142,6 +39,13 @@ pub enum Event {
Heartbeat,
/// Run the preview command
RunPreview,
/// Run a command in the foreground, handing it the terminal
///
/// Carries the already-expanded command line. Handled by the TUI event
/// loop (which has access to the [`Tui`](crate::tui::Tui)) rather than by
/// `handle_action`, because running a foreground process requires
/// suspending skim's own input reader and toggling terminal modes.
RunExecute(String),
/// Redraw the screen
Redraw,
/// Reload with a new command
@ -149,287 +53,3 @@ pub enum Event {
/// Terminal was resized to (columns, rows)
Resize(u16, u16),
}
/// Actions that can be performed in skim
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "listen", derive(serde::Serialize, serde::Deserialize))]
pub enum Action {
/// Abort and exit with error
Abort,
/// Accept selection and exit with optional key
Accept(Option<String>),
/// Add a character to the query
AddChar(char),
/// Append to selection and select
AppendAndSelect,
/// Move cursor backward one character
BackwardChar,
/// Delete character before cursor
BackwardDeleteChar,
/// Delete character before cursor or exit if the query is empty
BackwardDeleteCharEof,
/// Delete word before cursor
BackwardKillWord,
/// Move cursor backward one word
BackwardWord,
/// Move cursor to beginning of line
BeginningOfLine,
/// Cancel current operation
Cancel,
/// Clear the screen
ClearScreen,
/// Delete character under cursor
DeleteChar,
/// Delete character or exit if empty
DeleteCharEof,
/// Deselect all items
DeselectAll,
/// Move selection down by N items
Down(u16),
/// Move cursor to end of line
EndOfLine,
/// Execute a command
Execute(String),
/// Execute a command silently
ExecuteSilent(String),
/// Jump to first item in list
First,
/// Move cursor forward one character
ForwardChar,
/// Move cursor forward one word
ForwardWord,
/// Execute action if query is empty
IfQueryEmpty(String, Option<String>),
/// Execute action if query is not empty
IfQueryNotEmpty(String, Option<String>),
/// Execute action if no items match
IfNonMatched(String, Option<String>),
/// Ignore the action
Ignore,
/// Delete from cursor to end of line
KillLine,
/// Delete word after cursor
KillWord,
/// Jump to last item in list
Last,
/// Move to next history entry
NextHistory,
/// Scroll down by half a page
HalfPageDown(i32),
/// Scroll up by half a page
HalfPageUp(i32),
/// Scroll down by a page
PageDown(i32),
/// Scroll up by a page
PageUp(i32),
/// Scroll preview up
PreviewUp(i32),
/// Scroll preview down
PreviewDown(i32),
/// Scroll preview left
PreviewLeft(i32),
/// Scroll preview right
PreviewRight(i32),
/// Scroll preview up by a page
PreviewPageUp(i32),
/// Scroll preview down by a page
PreviewPageDown(i32),
/// Move to previous history entry
PreviousHistory,
/// Redraw the screen
Redraw,
/// Refresh the command
RefreshCmd,
/// Refresh the preview
RefreshPreview,
/// Restart the matcher
RestartMatcher,
/// Reload with optional new command
Reload(Option<String>),
/// Rotate through matching modes
RotateMode,
/// Scroll item list left
ScrollLeft(i32),
/// Scroll item list right
ScrollRight(i32),
/// Select all items
SelectAll,
/// Select a specific row
SelectRow(usize),
/// Select current item
Select,
/// Set the header (or disable it on an empty value)
SetHeader(Option<String>),
/// Set the preview cmd and rerun preview
SetPreviewCmd(String),
/// Set the query to the expanded value
SetQuery(String),
/// Toggle selection of current item
Toggle,
/// Toggle selection of all items
ToggleAll,
/// Toggle and move in
ToggleIn,
/// Toggle interactive mode
ToggleInteractive,
/// Toggle and move out
ToggleOut,
/// Toggle preview visibility
TogglePreview,
/// Toggle preview line wrapping
TogglePreviewWrap,
/// Toggle sorting
ToggleSort,
/// Jump to first item in list (alias for First)
Top,
/// Discard line (unix-style)
UnixLineDiscard,
/// Delete word backward (unix-style)
UnixWordRubout,
/// Move selection up by N items
Up(u16),
/// Yank (paste)
Yank,
/// Custom action from lib
#[debug("custom")]
#[eq(skip)]
#[partial_eq(skip)]
#[cfg_attr(feature = "listen", serde(skip))]
Custom(ActionCallback),
}
/// Parses an action string into an Action enum
///
/// Returns `None` if the action is unrecognized, or an `if-*` action is
/// specified without its required argument.
#[allow(clippy::too_many_lines)]
#[must_use]
pub fn parse_action(raw_action: &str) -> Option<Action> {
let parts = raw_action.split_once([':', '(', ')']);
let action;
let mut arg = None;
match parts {
None => action = raw_action,
Some((act, "")) => action = act,
Some((act, a)) => {
action = act;
arg = Some(a.trim_end_matches(')').to_string());
}
}
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!(
action,
"add-char" | "execute" | "execute-silent" | "set-preview-cmd" | "set-query"
) && 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),
"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),
"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
}
}
}
#[cfg(test)]
#[path = "event_tests.rs"]
mod tests;

View file

@ -1,190 +0,0 @@
use super::*;
const NO_ARG_ACTIONS: &[&str] = &[
"abort",
"append-and-select",
"backward-char",
"backward-delete-char",
"backward-delete-char/eof",
"backward-kill-word",
"backward-word",
"beginning-of-line",
"cancel",
"clear-screen",
"delete-char",
"delete-char/eof",
"deselect-all",
"end-of-line",
"first",
"forward-char",
"forward-word",
"ignore",
"kill-line",
"kill-word",
"last",
"next-history",
"previous-history",
"redraw",
"refresh-cmd",
"refresh-preview",
"restart-matcher",
"rotate-mode",
"select",
"select-all",
"toggle",
"toggle-all",
"toggle-in",
"toggle-interactive",
"toggle-out",
"toggle-preview",
"toggle-preview-wrap",
"toggle-sort",
"top",
"unix-line-discard",
"unix-word-rubout",
"yank",
];
#[test]
fn parse_all_no_arg_actions() {
for name in NO_ARG_ACTIONS {
assert!(parse_action(name).is_some(), "expected `{name}` to parse");
}
}
#[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)));
}
#[test]
fn parse_numeric_actions_with_colon_arg() {
assert_eq!(parse_action("down:3"), Some(Action::Down(3)));
assert_eq!(parse_action("up:5"), Some(Action::Up(5)));
assert_eq!(parse_action("half-page-down:2"), Some(Action::HalfPageDown(2)));
assert_eq!(parse_action("preview-up:4"), Some(Action::PreviewUp(4)));
assert_eq!(parse_action("select-row:7"), Some(Action::SelectRow(7)));
}
#[test]
fn parse_numeric_actions_with_paren_arg() {
assert_eq!(parse_action("down(3)"), Some(Action::Down(3)));
assert_eq!(parse_action("scroll-right(2)"), Some(Action::ScrollRight(2)));
}
#[test]
fn parse_string_arg_actions() {
assert_eq!(
parse_action("execute:ls -la"),
Some(Action::Execute("ls -la".to_string()))
);
assert_eq!(
parse_action("execute-silent:touch x"),
Some(Action::ExecuteSilent("touch x".to_string()))
);
assert_eq!(
parse_action("set-query:hello"),
Some(Action::SetQuery("hello".to_string()))
);
assert_eq!(
parse_action("set-preview-cmd:cat {}"),
Some(Action::SetPreviewCmd("cat {}".to_string()))
);
assert_eq!(parse_action("add-char:z"), Some(Action::AddChar('z')));
}
#[test]
fn parse_optional_arg_actions() {
assert_eq!(parse_action("accept"), Some(Action::Accept(None)));
assert_eq!(
parse_action("accept:enter"),
Some(Action::Accept(Some("enter".to_string())))
);
assert_eq!(parse_action("set-header"), Some(Action::SetHeader(None)));
assert_eq!(parse_action("reload"), Some(Action::Reload(None)));
assert_eq!(
parse_action("reload:find ."),
Some(Action::Reload(Some("find .".to_string())))
);
}
#[test]
fn parse_if_chains_then_only() {
assert_eq!(
parse_action("if-query-empty:abort"),
Some(Action::IfQueryEmpty("abort".to_string(), None))
);
assert_eq!(
parse_action("if-non-matched:ignore"),
Some(Action::IfNonMatched("ignore".to_string(), None))
);
}
#[test]
fn parse_if_chains_then_and_else() {
assert_eq!(
parse_action("if-query-not-empty:abort+ignore"),
Some(Action::IfQueryNotEmpty("abort".to_string(), Some("ignore".to_string())))
);
}
#[test]
fn parse_numeric_action_with_invalid_arg_falls_back_to_default() {
// A non-numeric argument is ignored and the default count is used.
assert_eq!(parse_action("down:abc"), Some(Action::Down(1)));
assert_eq!(parse_action("page-up:xyz"), Some(Action::PageUp(1)));
// SelectRow defaults to 0 rather than 1.
assert_eq!(parse_action("select-row:nope"), Some(Action::SelectRow(0)));
}
#[test]
fn parse_unknown_action_returns_none() {
assert_eq!(parse_action("not-a-real-action"), None);
}
#[test]
fn parse_action_trailing_separator_yields_no_arg() {
// A separator with nothing after it (`act:`) is treated as if no argument
// was supplied, so optional-arg actions fall back to their `None` form
// rather than being handed an empty string.
assert_eq!(parse_action("accept:"), Some(Action::Accept(None)));
assert_eq!(parse_action("reload:"), Some(Action::Reload(None)));
assert_eq!(parse_action("set-header:"), Some(Action::SetHeader(None)));
// Numeric actions fall back to their default count for the same reason.
assert_eq!(parse_action("down:"), Some(Action::Down(1)));
assert_eq!(parse_action("select-row:"), Some(Action::SelectRow(0)));
}
#[test]
fn parse_if_chain_with_trailing_plus_has_empty_else() {
// A trailing `+` yields a then-branch with no otherwise-branch.
assert_eq!(
parse_action("if-query-empty:abort+"),
Some(Action::IfQueryEmpty("abort".to_string(), None))
);
}
#[test]
fn parse_if_chain_unknown_kind_returns_none() {
// An `if-` prefixed action that is not one of the known kinds is rejected.
assert_eq!(parse_action("if-bogus:abort"), None);
}
#[test]
fn action_callback_debug_is_opaque() {
let cb = ActionCallback::new_sync(|_app| Ok(vec![]));
assert_eq!(format!("{cb:?}"), "ActionCallback");
}
#[test]
fn action_callback_async_constructor_builds() {
// The async constructor wraps the closure without invoking it.
let cb = ActionCallback::new(|_app| async move { Ok(vec![Event::Render]) });
// Cloning shares the same inner callback.
let _clone = cb.clone();
assert_eq!(format!("{cb:?}"), "ActionCallback");
}

View file

@ -165,12 +165,9 @@ impl Input {
}
pub fn insert_str(&mut self, s: &str) {
self.value.insert_str(self.cursor_pos as usize, s);
self.move_cursor(
s.chars()
.count()
.try_into()
.expect("Failed to fit inserted str len into an i32"),
);
// `cursor_pos` is a byte offset (see `move_cursor_to`), so advance by the
// inserted byte length, not the char count.
self.move_cursor(s.len().try_into().expect("Failed to fit inserted str len into an i32"));
}
fn nchars(&self) -> usize {
self.value.chars().count()
@ -503,30 +500,40 @@ impl SkimWidget for Input {
.render(area, buf);
}
}
InfoDisplay::Default => {
InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right => {
// Default mode: render status as block title (separate line)
// In normal layout: status above input (title_top)
// In reverse layout: status below input (title_bottom)
//
// Left and Right modes pack both titles together at the corresponding edge.
if let Some(ref status) = self.status_info {
let left_title = status.left_title();
let right_title = status.right_title();
if self.reverse {
block = block
.title_bottom(Line::from(left_title).style(self.theme.info).alignment(Alignment::Left))
.title_bottom(
Line::from(right_title)
.style(self.theme.info)
.alignment(Alignment::Right),
);
if matches!(self.info.display, InfoDisplay::Left | InfoDisplay::Right) {
let alignment = if self.info.display == InfoDisplay::Left {
Alignment::Left
} else {
Alignment::Right
};
let title = Line::from(format!("{left_title} {right_title}"))
.style(self.theme.info)
.alignment(alignment);
block = if self.reverse {
block.title_bottom(title)
} else {
block.title_top(title)
};
} else {
block = block
.title_top(Line::from(left_title).style(self.theme.info).alignment(Alignment::Left))
.title_top(
Line::from(right_title)
.style(self.theme.info)
.alignment(Alignment::Right),
);
let info_line = Line::from(left_title).style(self.theme.info).alignment(Alignment::Left);
let index_line = Line::from(right_title)
.style(self.theme.info)
.alignment(Alignment::Right);
block = if self.reverse {
block.title_bottom(info_line).title_bottom(index_line)
} else {
block.title_top(info_line).title_top(index_line)
};
}
}

View file

@ -236,3 +236,36 @@ fn input_render_writes_prompt_and_value() {
}
assert!(text.contains("hello"));
}
#[test]
fn insert_str_leaves_cursor_at_end_for_multibyte() {
// `cursor_pos` is a byte offset, so advancing it by char count left the cursor
// inside the inserted text whenever a character was wider than one byte.
let mut input = Input::default();
input.insert_str("中文");
assert_eq!(input.cursor_pos as usize, "中文".len());
}
#[test]
fn consecutive_insert_str_preserves_order_for_multibyte() {
// Bracketed paste and IMEs deliver whole strings, so a wrong cursor position
// made the next chunk land in the middle of the previous one.
let mut input = Input::default();
input.insert_str("中文");
input.insert_str("测试");
assert_eq!(input.value, "中文测试");
}
#[test]
fn insert_str_matches_repeated_insert_for_multibyte() {
let mut by_str = Input::default();
by_str.insert_str("中文");
let mut by_char = Input::default();
for c in "中文".chars() {
by_char.insert(c);
}
assert_eq!(by_str.value, by_char.value);
assert_eq!(by_str.cursor_pos, by_char.cursor_pos);
}

View file

@ -1,5 +1,6 @@
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use indexmap::IndexSet;
use ratatui::widgets::{
@ -10,7 +11,6 @@ use regex::Regex;
use crate::item::MatchedItem;
use crate::options::feature_flag;
use crate::spinlock::SpinLock;
use crate::theme::ColorTheme;
use crate::tui::BorderType;
use crate::tui::item_renderer::ItemRenderer;
@ -28,12 +28,15 @@ pub(crate) enum MergeStrategy {
SortedMerge,
/// Append to existing list without sorting (for --no-sort)
Append,
/// Prepend to existing list without sorting (for --tac --no-sort)
Prepend,
}
/// Processed items ready for rendering
pub(crate) struct ProcessedItems {
pub(crate) items: Vec<MatchedItem>,
pub(crate) merge: MergeStrategy,
pub(crate) generation: usize,
}
impl Default for ProcessedItems {
@ -41,6 +44,7 @@ impl Default for ProcessedItems {
Self {
items: Vec::new(),
merge: MergeStrategy::Replace,
generation: 0,
}
}
}
@ -50,7 +54,8 @@ impl Default for ProcessedItems {
pub struct ItemList {
pub(crate) items: Vec<MatchedItem>,
pub(crate) selection: IndexSet<MatchedItem>,
pub(crate) processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
pub(crate) processed_items: Arc<Mutex<Option<ProcessedItems>>>,
pub(crate) matcher_generation: Arc<AtomicUsize>,
pub(crate) direction: ListDirection,
pub(crate) offset: usize,
/// How many leading sub-lines of items[offset] have been scrolled off the top.
@ -125,6 +130,27 @@ impl ItemList {
self.showing_stale_items = false;
}
/// Prepends a batch while preserving either the head-following behavior or
/// the item currently focused by a user who has moved away from the head.
fn prepend(&mut self, mut items: Vec<MatchedItem>) {
if items.is_empty() {
return;
}
let added = items.len();
let follows_head = self.current == 0;
items.append(&mut self.items);
self.items = items;
if follows_head {
self.offset = 0;
self.sub_offset = 0;
} else {
self.current = self.current.saturating_add(added);
self.offset = self.offset.saturating_add(added);
}
}
/// Toggles the selection state of the item at the given index
pub fn toggle_at(&mut self, index: usize) {
if self.items.is_empty() {
@ -397,7 +423,8 @@ impl SkimWidget for ItemList {
(None, 0)
};
let processed_items = Arc::new(SpinLock::new(None));
let processed_items = Arc::new(Mutex::new(None));
let matcher_generation = Arc::new(AtomicUsize::new(0));
let interactive = options.interactive;
let no_clear_if_empty = options.no_clear_if_empty;
@ -406,6 +433,7 @@ impl SkimWidget for ItemList {
// Spawn background processing thread with the appropriate configuration
Self {
processed_items,
matcher_generation,
reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
direction: match options.layout {
TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
@ -485,8 +513,17 @@ impl SkimWidget for ItemList {
}
let initial_current = this.selected();
// Check for pre-processed items from background thread (non-blocking)
let items_updated = if let Some(processed) = this.processed_items.lock().take() {
// Check for pre-processed items from background thread (non-blocking).
// Bind the result separately so the lock guard is dropped before a merge
// mutates the item list.
let processed = this
.processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
let current_generation = this.matcher_generation.load(Ordering::Acquire);
let processed = processed.filter(|result| result.generation == current_generation);
let items_updated = if let Some(processed) = processed {
debug!("Render: Got {} processed items", processed.items.len());
// Check if items are empty or blank for no_clear_if_empty handling
@ -513,6 +550,9 @@ impl SkimWidget for ItemList {
MergeStrategy::Append => {
this.items.extend(processed.items);
}
MergeStrategy::Prepend => {
this.prepend(processed.items);
}
}
this.showing_stale_items = false;

View file

@ -240,7 +240,11 @@ fn render_list(il: &mut ItemList, w: u16, h: u16) {
}
fn set_processed(il: &ItemList, items: Vec<MatchedItem>, merge: MergeStrategy) {
*il.processed_items.lock() = Some(ProcessedItems { items, merge });
*il.processed_items.lock().unwrap() = Some(ProcessedItems {
items,
merge,
generation: il.matcher_generation.load(std::sync::atomic::Ordering::Acquire),
});
}
#[test]
@ -253,6 +257,21 @@ fn render_applies_replace_strategy() {
assert_eq!(il.items[0].text(), "new");
}
#[test]
fn render_discards_results_from_stale_generation() {
let mut il = list(2);
*il.processed_items.lock().unwrap() = Some(ProcessedItems {
items: vec![matched("stale", 0)],
merge: MergeStrategy::Replace,
generation: 0,
});
il.matcher_generation.store(1, std::sync::atomic::Ordering::Release);
render_list(&mut il, 20, 5);
assert_eq!(il.items.len(), 2);
assert!(il.items.iter().all(|item| item.text() != "stale"));
}
#[test]
fn render_applies_append_strategy() {
let mut il = list(2);
@ -273,6 +292,43 @@ fn render_applies_sorted_merge_strategy() {
assert_eq!(il.items.len(), 2);
}
#[test]
fn render_prepends_tac_batch_and_follows_head() {
let mut il = ItemList::default();
let mut base = vec![matched("c", 2), matched("b", 1), matched("a", 0)];
il.append(&mut base);
set_processed(&il, vec![matched("e", 4), matched("d", 3)], MergeStrategy::Prepend);
render_list(&mut il, 20, 5);
let texts: Vec<_> = il.items.iter().map(|item| item.item.text().into_owned()).collect();
assert_eq!(texts, ["e", "d", "c", "b", "a"]);
assert_eq!(il.current, 0);
assert_eq!(
il.selected().as_ref().map(|item| item.item.text().into_owned()),
Some("e".into())
);
}
#[test]
fn render_prepend_preserves_focus_away_from_head() {
let mut il = ItemList::default();
let mut base = vec![matched("c", 2), matched("b", 1), matched("a", 0)];
il.append(&mut base);
il.current = 1;
il.offset = 1;
set_processed(&il, vec![matched("e", 4), matched("d", 3)], MergeStrategy::Prepend);
render_list(&mut il, 20, 5);
assert_eq!(il.current, 3);
assert_eq!(il.offset, 3);
assert_eq!(
il.selected().as_ref().map(|item| item.item.text().into_owned()),
Some("b".into())
);
}
#[test]
fn render_empty_list_does_not_panic() {
let mut il = ItemList::default();

View file

@ -92,8 +92,29 @@ impl<'a> ItemRenderer<'a> {
out: &mut Vec<ListItem<'static>>,
) -> usize {
let item_text = item.item.text();
let sub_lines = self.split_sub_lines(item_text.as_ref());
let (match_start_char, match_end_char) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref());
// When fields are hidden (--hide-nth), project the display text and match
// positions into the visible coordinate space so hidden characters are ignored
// for sub-line splitting, highlighting, and horizontal scrolling. `display()`
// performs the same projection, keeping the styled line consistent with these
// positions.
let (display_text, match_start_char, match_end_char): (std::borrow::Cow<'_, str>, usize, usize) =
match item.item.hidden_ranges() {
Some(hidden) if !hidden.is_empty() => {
let (visible, map) = crate::helper::item::project_visible_text(item_text.as_ref(), hidden);
let matches = Self::display_matches(item.matched_range.as_ref());
let indices = crate::helper::item::project_match_indices(item_text.as_ref(), &matches, &map);
let (start, end) = match (indices.first(), indices.last()) {
(Some(first), Some(last)) => (*first, *last + 1),
_ => (0, 0),
};
(std::borrow::Cow::Owned(visible), start, end)
}
_ => {
let (start, end) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref());
(item_text, start, end)
}
};
let sub_lines = self.split_sub_lines(display_text.as_ref());
let mut added = 0usize;
// Collect rows for this item into a temporary buffer so we can reverse

View file

@ -56,6 +56,8 @@ pub struct LayoutTemplate {
/// Pre-built [`Layout`] for carving the preview out of the full area
/// (step 1). `None` when no preview is visible.
preview_layout: Option<Layout>,
/// Whether adjacent bordered widgets share their touching row or column.
collapse_borders: bool,
/// Pre-built [`Layout`] for splitting the work area into three slots.
///
/// When `work_layout_reversed` is `false` the slots map to
@ -75,12 +77,17 @@ impl LayoutTemplate {
#[must_use]
pub fn from_options(options: &SkimOptions, header_height: u16) -> Self {
let has_border = options.border.is_some();
let collapse_borders = has_border && !options.border_no_collapse;
let overlap = u16::from(collapse_borders);
// Rows consumed by the input widget.
let input_rows: u16 = if has_border {
3 // 1 content + 2 border rows
} else {
1 + u16::from(options.info.display == InfoDisplay::Default)
1 + u16::from(matches!(
options.info.display,
InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right
))
};
// Rows consumed by the header widget.
@ -120,18 +127,17 @@ impl LayoutTemplate {
//
// For Default / ReverseList: slots are [list, header, input] top-to-bottom.
// For Reverse: slots are [input, header, list] top-to-bottom.
let non_list_rows = input_rows + header_rows;
let work_layout_reversed = options.layout == TuiLayout::Reverse;
let work_layout = if show_header {
match options.layout {
TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
Constraint::Fill(1),
Constraint::Length(header_rows),
Constraint::Length(input_rows),
Constraint::Length(header_rows.saturating_sub(overlap)),
Constraint::Length(input_rows.saturating_sub(overlap)),
]),
TuiLayout::Reverse => Layout::vertical([
Constraint::Length(input_rows),
Constraint::Length(header_rows),
Constraint::Length(header_rows.saturating_sub(overlap)),
Constraint::Fill(1),
]),
}
@ -140,10 +146,10 @@ impl LayoutTemplate {
TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
Constraint::Fill(1),
Constraint::Length(0),
Constraint::Length(non_list_rows),
Constraint::Length(input_rows.saturating_sub(overlap)),
]),
TuiLayout::Reverse => Layout::vertical([
Constraint::Length(non_list_rows),
Constraint::Length(input_rows),
Constraint::Length(0),
Constraint::Fill(1),
]),
@ -155,6 +161,7 @@ impl LayoutTemplate {
preview_placement,
work_layout_reversed,
preview_layout,
collapse_borders,
work_layout,
}
}
@ -166,7 +173,14 @@ impl LayoutTemplate {
// ── Step 1: carve out the preview from the full area ─────────────────
let (work_area, preview_area): (Rect, Option<Rect>) = match &self.preview_layout {
Some(layout) => {
let [a, b]: [Rect; 2] = layout.areas(area);
let [a, mut b]: [Rect; 2] = layout.areas(area);
if self.collapse_borders {
b = match self.preview_placement {
PreviewPlacement::Left | PreviewPlacement::Right => extend_left(b, area.x),
PreviewPlacement::Up | PreviewPlacement::Down => extend_up(b, area.y),
PreviewPlacement::None => unreachable!(),
};
}
match self.preview_placement {
// preview is the first segment for Left / Up
PreviewPlacement::Left | PreviewPlacement::Up => (b, Some(a)),
@ -183,12 +197,26 @@ impl LayoutTemplate {
// or [input, header, list] when true (Reverse layout).
let [slot0, slot1, slot2]: [Rect; 3] = self.work_layout.areas(work_area);
let (list_area, header_slot, input_area) = if self.work_layout_reversed {
let (mut list_area, mut header_slot, mut input_area) = if self.work_layout_reversed {
(slot2, slot1, slot0)
} else {
(slot0, slot1, slot2)
};
if self.collapse_borders {
if self.work_layout_reversed {
if self.show_header {
header_slot = extend_up(header_slot, work_area.y);
}
list_area = extend_up(list_area, work_area.y);
} else {
if self.show_header {
header_slot = extend_up(header_slot, work_area.y);
}
input_area = extend_up(input_area, work_area.y);
}
}
let header_area = if self.show_header { Some(header_slot) } else { None };
AppLayout {
@ -238,6 +266,22 @@ impl AppLayout {
// Helper
// ---------------------------------------------------------------------------
fn extend_up(mut rect: Rect, top: u16) -> Rect {
if rect.y > top {
rect.y -= 1;
rect.height = rect.height.saturating_add(1);
}
rect
}
fn extend_left(mut rect: Rect, left: u16) -> Rect {
if rect.x > left {
rect.x -= 1;
rect.width = rect.width.saturating_add(1);
}
rect
}
fn size_to_constraint(size: Size) -> (Constraint, Constraint) {
match size {
Size::Fixed(n) => (Constraint::Length(n), Constraint::Fill(1)),

View file

@ -27,6 +27,14 @@ fn assert_horizontally_adjacent(a: Rect, b: Rect, label: &str) {
assert_eq!(a.x + a.width, b.x, "{label}: b should start right after a");
}
fn assert_vertical_border_overlap(a: Rect, b: Rect, label: &str) {
assert_eq!(a.y + a.height - 1, b.y, "{label}: borders should share one row");
}
fn assert_horizontal_border_overlap(a: Rect, b: Rect, label: &str) {
assert_eq!(a.x + a.width - 1, b.x, "{label}: borders should share one column");
}
// Compute layout with no reserved-item header lines (header_height = 0
// unless the test needs something different).
fn compute(options: &SkimOptions) -> AppLayout {
@ -367,9 +375,10 @@ fn default_with_borders_no_header() {
let options = opts().border(crate::tui::BorderType::Plain).build().unwrap();
let layout = compute(&options);
// input = 3 rows (1 content + 2 border)
// input = 3 rows (1 content + 2 border), sharing one border row with the list.
assert_eq!(layout.input_area.height, 3);
assert_eq!(layout.list_area.height, 21);
assert_eq!(layout.list_area.height, 22);
assert_vertical_border_overlap(layout.list_area, layout.input_area, "list→input");
assert!(layout.header_area.is_none());
}
@ -382,11 +391,13 @@ fn default_with_borders_and_header() {
.unwrap();
let layout = compute_with_header_height(&options, 2);
// input = 3, header = 2+2 = 4
// input = 3, header = 2+2 = 4; both separators are shared.
assert_eq!(layout.input_area.height, 3);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 4);
assert_eq!(layout.list_area.height, 24 - 3 - 4);
assert_eq!(layout.list_area.height, 19);
assert_vertical_border_overlap(layout.list_area, h, "list→header");
assert_vertical_border_overlap(h, layout.input_area, "header→input");
}
#[test]
@ -401,8 +412,25 @@ fn reverse_with_borders() {
// input at top (y = 0)
assert_eq!(layout.input_area.y, 0);
assert_eq!(layout.input_area.height, 3);
assert_eq!(layout.list_area.y, 3);
assert_eq!(layout.list_area.height, 21);
assert_eq!(layout.list_area.y, 2);
assert_eq!(layout.list_area.height, 22);
assert_vertical_border_overlap(layout.input_area, layout.list_area, "input→list");
}
#[test]
fn border_no_collapse_keeps_separate_widget_areas() {
let options = opts()
.border(crate::tui::BorderType::Plain)
.border_no_collapse(true)
.header("hdr")
.build()
.unwrap();
let layout = compute_with_header_height(&options, 2);
let header = layout.header_area.unwrap();
assert_eq!(layout.list_area.height, 17);
assert_vertically_adjacent(layout.list_area, header, "list→header");
assert_vertically_adjacent(header, layout.input_area, "header→input");
}
// ── Coverage / edge cases ──────────────────────────────────────────────
@ -440,7 +468,7 @@ fn all_areas_non_overlapping_default() {
}
#[test]
fn all_areas_non_overlapping_reverse() {
fn collapsed_borders_overlap_in_reverse_layout() {
let options = opts()
.layout(TuiLayout::Reverse)
.inline_info(true)
@ -455,14 +483,15 @@ fn all_areas_non_overlapping_reverse() {
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
// Horizontally disjoint: preview on left, everything else on right.
// Preview and work widgets share their touching border column.
assert_eq!(preview.x, 0);
assert_eq!(preview.width, 25);
assert_eq!(layout.list_area.x, 25);
assert_eq!(layout.list_area.x, 24);
assert_horizontal_border_overlap(preview, layout.list_area, "preview→list");
// Vertical ordering within work column (Reverse): input, header, list.
assert_vertically_adjacent(layout.input_area, header, "input→header");
assert_vertically_adjacent(header, layout.list_area, "header→list");
// Vertical borders are shared too (Reverse order: input, header, list).
assert_vertical_border_overlap(layout.input_area, header, "input→header");
assert_vertical_border_overlap(header, layout.list_area, "header→list");
}
#[test]

View file

@ -17,7 +17,9 @@ pub(crate) mod util;
#[cfg(windows)]
mod windows;
pub use backend::Tui;
/// Event handling and action definitions
/// Action definitions, catalog and parsing
pub mod actions;
/// Event handling
pub mod event;
/// Header display components
pub mod header;

View file

@ -1,5 +1,5 @@
use ansi_to_tui::IntoText;
use color_eyre::eyre::{Result, eyre};
use eyre::{Result, eyre};
use portable_pty::{PtyPair, PtySize, native_pty_system};
use ratatui::layout::Alignment;
use ratatui::prelude::Backend;
@ -15,9 +15,11 @@ use tui_term::widget::PseudoTerminal;
use std::env;
use std::io::Read;
use std::sync::{Arc, RwLock, mpsc};
use std::process::{Child, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock, mpsc};
use std::thread::JoinHandle;
use std::time::Instant;
use std::time::{Duration, Instant};
use super::statusline::spinner_char;
use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query};
@ -31,6 +33,79 @@ use crate::{SkimItem, SkimOptions};
pub type PreviewCallbackFn = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
const PREVIEW_MAX_BYTES: usize = 1024 * 1024;
const VT_SCROLLBACK: usize = 100_000;
type PlainChild = Arc<Mutex<Option<Child>>>;
fn read_bounded(mut reader: impl Read) -> Vec<u8> {
read_bounded_with_updates(&mut reader, |_| {})
}
fn read_bounded_with_updates(mut reader: impl Read, mut update: impl FnMut(&[u8])) -> Vec<u8> {
const UPDATE_INTERVAL: Duration = Duration::from_millis(16);
let mut output = Vec::with_capacity(PREVIEW_MAX_BYTES);
let mut buffer = [0; 8192];
let mut last_update = None;
let mut published_len = 0;
loop {
match reader.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(read) => {
let retained = PREVIEW_MAX_BYTES.saturating_sub(output.len()).min(read);
output.extend_from_slice(&buffer[..retained]);
let update_due = last_update.is_none_or(|last: Instant| last.elapsed() >= UPDATE_INTERVAL);
if retained > 0 && (update_due || output.len() == PREVIEW_MAX_BYTES) {
update(&output);
published_len = output.len();
last_update = Some(Instant::now());
}
}
}
}
if output.len() != published_len {
update(&output);
}
output
}
fn update_plain_content(content: &RwLock<PreviewContent>, cancelled: &AtomicBool, output: &[u8]) {
let Ok(text) = output.to_vec().into_text() else {
return;
};
if let Ok(mut content) = content.write()
&& !cancelled.load(Ordering::Acquire)
{
*content = PreviewContent::Text(text);
}
}
fn terminate_plain_child(child: &PlainChild) {
let Ok(mut guard) = child.lock() else {
return;
};
let Some(child) = guard.as_mut() else {
return;
};
#[cfg(unix)]
if let Ok(process_group) = i32::try_from(child.id()) {
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
let _ = killpg(Pid::from_raw(process_group), Signal::SIGKILL);
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/PID", &child.id().to_string(), "/T", "/F"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
let _ = child.kill();
}
/// Preview content options
pub(crate) enum PreviewContent {
@ -81,11 +156,13 @@ pub struct Preview {
pub cmd: String,
pub rows: u16,
pub cols: u16,
pub scroll_y: u16,
pub scroll_x: u16,
pub scroll_y: usize,
pub scroll_x: usize,
pub thread_handle: Option<JoinHandle<()>>,
/// Channel to signal thread interruption
interrupt_tx: Option<mpsc::Sender<()>>,
plain_child: Option<PlainChild>,
plain_cancelled: Option<Arc<AtomicBool>>,
pub theme: Arc<ColorTheme>,
/// Border type
pub border: BorderType,
@ -97,7 +174,7 @@ pub struct Preview {
image: bool,
#[cfg(feature = "image")]
image_picker: Option<Picker>,
pub total_lines: u16,
pub total_lines: usize,
loading: bool,
spinner_start: Instant,
}
@ -150,18 +227,12 @@ impl Preview {
}
/// Convert a Size value to an actual offset based on preview dimensions
fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> u16 {
fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> usize {
let dimension = if is_vertical { self.rows } else { self.cols };
match size {
super::Size::Fixed(n) => n,
super::Size::Percent(p) => {
let dimension = if is_vertical { self.rows } else { self.cols };
// Result is at most dimension (a u16), so truncation cannot occur.
u16::try_from(u32::from(dimension) * u32::from(p) / 100).unwrap_or(u16::MAX)
}
super::Size::Neg(n) => {
let dimension = if is_vertical { self.rows } else { self.cols };
dimension.saturating_sub(n)
}
super::Size::Fixed(n) => usize::from(n),
super::Size::Percent(p) => usize::from(dimension) * usize::from(p) / 100,
super::Size::Neg(n) => usize::from(dimension.saturating_sub(n)),
}
}
@ -224,9 +295,9 @@ impl Preview {
pub fn content(&mut self, content: &[u8]) -> Result<()> {
let text = content.to_owned().into_text()?;
let Ok(mut content) = self.content.write() else {
return Err(color_eyre::eyre::eyre!("Failed to acquire content for writing"));
return Err(eyre::eyre!("Failed to acquire content for writing"));
};
self.total_lines = text.lines.len().try_into().unwrap();
self.total_lines = text.lines.len();
*content = PreviewContent::Text(text);
self.scroll_y = 0;
self.scroll_x = 0;
@ -256,7 +327,7 @@ impl Preview {
}
pub fn scroll_up(&mut self, lines: u16) {
self.scroll_y = self.scroll_y.saturating_sub(lines);
self.scroll_y = self.scroll_y.saturating_sub(usize::from(lines));
}
pub fn scroll_down(&mut self, lines: u16) {
@ -265,26 +336,26 @@ impl Preview {
self.total_lines, self.rows
);
if self.total_lines > 0 {
self.scroll_y = self
.scroll_y
.saturating_add(lines)
.min(self.total_lines.saturating_sub(self.rows.saturating_sub(1)));
self.scroll_y = self.scroll_y.saturating_add(usize::from(lines)).min(
self.total_lines
.saturating_sub(usize::from(self.rows.saturating_sub(1))),
);
} else {
// We might not have the actual total_lines value
self.scroll_y = self.scroll_y.saturating_add(lines);
self.scroll_y = self.scroll_y.saturating_add(usize::from(lines));
}
}
pub fn scroll_left(&mut self, cols: u16) {
self.scroll_x = self.scroll_x.saturating_sub(cols);
self.scroll_x = self.scroll_x.saturating_sub(usize::from(cols));
}
pub fn scroll_right(&mut self, cols: u16) {
self.scroll_x = self.scroll_x.saturating_add(cols);
self.scroll_x = self.scroll_x.saturating_add(usize::from(cols));
}
pub fn set_offset(&mut self, offset: u16) {
self.scroll_y = offset.saturating_sub(1); // -1 because line numbers are 1-indexed
self.scroll_y = usize::from(offset.saturating_sub(1)); // -1 because line numbers are 1-indexed
}
pub fn page_up(&mut self) {
@ -298,10 +369,19 @@ impl Preview {
}
/// Kill the preview child process and interrupt the reader thread.
pub fn kill(&mut self) {
if let Some(cancelled) = self.plain_cancelled.take() {
cancelled.store(true, Ordering::Release);
}
if let Some(tx) = self.interrupt_tx.take() {
let _ = tx.send(());
}
if let Some(child) = self.plain_child.take() {
trace!("killing plain preview child process group");
terminate_plain_child(&child);
}
if let Some(mut child) = self.pty_child.take() {
trace!("killing pty child process");
match child.try_wait() {
@ -490,43 +570,102 @@ impl Preview {
shell_cmd
.env("ROWS", self.rows.to_string())
.env("COLUMNS", self.cols.to_string())
.env("PAGER", "");
.env("PAGER", "")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Ok(cwd) = env::current_dir() {
shell_cmd.current_dir(cwd);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
shell_cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
shell_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
let (interrupt_tx, interrupt_rx) = mpsc::channel();
self.interrupt_tx = Some(interrupt_tx);
let cancelled = Arc::new(AtomicBool::new(false));
self.plain_cancelled = Some(cancelled.clone());
let mut child = match shell_cmd.spawn() {
Ok(child) => child,
Err(error) => {
log::info!("Shell cmd in error: {error:?}");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
return Ok(());
}
};
let stdout = child.stdout.take().expect("stdout was configured as piped");
let stderr = child.stderr.take().expect("stderr was configured as piped");
let child = Arc::new(Mutex::new(Some(child)));
self.plain_child = Some(child.clone());
self.thread_handle = Some(std::thread::spawn(move || {
if interrupt_rx.try_recv().is_ok() {
return;
}
let streaming_content = content.clone();
let streaming_cancelled = cancelled.clone();
let stdout_reader = std::thread::spawn(move || {
read_bounded_with_updates(stdout, |output| {
update_plain_content(&streaming_content, &streaming_cancelled, output);
})
});
let stderr_reader = std::thread::spawn(move || read_bounded(stderr));
let try_out = shell_cmd.output();
if try_out.is_err() {
log::info!("Shell cmd in error: {try_out:?}");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
return;
}
let mut out = try_out.unwrap();
if interrupt_rx.try_recv().is_ok() {
return;
}
if let Ok(mut c) = content.write() {
if out.status.success() {
out.stdout.resize(PREVIEW_MAX_BYTES.min(out.stdout.len()), 0);
*c = PreviewContent::Text(out.stdout.into_text().unwrap_or_default());
} else {
*c = PreviewContent::Text(out.stderr.clone().into_text().unwrap_or_default());
let status = loop {
match interrupt_rx.recv_timeout(Duration::from_millis(10)) {
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
terminate_plain_child(&child);
break None;
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
let wait_result = match child.lock() {
Ok(mut guard) => guard.as_mut().map(Child::try_wait),
Err(_) => break None,
};
match wait_result {
Some(Ok(Some(status))) => break Some(status),
Some(Ok(None)) => {}
Some(Err(error)) => {
log::info!("Failed to wait for preview command: {error:?}");
break None;
}
None => break None,
}
};
// A shell can exit while a background descendant still owns the pipes.
// Terminate the whole process group before joining the drain threads.
terminate_plain_child(&child);
let stdout = stdout_reader.join().unwrap_or_default();
let stderr = stderr_reader.join().unwrap_or_default();
if let Ok(mut guard) = child.lock()
&& let Some(mut child) = guard.take()
&& status.is_none()
{
let _ = child.wait();
}
trace!("sending ready ping");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
let Some(status) = status else {
return;
};
if let Ok(mut c) = content.write()
&& !cancelled.load(Ordering::Acquire)
{
let output = if status.success() { stdout } else { stderr };
*c = PreviewContent::Text(output.into_text().unwrap_or_default());
}
if !cancelled.load(Ordering::Acquire) {
trace!("sending ready ping");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
}
}));
}
Ok(())
@ -538,12 +677,14 @@ impl Preview {
area: ratatui::layout::Rect,
buf: &mut ratatui::prelude::Buffer,
text: &Text,
) -> u16 {
) -> usize {
// Calculate total lines in content
let total_lines: u16 = text.lines.len().try_into().unwrap();
let total_lines = text.lines.len();
// Create paragraph with optional block
let mut paragraph = Paragraph::new(text.clone()).scroll((self.scroll_y, self.scroll_x));
// Ratatui terminal coordinates are u16. Saturate previews that exceed that range.
let scroll_y = u16::try_from(self.scroll_y).unwrap_or(u16::MAX);
let scroll_x = u16::try_from(self.scroll_x).unwrap_or(u16::MAX);
let mut paragraph = Paragraph::new(text.clone()).scroll((scroll_y, scroll_x));
// Enable wrapping if wrap is true
if self.wrap {
@ -552,7 +693,7 @@ impl Preview {
// Add scroll position indicator at top-right if scrolled
if self.scroll_y > 0 && total_lines > 0 {
let current_line = (self.scroll_y + 1) as usize; // +1 because scroll_y is 0-indexed but we want 1-indexed display
let current_line = self.scroll_y.saturating_add(1); // Display line numbers are 1-indexed.
let title = format!("{current_line}/{total_lines}");
outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
@ -569,23 +710,21 @@ impl Preview {
area: ratatui::layout::Rect,
buf: &mut ratatui::prelude::Buffer,
parser: &std::sync::RwLock<tui_term::vt100::Parser>,
) -> u16 {
let mut total_lines = 0u16;
) -> usize {
let mut total_lines = 0usize;
// For terminal content, manipulate scrollback to implement scrolling
if let Ok(mut parser_guard) = parser.try_write() {
let scrollback_len = parser_guard.screen().scrollback();
// Reset scrollback to its full size first
parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
// If the scrollback is not empty, we seem to be off by one
total_lines = (scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count())
.try_into()
.unwrap();
total_lines = scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count();
if self.scroll_y > 0 {
trace!("scrolling in vt buffer: {}/{}", self.scroll_y, total_lines);
// Reduce scrollback by scroll_y to show earlier content
parser_guard
.screen_mut()
.set_scrollback(scrollback_len.saturating_sub(self.scroll_y.into()));
.set_scrollback(scrollback_len.saturating_sub(self.scroll_y));
}
}
@ -674,6 +813,8 @@ impl SkimWidget for Preview {
scroll_x: 0,
thread_handle: None,
interrupt_tx: None,
plain_child: None,
plain_cancelled: None,
pty: None,
pty_child: None,
#[cfg(feature = "image")]

View file

@ -5,7 +5,7 @@ use ratatui::layout::Size;
#[cfg(feature = "image")]
use ratatui_image::picker::Picker;
use super::Preview;
use super::{PREVIEW_MAX_BYTES, Preview, PreviewContent, read_bounded, update_plain_content};
#[cfg(feature = "image")]
fn image(width: u32, height: u32) -> DynamicImage {
@ -60,6 +60,130 @@ fn content_loads_text_and_resets_scroll() {
assert!(!p.is_loading());
}
#[test]
fn large_text_content_does_not_overflow_line_count() {
let input = "x\n".repeat(70_000);
let mut preview = Preview::default();
preview.content(input.as_bytes()).unwrap();
assert_eq!(preview.total_lines, 70_000);
let content = preview.content.read().unwrap();
let PreviewContent::Text(text) = &*content else {
panic!("expected text preview");
};
let area = ratatui::layout::Rect::new(0, 0, 20, 5);
let mut buffer = ratatui::buffer::Buffer::empty(area);
assert_eq!(
preview.render_text(ratatui::widgets::Block::new(), area, &mut buffer, text),
70_000
);
}
#[test]
fn bounded_reader_discards_output_after_limit() {
let input = vec![b'x'; PREVIEW_MAX_BYTES + 4096];
let output = read_bounded(std::io::Cursor::new(input));
assert_eq!(output.len(), PREVIEW_MAX_BYTES);
}
fn preview_contains(preview: &Preview, expected: &str) -> bool {
preview.content.read().is_ok_and(|content| match &*content {
PreviewContent::Text(text) => text
.lines
.iter()
.any(|line| line.spans.iter().any(|span| span.content.as_ref().contains(expected))),
_ => false,
})
}
#[cfg(unix)]
#[test]
fn plain_preview_streams_before_command_exits() {
use std::time::{Duration, Instant};
use ratatui::backend::TestBackend;
let mut preview = Preview::default();
preview.pty = None;
let mut tui =
super::super::Tui::new_with_height_and_backend(TestBackend::new(20, 5), super::super::Size::Percent(100))
.unwrap();
preview.spawn(&mut tui, "printf streamed; sleep 30").unwrap();
let started = Instant::now();
let streamed_in_time = loop {
if preview_contains(&preview, "streamed") {
break true;
}
if started.elapsed() >= Duration::from_secs(2) {
break false;
}
std::thread::sleep(Duration::from_millis(10));
};
preview.kill();
preview.thread_handle.take().unwrap().join().unwrap();
assert!(streamed_in_time, "preview output did not stream");
}
#[cfg(unix)]
#[test]
fn stale_plain_preview_cannot_replace_newer_streamed_output() {
use std::time::{Duration, Instant};
use ratatui::backend::TestBackend;
let mut preview = Preview::default();
preview.pty = None;
let mut tui =
super::super::Tui::new_with_height_and_backend(TestBackend::new(20, 5), super::super::Size::Percent(100))
.unwrap();
preview.spawn(&mut tui, "printf stale; sleep 30").unwrap();
let stale_cancelled = preview.plain_cancelled.as_ref().unwrap().clone();
let stale_thread = preview.thread_handle.take().unwrap();
preview.spawn(&mut tui, "printf current; sleep 30").unwrap();
let started = Instant::now();
let current_streamed = loop {
if preview_contains(&preview, "current") {
break true;
}
if started.elapsed() >= Duration::from_secs(2) {
break false;
}
std::thread::sleep(Duration::from_millis(10));
};
update_plain_content(&preview.content, &stale_cancelled, b"stale");
let stale_write_was_ignored = preview_contains(&preview, "current") && !preview_contains(&preview, "stale");
preview.kill();
preview.thread_handle.take().unwrap().join().unwrap();
stale_thread.join().unwrap();
assert!(current_streamed, "new preview output did not stream");
assert!(stale_write_was_ignored, "stale preview replaced newer output");
}
#[cfg(unix)]
#[test]
fn plain_preview_can_be_cancelled() {
use std::time::{Duration, Instant};
use ratatui::backend::TestBackend;
let mut preview = Preview::default();
preview.pty = None;
let mut tui =
super::super::Tui::new_with_height_and_backend(TestBackend::new(20, 5), super::super::Size::Percent(100))
.unwrap();
preview.spawn(&mut tui, "sleep 30").unwrap();
let started = Instant::now();
preview.kill();
preview.thread_handle.take().unwrap().join().unwrap();
assert!(started.elapsed() < Duration::from_secs(2));
}
#[test]
fn vertical_scroll_clamps_to_content() {
let mut p = Preview::default();

View file

@ -17,6 +17,10 @@ pub enum InfoDisplay {
/// Display info in a separate line (default)
#[default]
Default,
/// Display all info in a separate line, left-aligned
Left,
/// Display all info in a separate line, right-aligned
Right,
/// Display info inline with the input
Inline,
/// Hide the info display
@ -61,16 +65,18 @@ impl From<InfoDisplay> for Info {
impl From<&str> for Info {
fn from(s: &str) -> Self {
use InfoDisplay::{Default, Hidden, Inline, InlineRight};
use InfoDisplay::{Default, Hidden, Inline, InlineRight, Left, Right};
let mut parts = s.split(':');
let display = match parts.next() {
None | Some("default") => Default,
Some("left") => Left,
Some("right") => Right,
Some("inline") => Inline,
Some("inline-right") => InlineRight,
Some("hidden") => Hidden,
Some(x) => panic!(
"Failed to parse {x} as an InfoDisplay. Possible options are `default`, `inline`, `inline-right` or `hidden`"
"Failed to parse {x} as an InfoDisplay. Possible options are `default`, `left`, `right`, `inline`, `inline-right` or `hidden`"
),
};
let separator = if display.is_inline() {
@ -98,6 +104,8 @@ mod tests {
assert!(InfoDisplay::Inline.is_inline());
assert!(InfoDisplay::InlineRight.is_inline());
assert!(!InfoDisplay::Default.is_inline());
assert!(!InfoDisplay::Left.is_inline());
assert!(!InfoDisplay::Right.is_inline());
assert!(!InfoDisplay::Hidden.is_inline());
}
@ -112,6 +120,12 @@ mod tests {
let default = Info::from(InfoDisplay::Default);
assert_eq!(default.separator(), None);
let left = Info::from(InfoDisplay::Left);
assert_eq!(left.separator(), None);
let right = Info::from(InfoDisplay::Right);
assert_eq!(right.separator(), None);
let hidden = Info::from(InfoDisplay::Hidden);
assert_eq!(hidden.separator(), None);
}
@ -119,6 +133,8 @@ mod tests {
#[test]
fn info_from_str_parses_each_mode() {
assert_eq!(Info::from("default").display, InfoDisplay::Default);
assert_eq!(Info::from("left").display, InfoDisplay::Left);
assert_eq!(Info::from("right").display, InfoDisplay::Right);
assert_eq!(Info::from("inline").display, InfoDisplay::Inline);
assert_eq!(Info::from("inline-right").display, InfoDisplay::InlineRight);
assert_eq!(Info::from("hidden").display, InfoDisplay::Hidden);

View file

@ -251,6 +251,123 @@ impl Drop for RawMode {
}
}
/// Detect the terminal's image protocol and cell size through its controlling TTY.
///
/// Unlike `Picker::from_query_stdio`, this does not read from standard input, which may be the
/// item stream when skim is used in a pipeline. The caller must have put the terminal in raw mode.
#[cfg(all(feature = "image", unix))]
pub(crate) fn detect_image_picker() -> eyre::Result<ratatui_image::picker::Picker> {
use std::env;
use std::os::fd::AsRawFd as _;
use std::time::Instant;
use eyre::eyre;
use nix::sys::time::{suseconds_t, time_t};
use ratatui_image::FontSize;
use ratatui_image::picker::cap_parser::{Parser, QueryStdioOptions, Response};
use ratatui_image::picker::{Picker, ProtocolType};
let mut tty = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits())
.open("/dev/tty")?;
let mut options = QueryStdioOptions::default();
let is_wezterm = env::var("WEZTERM_EXECUTABLE").is_ok_and(|value| !value.is_empty());
let is_konsole = env::var("KONSOLE_VERSION").is_ok_and(|value| !value.is_empty());
if is_wezterm || is_konsole {
options.blacklist_protocols = vec![ProtocolType::Kitty, ProtocolType::Sixel];
}
let timeout = options.timeout;
let is_tmux = env::var("TMUX").is_ok_and(|value| !value.is_empty());
tty.write_all(Parser::query(is_tmux, options).as_bytes())?;
tty.flush()?;
let deadline = Instant::now() + timeout;
let mut parser = Parser::new();
let mut responses = Vec::new();
'query: loop {
let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
return Err(eyre!("terminal image protocol detection timed out"));
};
let micros = i32::try_from(remaining.as_micros()).unwrap_or(i32::MAX);
let mut select_timeout =
nix::sys::time::TimeVal::new(time_t::from(micros / 1_000_000), suseconds_t::from(micros % 1_000_000));
let mut rfds = nix::sys::select::FdSet::new();
rfds.insert(tty.as_fd());
match nix::sys::select::select(
rfds.highest().unwrap().as_raw_fd() + 1,
Some(&mut rfds),
None,
None,
Some(&mut select_timeout),
) {
Ok(0) => return Err(eyre!("terminal image protocol detection timed out")),
Ok(_) => {
let mut buf = [0; 128];
match tty.read(&mut buf) {
Ok(0) => return Err(eyre!("controlling terminal closed during image protocol detection")),
Ok(read) => {
for byte in &buf[..read] {
for response in parser.push(char::from(*byte)) {
if response == Response::Status {
break 'query;
}
responses.push(response);
}
}
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
Err(err) => return Err(err.into()),
}
}
Err(nix::errno::Errno::EINTR) => {}
Err(err) => return Err(io::Error::from_raw_os_error(err as i32).into()),
}
}
let mut protocol = None;
let mut font_size = None;
for response in responses {
match response {
Response::Kitty => protocol = Some(ProtocolType::Kitty),
Response::Sixel if protocol.is_none() => protocol = Some(ProtocolType::Sixel),
Response::CellSize(Some((width, height))) => font_size = Some(FontSize::new(width, height)),
_ => {}
}
}
let font_size = font_size.or_else(|| {
let size = crossterm::terminal::window_size().ok()?;
if size.width == 0 || size.height == 0 || size.columns == 0 || size.rows == 0 {
return None;
}
Some(FontSize::new(size.width / size.columns, size.height / size.rows))
});
let Some(font_size) = font_size else {
return Ok(Picker::halfblocks());
};
// This deprecated constructor is currently the only public way to set the font size while also
// initializing ratatui-image's private tmux state.
#[allow(deprecated)]
let mut picker = Picker::from_fontsize(font_size);
if let Some(protocol) = protocol {
picker.set_protocol_type(protocol);
}
Ok(picker)
}
/// Detect the image protocol through standard I/O on Windows.
#[cfg(all(feature = "image", windows))]
pub(crate) fn detect_image_picker() -> eyre::Result<ratatui_image::picker::Picker> {
Ok(ratatui_image::picker::Picker::from_query_stdio()?)
}
/// Get cursor position, 1-based
#[cfg(unix)]
pub(crate) fn cursor_pos_from_tty() -> io::Result<(u16, u16)> {

View file

@ -47,3 +47,30 @@ insta_test!(test_prompt_ansi, ["a"], &["--prompt", "\x1b[1;34mprompt\x1b[0m noco
@snap;
@snap_color;
});
// --ansi combined with --hide-nth: the hidden (red) middle field is removed from
// the rendered line, while the surviving green/plain fields keep their ANSI colors.
// The color snapshot confirms the green foreground survives and the red one is gone.
insta_test!(
test_ansi_hide_nth,
@bytes b"\x1b[32mgreen\x1b[0m \x1b[31mred\x1b[0m plain\n",
&["--ansi", "--delimiter", " ", "--hide-nth", "2"],
{
@snap;
@snap_color;
}
);
// The hidden ANSI field stays searchable: matching its text ("red") still selects
// the item even though the field is not shown, and no highlight leaks onto the
// visible text.
insta_test!(
test_ansi_hide_nth_searchable,
@bytes b"\x1b[32mgreen\x1b[0m \x1b[31mred\x1b[0m plain\n",
&["--ansi", "--delimiter", " ", "--hide-nth", "2"],
{
@type "red";
@snap;
@snap_color;
}
);

View file

@ -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';

View file

@ -110,6 +110,97 @@ fn filter_mode_with_print0() {
assert!(stdout.contains('\0'));
}
#[test]
fn filter_mode_inverse_query_checks_every_nth_field() {
// `--nth 1,2` gives each item two matching ranges. An inverse query must
// reject an item when ANY of them contains the term, not just the first one.
let (code, stdout, _) = run_sk_argv("foo bar\nqux bar\n", &["-f", "!foo", "--nth", "1,2"], &[]);
assert_eq!(code, Some(0));
assert!(
!stdout.contains("foo bar"),
"!foo must exclude 'foo bar' (got {stdout:?})"
);
assert!(stdout.contains("qux bar"), "!foo must keep 'qux bar' (got {stdout:?})");
// The term sitting in the second field must be caught too.
let (code, stdout, _) = run_sk_argv("bar foo\nbar qux\n", &["-f", "!foo", "--nth", "1,2"], &[]);
assert_eq!(code, Some(0));
assert!(
!stdout.contains("bar foo"),
"!foo must exclude 'bar foo' (got {stdout:?})"
);
assert!(stdout.contains("bar qux"), "!foo must keep 'bar qux' (got {stdout:?})");
}
#[test]
fn filter_mode_no_sort_preserves_input_order() {
// Workers grab 4096-item chunks from a shared queue, so with enough items
// each worker processes several chunks and the concatenation order of
// worker results is nondeterministic. --no-sort must restore input order.
let input: String = (0..50_000).map(|i| format!("item{i:06} x\n")).collect();
let expected: Vec<String> = (0..50_000).map(|i| format!("item{i:06} x")).collect();
let (code, stdout, _) = run_sk_argv(&input, &["--no-sort", "-f", "x"], &[]);
assert_eq!(code, Some(0));
let lines: Vec<String> = stdout.lines().map(String::from).collect();
assert_eq!(lines.len(), expected.len());
let first_mismatch = lines.iter().zip(&expected).position(|(a, b)| a != b);
assert!(
first_mismatch.is_none(),
"output diverges from input order at line {first_mismatch:?}"
);
}
#[test]
fn with_nth_accepts_space_separated_negative_index() {
// `--with-nth -1` (space form) used to be parsed as a missing value, while
// `--nth -1` and `--with-nth=-1` both worked.
let (code, stdout, stderr) = run_sk_argv("a b c", &["-f", "c", "--with-nth", "-1"], &[]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(stdout.trim_end(), "a b c");
// The space form and the `=` form must agree.
let (code_eq, stdout_eq, _) = run_sk_argv("a b c", &["-f", "c", "--with-nth=-1"], &[]);
assert_eq!((code, stdout), (code_eq, stdout_eq));
}
#[test]
fn nth_index_past_i32_does_not_fall_back_to_field_1() {
// An index too large for i32 used to fail to parse and silently become field 1,
// so `--nth <huge>` matched the first field instead of matching nothing.
let (code_huge, out_huge, _) = run_sk_argv("a b c", &["-f", "a", "--nth", "2147483648"], &[]);
let (code_oob, out_oob, _) = run_sk_argv("a b c", &["-f", "a", "--nth", "5"], &[]);
assert_eq!((code_huge, out_huge.as_str()), (code_oob, out_oob.as_str()));
assert_eq!(code_huge, Some(1), "an out-of-range field must match nothing");
assert!(out_huge.is_empty());
// Same for the `{N}` field syntax in --output-format. Assert the exit status and
// stderr too, so an empty stdout can't pass by way of the placeholder erroring out.
for placeholder in ["{2147483648}", "{-2147483649}"] {
let (code, out, err) = run_sk_argv("a b c", &["-1", "-q", "a", "--output-format", placeholder], &[]);
assert_eq!(code, Some(0), "{placeholder}: stderr: {err}");
assert_eq!(err, "", "{placeholder} should not error");
assert_eq!(out.trim_end(), "", "{placeholder} should render an empty field");
}
}
#[test]
fn pathname_tiebreak_is_not_broken_by_a_non_ascii_directory() {
// `path_name_offset` used to be a byte offset while `Rank::begin` is a char
// index, so a multi-byte directory component inflated the PathName score and
// pushed the filename match below the directory match.
let (code, stdout, stderr) = run_sk_argv("ééééé/a\na/xxxxx\n", &["-f", "a", "--scheme", "path"], &[]);
assert_eq!(code, Some(0), "stderr: {stderr}");
assert_eq!(
stdout.lines().next(),
Some("ééééé/a"),
"the filename match must rank first, got: {stdout:?}"
);
// The all-ASCII shape of the same input already ranked correctly; both must agree.
let (_, ascii_stdout, _) = run_sk_argv("eeeee/a\na/xxxxx\n", &["-f", "a", "--scheme", "path"], &[]);
assert_eq!(ascii_stdout.lines().next(), Some("eeeee/a"));
}
#[test]
fn select_1_with_output_format() {
// --output-format renders the selected item through the printf branch.

View file

@ -1,8 +1,8 @@
use std::io::Cursor;
use clap::Parser;
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
use eyre::Result;
use ratatui::backend::TestBackend;
use skim::prelude::*;
use skim::tui::event::Action;
@ -276,7 +276,7 @@ impl TestHarness {
// Wait for reader to finish
while !self.skim.reader_done() {
if start.elapsed() > timeout {
return Err(color_eyre::eyre::eyre!("Timeout waiting for reader to finish"));
return Err(eyre::eyre!("Timeout waiting for reader to finish"));
}
// Check reader status (may restart matcher)
self.skim.check_reader();
@ -299,7 +299,7 @@ impl TestHarness {
// Wait for matcher to complete
while !self.skim.app().matcher_control.stopped() {
if start.elapsed() > timeout {
return Err(color_eyre::eyre::eyre!("Timeout waiting for matcher to stop"));
return Err(eyre::eyre!("Timeout waiting for matcher to stop"));
}
std::thread::sleep(poll_interval);
}
@ -332,7 +332,7 @@ impl TestHarness {
let debounce_start = std::time::Instant::now();
while self.skim.app().pending_preview_run {
if debounce_start.elapsed() > debounce_timeout {
return Err(color_eyre::eyre::eyre!("Timeout waiting for debounced preview to run"));
return Err(eyre::eyre!("Timeout waiting for debounced preview to run"));
}
std::thread::sleep(std::time::Duration::from_millis(10));
@ -524,7 +524,7 @@ pub fn enter_cmd(cmd: &str, options: SkimOptions) -> Result<TestHarness> {
.stderr(std::process::Stdio::null())
.spawn()?
.stdout
.ok_or_else(|| color_eyre::eyre::eyre!("Failed to capture stdout"))?,
.ok_or_else(|| eyre::eyre!("Failed to capture stdout"))?,
));
enter_sized_with_source(options, 80, 24, Some(rx))
@ -662,7 +662,7 @@ macro_rules! insta_test {
// Simple variant with items array - just snapshot
($name:ident, [$($item:expr),* $(,)?], $options:expr) => {
#[test]
fn $name() -> color_eyre::Result<()> {
fn $name() -> eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_items([$($item),*], options)?;
let __desc = format!(
@ -678,7 +678,7 @@ macro_rules! insta_test {
// Simple variant with items expression (identifier or expression) - just snapshot
($name:ident, $items:expr, $options:expr) => {
#[test]
fn $name() -> color_eyre::Result<()> {
fn $name() -> eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_items($items, options)?;
let __desc = format!(
@ -694,7 +694,7 @@ macro_rules! insta_test {
// Simple variant with @cmd - just snapshot
($name:ident, @cmd $cmd:expr, $options:expr) => {
#[test]
fn $name() -> color_eyre::Result<()> {
fn $name() -> eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_cmd($cmd, options)?;
let __desc = format!(
@ -710,7 +710,7 @@ macro_rules! insta_test {
// Simple variant with @bytes - just snapshot
($name:ident, @bytes $bytes:expr, $options:expr) => {
#[test]
fn $name() -> color_eyre::Result<()> {
fn $name() -> eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_bytes($bytes, options)?;
let __desc = format!(
@ -726,7 +726,7 @@ macro_rules! insta_test {
// Simple variant with @interactive - just snapshot
($name:ident, @interactive, $options:expr) => {
#[test]
fn $name() -> color_eyre::Result<()> {
fn $name() -> eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_interactive(options)?;
let __desc = format!(
@ -741,7 +741,7 @@ macro_rules! insta_test {
// DSL variant with items expression (identifier or expression)
($name:ident, $items:expr, $options:expr, { $($content:tt)* }) => {
#[test]
fn $name() -> color_eyre::Result<()> {
fn $name() -> eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_items($items, options)?;
let __base_desc = format!(
@ -760,7 +760,7 @@ macro_rules! insta_test {
// DSL variant with @cmd
($name:ident, @cmd $cmd:expr, $options:expr, { $($content:tt)* }) => {
#[test]
fn $name() -> color_eyre::Result<()> {
fn $name() -> eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_cmd($cmd, options)?;
let __base_desc = format!(
@ -779,7 +779,7 @@ macro_rules! insta_test {
// DSL variant with @bytes
($name:ident, @bytes $bytes:expr, $options:expr, { $($content:tt)* }) => {
#[test]
fn $name() -> color_eyre::Result<()> {
fn $name() -> eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_bytes($bytes, options)?;
let __base_desc = format!(
@ -798,7 +798,7 @@ macro_rules! insta_test {
// DSL variant with @interactive
($name:ident, @interactive, $options:expr, { $($content:tt)* }) => {
#[test]
fn $name() -> color_eyre::Result<()> {
fn $name() -> eyre::Result<()> {
let options = $crate::common::insta::parse_options($options);
let mut h = $crate::common::insta::enter_interactive(options)?;
let __base_desc = format!(

View file

@ -1,8 +1,9 @@
#[macro_use]
pub mod insta;
// Zellij-backed end-to-end harness. Cross-platform (Zellij 0.44+ and the
// in-process PTY both run on Windows), so it is not gated to unix.
#[macro_use]
#[cfg(unix)]
pub mod tmux;
pub mod zellij;
/// Raw binary path. Use `Command::new(SK)` to spawn directly; apply
/// `SKIM_ENV_REMOVES` via `.env_remove()` on the command when needed.

View file

@ -1,712 +0,0 @@
use std::fmt::{Display, Formatter};
use std::fs::File;
use std::io::{BufReader, ErrorKind, Read, Result};
use std::path::Path;
use std::process::Command;
use std::thread::sleep;
use std::time::Duration;
use rand::RngExt as _;
use rand::distr::Alphanumeric;
use tempfile::{NamedTempFile, TempDir, tempdir};
use which::which;
use crate::common::{SK, SKIM_SHELL_ENV_CLEAR};
pub fn sk(outfile: &str, opts: &[&str]) -> String {
format!(
"{}{} {} > {}.part; mv {}.part {}",
SKIM_SHELL_ENV_CLEAR,
SK,
opts.join(" "),
outfile,
outfile,
outfile
)
}
pub fn wait<F, T>(pred: F) -> Result<T>
where
F: Fn() -> Result<T>,
{
for _ in 1..500 {
if let Ok(t) = pred() {
return Ok(t);
}
sleep(Duration::from_millis(10));
}
Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "wait timed out"))
}
pub enum Keys<'a> {
Str(&'a str),
Key(char),
Ctrl(&'a Keys<'a>),
Alt(&'a Keys<'a>),
Enter,
Tab,
BTab,
Left,
Right,
BSpace,
Up,
Down,
Escape,
}
impl Display for Keys<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
use Keys::*;
match self {
Str(s) => write!(f, "{}", s),
Key(c) => write!(f, "{}", c),
Ctrl(k) => write!(f, "C-{}", k),
Alt(k) => write!(f, "M-{}", k),
Enter => write!(f, "Enter"),
Tab => write!(f, "Tab"),
BTab => write!(f, "BTab"),
Left => write!(f, "Left"),
Right => write!(f, "Right"),
BSpace => write!(f, "BSpace"),
Up => write!(f, "Up"),
Down => write!(f, "Down"),
Escape => write!(f, "Escape"),
}
}
}
pub struct TmuxController {
pub window: String,
pub tempdir: TempDir,
pub outfile: Option<String>,
}
impl Default for TmuxController {
fn default() -> Self {
Self {
window: String::new(),
tempdir: tempfile::tempdir().expect("Failed to create tempdir"),
outfile: None,
}
}
}
impl TmuxController {
pub fn run(args: &[&str]) -> Result<Vec<String>> {
let output = Command::new(which("tmux").expect("Please install tmux to $PATH"))
.args(args)
.output()?
.stdout
.split(|c| *c == b'\n')
.map(|bytes| String::from_utf8(bytes.to_vec()).expect("Failed to parse bytes as UTF8 string"))
.collect::<Vec<String>>();
Ok(output[0..output.len() - 1].to_vec())
}
pub fn new_named(name: &str) -> Result<Self> {
let unset_cmd = "unset SKIM_DEFAULT_COMMAND SKIM_DEFAULT_OPTIONS PS1 PROMPT_COMMAND HISTFILE";
let full_name = format!(
"{name}-{}",
rand::rng()
.sample_iter(&Alphanumeric)
.take(4)
.map(char::from)
.collect::<String>()
);
let shell_cmd = "bash --rcfile None";
Self::run(&[
"new-window",
"-d",
"-P",
"-F",
"#I",
"-t",
"skim_e2e:",
"-n",
&full_name,
&format!("{}; {}", unset_cmd, shell_cmd),
])?;
Self::run(&["set-window-option", "-t", &full_name, "pane-base-index", "0"])?;
Ok(Self {
window: format!("skim_e2e:{full_name}"),
tempdir: tempdir()?,
outfile: None,
})
}
pub fn new() -> Result<Self> {
let name: String = rand::rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
Self::new_named(&name)
}
pub fn send_keys(&self, keys: &[Keys]) -> std::io::Result<()> {
print!("typing `");
for key in keys {
Self::run(&["send-keys", "-t", &self.window, &key.to_string()])?;
print!("{}", key);
}
println!("`");
Ok(())
}
pub fn tempfile(&self) -> Result<String> {
Ok(NamedTempFile::new_in(&self.tempdir)?
.path()
.to_str()
.unwrap()
.to_string())
}
// Returns the lines in reverted order
pub fn capture(&self) -> Result<Vec<String>> {
let tempfile = wait(|| {
let tempfile = self.tempfile()?;
Self::run(&[
"capture-pane",
"-J",
"-b",
&self.window,
"-t",
&format!("{}.0", self.window),
])?;
Self::run(&["save-buffer", "-b", &self.window, &tempfile])?;
Ok(tempfile)
})?;
let mut string_lines = String::new();
BufReader::new(File::open(tempfile)?).read_to_string(&mut string_lines)?;
let str_lines = string_lines.trim();
Ok(str_lines
.split("\n")
.map(|s| s.to_string())
.collect::<Vec<String>>()
.into_iter()
.rev()
.collect())
}
// Capture with ANSI escape sequences preserved (using -e flag)
// Returns the lines in reverted order with ANSI codes
pub fn capture_colored(&self) -> Result<Vec<String>> {
let tempfile = wait(|| {
let tempfile = self.tempfile()?;
Self::run(&[
"capture-pane",
"-e",
"-J",
"-b",
&self.window,
"-t",
&format!("{}.0", self.window),
])?;
Self::run(&["save-buffer", "-b", &self.window, &tempfile])?;
Ok(tempfile)
})?;
let mut string_lines = String::new();
BufReader::new(File::open(tempfile)?).read_to_string(&mut string_lines)?;
let str_lines = string_lines.trim();
Ok(str_lines
.split("\n")
.map(|s| s.to_string())
.collect::<Vec<String>>()
.into_iter()
.rev()
.collect())
}
pub fn until<F>(&self, pred: F) -> std::io::Result<()>
where
F: Fn(&[String]) -> bool,
{
match wait(|| {
let lines = self.capture()?;
if pred(&lines) {
return Ok(true);
}
Err(std::io::Error::other("pred not matched"))
}) {
Ok(true) => Ok(()),
Ok(false) => Err(std::io::Error::other(self.capture()?.join("\n"))),
_ => Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
self.capture()?.join("\n"),
)),
}
}
/// Capture skim output without ANSI sequences
pub fn output(&self) -> Result<Vec<String>> {
if let Some(ref outfile) = self.outfile {
self.output_from(outfile)
} else {
Err(std::io::Error::new(
ErrorKind::NotFound,
"You need to use start_sk to get an outfile",
))
}
}
/// Capture skim output from explicit outfile path
pub fn output_from(&self, outfile: &str) -> Result<Vec<String>> {
wait(|| {
if Path::new(&outfile).exists() {
Ok(())
} else {
Err(std::io::Error::new(ErrorKind::NotFound, "outfile does not exist yet"))
}
})?;
let mut string_lines = String::new();
BufReader::new(File::open(outfile)?).read_to_string(&mut string_lines)?;
let str_lines = string_lines.trim();
Ok(str_lines
.split("\n")
.map(|s| s.to_string())
.collect::<Vec<String>>()
.into_iter()
.collect())
}
pub fn start_sk(&mut self, stdin_cmd: Option<&str>, opts: &[&str]) -> Result<String> {
let outfile = self.tempfile()?;
let sk_cmd = sk(&outfile, opts);
let cmd = match stdin_cmd {
Some(s) => format!("{} | {}", s, sk_cmd),
None => sk_cmd,
};
println!("--- starting up sk ---");
self.send_keys(&[Keys::Str(&cmd), Keys::Enter])?;
println!("--- sk is running ---");
self.outfile = Some(outfile.clone());
Ok(outfile)
}
}
impl Drop for TmuxController {
fn drop(&mut self) {
let _ = Self::run(&["kill-window", "-t", &self.window]);
}
}
// ============================================================================
// sk_test! - Macro for writing compact tmux-based integration tests
// ============================================================================
//
// USAGE GUIDE
// -----------
//
// 1. INPUT SYNTAX:
// - Echo string: "a\\nb\\nc" -> Runs: echo -n -e 'a\nb\nc'
// - Command: @cmd "seq 1 100" -> Runs: seq 1 100 (pipe to sk)
//
// 2. DSL SYNTAX (Only syntax supported):
//
// sk_test!(test_name, "input", &["--opts"], {
// @capture[0] eq(">"); // Wait until capture[0] == ">"
// @capture[1] trim().starts_with("3/3"); // Wait until capture[1].trim().starts_with("3/3")
// @capture[-1] eq("foo"); // Wait until last line == "foo"
// @capture[*] contains("bar"); // Wait until any line contains "bar"
// @output[0] eq("result"); // Wait until output[0] == "result"
// @output[-1] eq("last"); // Wait until output[-1] (last line) == "last"
// @output[*] starts_with("prefix"); // Wait until any output line starts with "prefix"
// @capture_colored[0] contains("\x1b"); // Wait until colored capture contains ANSI
// @lines |l| (l.len() > 5); // Complex assertion with closure
// @keys Enter, Tab; // Send multiple keys
// @dbg; // Debug print current capture
// });
//
// NOTE: All methods use wait() for consistent retry behavior. Any TmuxController
// method that takes no args and returns Result<Vec<String>> can be used:
// capture, output, capture_colored, etc.
//
// EXAMPLES
// --------
//
// Example 1: Simple test with echo input (all methods wait/retry)
// sk_test!(simple, "a\\nb\\nc", &[], {
// @capture[0] eq(">"); // Waits until condition is met
// @keys Enter;
// @output[0] eq("a"); // Waits until output is available
// });
//
// Example 2: Using command input with @cmd
// sk_test!(with_seq, @cmd "seq 1 10", &["--bind", "'ctrl-t:toggle-all'"], {
// @capture[0] eq(">");
// @keys Ctrl(&Key('t'));
// @capture[2] eq(">>1");
// });
//
// Example 3: Complex closures with @lines
// sk_test!(complex, "apple\\nbanana", &[], {
// @lines |l| (l.len() > 4);
// @keys Str("ana");
// @lines |l| (l.iter().any(|x| x.contains("banana")));
// });
//
// Example 4: Method chaining
// sk_test!(chaining, " foo \\n bar ", &[], {
// @capture[2] trim().eq("foo");
// @keys Enter;
// @output[0] trim().eq("foo");
// });
//
// Example 5: Using wildcards and negative indices
// sk_test!(wildcards, "apple\\nbanana\\ncherry", &[], {
// @capture[*] contains("3/3"); // Any line contains "3/3"
// @capture[-1] starts_with(">"); // Last line starts with ">"
// @keys Str("ana");
// @capture[*] contains("banana"); // Any line contains "banana"
// @keys Enter;
// @output[0] eq("banana"); // First output line
// @output[-1] eq("banana"); // Last output line
// @output[*] starts_with("b"); // Any output line starts with "b"
// });
//
// Example 6: New array syntax test
// sk_test!(new_syntax_test, "foo\\nbar\\nbaz", &[], {
// @capture[0] starts_with(">");
// @capture[1] contains("3/3");
// @keys Enter;
// @output[0] eq("foo");
// @output[- 1] eq("foo");
// });
//
// Example 7: Wildcard syntax test
// sk_test!(wildcard_syntax_test, "apple\\nbanana\\ncherry", &[], {
// @capture[*] contains("3/3");
// @keys Str("ana");
// @capture[*] contains("banana");
// @keys Enter;
// @output[*] eq("banana");
// });
//
// Example 8: Comprehensive example showing all features
// sk_test!(comprehensive_example, "foo\\nbar\\nbaz\\nqux", &[], {
// // Positive index with simple method
// @capture[0] starts_with(">");
//
// // Positive index with method chain
// @capture[1] trim().contains("4/4");
//
// // Wildcard - check if any line matches
// @capture[*] contains("foo");
//
// // Send keys
// @keys Str("ba");
//
// // Negative index - last line
// @capture[- 1] contains("bar");
//
// // Select first match
// @keys Enter;
//
// // Output assertions
// @output[0] eq("bar"); // First output line
// @output[- 1] eq("bar"); // Last output line
// @output[*] starts_with("b"); // Any output line starts with "b"
// });
//
// Example 9: Using capture_colored for ANSI escape sequences
// sk_test!(ansi_test, @cmd "echo -e '\\x1b[31mred\\x1b[0m'", &["--ansi"], {
// @capture[*] contains("red");
// @capture_colored[*] contains("\x1b[31m"); // Check for ANSI codes
// @keys Enter;
// });
//
// DSL COMMAND REFERENCE
// ---------------------
// @METHOD[N] method_chain Wait until METHOD[N].method_chain is true (N = line number)
// @METHOD[-N] method_chain Wait until METHOD[-N].method_chain is true (negative index)
// @METHOD[*] method_chain Wait until any line matches (uses .iter().any())
// where METHOD is any TmuxController method returning Result<Vec<String>>:
// - capture: Wait until condition is true
// - output: Wait until condition is true
// - capture_colored: Wait until condition is true on colored capture
// All methods use wait() for consistent retry behavior
// @lines |l| (expr) Call tmux.until(|l| expr)? with closure
// @keys key1, key2 Send keys (automatically adds ?)
// @dbg Debug print current capture
//
// NOTES
// -----
// - The `tmux` variable is implicitly available in DSL blocks
// - All variants automatically handle Result propagation and Ok(()) return
// - DSL closures must be wrapped in parentheses: |l| (expr)
// - Method chains support any String/&str method: eq(), starts_with(), contains(), trim(), etc.
// - You can chain methods: trim().starts_with("foo")
// - Negative indices work like Python: -1 is last element, -2 is second-to-last, etc.
// - ALL methods use wait() with retry logic - no immediate assertions
// - wait() retries every 10ms for up to 10 seconds before timing out
//
#[allow(unused_macros)]
macro_rules! sk_test {
// Standard variant with echo input: explicit variable name with block
($name:tt, $input:expr, $options:expr, $tmux:ident => $content:block) => {
#[test]
#[allow(unused_variables)]
fn $name() -> std::io::Result<()> {
let mut $tmux = crate::common::tmux::TmuxController::new()?;
$tmux.start_sk(Some(&format!("echo -n -e '{}'", $input)), $options)?;
$content
Ok(())
}
};
// Standard variant with arbitrary command: use @cmd marker
($name:tt, @cmd $cmd:expr, $options:expr, $tmux:ident => $content:block) => {
#[test]
#[allow(unused_variables)]
fn $name() -> std::io::Result<()> {
let mut $tmux = crate::common::tmux::TmuxController::new()?;
$tmux.start_sk(Some($cmd), $options)?;
$content
Ok(())
}
};
// DSL variant with echo input
($name:tt, $input:expr, $options:expr, { $($content:tt)* }) => {
#[test]
#[allow(unused_variables)]
fn $name() -> std::io::Result<()> {
let mut tmux = crate::common::tmux::TmuxController::new_named(stringify!($name))?;
tmux.start_sk(Some(&format!("echo -n -e '{}'", $input)), $options)?;
sk_test!(@expand tmux; $($content)*);
Ok(())
}
};
// DSL variant with arbitrary command: use @cmd marker
($name:tt, @cmd $cmd:expr, $options:expr, { $($content:tt)* }) => {
#[test]
#[allow(unused_variables)]
fn $name() -> std::io::Result<()> {
let mut tmux = crate::common::tmux::TmuxController::new_named(stringify!($name))?;
tmux.start_sk(Some($cmd), $options)?;
sk_test!(@expand tmux; $($content)*);
Ok(())
}
};
// Token processing rules
(@expand $tmux:ident; ) => {};
// Generic method patterns - works with any TmuxController method
// @method[*] - check if any line matches (uses .iter().any())
(@expand $tmux:ident; @ $method:ident [ * ] $($rest:tt)*) => {
sk_test!(@method_any_collect $tmux, $method, [] ; $($rest)*);
};
// @method[-idx] for negative index - supports arbitrary method chains (must come before positive)
(@expand $tmux:ident; @ $method:ident [ - $idx:literal ] $($rest:tt)*) => {
sk_test!(@method_neg_collect $tmux, $method, $idx, [] ; $($rest)*);
};
// @method[idx] for positive index - supports arbitrary method chains
(@expand $tmux:ident; @ $method:ident [ $idx:literal ] $($rest:tt)*) => {
sk_test!(@method_pos_collect $tmux, $method, $idx, [] ; $($rest)*);
};
// Collect tokens until semicolon for positive index - dispatches to wait or assert
(@method_pos_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; ; $($rest:tt)*) => {
sk_test!(@method_pos_dispatch $tmux, $method, $idx, [$($methods)*]);
sk_test!(@expand $tmux; $($rest)*);
};
(@method_pos_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => {
sk_test!(@method_pos_collect $tmux, $method, $idx, [$($methods)* $next] ; $($rest)*);
};
// Dispatch for positive index - all methods use wait()
(@method_pos_dispatch $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*]) => {
{
if crate::common::tmux::wait(|| {
let lines = $tmux.$method()?;
if lines.len() > $idx && lines[$idx].$($methods)* {
Ok(true)
} else {
Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met"))
}
}).is_err() {
let lines = $tmux.$method().unwrap_or_default();
let actual = if lines.len() > $idx { &lines[$idx] } else { "<no line>" };
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("Timed out waiting for {}[{}].{}, got: {}", stringify!($method), $idx, stringify!($($methods)*), actual)
));
}
}
};
// Collect tokens until semicolon for negative index - dispatches to wait or assert
(@method_neg_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; ; $($rest:tt)*) => {
sk_test!(@method_neg_dispatch $tmux, $method, $idx, [$($methods)*]);
sk_test!(@expand $tmux; $($rest)*);
};
(@method_neg_collect $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => {
sk_test!(@method_neg_collect $tmux, $method, $idx, [$($methods)* $next] ; $($rest)*);
};
// Dispatch for negative index - all methods use wait()
(@method_neg_dispatch $tmux:ident, $method:ident, $idx:expr, [$($methods:tt)*]) => {
{
if crate::common::tmux::wait(|| {
let lines = $tmux.$method()?;
if lines.len() >= $idx {
let actual_idx = lines.len() - $idx;
if lines[actual_idx].$($methods)* {
Ok(true)
} else {
Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met"))
}
} else {
Err(std::io::Error::new(std::io::ErrorKind::Other, "not enough lines"))
}
}).is_err() {
let lines = $tmux.$method().unwrap_or_default();
let actual_idx = lines.len().saturating_sub($idx);
let actual = if lines.len() >= $idx { &lines[actual_idx] } else { "<no line>" };
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("Timed out waiting for {}[-{}].{}, got: {}", stringify!($method), $idx, stringify!($($methods)*), actual)
));
}
}
};
// Collect tokens until semicolon for wildcard [*] - dispatches to wait or assert
(@method_any_collect $tmux:ident, $method:ident, [$($methods:tt)*] ; ; $($rest:tt)*) => {
sk_test!(@method_any_dispatch $tmux, $method, [$($methods)*]);
sk_test!(@expand $tmux; $($rest)*);
};
(@method_any_collect $tmux:ident, $method:ident, [$($methods:tt)*] ; $next:tt $($rest:tt)*) => {
sk_test!(@method_any_collect $tmux, $method, [$($methods)* $next] ; $($rest)*);
};
// Dispatch for wildcard - all methods use wait()
(@method_any_dispatch $tmux:ident, $method:ident, [$($methods:tt)*]) => {
{
if crate::common::tmux::wait(|| {
let lines = $tmux.$method()?;
if lines.iter().any(|line| line.$($methods)*) {
Ok(true)
} else {
Err(std::io::Error::new(std::io::ErrorKind::Other, "condition not met"))
}
}).is_err() {
let lines = $tmux.$method().unwrap_or_default();
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("Timed out waiting for {}[*] any line matching .{}, got: {:?}", stringify!($method), stringify!($($methods)*), lines)
));
}
}
};
// @lines command for tmux.until with closure
(@expand $tmux:ident; @ lines | $param:ident | ( $($body:tt)* ) ; $($rest:tt)*) => {
$tmux.until(|$param| $($body)*)?;
sk_test!(@expand $tmux; $($rest)*);
};
// @keys command for send_keys - supports any number of keys
(@expand $tmux:ident; @ keys $($key:expr),+ ; $($rest:tt)*) => {
send_keys!($tmux, $($key),+)?;
sk_test!(@expand $tmux; $($rest)*);
};
// @dbg command for debug printing
(@expand $tmux:ident; @ dbg ; $($rest:tt)*) => {
match $tmux.capture() {
Ok(lines) => println!("DBG: capture: {:?}", lines),
Err(e) => println!("DBG: capture failed: {}", e),
}
match $tmux.output() {
Ok(lines) => println!("DBG: output: {:?}", lines),
Err(e) => println!("DBG: output failed: {}", e),
}
sk_test!(@expand $tmux; $($rest)*);
};
// Pass through regular Rust statements that access tmux (catch-all, must be last)
(@expand $tmux:ident; $stmt:stmt ; $($rest:tt)*) => {
#[allow(redundant_semicolons)]
{
$stmt;
sk_test!(@expand $tmux; $($rest)*);
}
};
}
#[allow(unused_macros)]
macro_rules! assert_line {
($tmux:ident, $line_nr:literal $($expression:tt)+) => {
{
if $tmux.until(|l| l.len() > $line_nr && l[$line_nr] $($expression)+).is_err() {
let lines = $tmux.capture().unwrap_or_default();
let actual = if lines.len() > $line_nr { &lines[$line_nr] } else { "<no line>" };
Err(std::io::std::io::Error::new(std::io::std::io::ErrorKind::TimedOut, format!("Timed out waiting for condition on line {}, got {} but expected it to {}", $line_nr, actual, stringify!($($expression)+))))
} else {
Ok(())
}
}?
};
}
#[allow(unused_macros)]
macro_rules! send_keys {
($tmux:ident, $($key:expr),+) => {
$tmux.send_keys(&[$($key),+])
};
}
#[allow(unused_macros)]
macro_rules! assert_output_line {
($tmux:ident, $line_nr:literal $($expression:tt)+) => {
let output = $tmux.output()?;
println!("Output: {output:?}");
assert!(output[$line_nr] $($expression)+, "Timed out waiting for condition on output line {}, expected it to {}", $line_nr, stringify!($($expression)+));
};
}
// Ultra-short aliases for compact test writing
// Usage: line!(t, 0 == ">") instead of assert_line!(t, 0 == ">")
#[allow(unused_macros)]
macro_rules! line {
($tmux:ident, $line_nr:literal $($expression:tt)+) => {
assert_line!($tmux, $line_nr $($expression)+)
};
}
#[allow(unused_macros)]
macro_rules! keys {
($tmux:ident, $($key:expr),+) => {
send_keys!($tmux, $($key),+)
};
}
#[allow(unused_macros)]
macro_rules! out {
($tmux:ident, $line_nr:literal $($expression:tt)+) => {
assert_output_line!($tmux, $line_nr $($expression)+)
};
}

1201
tests/common/zellij.rs Normal file

File diff suppressed because it is too large Load diff

127
tests/execute.rs Normal file
View file

@ -0,0 +1,127 @@
#![allow(missing_docs, clippy::pedantic)]
// The Zellij harness is cross-platform, but this file stays unix-only for
// reasons unrelated to the multiplexer: it builds an executable bash helper via
// `std::os::unix::fs::PermissionsExt` and drives it with a POSIX shell script.
// (`#![cfg(unix)]` already covers both Linux and macOS.)
#![cfg(unix)]
#[allow(dead_code)]
mod common;
use std::fs::{self, File, Permissions};
use std::io::{Read, Result, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use common::zellij::Keys::*;
use common::zellij::{ZellijController, wait};
/// Read the whole file at `path` into a `String`.
fn read_file(path: &Path) -> Result<String> {
let mut s = String::new();
File::open(path)?.read_to_string(&mut s)?;
Ok(s)
}
/// Drive an interactive child through an `execute` action and assert it keeps
/// receiving keystrokes, then that skim itself is interactive again once the
/// child exits.
///
/// Two independent bugs used to make a program run via `execute(...)` freeze:
/// 1. skim's own input reader kept reading the terminal while the child ran,
/// so skim and the child raced for keystrokes and roughly half were lost.
/// 2. the child inherited skim's stdin, which is the item *pipe* here
/// (`printf … | sk`), so an interactive child had no keyboard at all.
///
/// The child below reads four raw single keystrokes and records them, in order.
/// It only completes if it received every keystroke, so if either bug regresses
/// the result file is never finished and the wait times out. Typing a query
/// afterwards confirms skim restarted its reader and repainted — the latter
/// exercised for both the fullscreen and inline layouts, since the post-execute
/// repaint path differs from a normal render.
fn run_interactive_execute(name: &str, extra_opts: &[&str]) -> Result<()> {
let mut tmux = ZellijController::new_named(name)?;
let dir = tmux.tempdir.path().to_path_buf();
let script = dir.join("interactive.sh");
let ready = dir.join("ready");
let result = dir.join("keys.txt");
// A tiny interactive "TUI": announce readiness, then read four raw single
// keystrokes from the terminal and write them, in order, to the result
// file. `read -rsn1` requires bash, which is present on the Linux and macOS
// CI runners. If a keystroke is stolen, the loop blocks on `read` and the
// result file is never written.
let script_body = r#"#!/usr/bin/env bash
out="$1"
ready="$2"
: > "$ready"
s=""
for _ in 1 2 3 4; do
IFS= read -rsn1 c || break
s="$s$c"
done
printf '%s' "$s" > "$out"
"#;
File::create(&script)?.write_all(script_body.as_bytes())?;
fs::set_permissions(&script, Permissions::from_mode(0o755))?;
let bind = format!(
"--bind='enter:execute(bash {} {} {})'",
script.display(),
result.display(),
ready.display()
);
let mut opts: Vec<&str> = extra_opts.to_vec();
opts.push(bind.as_str());
// skim reads its items from a *pipe*; Enter runs the interactive child.
tmux.start_sk(Some("printf 'aaa\\nbbb\\nccc'"), &opts)?;
// Wait for skim to come up (prompt line present).
tmux.until(|l| l.iter().any(|s| s.trim_start().starts_with(">")))?;
// Trigger the execute action and wait until the child has taken over the
// terminal. Waiting for the readiness marker guarantees skim has already
// suspended its own reader, so there is no race for the keystrokes below.
tmux.send_keys(&[Enter])?;
wait(|| {
if ready.exists() {
Ok(())
} else {
Err(std::io::Error::other("child not ready yet"))
}
})?;
// Feed the child four distinct keystrokes.
tmux.send_keys(&[Key('w'), Key('x'), Key('y'), Key('z')])?;
// The child finishes only if it received every keystroke.
wait(|| match read_file(&result) {
Ok(s) if s == "wxyz" => Ok(()),
_ => Err(std::io::Error::other("keys not complete yet")),
})
.map_err(|_| {
std::io::Error::other(format!(
"child did not receive all keystrokes; result file = {:?}",
read_file(&result).ok()
))
})?;
// The child has exited: skim should have restarted its reader and repainted
// (skim's stdout is redirected to a file by `start_sk`, which used to stall
// the post-execute repaint). Typing a query must filter the piped items.
tmux.send_keys(&[Str("aaa")])?;
tmux.until(|l| l.iter().any(|s| s.contains("1/3")))?;
Ok(())
}
#[test]
fn execute_interactive_child_keeps_receiving_keys_fullscreen() -> Result<()> {
run_interactive_execute("execute_interactive_fs", &[])
}
#[test]
fn execute_interactive_child_keeps_receiving_keys_inline() -> Result<()> {
run_interactive_execute("execute_interactive_inline", &["--height=40%"])
}

110
tests/interactive.rs Normal file
View file

@ -0,0 +1,110 @@
#![allow(missing_docs, clippy::pedantic)]
// Pure Zellij-harness e2e tests: they drive `sk` entirely through the terminal
// and depend on nothing OS-specific beyond the harness itself, which is
// cross-platform (see tests/common/zellij.rs). So these run on Linux, macOS and
// Windows.
#[allow(dead_code)]
#[macro_use]
mod common;
use std::io::Cursor;
use skim::prelude::*;
use common::zellij::Keys::*;
sk_test!(sk_version_long, "", &["--version"], {
@output[0] starts_with("sk ");
});
sk_test!(sk_version_short, "", &["-V"], {
@output[0] starts_with("sk ");
});
sk_test!(inline_clear_on_exit, @cmd "seq 1 10", &["--height=50%"], {
@capture[0] starts_with(">");
@keys Escape;
@lines |l| (!l.iter().any(|line| line.starts_with(">")));
});
sk_test!(inline_clear_on_exit_reverse, @cmd "seq 1 10", &["--height=50%", "--layout=reverse"], {
@capture[*] starts_with(">");
@keys Escape;
@lines |l| (!l.iter().any(|line| line.starts_with(">")));
});
sk_test!(inline_clear_on_exit_reverse_list, @cmd "seq 1 10", &["--height=50%", "--layout=reverse-list"], {
@capture[*] starts_with(">");
@keys Escape;
@lines |l| (!l.iter().any(|line| line.starts_with(">")));
});
sk_test!(issue_1120_height_mode_clears_on_exit, @cmd "seq 1 10", &["--height=50%"], {
@capture[0] starts_with(">");
@keys Key('\x1b');
@lines |l| (!l.iter().any(|line| line.starts_with(">")));
});
sk_test!(min_height_grows_inline_viewport, @cmd "for i in {1..20}; do echo min-height-item-$i; done", &["--height=20%", "--min-height=10"], {
@lines |l| (l.iter().map(|line| line.matches("min-height-item-").count()).sum::<usize>() >= 7);
@keys Escape;
});
#[test]
fn library_builder_min_height_child() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var_os("SKIM_MIN_HEIGHT_BUILDER_CHILD").is_none() {
return Ok(());
}
let options = SkimOptionsBuilder::default().height("20%").min_height("10").build()?;
let items = SkimItemReader::default().of_bufread(Cursor::new(
(1..=20)
.map(|i| format!("builder-min-height-item-{i}"))
.collect::<Vec<_>>()
.join("\n"),
));
Skim::run_with(options, Some(items))?;
Ok(())
}
#[test]
fn library_builder_min_height_resizes_and_scrolls() -> Result<(), Box<dyn std::error::Error>> {
let zellij = common::zellij::ZellijController::new_named("builderminheight")?;
zellij.send_keys(&[Str("printf '\\n%.0s' {1..22}"), Enter])?;
zellij.until(|lines| lines.first().is_some_and(|line| line.starts_with("skim$")))?;
let test_binary = std::env::current_exe()?.to_string_lossy().replace('\\', "/");
let test_binary = format!("'{}'", test_binary.replace('\'', "'\\''"));
let command =
format!("SKIM_MIN_HEIGHT_BUILDER_CHILD=1 {test_binary} --exact library_builder_min_height_child --nocapture");
zellij.send_keys(&[Str(&command), Enter])?;
zellij.until(|lines| {
lines
.iter()
.map(|line| line.matches("builder-min-height-item-").count())
.sum::<usize>()
>= 7
})?;
zellij.send_keys(&[Escape])?;
zellij.until(|lines| lines.iter().any(|line| line.contains("test result: ok")))?;
Ok(())
}
#[test]
fn min_height_scrolls_when_cursor_is_near_terminal_bottom() -> std::io::Result<()> {
let mut zellij = common::zellij::ZellijController::new_named("minheightscroll")?;
zellij.send_keys(&[Str("printf '\\n%.0s' {1..22}"), Enter])?;
zellij.until(|lines| lines.first().is_some_and(|line| line.starts_with("skim$")))?;
zellij.start_sk(
Some("for i in {1..20}; do echo min-height-scroll-item-$i; done"),
&["--height=20%", "--min-height=10"],
)?;
zellij.until(|lines| {
lines
.iter()
.map(|line| line.matches("min-height-scroll-item-").count())
.sum::<usize>()
>= 7
})?;
zellij.send_keys(&[Escape])?;
Ok(())
}

View file

@ -33,6 +33,10 @@ insta_test!(layout_border, ["header line 1", "header line 2", "a", "b", "c", "ab
@snap;
});
insta_test!(layout_border_no_collapse, ["header line 1", "header line 2", "a", "b", "c", "ab", "ac"], &args(&["--border", "--border-no-collapse"]), {
@snap;
});
insta_test!(layout_reverse, ["header line 1", "header line 2", "a", "b", "c", "ab", "ac"], &args(&["--layout", "reverse"]), {
@snap;
});
@ -41,6 +45,10 @@ insta_test!(layout_reverse_border, ["header line 1", "header line 2", "a", "b",
@snap;
});
insta_test!(layout_reverse_border_no_collapse, ["header line 1", "header line 2", "a", "b", "c", "ab", "ac"], &args(&["--layout", "reverse", "--border", "--border-no-collapse"]), {
@snap;
});
insta_test!(layout_reverse_list, ["header line 1", "header line 2", "a", "b", "c", "ab", "ac"], &args(&["--layout", "reverse-list"]), {
@snap;
});
@ -48,3 +56,7 @@ insta_test!(layout_reverse_list, ["header line 1", "header line 2", "a", "b", "c
insta_test!(layout_reverse_list_border, ["header line 1", "header line 2", "a", "b", "c", "ab", "ac"], &args(&["--layout", "reverse-list", "--border"]), {
@snap;
});
insta_test!(layout_reverse_list_border_no_collapse, ["header line 1", "header line 2", "a", "b", "c", "ab", "ac"], &args(&["--layout", "reverse-list", "--border", "--border-no-collapse"]), {
@snap;
});

View file

@ -1,18 +1,21 @@
// TODO: automate listen tests on windows
// Maybe using smaller tests ? actions processing is already tested, only the IPC part needs testing
#![allow(missing_docs, clippy::pedantic)]
// The Zellij harness is cross-platform, but this file stays unix-only for
// reasons unrelated to the multiplexer: skim's `--listen`/`--remote` IPC binds a
// unix domain socket here. (`#![cfg(unix)]` already covers both Linux and macOS.)
#![cfg(all(unix, feature = "listen"))]
#[allow(dead_code)]
#[macro_use]
mod common;
use common::tmux::Keys::*;
use common::zellij::Keys::*;
use rand::RngExt as _;
use rand::distr::Alphabetic;
use std::io::{Result, Write as _};
use std::process::{Child, Command, Stdio};
use common::tmux::TmuxController;
use common::zellij::ZellijController;
use crate::common::{SK, SKIM_ENV_REMOVES};
@ -30,8 +33,8 @@ fn send(child: &mut Child, msg: &str) -> Result<()> {
Ok(())
}
fn setup(name: &str, extra_args: &[&str]) -> Result<(TmuxController, Child)> {
let mut tmux = TmuxController::new_named(name)?;
fn setup(name: &str, extra_args: &[&str]) -> Result<(ZellijController, Child)> {
let mut tmux = ZellijController::new_named(name)?;
let socket_name = format!(
"sk-test-{name}{}",
rand::rng()
@ -420,3 +423,36 @@ fn listen_yank() -> std::io::Result<()> {
);
Ok(())
}
// Bind a previously-unbound key over IPC, then trigger it from the keyboard.
#[test]
fn listen_bind() -> std::io::Result<()> {
let (tmux, mut stream) = setup("bind", &[])?;
sk_test!(@expand tmux;
@capture[2]starts_with("> a");
// The header acknowledges that the preceding bind has been processed
// before terminal key events are sent through a separate channel.
send(&mut stream, "bind(ctrl-x:up)+set-header(ready)")?;
@capture[*]trim().eq("ready");
@keys Ctrl(&Key('x')), Ctrl(&Key('x'));
@capture[*]starts_with("> c");
);
Ok(())
}
// Unbind a key over IPC so its keypress becomes a no-op. The trailing `x`
// distinguishes the two cases: if ctrl-u were still bound to unix-line-discard
// the query would read `> x`, but with it unbound the query is preserved.
#[test]
fn listen_unbind() -> std::io::Result<()> {
let (tmux, mut stream) = setup("unbind", &[])?;
sk_test!(@expand tmux;
@keys Str("hello");
@capture[0]trim().eq("> hello");
send(&mut stream, "unbind(ctrl-u)+set-header(ready)")?;
@capture[*]trim().eq("ready");
@keys Ctrl(&Key('u')), Key('x');
@capture[0]trim().eq("> hellox");
);
Ok(())
}

Some files were not shown because too many files have changed in this diff Show more