mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 15:26:18 -04:00
Compare commits
No commits in common. "master" and "v5.1.0" have entirely different histories.
5
.codegraph/.gitignore
vendored
5
.codegraph/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
75
.github/workflows/apt.yml
vendored
75
.github/workflows/apt.yml
vendored
|
|
@ -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
|
||||
80
.github/workflows/package.yml
vendored
80
.github/workflows/package.yml
vendored
|
|
@ -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
|
||||
13
.github/workflows/pr-auto-update.yml
vendored
13
.github/workflows/pr-auto-update.yml
vendored
|
|
@ -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
|
||||
10
.github/workflows/pr.yml
vendored
10
.github/workflows/pr.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
175
.github/workflows/release-pr.yml
vendored
175
.github/workflows/release-pr.yml
vendored
|
|
@ -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"
|
||||
72
.github/workflows/release.yml
vendored
72
.github/workflows/release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
169
.github/workflows/test.yml
vendored
169
.github/workflows/test.yml
vendored
|
|
@ -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)."
|
||||
|
|
|
|||
38
.github/workflows/winget.yml
vendored
38
.github/workflows/winget.yml
vendored
|
|
@ -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 }}
|
||||
32
AGENTS.md
32
AGENTS.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
322
ARCHITECTURE.md
322
ARCHITECTURE.md
|
|
@ -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
|
||||
|
|
|
|||
218
CHANGELOG.md
218
CHANGELOG.md
|
|
@ -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
1264
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
55
Cargo.toml
55
Cargo.toml
|
|
@ -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 },
|
||||
]
|
||||
|
|
|
|||
72
README.md
72
README.md
|
|
@ -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
BIN
bench.png
Binary file not shown.
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 152 KiB |
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@
|
|||
cargo-xwin
|
||||
gnuplot
|
||||
llvm
|
||||
cargo-bloat
|
||||
cargo-public-api
|
||||
];
|
||||
gungraun = with pkgs; [
|
||||
valgrind
|
||||
|
|
|
|||
153
fuzz/Cargo.lock
generated
153
fuzz/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
3
justfile
3
justfile
|
|
@ -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
|
||||
|
|
|
|||
327
man/man1/sk.1
327
man/man1/sk.1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]' \
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
5.7.0
|
||||
5.1.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
194
src/binds.rs
194
src/binds.rs
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
12
src/field.rs
12
src/field.rs
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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!('a'.eq('a', true));
|
||||
assert!('A'.eq('a', false));
|
||||
assert!('a'.is_lowercase());
|
||||
assert!(!'A'.is_lowercase());
|
||||
// Default (non-SIMD) find impls for char.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", "abc")
|
||||
.expect("fullwidth ASCII should match");
|
||||
assert_eq!(indices, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_quality() {
|
||||
let matcher = SkimMatcherV2::default().ignore_case();
|
||||
|
|
|
|||
|
|
@ -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', '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', 'a', true));
|
||||
assert!(char_equal('A', 'A', true));
|
||||
assert!(!char_equal('A', 'a', true));
|
||||
assert!(char_equal('A', 'a', false));
|
||||
assert!(char_equal('1', '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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
58
src/item.rs
58
src/item.rs
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
237
src/manpage.rs
237
src/manpage.rs
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
255
src/matcher.rs
255
src/matcher.rs
|
|
@ -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,
|
||||
¤t_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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
src/options.rs
104
src/options.rs
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
79
src/skim.rs
79
src/skim.rs
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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'));
|
||||
}
|
||||
434
src/tui/app.rs
434
src/tui/app.rs
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
398
src/tui/event.rs
398
src/tui/event.rs
|
|
@ -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
190
src/tui/event_tests.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
117
src/tui/util.rs
117
src/tui/util.rs
|
|
@ -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)> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
91
tests/cli.rs
91
tests/cli.rs
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
712
tests/common/tmux.rs
Normal 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
127
tests/execute.rs
127
tests/execute.rs
|
|
@ -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%"])
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue