Compare commits

..

No commits in common. "master" and "v5.1.0" have entirely different histories.

151 changed files with 3351 additions and 8865 deletions

View file

@ -1,5 +0,0 @@
# 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,8 +1,9 @@
experimental = ["wrapper-scripts"]
experimental = ["setup-scripts", "wrapper-scripts"]
# 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.
[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"
# Valgrind wrapper for memory leak detection
[scripts.wrapper.valgrind]
@ -15,28 +16,17 @@ 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.overrides]]
filter = 'binary(interactive) | binary(listen) | binary(popup) | binary(execute)'
test-group = 'e2e'
[[profile.default.scripts]]
platform = "cfg(unix)"
setup = ["stop-tmux", "start-tmux"]
[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
@ -49,6 +39,7 @@ 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
@ -77,3 +68,6 @@ 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"]

View file

@ -1,75 +0,0 @@
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

View file

@ -1,80 +0,0 @@
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

View file

@ -1,13 +0,0 @@
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 --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
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
- name: Check diff
run: |
if git diff --exit-code; then

View file

@ -1,175 +0,0 @@
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.32.0/cargo-dist-installer.sh | sh"
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.4/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
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@v7
uses: actions/upload-artifact@v6
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
@ -91,8 +91,10 @@ jobs:
uses: ./.github/workflows/test.yml
secrets: inherit
permissions:
"contents": "write"
"code-quality": "write"
"contents": "read"
"id-token": "write"
"pages": "write"
# Build and packages all the platform-specific things
build-local-artifacts:
@ -139,7 +141,7 @@ jobs:
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
@ -166,7 +168,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
@ -188,14 +190,14 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
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@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
@ -213,30 +215,20 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
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.custom-package.result == 'skipped' || needs.custom-package.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.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
@ -248,14 +240,14 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
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@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
@ -268,14 +260,14 @@ jobs:
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
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@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: artifacts
@ -310,45 +302,15 @@ 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') && (needs.custom-winget.result == 'skipped' || needs.custom-winget.result == 'success') && (needs.custom-apt.result == 'skipped' || needs.custom-apt.result == 'success') }}
if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish.result == 'skipped' || needs.custom-publish.result == 'success') }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -18,60 +18,38 @@ concurrency:
jobs:
nextest:
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
runs-on: ${{matrix.os}}
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
runner: ubuntu-latest
os: ubuntu-latest
target: x86_64-unknown-linux-musl
- build: macos
runner: macos-latest
os: macos-latest
target: x86_64-apple-darwin
- build: windows
runner: windows-latest
os: windows-latest
target: x86_64-pc-windows-msvc
permissions:
contents: read
steps:
- &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
- &linux-deps
name: "[linux] Install dependencies"
run: |
$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'
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
- &checkout
name: Checkout repository
@ -83,7 +61,7 @@ jobs:
run: rustup toolchain install
- &nextest-install
name: Install nextest
uses: taiki-e/install-action@v2.87.1
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9
- &cache
@ -93,7 +71,7 @@ jobs:
run: cargo test --doc
- name: "Run tests"
# Do not use `--all-targets` to avoid running benches
run: cargo nextest run --release --locked --profile ci --bins --lib --examples --tests
run: cargo nextest run --release --profile ci --bins --lib --examples --tests
env:
LC_ALL: en_US.UTF-8
TERM: xterm-256color
@ -115,28 +93,36 @@ jobs:
coverage:
runs-on: ubuntu-latest
continue-on-error: true
permissions:
contents: write
code-quality: write
steps:
- *zellij-install
- *linux-deps
- *checkout
- *toolchain
- *nextest-install
- uses: taiki-e/install-action@v2.87.1
- uses: taiki-e/install-action@v2
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 --locked --release --profile ci --branch --bins --lib --examples --tests --no-report
cargo +nightly llvm-cov nextest --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
@ -145,17 +131,29 @@ jobs:
status: ${{ env.COVERAGE_PERCENT }}
color: 'blue'
path: 'target/llvm-cov/html/coverage.svg'
- name: "Deploy coverage to gh-pages under /coverage"
- name: "Upload default branch results to gh pages"
if: *if-master
uses: peaceiris/actions-gh-pages@v4
uses: actions/upload-pages-artifact@v5
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: target/llvm-cov/html
destination_dir: coverage
keep_files: true
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
clippy:
runs-on: ${{matrix.runner}}
runs-on: ${{matrix.os}}
strategy:
matrix: *matrix
steps:
@ -166,7 +164,7 @@ jobs:
run: cargo clippy
rustfmt:
runs-on: ${{matrix.runner}}
runs-on: ${{matrix.os}}
strategy:
matrix: *matrix
steps:
@ -177,7 +175,7 @@ jobs:
cargo fmt --all -- --check
clippy-no-default-features:
runs-on: ${{matrix.runner}}
runs-on: ${{matrix.os}}
strategy:
matrix: *matrix
steps:
@ -186,11 +184,11 @@ jobs:
- *cache
- name: Run clippy with specific features
run: |
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
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
msrv:
@ -198,13 +196,16 @@ jobs:
steps:
- *checkout
- *toolchain
- uses: taiki-e/install-action@v2
with:
tool: cargo-msrv@0.19
- name: MSRV Verify
run: cargo +1.91.0 build --release --locked --all-targets
run: cargo msrv verify
fuzz:
permissions:
contents: read
runs-on: ${{matrix.runner}}
runs-on: ${{matrix.os}}
strategy:
matrix: *matrix
steps:
@ -223,7 +224,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.87.1
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz@0.13
- name: Run fuzz targets
@ -246,47 +247,21 @@ jobs:
done
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes-${{ runner.os }}
name: fuzz-crashes-${{ matrix.build }}
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
@ -301,4 +276,4 @@ jobs:
echo "One or more required jobs failed or were cancelled."
exit 1
fi
echo "All required jobs passed."
echo "All required jobs passed (deploy-coverage-page is expected to skip off master)."

View file

@ -1,38 +0,0 @@
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 run --tests` (drives `sk` through Zellij under the hood; needs `zellij` >= 0.44 and `bash` on `$PATH`)
- Integration/E2E tests: `cargo nextest --tests` (will need tmux under the hood)
- 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,34 +34,16 @@
- 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
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`).
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>`
## 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 / --hide-nth)
│ ├── field.rs ← field range parsing (--nth / --with-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); action list generated from ACTION_CATALOG
│ ├── manpage.rs ← man-page generation (cli feature)
│ ├── shell.rs ← shell completion generation (cli feature)
│ ├── engine/ ← match engine implementations
│ │ ├── mod.rs
@ -140,8 +140,7 @@ 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 (re-exports Action, ActionCallback, parse_action)
│ ├── actions.rs ← Action enum + name + parse_action + ACTION_CATALOG, all generated by define_action_catalog!
│ ├── event.rs ← Event enum, Action enum, ActionCallback, parse_action
│ ├── 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)
@ -154,8 +153,7 @@ skim/ ← workspace root
│ └── util.rs ← cursor helpers, style merging
├── tests/ ← integration & snapshot tests
│ ├── common/
│ │ ├── 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
│ │ └── insta.rs ← snap! / insta_test! macros for TUI snapshot testing
│ ├── snapshots/ ← committed .snap files
│ ├── ansi.rs ← ANSI rendering tests
│ ├── options.rs ← option coverage tests
@ -166,7 +164,6 @@ 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.
@ -177,9 +174,6 @@ 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`
@ -220,7 +214,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`. |
@ -271,7 +265,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)` |
@ -292,7 +286,6 @@ 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 {
@ -302,7 +295,6 @@ 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.
@ -314,7 +306,6 @@ 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
@ -322,7 +313,6 @@ 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() {
@ -331,7 +321,6 @@ 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 {
@ -340,7 +329,6 @@ 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`)
@ -354,7 +342,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) |
@ -381,12 +369,10 @@ 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`.
@ -428,11 +414,9 @@ 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 1: bounded dispatcher — uses in-flight tokens to limit queued/running jobs
├─ Pool workers: receive chunk jobs, validate UTF-8,
├─ Thread N: workers — receive chunks, validate UTF-8,
│ create DefaultSkimItem::new(line, ansi, trans_fields, matching_fields, delimiter)
│ .hidden_fields(hidden_fields, delimiter)
│ (handles ANSI stripping, --nth / --with-nth / --hide-nth inline),
│ (handles ANSI stripping, --nth / --with-nth transforms 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)
@ -454,32 +438,14 @@ 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
@ -492,7 +458,7 @@ Engines are composable through the factory pattern. Starting from `Matcher::crea
options
├── if regex mode:
│ RegexEngineFactory (configured with the same RankBuilder / --tiebreak criteria)
│ RegexEngineFactory
│ └─ if normalize: NormalizedEngineFactory(RegexEngineFactory)
└── else (fuzzy/exact mode):
@ -522,7 +488,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` |
@ -535,12 +501,11 @@ 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 |
@ -548,7 +513,6 @@ 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
@ -582,21 +546,20 @@ 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))
│ └─ validate query generation and write into Mutex<Option<ProcessedItems>>
│ └─ write into SpinLock<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()`. 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.
Interruption is cooperative: each chunk checks `interrupt.load(Relaxed)` before processing. `MatcherControl::kill()` sets `interrupt = true`; `MatcherControl::drop()` also calls `kill()`.
### 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 |
@ -609,13 +572,10 @@ 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` | 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.
| `Append` | `--no-sort` mode |
---
@ -624,26 +584,22 @@ 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.
- An `is_fullscreen` flag that determines the `ratatui::Viewport`.
- The fixed viewport `Rect` for inline mode, which can move when the terminal scrolls.
- A `is_fullscreen` flag that determines the `ratatui::Viewport`.
**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. 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.
Any fixed viewport is anchored at the current cursor position; the terminal is scrolled if needed to make room.
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()
@ -661,10 +617,6 @@ 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
@ -695,7 +647,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 |
@ -710,7 +662,6 @@ 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`:
@ -718,12 +669,11 @@ 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() → normal handling + optional `double-click` key event
Event::Mouse(…) → handle_mouse()
Event::PreviewReady → apply preview offset; needs_render()
Event::AppendItems → item_pool.append(); restart_matcher(false)
Event::ClearItems → item_pool.clear(); restart_matcher(true)
@ -732,7 +682,6 @@ Event::Reload(_) → (handled by Skim::tick, not here)
```
**`App::restart_matcher(force)`:**
```
restart_matcher(force)
├─ if !force && matcher not stopped → skip (debounce)
@ -740,7 +689,6 @@ 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
@ -768,13 +716,9 @@ 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. Bordered
widgets share adjacent border rows and columns by default;
`SkimOptions::border_no_collapse` keeps their areas separate.
`LayoutTemplate` pre-computes area splits from `SkimOptions` once and stores constraint trees. `apply(area: Rect) → AppLayout` is then a cheap, allocation-free split.
`AppLayout` has four optional areas:
```rust
pub struct AppLayout {
pub list_area: Rect,
@ -789,13 +733,12 @@ 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`
@ -807,13 +750,11 @@ 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()`
@ -827,24 +768,19 @@ 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<Mutex<Option<ProcessedItems>>>` — shared with matcher
- `matcher_generation: Arc<AtomicUsize>` — identifies the active query generation
- `processed_items: Arc<SpinLock<Option<ProcessedItems>>>` — shared with matcher
- `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`, rejects results from an old query generation, and swaps current results in through the mutex. Depending on `MergeStrategy`:
On each render, `ItemList::render()` checks `processed_items` and swaps them in atomically via the `SpinLock`. 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
@ -870,14 +806,13 @@ 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 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`.
**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`.
**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
@ -895,19 +830,16 @@ else if pty mode:
→ Event::PreviewReady when EOF
else:
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(…)
sh -c <cmd>
thread: wait for output → content.write() = PreviewContent::Text(…)
→ Event::PreviewReady
```
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.
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.
### 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.
@ -923,11 +855,8 @@ Inline sep: " < " (when inline_info)
Right side: multi-select count when multi mode
```
`InfoDisplay` has six modes:
`InfoDisplay` has four 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
@ -940,7 +869,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/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(raw)` (in `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>`.
@ -951,7 +880,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` |
@ -971,87 +900,6 @@ 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
```
@ -1064,13 +912,10 @@ 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. 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:
`handle_action` is a large match statement covering all ~70+ `Action` variants. 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` |
@ -1081,7 +926,6 @@ section is generated from — so the enum, `Action::name`, `parse_action` and th
| 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.
@ -1093,7 +937,7 @@ section is generated from — so the enum, `Action::name`, `parse_action` and th
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 |
@ -1123,9 +967,6 @@ 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)
@ -1138,7 +979,6 @@ Skim::output()
```
`SkimOutput` fields returned to caller:
```rust
pub struct SkimOutput {
pub final_event: Event, // Action::Accept or Action::Abort
@ -1153,7 +993,6 @@ 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`
@ -1206,7 +1045,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 |
@ -1226,7 +1065,6 @@ 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.
@ -1237,7 +1075,6 @@ 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.
@ -1256,7 +1093,6 @@ 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
@ -1279,23 +1115,21 @@ Main thread (Tokio runtime)
└─ Tokio task: Tui event pump (crossterm EventStream + tick timer)
Matcher ThreadPool (persistent)
└─ Worker threads process atomic match chunks; one separate coordinator thread waits for completion
ThreadPool (N = num_cpus OS threads, persistent)
├─ Matcher coordinator (1 slot per match run)
└─ Worker threads (N-1 slots per match run)
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
Reader threads (OS threads, per-invocation):
├─ collect_items thread: blocks on SkimItemReceiver (recv_timeout 5ms), calls ItemPool::append
├─ I/O reader thread: reads large byte chunks, splits lines, assigns sequence numbers
├─ Bounded dispatcher thread: submits chunk jobs only while an in-flight token is available
├─ Worker threads (N): parse lines, create DefaultSkimItem (ANSI strip + field transforms inline)
├─ Reorder thread: sequence-ordered output; drops tx_pipeline_done on EOF
└─ Killer thread: waits for rx_interrupt or rx_pipeline_done; kills a command child if present
└─ Killer thread (command inputs only): waits for rx_interrupt or rx_pipeline_done;
kills child process when either fires
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
Preview thread (OS thread, per preview spawn):
└─ reads PTY/child stdout or decodes image path → PreviewContent Arc<RwLock>
→ sends Event::PreviewReady
IPC handler task (Tokio, per connection):
└─ reads RON actions → sends Event::Action to TUI channel
@ -1305,10 +1139,9 @@ Popup stdin relay thread (OS thread, only in --popup/--tmux mode):
```
**Synchronization primitives used:**
- `Arc<Mutex<Option<ProcessedItems>>>` — matcher-to-ItemList result handoff without CPU-spinning under merge contention
- `Arc<SpinLock<Option<ProcessedItems>>>` — matcher-to-ItemList result handoff
- `Arc<AtomicBool>``needs_render` (matcher → event loop), `stopped` / `interrupt` (MatcherControl)
- `Arc<AtomicUsize>``processed` / `matched` counters, matcher query generation, reader `components_to_stop`
- `Arc<AtomicUsize>``processed` / `matched` counters, 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
@ -1317,6 +1150,55 @@ 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,224 +5,6 @@ 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

1264
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,12 @@
[package]
name = "skim"
version = "5.7.0"
version = "5.1.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"
@ -49,13 +51,6 @@ 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)'] }
@ -72,31 +67,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 }
eyre = "0.6.12"
color-eyre = "0.6.5"
# 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.13.0", optional = true }
frizbee = { version = "=0.10.0", optional = true }
futures = "0.3.32"
gungraun = { version = "0.19.1", optional = true }
image = { version = "0.25.10", optional = true, default-features = false, features = ["png", "jpeg", "gif", "webp"] }
image = { version = "0.25.10", optional = true }
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", "signal"] }
nix = { version = "0.31.3", features = ["fs", "poll"] }
portable-pty = "0.9.0"
ratatui = "0.30.0"
ratatui-image = { version = "11.0.4", features = ["crossterm"], default-features = false, optional = true }
ratatui-image = { version = "11.0.4", features = ["image-defaults", "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.8.0"
shell-quote = "0.7.2"
shlex = { version = "2.0.1", optional = true }
tempfile = "3.27.0"
thiserror = "2.0.18"
@ -113,7 +108,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.151" }
serde_json = { version = "=1.0.150" }
serial_test = "=3.5.0"
[[bench]]
@ -147,33 +142,3 @@ 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/coverage" >
<img src="https://skim-rs.github.io/skim/coverage/coverage.svg" alt="coverage badge" />
<a href="https://skim-rs.github.io/skim" >
<img src="https://skim-rs.github.io/skim/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,72 +81,28 @@ 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` |
| Windows | winget | `winget install skim` |
| Windows | Scoop | `scoop install skim` |
| Debian/Ubuntu | apt | see below |
| Fedora/RHEL | dnf | see below |
| 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` |
<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>
### 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.
### Fedora
Up to date Fedora 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: 181 KiB

After

Width:  |  Height:  |  Size: 152 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, log", lbl)
.set_y_label("Time (s, log)", lbl)
.set_x_label("Items", lbl)
.set_y_label("Time (s)", 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, log", lbl)
.set_x_label("Items", lbl)
.set_y_label("CPU (%)", lbl)
.set_border(true, &[Bottom, Left, Top, Right], &[Color(gnuplot::RGBString(SURFACE))])
.set_x_log(Some(10.0))
@ -1506,18 +1506,16 @@ fn cmd_plot(args: &PlotArgs) -> std::result::Result<(), Box<dyn std::error::Erro
// ── Panel 2: Peak Memory — log x, linear y ───────────────────────────────
{
let (mn, mx) = y_extent(&mem_bands);
let y_lo = (mn * 0.5).max(1e-9);
let (_, mx) = y_extent(&mem_bands);
let y_hi = (mx * 1.25).max(1.0);
let axes = fg.axes2d();
axes.set_title("Peak Memory", lbl)
.set_x_label("Items, log", lbl)
.set_y_label("Memory (MB, log)", lbl)
.set_x_label("Items", lbl)
.set_y_label("Memory (MB)", 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(y_lo), Fix(y_hi))
.set_y_range(Fix(0.0), Fix(y_hi))
.set_x_grid(true)
.set_y_grid(true)
.set_legend(
@ -1535,7 +1533,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, log", lbl)
.set_x_label("Items", 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,16 +91,15 @@ 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", group = "<!-- 8 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 9 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 10 -->◀️ Revert" },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
{ message = "^release", skip = true },
{ message = ".*", group = "<!-- 11 -->💼 Other" },
{ message = ".*", group = "<!-- 10 -->💼 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.32.0"
cargo-dist-version = "0.30.4"
# CI backends to support
ci = "github"
# The installers to generate for each app
@ -19,17 +19,13 @@ 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", "./winget", "./apt"]
publish-jobs = ["./publish"]
# Whether to publish prereleases to package managers
publish-prereleases = true
[dist.github-custom-job-permissions.test]
contents = "write"
id-token = "write"
[dist.github-custom-job-permissions.apt]
contents = "write"
code-quality = "write"
contents = "read"
pages = "write"
id-token = "write"

View file

@ -3,7 +3,7 @@
use skim::prelude::*;
use skim::tui::statusline::InfoDisplay;
fn main() -> eyre::Result<()> {
fn main() -> color_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 eyre::Result;
use color_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() -> eyre::Result<()> {
fn main() -> color_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(all(target_os = "linux", target_env = "gnu"))]
#[cfg(target_os = "linux")]
unsafe {
nix::libc::malloc_trim(0);
}

View file

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

View file

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

153
fuzz/Cargo.lock generated
View file

@ -2,6 +2,21 @@
# 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"
@ -81,6 +96,21 @@ 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"
@ -197,6 +227,33 @@ 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"
@ -681,6 +738,12 @@ 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"
@ -951,6 +1014,15 @@ 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"
@ -1054,6 +1126,15 @@ 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"
@ -1069,6 +1150,12 @@ 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"
@ -1439,6 +1526,12 @@ 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"
@ -1537,6 +1630,15 @@ 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"
@ -1613,14 +1715,14 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "skim"
version = "5.6.3"
version = "5.0.0"
dependencies = [
"ansi-to-tui",
"assert_enum_variants",
"color-eyre",
"crossterm",
"derive_builder",
"derive_more",
"eyre",
"futures",
"indexmap",
"kanal",
@ -1943,6 +2045,47 @@ 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"
@ -2037,6 +2180,12 @@ 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,8 +29,7 @@ auto-release:
test target="":
cargo test --doc
cargo nextest run {{ target }}
# Each e2e test creates and tears down its own Zellij session, so there is
# no shared multiplexer session to clean up here.
tmux kill-session -t skim_e2e
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.7.0"
.TH sk 1 "sk 5.1.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\-\-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]
\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]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH OPTIONS
@ -94,16 +94,6 @@ 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
@ -197,16 +187,13 @@ 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 key, event, and action bindings
Comma separated list of bindings
`\-\-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.
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
**Example**: `sk \-\-bind=ctrl\-j:accept,load:last,act\-up:down`
**Example**: `sk \-\-bind=ctrl\-j:accept,ctrl\-k:kill\-line`
## Multiple actions can be chained using + separator.
@ -346,11 +333,10 @@ 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 as a non\-negative row count
Minimum height of skim\*(Aqs window
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.
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
@ -403,8 +389,6 @@ 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
@ -461,9 +445,6 @@ 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
@ -515,9 +496,9 @@ Maximum number of query history entries to keep
\fB\-\-preview\fR \fI<PREVIEW>\fR
Preview command
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).
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).
**Examples**:
@ -756,9 +737,9 @@ If a term is prefixed by `!`, sk will exclude the items that match this term.
.SH KEYBINDS
.br
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.
Keybinds can be set by the `\-\-bind` option, which takes a comma\-separated list of [key]:[action[+action2].
.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)"
@ -838,285 +819,131 @@ Actions can take arguments, specified either between parentheses `reload(ls)` or
.br
* alt\-shift\-right
.br
* double\-click
.br
* any single character
.br
.SS "Bindable finder events"
.SS "Actions[:default keys][*notes]"
.br
* change: the query changes
* abort: ctrl\-c ctrl\-q esc
.br
* start: skim enters its event loop; fired once
* accept(...): enter *the argument will be printed when the binding is triggered*
.br
* load: the reader and matcher finish consuming the current input; fired once per read, including reloads
* append\-and\-select
.br
* result: filtering for the current query completes
* backward\-char: ctrl\-b left
.br
* focus: the focused item changes because of cursor movement or a result update
* backward\-delete\-char: ctrl\-h bspace
.br
* zero: the input stream is complete and the final search has no matches
* backward\-delete\-char/eof
.br
* one: the input stream is complete and the final search has exactly one match
* backward\-kill\-word: alt\-bs
.br
.SS "Actions as binding triggers"
* backward\-word: alt\-b shift\-left
.br
Actions can also be used as binding triggers. A follow\-up chain bound to an action name runs immediately after that action. Use the `act\-` prefix for action triggers; it is recommended to avoid ambiguity and required when the action name is also a key, for example `act\-up:last`.
* beginning\-of\-line: ctrl\-a home
.br
* clear\-screen: ctrl\-l
.br
Follow\-up chains use non\-recursive (`noremap`) semantics: their actions do not trigger further action bindings. Add `suppress` to skip the triggering action\*(Aqs default behavior, for example `act\-up:suppress+down`.
* delete\-char: del
.br
.SS Actions
* delete\-char/eof: ctrl\-d
.br
* abort: Abort and exit with error
* deselect\-all
.br
* accept(...): Accept selection and exit with optional key. The argument is printed when the binding is triggered.
* down: ctrl\-j ctrl\-n down
.br
* add\-char(...): Add a character to the query
* end\-of\-line: ctrl\-e end
.br
* append\-and\-select: Append to selection and select
* execute(...): *arg will be a command, see COMMAND EXPANSION for details
.br
* backward\-char: Move cursor backward one character
* execute\-silent(...): *arg will be a command, see COMMAND EXPANSION for details
.br
* backward\-delete\-char: Delete character before cursor
* forward\-char: ctrl\-f right
.br
* backward\-delete\-char/eof: Delete character before cursor or exit if the query is empty
* forward\-word: alt\-f shift\-right
.br
* backward\-kill\-word: Delete word before cursor
* if\-non\-matched
.br
* backward\-word: Move cursor backward one word
* if\-query\-empty
.br
* beginning\-of\-line: Move cursor to beginning of line
* if\-query\-not\-empty
.br
* 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`.
* ignore
.br
* cancel: Cancel current operation
* kill\-line
.br
* clear\-screen: Clear the screen
* kill\-word: alt\-d
.br
* delete\-char: Delete character under cursor
* next\-history: ctrl\-n with `\-\-history` or `\-\-cmd\-history`
.br
* delete\-char/eof: Delete character or exit if empty
* page\-down: pgdn
.br
* deselect\-all: Deselect all items
* page\-up: pgup
.br
* down(...): Move selection down by N items
* half\-page\-down
.br
* end\-of\-line: Move cursor to end of line
* half\-page\-up
.br
* execute(...): Execute a command. The argument is a command, see COMMAND EXPANSION for details.
* preview\-up: shift\-up
.br
* execute\-silent(...): Execute a command silently. The argument is a command, see COMMAND EXPANSION for details.
* preview\-down: shift\-down
.br
* first: Jump to first item in list
* preview\-left
.br
* forward\-char: Move cursor forward one character
* preview\-right
.br
* forward\-word: Move cursor forward one word
* preview\-page\-down
.br
* if\-query\-empty(...): Execute action if query is empty
* preview\-page\-up
.br
* if\-query\-not\-empty(...): Execute action if query is not empty
* previous\-history: ctrl\-p with `\-\-history` or `\-\-cmd\-history`
.br
* if\-non\-matched(...): Execute action if no items match
* redraw
.br
* ignore: Ignore the action
* refresh\-cmd
.br
* kill\-line: Delete from cursor to end of line
* refresh\-preview
.br
* kill\-word: Delete word after cursor
* reload(...)
.br
* last: Jump to last item in list
* select\-all
.br
* next\-history: Move to next history entry (requires `\-\-history` or `\-\-cmd\-history`)
* select\-row
.br
* half\-page\-down(...): Scroll down by half a page
* set\-preview\-cmd(...): *arg will be a expanded expression, see COMMAND EXPANSION for details
.br
* half\-page\-up(...): Scroll up by half a page
* set\-query(...): *arg will be a expanded expression, see COMMAND EXPANSION for details
.br
* page\-down(...): Scroll down by a page
* toggle
.br
* page\-up(...): Scroll up by a page
* toggle\-all
.br
* preview\-up(...): Scroll preview up
* toggle+down: ctrl\-i tab
.br
* preview\-down(...): Scroll preview down
* toggle\-in: (\-\-layout=reverse ? toggle+up: toggle+down)
.br
* preview\-left(...): Scroll preview left
* toggle\-interactive
.br
* preview\-right(...): Scroll preview right
* toggle\-out: (\-\-layout=reverse ? toggle+down: toggle+up)
.br
* preview\-page\-up(...): Scroll preview up by a page
* toggle\-preview
.br
* preview\-page\-down(...): Scroll preview down by a page
* toggle\-preview\-wrap
.br
* previous\-history: Move to previous history entry (requires `\-\-history` or `\-\-cmd\-history`)
* toggle\-sort
.br
* redraw: Redraw the screen
* toggle+up: btab shift\-tab
.br
* refresh\-cmd: Refresh the command
* top
.br
* refresh\-preview: Refresh the preview
* unix\-line\-discard: ctrl\-u
.br
* restart\-matcher: Restart the matcher
* unix\-word\-rubout: ctrl\-w
.br
* reload(...): Reload with optional new command
* up: ctrl\-k ctrl\-p up
.br
* 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
* yank: ctrl\-y
.br
.SH "COMMAND EXPANSION"
@ -1336,4 +1163,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.7.0
v5.1.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 .= ' '.skim#shellescape('--bind='.join(map(keys(opts._action), 'v:val.":accept(".v:val.")"'), ','))
let opts.options .= ' --expect='.join(keys(opts._action), ',')
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 --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"
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"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -53,10 +53,6 @@ _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,7 +13,6 @@ 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'
@ -28,7 +27,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 key, event, and action bindings' -r
complete -c sk -s b -l bind -d 'Comma separated list of 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
@ -38,7 +37,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 as a non-negative row count' -r
complete -c sk -l min-height -d 'Minimum height of skim\'s window' -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
@ -157,7 +156,6 @@ 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,7 +44,6 @@ 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
@ -56,7 +55,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 key, event, and action bindings
--bind(-b): string # Comma separated list of bindings
--multi(-m) # Enable multiple selection
--no-multi # Disable multiple selection
--no-mouse # Disable mouse
@ -79,7 +78,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 as a non-negative row count
--min-height: string # Minimum height of skim's window
--margin: string # Screen margin
--prompt(-p): string # Set prompt
--cmd-prompt: string # Set prompt in command mode
@ -94,7 +93,6 @@ 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,7 +21,6 @@ _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"
@ -37,8 +36,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 key, event, and action bindings]::BIND:_default' \
'*--bind=[Comma-separated key, event, and action bindings]::BIND:_default' \
'*-b+[Comma separated list of bindings]::BIND:_default' \
'*--bind=[Comma separated list of 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' \
@ -49,7 +48,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 as a non-negative row count]:MIN_HEIGHT:_default' \
'--min-height=[Minimum height of skim'\''s window]:MIN_HEIGHT:_default' \
'--margin=[Screen margin]:MARGIN:_default' \
'-p+[Set prompt]:PROMPT:_default' \
'--prompt=[Set prompt]:PROMPT:_default' \
@ -174,7 +173,6 @@ 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,6 +267,12 @@ _skim_dir_completion() {
"" "/" ""
}
_skim_feed_fifo() (
command rm -f "$1"
mkfifo "$1"
cat <&0 > "$1" &
)
_skim_complete() {
setopt localoptions ksh_arrays
# Split arguments around --
@ -290,17 +296,20 @@ _skim_complete() {
rest=("$@")
fi
local lbuf cmd matches post
local fifo lbuf cmd matches post
fifo="${TMPDIR:-/tmp}/skim-complete-fifo-$$"
lbuf=${rest[0]}
cmd=$(__skim_extract_command "$lbuf")
post="${funcstack[1]}_post"
type $post > /dev/null 2>&1 || post=cat
matches=$(SKIM_DEFAULT_OPTIONS="--reverse $SKIM_DEFAULT_OPTIONS $SKIM_COMPLETION_OPTS $str_arg" __skim_comprun "$cmd" "${args[@]}" -q "${(Q)prefix}" | $post | tr '\n' ' ')
_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' ' ')
if [ -n "$matches" ]; then
LBUFFER="$lbuf$matches"
fi
zle reset-prompt
command rm -f "$fifo"
}
_skim_complete_telnet() {

View file

@ -1 +1 @@
5.7.0
5.1.0

View file

@ -9,7 +9,8 @@ extern crate log;
extern crate shlex;
extern crate skim;
use eyre::{Result, eyre};
use color_eyre::Result;
use color_eyre::eyre::eyre;
#[cfg(feature = "listen")]
use interprocess::bound_util::RefWrite;
#[cfg(feature = "listen")]
@ -71,6 +72,7 @@ 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,89 +6,11 @@
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::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()
}
}
use crate::tui::event::{self, Action};
/// A map of key events to their associated actions
#[derive(Clone, Debug)]
@ -109,7 +31,7 @@ impl DerefMut for KeyMap {
impl From<&str> for KeyMap {
fn from(value: &str) -> Self {
parse_keymaps(split_top_level(value, ',').into_iter())
parse_keymaps(value.split(','))
}
}
@ -120,11 +42,6 @@ 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
@ -169,7 +86,6 @@ 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]);
@ -215,10 +131,7 @@ pub fn get_default_key_map() -> KeyMap {
KeyMap(ret)
}
/// 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`.
/// Parses a key str into a crossterm `KeyEvent`
///
/// # Errors
/// Returns an error if the key string is empty, contains an unknown modifier,
@ -227,9 +140,6 @@ 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;
@ -255,11 +165,8 @@ pub fn parse_key(key: &str) -> Result<KeyEvent> {
} else {
keycode = KeyCode::Char(char);
}
} 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.
} else if let Some(f) = key.strip_prefix('f') {
let f_index = f.parse::<u8>()?;
keycode = KeyCode::F(f_index);
} else {
keycode = match key.as_str() {
@ -277,10 +184,8 @@ pub fn parse_key(key: &str) -> Result<KeyEvent> {
"end" => KeyCode::End,
"pgup" => KeyCode::PageUp,
"pgdown" => KeyCode::PageDown,
s => match SkimEvent::from_name(s) {
Some(event) => event.key_code(),
None => return Err(eyre!("Unknown key {}", s)),
},
"change" => KeyCode::F(255),
s => return Err(eyre!("Unknown key {}", s)),
}
}
@ -299,90 +204,13 @@ 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 = split_top_level(action_chain, '+').into_iter();
let mut split = action_chain.split('+');
while let Some(mut s) = split.next().map(String::from) {
if (s.starts_with("if-") || s.ends_with('{'))
@ -390,7 +218,7 @@ pub fn parse_action_chain(action_chain: &str) -> Result<Vec<Action>> {
{
s += &(String::from("+") + otherwise);
}
if let Some(act) = actions::parse_action(&s) {
if let Some(act) = event::parse_action(&s) {
actions.push(act);
}
}

View file

@ -1,5 +1,5 @@
use super::*;
use actions::Action::*;
use event::Action::*;
#[test]
fn test_parse_action_chain() {
let parsed = parse_action_chain(
@ -129,44 +129,6 @@ 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.
@ -187,104 +149,12 @@ 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,33 +79,24 @@ impl MatchEngine for ExactEngine {
let mut matched_result = None;
let item_text = item.text();
let default_range = [(0, item_text.len())];
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;
}
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;
}
// 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.
matched_result =
regex_match(&item_text[start..end], self.query_regex.as_ref()).map(|(s, e)| (s + start, e + start));
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,83 +83,6 @@ 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,14 +23,6 @@ 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)]
@ -40,8 +32,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| parse_index(s.as_str()));
let opt_right = caps.name("right").map(|s| parse_index(s.as_str()));
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_sep = caps.name("sep").map(|s| s.as_str().to_string());
match (opt_left, opt_right) {

View file

@ -19,24 +19,6 @@ 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,7 +1,6 @@
//! 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,16 +111,10 @@ impl Atom for u8 {
}
}
impl Atom for char {
#[inline(always)]
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)
self.to_lowercase().eq(b.to_lowercase())
}
#[inline(always)]
fn is_lowercase(self) -> bool {
self.is_lowercase()
@ -186,8 +179,6 @@ 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,14 +28,18 @@ 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;
let (j_first, row_bounds) = if ALLOW_TYPOS {
(find_first_char(pat, cho, respect_case)?, None)
if ALLOW_TYPOS {
j_first = find_first_char(pat, cho, respect_case)?;
row_bounds = None;
} else {
let fm = compute_first_match_cols(pat, cho, respect_case)?;
let lm = compute_last_match_cols(pat, cho, respect_case)?;
(fm[0], Some(compute_row_col_bounds(n, m, &fm, &lm)))
};
j_first = fm[0];
row_bounds = 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,14 +140,13 @@ impl FuzzyMatcher for ClangdMatcher {
let mut row = num_pattern_chars;
let mut col = num_choice_chars;
while col > 0 {
while row > 0 || col > 0 {
if last_action == Action::Match {
indices_reverse.push((col - 1) as IndexType);
}
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,9 +49,12 @@ impl FrizbeeMatcher {
LOCAL_MATCHER.with(|cell| {
let mut slot = cell.borrow_mut();
let matcher = slot.get_or_insert_with(|| Matcher::new("", &self.config));
if slot.as_ref().is_none() {
*slot = Some(Matcher::new("", &self.config));
}
let matcher = slot.as_mut().unwrap();
matcher.set_config(self.config.clone());
matcher.set_pattern(pattern);
matcher.set_needle(pattern);
f(matcher)
})
}
@ -62,10 +65,7 @@ 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.into_iter().map(|index| index as usize).collect(),
)
(hit.score.into(), hit.indices)
})
})
}

View file

@ -85,53 +85,4 @@ 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,15 +27,6 @@ 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,43 +22,26 @@ pub fn cheap_matches(choice: &[char], pattern: &[char], case_sensitive: bool) ->
}
}
/// 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.
/// Given 2 character, check if they are equal (considering ascii 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 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;
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.is_ascii() && b.is_ascii() {
return a.eq_ignore_ascii_case(&b);
}
a.to_lowercase().eq(b.to_lowercase())
}
#[derive(Debug, PartialEq)]
@ -175,56 +158,11 @@ 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, Matches, SkimItem};
use crate::{DisplayContext, SkimItem};
use ansi_to_tui::IntoText;
use ratatui::text::{Line, Span};
use regex::Regex;
@ -48,12 +48,6 @@ 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,
}
@ -168,7 +162,6 @@ impl DefaultSkimItem {
stripped_text: stripped_text.map(std::string::String::into_boxed_str),
ansi_info,
matching_ranges,
hidden_ranges: None,
disabled: false,
}))
} else {
@ -181,32 +174,6 @@ 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()
}
@ -261,16 +228,6 @@ 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 {
@ -316,10 +273,6 @@ 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)]
@ -336,23 +289,9 @@ 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 matches {
match context.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
@ -507,19 +446,6 @@ 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))
@ -625,110 +551,6 @@ 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,7 +30,6 @@ 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,
@ -45,7 +44,6 @@ 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,
@ -71,11 +69,6 @@ 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(),
@ -144,23 +137,6 @@ 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 {
@ -265,13 +241,16 @@ 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. **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.
/// 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.
/// 3. **Pool jobs** — parse lines, validate UTF-8, apply ANSI stripping and
/// field transforms, and create `DefaultSkimItem` + `Arc` per line.
/// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from workers
/// and emits them in sequence order so downstream index assignment
/// 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
/// and `--tac` behaviour are correct.
///
/// When `child` is `Some`, a **killer thread** is also spawned. It waits
@ -300,27 +279,18 @@ impl SkimItemReader {
// Stage 1: I/O thread.
Self::spawn_io_reader(source, tx_chunks, line_ending);
// 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");
}
// Stage 2: dispatcher thread — bridges the bounded channel to the pool.
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
@ -471,8 +441,7 @@ 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,171 +487,3 @@ 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,14 +24,12 @@ 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,
}
}
}
@ -45,13 +43,7 @@ impl RankBuilder {
}
criterion.dedup();
Self { criterion, tac: false }
}
#[must_use]
pub(crate) fn tac(mut self, tac: bool) -> Self {
self.tac = tac;
self
Self { criterion }
}
/// Returns the tiebreak criteria slice.
@ -60,40 +52,11 @@ impl RankBuilder {
&self.criterion
}
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.
/// Computes the byte offset of the first character after the last path separator
/// (`/` or `\`) in `text`. Returns `0` when no separator is present.
fn path_name_offset(text: &str) -> i32 {
text.rfind(['/', '\\']).map_or(0, |pos| {
i32::try_from(text[..pos].chars().count())
.unwrap_or(i32::MAX)
.saturating_add(1)
})
text.rfind(['/', '\\'])
.map_or(0, |pos| i32::try_from(pos).unwrap_or(i32::MAX).saturating_add(1))
}
/// Builds a `Rank` from raw match measurements.
@ -158,9 +121,8 @@ 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. The sixth slot is the tac-aware implicit index tiebreak.
/// Caching avoids recomputing it on every comparison during sort.
sort_key: [i32; 6],
/// criteria. Caching avoids recomputing it on every comparison during sort.
sort_key: [i32; 5],
}
impl std::fmt::Debug for MatchedItem {
@ -200,7 +162,7 @@ impl MatchedItem {
item,
rank,
matched_range,
sort_key: rank_builder.sort_key(&rank),
sort_key: rank.sort_key(rank_builder.criteria()),
}
}
/// Merge two sorted `Vec<MatchedItem>` lists into one, preserving sort order by rank.
@ -397,7 +359,9 @@ impl PartialOrd for MatchedItem {
impl Ord for MatchedItem {
fn cmp(&self, other: &Self) -> CmpOrd {
self.sort_key.cmp(&other.sort_key)
self.sort_key
.cmp(&other.sort_key)
.then_with(|| self.rank.index.cmp(&other.rank.index))
}
}

View file

@ -37,33 +37,6 @@ 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 {
@ -86,57 +59,6 @@ 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,16 +1,12 @@
//! 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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use eyre::Result;
use color_eyre::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:
@ -123,30 +119,70 @@ const KEYS_SS: &str = "
* alt-shift-down
* alt-shift-left
* alt-shift-right
* double-click
* any single character
";
const BINDABLE_EVENTS_SS: &str = concat!(
"\n",
"* change: the query changes\n",
"* start: skim enters its event loop; fired once\n",
"* load: the reader and matcher finish consuming the current input; ",
"fired once per read, including reloads\n",
"* result: filtering for the current query completes\n",
"* focus: the focused item changes because of cursor movement or a result update\n",
"* zero: the input stream is complete and the final search has no matches\n",
"* one: the input stream is complete and the final search has exactly one match\n",
);
const ACTION_BINDINGS_SS: &str = concat!(
"\n",
"Actions can also be used as binding triggers. A follow-up chain bound to an action name runs immediately ",
"after that action. Use the `act-` prefix for action triggers; it is recommended to avoid ambiguity and ",
"required when the action name is also a key, for example `act-up:last`.\n\n",
"Follow-up chains use non-recursive (`noremap`) semantics: their actions do not trigger further action ",
"bindings. Add `suppress` to skip the triggering action's default behavior, for example ",
"`act-up:suppress+down`.\n",
);
const ACTIONS_SS: &str = "
* 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
";
#[cfg(feature = "listen")]
const REMOTE_SECTION: &str = "
@ -160,87 +196,6 @@ 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() {
@ -327,19 +282,13 @@ Exact search can be enabled by default by the `--exact` command-line flag. In ex
section(
&mut custom,
"KEYBINDS",
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",
),
"
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`
",
);
subsection(&mut custom, "Available keys (aliases in parentheses)", KEYS_SS);
subsection(&mut custom, "Bindable finder events", BINDABLE_EVENTS_SS);
subsection(&mut custom, "Actions as binding triggers", ACTION_BINDINGS_SS);
subsection(&mut custom, "Actions", &actions_ss());
subsection(&mut custom, "Default key bindings", &default_keys_ss());
subsection(&mut custom, "Actions[:default keys][*notes]", ACTIONS_SS);
section(
&mut custom,
@ -456,54 +405,4 @@ 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,13 +2,14 @@
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`.
@ -20,80 +21,48 @@ 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, worker results arrive in chunk-index order and are
/// flattened without sorting.
/// When `no_sort` is true, the worker results are simply flattened.
///
/// 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: &Mutex<Option<ProcessedItems>>,
processed_items: &SpinLock<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());
// 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;
}
// Single lock, single write into processed_items.
let mut guard = processed_items.lock();
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 {
if matches!(merge_strategy, MergeStrategy::Prepend) {
items.append(&mut existing.items);
existing.items = items;
} else {
existing.items.extend(items);
}
existing.items.extend(items);
} else {
// Both sides are fully sorted — one O(n+m) merge.
MatchedItem::merge_into_sorted(&mut existing.items, items);
@ -103,7 +72,6 @@ fn merge_worker_results(
*guard = Some(ProcessedItems {
items,
merge: merge_strategy,
generation,
});
}
}
@ -220,16 +188,15 @@ impl Matcher {
#[must_use]
pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
if options.regex {
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
let regex_factory = RegexEngineFactory::builder().rank_builder(rank_builder.clone());
let regex_factory = RegexEngineFactory::builder();
let factory: Rc<dyn MatchEngineFactory> = if options.normalize {
Rc::new(NormalizedEngineFactory::new(regex_factory))
} else {
Rc::new(regex_factory)
};
(factory, rank_builder)
(factory, Arc::new(RankBuilder::default()))
} else {
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()));
log::debug!("Creating matcher for algo {:?}", options.algorithm);
let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
.fuzzy_algorithm(options.algorithm)
@ -296,12 +263,9 @@ impl Matcher {
query: &str,
item_pool: &Arc<ItemPool>,
thread_pool: &Arc<ThreadPool>,
processed_items: Arc<Mutex<Option<ProcessedItems>>>,
processed_items: Arc<SpinLock<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);
@ -357,7 +321,6 @@ 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
@ -374,10 +337,7 @@ impl Matcher {
if let Some(match_result) = matcher_engine.match_item(item.as_ref()) {
chunk_matched += 1;
let mut rank = match_result.rank;
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);
let index = chunk_start + i + start;
rank.index = i32::try_from(index).unwrap_or(i32::MAX);
local_matches.push(MatchedItem::new(
Arc::clone(item),
@ -415,7 +375,11 @@ 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.
|acc: &mut Vec<MatchedItem>| acc.sort_unstable(),
move |acc: &mut Vec<MatchedItem>| {
if !no_sort {
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.
@ -424,15 +388,7 @@ impl Matcher {
return;
}
merge_worker_results(
worker_results,
no_sort,
&processed_items,
merge_strategy,
generation,
&current_generation,
&needs_render,
);
merge_worker_results(worker_results, no_sort, &processed_items, merge_strategy, &needs_render);
},
);
stopped.store(true, Ordering::Relaxed);
@ -500,135 +456,26 @@ 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 = Mutex::new(None);
let processed = SpinLock::new(None);
let needs_render = AtomicBool::new(false);
let workers = vec![vec![matched("b", 1)], vec![matched("a", 0)]];
merge_test_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
merge_worker_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
assert!(needs_render.load(Ordering::Relaxed));
let guard = processed.lock().unwrap();
let guard = processed.lock();
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 = Mutex::new(None);
let processed = SpinLock::new(None);
let needs_render = AtomicBool::new(false);
// First append establishes the existing list.
merge_test_results(
merge_worker_results(
vec![vec![matched("a", 0)]],
true,
&processed,
@ -636,7 +483,7 @@ mod tests {
&needs_render,
);
// Second append with no_sort extends the existing list in place.
merge_test_results(
merge_worker_results(
vec![vec![matched("b", 1)]],
true,
&processed,
@ -644,49 +491,7 @@ mod tests {
&needs_render,
);
let guard = processed.lock().unwrap();
let guard = processed.lock();
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,24 +16,13 @@ use crate::binds::KeyMap;
use crate::item::RankCriteria;
use crate::prelude::SkimItemReader;
use crate::reader::CommandCollector;
use crate::tui::actions::Action;
use crate::tui::event::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> {
@ -207,37 +196,10 @@ pub struct SkimOptions {
/// See **nth** for the details
#[cfg_attr(
feature = "cli",
arg(
long,
default_value = "",
help_heading = "Search",
value_delimiter = ',',
allow_hyphen_values = true,
)
arg(long, default_value = "", help_heading = "Search", value_delimiter = ',')
)]
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.
@ -336,16 +298,13 @@ pub struct SkimOptions {
scheme: Option<MatchScheme>,
// --- Interface ---
/// Comma-separated key, event, and action bindings
/// Comma separated list of bindings
///
/// `--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.
/// 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
///
/// **Example**: `sk --bind=ctrl-j:accept,load:last,act-up:down`
/// **Example**: `sk --bind=ctrl-j:accept,ctrl-k:kill-line`
///
/// ## Multiple actions can be chained using + separator.
///
@ -513,21 +472,13 @@ pub struct SkimOptions {
#[cfg_attr(feature = "cli", arg(long, help_heading = "Layout"))]
pub no_height: bool,
/// Minimum height of skim's window as a non-negative row count
/// Minimum height of skim's window
///
/// 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.
/// 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",
allow_hyphen_values = true,
value_parser = parse_min_height_value,
verbatim_doc_comment
)
arg(long, default_value = "10", help_heading = "Layout", verbatim_doc_comment)
)]
pub min_height: String,
@ -610,8 +561,6 @@ 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(
@ -651,10 +600,6 @@ 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,
@ -727,9 +672,9 @@ pub struct SkimOptions {
// --- Preview ---
/// Preview command
///
/// 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).
/// 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).
///
/// **Examples**:
///
@ -1140,14 +1085,6 @@ 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 {
@ -1172,7 +1109,6 @@ 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(),
@ -1249,7 +1185,6 @@ 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(),
@ -1303,7 +1238,6 @@ 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")]
@ -1345,18 +1279,10 @@ impl SkimOptions {
}
self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| {
res.add_keymaps_str(part);
res.add_keymaps(part.split(','));
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,7 +2,6 @@
//! 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;
@ -22,50 +21,6 @@ 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.
@ -332,38 +287,3 @@ 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::actions::Action;
use crate::tui::event::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::actions::Action;
use crate::tui::event::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::actions::Action;
pub use crate::tui::event::Action;
pub use crate::tui::{Event, PreviewCallback};
pub use crate::*;
pub use kanal::{Receiver, Sender, bounded, unbounded};

View file

@ -10,9 +10,7 @@ use crate::{SkimItem, SkimItemReceiver};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread::JoinHandle;
use std::time::Duration;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
/// Trait for collecting items from command output
pub trait CommandCollector {
@ -41,7 +39,6 @@ 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>>>>,
}
@ -55,14 +52,7 @@ impl ReaderControl {
let _ = self.tx_interrupt_cmd.clone().map(|tx| tx.send(1));
let _ = self.tx_interrupt.send(1);
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));
}
while self.components_to_stop.load(Ordering::SeqCst) != 0 {}
}
/// Takes all items collected so far
@ -133,14 +123,12 @@ impl Reader {
);
let components_to_stop_clone = components_to_stop.clone();
let (tx_interrupt, collector_handle) =
collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
let tx_interrupt = 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,
}
}
@ -161,7 +149,7 @@ impl Reader {
);
let components_to_stop_clone = components_to_stop.clone();
let (tx_interrupt, collector_handle) = collect_items(components_to_stop_clone, rx_item, move |items| {
let tx_interrupt = collect_items(components_to_stop_clone, rx_item, move |items| {
item_pool.append(items);
});
debug!("collect: started ({components_to_stop:?} components)");
@ -170,7 +158,6 @@ impl Reader {
tx_interrupt,
tx_interrupt_cmd,
components_to_stop,
collector_handle: Some(collector_handle),
items,
}
}
@ -185,19 +172,18 @@ impl Default for Reader {
}
}
fn collect_items<F>(
components_to_stop: Arc<AtomicUsize>,
rx_item: SkimItemReceiver,
callback: F,
) -> (Sender<i32>, JoinHandle<()>)
fn collect_items<F>(components_to_stop: Arc<AtomicUsize>, rx_item: SkimItemReceiver, callback: F) -> Sender<i32>
where
F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
{
let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
components_to_stop.fetch_add(1, Ordering::AcqRel);
let handle = std::thread::spawn(move || {
let started = Arc::new(AtomicBool::new(false));
let started_clone = started.clone();
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() {
@ -219,11 +205,15 @@ where
}
}
components_to_stop.fetch_sub(1, Ordering::AcqRel);
components_to_stop.fetch_sub(1, Ordering::SeqCst);
debug!("collect_item stop");
});
(tx_interrupt, handle)
while !started.load(Ordering::SeqCst) {
// busy waiting for the thread to start. (components_to_stop is added)
}
tx_interrupt
}
#[cfg(test)]

View file

@ -3,19 +3,16 @@ 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::actions::Action;
#[cfg(feature = "image")]
use crate::tui::util::detect_image_picker;
use crate::tui::event::Action;
use crate::tui::{App, Event, Size, TICK_RATE, Tui};
use crate::{SkimItem, SkimItemReceiver, SkimOptions, SkimOutput};
@ -44,8 +41,6 @@ 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 {
@ -140,8 +135,6 @@ 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(())
}
@ -195,7 +188,6 @@ where
listener: None,
final_event: Event::Quit,
final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
start_fired: false,
})
}
@ -204,28 +196,6 @@ 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.
@ -248,10 +218,6 @@ 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.
@ -268,11 +234,6 @@ 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.
@ -404,7 +365,7 @@ where
if !tui.is_fullscreen {
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;
}
let picker = detect_image_picker().unwrap_or_else(|err| {
let picker = Picker::from_query_stdio().unwrap_or_else(|err| {
warn!("failed to query terminal image protocol: {err:?}");
Picker::halfblocks()
});
@ -424,9 +385,6 @@ 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(())
}
@ -446,18 +404,6 @@ 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 {
@ -470,7 +416,6 @@ where
.item_list
.processed_items
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
.unwrap_or_default()
.items
@ -522,14 +467,7 @@ 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()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
.unwrap_or_default()
.items;
app.item_list.items = app.item_list.processed_items.lock().take().unwrap_or_default().items;
debug!("early exit, result: {:?}", app.results());
return false;
}
@ -642,11 +580,6 @@ 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! {
@ -667,10 +600,6 @@ 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,17 +74,6 @@ 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,42 +66,6 @@ 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();
@ -144,20 +108,6 @@ 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 guard after its lock has been acquired.
fn new(lock: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
Self { __lock: lock }
/// Creates a new guard for the given lock
pub fn new(pool: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
Self { __lock: pool }
}
}

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 results to `merge`.
/// pool, then hands the per-worker 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,10 +285,8 @@ 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. When `preserve_chunk_order` is
/// set, reduction and preparation are skipped and each chunk result is stored
/// directly in its chunk-indexed slot.
/// result into its slot. The coordinator collects every worker's result and
/// passes them all to `merge` in a single call.
///
/// 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
@ -300,7 +298,6 @@ 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`).
@ -312,7 +309,6 @@ 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,
@ -340,10 +336,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));
// 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());
// 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());
// Barrier: we wait until all workers have finished.
let remaining = Arc::new(AtomicCounter::new(num_workers));
@ -372,25 +368,9 @@ 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 = 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 local_acc = {
let mut local_acc = w_identity();
loop {
let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
if chunk_idx >= num_chunks {
@ -407,14 +387,16 @@ 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);
Some(local_acc)
// w_items, w_next_chunk, w_process_chunk, w_reduce,
// w_prepare, w_identity are dropped when this block ends.
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) };
}
// 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) };
// Drop the slots Arc *before* signalling completion.
// The coordinator calls Arc::into_inner(slots) after wait_for_zero
@ -436,7 +418,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 worker or chunk results in slot order and hand them to `merge`.
// Collect per-worker results and hand them to `merge` in one call.
// 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,7 +72,6 @@ fn parallel_work_queue_sums() {
4,
&items,
64,
false,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,
@ -86,29 +85,6 @@ 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);
@ -119,7 +95,6 @@ fn parallel_work_queue_empty() {
2,
&items,
64,
false,
Vec::<u64>::new,
|_start, chunk| chunk.to_vec(),
|acc, mut partial| acc.append(&mut partial),
@ -143,7 +118,6 @@ fn parallel_work_queue_single_thread() {
1,
&items,
10,
false,
|| 0i32,
|_start, chunk| chunk.iter().sum::<i32>(),
|acc, partial| *acc += partial,
@ -182,7 +156,6 @@ fn parallel_work_queue_many_workers_few_chunks() {
8,
&items,
5,
false,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,
@ -213,7 +186,6 @@ fn parallel_work_queue_single_thread_pool_no_deadlock() {
1,
&items,
10,
false,
|| 0u64,
|_start, chunk| chunk.iter().sum::<u64>(),
|acc, partial| *acc += partial,

View file

@ -1,474 +0,0 @@
//! 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;

View file

@ -1,342 +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",
"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,14 +18,13 @@ use crate::{ItemPreview, PreviewContext, Rank, SkimItem, SkimOptions, util};
#[path = "app_tests.rs"]
mod tests;
use super::actions::Action;
use super::event::Action;
use super::header::Header;
use super::item_list::ItemList;
use super::{Event, Tui, input, preview};
use crate::binds::SkimEvent;
use crate::thread_pool::{self, ThreadPool};
use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use eyre::{Result, bail};
use color_eyre::eyre::{Result, bail};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use input::Input;
use preview::Preview;
use ratatui::buffer::Buffer;
@ -43,7 +42,6 @@ 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)]
@ -56,8 +54,6 @@ 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),
@ -131,21 +127,6 @@ 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 {
@ -236,7 +217,6 @@ 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())
@ -270,13 +250,6 @@ 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,
}
}
}
@ -310,7 +283,6 @@ 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(),
@ -344,13 +316,6 @@ 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,
}
}
@ -364,7 +329,9 @@ 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 '+'
@ -408,65 +375,9 @@ impl App {
self.items_just_updated = true;
}
/// 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 after selection changes (e.g., selection actions, `Event::Key`)
fn on_selection_changed() -> Vec<Event> {
vec![Event::RunPreview]
}
/// Call when query changes (e.g., `AddChar`, `BackwardDeleteChar`, etc.)
@ -478,7 +389,7 @@ impl App {
}
self.restart_matcher_debounced();
vec![
Event::Key(crate::binds::SkimEvent::Change.into()), // fire the `change` event binding
Event::Key(KeyEvent::new(KeyCode::F(255), KeyModifiers::NONE)), // Send F255 which is the change bind
Event::RunPreview,
]
}
@ -582,7 +493,7 @@ impl App {
u16::try_from(u32::from(self.preview.rows) * u32::from(p) / 100).unwrap_or(u16::MAX)
}
};
self.preview.scroll_y = usize::from(v_scroll);
self.preview.scroll_y = v_scroll;
self.preview.scroll_down(v_offset);
let h_scroll = match preview_position.h_scroll {
@ -599,7 +510,7 @@ impl App {
u16::try_from(u32::from(self.preview.cols) * u32::from(p) / 100).unwrap_or(u16::MAX)
}
};
self.preview.scroll_x = usize::from(h_scroll).saturating_add(usize::from(h_offset));
self.preview.scroll_x = h_scroll.saturating_add(h_offset);
}
ItemPreview::TextWithPos(t, preview_position) | ItemPreview::AnsiWithPos(t, preview_position) => self
.preview
@ -633,7 +544,6 @@ 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
@ -642,11 +552,6 @@ 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
@ -667,19 +572,6 @@ 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)
@ -692,26 +584,9 @@ impl App {
warn!("RunPreview: error {e:?}");
}
}
Event::RunExecute(cmd) => {
tui.run_execute(cmd)?;
self.handle_event(tui, &Event::Redraw)?;
}
Event::Clear => {
Event::Clear | Event::Redraw => {
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;
@ -755,16 +630,6 @@ 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}");
@ -801,10 +666,9 @@ impl App {
self.on_items_updated();
}
fn handle_key(&mut self, key: &KeyEvent) -> Vec<Event> {
let normalized_key = KeyEvent::new(key.code, key.modifiers);
debug!("key event: {key:?}, normalized: {normalized_key:?}");
debug!("key event: {key:?}");
if let Some(act) = &self.options.keymap.get(&normalized_key) {
if let Some(act) = &self.options.keymap.get(key) {
debug!("{act:?}");
return act.iter().map(|a| Event::Action(a.clone())).collect();
}
@ -829,61 +693,23 @@ impl App {
vec![]
}
/// Runs an action, then directly dispatches any follow-up actions bound to it.
///
/// Follow-ups use non-recursive (`noremap`) semantics: an action in the
/// follow-up chain does not trigger its own follow-up binding. If the chain
/// contains [`Action::Suppress`], the triggering action is skipped.
fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
let follow = self.options.action_binds.get(act.name()).cloned();
let suppress_default = follow
.as_ref()
.is_some_and(|chain| chain.iter().any(|a| matches!(a, Action::Suppress)));
let mut events = if suppress_default {
Vec::new()
} else {
self.dispatch_action(act)?
};
if let Some(chain) = follow {
for action in chain.iter().filter(|a| !matches!(a, Action::Suppress)) {
events.extend(self.dispatch_action(action)?);
}
}
Ok(events)
}
fn dispatch_conditional(&mut self, condition: bool, then: &str, otherwise: Option<&str>) -> Result<Vec<Event>> {
let Some(chain) = condition.then_some(then).or(otherwise) else {
return Ok(Vec::new());
};
// `if-*` branch chains are stored unparsed (see `parse_action`), so an
// invalid action name only surfaces here, at dispatch time. Log and
// skip the chain instead of erroring out of the event loop, matching
// the invalid-chain handling of `parse_action_binds`.
let actions = match crate::binds::parse_action_chain(chain) {
Ok(actions) => actions,
Err(err) => {
warn!("Ignoring conditional action chain `{chain}`: {err}");
return Ok(Vec::new());
}
};
let mut events = Vec::new();
for action in actions {
events.extend(self.dispatch_action(&action)?);
}
Ok(events)
}
#[allow(clippy::too_many_lines)]
fn dispatch_action(&mut self, act: &Action) -> Result<Vec<Event>> {
#[allow(clippy::enum_glob_use)]
use Action::*;
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,
};
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);
@ -905,7 +731,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);
@ -936,15 +762,6 @@ 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();
@ -968,7 +785,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) => {
@ -976,19 +793,41 @@ 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) => {
// 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`.
use std::io::IsTerminal as _;
let expanded_cmd = self.expand_cmd(cmd, true);
debug!("execute: {expanded_cmd}");
return Ok(vec![Event::RunExecute(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]);
}
ExecuteSilent(cmd) => {
let expanded_cmd = self.expand_cmd(cmd, true);
@ -1000,7 +839,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);
@ -1009,18 +848,39 @@ impl App {
self.input.move_cursor_forward_word();
}
IfQueryEmpty(then, otherwise) => {
return self.dispatch_conditional(self.input.is_empty(), then, otherwise.as_deref());
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());
}
}
IfQueryNotEmpty(then, otherwise) => {
return self.dispatch_conditional(!self.input.is_empty(), then, otherwise.as_deref());
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());
}
}
IfNonMatched(then, otherwise) => {
return self.dispatch_conditional(self.item_list.items.is_empty(), then, otherwise.as_deref());
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());
}
}
// `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 => (),
Ignore => (),
KillLine => {
let cursor = self.input.cursor_pos as usize;
let deleted = self.input.split_off(cursor);
@ -1035,7 +895,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
@ -1082,7 +942,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;
@ -1091,7 +951,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);
@ -1100,7 +960,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);
@ -1109,7 +969,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));
@ -1221,25 +1081,15 @@ impl App {
}
SelectAll => {
self.item_list.select_all();
return Ok(self.on_selection_changed());
return Ok(Self::on_selection_changed());
}
SelectRow(row) => {
self.item_list.select_row(*row);
return Ok(self.on_selection_changed());
return Ok(Self::on_selection_changed());
}
Select => {
self.item_list.select();
return Ok(self.on_selection_changed());
}
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)]);
return Ok(Self::on_selection_changed());
}
SetHeader(opt_header) => {
opt_header.clone_into(&mut self.options.header);
@ -1259,11 +1109,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();
@ -1271,7 +1121,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;
@ -1284,7 +1134,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;
@ -1299,22 +1149,6 @@ 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());
@ -1330,7 +1164,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
@ -1338,7 +1172,7 @@ impl App {
return Ok(self.on_query_changed());
}
Custom(cb) => {
return cb.call(self).map_err(|e| eyre::eyre!("{}", e));
return cb.call(self).map_err(|e| color_eyre::eyre::eyre!("{}", e));
}
}
Ok(Vec::default())
@ -1348,7 +1182,7 @@ impl App {
pub fn results(&mut self) -> Vec<MatchedItem> {
if self.options.filter.is_some() {
// In filter mode, drain items to avoid cloning
std::mem::take(&mut self.item_list.items)
self.item_list.items.drain(..).collect()
} 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() {
@ -1358,18 +1192,6 @@ 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.
@ -1378,19 +1200,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 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;
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;
}
}
let matcher_stopped = self.matcher_control.stopped();
@ -1419,16 +1241,8 @@ 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 {
@ -1442,14 +1256,8 @@ 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;
}
}
@ -1538,7 +1346,6 @@ 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 => {
@ -1564,10 +1371,6 @@ 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)
@ -1604,15 +1407,10 @@ impl App {
self.needs_render();
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()));
if self.item_list.current != old_current {
return Ok(Self::on_selection_changed());
}
Ok(events)
Ok(vec![])
}
fn toggle_spinner(&mut self) {
self.show_spinner = !self.show_spinner;

View file

@ -14,8 +14,7 @@ use std::sync::Arc;
use super::*;
use crate::item::{MatchedItem, RankBuilder};
use crate::tui::actions::Action;
use crate::tui::event::Event;
use crate::tui::event::{Action, Event};
use crate::tui::layout::LayoutTemplate;
use crate::tui::statusline::InfoDisplay;
use crate::{Rank, SkimItem};
@ -58,59 +57,12 @@ 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 `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()))
);
// on_query_changed emits a F255 change-key event and a RunPreview
assert!(events.iter().any(|e| matches!(e, Event::RunPreview)));
}
@ -395,59 +347,6 @@ 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"]);
@ -551,89 +450,32 @@ fn toggle_in_out_in_bottom_to_top_layout() {
#[test]
fn if_query_empty_branches() {
let mut app = App::default();
assert!(act(&mut app, Action::IfQueryEmpty("abort".to_string(), None)).is_empty());
assert!(app.should_quit);
// 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))));
let mut app = App::default();
// Query non-empty -> "otherwise" branch.
app.input.value = "x".to_string();
assert!(
act(
&mut app,
Action::IfQueryEmpty("ignore".to_string(), Some("abort".to_string())),
)
.is_empty()
let events = act(
&mut app,
Action::IfQueryEmpty("ignore".to_string(), Some("abort".to_string())),
);
assert!(app.should_quit);
assert!(events.iter().any(|e| matches!(e, Event::Action(Action::Abort))));
}
#[test]
fn if_query_not_empty_branches() {
let mut app = App::default();
app.input.value = "x".to_string();
assert!(act(&mut app, Action::IfQueryNotEmpty("abort".to_string(), None)).is_empty());
assert!(app.should_quit);
let events = act(&mut app, Action::IfQueryNotEmpty("abort".to_string(), None));
assert!(events.iter().any(|e| matches!(e, Event::Action(Action::Abort))));
let mut app = App::default();
assert!(
act(
&mut app,
Action::IfQueryNotEmpty("ignore".to_string(), Some("abort".to_string())),
)
.is_empty()
let events = act(
&mut app,
Action::IfQueryNotEmpty("abort".to_string(), Some("ignore".to_string())),
);
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(_))));
assert!(events.iter().all(|e| matches!(e, Event::Action(Action::Ignore))));
}
#[test]
@ -664,64 +506,30 @@ fn next_history_at_most_recent_is_noop() {
}
#[test]
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();
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");
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" };
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");
let mut app = App::default();
let events = act(&mut app, Action::Execute(noop.to_string()));
assert!(events.iter().any(|e| matches!(e, Event::Redraw)));
}
#[test]
fn if_non_matched_branches() {
// Empty item list -> "then".
let mut app = App::default();
assert!(act(&mut app, Action::IfNonMatched("abort".to_string(), None)).is_empty());
assert!(app.should_quit);
let events = act(&mut app, Action::IfNonMatched("abort".to_string(), None));
assert!(events.iter().any(|e| matches!(e, Event::Action(Action::Abort))));
// Non-empty list -> "otherwise".
let mut app = app_with_items(&["a"]);
assert!(
act(
&mut app,
Action::IfNonMatched("ignore".to_string(), Some("abort".to_string())),
)
.is_empty()
let events = act(
&mut app,
Action::IfNonMatched("abort".to_string(), Some("ignore".to_string())),
);
assert!(app.should_quit);
assert!(events.iter().all(|e| matches!(e, Event::Action(Action::Ignore))));
}
#[test]
@ -808,7 +616,7 @@ fn deselect_all_clears_existing_selection() {
#[test]
fn custom_action_runs_callback() {
use crate::tui::actions::ActionCallback;
use crate::tui::event::ActionCallback;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
@ -822,7 +630,7 @@ fn custom_action_runs_callback() {
#[test]
fn custom_action_runs_async_callback() {
use crate::tui::actions::ActionCallback;
use crate::tui::event::ActionCallback;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
@ -835,76 +643,6 @@ 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();
@ -974,23 +712,6 @@ 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();
@ -1041,13 +762,6 @@ 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"]);
@ -1393,33 +1107,6 @@ 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(..)).
// ---------------------------------------------------------------------------
@ -1536,39 +1223,6 @@ 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,15 +1,13 @@
use std::io::{BufWriter, stderr};
use std::io::BufWriter;
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};
@ -42,8 +40,6 @@ 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,
}
@ -54,7 +50,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(stderr()));
let backend = CrosstermBackend::new(std::io::BufWriter::new(std::io::stderr()));
Self::new_with_height_and_backend(backend, height)
}
/// Disable mouse handling.
@ -89,7 +85,6 @@ 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()?;
@ -97,18 +92,16 @@ 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!(stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
crossterm::execute!(std::io::stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
y = y.saturating_sub(to_scroll);
}
rect = Some(Rect::new(
Viewport::Fixed(Rect::new(
0,
y,
backend.size().expect("Failed to get terminal width").width - 1,
height,
));
Viewport::Fixed(rect.unwrap())
))
} else {
rect = None;
Viewport::Fullscreen
};
@ -116,7 +109,6 @@ 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),
@ -152,7 +144,13 @@ where
#[cfg(windows)]
super::windows::install_ctrl_c_handler()?;
self.execute_enter()?;
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)?;
}
Ok(())
}
@ -173,7 +171,7 @@ where
let area = self.get_frame().area();
let orig = ratatui::layout::Position { x: area.x, y: area.y };
crossterm::execute!(
stderr(),
std::io::stderr(),
cursor::MoveTo(orig.x, orig.y),
Clear(ClearType::FromCursorDown)
)?;
@ -186,47 +184,6 @@ 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();
@ -235,17 +192,10 @@ 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();
// Cancel any previously running reader before spawning a new one.
let cancellation_token_clone = self.cancellation_token.clone();
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);
@ -291,142 +241,6 @@ 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>
@ -480,11 +294,8 @@ 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!(
stderr(),
std::io::stderr(),
DisableMouseCapture,
DisableBracketedPaste,
LeaveAlternateScreen,
@ -494,22 +305,6 @@ 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 {
@ -522,14 +317,6 @@ 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();
@ -561,50 +348,4 @@ 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,8 +1,111 @@
use std::sync::Arc;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use crate::exhaustive_match;
use crossterm::event::{KeyEvent, MouseEvent};
use derive_more::{Debug, Eq, PartialEq};
pub use super::actions::{Action, ActionCallback, parse_action};
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))
}
}
/// Events that can occur during skim's execution
#[derive(Clone, Debug)]
@ -39,13 +142,6 @@ 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
@ -53,3 +149,287 @@ 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;

190
src/tui/event_tests.rs Normal file
View file

@ -0,0 +1,190 @@
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,9 +165,12 @@ impl Input {
}
pub fn insert_str(&mut self, s: &str) {
self.value.insert_str(self.cursor_pos as usize, s);
// `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"));
self.move_cursor(
s.chars()
.count()
.try_into()
.expect("Failed to fit inserted str len into an i32"),
);
}
fn nchars(&self) -> usize {
self.value.chars().count()
@ -500,40 +503,30 @@ impl SkimWidget for Input {
.render(area, buf);
}
}
InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right => {
InfoDisplay::Default => {
// 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 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)
};
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),
);
} else {
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)
};
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),
);
}
}

View file

@ -236,36 +236,3 @@ 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,6 +1,5 @@
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use indexmap::IndexSet;
use ratatui::widgets::{
@ -11,6 +10,7 @@ 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,15 +28,12 @@ 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 {
@ -44,7 +41,6 @@ impl Default for ProcessedItems {
Self {
items: Vec::new(),
merge: MergeStrategy::Replace,
generation: 0,
}
}
}
@ -54,8 +50,7 @@ impl Default for ProcessedItems {
pub struct ItemList {
pub(crate) items: Vec<MatchedItem>,
pub(crate) selection: IndexSet<MatchedItem>,
pub(crate) processed_items: Arc<Mutex<Option<ProcessedItems>>>,
pub(crate) matcher_generation: Arc<AtomicUsize>,
pub(crate) processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
pub(crate) direction: ListDirection,
pub(crate) offset: usize,
/// How many leading sub-lines of items[offset] have been scrolled off the top.
@ -130,27 +125,6 @@ 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() {
@ -423,8 +397,7 @@ impl SkimWidget for ItemList {
(None, 0)
};
let processed_items = Arc::new(Mutex::new(None));
let matcher_generation = Arc::new(AtomicUsize::new(0));
let processed_items = Arc::new(SpinLock::new(None));
let interactive = options.interactive;
let no_clear_if_empty = options.no_clear_if_empty;
@ -433,7 +406,6 @@ 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,
@ -513,17 +485,8 @@ impl SkimWidget for ItemList {
}
let initial_current = this.selected();
// 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 {
// Check for pre-processed items from background thread (non-blocking)
let items_updated = if let Some(processed) = this.processed_items.lock().take() {
debug!("Render: Got {} processed items", processed.items.len());
// Check if items are empty or blank for no_clear_if_empty handling
@ -550,9 +513,6 @@ 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,11 +240,7 @@ fn render_list(il: &mut ItemList, w: u16, h: u16) {
}
fn set_processed(il: &ItemList, items: Vec<MatchedItem>, merge: MergeStrategy) {
*il.processed_items.lock().unwrap() = Some(ProcessedItems {
items,
merge,
generation: il.matcher_generation.load(std::sync::atomic::Ordering::Acquire),
});
*il.processed_items.lock() = Some(ProcessedItems { items, merge });
}
#[test]
@ -257,21 +253,6 @@ 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);
@ -292,43 +273,6 @@ 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,29 +92,8 @@ impl<'a> ItemRenderer<'a> {
out: &mut Vec<ListItem<'static>>,
) -> usize {
let item_text = item.item.text();
// 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 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());
let mut added = 0usize;
// Collect rows for this item into a temporary buffer so we can reverse

View file

@ -56,8 +56,6 @@ 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
@ -77,17 +75,12 @@ 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(matches!(
options.info.display,
InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right
))
1 + u16::from(options.info.display == InfoDisplay::Default)
};
// Rows consumed by the header widget.
@ -127,17 +120,18 @@ 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.saturating_sub(overlap)),
Constraint::Length(input_rows.saturating_sub(overlap)),
Constraint::Length(header_rows),
Constraint::Length(input_rows),
]),
TuiLayout::Reverse => Layout::vertical([
Constraint::Length(input_rows),
Constraint::Length(header_rows.saturating_sub(overlap)),
Constraint::Length(header_rows),
Constraint::Fill(1),
]),
}
@ -146,10 +140,10 @@ impl LayoutTemplate {
TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
Constraint::Fill(1),
Constraint::Length(0),
Constraint::Length(input_rows.saturating_sub(overlap)),
Constraint::Length(non_list_rows),
]),
TuiLayout::Reverse => Layout::vertical([
Constraint::Length(input_rows),
Constraint::Length(non_list_rows),
Constraint::Length(0),
Constraint::Fill(1),
]),
@ -161,7 +155,6 @@ impl LayoutTemplate {
preview_placement,
work_layout_reversed,
preview_layout,
collapse_borders,
work_layout,
}
}
@ -173,14 +166,7 @@ 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, 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!(),
};
}
let [a, b]: [Rect; 2] = layout.areas(area);
match self.preview_placement {
// preview is the first segment for Left / Up
PreviewPlacement::Left | PreviewPlacement::Up => (b, Some(a)),
@ -197,26 +183,12 @@ impl LayoutTemplate {
// or [input, header, list] when true (Reverse layout).
let [slot0, slot1, slot2]: [Rect; 3] = self.work_layout.areas(work_area);
let (mut list_area, mut header_slot, mut input_area) = if self.work_layout_reversed {
let (list_area, header_slot, 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 {
@ -266,22 +238,6 @@ 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,14 +27,6 @@ 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 {
@ -375,10 +367,9 @@ 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), sharing one border row with the list.
// input = 3 rows (1 content + 2 border)
assert_eq!(layout.input_area.height, 3);
assert_eq!(layout.list_area.height, 22);
assert_vertical_border_overlap(layout.list_area, layout.input_area, "list→input");
assert_eq!(layout.list_area.height, 21);
assert!(layout.header_area.is_none());
}
@ -391,13 +382,11 @@ fn default_with_borders_and_header() {
.unwrap();
let layout = compute_with_header_height(&options, 2);
// input = 3, header = 2+2 = 4; both separators are shared.
// input = 3, header = 2+2 = 4
assert_eq!(layout.input_area.height, 3);
let h = layout.header_area.unwrap();
assert_eq!(h.height, 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");
assert_eq!(layout.list_area.height, 24 - 3 - 4);
}
#[test]
@ -412,25 +401,8 @@ 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, 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");
assert_eq!(layout.list_area.y, 3);
assert_eq!(layout.list_area.height, 21);
}
// ── Coverage / edge cases ──────────────────────────────────────────────
@ -468,7 +440,7 @@ fn all_areas_non_overlapping_default() {
}
#[test]
fn collapsed_borders_overlap_in_reverse_layout() {
fn all_areas_non_overlapping_reverse() {
let options = opts()
.layout(TuiLayout::Reverse)
.inline_info(true)
@ -483,15 +455,14 @@ fn collapsed_borders_overlap_in_reverse_layout() {
let preview = layout.preview_area.unwrap();
let header = layout.header_area.unwrap();
// Preview and work widgets share their touching border column.
// Horizontally disjoint: preview on left, everything else on right.
assert_eq!(preview.x, 0);
assert_eq!(preview.width, 25);
assert_eq!(layout.list_area.x, 24);
assert_horizontal_border_overlap(preview, layout.list_area, "preview→list");
assert_eq!(layout.list_area.x, 25);
// 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");
// 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");
}
#[test]

View file

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

View file

@ -1,5 +1,5 @@
use ansi_to_tui::IntoText;
use eyre::{Result, eyre};
use color_eyre::eyre::{Result, eyre};
use portable_pty::{PtyPair, PtySize, native_pty_system};
use ratatui::layout::Alignment;
use ratatui::prelude::Backend;
@ -15,11 +15,9 @@ use tui_term::widget::PseudoTerminal;
use std::env;
use std::io::Read;
use std::process::{Child, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock, mpsc};
use std::sync::{Arc, RwLock, mpsc};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use std::time::Instant;
use super::statusline::spinner_char;
use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query};
@ -33,79 +31,6 @@ 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 {
@ -156,13 +81,11 @@ pub struct Preview {
pub cmd: String,
pub rows: u16,
pub cols: u16,
pub scroll_y: usize,
pub scroll_x: usize,
pub scroll_y: u16,
pub scroll_x: u16,
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,
@ -174,7 +97,7 @@ pub struct Preview {
image: bool,
#[cfg(feature = "image")]
image_picker: Option<Picker>,
pub total_lines: usize,
pub total_lines: u16,
loading: bool,
spinner_start: Instant,
}
@ -227,12 +150,18 @@ 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) -> usize {
let dimension = if is_vertical { self.rows } else { self.cols };
fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> u16 {
match size {
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)),
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)
}
}
}
@ -295,9 +224,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(eyre::eyre!("Failed to acquire content for writing"));
return Err(color_eyre::eyre::eyre!("Failed to acquire content for writing"));
};
self.total_lines = text.lines.len();
self.total_lines = text.lines.len().try_into().unwrap();
*content = PreviewContent::Text(text);
self.scroll_y = 0;
self.scroll_x = 0;
@ -327,7 +256,7 @@ impl Preview {
}
pub fn scroll_up(&mut self, lines: u16) {
self.scroll_y = self.scroll_y.saturating_sub(usize::from(lines));
self.scroll_y = self.scroll_y.saturating_sub(lines);
}
pub fn scroll_down(&mut self, lines: u16) {
@ -336,26 +265,26 @@ impl Preview {
self.total_lines, self.rows
);
if self.total_lines > 0 {
self.scroll_y = self.scroll_y.saturating_add(usize::from(lines)).min(
self.total_lines
.saturating_sub(usize::from(self.rows.saturating_sub(1))),
);
self.scroll_y = self
.scroll_y
.saturating_add(lines)
.min(self.total_lines.saturating_sub(self.rows.saturating_sub(1)));
} else {
// We might not have the actual total_lines value
self.scroll_y = self.scroll_y.saturating_add(usize::from(lines));
self.scroll_y = self.scroll_y.saturating_add(lines);
}
}
pub fn scroll_left(&mut self, cols: u16) {
self.scroll_x = self.scroll_x.saturating_sub(usize::from(cols));
self.scroll_x = self.scroll_x.saturating_sub(cols);
}
pub fn scroll_right(&mut self, cols: u16) {
self.scroll_x = self.scroll_x.saturating_add(usize::from(cols));
self.scroll_x = self.scroll_x.saturating_add(cols);
}
pub fn set_offset(&mut self, offset: u16) {
self.scroll_y = usize::from(offset.saturating_sub(1)); // -1 because line numbers are 1-indexed
self.scroll_y = offset.saturating_sub(1); // -1 because line numbers are 1-indexed
}
pub fn page_up(&mut self) {
@ -369,19 +298,10 @@ 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() {
@ -570,102 +490,43 @@ impl Preview {
shell_cmd
.env("ROWS", self.rows.to_string())
.env("COLUMNS", self.cols.to_string())
.env("PAGER", "")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
.env("PAGER", "");
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 || {
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 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();
}
let Some(status) = status else {
if interrupt_rx.try_recv().is_ok() {
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 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());
}
}
trace!("sending ready ping");
let _ = event_tx_clone.blocking_send(Event::PreviewReady);
}));
}
Ok(())
@ -677,14 +538,12 @@ impl Preview {
area: ratatui::layout::Rect,
buf: &mut ratatui::prelude::Buffer,
text: &Text,
) -> usize {
) -> u16 {
// Calculate total lines in content
let total_lines = text.lines.len();
let total_lines: u16 = text.lines.len().try_into().unwrap();
// 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));
// Create paragraph with optional block
let mut paragraph = Paragraph::new(text.clone()).scroll((self.scroll_y, self.scroll_x));
// Enable wrapping if wrap is true
if self.wrap {
@ -693,7 +552,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.saturating_add(1); // Display line numbers are 1-indexed.
let current_line = (self.scroll_y + 1) as usize; // +1 because scroll_y is 0-indexed but we want 1-indexed display
let title = format!("{current_line}/{total_lines}");
outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
@ -710,21 +569,23 @@ impl Preview {
area: ratatui::layout::Rect,
buf: &mut ratatui::prelude::Buffer,
parser: &std::sync::RwLock<tui_term::vt100::Parser>,
) -> usize {
let mut total_lines = 0usize;
) -> u16 {
let mut total_lines = 0u16;
// 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();
total_lines = (scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count())
.try_into()
.unwrap();
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));
.set_scrollback(scrollback_len.saturating_sub(self.scroll_y.into()));
}
}
@ -813,8 +674,6 @@ 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_MAX_BYTES, Preview, PreviewContent, read_bounded, update_plain_content};
use super::Preview;
#[cfg(feature = "image")]
fn image(width: u32, height: u32) -> DynamicImage {
@ -60,130 +60,6 @@ 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,10 +17,6 @@ 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
@ -65,18 +61,16 @@ impl From<InfoDisplay> for Info {
impl From<&str> for Info {
fn from(s: &str) -> Self {
use InfoDisplay::{Default, Hidden, Inline, InlineRight, Left, Right};
use InfoDisplay::{Default, Hidden, Inline, InlineRight};
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`, `left`, `right`, `inline`, `inline-right` or `hidden`"
"Failed to parse {x} as an InfoDisplay. Possible options are `default`, `inline`, `inline-right` or `hidden`"
),
};
let separator = if display.is_inline() {
@ -104,8 +98,6 @@ 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());
}
@ -120,12 +112,6 @@ 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);
}
@ -133,8 +119,6 @@ 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,123 +251,6 @@ 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,30 +47,3 @@ 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,82 +53,6 @@ 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,97 +110,6 @@ 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(eyre::eyre!("Timeout waiting for reader to finish"));
return Err(color_eyre::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(eyre::eyre!("Timeout waiting for matcher to stop"));
return Err(color_eyre::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(eyre::eyre!("Timeout waiting for debounced preview to run"));
return Err(color_eyre::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(|| eyre::eyre!("Failed to capture stdout"))?,
.ok_or_else(|| color_eyre::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() -> eyre::Result<()> {
fn $name() -> color_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() -> eyre::Result<()> {
fn $name() -> color_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() -> eyre::Result<()> {
fn $name() -> color_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() -> eyre::Result<()> {
fn $name() -> color_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() -> eyre::Result<()> {
fn $name() -> color_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() -> eyre::Result<()> {
fn $name() -> color_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() -> eyre::Result<()> {
fn $name() -> color_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() -> eyre::Result<()> {
fn $name() -> color_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() -> eyre::Result<()> {
fn $name() -> color_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,9 +1,8 @@
#[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]
pub mod zellij;
#[cfg(unix)]
pub mod tmux;
/// Raw binary path. Use `Command::new(SK)` to spawn directly; apply
/// `SKIM_ENV_REMOVES` via `.env_remove()` on the command when needed.

712
tests/common/tmux.rs Normal file
View file

@ -0,0 +1,712 @@
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)+)
};
}

File diff suppressed because it is too large Load diff

View file

@ -1,127 +0,0 @@
#![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%"])
}

View file

@ -1,110 +0,0 @@
#![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,10 +33,6 @@ 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;
});
@ -45,10 +41,6 @@ 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;
});
@ -56,7 +48,3 @@ 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,21 +1,18 @@
// 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::zellij::Keys::*;
use common::tmux::Keys::*;
use rand::RngExt as _;
use rand::distr::Alphabetic;
use std::io::{Result, Write as _};
use std::process::{Child, Command, Stdio};
use common::zellij::ZellijController;
use common::tmux::TmuxController;
use crate::common::{SK, SKIM_ENV_REMOVES};
@ -33,8 +30,8 @@ fn send(child: &mut Child, msg: &str) -> Result<()> {
Ok(())
}
fn setup(name: &str, extra_args: &[&str]) -> Result<(ZellijController, Child)> {
let mut tmux = ZellijController::new_named(name)?;
fn setup(name: &str, extra_args: &[&str]) -> Result<(TmuxController, Child)> {
let mut tmux = TmuxController::new_named(name)?;
let socket_name = format!(
"sk-test-{name}{}",
rand::rng()
@ -423,36 +420,3 @@ 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